/home/runner/work/lyquor/lyquor/lyquid-agent/src/dispatch/outcome.rs
Line | Count | Source |
1 | | //! Wire types for self-dispatch outcome callbacks. |
2 | | //! |
3 | | //! `DispatchOutcome` is an Agent SDK concept: an instance fn classifies one external |
4 | | //! action attempt, then certifies this payload back into a network callback. The SDK |
5 | | //! callback routing lives in `workflow_helpers::apply_dispatch_outcome`. |
6 | | //! |
7 | | //! ## Variant guide |
8 | | //! |
9 | | //! Five variants matching the five non-trivial `AgentEvent` transitions on the execution |
10 | | //! side. No new event types - `DispatchOutcome` is just the classification payload the |
11 | | //! instance fn produces; the SDK callback routes by variant. |
12 | | //! |
13 | | //! ## Wire shape |
14 | | //! |
15 | | //! `DispatchOutcome` crosses the certified-callback boundary on the in-Lyquid |
16 | | //! self-dispatch path: `Succeeded { receipt_output_json: String }` carries the |
17 | | //! audit-anchor payload as opaque JSON (the SDK doesn't unpack typed adapter output), |
18 | | //! and the four non-Succeeded variants carry only `reason: String`. The actual cert |
19 | | //! payload also embeds `op_id_hex` and `observed_retry_count` for the stale-callback |
20 | | //! fingerprint (see `apply_dispatch_outcome` in `workflow_helpers`). |
21 | | |
22 | | use serde::{Deserialize, Serialize}; |
23 | | |
24 | | /// SDK-enforced cap on automatic retries triggered by `DispatchOutcome::RetryablePrePayment`. |
25 | | /// Service code that legitimately needs different bounds should re-litigate in design |
26 | | /// review; until then, every self-dispatch service shares this cap. |
27 | | pub const MAX_PREPAYMENT_RETRY_ATTEMPTS: u32 = 3; |
28 | | |
29 | | /// Classification of a single self-dispatch attempt's outcome, produced by the instance |
30 | | /// function and routed by the SDK to the matching `AgentEvent` writer. |
31 | | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
32 | | #[serde(tag = "kind")] |
33 | | pub enum DispatchOutcome { |
34 | | /// Adapter ran to completion. `receipt_output_json` is the audit-anchor payload that |
35 | | /// becomes `Receipt.output` after the service's domain-side schema check. Maps to |
36 | | /// `AgentEvent::OperationSucceeded` -> `Finished(Succeeded)`. |
37 | | Succeeded { receipt_output_json: String }, |
38 | | |
39 | | /// Pre-payment / pre-side-effect transient failure (5xx, transport, etc.). Safe to |
40 | | /// re-attempt with the SAME idempotency_key. Maps to `AgentEvent::OperationRetried` -> |
41 | | /// `Running { op_id: Some(_) }`. A service callback that receives |
42 | | /// `DispatchAction::Retried` can re-fire `dispatch_pending_*` with |
43 | | /// `TriggerMode::Commit`. |
44 | | /// |
45 | | /// **`attempt` is intentionally NOT in the variant.** Instance-fn-supplied attempt |
46 | | /// counts can't be trusted (a buggy adapter could loop past the cap). The SDK callback |
47 | | /// derives `prev_attempt + 1` from the workflow's event history (count of prior |
48 | | /// `OperationRetried` events for the current `op_id`) and enforces |
49 | | /// [`MAX_PREPAYMENT_RETRY_ATTEMPTS`] there. |
50 | | RetryablePrePayment { reason: String }, |
51 | | |
52 | | /// Side effect MAY have happened; settlement state is uncertain (post-payment |
53 | | /// transport failure, missing or undecodable confirmation, etc.). Maps to |
54 | | /// `AgentEvent::OperationAmbiguous` -> `Blocked(OperationAmbiguous)`. Workflow requires |
55 | | /// an explicit `retry_*_dispatch` ABI call to re-attempt - the service does not |
56 | | /// auto-retry because the counterparty may have already settled. |
57 | | Ambiguous { reason: String }, |
58 | | |
59 | | /// Permanent rejection; no side effect occurred. Caller bug (validation failure, |
60 | | /// policy violation). Maps to `AgentEvent::OperationFailed` -> `Finished(Failed)`. |
61 | | /// Workflow is terminal. |
62 | | Failed { reason: String }, |
63 | | |
64 | | /// Side effect happened in a way that needs human disambiguation. Rare. Maps to |
65 | | /// `AgentEvent::OperationRequiresHuman` -> `Blocked(HumanInputRequired)`. |
66 | | RequiresHuman { reason: String }, |
67 | | } |
68 | | |
69 | | impl DispatchOutcome { |
70 | | /// Stable variant tag for logging / diagnostics. Mirrors the serde `kind` discriminant. |
71 | 5 | pub fn variant_name(&self) -> &'static str { |
72 | 5 | match self { |
73 | 1 | Self::Succeeded { .. } => "Succeeded", |
74 | 1 | Self::RetryablePrePayment { .. } => "RetryablePrePayment", |
75 | 1 | Self::Ambiguous { .. } => "Ambiguous", |
76 | 1 | Self::Failed { .. } => "Failed", |
77 | 1 | Self::RequiresHuman { .. } => "RequiresHuman", |
78 | | } |
79 | 5 | } |
80 | | } |
81 | | |
82 | | #[cfg(test)] |
83 | | mod tests { |
84 | | use super::*; |
85 | | |
86 | | #[test] |
87 | 1 | fn round_trip_succeeded() { |
88 | 1 | let out = DispatchOutcome::Succeeded { |
89 | 1 | receipt_output_json: r#"{"tx_hash":"0xabc"}"#.into(), |
90 | 1 | }; |
91 | 1 | let s = serde_json::to_string(&out).unwrap(); |
92 | 1 | assert!(s.contains(r#""kind":"Succeeded""#)); |
93 | 1 | let decoded: DispatchOutcome = serde_json::from_str(&s).unwrap(); |
94 | 1 | assert_eq!(decoded, out); |
95 | 1 | } |
96 | | |
97 | | #[test] |
98 | 1 | fn round_trip_retryable() { |
99 | 1 | let out = DispatchOutcome::RetryablePrePayment { |
100 | 1 | reason: "5xx from gateway".into(), |
101 | 1 | }; |
102 | 1 | let s = serde_json::to_string(&out).unwrap(); |
103 | 1 | assert!(s.contains(r#""kind":"RetryablePrePayment""#)); |
104 | 1 | let decoded: DispatchOutcome = serde_json::from_str(&s).unwrap(); |
105 | 1 | assert_eq!(decoded, out); |
106 | 1 | } |
107 | | |
108 | | #[test] |
109 | 1 | fn round_trip_ambiguous() { |
110 | 1 | let out = DispatchOutcome::Ambiguous { |
111 | 1 | reason: "missing PAYMENT-RESPONSE".into(), |
112 | 1 | }; |
113 | 1 | let decoded: DispatchOutcome = serde_json::from_str(&serde_json::to_string(&out).unwrap()).unwrap(); |
114 | 1 | assert_eq!(decoded, out); |
115 | 1 | } |
116 | | |
117 | | #[test] |
118 | 1 | fn round_trip_failed() { |
119 | 1 | let out = DispatchOutcome::Failed { |
120 | 1 | reason: "amount exceeds policy cap".into(), |
121 | 1 | }; |
122 | 1 | let decoded: DispatchOutcome = serde_json::from_str(&serde_json::to_string(&out).unwrap()).unwrap(); |
123 | 1 | assert_eq!(decoded, out); |
124 | 1 | } |
125 | | |
126 | | #[test] |
127 | 1 | fn round_trip_requires_human() { |
128 | 1 | let out = DispatchOutcome::RequiresHuman { |
129 | 1 | reason: "double-debit detected".into(), |
130 | 1 | }; |
131 | 1 | let decoded: DispatchOutcome = serde_json::from_str(&serde_json::to_string(&out).unwrap()).unwrap(); |
132 | 1 | assert_eq!(decoded, out); |
133 | 1 | } |
134 | | |
135 | | #[test] |
136 | 1 | fn variant_names_are_stable() { |
137 | 1 | assert_eq!( |
138 | 1 | DispatchOutcome::Succeeded { |
139 | 1 | receipt_output_json: "{}".into() |
140 | 1 | } |
141 | 1 | .variant_name(), |
142 | | "Succeeded" |
143 | | ); |
144 | 1 | assert_eq!( |
145 | 1 | DispatchOutcome::RetryablePrePayment { reason: "".into() }.variant_name(), |
146 | | "RetryablePrePayment" |
147 | | ); |
148 | 1 | assert_eq!( |
149 | 1 | DispatchOutcome::Ambiguous { reason: "".into() }.variant_name(), |
150 | | "Ambiguous" |
151 | | ); |
152 | 1 | assert_eq!(DispatchOutcome::Failed { reason: "".into() }.variant_name(), "Failed"); |
153 | 1 | assert_eq!( |
154 | 1 | DispatchOutcome::RequiresHuman { reason: "".into() }.variant_name(), |
155 | | "RequiresHuman" |
156 | | ); |
157 | 1 | } |
158 | | } |