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 Error,
25 Cancelled,
26}
27
28impl std::fmt::Display for RunStatus {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 RunStatus::Starting => write!(f, "Starting"),
32 RunStatus::Running => write!(f, "Running"),
33 RunStatus::WaitingInput => write!(f, "WaitingInput"),
34 RunStatus::Complete => write!(f, "Complete"),
35 RunStatus::CompleteInteractive => write!(f, "CompleteInteractive"),
36 RunStatus::Error => write!(f, "Error"),
37 RunStatus::Cancelled => write!(f, "Cancelled"),
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44pub struct RunMeta {
45 pub run_id: String,
46 pub agent_name: String,
47 pub agent_path: String,
49 pub task: String,
50 pub model: Option<String>,
51 pub pid: u32,
53 pub status: RunStatus,
54 pub current_stage: String,
55 pub stage_index: usize,
56 pub num_stages: usize,
57 pub iteration: usize,
58 pub prompt_tokens: usize,
59 pub completion_tokens: usize,
60 #[serde(default)]
62 pub cached_tokens: usize,
63 #[serde(default)]
65 pub cache_write_tokens: usize,
66 #[serde(default)]
68 pub tool_calls: usize,
69 pub workdir: String,
71 pub started_at: i64,
73 pub updated_at: i64,
75 pub error: Option<String>,
76 #[serde(default)]
78 pub title: Option<String>,
79 #[serde(default)]
81 pub metadata: HashMap<String, String>,
82 #[serde(default)]
84 pub callback_url: Option<String>,
85 #[serde(default)]
93 pub callback_secret: Option<String>,
94 #[serde(default)]
96 pub parent_run_id: Option<String>,
97 #[serde(default)]
101 pub children: Vec<String>,
102 #[serde(default)]
105 pub depth: usize,
106 #[serde(default)]
109 pub max_child_depth: usize,
110 #[serde(default)]
112 pub flags: RunFlags,
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
123pub struct RunFlags {
124 #[serde(default)]
128 pub modified_files: Vec<String>,
129 #[serde(default)]
131 pub modified_file_count: usize,
132 #[serde(default)]
134 pub empty_output: bool,
135 #[serde(default)]
137 pub max_iterations_hit: usize,
138 #[serde(default)]
141 pub gates_forced: usize,
142 #[serde(default)]
144 pub workspace_lost: bool,
145}
146
147pub const MAX_TRACKED_MODIFIED_FILES: usize = 200;
150
151impl RunFlags {
152 pub fn record_modification(&mut self, path: &str) {
154 self.modified_file_count += 1;
155 if self.modified_files.len() < MAX_TRACKED_MODIFIED_FILES
156 && !self.modified_files.iter().any(|p| p == path)
157 {
158 self.modified_files.push(path.to_string());
159 }
160 }
161}
162
163impl RunMeta {
164 #[must_use]
176 pub fn redacted(&self) -> Self {
177 Self {
178 callback_secret: None,
179 ..self.clone()
180 }
181 }
182
183 pub fn new(
184 run_id: String,
185 agent_name: String,
186 agent_path: String,
187 task: String,
188 model: Option<String>,
189 workdir: String,
190 num_stages: usize,
191 ) -> Self {
192 let now = now_secs();
193 Self {
194 run_id,
195 agent_name,
196 agent_path,
197 task,
198 model,
199 pid: 0,
200 status: RunStatus::Starting,
201 current_stage: String::new(),
202 stage_index: 0,
203 num_stages,
204 iteration: 0,
205 prompt_tokens: 0,
206 completion_tokens: 0,
207 cached_tokens: 0,
208 cache_write_tokens: 0,
209 tool_calls: 0,
210 workdir,
211 started_at: now,
212 updated_at: now,
213 error: None,
214 title: None,
215 metadata: HashMap::new(),
216 callback_url: None,
217 callback_secret: None,
218 parent_run_id: None,
219 children: Vec::new(),
220 depth: 0,
221 max_child_depth: 0,
222 flags: RunFlags::default(),
223 }
224 }
225
226 pub fn touch(&mut self) {
227 self.updated_at = now_secs();
228 }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233pub struct RegionEntrySnapshot {
234 pub content: String,
235 pub tokens: usize,
236 #[serde(default)]
239 pub kind: crate::region::EntryKind,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub metadata: Option<serde_json::Value>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub key: Option<String>,
245 #[serde(default)]
257 pub taint: crate::taint::TaintLevel,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
262pub struct RegionSnapshot {
263 pub name: String,
264 pub kind: String,
266 pub current_tokens: usize,
267 pub max_tokens: usize,
268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub entries: Vec<RegionEntrySnapshot>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
275pub struct ContextSnapshot {
276 pub stage_name: String,
277 pub total_tokens: usize,
278 pub max_tokens: usize,
279 pub regions: Vec<RegionSnapshot>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284#[serde(rename_all = "snake_case")]
285pub enum StageRunStatus {
286 Pending,
287 Active,
288 WaitingInput,
289 Complete,
290 Error,
291}
292
293impl std::fmt::Display for StageRunStatus {
294 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 match self {
296 StageRunStatus::Pending => write!(f, "Pending"),
297 StageRunStatus::Active => write!(f, "Active"),
298 StageRunStatus::WaitingInput => write!(f, "WaitingInput"),
299 StageRunStatus::Complete => write!(f, "Complete"),
300 StageRunStatus::Error => write!(f, "Error"),
301 }
302 }
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct StageRecord {
308 pub name: String,
309 pub index: usize,
310 pub status: StageRunStatus,
311 pub prompt_tokens: usize,
312 pub completion_tokens: usize,
313 #[serde(default)]
315 pub cached_tokens: usize,
316 pub started_at: Option<i64>,
318 pub ended_at: Option<i64>,
320}
321
322impl StageRecord {
323 pub fn new(name: String, index: usize) -> Self {
324 Self {
325 name,
326 index,
327 status: StageRunStatus::Pending,
328 prompt_tokens: 0,
329 completion_tokens: 0,
330 cached_tokens: 0,
331 started_at: None,
332 ended_at: None,
333 }
334 }
335}
336
337fn now_secs() -> i64 {
339 SystemTime::now()
340 .duration_since(UNIX_EPOCH)
341 .map(|d| d.as_secs() as i64)
342 .unwrap_or(0)
343}
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn sample_meta() -> RunMeta {
349 RunMeta::new(
350 "run-1".to_string(),
351 "agent".to_string(),
352 "/agents/agent".to_string(),
353 "do the thing".to_string(),
354 Some("claude-sonnet-4-6".to_string()),
355 "/work".to_string(),
356 3,
357 )
358 }
359
360 #[test]
364 fn redacted_drops_the_callback_secret_and_keeps_everything_else() {
365 let mut m = sample_meta();
366 m.callback_secret = Some("shhh".to_string());
367 m.callback_url = Some("https://example.com/hook".to_string());
368
369 let r = m.redacted();
370 assert_eq!(r.callback_secret, None);
371 assert_eq!(r.callback_url.as_deref(), Some("https://example.com/hook"));
374 assert_eq!(r.run_id, m.run_id);
375 assert_eq!(r.task, m.task);
376
377 let json = serde_json::to_string(&r).unwrap();
381 assert!(!json.contains("shhh"), "{json}");
382
383 assert_eq!(m.callback_secret.as_deref(), Some("shhh"));
386 }
387
388 #[test]
389 fn run_meta_new_sets_defaults() {
390 let m = sample_meta();
391 assert_eq!(m.run_id, "run-1");
392 assert_eq!(m.agent_name, "agent");
393 assert_eq!(m.agent_path, "/agents/agent");
394 assert_eq!(m.task, "do the thing");
395 assert_eq!(m.model.as_deref(), Some("claude-sonnet-4-6"));
396 assert_eq!(m.workdir, "/work");
397 assert_eq!(m.num_stages, 3);
398 assert_eq!(m.pid, 0);
399 assert_eq!(m.status, RunStatus::Starting);
400 assert_eq!(m.stage_index, 0);
401 assert_eq!(m.iteration, 0);
402 assert_eq!(m.prompt_tokens, 0);
403 assert_eq!(m.completion_tokens, 0);
404 assert_eq!(m.cached_tokens, 0);
405 assert_eq!(m.cache_write_tokens, 0);
406 assert_eq!(m.tool_calls, 0);
407 assert!(m.error.is_none());
408 assert!(m.title.is_none());
409 assert!(m.metadata.is_empty());
410 assert!(m.callback_url.is_none());
411 assert!(m.callback_secret.is_none());
412 assert!(m.parent_run_id.is_none());
413 assert!(m.children.is_empty());
414 assert_eq!(m.depth, 0);
415 assert_eq!(m.max_child_depth, 0);
416 assert!(m.current_stage.is_empty());
417 assert_eq!(m.started_at, m.updated_at);
418 }
419
420 #[test]
421 fn run_meta_touch_advances_updated_at() {
422 let mut m = sample_meta();
423 m.updated_at = 0;
424 m.touch();
425 assert!(m.updated_at > 0);
426 }
427
428 #[test]
429 fn run_meta_serde_roundtrip() {
430 let mut m = sample_meta();
431 m.status = RunStatus::Running;
432 m.metadata.insert("k".to_string(), "v".to_string());
433 m.title = Some("A title".to_string());
434 m.callback_secret = Some("shh".to_string());
435 m.parent_run_id = Some("parent-1".to_string());
436 m.children = vec!["child-a".to_string(), "child-b".to_string()];
437 m.depth = 2;
438 m.max_child_depth = 5;
439 let json = serde_json::to_string(&m).unwrap();
440 let back: RunMeta = serde_json::from_str(&json).unwrap();
441 assert_eq!(back.run_id, m.run_id);
442 assert_eq!(back.status, RunStatus::Running);
443 assert_eq!(back.metadata.get("k").map(String::as_str), Some("v"));
444 assert_eq!(back.title.as_deref(), Some("A title"));
445 assert_eq!(back.callback_secret.as_deref(), Some("shh"));
446 assert_eq!(back.parent_run_id.as_deref(), Some("parent-1"));
447 assert_eq!(
448 back.children,
449 vec!["child-a".to_string(), "child-b".to_string()]
450 );
451 assert_eq!(back.depth, 2);
452 assert_eq!(back.max_child_depth, 5);
453 }
454
455 #[test]
456 fn run_status_display_all_variants() {
457 assert_eq!(RunStatus::Starting.to_string(), "Starting");
458 assert_eq!(RunStatus::Running.to_string(), "Running");
459 assert_eq!(RunStatus::WaitingInput.to_string(), "WaitingInput");
460 assert_eq!(RunStatus::Complete.to_string(), "Complete");
461 assert_eq!(
462 RunStatus::CompleteInteractive.to_string(),
463 "CompleteInteractive"
464 );
465 assert_eq!(RunStatus::Error.to_string(), "Error");
466 assert_eq!(RunStatus::Cancelled.to_string(), "Cancelled");
467 }
468
469 #[test]
470 fn run_status_serde_snake_case_roundtrip() {
471 for s in [
472 RunStatus::Starting,
473 RunStatus::Running,
474 RunStatus::WaitingInput,
475 RunStatus::Complete,
476 RunStatus::CompleteInteractive,
477 RunStatus::Error,
478 RunStatus::Cancelled,
479 ] {
480 let json = serde_json::to_string(&s).unwrap();
481 let back: RunStatus = serde_json::from_str(&json).unwrap();
482 assert_eq!(back, s);
483 }
484 assert_eq!(
485 serde_json::to_string(&RunStatus::WaitingInput).unwrap(),
486 "\"waiting_input\""
487 );
488 }
489
490 #[test]
491 fn context_snapshot_serde_roundtrip() {
492 let snap = ContextSnapshot {
493 stage_name: "plan".to_string(),
494 total_tokens: 42,
495 max_tokens: 100,
496 regions: vec![RegionSnapshot {
497 name: "history".to_string(),
498 kind: "sliding".to_string(),
499 current_tokens: 10,
500 max_tokens: 50,
501 entries: vec![RegionEntrySnapshot {
502 content: "hi".to_string(),
503 tokens: 1,
504 kind: crate::region::EntryKind::UserMessage,
505 metadata: Some(serde_json::json!({"a": 1})),
506 key: Some("k".to_string()),
507 taint: Default::default(),
508 }],
509 }],
510 };
511 let json = serde_json::to_string(&snap).unwrap();
512 let back: ContextSnapshot = serde_json::from_str(&json).unwrap();
513 assert_eq!(back.stage_name, "plan");
514 assert_eq!(back.regions.len(), 1);
515 assert_eq!(back.regions[0].entries.len(), 1);
516 assert_eq!(back.regions[0].entries[0].content, "hi");
517 assert_eq!(back.regions[0].entries[0].key.as_deref(), Some("k"));
518 }
519
520 #[test]
521 fn region_snapshot_skips_empty_entries_in_json() {
522 let snap = RegionSnapshot {
523 name: "r".to_string(),
524 kind: "pinned".to_string(),
525 current_tokens: 0,
526 max_tokens: 0,
527 entries: vec![],
528 };
529 let json = serde_json::to_string(&snap).unwrap();
530 assert!(!json.contains("entries"));
531 }
532
533 #[test]
534 fn stage_run_status_display_all_variants() {
535 assert_eq!(StageRunStatus::Pending.to_string(), "Pending");
536 assert_eq!(StageRunStatus::Active.to_string(), "Active");
537 assert_eq!(StageRunStatus::WaitingInput.to_string(), "WaitingInput");
538 assert_eq!(StageRunStatus::Complete.to_string(), "Complete");
539 assert_eq!(StageRunStatus::Error.to_string(), "Error");
540 }
541
542 #[test]
543 fn run_flags_record_modification_dedups_paths_and_caps_the_list() {
544 let mut flags = RunFlags::default();
545 flags.record_modification("src/a.rs");
546 flags.record_modification("src/a.rs");
547 flags.record_modification("src/b.rs");
548 assert_eq!(flags.modified_file_count, 3);
549 assert_eq!(flags.modified_files, vec!["src/a.rs", "src/b.rs"]);
550
551 for i in 0..MAX_TRACKED_MODIFIED_FILES {
554 flags.record_modification(&format!("f{i}.rs"));
555 }
556 assert_eq!(flags.modified_files.len(), MAX_TRACKED_MODIFIED_FILES);
557 assert_eq!(flags.modified_file_count, 3 + MAX_TRACKED_MODIFIED_FILES);
558 }
559
560 #[test]
561 fn run_meta_flags_default_for_older_files() {
562 let mut meta = RunMeta::new(
564 "r".to_string(),
565 "a".to_string(),
566 "/p".to_string(),
567 "t".to_string(),
568 None,
569 "/w".to_string(),
570 1,
571 );
572 meta.flags.empty_output = true;
573 let json = serde_json::to_string(&meta).unwrap();
574 let stripped = json.replace(r#","flags":{"modified_files":[],"modified_file_count":0,"empty_output":true,"max_iterations_hit":0,"gates_forced":0,"workspace_lost":false}"#, "");
575 assert!(!stripped.contains("flags"));
576 let back: RunMeta = serde_json::from_str(&stripped).unwrap();
577 assert_eq!(back.flags, RunFlags::default());
578 }
579
580 #[test]
581 fn stage_record_new_and_serde_roundtrip() {
582 let rec = StageRecord::new("analyze".to_string(), 2);
583 assert_eq!(rec.name, "analyze");
584 assert_eq!(rec.index, 2);
585 assert_eq!(rec.status, StageRunStatus::Pending);
586 assert_eq!(rec.prompt_tokens, 0);
587 assert_eq!(rec.completion_tokens, 0);
588 assert_eq!(rec.cached_tokens, 0);
589 assert!(rec.started_at.is_none());
590 assert!(rec.ended_at.is_none());
591
592 let json = serde_json::to_string(&rec).unwrap();
593 let back: StageRecord = serde_json::from_str(&json).unwrap();
594 assert_eq!(back.name, "analyze");
595 assert_eq!(back.status, StageRunStatus::Pending);
596 }
597}