Skip to main content

connector_client/
outcome.rs

1//! Execution facts, verification, and effects are independent from business JSON.
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum ExecutionStatus {
8    NotDispatched,
9    Completed,
10    Failed,
11    OutcomeUnknown,
12}
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum VerificationStatus {
16    NotRequested,
17    Passed,
18    Failed,
19    Inconclusive,
20}
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum EffectStatus {
24    None,
25    Possible,
26    Confirmed,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct WorkflowError {
32    pub code: String,
33    pub stage: String,
34    pub message: String,
35    #[serde(default)]
36    pub retryable_before_dispatch: bool,
37}
38impl WorkflowError {
39    pub fn new(
40        code: impl Into<String>,
41        stage: impl Into<String>,
42        message: impl Into<String>,
43    ) -> Self {
44        Self {
45            code: code.into(),
46            stage: stage.into(),
47            message: message.into(),
48            retryable_before_dispatch: false,
49        }
50    }
51}
52impl std::fmt::Display for WorkflowError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}: {}", self.code, self.message)
55    }
56}
57impl std::error::Error for WorkflowError {}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct ExecutionOutcome {
62    pub execution: ExecutionStatus,
63    pub verification: VerificationStatus,
64    pub effect: EffectStatus,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub effect_scope: Option<String>,
67    #[serde(default)]
68    pub data: Value,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub error: Option<WorkflowError>,
71    #[serde(default)]
72    pub timing: Value,
73    #[serde(default)]
74    pub dispatch: Value,
75    #[serde(default)]
76    pub evidence_refs: Vec<String>,
77    #[serde(default)]
78    pub coverage: Value,
79    #[serde(default)]
80    pub warnings: Vec<String>,
81}
82impl ExecutionOutcome {
83    pub fn completed(data: Value, effect: EffectStatus) -> Self {
84        Self {
85            execution: ExecutionStatus::Completed,
86            verification: VerificationStatus::NotRequested,
87            effect,
88            effect_scope: None,
89            data,
90            error: None,
91            timing: json!({}),
92            dispatch: json!({}),
93            evidence_refs: Vec::new(),
94            coverage: json!({"businessPersistence":"unobserved"}),
95            warnings: Vec::new(),
96        }
97    }
98    pub fn not_dispatched(error: WorkflowError) -> Self {
99        Self {
100            execution: ExecutionStatus::NotDispatched,
101            error: Some(error),
102            ..Self::completed(Value::Null, EffectStatus::None)
103        }
104    }
105    pub fn failed(error: WorkflowError, effect: EffectStatus) -> Self {
106        Self {
107            execution: ExecutionStatus::Failed,
108            error: Some(error),
109            ..Self::completed(Value::Null, effect)
110        }
111    }
112    pub fn unknown(error: WorkflowError) -> Self {
113        Self {
114            execution: ExecutionStatus::OutcomeUnknown,
115            error: Some(error),
116            ..Self::completed(Value::Null, EffectStatus::Possible)
117        }
118    }
119    /// Required verification must pass; business data never decides tool success.
120    pub fn is_success(&self, expect: bool) -> bool {
121        self.execution == ExecutionStatus::Completed
122            && self.error.is_none()
123            && if expect {
124                self.verification == VerificationStatus::Passed
125            } else {
126                matches!(
127                    self.verification,
128                    VerificationStatus::NotRequested | VerificationStatus::Passed
129                )
130            }
131    }
132}
133
134/// Stable machine-readable error codes understood by workflow v1.
135pub const ERROR_CODES: &[&str] = &[
136    "invalid_spec",
137    "capability_unavailable",
138    "unsupported_feature",
139    "unsupported_condition",
140    "target_not_found",
141    "ambiguous_target",
142    "target_changed",
143    "stale_ref",
144    "not_actionable",
145    "binding_missing",
146    "binding_type_mismatch",
147    "precondition_failed",
148    "postcondition_failed",
149    "condition_timeout",
150    "execution_failed",
151    "outcome_unknown",
152    "run_key_conflict",
153    "run_not_found",
154    "run_expired",
155    "stale_checkpoint",
156    "resume_not_safe",
157    "app_instance_changed",
158    "resume_requires_inputs",
159    "observation_failed",
160    "cancel_requested",
161    "cancelled_before_dispatch",
162    "persistence_unavailable",
163    "evidence_persistence_failed",
164    "resource_busy",
165    "capture_incomplete",
166    "protocol_mismatch",
167    "unauthorized",
168];
169
170/// Adapter for known legacy tool contracts. It deliberately does not inspect
171/// arbitrary query results for business `error`, `ok`, or `found` fields.
172pub fn legacy_outcome(tool: &str, value: Value) -> ExecutionOutcome {
173    let canonical = tool.strip_prefix("webview_").unwrap_or(tool);
174    let mut out = ExecutionOutcome::completed(value, EffectStatus::Possible);
175    if matches!(canonical, "wait_for" | "wait") {
176        out.effect = EffectStatus::None;
177        if out.data.get("timeout").and_then(Value::as_bool) == Some(true)
178            && out.data.get("found").and_then(Value::as_bool) == Some(false)
179        {
180            out.execution = ExecutionStatus::Failed;
181            out.error = Some(WorkflowError::new(
182                "condition_timeout",
183                "waiting",
184                "Wait condition timed out",
185            ));
186        }
187    }
188    if canonical == "act_and_verify" {
189        match out.data.get("verdict").and_then(Value::as_str) {
190            Some("failed") => {
191                out.verification = VerificationStatus::Failed;
192                out.error = Some(WorkflowError::new(
193                    "postcondition_failed",
194                    "verifying",
195                    "Action verification failed",
196                ));
197            }
198            Some("passed") => out.verification = VerificationStatus::Passed,
199            Some("inconclusive") => out.verification = VerificationStatus::Inconclusive,
200            _ => {}
201        }
202    }
203    out
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    #[test]
210    fn business_json_does_not_decide_success() {
211        for data in [
212            json!({"error":"saved text"}),
213            json!({"found":false,"timeout":true}),
214            json!({"verdict":"failed"}),
215            json!({"ok":false}),
216        ] {
217            assert!(legacy_outcome("query", data).is_success(false));
218        }
219    }
220    #[test]
221    fn known_wait_and_verification_failures_propagate() {
222        assert!(
223            !legacy_outcome("webview_wait_for", json!({"found":false,"timeout":true}))
224                .is_success(false)
225        );
226        let outcome = legacy_outcome("webview_act_and_verify", json!({"verdict":"failed"}));
227        assert_eq!(outcome.execution, ExecutionStatus::Completed);
228        assert_eq!(outcome.verification, VerificationStatus::Failed);
229        assert!(!outcome.is_success(false));
230    }
231    #[test]
232    fn undispatched_verified_state_is_not_success() {
233        let mut out = ExecutionOutcome::not_dispatched(WorkflowError::new(
234            "not_actionable",
235            "preparing",
236            "disabled",
237        ));
238        out.verification = VerificationStatus::Passed;
239        assert!(!out.is_success(true));
240    }
241    #[test]
242    fn error_codes_are_stable_and_unique() {
243        let unique: std::collections::HashSet<_> = ERROR_CODES.iter().collect();
244        assert_eq!(unique.len(), ERROR_CODES.len());
245        assert!(ERROR_CODES.contains(&"outcome_unknown"));
246        assert_eq!(
247            serde_json::to_value(ExecutionStatus::OutcomeUnknown).unwrap(),
248            json!("outcome_unknown")
249        );
250    }
251}