1use jiff::Timestamp;
8use serde::{Deserialize, Serialize};
9
10use crate::agent::AgentName;
11use crate::cost::{CostSource, TokenUsage};
12use crate::flight::{ItineraryId, RunId};
13use crate::pipeline::PipelineName;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum Outcome {
23 Running,
25 Succeeded,
27 Failed,
29 TimedOut,
31 Halted,
33 Interrupted,
35}
36
37impl Outcome {
38 #[must_use]
40 pub fn is_live(self) -> bool {
41 matches!(self, Self::Running)
42 }
43
44 #[must_use]
46 pub fn is_success(self) -> bool {
47 matches!(self, Self::Succeeded)
48 }
49
50 #[must_use]
55 pub fn is_failure(self) -> bool {
56 matches!(self, Self::Failed | Self::TimedOut | Self::Interrupted)
57 }
58
59 #[must_use]
61 pub fn slug(self) -> &'static str {
62 match self {
63 Self::Running => "running",
64 Self::Succeeded => "succeeded",
65 Self::Failed => "failed",
66 Self::TimedOut => "timed_out",
67 Self::Halted => "halted",
68 Self::Interrupted => "interrupted",
69 }
70 }
71
72 #[must_use]
74 pub fn from_slug(slug: &str) -> Option<Self> {
75 [
76 Self::Running,
77 Self::Succeeded,
78 Self::Failed,
79 Self::TimedOut,
80 Self::Halted,
81 Self::Interrupted,
82 ]
83 .into_iter()
84 .find(|outcome| outcome.slug() == slug)
85 }
86}
87
88impl std::fmt::Display for Outcome {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.write_str(self.slug())
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
99pub struct RunRecord {
100 pub run: RunId,
102 pub itinerary: ItineraryId,
104 pub agent: AgentName,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub pipeline: Option<PipelineName>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub model: Option<String>,
112 pub outcome: Outcome,
114 pub started_at: Timestamp,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub finished_at: Option<Timestamp>,
119 #[serde(default)]
121 pub usd: f64,
122 pub source: CostSource,
124 #[serde(default)]
126 pub usage: TokenUsage,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub detail: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub blocked_on: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub pid: Option<u32>,
150}
151
152impl RunRecord {
153 #[must_use]
155 pub fn started(
156 run: RunId,
157 itinerary: ItineraryId,
158 agent: AgentName,
159 started_at: Timestamp,
160 ) -> Self {
161 Self {
162 run,
163 itinerary,
164 agent,
165 pipeline: None,
166 model: None,
167 outcome: Outcome::Running,
168 started_at,
169 finished_at: None,
170 usd: 0.0,
171 source: CostSource::Unreported,
172 usage: TokenUsage::default(),
173 detail: None,
174 blocked_on: None,
175 pid: None,
176 }
177 }
178
179 #[must_use]
181 pub fn from_pipeline(mut self, pipeline: PipelineName) -> Self {
182 self.pipeline = Some(pipeline);
183 self
184 }
185
186 #[must_use]
188 pub fn using_model(mut self, model: impl Into<String>) -> Self {
189 self.model = Some(model.into());
190 self
191 }
192
193 #[must_use]
195 pub fn finished(mut self, outcome: Outcome, at: Timestamp) -> Self {
196 self.outcome = outcome;
197 self.finished_at = Some(at);
198 self
199 }
200
201 #[must_use]
203 pub fn costing(mut self, usd: f64, source: CostSource, usage: TokenUsage) -> Self {
204 self.usd = usd;
205 self.source = source;
206 self.usage = usage;
207 self
208 }
209
210 #[must_use]
212 pub fn because(mut self, detail: impl Into<String>) -> Self {
213 self.detail = Some(detail.into());
214 self
215 }
216
217 #[must_use]
219 pub fn blocked_on(mut self, summary: impl Into<String>) -> Self {
220 self.blocked_on = Some(summary.into());
221 self
222 }
223
224 #[must_use]
226 pub fn needed_help(&self) -> bool {
227 self.blocked_on.is_some()
228 }
229
230 #[must_use]
232 pub fn with_pid(mut self, pid: u32) -> Self {
233 self.pid = Some(pid);
234 self
235 }
236
237 #[must_use]
243 pub fn duration_secs(&self) -> Option<i64> {
244 let finished = self.finished_at?;
245 let seconds = finished.as_second() - self.started_at.as_second();
246 (seconds >= 0).then_some(seconds)
247 }
248
249 #[must_use]
255 pub fn filed_at(&self) -> Timestamp {
256 self.finished_at.unwrap_or(self.started_at)
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn at(rfc3339: &str) -> Timestamp {
265 rfc3339.parse().expect("valid timestamp")
266 }
267
268 fn record() -> RunRecord {
269 RunRecord::started(
270 RunId::generate(),
271 ItineraryId::generate(),
272 "developer".into(),
273 at("2026-09-16T10:00:00Z"),
274 )
275 }
276
277 #[test]
278 fn a_finished_run_reports_how_long_it_took() {
279 let done = record().finished(Outcome::Succeeded, at("2026-09-16T10:04:30Z"));
280
281 assert_eq!(done.duration_secs(), Some(270));
282 assert!(done.outcome.is_success());
283 assert!(!done.outcome.is_live());
284 }
285
286 #[test]
287 fn a_running_run_has_no_duration_yet() {
288 let live = record();
289
290 assert_eq!(live.duration_secs(), None);
291 assert!(live.outcome.is_live());
292 assert_eq!(live.filed_at(), live.started_at);
293 }
294
295 #[test]
296 fn a_backwards_clock_produces_no_duration_rather_than_a_negative_one() {
297 let impossible = record().finished(Outcome::Succeeded, at("2026-09-16T09:59:00Z"));
300
301 assert_eq!(impossible.duration_secs(), None);
302 }
303
304 #[test]
305 fn runs_are_filed_by_when_they_finished() {
306 let done = record().finished(Outcome::Succeeded, at("2026-09-16T10:04:30Z"));
309
310 assert_eq!(done.filed_at(), at("2026-09-16T10:04:30Z"));
311 }
312
313 #[test]
314 fn a_rail_stopping_work_is_not_a_failure() {
315 assert!(!Outcome::Halted.is_failure());
318 assert!(Outcome::Failed.is_failure());
319 assert!(Outcome::TimedOut.is_failure());
320 assert!(Outcome::Interrupted.is_failure());
321 }
322
323 #[test]
324 fn outcome_slugs_round_trip() {
325 for outcome in [
326 Outcome::Running,
327 Outcome::Succeeded,
328 Outcome::Failed,
329 Outcome::TimedOut,
330 Outcome::Halted,
331 Outcome::Interrupted,
332 ] {
333 assert_eq!(Outcome::from_slug(outcome.slug()), Some(outcome));
334 }
335
336 assert_eq!(Outcome::from_slug("exploded"), None);
337 }
338
339 #[test]
340 fn a_record_serialises_to_one_readable_line() {
341 let done = record()
344 .from_pipeline("triage".into())
345 .using_model("claude-opus-5")
346 .finished(Outcome::Succeeded, at("2026-09-16T10:04:30Z"))
347 .costing(1.25, CostSource::Reported, TokenUsage::default());
348
349 let line = serde_json::to_string(&done).expect("serialises");
350
351 assert!(
352 !line.contains('\n'),
353 "a record must occupy exactly one line"
354 );
355 assert!(
356 line.contains(r#""started_at":"2026-09-16T10:00:00Z""#),
357 "{line}"
358 );
359 assert!(line.contains(r#""outcome":"succeeded""#), "{line}");
360 assert!(line.contains(r#""pipeline":"triage""#), "{line}");
361 }
362
363 #[test]
364 fn absent_optional_fields_are_left_out_rather_than_written_as_null() {
365 let line = serde_json::to_string(&record()).expect("serialises");
366
367 assert!(!line.contains("null"), "{line}");
368 assert!(!line.contains("finished_at"), "{line}");
369 }
370
371 #[test]
372 fn a_run_can_succeed_and_still_have_been_blocked() {
373 let limited = record()
376 .finished(Outcome::Succeeded, at("2026-09-16T10:05:00Z"))
377 .blocked_on("could not read the linked file: permission denied");
378
379 assert!(limited.outcome.is_success());
380 assert!(limited.needed_help());
381 assert!(!record().needed_help());
382 }
383
384 #[test]
385 fn a_live_run_records_the_process_it_is_waiting_on() {
386 let spawned = record().with_pid(4242);
389
390 let line = serde_json::to_string(&spawned).expect("serialises");
391 assert!(line.contains(r#""pid":4242"#), "{line}");
392
393 let closed = spawned.finished(Outcome::Succeeded, at("2026-09-16T10:01:00Z"));
394 assert_eq!(closed.pid, Some(4242), "the id survives the run ending");
395 }
396
397 #[test]
398 fn a_record_round_trips_through_its_wire_format() {
399 let done = record()
400 .from_pipeline("triage".into())
401 .finished(Outcome::Halted, at("2026-09-16T10:01:00Z"))
402 .because("out of Fuel");
403
404 let line = serde_json::to_string(&done).expect("serialises");
405 let back: RunRecord = serde_json::from_str(&line).expect("deserialises");
406
407 assert_eq!(back, done);
408 assert_eq!(back.detail.as_deref(), Some("out of Fuel"));
409 }
410}