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 ///
550 /// Every region the window carries is measured, including the ones a stage
551 /// layout hides rather than declares, so a stage can list a region it never
552 /// assembled into a request.
553 #[serde(default)]
554 pub region_tokens: std::collections::BTreeMap<String, usize>,
555 /// Prompt tokens billed by this stage's first call, the baseline the
556 /// runaway-context check compares against. `None` until it runs once.
557 #[serde(default)]
558 pub first_call_prompt_tokens: Option<usize>,
559 /// Whether the runaway-context warning has already fired for this stage, so
560 /// it is said once on the crossing rather than on every call afterwards.
561 #[serde(default)]
562 pub runaway_warned: bool,
563 /// Unix timestamp (seconds); None until the stage starts.
564 pub started_at: Option<i64>,
565 /// Unix timestamp (seconds); None until the stage ends.
566 pub ended_at: Option<i64>,
567}
568
569impl StageRecord {
570 /// A stage the run has not entered yet: [`StageRunStatus::Pending`], zero
571 /// tokens, and neither timestamp set.
572 pub fn new(name: String, index: usize) -> Self {
573 Self {
574 name,
575 index,
576 status: StageRunStatus::Pending,
577 entered: false,
578 prompt_tokens: 0,
579 completion_tokens: 0,
580 cached_tokens: 0,
581 cache_write_tokens: 0,
582 region_tokens: std::collections::BTreeMap::new(),
583 first_call_prompt_tokens: None,
584 runaway_warned: false,
585 started_at: None,
586 ended_at: None,
587 }
588 }
589}
590
591/// Current Unix time in seconds (saturating to 0 before the epoch).
592fn now_secs() -> i64 {
593 SystemTime::now()
594 .duration_since(UNIX_EPOCH)
595 .map(|d| d.as_secs() as i64)
596 .unwrap_or(0)
597}
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 fn sample_meta() -> RunMeta {
603 RunMeta::new(
604 "run-1".to_string(),
605 "agent".to_string(),
606 "/agents/agent".to_string(),
607 "do the thing".to_string(),
608 Some("claude-sonnet-4-6".to_string()),
609 "/work".to_string(),
610 3,
611 )
612 }
613
614 /// The webhook signing secret must not survive into anything served over
615 /// the API - an unredacted meta lets `GET /api/agents` hand it to any
616 /// token holder.
617 #[test]
618 fn redacted_drops_the_callback_secret_and_keeps_everything_else() {
619 let mut m = sample_meta();
620 m.callback_secret = Some("shhh".to_string());
621 m.callback_url = Some("https://example.com/hook".to_string());
622
623 let r = m.redacted();
624 assert_eq!(r.callback_secret, None);
625 // The URL is not a secret and stays: a caller needs to see where its own
626 // webhook was pointed.
627 assert_eq!(r.callback_url.as_deref(), Some("https://example.com/hook"));
628 assert_eq!(r.run_id, m.run_id);
629 assert_eq!(r.task, m.task);
630
631 // Serializing the redacted form must not mention it at all - a `None`
632 // that still emitted `"callback_secret": null` would be fine, but an
633 // assertion on the wire format is what a reviewer actually checks.
634 let json = serde_json::to_string(&r).unwrap();
635 assert!(!json.contains("shhh"), "{json}");
636
637 // ...and the original is untouched, because the daemon still needs it to
638 // sign the webhook for a run it reloaded after a restart.
639 assert_eq!(m.callback_secret.as_deref(), Some("shhh"));
640 }
641
642 #[test]
643 fn run_meta_new_sets_defaults() {
644 let m = sample_meta();
645 assert_eq!(m.run_id, "run-1");
646 assert_eq!(m.agent_name, "agent");
647 assert_eq!(m.agent_path, "/agents/agent");
648 assert_eq!(m.task, "do the thing");
649 assert_eq!(m.model.as_deref(), Some("claude-sonnet-4-6"));
650 assert_eq!(m.workdir, "/work");
651 assert_eq!(m.num_stages, 3);
652 assert_eq!(m.pid, 0);
653 assert_eq!(m.status, RunStatus::Starting);
654 assert_eq!(m.stage_index, 0);
655 assert_eq!(m.iteration, 0);
656 assert_eq!(m.prompt_tokens, 0);
657 assert_eq!(m.completion_tokens, 0);
658 assert_eq!(m.cached_tokens, 0);
659 assert_eq!(m.cache_write_tokens, 0);
660 assert_eq!(m.tool_calls, 0);
661 assert!(m.error.is_none());
662 assert!(m.title.is_none());
663 assert!(m.metadata.is_empty());
664 assert!(m.callback_url.is_none());
665 assert!(m.callback_secret.is_none());
666 assert!(m.parent_run_id.is_none());
667 assert!(m.children.is_empty());
668 assert_eq!(m.depth, 0);
669 assert_eq!(m.max_child_depth, 0);
670 assert!(m.current_stage.is_empty());
671 assert_eq!(m.started_at, m.updated_at);
672 }
673
674 #[test]
675 fn run_meta_touch_advances_updated_at() {
676 let mut m = sample_meta();
677 m.updated_at = 0;
678 m.touch();
679 assert!(m.updated_at > 0);
680 }
681
682 #[test]
683 fn run_meta_serde_roundtrip() {
684 let mut m = sample_meta();
685 m.status = RunStatus::Running;
686 m.metadata.insert("k".to_string(), "v".to_string());
687 m.title = Some("A title".to_string());
688 m.callback_secret = Some("shh".to_string());
689 m.parent_run_id = Some("parent-1".to_string());
690 m.children = vec!["child-a".to_string(), "child-b".to_string()];
691 m.depth = 2;
692 m.max_child_depth = 5;
693 let json = serde_json::to_string(&m).unwrap();
694 let back: RunMeta = serde_json::from_str(&json).unwrap();
695 assert_eq!(back.run_id, m.run_id);
696 assert_eq!(back.status, RunStatus::Running);
697 assert_eq!(back.metadata.get("k").map(String::as_str), Some("v"));
698 assert_eq!(back.title.as_deref(), Some("A title"));
699 assert_eq!(back.callback_secret.as_deref(), Some("shh"));
700 assert_eq!(back.parent_run_id.as_deref(), Some("parent-1"));
701 assert_eq!(
702 back.children,
703 vec!["child-a".to_string(), "child-b".to_string()]
704 );
705 assert_eq!(back.depth, 2);
706 assert_eq!(back.max_child_depth, 5);
707 }
708
709 #[test]
710 fn run_status_display_all_variants() {
711 assert_eq!(RunStatus::Starting.to_string(), "Starting");
712 assert_eq!(RunStatus::Running.to_string(), "Running");
713 assert_eq!(RunStatus::WaitingInput.to_string(), "WaitingInput");
714 assert_eq!(RunStatus::Complete.to_string(), "Complete");
715 assert_eq!(
716 RunStatus::CompleteInteractive.to_string(),
717 "CompleteInteractive"
718 );
719 assert_eq!(RunStatus::Paused.to_string(), "Paused");
720 assert_eq!(RunStatus::Error.to_string(), "Error");
721 assert_eq!(RunStatus::Cancelled.to_string(), "Cancelled");
722 }
723
724 #[test]
725 fn run_status_serde_snake_case_roundtrip() {
726 for s in [
727 RunStatus::Starting,
728 RunStatus::Running,
729 RunStatus::WaitingInput,
730 RunStatus::Complete,
731 RunStatus::CompleteInteractive,
732 RunStatus::Paused,
733 RunStatus::Error,
734 RunStatus::Cancelled,
735 ] {
736 let json = serde_json::to_string(&s).unwrap();
737 let back: RunStatus = serde_json::from_str(&json).unwrap();
738 assert_eq!(back, s);
739 }
740 assert_eq!(
741 serde_json::to_string(&RunStatus::WaitingInput).unwrap(),
742 "\"waiting_input\""
743 );
744 assert_eq!(
745 serde_json::to_string(&RunStatus::Paused).unwrap(),
746 "\"paused\""
747 );
748 }
749
750 #[test]
751 fn context_snapshot_serde_roundtrip() {
752 let snap = ContextSnapshot {
753 stage_name: "plan".to_string(),
754 total_tokens: 42,
755 max_tokens: 100,
756 regions: vec![RegionSnapshot {
757 name: "history".to_string(),
758 kind: "sliding".to_string(),
759 current_tokens: 10,
760 max_tokens: 50,
761 entries: vec![RegionEntrySnapshot {
762 content: "hi".to_string(),
763 tokens: 1,
764 kind: crate::region::EntryKind::UserMessage,
765 metadata: Some(serde_json::json!({"a": 1})),
766 key: Some("k".to_string()),
767 taint: Default::default(),
768 }],
769 }],
770 };
771 let json = serde_json::to_string(&snap).unwrap();
772 let back: ContextSnapshot = serde_json::from_str(&json).unwrap();
773 assert_eq!(back.stage_name, "plan");
774 assert_eq!(back.regions.len(), 1);
775 assert_eq!(back.regions[0].entries.len(), 1);
776 assert_eq!(back.regions[0].entries[0].content, "hi");
777 assert_eq!(back.regions[0].entries[0].key.as_deref(), Some("k"));
778 }
779
780 #[test]
781 fn region_snapshot_skips_empty_entries_in_json() {
782 let snap = RegionSnapshot {
783 name: "r".to_string(),
784 kind: "pinned".to_string(),
785 current_tokens: 0,
786 max_tokens: 0,
787 entries: vec![],
788 };
789 let json = serde_json::to_string(&snap).unwrap();
790 assert!(!json.contains("entries"));
791 }
792
793 #[test]
794 fn stage_run_status_display_all_variants() {
795 assert_eq!(StageRunStatus::Pending.to_string(), "Pending");
796 assert_eq!(StageRunStatus::Skipped.to_string(), "Skipped");
797 assert_eq!(StageRunStatus::Active.to_string(), "Active");
798 assert_eq!(StageRunStatus::WaitingInput.to_string(), "WaitingInput");
799 assert_eq!(StageRunStatus::Complete.to_string(), "Complete");
800 assert_eq!(StageRunStatus::Error.to_string(), "Error");
801 }
802
803 #[test]
804 fn run_flags_record_modification_dedups_paths_and_caps_the_list() {
805 let mut flags = RunFlags::default();
806 flags.record_modification("src/a.rs");
807 flags.record_modification("src/a.rs");
808 flags.record_modification("src/b.rs");
809 assert_eq!(flags.modified_file_count, 3);
810 assert_eq!(flags.modified_files, vec!["src/a.rs", "src/b.rs"]);
811
812 // Past the cap the count keeps rising but the list stops growing, so a
813 // long run can't bloat meta.json.
814 for i in 0..MAX_TRACKED_MODIFIED_FILES {
815 flags.record_modification(&format!("f{i}.rs"));
816 }
817 assert_eq!(flags.modified_files.len(), MAX_TRACKED_MODIFIED_FILES);
818 assert_eq!(flags.modified_file_count, 3 + MAX_TRACKED_MODIFIED_FILES);
819 }
820
821 #[test]
822 fn run_meta_flags_default_for_older_files() {
823 // meta.json written before #107 has no `flags` key at all.
824 let mut meta = RunMeta::new(
825 "r".to_string(),
826 "a".to_string(),
827 "/p".to_string(),
828 "t".to_string(),
829 None,
830 "/w".to_string(),
831 1,
832 );
833 meta.flags.empty_output = true;
834 // Drop the key structurally rather than by string surgery: a literal
835 // spelling of the serialized flags silently stops matching the moment a
836 // field is added, and the test then passes for the wrong reason.
837 let mut json = serde_json::to_value(&meta).unwrap();
838 json.as_object_mut().unwrap().remove("flags").unwrap();
839 assert!(!json.to_string().contains("flags"));
840 let back: RunMeta = serde_json::from_value(json).unwrap();
841 assert_eq!(back.flags, RunFlags::default());
842 }
843
844 #[test]
845 fn stage_record_new_and_serde_roundtrip() {
846 let rec = StageRecord::new("analyze".to_string(), 2);
847 assert_eq!(rec.name, "analyze");
848 assert_eq!(rec.index, 2);
849 assert_eq!(rec.status, StageRunStatus::Pending);
850 assert_eq!(rec.prompt_tokens, 0);
851 assert_eq!(rec.completion_tokens, 0);
852 assert_eq!(rec.cached_tokens, 0);
853 assert!(rec.started_at.is_none());
854 assert!(rec.ended_at.is_none());
855
856 let json = serde_json::to_string(&rec).unwrap();
857 let back: StageRecord = serde_json::from_str(&json).unwrap();
858 assert_eq!(back.name, "analyze");
859 assert_eq!(back.status, StageRunStatus::Pending);
860 }
861}