use serde::{Deserialize, Serialize};
use super::Status;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RunSource {
Direct,
Workflow,
Fleet,
CoreJob,
Task,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RunState {
Queued,
Initializing,
Running,
WaitingModel,
WaitingTool,
WaitingInput,
Paused,
Stopping,
Terminal,
}
impl Status for RunState {
fn is_terminal(&self) -> bool {
matches!(self, Self::Terminal)
}
fn is_active(&self) -> bool {
matches!(
self,
Self::Queued
| Self::Initializing
| Self::Running
| Self::WaitingModel
| Self::WaitingTool
| Self::WaitingInput
| Self::Stopping
)
}
fn is_paused(&self) -> bool {
matches!(self, Self::Paused)
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct BudgetSummary {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_budget: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens_used: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steps_taken: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_steps: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TerminalOutcome {
Completed,
Failed,
Cancelled,
Interrupted,
BudgetExhausted,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TerminalSummary {
pub outcome: TerminalOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ended_at_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReceiptKind {
Artifact,
Gate,
Receipt,
Approval,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReceiptRef {
pub kind: ReceiptKind,
pub reference: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentRunSnapshot {
pub run_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub source: RunSource,
pub state: RunState,
#[serde(default)]
pub budget: BudgetSummary,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal: Option<TerminalSummary>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<ReceiptRef>,
}
impl AgentRunSnapshot {
#[must_use]
pub fn is_coherent(&self) -> bool {
matches!(self.state, RunState::Terminal) == self.terminal.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn full_snapshot() -> AgentRunSnapshot {
AgentRunSnapshot {
run_id: "agent_1234abcd".to_string(),
parent: Some("agent_00ff00ff".to_string()),
source: RunSource::Direct,
state: RunState::Terminal,
budget: BudgetSummary {
token_budget: Some(50_000),
tokens_used: Some(12_345),
steps_taken: Some(7),
max_steps: Some(40),
duration_ms: Some(93_000),
},
terminal: Some(TerminalSummary {
outcome: TerminalOutcome::Completed,
ended_at_ms: Some(1_800_000_000_000),
detail: Some("verified build".to_string()),
}),
refs: vec![ReceiptRef {
kind: ReceiptKind::Artifact,
reference: "runs/agent_1234abcd/result.md".to_string(),
label: Some("result".to_string()),
}],
}
}
fn minimal_snapshot() -> AgentRunSnapshot {
AgentRunSnapshot {
run_id: "job-42".to_string(),
parent: None,
source: RunSource::CoreJob,
state: RunState::Queued,
budget: BudgetSummary::default(),
terminal: None,
refs: Vec::new(),
}
}
#[test]
fn full_snapshot_round_trips() {
let snapshot = full_snapshot();
let json = serde_json::to_string(&snapshot).expect("serialize");
let back: AgentRunSnapshot = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, snapshot);
assert!(back.is_coherent());
}
#[test]
fn minimal_snapshot_round_trips_and_skips_empty_fields() {
let snapshot = minimal_snapshot();
let json = serde_json::to_string(&snapshot).expect("serialize");
assert!(!json.contains("parent"));
assert!(!json.contains("terminal"));
assert!(!json.contains("refs"));
assert!(!json.contains("token_budget"));
let back: AgentRunSnapshot = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, snapshot);
assert!(back.is_coherent());
}
#[test]
fn enum_wire_names_are_snake_case_and_stable() {
assert_eq!(
serde_json::to_string(&RunSource::CoreJob).unwrap(),
"\"core_job\""
);
assert_eq!(
serde_json::to_string(&RunState::WaitingModel).unwrap(),
"\"waiting_model\""
);
assert_eq!(
serde_json::to_string(&TerminalOutcome::BudgetExhausted).unwrap(),
"\"budget_exhausted\""
);
assert_eq!(
serde_json::to_string(&ReceiptKind::Gate).unwrap(),
"\"gate\""
);
for state in [
RunState::Queued,
RunState::Initializing,
RunState::Running,
RunState::WaitingModel,
RunState::WaitingTool,
RunState::WaitingInput,
RunState::Paused,
RunState::Stopping,
RunState::Terminal,
] {
let json = serde_json::to_string(&state).unwrap();
let back: RunState = serde_json::from_str(&json).unwrap();
assert_eq!(back, state);
}
}
#[test]
fn run_state_status_trait_partitions_all_states() {
for state in [
RunState::Queued,
RunState::Initializing,
RunState::Running,
RunState::WaitingModel,
RunState::WaitingTool,
RunState::WaitingInput,
RunState::Paused,
RunState::Stopping,
RunState::Terminal,
] {
let classifications = [state.is_terminal(), state.is_active(), state.is_paused()];
assert_eq!(
classifications.iter().filter(|flag| **flag).count(),
1,
"state {state:?} must be exactly one of terminal/active/paused"
);
}
}
#[test]
fn incoherent_snapshot_is_detectable() {
let mut snapshot = minimal_snapshot();
snapshot.terminal = Some(TerminalSummary {
outcome: TerminalOutcome::Completed,
ended_at_ms: None,
detail: None,
});
assert!(!snapshot.is_coherent());
}
}