Coverage Report

Created: 2026-05-23 17:32

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}