1use serde::{Deserialize, Serialize};
26
27use super::Status;
28
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
31#[serde(rename_all = "snake_case")]
32pub enum RunSource {
33 Direct,
35 Workflow,
37 Fleet,
39 CoreJob,
41 Task,
43}
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum RunState {
54 Queued,
56 Initializing,
58 Running,
60 WaitingModel,
62 WaitingTool,
64 WaitingInput,
66 Paused,
68 Stopping,
70 Terminal,
72}
73
74impl Status for RunState {
75 fn is_terminal(&self) -> bool {
76 matches!(self, Self::Terminal)
77 }
78 fn is_active(&self) -> bool {
79 matches!(
80 self,
81 Self::Queued
82 | Self::Initializing
83 | Self::Running
84 | Self::WaitingModel
85 | Self::WaitingTool
86 | Self::WaitingInput
87 | Self::Stopping
88 )
89 }
90 fn is_paused(&self) -> bool {
91 matches!(self, Self::Paused)
92 }
93}
94
95#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
100pub struct BudgetSummary {
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub token_budget: Option<u64>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub tokens_used: Option<u64>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub steps_taken: Option<u32>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub max_steps: Option<u32>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub duration_ms: Option<u64>,
116}
117
118#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "snake_case")]
121pub enum TerminalOutcome {
122 Completed,
124 Failed,
126 Cancelled,
128 Interrupted,
130 BudgetExhausted,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136pub struct TerminalSummary {
137 pub outcome: TerminalOutcome,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub ended_at_ms: Option<i64>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub detail: Option<String>,
145}
146
147#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "snake_case")]
150pub enum ReceiptKind {
151 Artifact,
153 Gate,
155 Receipt,
157 Approval,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166pub struct ReceiptRef {
167 pub kind: ReceiptKind,
168 pub reference: String,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub label: Option<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176pub struct AgentRunSnapshot {
177 pub run_id: String,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub parent: Option<String>,
183 pub source: RunSource,
185 pub state: RunState,
187 #[serde(default)]
189 pub budget: BudgetSummary,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub terminal: Option<TerminalSummary>,
193 #[serde(default, skip_serializing_if = "Vec::is_empty")]
195 pub refs: Vec<ReceiptRef>,
196}
197
198impl AgentRunSnapshot {
199 #[must_use]
204 pub fn is_coherent(&self) -> bool {
205 matches!(self.state, RunState::Terminal) == self.terminal.is_some()
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 fn full_snapshot() -> AgentRunSnapshot {
214 AgentRunSnapshot {
215 run_id: "agent_1234abcd".to_string(),
216 parent: Some("agent_00ff00ff".to_string()),
217 source: RunSource::Direct,
218 state: RunState::Terminal,
219 budget: BudgetSummary {
220 token_budget: Some(50_000),
221 tokens_used: Some(12_345),
222 steps_taken: Some(7),
223 max_steps: Some(40),
224 duration_ms: Some(93_000),
225 },
226 terminal: Some(TerminalSummary {
227 outcome: TerminalOutcome::Completed,
228 ended_at_ms: Some(1_800_000_000_000),
229 detail: Some("verified build".to_string()),
230 }),
231 refs: vec![ReceiptRef {
232 kind: ReceiptKind::Artifact,
233 reference: "runs/agent_1234abcd/result.md".to_string(),
234 label: Some("result".to_string()),
235 }],
236 }
237 }
238
239 fn minimal_snapshot() -> AgentRunSnapshot {
240 AgentRunSnapshot {
241 run_id: "job-42".to_string(),
242 parent: None,
243 source: RunSource::CoreJob,
244 state: RunState::Queued,
245 budget: BudgetSummary::default(),
246 terminal: None,
247 refs: Vec::new(),
248 }
249 }
250
251 #[test]
252 fn full_snapshot_round_trips() {
253 let snapshot = full_snapshot();
254 let json = serde_json::to_string(&snapshot).expect("serialize");
255 let back: AgentRunSnapshot = serde_json::from_str(&json).expect("deserialize");
256 assert_eq!(back, snapshot);
257 assert!(back.is_coherent());
258 }
259
260 #[test]
261 fn minimal_snapshot_round_trips_and_skips_empty_fields() {
262 let snapshot = minimal_snapshot();
263 let json = serde_json::to_string(&snapshot).expect("serialize");
264 assert!(!json.contains("parent"));
266 assert!(!json.contains("terminal"));
267 assert!(!json.contains("refs"));
268 assert!(!json.contains("token_budget"));
269 let back: AgentRunSnapshot = serde_json::from_str(&json).expect("deserialize");
270 assert_eq!(back, snapshot);
271 assert!(back.is_coherent());
272 }
273
274 #[test]
275 fn enum_wire_names_are_snake_case_and_stable() {
276 assert_eq!(
277 serde_json::to_string(&RunSource::CoreJob).unwrap(),
278 "\"core_job\""
279 );
280 assert_eq!(
281 serde_json::to_string(&RunState::WaitingModel).unwrap(),
282 "\"waiting_model\""
283 );
284 assert_eq!(
285 serde_json::to_string(&TerminalOutcome::BudgetExhausted).unwrap(),
286 "\"budget_exhausted\""
287 );
288 assert_eq!(
289 serde_json::to_string(&ReceiptKind::Gate).unwrap(),
290 "\"gate\""
291 );
292 for state in [
294 RunState::Queued,
295 RunState::Initializing,
296 RunState::Running,
297 RunState::WaitingModel,
298 RunState::WaitingTool,
299 RunState::WaitingInput,
300 RunState::Paused,
301 RunState::Stopping,
302 RunState::Terminal,
303 ] {
304 let json = serde_json::to_string(&state).unwrap();
305 let back: RunState = serde_json::from_str(&json).unwrap();
306 assert_eq!(back, state);
307 }
308 }
309
310 #[test]
311 fn run_state_status_trait_partitions_all_states() {
312 for state in [
313 RunState::Queued,
314 RunState::Initializing,
315 RunState::Running,
316 RunState::WaitingModel,
317 RunState::WaitingTool,
318 RunState::WaitingInput,
319 RunState::Paused,
320 RunState::Stopping,
321 RunState::Terminal,
322 ] {
323 let classifications = [state.is_terminal(), state.is_active(), state.is_paused()];
324 assert_eq!(
325 classifications.iter().filter(|flag| **flag).count(),
326 1,
327 "state {state:?} must be exactly one of terminal/active/paused"
328 );
329 }
330 }
331
332 #[test]
333 fn incoherent_snapshot_is_detectable() {
334 let mut snapshot = minimal_snapshot();
335 snapshot.terminal = Some(TerminalSummary {
336 outcome: TerminalOutcome::Completed,
337 ended_at_ms: None,
338 detail: None,
339 });
340 assert!(!snapshot.is_coherent());
341 }
342}