1use 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 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}