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