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    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/// Metadata for a single background agent run.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44pub struct RunMeta {
45    pub run_id: String,
46    pub agent_name: String,
47    /// Absolute path to the agent manifest directory
48    pub agent_path: String,
49    pub task: String,
50    pub model: Option<String>,
51    /// PID of the worker process
52    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    /// Cumulative tokens read from provider cache.
61    #[serde(default)]
62    pub cached_tokens: usize,
63    /// Cumulative tokens written to provider cache.
64    #[serde(default)]
65    pub cache_write_tokens: usize,
66    /// Total number of tool calls made across all iterations.
67    #[serde(default)]
68    pub tool_calls: usize,
69    /// Absolute path to the working directory for tool execution
70    pub workdir: String,
71    /// Unix timestamp (seconds)
72    pub started_at: i64,
73    /// Unix timestamp (seconds)
74    pub updated_at: i64,
75    pub error: Option<String>,
76    /// Short human-readable title generated from the task prompt (None until generated).
77    #[serde(default)]
78    pub title: Option<String>,
79    /// Custom key-value pairs from the spawn request (API metadata).
80    #[serde(default)]
81    pub metadata: HashMap<String, String>,
82    /// Webhook URL to POST on agent completion/error.
83    #[serde(default)]
84    pub callback_url: Option<String>,
85    /// Optional shared secret used to HMAC-SHA256 sign the webhook body
86    /// (`X-Leviath-Signature` header) so the receiver can verify authenticity.
87    ///
88    /// Persisted, because the daemon must still be able to sign a webhook for a
89    /// run it reloaded after a restart. **Never serve it** - strip it with
90    /// [`RunMeta::redacted`] before any of this struct leaves the process. See
91    /// that method for what went wrong.
92    #[serde(default)]
93    pub callback_secret: Option<String>,
94    /// Links sub-agent runs to their parent run.
95    #[serde(default)]
96    pub parent_run_id: Option<String>,
97    /// Run-ids of this agent's direct sub-agents (sub-agent-tool spawns and
98    /// fan-out workers). Persisted so the daemon can rebuild the exact
99    /// parent→children tree on restart rather than reload children as orphans.
100    #[serde(default)]
101    pub children: Vec<String>,
102    /// This agent's depth in the sub-agent tree (0 for a top-level run).
103    /// Persisted so a reloaded child enforces its remaining spawn-depth budget.
104    #[serde(default)]
105    pub depth: usize,
106    /// The sub-agent depth cap this agent imposes on its own children
107    /// (0 when it has none). Restores `SubAgentChildren::max_child_depth`.
108    #[serde(default)]
109    pub max_child_depth: usize,
110    /// Why this run may have produced nothing useful - see [`RunFlags`].
111    #[serde(default)]
112    pub flags: RunFlags,
113}
114
115/// Post-hoc diagnosis of a run's productivity, persisted in `meta.json` so a
116/// harness (or the dashboard) can tell an empty run from a successful one
117/// without inspecting the workspace or parsing logs.
118///
119/// The motivating failure: 13/300 SWE-bench runs completed their whole stage
120/// pipeline and produced no file changes at all. Nothing on disk said so, or
121/// said why.
122#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
123pub struct RunFlags {
124    /// Paths passed to file-modifying tools that succeeded, in first-touch
125    /// order. Capped at [`MAX_TRACKED_MODIFIED_FILES`]; `modified_file_count`
126    /// keeps the true total.
127    #[serde(default)]
128    pub modified_files: Vec<String>,
129    /// Total successful file-modifying tool calls across the run (uncapped).
130    #[serde(default)]
131    pub modified_file_count: usize,
132    /// The run reached a terminal status having modified nothing.
133    #[serde(default)]
134    pub empty_output: bool,
135    /// How many stages exhausted their `max_iterations`.
136    #[serde(default)]
137    pub max_iterations_hit: usize,
138    /// How many transitions proceeded past an unsatisfied gate because the
139    /// gate's re-run budget ran out.
140    #[serde(default)]
141    pub gates_forced: usize,
142    /// The working directory disappeared mid-run.
143    #[serde(default)]
144    pub workspace_lost: bool,
145}
146
147/// How many distinct modified paths [`RunFlags`] records before it stops
148/// growing (the count keeps rising). Bounds `meta.json` for a long run.
149pub const MAX_TRACKED_MODIFIED_FILES: usize = 200;
150
151impl RunFlags {
152    /// Record a successful modifying tool call on `path`.
153    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    /// This run's metadata with the webhook signing secret removed, for anything
165    /// that leaves the process.
166    ///
167    /// `GET /api/agents`, `/api/agents/{id}` and `/api/agents/{id}/children` all
168    /// serialized `RunMeta` whole, so any holder of the API token could read
169    /// every run's `callback_secret` - the key that authenticates Leviath's
170    /// webhooks to their receivers. Mirrors the `RedactedConfig` pattern the
171    /// `/api/config` handler already uses correctly.
172    ///
173    /// Returns an owned copy rather than mutating in place so a caller cannot
174    /// accidentally redact the record the daemon still needs for signing.
175    #[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/// One content entry within a region, captured at snapshot time.
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233pub struct RegionEntrySnapshot {
234    pub content: String,
235    pub tokens: usize,
236    /// The entry's role/kind, so a snapshot round-trips faithfully when the
237    /// daemon reloads it on restart. Defaults to `Text` for older snapshots.
238    #[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    /// Key for HashMap region entries (file paths, section names, etc.)
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub key: Option<String>,
245    /// How sensitive this entry is.
246    ///
247    /// Persisted because taint was not, and a restore that dropped it silently
248    /// disarmed the gate: the reloaded run re-enabled taint tracking, found
249    /// every region back at `Public`, and let outbound tools through that had
250    /// been blocked a moment earlier. Any restart, crash-recovery, `resume`, or
251    /// page-in did it.
252    ///
253    /// Defaults to `Public` for snapshots written before this field existed -
254    /// the same value they were being restored with anyway, so nothing is worse
255    /// than it was, and new runs are correct from their first write.
256    #[serde(default)]
257    pub taint: crate::taint::TaintLevel,
258}
259
260/// Per-region token snapshot written by the background worker after each inference.
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
262pub struct RegionSnapshot {
263    pub name: String,
264    /// Stringified kind: "pinned", "temporary", "clearable", "sliding", "compacting", "history"
265    pub kind: String,
266    pub current_tokens: usize,
267    pub max_tokens: usize,
268    /// Actual content entries stored in this region (empty for zero-token regions).
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub entries: Vec<RegionEntrySnapshot>,
271}
272
273/// Snapshot of the full context window, written to `context.json` alongside `meta.json`.
274#[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/// Status of an individual stage within a run.
283#[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/// Metadata record for a single stage within a run.
306#[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    /// Tokens read from provider cache in this stage.
314    #[serde(default)]
315    pub cached_tokens: usize,
316    /// Unix timestamp (seconds); None until the stage starts.
317    pub started_at: Option<i64>,
318    /// Unix timestamp (seconds); None until the stage ends.
319    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
337/// Current Unix time in seconds (saturating to 0 before the epoch).
338fn 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    /// The webhook signing secret must not survive into anything served over
361    /// the API - an unredacted meta lets `GET /api/agents` hand it to any
362    /// token holder.
363    #[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        // The URL is not a secret and stays: a caller needs to see where its own
372        // webhook was pointed.
373        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        // Serializing the redacted form must not mention it at all - a `None`
378        // that still emitted `"callback_secret": null` would be fine, but an
379        // assertion on the wire format is what a reviewer actually checks.
380        let json = serde_json::to_string(&r).unwrap();
381        assert!(!json.contains("shhh"), "{json}");
382
383        // ...and the original is untouched, because the daemon still needs it to
384        // sign the webhook for a run it reloaded after a restart.
385        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        // Past the cap the count keeps rising but the list stops growing, so a
552        // long run can't bloat meta.json.
553        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        // meta.json written before #107 has no `flags` key at all.
563        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}