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    /// Accepted and being set up; no inference has been issued yet.
18    Starting,
19    /// Working: inferring, calling tools, or moving between stages.
20    Running,
21    /// Blocked on a person. A human-in-the-loop tool or an interaction point is
22    /// waiting for an answer, and the run holds its concurrency slot until it
23    /// gets one.
24    WaitingInput,
25    /// Finished, with nothing further to accept.
26    Complete,
27    /// All required stages done; agent still accepts optional follow-up input.
28    /// Shown as "Complete" in the dashboard - no kill option, input still enabled.
29    CompleteInteractive,
30    /// Paused by the user; resumes on request and is restored paused after a
31    /// daemon restart.
32    Paused,
33    /// Stopped by a failure. `RunMeta::error` carries what went wrong.
34    Error,
35    /// Stopped from outside, by `lev kill` or a shutting-down daemon. Distinct
36    /// from [`Error`](Self::Error): nothing went wrong, someone decided.
37    Cancelled,
38}
39
40impl std::fmt::Display for RunStatus {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            RunStatus::Starting => write!(f, "Starting"),
44            RunStatus::Running => write!(f, "Running"),
45            RunStatus::WaitingInput => write!(f, "WaitingInput"),
46            RunStatus::Complete => write!(f, "Complete"),
47            RunStatus::CompleteInteractive => write!(f, "CompleteInteractive"),
48            RunStatus::Paused => write!(f, "Paused"),
49            RunStatus::Error => write!(f, "Error"),
50            RunStatus::Cancelled => write!(f, "Cancelled"),
51        }
52    }
53}
54
55/// Metadata for a single background agent run.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct RunMeta {
58    /// Identifies the run everywhere, and names its directory under
59    /// `~/.leviath/runs/`. Assigned at spawn and never reused.
60    pub run_id: String,
61    /// The blueprint's `[agent] name`, not the file it was loaded from. Two runs
62    /// of the same agent from different paths share this.
63    pub agent_name: String,
64    /// Absolute path to the agent manifest directory
65    pub agent_path: String,
66    /// The task text the run was started with, verbatim.
67    pub task: String,
68    /// The `provider/model` actually resolved for the entry stage, or `None`
69    /// before resolution. Later stages may use a different one; this is not
70    /// rewritten to follow them.
71    pub model: Option<String>,
72    /// Always 0. There is no worker process per run: the daemon hosts every run
73    /// as an entity in one shared world, so no run has a pid of its own.
74    ///
75    /// Kept because it is written into every `meta.json` there has ever been,
76    /// and served from `GET /api/agents`. Do not key liveness on it. `pid == 0`
77    /// is true of a run that is working, a run that has finished, and a run
78    /// nothing is driving, so a sweeper that reverts on it reverts everything.
79    /// Ask the daemon (`lev ps`) whether it is still hosting the run, and read
80    /// `status` and `last_progress_at` off disk for what became of it.
81    #[serde(default)]
82    pub pid: u32,
83    /// Where the run stands. The durable counterpart to the ECS world's live
84    /// `AgentStatus`, and the one that survives a daemon restart.
85    pub status: RunStatus,
86    /// Name of the stage the run is in, matching a key under `[stages]`.
87    pub current_stage: String,
88    /// Zero-based position of `current_stage` in the blueprint's stage list.
89    /// Not a progress measure: stages can loop and revisit.
90    pub stage_index: usize,
91    /// How many stages the blueprint declares, so a reader can render
92    /// `stage_index` as "3 of 7" without loading the manifest.
93    pub num_stages: usize,
94    /// Inference turns taken in the current stage, reset on entering a new one.
95    /// Compared against the stage's `max_iterations`.
96    pub iteration: usize,
97    /// Cumulative input tokens billed across every inference this run has made,
98    /// including retries.
99    pub prompt_tokens: usize,
100    /// Cumulative output tokens billed across every inference this run has made.
101    pub completion_tokens: usize,
102    /// Cumulative tokens read from provider cache.
103    #[serde(default)]
104    pub cached_tokens: usize,
105    /// Cumulative tokens written to provider cache.
106    #[serde(default)]
107    pub cache_write_tokens: usize,
108    /// Total number of tool calls made across all iterations.
109    #[serde(default)]
110    pub tool_calls: usize,
111    /// Absolute path to the working directory for tool execution
112    pub workdir: String,
113    /// Unix timestamp (seconds)
114    pub started_at: i64,
115    /// Unix timestamp (seconds)
116    pub updated_at: i64,
117    /// Unix seconds when this run last actually moved: a new iteration, a new
118    /// stage, or a change of status. `None` before the first snapshot lands, and
119    /// on runs written by a daemon older than this field.
120    ///
121    /// Distinct from `updated_at`, which also advances on the 30-second
122    /// persistence heartbeat and so stays fresh on a run that is wedged. A fresh
123    /// `updated_at` is evidence the daemon is alive, and no evidence at all about
124    /// the run. Anything that ages a run must read this instead. Note that a
125    /// daemon restart resets it: a reloaded run really is re-driven from its
126    /// saved context, so it really has just moved.
127    #[serde(default)]
128    pub last_progress_at: Option<i64>,
129    /// What went wrong, set alongside [`RunStatus::Error`]. `None` on every
130    /// other status.
131    pub error: Option<String>,
132    /// Short human-readable title generated from the task prompt (None until generated).
133    #[serde(default)]
134    pub title: Option<String>,
135    /// Custom key-value pairs from the spawn request (API metadata).
136    #[serde(default)]
137    pub metadata: HashMap<String, String>,
138    /// Webhook URL to POST on agent completion/error.
139    #[serde(default)]
140    pub callback_url: Option<String>,
141    /// Optional shared secret used to HMAC-SHA256 sign the webhook body
142    /// (`X-Leviath-Signature` header) so the receiver can verify authenticity.
143    ///
144    /// Persisted, because the daemon must still be able to sign a webhook for a
145    /// run it reloaded after a restart. **Never serve it** - strip it with
146    /// [`RunMeta::redacted`] before any of this struct leaves the process. See
147    /// that method for what went wrong.
148    #[serde(default)]
149    pub callback_secret: Option<String>,
150    /// Links sub-agent runs to their parent run.
151    #[serde(default)]
152    pub parent_run_id: Option<String>,
153    /// Run-ids of this agent's direct sub-agents (sub-agent-tool spawns and
154    /// fan-out workers). Persisted so the daemon can rebuild the exact
155    /// parent→children tree on restart rather than reload children as orphans.
156    #[serde(default)]
157    pub children: Vec<String>,
158    /// This agent's depth in the sub-agent tree (0 for a top-level run).
159    /// Persisted so a reloaded child enforces its remaining spawn-depth budget.
160    #[serde(default)]
161    pub depth: usize,
162    /// The sub-agent depth cap this agent imposes on its own children
163    /// (0 when it has none). Restores `SubAgentChildren::max_child_depth`.
164    #[serde(default)]
165    pub max_child_depth: usize,
166    /// Why this run may have produced nothing useful - see [`RunFlags`].
167    #[serde(default)]
168    pub flags: RunFlags,
169    /// Whether the run was launched unattended (`--yolo`), so a daemon restart
170    /// resumes it the way it was started.
171    ///
172    /// This used to be dropped on reload, on the reasoning that forgetting a
173    /// launch override can only prompt more, never less. In practice it meant a
174    /// restart silently converted an unattended run into one parked on a prompt
175    /// nobody was watching for - the operator's own consent, given at launch,
176    /// discarded by an implementation detail they never saw. Runs written before
177    /// this field existed default to attended, so nothing is escalated
178    /// retroactively.
179    #[serde(default)]
180    pub yolo: bool,
181    /// How much of the blueprint's `[read_paths]` the config granted, as
182    /// resolved at spawn. `None` for a blueprint that declared none, and for
183    /// runs written before this field existed.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub read_paths: Option<ReadPathGrantCounts>,
186    /// What the agent handed back, if it submitted anything: everything about
187    /// the answer except the bytes.
188    ///
189    /// This is the run's answer, as distinct from `error` (why it failed) and
190    /// from the stage logs (what it did along the way). The content itself is
191    /// in a sidecar file beside this one, because this file is parsed for every
192    /// run on every listing and must stay small no matter how long an answer is.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub final_output: Option<crate::output::FinalOutputDescriptor>,
195    /// The output shape this run was launched asking for, when the caller
196    /// overrode the blueprint's.
197    ///
198    /// Persisted for the same reason `yolo` is: a daemon restart rebuilds the
199    /// run's spawn arguments from this file, and dropping the request would
200    /// silently revert the run to the blueprint's shape partway through. The
201    /// caller asked once and should not have to ask again.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub output_request: Option<crate::output::OutputSpec>,
204}
205
206/// How many `[read_paths]` entries a run's blueprint declared, and how many of
207/// them the user's config actually granted.
208///
209/// Declaring is not granting: an ungranted entry is inert, and the reads it was
210/// meant to allow are refused. Recorded at spawn, because that is when the
211/// policy the run enforces is fixed - editing the config afterwards changes
212/// nothing for a run already in flight.
213#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
214pub struct ReadPathGrantCounts {
215    /// Entries the blueprint declares.
216    pub declared: usize,
217    /// Entries the config grants.
218    pub granted: usize,
219}
220
221/// Post-hoc diagnosis of a run's productivity, persisted in `meta.json` so a
222/// harness (or the dashboard) can tell an empty run from a successful one
223/// without inspecting the workspace or parsing logs.
224///
225/// The motivating failure: 13/300 SWE-bench runs completed their whole stage
226/// pipeline and produced no file changes at all. Nothing on disk said so, or
227/// said why.
228#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
229pub struct RunFlags {
230    /// Paths passed to file-modifying tools that succeeded, in first-touch
231    /// order. Capped at [`MAX_TRACKED_MODIFIED_FILES`]; `modified_file_count`
232    /// keeps the true total.
233    #[serde(default)]
234    pub modified_files: Vec<String>,
235    /// Total successful file-modifying tool calls across the run (uncapped).
236    #[serde(default)]
237    pub modified_file_count: usize,
238    /// The run reached a terminal status having modified nothing, and its
239    /// blueprint gave it a way to modify something. See [`Self::no_output_tools`].
240    #[serde(default)]
241    pub empty_output: bool,
242    /// No stage of the blueprint advertised a file-modifying tool, so this run
243    /// could never have produced the file changes `empty_output` looks for.
244    ///
245    /// Recorded because "modified no files" only diagnoses an agent that was
246    /// supposed to modify files. A router that spawns sub-agents, or an agent
247    /// whose answer is its text, would otherwise report itself empty on every
248    /// successful run - which is what happened in issue #192. The framework has
249    /// no basis to judge such a run, so it says nothing rather than accusing.
250    ///
251    /// This mirrors the escape the runtime's `gate_blocks` already applies per
252    /// stage: a `require_modifications` gate on a stage that advertises no
253    /// modifying tool is skipped, because it could never pass.
254    ///
255    /// Phrased negatively so the `false` that [`Default`] and `serde(default)`
256    /// produce means "was capable" - the behavior every `meta.json` written
257    /// before this field had.
258    #[serde(default)]
259    pub no_output_tools: bool,
260    /// How many stages exhausted their `max_iterations`.
261    #[serde(default)]
262    pub max_iterations_hit: usize,
263    /// How many transitions proceeded past an unsatisfied gate because the
264    /// gate's re-run budget ran out.
265    #[serde(default)]
266    pub gates_forced: usize,
267    /// Regions declared `required` that were still empty when the stage that
268    /// owed them gave up and moved on, in the order they were abandoned.
269    ///
270    /// The mechanism re-runs the stage a bounded number of times and then
271    /// proceeds with a log line, which nothing downstream reads: a run whose
272    /// agent wrote its plan and a run where we asked twice and moved on both
273    /// finished `complete`, with the second silently missing the artifact every
274    /// later stage's prompt says to work from (#371). Names rather than a count
275    /// because knowing *which* region was abandoned is what makes it
276    /// actionable, and a run cannot abandon many.
277    #[serde(default)]
278    pub required_regions_abandoned: Vec<String>,
279    /// The working directory disappeared mid-run.
280    #[serde(default)]
281    pub workspace_lost: bool,
282    /// The run submitted a final output.
283    ///
284    /// Counts as having produced something, alongside file modifications.
285    /// Without this an agent whose whole deliverable is its answer - a
286    /// researcher, a reviewer, a router - reported itself empty on every
287    /// successful run, which is the same mistake [`Self::no_output_tools`] was
288    /// added to correct from the other direction.
289    #[serde(default)]
290    pub produced_output: bool,
291    /// How many stages transitioned without the final output they required,
292    /// because the re-run budget ran out.
293    ///
294    /// The counterpart to [`Self::gates_forced`]: the run finished, and this
295    /// says the answer it hands back may be missing.
296    #[serde(default)]
297    pub output_forced: usize,
298}
299
300/// How many distinct modified paths [`RunFlags`] records before it stops
301/// growing (the count keeps rising). Bounds `meta.json` for a long run.
302pub const MAX_TRACKED_MODIFIED_FILES: usize = 200;
303
304impl RunFlags {
305    /// Record a successful modifying tool call on `path`.
306    pub fn record_modification(&mut self, path: &str) {
307        self.modified_file_count += 1;
308        if self.modified_files.len() < MAX_TRACKED_MODIFIED_FILES
309            && !self.modified_files.iter().any(|p| p == path)
310        {
311            self.modified_files.push(path.to_string());
312        }
313    }
314}
315
316impl RunMeta {
317    /// This run's metadata with the webhook signing secret removed, for anything
318    /// that leaves the process.
319    ///
320    /// `GET /api/agents`, `/api/agents/{id}` and `/api/agents/{id}/children` all
321    /// serialized `RunMeta` whole, so any holder of the API token could read
322    /// every run's `callback_secret` - the key that authenticates Leviath's
323    /// webhooks to their receivers. Mirrors the `RedactedConfig` pattern the
324    /// `/api/config` handler already uses correctly.
325    ///
326    /// Returns an owned copy rather than mutating in place so a caller cannot
327    /// accidentally redact the record the daemon still needs for signing.
328    #[must_use]
329    pub fn redacted(&self) -> Self {
330        Self {
331            callback_secret: None,
332            ..self.clone()
333        }
334    }
335
336    /// A newly accepted run: [`RunStatus::Starting`], both timestamps now, every
337    /// counter at zero and every optional field unset.
338    ///
339    /// Only the seven values a caller genuinely knows at spawn are parameters.
340    /// Everything else is filled in by the daemon as the run proceeds, so taking
341    /// them here would invite a caller to invent a stage or a token count.
342    pub fn new(
343        run_id: String,
344        agent_name: String,
345        agent_path: String,
346        task: String,
347        model: Option<String>,
348        workdir: String,
349        num_stages: usize,
350    ) -> Self {
351        let now = now_secs();
352        Self {
353            run_id,
354            agent_name,
355            agent_path,
356            task,
357            model,
358            pid: 0,
359            status: RunStatus::Starting,
360            current_stage: String::new(),
361            stage_index: 0,
362            num_stages,
363            iteration: 0,
364            prompt_tokens: 0,
365            completion_tokens: 0,
366            cached_tokens: 0,
367            cache_write_tokens: 0,
368            tool_calls: 0,
369            workdir,
370            started_at: now,
371            updated_at: now,
372            last_progress_at: None,
373            error: None,
374            title: None,
375            metadata: HashMap::new(),
376            callback_url: None,
377            callback_secret: None,
378            parent_run_id: None,
379            children: Vec::new(),
380            depth: 0,
381            max_child_depth: 0,
382            final_output: None,
383            output_request: None,
384            flags: RunFlags::default(),
385            yolo: false,
386            read_paths: None,
387        }
388    }
389
390    /// Stamp `updated_at` with the current time.
391    ///
392    /// Deliberately does **not** touch `last_progress_at`: the 30-second
393    /// persistence heartbeat calls this, and a run that is wedged must not look
394    /// like one that just moved. See [`RunMeta::last_progress_at`].
395    pub fn touch(&mut self) {
396        self.updated_at = now_secs();
397    }
398}
399
400/// One content entry within a region, captured at snapshot time.
401#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
402pub struct RegionEntrySnapshot {
403    /// The entry's text, exactly as it sat in the live region.
404    pub content: String,
405    /// The entry's token cost as counted when it was added, carried through the
406    /// snapshot so a reload does not have to re-tokenize to rebuild budgets.
407    pub tokens: usize,
408    /// The entry's role/kind, so a snapshot round-trips faithfully when the
409    /// daemon reloads it on restart. Defaults to `Text` for older snapshots.
410    #[serde(default)]
411    pub kind: crate::region::EntryKind,
412    /// Free-form structured data an entry writer attached, passed through
413    /// untouched. Nothing in the engine interprets it.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub metadata: Option<serde_json::Value>,
416    /// Key for HashMap region entries (file paths, section names, etc.)
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub key: Option<String>,
419    /// How sensitive this entry is.
420    ///
421    /// Persisted because taint was not, and a restore that dropped it silently
422    /// disarmed the gate: the reloaded run re-enabled taint tracking, found
423    /// every region back at `Public`, and let outbound tools through that had
424    /// been blocked a moment earlier. Any restart, crash-recovery, `resume`, or
425    /// page-in did it.
426    ///
427    /// Defaults to `Public` for snapshots written before this field existed -
428    /// the same value they were being restored with anyway, so nothing is worse
429    /// than it was, and new runs are correct from their first write.
430    #[serde(default)]
431    pub taint: crate::taint::TaintLevel,
432}
433
434/// Per-region token snapshot written by the background worker after each inference.
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
436pub struct RegionSnapshot {
437    /// The region's name, matching its key under `[context.regions]`.
438    pub name: String,
439    /// Stringified kind: "pinned", "temporary", "clearable", "sliding", "compacting", "history"
440    pub kind: String,
441    /// Tokens the region held when the snapshot was taken.
442    pub current_tokens: usize,
443    /// The region's ceiling at snapshot time, already resolved against the
444    /// model in front of it, so a percentage budget appears here as a number.
445    pub max_tokens: usize,
446    /// Actual content entries stored in this region (empty for zero-token regions).
447    #[serde(default, skip_serializing_if = "Vec::is_empty")]
448    pub entries: Vec<RegionEntrySnapshot>,
449}
450
451/// Snapshot of the full context window, written to `context.json` alongside `meta.json`.
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
453pub struct ContextSnapshot {
454    /// The stage the run was in when this was written.
455    pub stage_name: String,
456    /// Tokens held across every region, which is what the next request costs
457    /// before the model's reply.
458    pub total_tokens: usize,
459    /// The whole window's budget, from the blueprint's `total_budget_tokens` or
460    /// the model's own limit.
461    pub max_tokens: usize,
462    /// Every region, in layout order.
463    pub regions: Vec<RegionSnapshot>,
464}
465
466/// Status of an individual stage within a run.
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
468#[serde(rename_all = "snake_case")]
469pub enum StageRunStatus {
470    /// Declared but not yet entered.
471    Pending,
472    /// The stage the run is in right now. At most one stage is `Active`.
473    Active,
474    /// Entered, and blocked on a person answering.
475    WaitingInput,
476    /// Finished and left. A stage that loops back becomes `Active` again.
477    Complete,
478    /// Ended in a failure. The run's own `error` carries the message.
479    Error,
480    /// The run finished without ever entering this stage.
481    ///
482    /// Distinct from [`Pending`](Self::Pending), which means "not yet" while a
483    /// run is live, and from [`Complete`](Self::Complete), which these used to
484    /// be recorded as: the ledger marked every stage positioned before the
485    /// cursor complete, and a graph does not visit its stages in index order,
486    /// so an error-recovery branch nothing reached was filed as having run
487    /// (#372). Its `region_tokens` is empty because nothing ever wrote it,
488    /// which made the next real stage look like it had written every region
489    /// from zero.
490    Skipped,
491}
492
493impl std::fmt::Display for StageRunStatus {
494    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
495        match self {
496            StageRunStatus::Pending => write!(f, "Pending"),
497            StageRunStatus::Skipped => write!(f, "Skipped"),
498            StageRunStatus::Active => write!(f, "Active"),
499            StageRunStatus::WaitingInput => write!(f, "WaitingInput"),
500            StageRunStatus::Complete => write!(f, "Complete"),
501            StageRunStatus::Error => write!(f, "Error"),
502        }
503    }
504}
505
506/// Metadata record for a single stage within a run.
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct StageRecord {
509    /// The stage's name, matching its key under `[stages]`.
510    pub name: String,
511    /// Zero-based position in the blueprint's stage list.
512    pub index: usize,
513    /// Where this stage stands.
514    pub status: StageRunStatus,
515    /// Whether the run has ever actually been in this stage.
516    ///
517    /// Position cannot answer this. A graph blueprint reaches its stages in
518    /// whatever order its edges describe, so "index below the cursor" includes
519    /// every branch the run went past without taking - and reading it as
520    /// "finished" is what filed never-entered stages as `Complete` (#372).
521    /// Sticky once set, so a stage the run has left and may re-enter stays
522    /// entered.
523    #[serde(default)]
524    pub entered: bool,
525    /// Input tokens billed while this stage was active. A revisited stage keeps
526    /// accumulating rather than resetting, so the run's total is the sum.
527    pub prompt_tokens: usize,
528    /// Output tokens billed while this stage was active, accumulating the same
529    /// way.
530    pub completion_tokens: usize,
531    /// Tokens read from provider cache in this stage.
532    #[serde(default)]
533    pub cached_tokens: usize,
534    /// Tokens *written* to provider cache in this stage.
535    ///
536    /// Without it only half of a cache decision was visible: a stage showing
537    /// no reads might be paying to write a prefix nothing reuses, or might not
538    /// be caching at all, and the ledger could not tell those apart.
539    #[serde(default)]
540    pub cache_write_tokens: usize,
541    /// Per-region token contribution to this stage's calls, by region name.
542    ///
543    /// The central question of a structured layout is "what am I paying to
544    /// carry, and where", and answering it meant replaying the context history
545    /// and grouping by stage - archaeology for something the runtime already
546    /// knows. Recorded as the largest each region reached while the stage was
547    /// active, which is the number that decides whether a region is earning its
548    /// place.
549    #[serde(default)]
550    pub region_tokens: std::collections::BTreeMap<String, usize>,
551    /// Prompt tokens billed by this stage's first call, the baseline the
552    /// runaway-context check compares against. `None` until it runs once.
553    #[serde(default)]
554    pub first_call_prompt_tokens: Option<usize>,
555    /// Whether the runaway-context warning has already fired for this stage, so
556    /// it is said once on the crossing rather than on every call afterwards.
557    #[serde(default)]
558    pub runaway_warned: bool,
559    /// Unix timestamp (seconds); None until the stage starts.
560    pub started_at: Option<i64>,
561    /// Unix timestamp (seconds); None until the stage ends.
562    pub ended_at: Option<i64>,
563}
564
565impl StageRecord {
566    /// A stage the run has not entered yet: [`StageRunStatus::Pending`], zero
567    /// tokens, and neither timestamp set.
568    pub fn new(name: String, index: usize) -> Self {
569        Self {
570            name,
571            index,
572            status: StageRunStatus::Pending,
573            entered: false,
574            prompt_tokens: 0,
575            completion_tokens: 0,
576            cached_tokens: 0,
577            cache_write_tokens: 0,
578            region_tokens: std::collections::BTreeMap::new(),
579            first_call_prompt_tokens: None,
580            runaway_warned: false,
581            started_at: None,
582            ended_at: None,
583        }
584    }
585}
586
587/// Current Unix time in seconds (saturating to 0 before the epoch).
588fn now_secs() -> i64 {
589    SystemTime::now()
590        .duration_since(UNIX_EPOCH)
591        .map(|d| d.as_secs() as i64)
592        .unwrap_or(0)
593}
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    fn sample_meta() -> RunMeta {
599        RunMeta::new(
600            "run-1".to_string(),
601            "agent".to_string(),
602            "/agents/agent".to_string(),
603            "do the thing".to_string(),
604            Some("claude-sonnet-4-6".to_string()),
605            "/work".to_string(),
606            3,
607        )
608    }
609
610    /// The webhook signing secret must not survive into anything served over
611    /// the API - an unredacted meta lets `GET /api/agents` hand it to any
612    /// token holder.
613    #[test]
614    fn redacted_drops_the_callback_secret_and_keeps_everything_else() {
615        let mut m = sample_meta();
616        m.callback_secret = Some("shhh".to_string());
617        m.callback_url = Some("https://example.com/hook".to_string());
618
619        let r = m.redacted();
620        assert_eq!(r.callback_secret, None);
621        // The URL is not a secret and stays: a caller needs to see where its own
622        // webhook was pointed.
623        assert_eq!(r.callback_url.as_deref(), Some("https://example.com/hook"));
624        assert_eq!(r.run_id, m.run_id);
625        assert_eq!(r.task, m.task);
626
627        // Serializing the redacted form must not mention it at all - a `None`
628        // that still emitted `"callback_secret": null` would be fine, but an
629        // assertion on the wire format is what a reviewer actually checks.
630        let json = serde_json::to_string(&r).unwrap();
631        assert!(!json.contains("shhh"), "{json}");
632
633        // ...and the original is untouched, because the daemon still needs it to
634        // sign the webhook for a run it reloaded after a restart.
635        assert_eq!(m.callback_secret.as_deref(), Some("shhh"));
636    }
637
638    #[test]
639    fn run_meta_new_sets_defaults() {
640        let m = sample_meta();
641        assert_eq!(m.run_id, "run-1");
642        assert_eq!(m.agent_name, "agent");
643        assert_eq!(m.agent_path, "/agents/agent");
644        assert_eq!(m.task, "do the thing");
645        assert_eq!(m.model.as_deref(), Some("claude-sonnet-4-6"));
646        assert_eq!(m.workdir, "/work");
647        assert_eq!(m.num_stages, 3);
648        assert_eq!(m.pid, 0);
649        assert_eq!(m.status, RunStatus::Starting);
650        assert_eq!(m.stage_index, 0);
651        assert_eq!(m.iteration, 0);
652        assert_eq!(m.prompt_tokens, 0);
653        assert_eq!(m.completion_tokens, 0);
654        assert_eq!(m.cached_tokens, 0);
655        assert_eq!(m.cache_write_tokens, 0);
656        assert_eq!(m.tool_calls, 0);
657        assert!(m.error.is_none());
658        assert!(m.title.is_none());
659        assert!(m.metadata.is_empty());
660        assert!(m.callback_url.is_none());
661        assert!(m.callback_secret.is_none());
662        assert!(m.parent_run_id.is_none());
663        assert!(m.children.is_empty());
664        assert_eq!(m.depth, 0);
665        assert_eq!(m.max_child_depth, 0);
666        assert!(m.current_stage.is_empty());
667        assert_eq!(m.started_at, m.updated_at);
668    }
669
670    #[test]
671    fn run_meta_touch_advances_updated_at() {
672        let mut m = sample_meta();
673        m.updated_at = 0;
674        m.touch();
675        assert!(m.updated_at > 0);
676    }
677
678    #[test]
679    fn run_meta_serde_roundtrip() {
680        let mut m = sample_meta();
681        m.status = RunStatus::Running;
682        m.metadata.insert("k".to_string(), "v".to_string());
683        m.title = Some("A title".to_string());
684        m.callback_secret = Some("shh".to_string());
685        m.parent_run_id = Some("parent-1".to_string());
686        m.children = vec!["child-a".to_string(), "child-b".to_string()];
687        m.depth = 2;
688        m.max_child_depth = 5;
689        let json = serde_json::to_string(&m).unwrap();
690        let back: RunMeta = serde_json::from_str(&json).unwrap();
691        assert_eq!(back.run_id, m.run_id);
692        assert_eq!(back.status, RunStatus::Running);
693        assert_eq!(back.metadata.get("k").map(String::as_str), Some("v"));
694        assert_eq!(back.title.as_deref(), Some("A title"));
695        assert_eq!(back.callback_secret.as_deref(), Some("shh"));
696        assert_eq!(back.parent_run_id.as_deref(), Some("parent-1"));
697        assert_eq!(
698            back.children,
699            vec!["child-a".to_string(), "child-b".to_string()]
700        );
701        assert_eq!(back.depth, 2);
702        assert_eq!(back.max_child_depth, 5);
703    }
704
705    #[test]
706    fn run_status_display_all_variants() {
707        assert_eq!(RunStatus::Starting.to_string(), "Starting");
708        assert_eq!(RunStatus::Running.to_string(), "Running");
709        assert_eq!(RunStatus::WaitingInput.to_string(), "WaitingInput");
710        assert_eq!(RunStatus::Complete.to_string(), "Complete");
711        assert_eq!(
712            RunStatus::CompleteInteractive.to_string(),
713            "CompleteInteractive"
714        );
715        assert_eq!(RunStatus::Paused.to_string(), "Paused");
716        assert_eq!(RunStatus::Error.to_string(), "Error");
717        assert_eq!(RunStatus::Cancelled.to_string(), "Cancelled");
718    }
719
720    #[test]
721    fn run_status_serde_snake_case_roundtrip() {
722        for s in [
723            RunStatus::Starting,
724            RunStatus::Running,
725            RunStatus::WaitingInput,
726            RunStatus::Complete,
727            RunStatus::CompleteInteractive,
728            RunStatus::Paused,
729            RunStatus::Error,
730            RunStatus::Cancelled,
731        ] {
732            let json = serde_json::to_string(&s).unwrap();
733            let back: RunStatus = serde_json::from_str(&json).unwrap();
734            assert_eq!(back, s);
735        }
736        assert_eq!(
737            serde_json::to_string(&RunStatus::WaitingInput).unwrap(),
738            "\"waiting_input\""
739        );
740        assert_eq!(
741            serde_json::to_string(&RunStatus::Paused).unwrap(),
742            "\"paused\""
743        );
744    }
745
746    #[test]
747    fn context_snapshot_serde_roundtrip() {
748        let snap = ContextSnapshot {
749            stage_name: "plan".to_string(),
750            total_tokens: 42,
751            max_tokens: 100,
752            regions: vec![RegionSnapshot {
753                name: "history".to_string(),
754                kind: "sliding".to_string(),
755                current_tokens: 10,
756                max_tokens: 50,
757                entries: vec![RegionEntrySnapshot {
758                    content: "hi".to_string(),
759                    tokens: 1,
760                    kind: crate::region::EntryKind::UserMessage,
761                    metadata: Some(serde_json::json!({"a": 1})),
762                    key: Some("k".to_string()),
763                    taint: Default::default(),
764                }],
765            }],
766        };
767        let json = serde_json::to_string(&snap).unwrap();
768        let back: ContextSnapshot = serde_json::from_str(&json).unwrap();
769        assert_eq!(back.stage_name, "plan");
770        assert_eq!(back.regions.len(), 1);
771        assert_eq!(back.regions[0].entries.len(), 1);
772        assert_eq!(back.regions[0].entries[0].content, "hi");
773        assert_eq!(back.regions[0].entries[0].key.as_deref(), Some("k"));
774    }
775
776    #[test]
777    fn region_snapshot_skips_empty_entries_in_json() {
778        let snap = RegionSnapshot {
779            name: "r".to_string(),
780            kind: "pinned".to_string(),
781            current_tokens: 0,
782            max_tokens: 0,
783            entries: vec![],
784        };
785        let json = serde_json::to_string(&snap).unwrap();
786        assert!(!json.contains("entries"));
787    }
788
789    #[test]
790    fn stage_run_status_display_all_variants() {
791        assert_eq!(StageRunStatus::Pending.to_string(), "Pending");
792        assert_eq!(StageRunStatus::Skipped.to_string(), "Skipped");
793        assert_eq!(StageRunStatus::Active.to_string(), "Active");
794        assert_eq!(StageRunStatus::WaitingInput.to_string(), "WaitingInput");
795        assert_eq!(StageRunStatus::Complete.to_string(), "Complete");
796        assert_eq!(StageRunStatus::Error.to_string(), "Error");
797    }
798
799    #[test]
800    fn run_flags_record_modification_dedups_paths_and_caps_the_list() {
801        let mut flags = RunFlags::default();
802        flags.record_modification("src/a.rs");
803        flags.record_modification("src/a.rs");
804        flags.record_modification("src/b.rs");
805        assert_eq!(flags.modified_file_count, 3);
806        assert_eq!(flags.modified_files, vec!["src/a.rs", "src/b.rs"]);
807
808        // Past the cap the count keeps rising but the list stops growing, so a
809        // long run can't bloat meta.json.
810        for i in 0..MAX_TRACKED_MODIFIED_FILES {
811            flags.record_modification(&format!("f{i}.rs"));
812        }
813        assert_eq!(flags.modified_files.len(), MAX_TRACKED_MODIFIED_FILES);
814        assert_eq!(flags.modified_file_count, 3 + MAX_TRACKED_MODIFIED_FILES);
815    }
816
817    #[test]
818    fn run_meta_flags_default_for_older_files() {
819        // meta.json written before #107 has no `flags` key at all.
820        let mut meta = RunMeta::new(
821            "r".to_string(),
822            "a".to_string(),
823            "/p".to_string(),
824            "t".to_string(),
825            None,
826            "/w".to_string(),
827            1,
828        );
829        meta.flags.empty_output = true;
830        // Drop the key structurally rather than by string surgery: a literal
831        // spelling of the serialized flags silently stops matching the moment a
832        // field is added, and the test then passes for the wrong reason.
833        let mut json = serde_json::to_value(&meta).unwrap();
834        json.as_object_mut().unwrap().remove("flags").unwrap();
835        assert!(!json.to_string().contains("flags"));
836        let back: RunMeta = serde_json::from_value(json).unwrap();
837        assert_eq!(back.flags, RunFlags::default());
838    }
839
840    #[test]
841    fn stage_record_new_and_serde_roundtrip() {
842        let rec = StageRecord::new("analyze".to_string(), 2);
843        assert_eq!(rec.name, "analyze");
844        assert_eq!(rec.index, 2);
845        assert_eq!(rec.status, StageRunStatus::Pending);
846        assert_eq!(rec.prompt_tokens, 0);
847        assert_eq!(rec.completion_tokens, 0);
848        assert_eq!(rec.cached_tokens, 0);
849        assert!(rec.started_at.is_none());
850        assert!(rec.ended_at.is_none());
851
852        let json = serde_json::to_string(&rec).unwrap();
853        let back: StageRecord = serde_json::from_str(&json).unwrap();
854        assert_eq!(back.name, "analyze");
855        assert_eq!(back.status, StageRunStatus::Pending);
856    }
857}