Skip to main content

wist_contracts/
action_result.rs

1//! `ActionResult` contract types.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::API_VERSION_V1;
7
8pub const ACTION_RESULT_KIND: &str = "action_result";
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
11#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Contract")]
12#[serde(deny_unknown_fields)]
13pub struct ActionResult {
14    pub api_version: String,
15    pub kind: String,
16    pub action_id: String,
17    pub execution_id: String,
18    pub request_id: Option<String>,
19    pub final_status: FinalStatus,
20    pub exit_reason: Option<String>,
21    pub step_records: Vec<StepRecord>,
22    pub outputs: ActionOutputs,
23    pub resource_usage: Option<ResourceUsage>,
24    pub started_at: Option<String>,
25    pub finished_at: Option<String>,
26}
27
28impl ActionResult {
29    pub fn new(action_id: String, execution_id: String, final_status: FinalStatus) -> Self {
30        Self {
31            api_version: API_VERSION_V1.to_string(),
32            kind: ACTION_RESULT_KIND.to_string(),
33            action_id,
34            execution_id,
35            request_id: None,
36            final_status,
37            exit_reason: None,
38            step_records: Vec::new(),
39            outputs: ActionOutputs::default(),
40            resource_usage: None,
41            started_at: None,
42            finished_at: None,
43        }
44    }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48pub enum FinalStatus {
49    #[serde(rename = "succeeded")]
50    Succeeded,
51    #[serde(rename = "failed")]
52    Failed,
53    #[serde(rename = "cancelled")]
54    Cancelled,
55    #[serde(rename = "timed_out")]
56    TimedOut,
57    #[serde(rename = "rejected")]
58    Rejected,
59}
60
61impl FinalStatus {
62    pub fn as_state_name(self) -> &'static str {
63        match self {
64            Self::Succeeded => "succeeded",
65            Self::Failed => "failed",
66            Self::Cancelled => "cancelled",
67            Self::TimedOut => "timed_out",
68            Self::Rejected => "rejected",
69        }
70    }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct StepRecord {
76    pub step_id: String,
77    pub attempt: u32,
78    pub op: Option<String>,
79    pub status: StepStatus,
80    pub started_at: String,
81    pub finished_at: Option<String>,
82    pub duration_ms: Option<u64>,
83    pub error_code: Option<String>,
84    pub stdout_summary: Option<String>,
85    pub stderr_summary: Option<String>,
86    pub resource_usage: Option<ResourceUsage>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90pub enum StepStatus {
91    #[serde(rename = "started")]
92    Started,
93    #[serde(rename = "succeeded")]
94    Succeeded,
95    #[serde(rename = "failed")]
96    Failed,
97    #[serde(rename = "cancelled")]
98    Cancelled,
99    #[serde(rename = "timed_out")]
100    TimedOut,
101    #[serde(rename = "skipped")]
102    Skipped,
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
106#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Contract")]
107#[serde(deny_unknown_fields)]
108pub struct ActionOutputs {
109    #[serde(default)]
110    pub items: Vec<ActionOutputItem>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
114#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Contract")]
115#[serde(deny_unknown_fields)]
116pub struct ActionOutputItem {
117    pub name: String,
118    pub value: Value,
119    pub redacted: Option<bool>,
120}
121
122#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct ResourceUsage {
125    pub max_rss_bytes: Option<u64>,
126    pub cpu_time_ms: Option<u64>,
127    pub stdout_bytes: Option<u64>,
128    pub stderr_bytes: Option<u64>,
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn new_stamps_version_kind_and_empty_defaults() {
137        let result = ActionResult::new(
138            "act-1".to_string(),
139            "exec-1".to_string(),
140            FinalStatus::Succeeded,
141        );
142        assert_eq!(result.api_version, API_VERSION_V1);
143        assert_eq!(result.kind, ACTION_RESULT_KIND);
144        assert!(result.step_records.is_empty());
145        assert_eq!(result.outputs, ActionOutputs::default());
146        assert_eq!(result.request_id, None);
147        assert_eq!(result.resource_usage, None);
148    }
149
150    #[test]
151    fn final_status_state_names_match_the_wire_names() {
152        // `as_state_name` 与 serde 的 rename 必须一字不差:下游把前者写进状态文件,
153        // 把后者写上线,两者分叉就只能靠人看出来。
154        for (status, name) in [
155            (FinalStatus::Succeeded, "succeeded"),
156            (FinalStatus::Failed, "failed"),
157            (FinalStatus::Cancelled, "cancelled"),
158            (FinalStatus::TimedOut, "timed_out"),
159            (FinalStatus::Rejected, "rejected"),
160        ] {
161            assert_eq!(status.as_state_name(), name);
162            assert_eq!(
163                serde_json::to_string(&status).unwrap(),
164                format!("\"{name}\"")
165            );
166            assert_eq!(
167                serde_json::from_str::<FinalStatus>(&format!("\"{name}\"")).unwrap(),
168                status
169            );
170        }
171        assert!(serde_json::from_str::<FinalStatus>("\"unknown\"").is_err());
172        assert!(serde_json::from_str::<StepStatus>("\"unknown\"").is_err());
173    }
174
175    #[test]
176    fn action_result_round_trips_with_nested_records_and_outputs() {
177        let mut result = ActionResult::new(
178            "act-1".to_string(),
179            "exec-1".to_string(),
180            FinalStatus::Failed,
181        );
182        result.step_records.push(StepRecord {
183            step_id: "step-1".to_string(),
184            attempt: 2,
185            op: Some("shell".to_string()),
186            status: StepStatus::Failed,
187            started_at: "2026-09-27T00:00:00Z".to_string(),
188            finished_at: Some("2026-09-27T00:00:01Z".to_string()),
189            duration_ms: Some(1_000),
190            error_code: Some("nonzero_exit".to_string()),
191            stdout_summary: None,
192            stderr_summary: Some("boom".to_string()),
193            resource_usage: Some(ResourceUsage {
194                max_rss_bytes: Some(1024),
195                cpu_time_ms: None,
196                stdout_bytes: Some(0),
197                stderr_bytes: Some(4),
198            }),
199        });
200        result.outputs.items.push(ActionOutputItem {
201            name: "file".to_string(),
202            value: serde_json::json!({"path": "/tmp/out"}),
203            redacted: Some(true),
204        });
205
206        let json = serde_json::to_string(&result).expect("encode");
207        let back: ActionResult = serde_json::from_str(&json).expect("decode");
208        assert_eq!(back, result);
209    }
210
211    #[test]
212    fn action_result_rejects_unknown_fields() {
213        let result = ActionResult::new(
214            "act-1".to_string(),
215            "exec-1".to_string(),
216            FinalStatus::Succeeded,
217        );
218        let json = serde_json::to_string(&result).expect("encode");
219        let mutated = json.replacen('{', "{\"extra\":1,", 1);
220        assert!(serde_json::from_str::<ActionResult>(&mutated).is_err());
221    }
222}