Skip to main content

leviath_core/
run_meta.rs

1//! Plain, serializable run-state data types.
2//!
3//! These are pure data (`serde`-derived structs/enums plus trivial constructors)
4//! with no filesystem or async dependencies, so they can be named by both
5//! `leviath-cli` and the `leviath-runtime` engine. All on-disk IO for
6//! these types (reading/writing `meta.json`, run directories, snapshots, etc.)
7//! lives in `leviath_cli::runstate`.
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13/// Current status of a background run.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum RunStatus {
17    Starting,
18    Running,
19    WaitingInput,
20    Complete,
21    /// All required stages done; agent still accepts optional follow-up input.
22    /// Shown as "Complete" in the dashboard - no kill option, input still enabled.
23    CompleteInteractive,
24    /// Paused by the user; resumes on request and is restored paused after a
25    /// daemon restart.
26    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/// Metadata for a single background agent run.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct RunMeta {
49    pub run_id: String,
50    pub agent_name: String,
51    /// Absolute path to the agent manifest directory
52    pub agent_path: String,
53    pub task: String,
54    pub model: Option<String>,
55    /// Always 0. There is no worker process per run: the daemon hosts every run
56    /// as an entity in one shared world, so no run has a pid of its own.
57    ///
58    /// Kept because it is written into every `meta.json` there has ever been,
59    /// and served from `GET /api/agents`. Do not key liveness on it. `pid == 0`
60    /// is true of a run that is working, a run that has finished, and a run
61    /// nothing is driving, so a sweeper that reverts on it reverts everything.
62    /// Ask the daemon (`lev ps`) whether it is still hosting the run, and read
63    /// `status` and `last_progress_at` off disk for what became of it.
64    #[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    /// Cumulative tokens read from provider cache.
74    #[serde(default)]
75    pub cached_tokens: usize,
76    /// Cumulative tokens written to provider cache.
77    #[serde(default)]
78    pub cache_write_tokens: usize,
79    /// Total number of tool calls made across all iterations.
80    #[serde(default)]
81    pub tool_calls: usize,
82    /// Absolute path to the working directory for tool execution
83    pub workdir: String,
84    /// Unix timestamp (seconds)
85    pub started_at: i64,
86    /// Unix timestamp (seconds)
87    pub updated_at: i64,
88    /// Unix seconds when this run last actually moved: a new iteration, a new
89    /// stage, or a change of status. `None` before the first snapshot lands, and
90    /// on runs written by a daemon older than this field.
91    ///
92    /// Distinct from `updated_at`, which also advances on the 30-second
93    /// persistence heartbeat and so stays fresh on a run that is wedged. A fresh
94    /// `updated_at` is evidence the daemon is alive, and no evidence at all about
95    /// the run. Anything that ages a run must read this instead. Note that a
96    /// daemon restart resets it: a reloaded run really is re-driven from its
97    /// saved context, so it really has just moved.
98    #[serde(default)]
99    pub last_progress_at: Option<i64>,
100    pub error: Option<String>,
101    /// Short human-readable title generated from the task prompt (None until generated).
102    #[serde(default)]
103    pub title: Option<String>,
104    /// Custom key-value pairs from the spawn request (API metadata).
105    #[serde(default)]
106    pub metadata: HashMap<String, String>,
107    /// Webhook URL to POST on agent completion/error.
108    #[serde(default)]
109    pub callback_url: Option<String>,
110    /// Optional shared secret used to HMAC-SHA256 sign the webhook body
111    /// (`X-Leviath-Signature` header) so the receiver can verify authenticity.
112    ///
113    /// Persisted, because the daemon must still be able to sign a webhook for a
114    /// run it reloaded after a restart. **Never serve it** - strip it with
115    /// [`RunMeta::redacted`] before any of this struct leaves the process. See
116    /// that method for what went wrong.
117    #[serde(default)]
118    pub callback_secret: Option<String>,
119    /// Links sub-agent runs to their parent run.
120    #[serde(default)]
121    pub parent_run_id: Option<String>,
122    /// Run-ids of this agent's direct sub-agents (sub-agent-tool spawns and
123    /// fan-out workers). Persisted so the daemon can rebuild the exact
124    /// parent→children tree on restart rather than reload children as orphans.
125    #[serde(default)]
126    pub children: Vec<String>,
127    /// This agent's depth in the sub-agent tree (0 for a top-level run).
128    /// Persisted so a reloaded child enforces its remaining spawn-depth budget.
129    #[serde(default)]
130    pub depth: usize,
131    /// The sub-agent depth cap this agent imposes on its own children
132    /// (0 when it has none). Restores `SubAgentChildren::max_child_depth`.
133    #[serde(default)]
134    pub max_child_depth: usize,
135    /// Why this run may have produced nothing useful - see [`RunFlags`].
136    #[serde(default)]
137    pub flags: RunFlags,
138    /// Whether the run was launched unattended (`--yolo`), so a daemon restart
139    /// resumes it the way it was started.
140    ///
141    /// This used to be dropped on reload, on the reasoning that forgetting a
142    /// launch override can only prompt more, never less. In practice it meant a
143    /// restart silently converted an unattended run into one parked on a prompt
144    /// nobody was watching for - the operator's own consent, given at launch,
145    /// discarded by an implementation detail they never saw. Runs written before
146    /// this field existed default to attended, so nothing is escalated
147    /// retroactively.
148    #[serde(default)]
149    pub yolo: bool,
150    /// How much of the blueprint's `[read_paths]` the config granted, as
151    /// resolved at spawn. `None` for a blueprint that declared none, and for
152    /// runs written before this field existed.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub read_paths: Option<ReadPathGrantCounts>,
155}
156
157/// How many `[read_paths]` entries a run's blueprint declared, and how many of
158/// them the user's config actually granted.
159///
160/// Declaring is not granting: an ungranted entry is inert, and the reads it was
161/// meant to allow are refused. Recorded at spawn, because that is when the
162/// policy the run enforces is fixed - editing the config afterwards changes
163/// nothing for a run already in flight.
164#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
165pub struct ReadPathGrantCounts {
166    /// Entries the blueprint declares.
167    pub declared: usize,
168    /// Entries the config grants.
169    pub granted: usize,
170}
171
172/// Post-hoc diagnosis of a run's productivity, persisted in `meta.json` so a
173/// harness (or the dashboard) can tell an empty run from a successful one
174/// without inspecting the workspace or parsing logs.
175///
176/// The motivating failure: 13/300 SWE-bench runs completed their whole stage
177/// pipeline and produced no file changes at all. Nothing on disk said so, or
178/// said why.
179#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
180pub struct RunFlags {
181    /// Paths passed to file-modifying tools that succeeded, in first-touch
182    /// order. Capped at [`MAX_TRACKED_MODIFIED_FILES`]; `modified_file_count`
183    /// keeps the true total.
184    #[serde(default)]
185    pub modified_files: Vec<String>,
186    /// Total successful file-modifying tool calls across the run (uncapped).
187    #[serde(default)]
188    pub modified_file_count: usize,
189    /// The run reached a terminal status having modified nothing, and its
190    /// blueprint gave it a way to modify something. See [`Self::no_output_tools`].
191    #[serde(default)]
192    pub empty_output: bool,
193    /// No stage of the blueprint advertised a file-modifying tool, so this run
194    /// could never have produced the file changes `empty_output` looks for.
195    ///
196    /// Recorded because "modified no files" only diagnoses an agent that was
197    /// supposed to modify files. A router that spawns sub-agents, or an agent
198    /// whose answer is its text, would otherwise report itself empty on every
199    /// successful run - which is what happened in issue #192. The framework has
200    /// no basis to judge such a run, so it says nothing rather than accusing.
201    ///
202    /// This mirrors the escape the runtime's `gate_blocks` already applies per
203    /// stage: a `require_modifications` gate on a stage that advertises no
204    /// modifying tool is skipped, because it could never pass.
205    ///
206    /// Phrased negatively so the `false` that [`Default`] and `serde(default)`
207    /// produce means "was capable" - the behavior every `meta.json` written
208    /// before this field had.
209    #[serde(default)]
210    pub no_output_tools: bool,
211    /// How many stages exhausted their `max_iterations`.
212    #[serde(default)]
213    pub max_iterations_hit: usize,
214    /// How many transitions proceeded past an unsatisfied gate because the
215    /// gate's re-run budget ran out.
216    #[serde(default)]
217    pub gates_forced: usize,
218    /// The working directory disappeared mid-run.
219    #[serde(default)]
220    pub workspace_lost: bool,
221}
222
223/// How many distinct modified paths [`RunFlags`] records before it stops
224/// growing (the count keeps rising). Bounds `meta.json` for a long run.
225pub const MAX_TRACKED_MODIFIED_FILES: usize = 200;
226
227impl RunFlags {
228    /// Record a successful modifying tool call on `path`.
229    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    /// This run's metadata with the webhook signing secret removed, for anything
241    /// that leaves the process.
242    ///
243    /// `GET /api/agents`, `/api/agents/{id}` and `/api/agents/{id}/children` all
244    /// serialized `RunMeta` whole, so any holder of the API token could read
245    /// every run's `callback_secret` - the key that authenticates Leviath's
246    /// webhooks to their receivers. Mirrors the `RedactedConfig` pattern the
247    /// `/api/config` handler already uses correctly.
248    ///
249    /// Returns an owned copy rather than mutating in place so a caller cannot
250    /// accidentally redact the record the daemon still needs for signing.
251    #[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/// One content entry within a region, captured at snapshot time.
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312pub struct RegionEntrySnapshot {
313    pub content: String,
314    pub tokens: usize,
315    /// The entry's role/kind, so a snapshot round-trips faithfully when the
316    /// daemon reloads it on restart. Defaults to `Text` for older snapshots.
317    #[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    /// Key for HashMap region entries (file paths, section names, etc.)
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub key: Option<String>,
324    /// How sensitive this entry is.
325    ///
326    /// Persisted because taint was not, and a restore that dropped it silently
327    /// disarmed the gate: the reloaded run re-enabled taint tracking, found
328    /// every region back at `Public`, and let outbound tools through that had
329    /// been blocked a moment earlier. Any restart, crash-recovery, `resume`, or
330    /// page-in did it.
331    ///
332    /// Defaults to `Public` for snapshots written before this field existed -
333    /// the same value they were being restored with anyway, so nothing is worse
334    /// than it was, and new runs are correct from their first write.
335    #[serde(default)]
336    pub taint: crate::taint::TaintLevel,
337}
338
339/// Per-region token snapshot written by the background worker after each inference.
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
341pub struct RegionSnapshot {
342    pub name: String,
343    /// Stringified kind: "pinned", "temporary", "clearable", "sliding", "compacting", "history"
344    pub kind: String,
345    pub current_tokens: usize,
346    pub max_tokens: usize,
347    /// Actual content entries stored in this region (empty for zero-token regions).
348    #[serde(default, skip_serializing_if = "Vec::is_empty")]
349    pub entries: Vec<RegionEntrySnapshot>,
350}
351
352/// Snapshot of the full context window, written to `context.json` alongside `meta.json`.
353#[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/// Status of an individual stage within a run.
362#[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/// Metadata record for a single stage within a run.
385#[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    /// Tokens read from provider cache in this stage.
393    #[serde(default)]
394    pub cached_tokens: usize,
395    /// Unix timestamp (seconds); None until the stage starts.
396    pub started_at: Option<i64>,
397    /// Unix timestamp (seconds); None until the stage ends.
398    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
416/// Current Unix time in seconds (saturating to 0 before the epoch).
417fn 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    /// The webhook signing secret must not survive into anything served over
440    /// the API - an unredacted meta lets `GET /api/agents` hand it to any
441    /// token holder.
442    #[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        // The URL is not a secret and stays: a caller needs to see where its own
451        // webhook was pointed.
452        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        // Serializing the redacted form must not mention it at all - a `None`
457        // that still emitted `"callback_secret": null` would be fine, but an
458        // assertion on the wire format is what a reviewer actually checks.
459        let json = serde_json::to_string(&r).unwrap();
460        assert!(!json.contains("shhh"), "{json}");
461
462        // ...and the original is untouched, because the daemon still needs it to
463        // sign the webhook for a run it reloaded after a restart.
464        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        // Past the cap the count keeps rising but the list stops growing, so a
637        // long run can't bloat meta.json.
638        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        // meta.json written before #107 has no `flags` key at all.
648        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        // Drop the key structurally rather than by string surgery: a literal
659        // spelling of the serialized flags silently stops matching the moment a
660        // field is added, and the test then passes for the wrong reason.
661        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}