Skip to main content

harn_vm/orchestration/records/
from_session.rs

1//! Project a [`RunRecord`] from a persisted agent session.
2//!
3//! Every `harn runs` surface — `report`, `review`, `view`, `inspect`,
4//! `export-training` — opens a run record. Nothing on the agent-session path
5//! writes one: `save_run_record` is called from `std/records`, `std/workflow`,
6//! and `run_review`, so a record exists only when a Harn *script* asks for one.
7//! A host that drives the agent loop directly, which is the canonical path for
8//! an IDE, gets full event persistence and no run record, and every reporting
9//! tool is inapplicable to the run it just finished. Issue #6120 is one such
10//! run: 9 252 persisted events, 2 046 session events, and nothing to open.
11//!
12//! Rather than making run-record emission a per-host obligation — discovered
13//! only when someone tries to report on a run and finds nothing — Harn projects
14//! the record from the session it already persisted. The one place that knows
15//! how to build a `RunRecord` stays in Harn, and any host on Harn's session
16//! store gets the whole reporting surface without writing code.
17//!
18//! ## What a projection can and cannot recover
19//!
20//! A projected record is explicitly marked as one (see [`PROJECTION_SOURCE`]
21//! and the `projected_from` metadata block) so no consumer mistakes it for a
22//! recorder-written record.
23//!
24//! Workflow-shaped fields — stages, transitions, checkpoints, pending and
25//! completed nodes — come back empty. That is not loss: an agent session has no
26//! stages, so empty is the accurate reading rather than a missing value.
27//!
28//! [`UNRECOVERABLE_FIELDS`] names the fields that are genuinely unavailable
29//! from a session alone, and a test asserts that list stays exactly the set the
30//! projector leaves at its default. That way the list cannot rot in either
31//! direction: naming a field that is now populated fails, and populating a
32//! field without removing it from the list fails too.
33
34use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36
37use harn_session_store::{
38    EventId, ListFilter, ReadRange, SessionMeta, SessionStatus, SessionStore, StoreError,
39    StoredEvent, MAX_READ_BATCH,
40};
41use rust_decimal::prelude::ToPrimitive;
42use rust_decimal::Decimal;
43use serde_json::json;
44
45use super::types::{LlmUsageRecord, RunChildRecord, RunRecord, RunTraceSpanRecord, ToolCallRecord};
46use crate::agent_sessions::event_facts as facts;
47use crate::value::VmError;
48
49/// Marker written into `metadata.projected_from.source`, identifying both that
50/// this record was projected and what it was projected from.
51pub const PROJECTION_SOURCE: &str = "harn.session_store.v1";
52
53/// Placeholder workflow id for a run that had no workflow.
54///
55/// An agent session is a run, but not a workflow run. Naming that explicitly
56/// beats an empty string, which reads as "we forgot" rather than "there wasn't
57/// one".
58pub const AGENT_SESSION_WORKFLOW_ID: &str = "agent-session";
59
60/// Dotted `RunRecord` field paths a session projection cannot source.
61///
62/// - `usage.total_duration_ms` and `trace_spans[].duration_ms`: `llm_call`
63///   session events carry tokens, cost, model, and provider, but no per-call
64///   latency. `harn runs report --events-db` already joins the event log, which
65///   does record it; that is the seam for latency rather than a guess made
66///   here. Each projected span carries `duration_available: false` so a zero is
67///   not mistaken for a measurement.
68/// - `policy`: the capability policy is a launch-time input, not something the
69///   session replays.
70/// - `replay_fixture`: derived by `save_run_record` from the assembled record,
71///   so a projection has nothing of its own to contribute.
72pub const UNRECOVERABLE_FIELDS: [&str; 4] = [
73    "usage.total_duration_ms",
74    "trace_spans[].duration_ms",
75    "policy",
76    "replay_fixture",
77];
78
79/// Project the session `session_id` into a [`RunRecord`].
80///
81/// Reads only through the store, so the same projection serves a SQLite store
82/// on disk and an in-memory one under test.
83pub async fn project_run_record_from_session(
84    store: &dyn SessionStore,
85    session_id: &str,
86) -> Result<RunRecord, VmError> {
87    let meta = store
88        .describe(session_id)
89        .await
90        .map_err(|error| match error {
91            StoreError::NotFound(_) => VmError::Runtime(format!(
92                "runs: no session '{session_id}' in this store. `harn session list` shows the \
93             sessions this workspace has persisted."
94            )),
95            other => VmError::Runtime(format!("runs: failed to describe session: {other}")),
96        })?;
97    let events = drain_events(store, session_id).await?;
98    let children = child_records(store, session_id).await?;
99    let root = root_session_id(store, &meta).await?;
100    Ok(assemble(meta, events, children, root))
101}
102
103/// Walk `parent_session_id` to the top of the delegation chain.
104///
105/// A grandchild's root is its grandparent, not its parent, so this cannot stop
106/// at one hop. The visited set bounds a store whose lineage has somehow become
107/// cyclic: reporting the last session before the cycle beats looping forever
108/// inside a reporting command.
109async fn root_session_id(store: &dyn SessionStore, meta: &SessionMeta) -> Result<String, VmError> {
110    let mut visited = std::collections::HashSet::from([meta.id.clone()]);
111    let mut current = meta.parent_session_id.clone();
112    let mut root = meta.id.clone();
113    while let Some(parent) = current {
114        if !visited.insert(parent.clone()) {
115            break;
116        }
117        match store.describe(&parent).await {
118            Ok(parent_meta) => {
119                root = parent_meta.id.clone();
120                current = parent_meta.parent_session_id;
121            }
122            // A parent pruned by retention leaves the deepest session we can
123            // still see as the root we can honestly name.
124            Err(StoreError::NotFound(_)) => {
125                root = parent;
126                break;
127            }
128            Err(error) => {
129                return Err(VmError::Runtime(format!(
130                    "runs: failed to walk session lineage: {error}"
131                )))
132            }
133        }
134    }
135    Ok(root)
136}
137
138/// Project `session_id` out of the canonical store under `root` and persist the
139/// result, returning the written path.
140///
141/// This is the whole host-facing surface: a host that already writes to Harn's
142/// session store needs one call to make every `harn runs` tool apply to a run.
143pub async fn materialize_session_run_record(
144    root: &Path,
145    session_id: &str,
146    out: Option<&Path>,
147) -> Result<String, VmError> {
148    let store =
149        crate::stdlib::session_store::open_existing_canonical_store(root)?.ok_or_else(|| {
150            VmError::Runtime(format!(
151                "runs: no session store under {}. A projected run record needs \
152                 `.harn/session-store.sqlite`; pass the workspace root that holds it.",
153                root.display()
154            ))
155        })?;
156    let run = project_run_record_from_session(&store, session_id).await?;
157    let path = out
158        .map(Path::to_path_buf)
159        .unwrap_or_else(|| default_projection_path(root, session_id));
160    super::persistence::save_run_record(&run, Some(&path.to_string_lossy()))
161}
162
163/// Where a projected record lands when the caller names no path.
164///
165/// Deterministic by session id so a host can resolve the record for a session
166/// without threading a path back through its own state — which is the join
167/// burin-code#5831 needs and the `sessions` table has no column for.
168pub fn default_projection_path(root: &Path, session_id: &str) -> PathBuf {
169    crate::runtime_paths::run_root(root).join(format!("{session_id}.json"))
170}
171
172/// One row of `harn session list`: enough to pick a session to report on
173/// without opening the store by hand.
174///
175/// Built from [`SessionMeta`] alone rather than by draining events, so listing
176/// a workspace with thousands of persisted events stays a single query.
177#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
178pub struct SessionRunSummary {
179    pub session_id: String,
180    /// The session's own lifecycle status: `open`, `closed`, `soft_deleted`,
181    /// or `hard_deleted`. Not the run's status — a host that exits without
182    /// closing leaves a finished run's session `open`.
183    pub session_status: String,
184    pub title: Option<String>,
185    pub parent_session_id: Option<String>,
186    pub created_at: String,
187    pub updated_at: String,
188    pub event_count: usize,
189    pub input_tokens: u64,
190    pub output_tokens: u64,
191    pub cost_usd_micros: u64,
192    /// Path to an already-materialized run record for this session, when one
193    /// exists. `None` means `--from-session` would project a fresh one.
194    pub run_record_path: Option<String>,
195}
196
197/// List the sessions persisted under `root`, newest first.
198///
199/// Without this, `--from-session` needs an id the caller can only get by
200/// opening SQLite by hand, so the reporting surface stays as unreachable as it
201/// was before it accepted sessions at all.
202pub async fn list_session_runs(
203    root: &Path,
204    limit: Option<usize>,
205) -> Result<Vec<SessionRunSummary>, VmError> {
206    let Some(store) = crate::stdlib::session_store::open_existing_canonical_store(root)? else {
207        return Ok(Vec::new());
208    };
209    let sessions = store
210        .list(ListFilter {
211            limit,
212            sort_by: harn_session_store::ListSortKey::CreatedAt,
213            order: harn_session_store::ListOrder::Descending,
214            ..ListFilter::default()
215        })
216        .await
217        .map_err(|error| VmError::Runtime(format!("runs: failed to list sessions: {error}")))?;
218    Ok(sessions
219        .into_iter()
220        .map(|meta| {
221            let record = default_projection_path(root, &meta.id);
222            SessionRunSummary {
223                session_status: status_discriminator(&meta.status).to_string(),
224                title: meta.title.clone(),
225                parent_session_id: meta.parent_session_id.clone(),
226                created_at: meta.created_at.clone(),
227                updated_at: meta.updated_at.clone(),
228                event_count: meta.event_count,
229                input_tokens: meta.usage_input,
230                output_tokens: meta.usage_output,
231                cost_usd_micros: meta.usage_cost_usd_micros,
232                run_record_path: record
233                    .is_file()
234                    .then(|| record.to_string_lossy().into_owned()),
235                session_id: meta.id,
236            }
237        })
238        .collect())
239}
240
241async fn drain_events(
242    store: &dyn SessionStore,
243    session_id: &str,
244) -> Result<Vec<StoredEvent>, VmError> {
245    let mut all = Vec::new();
246    let mut cursor: Option<EventId> = None;
247    loop {
248        let page = store
249            .read(
250                session_id,
251                ReadRange {
252                    from_event_id: cursor,
253                    to_event_id: None,
254                    limit: Some(MAX_READ_BATCH),
255                },
256            )
257            .await
258            .map_err(|error| {
259                VmError::Runtime(format!("runs: failed to read session events: {error}"))
260            })?;
261        let next = page.next_cursor;
262        all.extend(page.events);
263        match next {
264            Some(next_cursor) => cursor = Some(next_cursor),
265            None => break,
266        }
267    }
268    Ok(all)
269}
270
271/// Direct children of this session, as the store's own lineage records them.
272///
273/// `sessions.parent_session_id` is the delegation edge, so children come back
274/// without re-deriving lineage from worker metadata the way a recorder-written
275/// record has to.
276async fn child_records(
277    store: &dyn SessionStore,
278    session_id: &str,
279) -> Result<Vec<RunChildRecord>, VmError> {
280    let children = store
281        .list(ListFilter {
282            parent_session_id: Some(session_id.to_string()),
283            ..ListFilter::default()
284        })
285        .await
286        .map_err(|error| {
287            VmError::Runtime(format!("runs: failed to list child sessions: {error}"))
288        })?;
289    Ok(children
290        .into_iter()
291        .map(|child| RunChildRecord {
292            worker_id: child.id.clone(),
293            worker_name: child.persona.clone().unwrap_or_default(),
294            session_id: Some(child.id.clone()),
295            parent_session_id: Some(session_id.to_string()),
296            task: child.title.clone().unwrap_or_default(),
297            status: run_status_for(&child.status, None, None).to_string(),
298            started_at: child.created_at.clone(),
299            finished_at: child.closed_at.clone(),
300            run_id: Some(child.id.clone()),
301            ..RunChildRecord::default()
302        })
303        .collect())
304}
305
306/// Facts folded out of one pass over the session's events.
307#[derive(Default)]
308struct SessionFold {
309    task: Option<String>,
310    usage: LlmUsageRecord,
311    models: Vec<String>,
312    providers: Vec<String>,
313    cache_read_tokens: i64,
314    cache_write_tokens: i64,
315    /// Provider requests across every call, and why the extra ones happened.
316    /// Distinct from `usage.call_count`, which counts logical calls: a run
317    /// whose provider rejected a third of its requests with a retryable 429
318    /// reported a clean call count and no contention signal at all (#5847).
319    provider_attempts: i64,
320    rate_limited_attempts: i64,
321    empty_completion_attempts: i64,
322    other_retry_attempts: i64,
323    /// Cost accumulated as an exact decimal rather than by adding `f64`s.
324    /// Summing 96 float costs from a real run produced
325    /// `0.6060984600000002`; money is a base-10 quantity and a run report is
326    /// read by people reconciling spend, so the accumulator is exact and the
327    /// single conversion to `f64` happens once at the boundary the record type
328    /// requires.
329    total_cost: Decimal,
330    tools: Vec<ToolCallRecord>,
331    /// Index into `tools` by provider tool-call id, so a later update or result
332    /// lands on the call it belongs to rather than on whichever call was last.
333    tool_index: BTreeMap<String, usize>,
334    iteration: usize,
335    max_iteration: usize,
336    terminal: Option<TerminalFacts>,
337    llm_calls: Vec<LlmCallFacts>,
338}
339
340/// One provider call as the session recorded it.
341///
342/// Kept separate from the running `usage` aggregate because the report's
343/// per-call view needs each call individually, and a total cannot be
344/// un-summed.
345struct LlmCallFacts {
346    at_ms: i64,
347    model: Option<String>,
348    provider: Option<String>,
349    input_tokens: i64,
350    output_tokens: i64,
351    cache_read_tokens: i64,
352    cache_write_tokens: i64,
353    cost_usd: Option<f64>,
354}
355
356struct TerminalFacts {
357    final_status: Option<String>,
358    stop_reason: Option<String>,
359    error: Option<String>,
360    class: Option<String>,
361    kind: Option<crate::agent_events::AgentTerminalKind>,
362    owner: Option<String>,
363    reason: Option<String>,
364    at: String,
365}
366
367fn assemble(
368    meta: SessionMeta,
369    events: Vec<StoredEvent>,
370    children: Vec<RunChildRecord>,
371    root_run_id: String,
372) -> RunRecord {
373    let mut fold = SessionFold::default();
374    for event in &events {
375        fold.absorb(event);
376    }
377
378    let status = run_status_for(
379        &meta.status,
380        fold.terminal.as_ref().and_then(|terminal| terminal.kind),
381        fold.terminal
382            .as_ref()
383            .and_then(|t| t.final_status.as_deref()),
384    )
385    .to_string();
386    // A session left `open` by a host that exited without closing it still has
387    // a terminal event; that event's timestamp is when the run actually ended,
388    // and preferring `closed_at` when present keeps a cleanly closed session
389    // authoritative over it.
390    let finished_at = meta
391        .closed_at
392        .clone()
393        .or_else(|| fold.terminal.as_ref().map(|t| t.at.clone()));
394
395    let mut metadata = BTreeMap::new();
396    metadata.insert(
397        "projected_from".to_string(),
398        json!({
399            "source": PROJECTION_SOURCE,
400            "session_id": meta.id,
401            "session_status": status_discriminator(&meta.status),
402            "session_event_count": meta.event_count,
403            // Named for the *source*, not the file: `save_run_record` derives a
404            // `replay_fixture` from the assembled record on the way to disk, so
405            // a persisted projection has one even though the session never
406            // carried it. The claim here is about what the session could tell
407            // us, which is what a reader deciding whether to trust a field
408            // needs to know.
409            "not_recoverable_from_session": UNRECOVERABLE_FIELDS,
410        }),
411    );
412    // Wall clock is the session's own span. It is deliberately not folded into
413    // `usage.total_duration_ms`, which means time spent inside LLM calls and is
414    // not recoverable here — reporting one as the other would overstate model
415    // time by every second the run spent running tools.
416    metadata.insert(
417        "wall_clock_ms".to_string(),
418        json!(meta.updated_at_ms.saturating_sub(meta.created_at_ms)),
419    );
420    if fold.max_iteration > 0 {
421        metadata.insert("iterations".to_string(), json!(fold.max_iteration));
422    }
423    if fold.cache_read_tokens > 0 || fold.cache_write_tokens > 0 {
424        metadata.insert(
425            "cache_tokens".to_string(),
426            json!({"read": fold.cache_read_tokens, "write": fold.cache_write_tokens}),
427        );
428    }
429    if !fold.providers.is_empty() {
430        metadata.insert("providers".to_string(), json!(fold.providers));
431    }
432    // Only reported when the run actually retried. A block of zeroes on every
433    // clean run would train a reader to skip the one place the contention
434    // signal appears.
435    if fold.provider_attempts > fold.usage.call_count {
436        metadata.insert(
437            "provider_attempts".to_string(),
438            json!({
439                "total": fold.provider_attempts,
440                "retries": fold.provider_attempts - fold.usage.call_count,
441                "rate_limited": fold.rate_limited_attempts,
442                "empty_completion": fold.empty_completion_attempts,
443                "other": fold.other_retry_attempts,
444            }),
445        );
446    }
447    if let Some(terminal) = &fold.terminal {
448        if let Some(stop_reason) = &terminal.stop_reason {
449            metadata.insert("stop_reason".to_string(), json!(stop_reason));
450        }
451        if let Some(class) = &terminal.class {
452            metadata.insert("terminal_class".to_string(), json!(class));
453        }
454        if let Some(error) = &terminal.error {
455            metadata.insert("terminal_error".to_string(), json!(error));
456        }
457        if let Some(kind) = terminal.kind {
458            metadata.insert(
459                "terminal".to_string(),
460                json!({
461                    "kind": kind.as_str(),
462                    "reason": terminal.reason.as_deref().or(terminal.stop_reason.as_deref()),
463                    "owner": terminal.owner.as_deref().unwrap_or_else(|| kind.owner()),
464                }),
465            );
466        }
467    }
468
469    let usage = LlmUsageRecord {
470        models: fold.models.clone(),
471        total_cost: fold.total_cost.to_f64().unwrap_or_default(),
472        ..fold.usage
473    };
474
475    RunRecord {
476        type_name: "run".to_string(),
477        id: meta.id.clone(),
478        workflow_id: AGENT_SESSION_WORKFLOW_ID.to_string(),
479        workflow_name: meta.persona.clone(),
480        // Title when a host or person named the run; otherwise the first thing
481        // the run was actually asked to do. Not truncated: what the run was
482        // given is the fact, and display surfaces can shorten it.
483        task: meta.title.clone().or(fold.task).unwrap_or_default(),
484        status,
485        started_at: meta.created_at.clone(),
486        finished_at,
487        parent_run_id: meta.parent_session_id.clone(),
488        root_run_id: Some(root_run_id),
489        child_runs: children,
490        usage: (usage.call_count > 0).then_some(usage),
491        trace_spans: llm_call_spans(&meta, &fold.llm_calls),
492        tool_recordings: fold.tools,
493        execution: None,
494        metadata,
495        ..RunRecord::default()
496    }
497}
498
499impl SessionFold {
500    fn absorb(&mut self, event: &StoredEvent) {
501        match event.kind.discriminator() {
502            "message" => self.absorb_message(event),
503            "tool_call" => self.absorb_tool_call(event),
504            "tool_call_update" => self.absorb_tool_update(event),
505            "tool_result" => self.absorb_tool_result(event),
506            "llm_call" => self.absorb_llm_call(event),
507            "loop_checkpoint" => self.absorb_checkpoint(event),
508            "agent_run_terminal" => self.absorb_terminal(event),
509            _ => {}
510        }
511    }
512
513    fn absorb_message(&mut self, event: &StoredEvent) {
514        if self.task.is_some() {
515            return;
516        }
517        let is_user = event.actor.as_deref() == Some("user")
518            || facts::semantic_string(&event.payload, &facts::ROLE).as_deref() == Some("user");
519        if is_user {
520            self.task = facts::semantic_string(&event.payload, &facts::TEXT);
521        }
522    }
523
524    fn absorb_llm_call(&mut self, event: &StoredEvent) {
525        let payload = &event.payload;
526        self.llm_calls.push(LlmCallFacts {
527            at_ms: event.ts_ms,
528            model: facts::string_at(payload, facts::MODEL),
529            provider: facts::string_at(payload, facts::PROVIDER),
530            input_tokens: facts::i64_at(payload, facts::INPUT_TOKENS).unwrap_or(0),
531            output_tokens: facts::i64_at(payload, facts::OUTPUT_TOKENS).unwrap_or(0),
532            cache_read_tokens: facts::i64_at(payload, facts::CACHE_READ_TOKENS).unwrap_or(0),
533            cache_write_tokens: facts::i64_at(payload, facts::CACHE_WRITE_TOKENS).unwrap_or(0),
534            cost_usd: facts::f64_at(payload, facts::COST_USD),
535        });
536        self.usage.call_count += 1;
537        self.usage.input_tokens += facts::i64_at(payload, facts::INPUT_TOKENS).unwrap_or(0);
538        self.usage.output_tokens += facts::i64_at(payload, facts::OUTPUT_TOKENS).unwrap_or(0);
539        if let Some(cost) = facts::f64_at(payload, facts::COST_USD) {
540            self.total_cost += Decimal::from_f64_retain(cost).unwrap_or_default();
541        }
542        self.cache_read_tokens += facts::i64_at(payload, facts::CACHE_READ_TOKENS).unwrap_or(0);
543        self.cache_write_tokens += facts::i64_at(payload, facts::CACHE_WRITE_TOKENS).unwrap_or(0);
544        // A call recorded before provider attempts existed has no entry. It
545        // still made at least one request, so counting 1 keeps the total a
546        // lower bound rather than under-reporting a mixed-age session.
547        self.provider_attempts += facts::i64_at(payload, facts::PROVIDER_ATTEMPTS_TOTAL)
548            .filter(|total| *total > 0)
549            .unwrap_or(1);
550        self.rate_limited_attempts +=
551            facts::i64_at(payload, facts::PROVIDER_ATTEMPTS_RATE_LIMITED).unwrap_or(0);
552        self.empty_completion_attempts +=
553            facts::i64_at(payload, facts::PROVIDER_ATTEMPTS_EMPTY).unwrap_or(0);
554        self.other_retry_attempts +=
555            facts::i64_at(payload, facts::PROVIDER_ATTEMPTS_OTHER).unwrap_or(0);
556        if let Some(model) = facts::string_at(payload, facts::MODEL) {
557            push_distinct(&mut self.models, model);
558        }
559        if let Some(provider) = facts::string_at(payload, facts::PROVIDER) {
560            push_distinct(&mut self.providers, provider);
561        }
562    }
563
564    fn absorb_checkpoint(&mut self, event: &StoredEvent) {
565        if facts::string_at(&event.payload, facts::CHECKPOINT_KIND).as_deref()
566            != Some("iteration_start")
567        {
568            return;
569        }
570        if let Some(iteration) = facts::i64_at(&event.payload, facts::ITERATION) {
571            self.iteration = usize::try_from(iteration).unwrap_or(0);
572            self.max_iteration = self.max_iteration.max(self.iteration);
573        }
574    }
575
576    fn absorb_tool_call(&mut self, event: &StoredEvent) {
577        let payload = &event.payload;
578        let Some(tool_call_id) = facts::string_at(payload, facts::TOOL_CALL_ID) else {
579            return;
580        };
581        if self.tool_index.contains_key(&tool_call_id) {
582            return;
583        }
584        let args = facts::semantic_value(payload, &[facts::TOOL_RAW_INPUT])
585            .unwrap_or(serde_json::Value::Null);
586        let tool_name = facts::semantic_string(payload, &facts::TOOL_NAME_ANY).unwrap_or_default();
587        self.tool_index
588            .insert(tool_call_id.clone(), self.tools.len());
589        self.tools.push(ToolCallRecord {
590            args_hash: super::types::tool_fixture_hash(&tool_name, &args),
591            tool_name,
592            tool_use_id: tool_call_id,
593            iteration: self.iteration,
594            timestamp: event.ts.clone(),
595            ..ToolCallRecord::default()
596        });
597    }
598
599    fn absorb_tool_update(&mut self, event: &StoredEvent) {
600        let payload = &event.payload;
601        let Some(record) = self.tool_for(payload) else {
602            return;
603        };
604        let status = facts::string_at(payload, facts::TOOL_STATUS);
605        // Only a terminal update carries a duration, and only a terminal update
606        // should overwrite a rejection already recorded for this call.
607        match status.as_deref() {
608            Some("completed") | Some("failed") | Some("rejected") => {
609                record.is_rejected = status.as_deref() == Some("rejected");
610                if let Some(duration) = facts::i64_at(payload, facts::TOOL_DURATION_MS) {
611                    record.duration_ms = u64::try_from(duration).unwrap_or(0);
612                }
613            }
614            _ => {}
615        }
616    }
617
618    /// Attach a tool's output to the call it answers.
619    ///
620    /// `is_error` is deliberately not folded into `is_rejected`: a tool that
621    /// ran and failed is not a tool whose call was refused, and a rejected call
622    /// still emits a result event, so treating the two as one would clear the
623    /// rejection recorded moments earlier. The failure stays legible in
624    /// `result`, which carries the error text verbatim.
625    fn absorb_tool_result(&mut self, event: &StoredEvent) {
626        let payload = &event.payload;
627        let text = facts::semantic_string(payload, &facts::TEXT).unwrap_or_default();
628        let Some(record) = self.tool_for(payload) else {
629            return;
630        };
631        record.result = text;
632    }
633
634    /// Resolve the recorded call this event belongs to, by provider tool-call
635    /// id. Returns `None` for an event that names no call or names one this
636    /// session never opened.
637    fn tool_for(&mut self, payload: &serde_json::Value) -> Option<&mut ToolCallRecord> {
638        let tool_call_id = facts::string_at(payload, facts::TOOL_CALL_ID)?;
639        let index = *self.tool_index.get(&tool_call_id)?;
640        self.tools.get_mut(index)
641    }
642
643    fn absorb_terminal(&mut self, event: &StoredEvent) {
644        self.terminal = Some(TerminalFacts {
645            final_status: facts::string_at(&event.payload, facts::FINAL_STATUS),
646            stop_reason: facts::string_at(&event.payload, facts::STOP_REASON),
647            error: facts::string_at(&event.payload, facts::TERMINAL_ERROR),
648            class: facts::string_at(&event.payload, facts::TERMINAL_CLASS),
649            kind: facts::string_at(&event.payload, facts::TERMINAL_KIND)
650                .as_deref()
651                .and_then(crate::agent_events::AgentTerminalKind::from_wire),
652            owner: facts::string_at(&event.payload, facts::TERMINAL_OWNER),
653            reason: facts::string_at(&event.payload, facts::TERMINAL_REASON),
654            at: event.ts.clone(),
655        });
656    }
657}
658
659/// Map a session's lifecycle status and its loop's terminal status onto the
660/// run-record status vocabulary.
661///
662/// The loop's own verdict wins when it left one: a host that exits without
663/// closing the session leaves `status = open`, which would otherwise report a
664/// finished run as still running.
665fn run_status_for(
666    session_status: &SessionStatus,
667    terminal_kind: Option<crate::agent_events::AgentTerminalKind>,
668    final_status: Option<&str>,
669) -> &'static str {
670    if let Some(kind) = terminal_kind {
671        return kind.lifecycle_state().wire_name();
672    }
673    if let Some(final_status) = final_status.filter(|value| !value.is_empty()) {
674        return if crate::llm::session_status_indicates_error(final_status) {
675            "failed"
676        } else {
677            "completed"
678        };
679    }
680    match session_status {
681        SessionStatus::Open => "running",
682        SessionStatus::Closed => "completed",
683        SessionStatus::SoftDeleted | SessionStatus::HardDeleted => "deleted",
684    }
685}
686
687fn status_discriminator(status: &SessionStatus) -> &'static str {
688    match status {
689        SessionStatus::Open => "open",
690        SessionStatus::Closed => "closed",
691        SessionStatus::SoftDeleted => "soft_deleted",
692        SessionStatus::HardDeleted => "hard_deleted",
693    }
694}
695
696/// Project each recorded provider call into an `llm_call` trace span.
697///
698/// Without these the run report's `llm_calls` array comes back empty, which
699/// reads as "this run made no model calls" rather than "the per-call view has
700/// no source here" — affirmatively wrong for a run that made 96 of them.
701///
702/// `duration_ms` is 0 because a session `llm_call` event records tokens, cost,
703/// model, and provider but no latency, and the field is not optional. Each span
704/// says so in its metadata rather than letting a zero pass as a measurement:
705/// `--events-db` is the seam that carries real timing, and
706/// [`UNRECOVERABLE_FIELDS`] names this alongside `usage.total_duration_ms`.
707///
708/// `start_ms` is relative to the session's creation, matching the collector's
709/// epoch-relative convention. Absolute epoch milliseconds would be a different
710/// unit in the same field.
711fn llm_call_spans(meta: &SessionMeta, calls: &[LlmCallFacts]) -> Vec<RunTraceSpanRecord> {
712    calls
713        .iter()
714        .enumerate()
715        .map(|(index, call)| {
716            let mut metadata = BTreeMap::from([
717                (
718                    crate::tracing::meta::INPUT_TOKENS.to_string(),
719                    json!(call.input_tokens),
720                ),
721                (
722                    crate::tracing::meta::OUTPUT_TOKENS.to_string(),
723                    json!(call.output_tokens),
724                ),
725                (
726                    crate::tracing::meta::CACHE_READ_TOKENS.to_string(),
727                    json!(call.cache_read_tokens),
728                ),
729                (
730                    crate::tracing::meta::CACHE_WRITE_TOKENS.to_string(),
731                    json!(call.cache_write_tokens),
732                ),
733                ("duration_available".to_string(), json!(false)),
734            ]);
735            if let Some(model) = &call.model {
736                metadata.insert(crate::tracing::meta::MODEL.to_string(), json!(model));
737            }
738            if let Some(provider) = &call.provider {
739                metadata.insert(crate::tracing::meta::PROVIDER.to_string(), json!(provider));
740            }
741            RunTraceSpanRecord {
742                trace_id: meta.id.clone(),
743                // One-based so span ids stay distinguishable from the absent
744                // parent, which is `None` rather than 0.
745                span_id: index as u64 + 1,
746                parent_id: None,
747                kind: "llm_call".to_string(),
748                name: call.model.clone().unwrap_or_else(|| "llm_call".to_string()),
749                start_ms: u64::try_from(call.at_ms.saturating_sub(meta.created_at_ms)).unwrap_or(0),
750                duration_ms: 0,
751                ttft_ms: None,
752                metadata,
753                links: Vec::new(),
754                cost_usd: call.cost_usd,
755            }
756        })
757        .collect()
758}
759
760fn push_distinct(values: &mut Vec<String>, value: String) {
761    if !values.contains(&value) {
762        values.push(value);
763    }
764}
765
766#[cfg(test)]
767mod tests;