1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum RunStatus {
17 Starting,
18 Running,
19 WaitingInput,
20 Complete,
21 CompleteInteractive,
24 Paused,
27 Error,
28 Cancelled,
29}
30
31impl std::fmt::Display for RunStatus {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 RunStatus::Starting => write!(f, "Starting"),
35 RunStatus::Running => write!(f, "Running"),
36 RunStatus::WaitingInput => write!(f, "WaitingInput"),
37 RunStatus::Complete => write!(f, "Complete"),
38 RunStatus::CompleteInteractive => write!(f, "CompleteInteractive"),
39 RunStatus::Paused => write!(f, "Paused"),
40 RunStatus::Error => write!(f, "Error"),
41 RunStatus::Cancelled => write!(f, "Cancelled"),
42 }
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct RunMeta {
49 pub run_id: String,
50 pub agent_name: String,
51 pub agent_path: String,
53 pub task: String,
54 pub model: Option<String>,
55 #[serde(default)]
65 pub pid: u32,
66 pub status: RunStatus,
67 pub current_stage: String,
68 pub stage_index: usize,
69 pub num_stages: usize,
70 pub iteration: usize,
71 pub prompt_tokens: usize,
72 pub completion_tokens: usize,
73 #[serde(default)]
75 pub cached_tokens: usize,
76 #[serde(default)]
78 pub cache_write_tokens: usize,
79 #[serde(default)]
81 pub tool_calls: usize,
82 pub workdir: String,
84 pub started_at: i64,
86 pub updated_at: i64,
88 #[serde(default)]
99 pub last_progress_at: Option<i64>,
100 pub error: Option<String>,
101 #[serde(default)]
103 pub title: Option<String>,
104 #[serde(default)]
106 pub metadata: HashMap<String, String>,
107 #[serde(default)]
109 pub callback_url: Option<String>,
110 #[serde(default)]
118 pub callback_secret: Option<String>,
119 #[serde(default)]
121 pub parent_run_id: Option<String>,
122 #[serde(default)]
126 pub children: Vec<String>,
127 #[serde(default)]
130 pub depth: usize,
131 #[serde(default)]
134 pub max_child_depth: usize,
135 #[serde(default)]
137 pub flags: RunFlags,
138 #[serde(default)]
149 pub yolo: bool,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub read_paths: Option<ReadPathGrantCounts>,
155}
156
157#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
165pub struct ReadPathGrantCounts {
166 pub declared: usize,
168 pub granted: usize,
170}
171
172#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
180pub struct RunFlags {
181 #[serde(default)]
185 pub modified_files: Vec<String>,
186 #[serde(default)]
188 pub modified_file_count: usize,
189 #[serde(default)]
192 pub empty_output: bool,
193 #[serde(default)]
210 pub no_output_tools: bool,
211 #[serde(default)]
213 pub max_iterations_hit: usize,
214 #[serde(default)]
217 pub gates_forced: usize,
218 #[serde(default)]
220 pub workspace_lost: bool,
221}
222
223pub const MAX_TRACKED_MODIFIED_FILES: usize = 200;
226
227impl RunFlags {
228 pub fn record_modification(&mut self, path: &str) {
230 self.modified_file_count += 1;
231 if self.modified_files.len() < MAX_TRACKED_MODIFIED_FILES
232 && !self.modified_files.iter().any(|p| p == path)
233 {
234 self.modified_files.push(path.to_string());
235 }
236 }
237}
238
239impl RunMeta {
240 #[must_use]
252 pub fn redacted(&self) -> Self {
253 Self {
254 callback_secret: None,
255 ..self.clone()
256 }
257 }
258
259 pub fn new(
260 run_id: String,
261 agent_name: String,
262 agent_path: String,
263 task: String,
264 model: Option<String>,
265 workdir: String,
266 num_stages: usize,
267 ) -> Self {
268 let now = now_secs();
269 Self {
270 run_id,
271 agent_name,
272 agent_path,
273 task,
274 model,
275 pid: 0,
276 status: RunStatus::Starting,
277 current_stage: String::new(),
278 stage_index: 0,
279 num_stages,
280 iteration: 0,
281 prompt_tokens: 0,
282 completion_tokens: 0,
283 cached_tokens: 0,
284 cache_write_tokens: 0,
285 tool_calls: 0,
286 workdir,
287 started_at: now,
288 updated_at: now,
289 last_progress_at: None,
290 error: None,
291 title: None,
292 metadata: HashMap::new(),
293 callback_url: None,
294 callback_secret: None,
295 parent_run_id: None,
296 children: Vec::new(),
297 depth: 0,
298 max_child_depth: 0,
299 flags: RunFlags::default(),
300 yolo: false,
301 read_paths: None,
302 }
303 }
304
305 pub fn touch(&mut self) {
306 self.updated_at = now_secs();
307 }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312pub struct RegionEntrySnapshot {
313 pub content: String,
314 pub tokens: usize,
315 #[serde(default)]
318 pub kind: crate::region::EntryKind,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub metadata: Option<serde_json::Value>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub key: Option<String>,
324 #[serde(default)]
336 pub taint: crate::taint::TaintLevel,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
341pub struct RegionSnapshot {
342 pub name: String,
343 pub kind: String,
345 pub current_tokens: usize,
346 pub max_tokens: usize,
347 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub entries: Vec<RegionEntrySnapshot>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
354pub struct ContextSnapshot {
355 pub stage_name: String,
356 pub total_tokens: usize,
357 pub max_tokens: usize,
358 pub regions: Vec<RegionSnapshot>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
363#[serde(rename_all = "snake_case")]
364pub enum StageRunStatus {
365 Pending,
366 Active,
367 WaitingInput,
368 Complete,
369 Error,
370}
371
372impl std::fmt::Display for StageRunStatus {
373 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374 match self {
375 StageRunStatus::Pending => write!(f, "Pending"),
376 StageRunStatus::Active => write!(f, "Active"),
377 StageRunStatus::WaitingInput => write!(f, "WaitingInput"),
378 StageRunStatus::Complete => write!(f, "Complete"),
379 StageRunStatus::Error => write!(f, "Error"),
380 }
381 }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct StageRecord {
387 pub name: String,
388 pub index: usize,
389 pub status: StageRunStatus,
390 pub prompt_tokens: usize,
391 pub completion_tokens: usize,
392 #[serde(default)]
394 pub cached_tokens: usize,
395 pub started_at: Option<i64>,
397 pub ended_at: Option<i64>,
399}
400
401impl StageRecord {
402 pub fn new(name: String, index: usize) -> Self {
403 Self {
404 name,
405 index,
406 status: StageRunStatus::Pending,
407 prompt_tokens: 0,
408 completion_tokens: 0,
409 cached_tokens: 0,
410 started_at: None,
411 ended_at: None,
412 }
413 }
414}
415
416fn now_secs() -> i64 {
418 SystemTime::now()
419 .duration_since(UNIX_EPOCH)
420 .map(|d| d.as_secs() as i64)
421 .unwrap_or(0)
422}
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 fn sample_meta() -> RunMeta {
428 RunMeta::new(
429 "run-1".to_string(),
430 "agent".to_string(),
431 "/agents/agent".to_string(),
432 "do the thing".to_string(),
433 Some("claude-sonnet-4-6".to_string()),
434 "/work".to_string(),
435 3,
436 )
437 }
438
439 #[test]
443 fn redacted_drops_the_callback_secret_and_keeps_everything_else() {
444 let mut m = sample_meta();
445 m.callback_secret = Some("shhh".to_string());
446 m.callback_url = Some("https://example.com/hook".to_string());
447
448 let r = m.redacted();
449 assert_eq!(r.callback_secret, None);
450 assert_eq!(r.callback_url.as_deref(), Some("https://example.com/hook"));
453 assert_eq!(r.run_id, m.run_id);
454 assert_eq!(r.task, m.task);
455
456 let json = serde_json::to_string(&r).unwrap();
460 assert!(!json.contains("shhh"), "{json}");
461
462 assert_eq!(m.callback_secret.as_deref(), Some("shhh"));
465 }
466
467 #[test]
468 fn run_meta_new_sets_defaults() {
469 let m = sample_meta();
470 assert_eq!(m.run_id, "run-1");
471 assert_eq!(m.agent_name, "agent");
472 assert_eq!(m.agent_path, "/agents/agent");
473 assert_eq!(m.task, "do the thing");
474 assert_eq!(m.model.as_deref(), Some("claude-sonnet-4-6"));
475 assert_eq!(m.workdir, "/work");
476 assert_eq!(m.num_stages, 3);
477 assert_eq!(m.pid, 0);
478 assert_eq!(m.status, RunStatus::Starting);
479 assert_eq!(m.stage_index, 0);
480 assert_eq!(m.iteration, 0);
481 assert_eq!(m.prompt_tokens, 0);
482 assert_eq!(m.completion_tokens, 0);
483 assert_eq!(m.cached_tokens, 0);
484 assert_eq!(m.cache_write_tokens, 0);
485 assert_eq!(m.tool_calls, 0);
486 assert!(m.error.is_none());
487 assert!(m.title.is_none());
488 assert!(m.metadata.is_empty());
489 assert!(m.callback_url.is_none());
490 assert!(m.callback_secret.is_none());
491 assert!(m.parent_run_id.is_none());
492 assert!(m.children.is_empty());
493 assert_eq!(m.depth, 0);
494 assert_eq!(m.max_child_depth, 0);
495 assert!(m.current_stage.is_empty());
496 assert_eq!(m.started_at, m.updated_at);
497 }
498
499 #[test]
500 fn run_meta_touch_advances_updated_at() {
501 let mut m = sample_meta();
502 m.updated_at = 0;
503 m.touch();
504 assert!(m.updated_at > 0);
505 }
506
507 #[test]
508 fn run_meta_serde_roundtrip() {
509 let mut m = sample_meta();
510 m.status = RunStatus::Running;
511 m.metadata.insert("k".to_string(), "v".to_string());
512 m.title = Some("A title".to_string());
513 m.callback_secret = Some("shh".to_string());
514 m.parent_run_id = Some("parent-1".to_string());
515 m.children = vec!["child-a".to_string(), "child-b".to_string()];
516 m.depth = 2;
517 m.max_child_depth = 5;
518 let json = serde_json::to_string(&m).unwrap();
519 let back: RunMeta = serde_json::from_str(&json).unwrap();
520 assert_eq!(back.run_id, m.run_id);
521 assert_eq!(back.status, RunStatus::Running);
522 assert_eq!(back.metadata.get("k").map(String::as_str), Some("v"));
523 assert_eq!(back.title.as_deref(), Some("A title"));
524 assert_eq!(back.callback_secret.as_deref(), Some("shh"));
525 assert_eq!(back.parent_run_id.as_deref(), Some("parent-1"));
526 assert_eq!(
527 back.children,
528 vec!["child-a".to_string(), "child-b".to_string()]
529 );
530 assert_eq!(back.depth, 2);
531 assert_eq!(back.max_child_depth, 5);
532 }
533
534 #[test]
535 fn run_status_display_all_variants() {
536 assert_eq!(RunStatus::Starting.to_string(), "Starting");
537 assert_eq!(RunStatus::Running.to_string(), "Running");
538 assert_eq!(RunStatus::WaitingInput.to_string(), "WaitingInput");
539 assert_eq!(RunStatus::Complete.to_string(), "Complete");
540 assert_eq!(
541 RunStatus::CompleteInteractive.to_string(),
542 "CompleteInteractive"
543 );
544 assert_eq!(RunStatus::Paused.to_string(), "Paused");
545 assert_eq!(RunStatus::Error.to_string(), "Error");
546 assert_eq!(RunStatus::Cancelled.to_string(), "Cancelled");
547 }
548
549 #[test]
550 fn run_status_serde_snake_case_roundtrip() {
551 for s in [
552 RunStatus::Starting,
553 RunStatus::Running,
554 RunStatus::WaitingInput,
555 RunStatus::Complete,
556 RunStatus::CompleteInteractive,
557 RunStatus::Paused,
558 RunStatus::Error,
559 RunStatus::Cancelled,
560 ] {
561 let json = serde_json::to_string(&s).unwrap();
562 let back: RunStatus = serde_json::from_str(&json).unwrap();
563 assert_eq!(back, s);
564 }
565 assert_eq!(
566 serde_json::to_string(&RunStatus::WaitingInput).unwrap(),
567 "\"waiting_input\""
568 );
569 assert_eq!(
570 serde_json::to_string(&RunStatus::Paused).unwrap(),
571 "\"paused\""
572 );
573 }
574
575 #[test]
576 fn context_snapshot_serde_roundtrip() {
577 let snap = ContextSnapshot {
578 stage_name: "plan".to_string(),
579 total_tokens: 42,
580 max_tokens: 100,
581 regions: vec![RegionSnapshot {
582 name: "history".to_string(),
583 kind: "sliding".to_string(),
584 current_tokens: 10,
585 max_tokens: 50,
586 entries: vec![RegionEntrySnapshot {
587 content: "hi".to_string(),
588 tokens: 1,
589 kind: crate::region::EntryKind::UserMessage,
590 metadata: Some(serde_json::json!({"a": 1})),
591 key: Some("k".to_string()),
592 taint: Default::default(),
593 }],
594 }],
595 };
596 let json = serde_json::to_string(&snap).unwrap();
597 let back: ContextSnapshot = serde_json::from_str(&json).unwrap();
598 assert_eq!(back.stage_name, "plan");
599 assert_eq!(back.regions.len(), 1);
600 assert_eq!(back.regions[0].entries.len(), 1);
601 assert_eq!(back.regions[0].entries[0].content, "hi");
602 assert_eq!(back.regions[0].entries[0].key.as_deref(), Some("k"));
603 }
604
605 #[test]
606 fn region_snapshot_skips_empty_entries_in_json() {
607 let snap = RegionSnapshot {
608 name: "r".to_string(),
609 kind: "pinned".to_string(),
610 current_tokens: 0,
611 max_tokens: 0,
612 entries: vec![],
613 };
614 let json = serde_json::to_string(&snap).unwrap();
615 assert!(!json.contains("entries"));
616 }
617
618 #[test]
619 fn stage_run_status_display_all_variants() {
620 assert_eq!(StageRunStatus::Pending.to_string(), "Pending");
621 assert_eq!(StageRunStatus::Active.to_string(), "Active");
622 assert_eq!(StageRunStatus::WaitingInput.to_string(), "WaitingInput");
623 assert_eq!(StageRunStatus::Complete.to_string(), "Complete");
624 assert_eq!(StageRunStatus::Error.to_string(), "Error");
625 }
626
627 #[test]
628 fn run_flags_record_modification_dedups_paths_and_caps_the_list() {
629 let mut flags = RunFlags::default();
630 flags.record_modification("src/a.rs");
631 flags.record_modification("src/a.rs");
632 flags.record_modification("src/b.rs");
633 assert_eq!(flags.modified_file_count, 3);
634 assert_eq!(flags.modified_files, vec!["src/a.rs", "src/b.rs"]);
635
636 for i in 0..MAX_TRACKED_MODIFIED_FILES {
639 flags.record_modification(&format!("f{i}.rs"));
640 }
641 assert_eq!(flags.modified_files.len(), MAX_TRACKED_MODIFIED_FILES);
642 assert_eq!(flags.modified_file_count, 3 + MAX_TRACKED_MODIFIED_FILES);
643 }
644
645 #[test]
646 fn run_meta_flags_default_for_older_files() {
647 let mut meta = RunMeta::new(
649 "r".to_string(),
650 "a".to_string(),
651 "/p".to_string(),
652 "t".to_string(),
653 None,
654 "/w".to_string(),
655 1,
656 );
657 meta.flags.empty_output = true;
658 let mut json = serde_json::to_value(&meta).unwrap();
662 json.as_object_mut().unwrap().remove("flags").unwrap();
663 assert!(!json.to_string().contains("flags"));
664 let back: RunMeta = serde_json::from_value(json).unwrap();
665 assert_eq!(back.flags, RunFlags::default());
666 }
667
668 #[test]
669 fn stage_record_new_and_serde_roundtrip() {
670 let rec = StageRecord::new("analyze".to_string(), 2);
671 assert_eq!(rec.name, "analyze");
672 assert_eq!(rec.index, 2);
673 assert_eq!(rec.status, StageRunStatus::Pending);
674 assert_eq!(rec.prompt_tokens, 0);
675 assert_eq!(rec.completion_tokens, 0);
676 assert_eq!(rec.cached_tokens, 0);
677 assert!(rec.started_at.is_none());
678 assert!(rec.ended_at.is_none());
679
680 let json = serde_json::to_string(&rec).unwrap();
681 let back: StageRecord = serde_json::from_str(&json).unwrap();
682 assert_eq!(back.name, "analyze");
683 assert_eq!(back.status, StageRunStatus::Pending);
684 }
685}