Skip to main content

mlua_swarm/store/trace/
mod.rs

1//! `RunTraceStore` — the persisted per-Run trace rail (`TraceEvent`
2//! stream) plus the normalized worker-stats types (`TokenUsage` /
3//! [`WorkerStats`]) shared by the trace rail and the
4//! [`crate::store::run::StepEntry`] step-stats extension.
5//!
6//! Two rails observe the same dispatch (issue: per-step run stats):
7//!
8//! - [`crate::store::run::StepEntry`] — the **terminal summary** of one
9//!   dispatched step (write-once, appended by the dispatcher after the
10//!   outcome is known; carries duration / usage / model / verdict).
11//! - [`TraceEvent`] (this module) — the **in-flight stream** of what is
12//!   happening (`core.step_dispatched`, `mw.long_hold_warn`, …),
13//!   append-only, per-Run, ordered by `seq`.
14//!
15//! Everything here is purely observational: a failed append must never
16//! fail the dispatch it observes (callers warn-and-swallow — the same
17//! fail-open convention as `EngineDispatcher::dispatch`'s
18//! `append_step_entry`). The naming is deliberately `Trace`, not `Log`:
19//! in the Rust ecosystem "log" collides with the `log`/`tracing`
20//! facade crates, and this rail is domain data, not process logging.
21//!
22//! Kinds are an **open set** of namespaced strings — writers may insert
23//! new kinds without a schema migration. Current namespaces:
24//!
25//! - `core.*` — engine/dispatcher (`run_started` / `step_dispatched` /
26//!   `step_completed` / `cancel_requested` / `run_finished`)
27//! - `mw.*` — middleware (`long_hold_warn`, …)
28//! - `worker.*` — adapter / worker self-reports
29//! - `ext.*` — future external writers (Lua flow, enhance flow, tools)
30//!
31//! Layering invariant (future `mlua-swarm-trace` crate split): this
32//! module must not depend on engine types — only `crate::types` ids and
33//! serde values.
34
35use crate::types::RunId;
36use async_trait::async_trait;
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39use std::collections::HashMap;
40use std::sync::{Arc, Mutex};
41use thiserror::Error;
42
43pub mod sqlite;
44pub use sqlite::SqliteRunTraceStore;
45
46// ──────────────────────────────────────────────────────────────────────────
47// Normalized worker stats (TokenUsage / WorkerStats)
48// ──────────────────────────────────────────────────────────────────────────
49
50/// Aggregated token usage for one worker attempt, normalized across
51/// worker kinds (agent-block `agent.run` return / subprocess declared
52/// mapping / operator self-report). Field names follow the Anthropic
53/// wire convention (`input_tokens` / `output_tokens`) that agent-block
54/// already normalizes OpenAI-style responses into.
55///
56/// **Every wire field is optional on the way in** ([`TokenUsageWire`]):
57/// a reporter that only knows one axis (a harness completion notice
58/// that surfaces a single token total, an API response that omits the
59/// total) still lands a usable usage record instead of being dropped.
60/// The stored shape stays the closed 3-field triple — missing splits
61/// read as `0`, and a missing `total_tokens` is derived as
62/// `input + output` on decode.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
64#[serde(from = "TokenUsageWire")]
65#[schemars(with = "TokenUsageWire")]
66pub struct TokenUsage {
67    /// Prompt-side tokens consumed, summed across the attempt's turns.
68    /// `0` when the reporter did not split its total.
69    pub input_tokens: u64,
70    /// Completion-side tokens produced, summed across the attempt's
71    /// turns. `0` when the reporter did not split its total.
72    pub output_tokens: u64,
73    /// `input + output` (kept explicit because some producers report a
74    /// total that includes cache-read/creation tokens the two split
75    /// fields don't cover). Derived from the splits when the reporter
76    /// omits it.
77    pub total_tokens: u64,
78}
79
80impl TokenUsage {
81    /// Build a usage record from three independently-optional parts,
82    /// applying the same normalization the wire decode does: absent
83    /// splits read as `0`, and an absent total is derived as
84    /// `input + output`.
85    ///
86    /// `None` only when the reporter carried **no** token axis at all —
87    /// the caller then records no usage rather than a zeroed one.
88    ///
89    /// Shared by the hand-rolled extractors that read usage out of a
90    /// worker's raw payload (agent-block's `agent.run` return,
91    /// subprocess `usage_ptr` declarations) so every axis applies one
92    /// normalization rule.
93    pub fn from_parts(
94        input_tokens: Option<u64>,
95        output_tokens: Option<u64>,
96        total_tokens: Option<u64>,
97    ) -> Option<Self> {
98        if input_tokens.is_none() && output_tokens.is_none() && total_tokens.is_none() {
99            return None;
100        }
101        let input = input_tokens.unwrap_or(0);
102        let output = output_tokens.unwrap_or(0);
103        Some(Self {
104            input_tokens: input,
105            output_tokens: output,
106            total_tokens: total_tokens.unwrap_or(input + output),
107        })
108    }
109}
110
111/// Deserialization shadow of [`TokenUsage`] — the wire contract, where
112/// every token field is optional.
113///
114/// Exists so partial reports survive the decode: before it, a producer
115/// that sent `{"total_tokens": N}` alone failed the whole
116/// [`WorkerStats`] decode, and the best-effort ingest sites dropped the
117/// entire stats object (model / num_turns included) without a trace.
118#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
119pub struct TokenUsageWire {
120    /// Prompt-side tokens, when the reporter splits them out.
121    #[serde(default)]
122    pub input_tokens: Option<u64>,
123    /// Completion-side tokens, when the reporter splits them out.
124    #[serde(default)]
125    pub output_tokens: Option<u64>,
126    /// Reporter-supplied total; derived from the splits when absent.
127    #[serde(default)]
128    pub total_tokens: Option<u64>,
129}
130
131impl From<TokenUsageWire> for TokenUsage {
132    fn from(w: TokenUsageWire) -> Self {
133        // An all-absent object (`"usage": {}`) is a degenerate but legal
134        // wire value — it lands as an explicit all-zero record rather
135        // than an error, matching the "stats never gate the report"
136        // invariant.
137        TokenUsage::from_parts(w.input_tokens, w.output_tokens, w.total_tokens).unwrap_or(Self {
138            input_tokens: 0,
139            output_tokens: 0,
140            total_tokens: 0,
141        })
142    }
143}
144
145/// Normalized per-attempt worker statistics, reported by a worker
146/// boundary (spawner fold site / result captor / `POST
147/// /v1/worker/submit`) into the engine and folded into the terminal
148/// [`crate::store::run::StepEntry`] by the dispatcher.
149///
150/// The three named fields are the **closed schema** the engine knows;
151/// everything worker-kind-specific rides in [`Self::adapter_data`] as
152/// raw JSON the engine never interprets (capped at
153/// [`TRACE_PAYLOAD_CAP_BYTES`] on fold). Every field is optional —
154/// absence must never block a dispatch.
155#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
156pub struct WorkerStats {
157    /// Worker kind label (`"agent_block"` / `"subprocess"` /
158    /// `"operator"` / …) — set by whichever boundary constructed the
159    /// stats, since only that boundary knows its own kind.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub worker_kind: Option<String>,
162    /// The model that served the attempt, when the boundary knows it
163    /// (subprocess: the rendered `{model}` placeholder; operator:
164    /// self-report; agent-block: spec-declared model if any).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub model: Option<String>,
167    /// Normalized token usage, when the boundary can produce one.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub usage: Option<TokenUsage>,
170    /// Number of LLM turns the attempt ran (agent-block `num_turns`).
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub num_turns: Option<u32>,
173    /// Worker-kind-specific raw payload (exit code, stderr tail, cache
174    /// token detail, …). Observational only — the engine stores it
175    /// verbatim (size-capped) and never branches on it.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub adapter_data: Option<Value>,
178}
179
180impl WorkerStats {
181    /// `true` when no field carries information — callers skip
182    /// recording an all-empty stats value.
183    pub fn is_empty(&self) -> bool {
184        self.worker_kind.is_none()
185            && self.model.is_none()
186            && self.usage.is_none()
187            && self.num_turns.is_none()
188            && self.adapter_data.is_none()
189    }
190}
191
192// ──────────────────────────────────────────────────────────────────────────
193// TraceEvent / TraceQuery
194// ──────────────────────────────────────────────────────────────────────────
195
196/// Byte cap applied to [`TraceEvent::payload`] and
197/// [`WorkerStats::adapter_data`] before persisting. Oversized values are
198/// replaced by a truncation marker object (see [`cap_payload`]) — the
199/// trace rail is an observability artifact, not a blob store.
200pub const TRACE_PAYLOAD_CAP_BYTES: usize = 8 * 1024;
201
202/// Default per-Run retention ceiling — appends beyond this many events
203/// prune the oldest rows first.
204pub const DEFAULT_TRACE_MAX_EVENTS_PER_RUN: usize = 10_000;
205
206/// Default `list` page size when a query sets neither `limit` nor
207/// `latest`.
208pub const DEFAULT_TRACE_LIST_LIMIT: usize = 1_000;
209
210/// Well-known `TraceEvent.kind` values written by the engine itself.
211/// The kind axis is an open set — these constants exist so in-tree
212/// writers and tests agree on spelling, not to constrain writers.
213pub mod kind {
214    /// A Run began dispatching (server-side, once per kick).
215    pub const RUN_STARTED: &str = "core.run_started";
216    /// The dispatcher is about to spawn a step's worker.
217    pub const STEP_DISPATCHED: &str = "core.step_dispatched";
218    /// A step reached its terminal outcome (payload carries the status
219    /// label + timing summary; the authoritative record is the
220    /// `StepEntry` appended in the same breath).
221    pub const STEP_COMPLETED: &str = "core.step_completed";
222    /// A Run reached its terminal status (payload: `{"status": ...}`).
223    pub const RUN_FINISHED: &str = "core.run_finished";
224    /// Cancellation was requested for the Run.
225    pub const CANCEL_REQUESTED: &str = "core.cancel_requested";
226    /// `LongHoldMiddleware` observed a completion above its threshold.
227    pub const LONG_HOLD_WARN: &str = "mw.long_hold_warn";
228    /// A worker reported a degradation (mirrors the `DegradationEntry`
229    /// rail so the trace stream is self-contained).
230    pub const WORKER_DEGRADATION: &str = "worker.degradation";
231}
232
233/// One persisted trace event — a member of a Run's append-only stream.
234#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
235pub struct TraceEvent {
236    /// The Run this event belongs to.
237    #[schemars(with = "String")]
238    pub run_id: RunId,
239    /// Per-Run monotonically increasing ordering key, assigned by the
240    /// store at append time (1-based).
241    pub seq: u64,
242    /// Unix epoch milliseconds — when the event was recorded.
243    pub ts_ms: i64,
244    /// Namespaced kind string (open set; see the module doc).
245    pub kind: String,
246    /// The Blueprint step ref this event concerns, if any.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub step_ref: Option<String>,
249    /// The attempt number this event concerns, if any.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub attempt: Option<u32>,
252    /// Free-form JSON payload (capped at [`TRACE_PAYLOAD_CAP_BYTES`]).
253    pub payload: Value,
254}
255
256/// The caller-supplied half of a [`TraceEvent`] — the store assigns
257/// `seq` and `ts_ms` at append time.
258#[derive(Debug, Clone)]
259pub struct TraceEventDraft {
260    /// Namespaced kind string (open set).
261    pub kind: String,
262    /// The Blueprint step ref this event concerns, if any.
263    pub step_ref: Option<String>,
264    /// The attempt number this event concerns, if any.
265    pub attempt: Option<u32>,
266    /// Free-form JSON payload — capped by the store on append.
267    pub payload: Value,
268}
269
270/// Filter/paging parameters for [`RunTraceStore::list`]. All filters
271/// AND together; `latest` and `after` are mutually exclusive with
272/// `latest` winning (it answers "show me the tail" regardless of any
273/// cursor the caller also carried).
274#[derive(Debug, Clone, Default)]
275pub struct TraceQuery {
276    /// Forward-paging cursor: only events with `seq > after`.
277    pub after: Option<u64>,
278    /// Page size cap (defaults to [`DEFAULT_TRACE_LIST_LIMIT`]).
279    pub limit: Option<usize>,
280    /// Tail mode: return the LAST n matching events (still in ascending
281    /// `seq` order). Takes precedence over `after`/`limit`.
282    pub latest: Option<usize>,
283    /// Kind filters — an event matches when its kind equals, or starts
284    /// with, ANY entry (prefix match: `"mw."` matches every middleware
285    /// kind). Empty = no kind filter.
286    pub kinds: Vec<String>,
287    /// Exact `step_ref` filter.
288    pub step_ref: Option<String>,
289    /// Exact `attempt` filter.
290    pub attempt: Option<u32>,
291}
292
293impl TraceQuery {
294    /// Does `event` pass this query's kind/step/attempt filters
295    /// (paging axes excluded)?
296    fn matches(&self, event: &TraceEvent) -> bool {
297        if !self.kinds.is_empty()
298            && !self
299                .kinds
300                .iter()
301                .any(|k| event.kind == *k || event.kind.starts_with(k.as_str()))
302        {
303            return false;
304        }
305        if let Some(step_ref) = &self.step_ref {
306            if event.step_ref.as_deref() != Some(step_ref.as_str()) {
307                return false;
308            }
309        }
310        if let Some(attempt) = self.attempt {
311            if event.attempt != Some(attempt) {
312                return false;
313            }
314        }
315        true
316    }
317
318    /// Apply paging (latest wins over after/limit) to an ascending,
319    /// already-filtered event list.
320    fn page(&self, mut events: Vec<TraceEvent>) -> Vec<TraceEvent> {
321        if let Some(n) = self.latest {
322            let start = events.len().saturating_sub(n);
323            return events.split_off(start);
324        }
325        if let Some(after) = self.after {
326            events.retain(|e| e.seq > after);
327        }
328        let limit = self.limit.unwrap_or(DEFAULT_TRACE_LIST_LIMIT);
329        events.truncate(limit);
330        events
331    }
332}
333
334/// Replace an oversized payload with a truncation marker carrying the
335/// original size and a head excerpt, so a runaway writer cannot bloat
336/// the trace store. Values at or under [`TRACE_PAYLOAD_CAP_BYTES`] pass
337/// through unchanged.
338pub fn cap_payload(payload: Value) -> Value {
339    let serialized = payload.to_string();
340    if serialized.len() <= TRACE_PAYLOAD_CAP_BYTES {
341        return payload;
342    }
343    let head: String = serialized.chars().take(1024).collect();
344    serde_json::json!({
345        "truncated": true,
346        "size_bytes": serialized.len(),
347        "head": head,
348    })
349}
350
351/// Errors surfaced by a [`RunTraceStore`] implementation.
352#[derive(Debug, Error)]
353pub enum TraceStoreError {
354    /// Backend-specific failure.
355    #[error("other: {0}")]
356    Other(String),
357}
358
359// ──────────────────────────────────────────────────────────────────────────
360// RunTraceStore trait
361// ──────────────────────────────────────────────────────────────────────────
362
363/// Persistence interface for the per-Run trace stream.
364#[async_trait]
365pub trait RunTraceStore: Send + Sync {
366    /// Backend name — for diagnostics/logging.
367    fn name(&self) -> &str;
368
369    /// Append one event to `run_id`'s stream, assigning the next `seq`
370    /// and stamping `ts_ms`. The store caps `draft.payload` via
371    /// [`cap_payload`] and prunes the oldest rows beyond the per-Run
372    /// retention ceiling. Appending to an unknown `run_id` is legal —
373    /// the trace rail has no foreign-key coupling to `RunStore` (a
374    /// trace writer must never fail because Run-row creation raced it).
375    async fn append(
376        &self,
377        run_id: &RunId,
378        draft: TraceEventDraft,
379    ) -> Result<TraceEvent, TraceStoreError>;
380
381    /// List `run_id`'s events matching `query`, ascending by `seq`.
382    async fn list(
383        &self,
384        run_id: &RunId,
385        query: &TraceQuery,
386    ) -> Result<Vec<TraceEvent>, TraceStoreError>;
387
388    /// Delete every event belonging to `run_id`, returning the number
389    /// of deleted events. Deleting an unknown/empty Run is `Ok(0)`.
390    async fn delete_run(&self, run_id: &RunId) -> Result<u64, TraceStoreError>;
391}
392
393// ──────────────────────────────────────────────────────────────────────────
394// TraceHandle — the pervasive-insertion write port
395// ──────────────────────────────────────────────────────────────────────────
396
397/// A cheap, cloneable write handle binding one `run_id` to a
398/// [`RunTraceStore`] — the single port through which the dispatcher,
399/// middlewares (via `Engine::trace_handle`), server handlers, and any
400/// future writer append trace events. Appends are **best-effort**: a
401/// store failure is logged at `warn` and swallowed, never propagated
402/// (fail-open, matching the `append_step_entry` convention).
403#[derive(Clone)]
404pub struct TraceHandle {
405    run_id: RunId,
406    store: Arc<dyn RunTraceStore>,
407}
408
409impl TraceHandle {
410    /// Bind `run_id` to `store`.
411    pub fn new(run_id: RunId, store: Arc<dyn RunTraceStore>) -> Self {
412        Self { run_id, store }
413    }
414
415    /// The Run this handle appends into.
416    pub fn run_id(&self) -> &RunId {
417        &self.run_id
418    }
419
420    /// Best-effort append (see the struct doc). `kind` should follow
421    /// the namespaced open-set convention (`core.*` / `mw.*` /
422    /// `worker.*` / `ext.*`).
423    pub async fn append(
424        &self,
425        kind: &str,
426        step_ref: Option<&str>,
427        attempt: Option<u32>,
428        payload: Value,
429    ) {
430        let draft = TraceEventDraft {
431            kind: kind.to_string(),
432            step_ref: step_ref.map(str::to_string),
433            attempt,
434            payload,
435        };
436        if let Err(e) = self.store.append(&self.run_id, draft).await {
437            tracing::warn!(
438                run_id = %self.run_id,
439                kind = kind,
440                error = %e,
441                "TraceHandle::append failed (swallowed — trace is observational)"
442            );
443        }
444    }
445}
446
447impl std::fmt::Debug for TraceHandle {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        f.debug_struct("TraceHandle")
450            .field("run_id", &self.run_id)
451            .field("store", &self.store.name())
452            .finish()
453    }
454}
455
456// ──────────────────────────────────────────────────────────────────────────
457// InMemoryRunTraceStore
458// ──────────────────────────────────────────────────────────────────────────
459
460#[derive(Default)]
461struct TraceInner {
462    /// Per-Run ascending event lists.
463    events: HashMap<RunId, Vec<TraceEvent>>,
464    /// Per-Run next `seq` — kept separately from `events.len()` because
465    /// retention pruning removes head entries without recycling seqs.
466    next_seq: HashMap<RunId, u64>,
467}
468
469/// Process-volatile [`RunTraceStore`] — the default when no persistent
470/// backend is wired.
471pub struct InMemoryRunTraceStore {
472    inner: Mutex<TraceInner>,
473    max_events_per_run: usize,
474}
475
476impl InMemoryRunTraceStore {
477    /// Create an empty store with the default retention ceiling.
478    pub fn new() -> Self {
479        Self {
480            inner: Mutex::new(TraceInner::default()),
481            max_events_per_run: DEFAULT_TRACE_MAX_EVENTS_PER_RUN,
482        }
483    }
484
485    /// Create an empty store with a custom per-Run retention ceiling
486    /// (tests).
487    pub fn with_max_events_per_run(max: usize) -> Self {
488        Self {
489            inner: Mutex::new(TraceInner::default()),
490            max_events_per_run: max,
491        }
492    }
493}
494
495impl Default for InMemoryRunTraceStore {
496    fn default() -> Self {
497        Self::new()
498    }
499}
500
501/// Unix epoch milliseconds now — trace events are sub-second dense, so
502/// the store stamps millis (the coarser `now_unix` seconds stay on the
503/// pre-existing `StepEntry.at` / `RunRecord` fields).
504pub(crate) fn now_unix_ms() -> i64 {
505    use std::time::{SystemTime, UNIX_EPOCH};
506    SystemTime::now()
507        .duration_since(UNIX_EPOCH)
508        .map(|d| d.as_millis() as i64)
509        .unwrap_or(0)
510}
511
512#[async_trait]
513impl RunTraceStore for InMemoryRunTraceStore {
514    fn name(&self) -> &str {
515        "in-memory"
516    }
517
518    async fn append(
519        &self,
520        run_id: &RunId,
521        draft: TraceEventDraft,
522    ) -> Result<TraceEvent, TraceStoreError> {
523        let mut inner = self.inner.lock().unwrap();
524        let seq_slot = inner.next_seq.entry(run_id.clone()).or_insert(0);
525        *seq_slot += 1;
526        let event = TraceEvent {
527            run_id: run_id.clone(),
528            seq: *seq_slot,
529            ts_ms: now_unix_ms(),
530            kind: draft.kind,
531            step_ref: draft.step_ref,
532            attempt: draft.attempt,
533            payload: cap_payload(draft.payload),
534        };
535        let list = inner.events.entry(run_id.clone()).or_default();
536        list.push(event.clone());
537        if list.len() > self.max_events_per_run {
538            let overflow = list.len() - self.max_events_per_run;
539            list.drain(..overflow);
540        }
541        Ok(event)
542    }
543
544    async fn list(
545        &self,
546        run_id: &RunId,
547        query: &TraceQuery,
548    ) -> Result<Vec<TraceEvent>, TraceStoreError> {
549        let inner = self.inner.lock().unwrap();
550        let events: Vec<TraceEvent> = inner
551            .events
552            .get(run_id)
553            .map(|list| list.iter().filter(|e| query.matches(e)).cloned().collect())
554            .unwrap_or_default();
555        Ok(query.page(events))
556    }
557
558    async fn delete_run(&self, run_id: &RunId) -> Result<u64, TraceStoreError> {
559        let mut inner = self.inner.lock().unwrap();
560        inner.next_seq.remove(run_id);
561        Ok(inner
562            .events
563            .remove(run_id)
564            .map(|list| list.len() as u64)
565            .unwrap_or(0))
566    }
567}
568
569// ──────────────────────────────────────────────────────────────────────────
570// tests
571// ──────────────────────────────────────────────────────────────────────────
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use serde_json::json;
577
578    fn rid(s: &str) -> RunId {
579        RunId::parse(s).unwrap()
580    }
581
582    fn draft(kind: &str, step_ref: Option<&str>, attempt: Option<u32>) -> TraceEventDraft {
583        TraceEventDraft {
584            kind: kind.to_string(),
585            step_ref: step_ref.map(str::to_string),
586            attempt,
587            payload: json!({"k": kind}),
588        }
589    }
590
591    // ── TokenUsage / WorkerStats wire contract ───────────────────────
592
593    #[test]
594    fn usage_decodes_from_a_total_only_report() {
595        // The canonical operator self-report: a harness completion
596        // notice that surfaces one token total and no split. Before the
597        // wire shadow this failed the decode and took the whole
598        // WorkerStats (model / num_turns included) down with it.
599        let stats: WorkerStats = serde_json::from_value(json!({
600            "usage": {"total_tokens": 198471},
601            "model": "opus",
602            "num_turns": 22,
603        }))
604        .expect("a total-only usage must decode");
605        let usage = stats.usage.clone().expect("usage must survive the decode");
606        assert_eq!(usage.total_tokens, 198471);
607        assert_eq!(usage.input_tokens, 0, "unsplit report reads as 0");
608        assert_eq!(usage.output_tokens, 0);
609        assert_eq!(stats.model.as_deref(), Some("opus"));
610        assert_eq!(stats.num_turns, Some(22));
611        assert!(!stats.is_empty());
612    }
613
614    #[test]
615    fn usage_derives_the_total_from_the_splits() {
616        let usage: TokenUsage =
617            serde_json::from_value(json!({"input_tokens": 10, "output_tokens": 4}))
618                .expect("splits-only usage must decode");
619        assert_eq!(usage.total_tokens, 14, "absent total is derived");
620    }
621
622    #[test]
623    fn usage_keeps_a_reporter_total_that_exceeds_the_splits() {
624        // Cache-read/creation tokens live in the reporter's total but
625        // not in the two splits — never recompute over the report.
626        let usage: TokenUsage = serde_json::from_value(
627            json!({"input_tokens": 10, "output_tokens": 4, "total_tokens": 900}),
628        )
629        .unwrap();
630        assert_eq!(usage.total_tokens, 900);
631    }
632
633    #[test]
634    fn usage_serializes_as_the_closed_triple() {
635        let usage = TokenUsage::from_parts(None, None, Some(7)).unwrap();
636        assert_eq!(
637            serde_json::to_value(&usage).unwrap(),
638            json!({"input_tokens": 0, "output_tokens": 0, "total_tokens": 7}),
639            "the stored shape stays the 3-field triple"
640        );
641    }
642
643    #[test]
644    fn from_parts_is_none_only_when_no_axis_was_reported() {
645        assert_eq!(TokenUsage::from_parts(None, None, None), None);
646        assert!(TokenUsage::from_parts(Some(0), None, None).is_some());
647    }
648
649    #[test]
650    fn empty_usage_object_lands_as_an_explicit_zero_record() {
651        // Degenerate but legal: stats must never gate the report, so an
652        // empty object decodes rather than erroring.
653        let usage: TokenUsage = serde_json::from_value(json!({})).unwrap();
654        assert_eq!(usage.total_tokens, 0);
655    }
656
657    #[tokio::test]
658    async fn append_assigns_monotonic_seq_per_run() {
659        let s = InMemoryRunTraceStore::new();
660        let e1 = s
661            .append(&rid("R-1"), draft("core.run_started", None, None))
662            .await
663            .unwrap();
664        let e2 = s
665            .append(
666                &rid("R-1"),
667                draft("core.step_dispatched", Some("w"), Some(1)),
668            )
669            .await
670            .unwrap();
671        let other = s
672            .append(&rid("R-2"), draft("core.run_started", None, None))
673            .await
674            .unwrap();
675        assert_eq!(e1.seq, 1);
676        assert_eq!(e2.seq, 2);
677        assert_eq!(other.seq, 1, "seq is per-Run, not global");
678        assert!(e1.ts_ms > 0);
679    }
680
681    #[tokio::test]
682    async fn list_filters_by_kind_prefix_step_and_attempt() {
683        let s = InMemoryRunTraceStore::new();
684        let r = rid("R-1");
685        s.append(&r, draft("core.run_started", None, None))
686            .await
687            .unwrap();
688        s.append(&r, draft("core.step_dispatched", Some("a"), Some(1)))
689            .await
690            .unwrap();
691        s.append(&r, draft("mw.long_hold_warn", Some("a"), Some(1)))
692            .await
693            .unwrap();
694        s.append(&r, draft("core.step_completed", Some("b"), Some(2)))
695            .await
696            .unwrap();
697
698        let mw = s
699            .list(
700                &r,
701                &TraceQuery {
702                    kinds: vec!["mw.".into()],
703                    ..Default::default()
704                },
705            )
706            .await
707            .unwrap();
708        assert_eq!(mw.len(), 1);
709        assert_eq!(mw[0].kind, "mw.long_hold_warn");
710
711        let step_a = s
712            .list(
713                &r,
714                &TraceQuery {
715                    step_ref: Some("a".into()),
716                    ..Default::default()
717                },
718            )
719            .await
720            .unwrap();
721        assert_eq!(step_a.len(), 2);
722
723        let attempt2 = s
724            .list(
725                &r,
726                &TraceQuery {
727                    attempt: Some(2),
728                    ..Default::default()
729                },
730            )
731            .await
732            .unwrap();
733        assert_eq!(attempt2.len(), 1);
734        assert_eq!(attempt2[0].step_ref.as_deref(), Some("b"));
735    }
736
737    #[tokio::test]
738    async fn list_paging_after_and_latest() {
739        let s = InMemoryRunTraceStore::new();
740        let r = rid("R-1");
741        for i in 0..5 {
742            s.append(&r, draft(&format!("core.e{i}"), None, None))
743                .await
744                .unwrap();
745        }
746
747        let after = s
748            .list(
749                &r,
750                &TraceQuery {
751                    after: Some(3),
752                    ..Default::default()
753                },
754            )
755            .await
756            .unwrap();
757        assert_eq!(after.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![4, 5]);
758
759        let latest = s
760            .list(
761                &r,
762                &TraceQuery {
763                    latest: Some(2),
764                    // latest must win over a cursor the caller also set.
765                    after: Some(1),
766                    ..Default::default()
767                },
768            )
769            .await
770            .unwrap();
771        assert_eq!(latest.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![4, 5]);
772
773        let limited = s
774            .list(
775                &r,
776                &TraceQuery {
777                    limit: Some(2),
778                    ..Default::default()
779                },
780            )
781            .await
782            .unwrap();
783        assert_eq!(
784            limited.iter().map(|e| e.seq).collect::<Vec<_>>(),
785            vec![1, 2]
786        );
787    }
788
789    #[tokio::test]
790    async fn retention_prunes_oldest_keeping_seq() {
791        let s = InMemoryRunTraceStore::with_max_events_per_run(3);
792        let r = rid("R-1");
793        for i in 0..5 {
794            s.append(&r, draft(&format!("core.e{i}"), None, None))
795                .await
796                .unwrap();
797        }
798        let all = s.list(&r, &TraceQuery::default()).await.unwrap();
799        assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4, 5]);
800        // A later append keeps counting up — pruning never recycles seqs.
801        let e6 = s.append(&r, draft("core.e5", None, None)).await.unwrap();
802        assert_eq!(e6.seq, 6);
803    }
804
805    #[tokio::test]
806    async fn delete_run_removes_stream() {
807        let s = InMemoryRunTraceStore::new();
808        let r = rid("R-1");
809        s.append(&r, draft("core.run_started", None, None))
810            .await
811            .unwrap();
812        s.append(&r, draft("core.run_finished", None, None))
813            .await
814            .unwrap();
815        assert_eq!(s.delete_run(&r).await.unwrap(), 2);
816        assert!(s.list(&r, &TraceQuery::default()).await.unwrap().is_empty());
817        assert_eq!(s.delete_run(&r).await.unwrap(), 0, "double delete is Ok(0)");
818    }
819
820    #[tokio::test]
821    async fn oversized_payload_is_truncated_with_marker() {
822        let s = InMemoryRunTraceStore::new();
823        let r = rid("R-1");
824        let big = "x".repeat(TRACE_PAYLOAD_CAP_BYTES + 100);
825        let e = s
826            .append(
827                &r,
828                TraceEventDraft {
829                    kind: "worker.output".into(),
830                    step_ref: None,
831                    attempt: None,
832                    payload: json!({"blob": big}),
833                },
834            )
835            .await
836            .unwrap();
837        assert_eq!(e.payload.get("truncated"), Some(&json!(true)));
838        assert!(e.payload.get("size_bytes").is_some());
839    }
840
841    #[tokio::test]
842    async fn trace_handle_appends_best_effort() {
843        let store: Arc<dyn RunTraceStore> = Arc::new(InMemoryRunTraceStore::new());
844        let handle = TraceHandle::new(rid("R-1"), store.clone());
845        handle
846            .append(kind::STEP_DISPATCHED, Some("w"), Some(1), json!({}))
847            .await;
848        let events = store
849            .list(&rid("R-1"), &TraceQuery::default())
850            .await
851            .unwrap();
852        assert_eq!(events.len(), 1);
853        assert_eq!(events[0].kind, kind::STEP_DISPATCHED);
854    }
855
856    #[test]
857    fn worker_stats_is_empty_reflects_fields() {
858        assert!(WorkerStats::default().is_empty());
859        let stats = WorkerStats {
860            usage: Some(TokenUsage {
861                input_tokens: 1,
862                output_tokens: 2,
863                total_tokens: 3,
864            }),
865            ..Default::default()
866        };
867        assert!(!stats.is_empty());
868    }
869}