Skip to main content

harn_vm/
session_timeline.rs

1//! Session timeline projection for client-facing observability.
2//!
3//! This module does not own persistence. It projects the existing run record
4//! spans and event-log topics into one stable, redacted shape that clients can
5//! query or subscribe to without learning Harn's storage internals.
6
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::hash::{DefaultHasher, Hash, Hasher};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use futures::stream::{self, BoxStream};
13use futures::StreamExt;
14use harn_session_store::{
15    ListFilter, ReadRange, SessionEventKind, SessionMeta, SessionStore, StoredEvent, MAX_READ_BATCH,
16};
17use serde::{Deserialize, Serialize};
18
19use crate::event_log::{AnyEventLog, EventId, EventLog, LogError, LogEvent, Topic};
20use crate::orchestration::{load_run_record, RunRecord, RunTraceSpanRecord};
21use crate::redact::{current_policy, RedactionPolicy};
22
23pub const SESSION_TIMELINE_SCHEMA_VERSION: u32 = 1;
24pub const SESSION_TIMELINE_QUERY_METHOD: &str = "harn.session_timeline.query";
25pub const SESSION_TIMELINE_SUBSCRIBE_METHOD: &str = "harn.session_timeline.subscribe";
26pub const SESSION_TIMELINE_UNSUBSCRIBE_METHOD: &str = "harn.session_timeline.unsubscribe";
27pub const SESSION_TIMELINE_UPDATE_METHOD: &str = "harn.session_timeline.update";
28
29const DEFAULT_QUERY_LIMIT: usize = 1024;
30const READ_BATCH_SIZE: usize = 256;
31
32#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(default, rename_all = "camelCase")]
34pub struct SessionTimelineQuery {
35    #[serde(alias = "session_id")]
36    pub session_id: Option<String>,
37    #[serde(alias = "run_id")]
38    pub run_id: Option<String>,
39    #[serde(alias = "run_path")]
40    pub run_path: Option<String>,
41    #[serde(alias = "project_id")]
42    pub project_id: Option<String>,
43    #[serde(alias = "from_cursor")]
44    pub from_cursor: SessionTimelineCursor,
45    pub limit: Option<usize>,
46}
47
48impl SessionTimelineQuery {
49    pub fn for_session(session_id: impl Into<String>) -> Self {
50        Self {
51            session_id: Some(session_id.into()),
52            ..Self::default()
53        }
54    }
55
56    fn limit(&self) -> usize {
57        self.limit.unwrap_or(DEFAULT_QUERY_LIMIT).max(1)
58    }
59
60    fn topics(&self) -> Vec<Topic> {
61        let mut topics = Vec::new();
62        if let Some(session_id) = self.session_id.as_deref() {
63            topics.push(agent_events_topic(session_id));
64        }
65        topics.push(static_topic(crate::channels::CHANNEL_TRANSCRIPT_TOPIC));
66        topics.push(static_topic(crate::channels::CHANNEL_AUDIT_TOPIC));
67        topics
68    }
69}
70
71#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(default)]
73pub struct SessionTimelineCursor {
74    pub topics: BTreeMap<String, EventId>,
75}
76
77impl SessionTimelineCursor {
78    pub fn event_id_for(&self, topic: &Topic) -> Option<EventId> {
79        self.topics.get(topic.as_str()).copied()
80    }
81
82    fn bump(&mut self, topic: &str, event_id: EventId) {
83        self.topics
84            .entry(topic.to_string())
85            .and_modify(|cursor| *cursor = (*cursor).max(event_id))
86            .or_insert(event_id);
87    }
88}
89
90#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
91#[serde(rename_all = "camelCase")]
92pub struct SessionTimelineSnapshot {
93    pub schema_version: u32,
94    pub query: SessionTimelineQuery,
95    pub cursor: SessionTimelineCursor,
96    pub nodes: Vec<SessionTimelineNode>,
97}
98
99#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(rename_all = "camelCase")]
101pub struct SessionTimelineUpdate {
102    pub schema_version: u32,
103    pub cursor: SessionTimelineCursor,
104    pub node: SessionTimelineNode,
105}
106
107#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
108#[serde(rename_all = "camelCase")]
109pub struct SessionTimelineNode {
110    pub id: String,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub parent_id: Option<String>,
113    #[serde(default)]
114    pub children: Vec<String>,
115    pub category: String,
116    pub kind: String,
117    pub name: String,
118    pub status: String,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub trace_id: Option<String>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub span_id: Option<String>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub occurred_at_ms: Option<i64>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub start_ms: Option<u64>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub duration_ms: Option<u64>,
129    #[serde(default)]
130    pub attributes: serde_json::Value,
131    #[serde(default)]
132    pub references: Vec<SessionTimelineReference>,
133    #[serde(default)]
134    pub links: Vec<SessionTimelineLink>,
135    pub order: u64,
136}
137
138#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "camelCase")]
140pub struct SessionTimelineReference {
141    pub kind: String,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub id: Option<String>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub topic: Option<String>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub event_id: Option<EventId>,
148}
149
150#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "camelCase")]
152pub struct SessionTimelineLink {
153    pub kind: String,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub target_id: Option<String>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub trace_id: Option<String>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub span_id: Option<String>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub event_id: Option<String>,
162}
163
164#[derive(Debug)]
165pub enum SessionTimelineError {
166    EventLog(LogError),
167    RunRecord(String),
168    SessionStore(String),
169}
170
171impl std::fmt::Display for SessionTimelineError {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        match self {
174            Self::EventLog(error) => error.fmt(f),
175            Self::RunRecord(message) => f.write_str(message),
176            Self::SessionStore(message) => f.write_str(message),
177        }
178    }
179}
180
181impl std::error::Error for SessionTimelineError {}
182
183impl From<LogError> for SessionTimelineError {
184    fn from(error: LogError) -> Self {
185        Self::EventLog(error)
186    }
187}
188
189#[derive(Clone)]
190struct TimelineDraft {
191    sort_ms: i128,
192    sequence: u64,
193    node: SessionTimelineNode,
194}
195
196pub fn agent_events_topic(session_id: &str) -> Topic {
197    Topic::new(format!(
198        "observability.agent_events.{}",
199        crate::event_log::sanitize_topic_component(session_id)
200    ))
201    .expect("sanitized session id should produce a valid topic")
202}
203
204pub fn timeline_from_run_record(
205    run: &RunRecord,
206    query: SessionTimelineQuery,
207) -> SessionTimelineSnapshot {
208    let policy = current_policy();
209    let mut builder = TimelineBuilder::new(query.clone());
210    if run_matches_query(run, &query) {
211        builder.add_run_spans(run, &policy);
212    }
213    builder.finish()
214}
215
216pub async fn query_session_timeline(
217    log: Option<&AnyEventLog>,
218    run: Option<&RunRecord>,
219    query: SessionTimelineQuery,
220) -> Result<SessionTimelineSnapshot, SessionTimelineError> {
221    let policy = current_policy();
222    let mut builder = TimelineBuilder::new(query.clone());
223    if let Some(run) = run.filter(|run| run_matches_query(run, &query)) {
224        builder.add_run_spans(run, &policy);
225    } else if run.is_none() {
226        if let Some(run) = load_run_for_timeline(&query)? {
227            if run_matches_query(&run, &query) {
228                builder.add_run_spans(&run, &policy);
229            }
230        }
231    }
232    if let Some(log) = log {
233        builder.add_event_log(log, &policy).await?;
234    }
235    Ok(builder.finish())
236}
237
238/// Project durable canonical transcript rows into the same timeline contract
239/// used by live observability. Returns `None` when the project has no canonical
240/// store or the requested session does not exist.
241pub async fn query_persisted_session_timeline(
242    project_root: &Path,
243    query: SessionTimelineQuery,
244) -> Result<Option<SessionTimelineSnapshot>, SessionTimelineError> {
245    let Some(store) = crate::stdlib::session_store::open_existing_canonical_store(project_root)
246        .map_err(|error| SessionTimelineError::SessionStore(error.to_string()))?
247    else {
248        return Ok(None);
249    };
250    query_session_store_timeline(&store, query).await
251}
252
253/// Project one [`SessionStore`] through Harn's canonical semantic timeline.
254///
255/// Transport adapters use this seam so HTTP, ACP, and embedded callers receive
256/// identical node identity, grouping, status, references, links, and redaction
257/// without learning how stored event envelopes map to product chronology.
258pub async fn query_session_store_timeline(
259    store: &dyn SessionStore,
260    query: SessionTimelineQuery,
261) -> Result<Option<SessionTimelineSnapshot>, SessionTimelineError> {
262    let Some(session_id) = query.session_id.as_deref() else {
263        return Ok(None);
264    };
265
266    let topic = canonical_session_topic(session_id);
267    let mut from = query.from_cursor.topics.get(&topic).copied();
268    let mut builder = TimelineBuilder::new(query.clone());
269    let mut saw_event = false;
270    loop {
271        let remaining = query.limit().saturating_sub(builder.nodes.len()).max(1);
272        let page = match store
273            .read(
274                session_id,
275                ReadRange {
276                    from_event_id: from,
277                    limit: Some(remaining.min(MAX_READ_BATCH)),
278                    ..ReadRange::default()
279                },
280            )
281            .await
282        {
283            Ok(page) => page,
284            Err(harn_session_store::StoreError::NotFound(_)) => return Ok(None),
285            Err(error) => return Err(SessionTimelineError::SessionStore(error.to_string())),
286        };
287        saw_event |= !page.events.is_empty();
288        for event in page.events {
289            builder.add_stored_event(&topic, event);
290        }
291        if page.next_cursor.is_none() || builder.nodes.len() >= query.limit() {
292            break;
293        }
294        from = page.next_cursor;
295    }
296    if !saw_event {
297        match store.describe(session_id).await {
298            Ok(_) => {}
299            Err(harn_session_store::StoreError::NotFound(_)) => return Ok(None),
300            Err(error) => return Err(SessionTimelineError::SessionStore(error.to_string())),
301        }
302    }
303    Ok(Some(builder.finish_in_source_order()))
304}
305
306/// List canonical sessions for one project without exposing SQLite layout or
307/// schema policy to product clients.
308pub async fn list_persisted_sessions(
309    project_root: &Path,
310    limit: usize,
311) -> Result<Vec<SessionMeta>, SessionTimelineError> {
312    let Some(store) = crate::stdlib::session_store::open_existing_canonical_store(project_root)
313        .map_err(|error| SessionTimelineError::SessionStore(error.to_string()))?
314    else {
315        return Ok(Vec::new());
316    };
317    store
318        .list(ListFilter {
319            project_scope: Some(project_root.to_string_lossy().into_owned()),
320            limit: Some(limit),
321            ..ListFilter::default()
322        })
323        .await
324        .map_err(|error| SessionTimelineError::SessionStore(error.to_string()))
325}
326
327pub async fn subscribe_session_timeline(
328    log: Arc<AnyEventLog>,
329    query: SessionTimelineQuery,
330) -> Result<
331    BoxStream<'static, Result<SessionTimelineUpdate, SessionTimelineError>>,
332    SessionTimelineError,
333> {
334    let policy = current_policy();
335    let mut streams = Vec::new();
336    for topic in query.topics() {
337        let topic_name = topic.as_str().to_string();
338        let from_cursor = query.from_cursor.event_id_for(&topic);
339        let events = log.clone().subscribe(&topic, from_cursor).await?;
340        let query = query.clone();
341        let policy = policy.clone();
342        streams.push(Box::pin(events.filter_map(move |item| {
343            let topic_name = topic_name.clone();
344            let query = query.clone();
345            let policy = policy.clone();
346            async move {
347                match item {
348                    Ok((event_id, event)) => {
349                        event_update(&query, &policy, &topic_name, event_id, event).map(Ok)
350                    }
351                    Err(error) => Some(Err(SessionTimelineError::EventLog(error))),
352                }
353            }
354        }))
355            as BoxStream<
356                'static,
357                Result<SessionTimelineUpdate, SessionTimelineError>,
358            >);
359    }
360    Ok(Box::pin(stream::select_all(streams)))
361}
362
363struct TimelineBuilder {
364    query: SessionTimelineQuery,
365    cursor: SessionTimelineCursor,
366    nodes: Vec<TimelineDraft>,
367    tool_positions: HashMap<u64, usize>,
368    collided_tool_positions: HashMap<String, usize>,
369}
370
371impl TimelineBuilder {
372    fn new(query: SessionTimelineQuery) -> Self {
373        let capacity = query.limit().min(10_000);
374        Self {
375            cursor: query.from_cursor.clone(),
376            query,
377            nodes: Vec::with_capacity(capacity),
378            tool_positions: HashMap::with_capacity(capacity / 2),
379            collided_tool_positions: HashMap::new(),
380        }
381    }
382
383    fn push(&mut self, draft: TimelineDraft) {
384        self.nodes.push(draft);
385    }
386
387    fn register_tool_position(&mut self, hash: u64, index: usize) {
388        const COLLISION: usize = usize::MAX;
389        match self.tool_positions.get(&hash).copied() {
390            None => {
391                self.tool_positions.insert(hash, index);
392            }
393            Some(COLLISION) => {
394                self.collided_tool_positions
395                    .insert(self.nodes[index].node.id.clone(), index);
396            }
397            Some(existing) => {
398                self.tool_positions.insert(hash, COLLISION);
399                self.collided_tool_positions
400                    .insert(self.nodes[existing].node.id.clone(), existing);
401                self.collided_tool_positions
402                    .insert(self.nodes[index].node.id.clone(), index);
403            }
404        }
405    }
406
407    fn stored_tool_position(&self, hash: u64, event: &StoredEvent) -> Option<usize> {
408        const COLLISION: usize = usize::MAX;
409        let index = self.tool_positions.get(&hash).copied()?;
410        if index == COLLISION {
411            let id = stored_tool_node_id(event)?;
412            return self.collided_tool_positions.get(&id).copied();
413        }
414        stored_tool_node_matches(&self.nodes[index].node, event).then_some(index)
415    }
416
417    fn add_run_spans(&mut self, run: &RunRecord, policy: &RedactionPolicy) {
418        for span in &run.trace_spans {
419            if !span_matches_query(span, &self.query) {
420                continue;
421            }
422            let node = span_node(span, policy);
423            self.push(TimelineDraft {
424                sort_ms: i128::from(span.start_ms),
425                sequence: span.span_id,
426                node,
427            });
428        }
429    }
430
431    fn add_stored_event(&mut self, topic: &str, event: StoredEvent) {
432        self.cursor.bump(topic, event.event_id);
433        let is_tool_result = matches!(&event.kind, SessionEventKind::ToolResult);
434        let tool_hash = stored_tool_node_hash(&event);
435        let sequence = event.event_id;
436        let event_ts_ms = event.ts_ms;
437        let sort_ms = i128::from(event_ts_ms);
438        if let Some(index) = tool_hash.and_then(|hash| self.stored_tool_position(hash, &event)) {
439            if is_tool_result {
440                merge_stored_tool_result(&mut self.nodes[index].node, event);
441            } else {
442                let mut node = stored_event_node(event);
443                merge_missing_attributes(&mut node.attributes, &self.nodes[index].node.attributes);
444                self.nodes[index].node = node;
445            }
446            return;
447        }
448        let mut node = stored_event_node(event);
449        if is_tool_result {
450            node.duration_ms = node.start_ms.and_then(|start| {
451                nonnegative_u64(event_ts_ms).map(|end| end.saturating_sub(start))
452            });
453        }
454        let index = self.nodes.len();
455        self.push(TimelineDraft {
456            sort_ms,
457            sequence,
458            node,
459        });
460        if let Some(hash) = tool_hash {
461            self.register_tool_position(hash, index);
462        }
463    }
464
465    async fn add_event_log(
466        &mut self,
467        log: &AnyEventLog,
468        policy: &RedactionPolicy,
469    ) -> Result<(), SessionTimelineError> {
470        for topic in self.query.topics() {
471            let topic_name = topic.as_str().to_string();
472            let mut from = self.query.from_cursor.event_id_for(&topic);
473            loop {
474                let batch = log.read_range(&topic, from, READ_BATCH_SIZE).await?;
475                let batch_len = batch.len();
476                for (event_id, event) in batch {
477                    from = Some(event_id);
478                    self.cursor.bump(&topic_name, event_id);
479                    if let Some(node) =
480                        event_node(&self.query, policy, &topic_name, event_id, event)
481                    {
482                        let sort_ms = node
483                            .occurred_at_ms
484                            .map(i128::from)
485                            .or_else(|| node.start_ms.map(i128::from))
486                            .unwrap_or(i128::from(event_id));
487                        self.push(TimelineDraft {
488                            sort_ms,
489                            sequence: event_id,
490                            node,
491                        });
492                    }
493                }
494                if batch_len < READ_BATCH_SIZE || self.nodes.len() >= self.query.limit() {
495                    break;
496                }
497            }
498        }
499        Ok(())
500    }
501
502    fn finish(self) -> SessionTimelineSnapshot {
503        self.finish_with_ordering(true)
504    }
505
506    fn finish_in_source_order(self) -> SessionTimelineSnapshot {
507        self.finish_with_ordering(false)
508    }
509
510    fn finish_with_ordering(mut self, sort: bool) -> SessionTimelineSnapshot {
511        if sort {
512            self.nodes.sort_by(|left, right| {
513                left.sort_ms
514                    .cmp(&right.sort_ms)
515                    .then_with(|| left.sequence.cmp(&right.sequence))
516                    .then_with(|| left.node.id.cmp(&right.node.id))
517            });
518        }
519        self.nodes.truncate(self.query.limit());
520
521        let mut children_by_parent: BTreeMap<String, Vec<String>> = BTreeMap::new();
522        if self
523            .nodes
524            .iter()
525            .any(|draft| draft.node.parent_id.is_some())
526        {
527            let visible_ids: HashSet<&str> = self
528                .nodes
529                .iter()
530                .map(|draft| draft.node.id.as_str())
531                .collect();
532            for draft in &self.nodes {
533                let Some(parent_id) = draft.node.parent_id.as_ref() else {
534                    continue;
535                };
536                if visible_ids.contains(parent_id.as_str()) {
537                    children_by_parent
538                        .entry(parent_id.clone())
539                        .or_default()
540                        .push(draft.node.id.clone());
541                }
542            }
543        }
544
545        let nodes = self
546            .nodes
547            .into_iter()
548            .enumerate()
549            .map(|(index, mut draft)| {
550                draft.node.order = index as u64;
551                draft.node.children = children_by_parent
552                    .remove(&draft.node.id)
553                    .unwrap_or_default();
554                draft.node
555            })
556            .collect();
557
558        SessionTimelineSnapshot {
559            schema_version: SESSION_TIMELINE_SCHEMA_VERSION,
560            query: self.query,
561            cursor: self.cursor,
562            nodes,
563        }
564    }
565}
566
567fn canonical_session_topic(session_id: &str) -> String {
568    format!("session-store:{session_id}")
569}
570
571fn stored_tool_node_id(event: &StoredEvent) -> Option<String> {
572    if !matches!(
573        &event.kind,
574        SessionEventKind::ToolCall | SessionEventKind::ToolResult
575    ) {
576        return None;
577    }
578    let tool_call_id = event.headers.get("tool_call_id")?;
579    let run_id = event.headers.get("run_id")?;
580    let turn_id = event.headers.get("turn_id")?;
581    Some(format!(
582        "session:{}:run:{run_id}:turn:{turn_id}:tool:{tool_call_id}",
583        event.session_id
584    ))
585}
586
587fn stored_tool_node_hash(event: &StoredEvent) -> Option<u64> {
588    if !matches!(
589        &event.kind,
590        SessionEventKind::ToolCall | SessionEventKind::ToolResult
591    ) {
592        return None;
593    }
594    let mut hasher = DefaultHasher::new();
595    event.session_id.hash(&mut hasher);
596    event.headers.get("run_id")?.hash(&mut hasher);
597    event.headers.get("turn_id")?.hash(&mut hasher);
598    event.headers.get("tool_call_id")?.hash(&mut hasher);
599    Some(hasher.finish())
600}
601
602fn stored_tool_node_matches(node: &SessionTimelineNode, event: &StoredEvent) -> bool {
603    let reference_session = node
604        .references
605        .iter()
606        .find(|reference| reference.kind == "session_event")
607        .and_then(|reference| reference.id.as_deref());
608    let link_target = |kind: &str| {
609        node.links
610            .iter()
611            .find(|link| link.kind == kind)
612            .and_then(|link| link.target_id.as_deref())
613    };
614    reference_session == Some(event.session_id.as_str())
615        && link_target("run") == event.headers.get("run_id").map(String::as_str)
616        && link_target("turn") == event.headers.get("turn_id").map(String::as_str)
617        && link_target("tool_call") == event.headers.get("tool_call_id").map(String::as_str)
618}
619
620fn merge_stored_tool_result(existing: &mut SessionTimelineNode, mut event: StoredEvent) {
621    debug_assert!(matches!(&event.kind, SessionEventKind::ToolResult));
622    let source_event_id = event.headers.remove("source_event_id");
623    let message_id = event.headers.remove("message_id");
624    let tool_name = semantic_string(
625        &event.payload,
626        &[
627            "/transcript_event/metadata/tool_name",
628            "/raw_message/name",
629            "/raw_message/tool_calls/0/name",
630        ],
631    );
632    let role = semantic_string(
633        &event.payload,
634        &["/transcript_event/role", "/raw_message/role"],
635    );
636    let output = semantic_value(
637        &event.payload,
638        &[
639            "/transcript_event/metadata/output",
640            "/raw_message/content",
641            "/transcript_event/text",
642        ],
643    );
644    let is_error = event
645        .payload
646        .pointer("/transcript_event/metadata/is_error")
647        .and_then(serde_json::Value::as_bool)
648        .unwrap_or(false);
649    let end_ms = nonnegative_u64(event.ts_ms);
650    let mut attributes = event.payload;
651    if let serde_json::Value::Object(attributes) = &mut attributes {
652        attributes.insert("revision".to_string(), event.event_id.into());
653        attributes.insert(
654            "recordHash".to_string(),
655            std::mem::take(&mut event.record_hash).into(),
656        );
657        if let Some(role) = role {
658            attributes.insert("role".to_string(), role.into());
659        }
660        if let Some(output) = output {
661            attributes.insert("output".to_string(), output);
662        }
663        attributes.insert("isError".to_string(), is_error.into());
664    }
665    let previous_attributes = std::mem::take(&mut existing.attributes);
666    merge_missing_attributes_owned(&mut attributes, previous_attributes);
667
668    if let Some(tool_name) = tool_name {
669        existing.name = tool_name;
670    }
671    let start_ms = existing
672        .start_ms
673        .or_else(|| existing.occurred_at_ms.and_then(nonnegative_u64));
674    existing.kind.clear();
675    existing.kind.push_str(event.kind.discriminator());
676    existing.status.clear();
677    existing
678        .status
679        .push_str(if is_error { "failed" } else { "completed" });
680    existing.occurred_at_ms = Some(event.ts_ms);
681    existing.start_ms = start_ms;
682    existing.duration_ms = start_ms
683        .zip(end_ms)
684        .map(|(start, end)| end.saturating_sub(start));
685    existing.attributes = attributes;
686    if let Some(reference) = existing
687        .references
688        .iter_mut()
689        .find(|reference| reference.kind == "session_event")
690    {
691        reference.event_id = Some(event.event_id);
692    }
693    let mut previous_links = std::mem::take(&mut existing.links);
694    let mut links = Vec::with_capacity(previous_links.len().max(5));
695    for kind in ["run", "turn"] {
696        if let Some(link) = take_timeline_link(&mut previous_links, kind) {
697            links.push(link);
698        }
699    }
700    links.extend(
701        [("source_event", source_event_id), ("message", message_id)]
702            .into_iter()
703            .filter_map(|(kind, target_id)| {
704                target_id.map(|target_id| SessionTimelineLink {
705                    kind: kind.to_string(),
706                    target_id: Some(target_id),
707                    trace_id: None,
708                    span_id: None,
709                    event_id: None,
710                })
711            }),
712    );
713    if let Some(link) = take_timeline_link(&mut previous_links, "tool_call") {
714        links.push(link);
715    }
716    existing.links = links;
717}
718
719fn take_timeline_link(
720    links: &mut Vec<SessionTimelineLink>,
721    kind: &str,
722) -> Option<SessionTimelineLink> {
723    let index = links.iter().position(|link| link.kind == kind)?;
724    Some(links.remove(index))
725}
726
727fn stored_event_node(mut event: StoredEvent) -> SessionTimelineNode {
728    let source_event_id = event.headers.remove("source_event_id");
729    let message_id = event.headers.remove("message_id");
730    let tool_call_id = event.headers.remove("tool_call_id");
731    let run_id = event.headers.remove("run_id");
732    let turn_id = event.headers.remove("turn_id");
733    let id = tool_call_id
734        .as_ref()
735        .zip(run_id.as_ref())
736        .zip(turn_id.as_ref())
737        .filter(|_| {
738            matches!(
739                &event.kind,
740                SessionEventKind::ToolCall | SessionEventKind::ToolResult
741            )
742        })
743        .map(|((tool_call_id, run_id), turn_id)| {
744            format!(
745                "session:{}:run:{run_id}:turn:{turn_id}:tool:{tool_call_id}",
746                event.session_id
747            )
748        })
749        .or_else(|| {
750            source_event_id
751                .as_ref()
752                .map(|id| format!("session:{}:source:{id}", event.session_id))
753        })
754        .unwrap_or_else(|| format!("session:{}:event:{}", event.session_id, event.event_id));
755    let category = match &event.kind {
756        SessionEventKind::Message => "message",
757        SessionEventKind::ToolCall | SessionEventKind::ToolResult => "tool",
758        SessionEventKind::Plan => "plan",
759        SessionEventKind::Compaction => "compaction",
760        SessionEventKind::PermissionDecision => "permission",
761        SessionEventKind::Receipt => "receipt",
762        _ => "event",
763    }
764    .to_string();
765    let status = match &event.kind {
766        SessionEventKind::ToolCall => "running",
767        SessionEventKind::ToolResult => tool_result_status(&event),
768        SessionEventKind::Custom { custom_type } if custom_type == "agent_run_terminal" => event
769            .payload
770            .pointer("/transcript_event/metadata/final_status")
771            .and_then(serde_json::Value::as_str)
772            .unwrap_or("completed"),
773        _ => "completed",
774    }
775    .to_string();
776    let tool_name = || {
777        semantic_string(
778            &event.payload,
779            &[
780                "/transcript_event/metadata/tool_name",
781                "/raw_message/name",
782                "/raw_message/tool_calls/0/name",
783            ],
784        )
785    };
786    let visible_text = || {
787        semantic_string(
788            &event.payload,
789            &["/transcript_event/text", "/raw_message/content"],
790        )
791    };
792    let name = match &event.kind {
793        SessionEventKind::ToolCall => tool_name().or_else(visible_text),
794        // Result text is output, not the tool's product label. The
795        // discriminator sentinel makes the revision merge retain the call
796        // node's name when this envelope omits tool_name.
797        SessionEventKind::ToolResult => tool_name(),
798        _ => visible_text(),
799    }
800    .unwrap_or_else(|| event.kind.discriminator().to_string());
801    let links = [
802        ("run", run_id),
803        ("turn", turn_id),
804        ("source_event", source_event_id),
805        ("message", message_id),
806        ("tool_call", tool_call_id),
807    ]
808    .into_iter()
809    .filter_map(|(kind, target_id)| {
810        target_id.map(|target_id| SessionTimelineLink {
811            kind: kind.to_string(),
812            target_id: Some(target_id),
813            trace_id: None,
814            span_id: None,
815            event_id: None,
816        })
817    })
818    .collect();
819    let role = semantic_string(
820        &event.payload,
821        &["/transcript_event/role", "/raw_message/role"],
822    );
823    let semantic_attribute = match &event.kind {
824        SessionEventKind::ToolCall => semantic_value(
825            &event.payload,
826            &[
827                "/transcript_event/metadata/input",
828                "/raw_message/input",
829                "/raw_message/tool_calls/0/arguments",
830            ],
831        )
832        .map(|value| ("input", value)),
833        SessionEventKind::ToolResult => semantic_value(
834            &event.payload,
835            &[
836                "/transcript_event/metadata/output",
837                "/raw_message/content",
838                "/transcript_event/text",
839            ],
840        )
841        .map(|value| ("output", value)),
842        _ => None,
843    };
844    let is_error = matches!(&event.kind, SessionEventKind::ToolResult).then(|| {
845        event
846            .payload
847            .pointer("/transcript_event/metadata/is_error")
848            .and_then(serde_json::Value::as_bool)
849            .unwrap_or(false)
850    });
851    let mut attributes = event.payload;
852    if let serde_json::Value::Object(attributes) = &mut attributes {
853        attributes.insert("sessionId".to_string(), event.session_id.clone().into());
854        attributes.insert("revision".to_string(), event.event_id.into());
855        attributes.insert("recordHash".to_string(), event.record_hash.into());
856        if let Some(role) = role {
857            attributes.insert("role".to_string(), role.into());
858        }
859        if let Some((key, value)) = semantic_attribute {
860            attributes.insert(key.to_string(), value);
861        }
862        if let Some(is_error) = is_error {
863            attributes.insert("isError".to_string(), is_error.into());
864        }
865    }
866    let start_ms = matches!(&event.kind, SessionEventKind::ToolCall)
867        .then(|| nonnegative_u64(event.ts_ms))
868        .flatten();
869    let session_topic = canonical_session_topic(&event.session_id);
870    SessionTimelineNode {
871        id,
872        parent_id: None,
873        children: Vec::new(),
874        category,
875        kind: event.kind.discriminator().to_string(),
876        name,
877        status,
878        trace_id: None,
879        span_id: None,
880        occurred_at_ms: Some(event.ts_ms),
881        start_ms,
882        duration_ms: None,
883        attributes,
884        references: vec![SessionTimelineReference {
885            kind: "session_event".to_string(),
886            id: Some(event.session_id),
887            topic: Some(session_topic),
888            event_id: Some(event.event_id),
889        }],
890        links,
891        order: 0,
892    }
893}
894
895fn semantic_string(payload: &serde_json::Value, pointers: &[&str]) -> Option<String> {
896    pointers.iter().find_map(|pointer| {
897        payload
898            .pointer(pointer)
899            .and_then(serde_json::Value::as_str)
900            .map(str::trim)
901            .filter(|value| !value.is_empty())
902            .map(str::to_string)
903    })
904}
905
906fn semantic_value(payload: &serde_json::Value, pointers: &[&str]) -> Option<serde_json::Value> {
907    pointers
908        .iter()
909        .find_map(|pointer| payload.pointer(pointer))
910        .cloned()
911}
912
913fn nonnegative_u64(value: i64) -> Option<u64> {
914    u64::try_from(value).ok()
915}
916
917fn merge_missing_attributes(current: &mut serde_json::Value, previous: &serde_json::Value) {
918    let (serde_json::Value::Object(current), serde_json::Value::Object(previous)) =
919        (current, previous)
920    else {
921        return;
922    };
923    for (key, value) in previous {
924        current.entry(key.clone()).or_insert_with(|| value.clone());
925    }
926}
927
928fn merge_missing_attributes_owned(current: &mut serde_json::Value, previous: serde_json::Value) {
929    let (serde_json::Value::Object(current), serde_json::Value::Object(previous)) =
930        (current, previous)
931    else {
932        return;
933    };
934    for (key, value) in previous {
935        current.entry(key).or_insert(value);
936    }
937}
938
939fn tool_result_status(event: &StoredEvent) -> &'static str {
940    if event
941        .payload
942        .pointer("/transcript_event/metadata/is_error")
943        .and_then(serde_json::Value::as_bool)
944        .unwrap_or(false)
945    {
946        "failed"
947    } else {
948        "completed"
949    }
950}
951
952fn event_update(
953    query: &SessionTimelineQuery,
954    policy: &RedactionPolicy,
955    topic: &str,
956    event_id: EventId,
957    event: LogEvent,
958) -> Option<SessionTimelineUpdate> {
959    let mut node = event_node(query, policy, topic, event_id, event)?;
960    node.order = 0;
961    let mut cursor = SessionTimelineCursor::default();
962    cursor.bump(topic, event_id);
963    Some(SessionTimelineUpdate {
964        schema_version: SESSION_TIMELINE_SCHEMA_VERSION,
965        cursor,
966        node,
967    })
968}
969
970fn event_node(
971    query: &SessionTimelineQuery,
972    policy: &RedactionPolicy,
973    topic: &str,
974    event_id: EventId,
975    mut event: LogEvent,
976) -> Option<SessionTimelineNode> {
977    event.redact_in_place(policy);
978    if topic.starts_with("observability.agent_events.") {
979        return agent_event_node(query, topic, event_id, event);
980    }
981    if topic == crate::channels::CHANNEL_TRANSCRIPT_TOPIC {
982        return channel_lifecycle_node(query, topic, event_id, event);
983    }
984    if topic == crate::channels::CHANNEL_AUDIT_TOPIC {
985        return channel_audit_node(query, topic, event_id, event);
986    }
987    None
988}
989
990fn span_node(span: &RunTraceSpanRecord, policy: &RedactionPolicy) -> SessionTimelineNode {
991    let mut attributes = serde_json::json!(span.metadata);
992    policy.redact_json_in_place(&mut attributes);
993    let status = attributes
994        .get("status")
995        .and_then(serde_json::Value::as_str)
996        .unwrap_or("completed")
997        .to_string();
998    SessionTimelineNode {
999        id: span_node_id(&span.trace_id, span.span_id),
1000        parent_id: span
1001            .parent_id
1002            .map(|parent| span_node_id(&span.trace_id, parent)),
1003        children: Vec::new(),
1004        category: "span".to_string(),
1005        kind: span.kind.clone(),
1006        name: span.name.clone(),
1007        status,
1008        trace_id: Some(span.trace_id.clone()),
1009        span_id: Some(span.span_id.to_string()),
1010        occurred_at_ms: None,
1011        start_ms: Some(span.start_ms),
1012        duration_ms: Some(span.duration_ms),
1013        attributes,
1014        references: vec![SessionTimelineReference {
1015            kind: "run_trace_span".to_string(),
1016            id: Some(span.span_id.to_string()),
1017            topic: None,
1018            event_id: None,
1019        }],
1020        links: span
1021            .links
1022            .iter()
1023            .map(|link| SessionTimelineLink {
1024                kind: link
1025                    .attributes
1026                    .get("harn.link.kind")
1027                    .cloned()
1028                    .unwrap_or_else(|| "span_link".to_string()),
1029                target_id: Some(format!("span:{}:{}", link.trace_id, link.span_id)),
1030                trace_id: Some(link.trace_id.clone()),
1031                span_id: Some(link.span_id.clone()),
1032                event_id: None,
1033            })
1034            .collect(),
1035        order: 0,
1036    }
1037}
1038
1039fn agent_event_node(
1040    query: &SessionTimelineQuery,
1041    topic: &str,
1042    event_id: EventId,
1043    event: LogEvent,
1044) -> Option<SessionTimelineNode> {
1045    if !event_matches_query(
1046        query,
1047        &event.payload,
1048        Some(&event.headers),
1049        &["session_id"],
1050        &[],
1051    ) {
1052        return None;
1053    }
1054    let event_value = event.payload.get("event").unwrap_or(&event.payload);
1055    let event_type = event_value
1056        .get("type")
1057        .and_then(serde_json::Value::as_str)
1058        .unwrap_or(event.kind.as_str());
1059    let status = event_status(event_value).unwrap_or("observed").to_string();
1060    Some(SessionTimelineNode {
1061        id: format!("event:{topic}:{event_id}"),
1062        parent_id: None,
1063        children: Vec::new(),
1064        category: "agent_event".to_string(),
1065        kind: event.kind.clone(),
1066        name: event_type.to_string(),
1067        status,
1068        trace_id: None,
1069        span_id: None,
1070        occurred_at_ms: Some(event.occurred_at_ms),
1071        start_ms: None,
1072        duration_ms: duration_ms(event_value),
1073        attributes: event.payload,
1074        references: vec![event_ref(topic, event_id)],
1075        links: Vec::new(),
1076        order: 0,
1077    })
1078}
1079
1080fn channel_lifecycle_node(
1081    query: &SessionTimelineQuery,
1082    topic: &str,
1083    event_id: EventId,
1084    event: LogEvent,
1085) -> Option<SessionTimelineNode> {
1086    if !event_matches_query(
1087        query,
1088        &event.payload,
1089        Some(&event.headers),
1090        &["session_id", "matched_in_session_id"],
1091        &["pipeline_id"],
1092    ) {
1093        return None;
1094    }
1095    let channel_event_id = string_field(&event.payload, "event_id");
1096    let trigger_id = string_field(&event.payload, "trigger_id");
1097    let is_match = event.kind == crate::channels::CHANNEL_MATCH_TRANSCRIPT_KIND;
1098    let id = if is_match {
1099        format!(
1100            "channel:{}:match:{}",
1101            channel_event_id.as_deref().unwrap_or("unknown"),
1102            trigger_id.as_deref().unwrap_or("unknown")
1103        )
1104    } else {
1105        format!(
1106            "channel:{}:emit",
1107            channel_event_id.as_deref().unwrap_or("unknown")
1108        )
1109    };
1110    let mut links: Vec<SessionTimelineLink> = if is_match {
1111        channel_event_id
1112            .as_ref()
1113            .map(|event_id| SessionTimelineLink {
1114                kind: "channel_emit".to_string(),
1115                target_id: Some(format!("channel:{event_id}:emit")),
1116                trace_id: None,
1117                span_id: None,
1118                event_id: Some(event_id.clone()),
1119            })
1120            .into_iter()
1121            .collect()
1122    } else {
1123        Vec::new()
1124    };
1125    if is_match {
1126        links.extend(channel_batch_links(&event.payload));
1127    }
1128    Some(SessionTimelineNode {
1129        id,
1130        parent_id: None,
1131        children: Vec::new(),
1132        category: "channel".to_string(),
1133        kind: event.kind.clone(),
1134        name: string_field(&event.payload, "name_resolved")
1135            .or_else(|| string_field(&event.payload, "name"))
1136            .unwrap_or_else(|| event.kind.clone()),
1137        status: if event
1138            .payload
1139            .get("duplicate")
1140            .and_then(serde_json::Value::as_bool)
1141            .unwrap_or(false)
1142        {
1143            "duplicate".to_string()
1144        } else {
1145            "observed".to_string()
1146        },
1147        trace_id: None,
1148        span_id: string_field(&event.payload, "span_id"),
1149        occurred_at_ms: Some(event.occurred_at_ms),
1150        start_ms: None,
1151        duration_ms: None,
1152        attributes: event.payload,
1153        references: vec![event_ref(topic, event_id)],
1154        links,
1155        order: 0,
1156    })
1157}
1158
1159fn channel_audit_node(
1160    query: &SessionTimelineQuery,
1161    topic: &str,
1162    event_id: EventId,
1163    event: LogEvent,
1164) -> Option<SessionTimelineNode> {
1165    if !event_matches_query(
1166        query,
1167        &event.payload,
1168        Some(&event.headers),
1169        &["session_id", "matched_in_session_id"],
1170        &["pipeline_id", "run_id"],
1171    ) {
1172        return None;
1173    }
1174    let channel_event_id = string_field(&event.payload, "event_id");
1175    let trigger_id = string_field(&event.payload, "trigger_id");
1176    let is_match = event.kind == crate::channels::CHANNEL_MATCH_RECEIPT_KIND;
1177    let id = if is_match {
1178        format!(
1179            "channel_receipt:{}:match:{}",
1180            channel_event_id.as_deref().unwrap_or("unknown"),
1181            trigger_id.as_deref().unwrap_or("unknown")
1182        )
1183    } else {
1184        format!(
1185            "channel_receipt:{}:emit",
1186            channel_event_id.as_deref().unwrap_or("unknown")
1187        )
1188    };
1189    let mut links: Vec<SessionTimelineLink> = if is_match {
1190        channel_event_id
1191            .as_ref()
1192            .map(|event_id| SessionTimelineLink {
1193                kind: "channel_emit".to_string(),
1194                target_id: Some(format!("channel_receipt:{event_id}:emit")),
1195                trace_id: None,
1196                span_id: None,
1197                event_id: Some(event_id.clone()),
1198            })
1199            .into_iter()
1200            .collect()
1201    } else {
1202        Vec::new()
1203    };
1204    if is_match {
1205        links.extend(channel_batch_links(&event.payload));
1206    }
1207    Some(SessionTimelineNode {
1208        id,
1209        parent_id: None,
1210        children: Vec::new(),
1211        category: "channel_audit".to_string(),
1212        kind: event.kind.clone(),
1213        name: string_field(&event.payload, "name_resolved").unwrap_or_else(|| event.kind.clone()),
1214        status: event
1215            .payload
1216            .get("handler_result")
1217            .and_then(|value| value.get("status"))
1218            .and_then(serde_json::Value::as_str)
1219            .or_else(|| {
1220                event.payload.get("inserted").and_then(|inserted| {
1221                    if inserted.as_bool() == Some(false) {
1222                        Some("duplicate")
1223                    } else {
1224                        None
1225                    }
1226                })
1227            })
1228            .unwrap_or("recorded")
1229            .to_string(),
1230        trace_id: None,
1231        span_id: string_field(&event.payload, "span_id"),
1232        occurred_at_ms: Some(event.occurred_at_ms),
1233        start_ms: None,
1234        duration_ms: None,
1235        attributes: event.payload,
1236        references: vec![event_ref(topic, event_id)],
1237        links,
1238        order: 0,
1239    })
1240}
1241
1242fn event_matches_query(
1243    query: &SessionTimelineQuery,
1244    payload: &serde_json::Value,
1245    headers: Option<&BTreeMap<String, String>>,
1246    session_keys: &[&str],
1247    run_keys: &[&str],
1248) -> bool {
1249    field_query_matches(query.session_id.as_deref(), payload, headers, session_keys)
1250        && field_query_matches(query.run_id.as_deref(), payload, headers, run_keys)
1251        && field_query_matches(
1252            query.project_id.as_deref(),
1253            payload,
1254            headers,
1255            &["project_id", "projectId", "workspace_id", "workspaceId"],
1256        )
1257}
1258
1259fn field_query_matches(
1260    expected: Option<&str>,
1261    payload: &serde_json::Value,
1262    headers: Option<&BTreeMap<String, String>>,
1263    keys: &[&str],
1264) -> bool {
1265    let Some(expected) = expected else {
1266        return true;
1267    };
1268    if expected.is_empty() {
1269        return true;
1270    }
1271    if keys.is_empty() {
1272        return true;
1273    }
1274    keys.iter().any(|key| {
1275        payload
1276            .get(*key)
1277            .and_then(serde_json::Value::as_str)
1278            .is_some_and(|value| value == expected)
1279            || payload
1280                .get("event")
1281                .and_then(|event| event.get(*key))
1282                .and_then(serde_json::Value::as_str)
1283                .is_some_and(|value| value == expected)
1284            || headers
1285                .and_then(|headers| headers.get(*key))
1286                .is_some_and(|value| value == expected)
1287    })
1288}
1289
1290fn span_matches_query(span: &RunTraceSpanRecord, query: &SessionTimelineQuery) -> bool {
1291    if let Some(session_id) = query.session_id.as_deref() {
1292        let has_session_attr = span.metadata.contains_key("session_id")
1293            || span.metadata.contains_key("agent_session_id");
1294        if has_session_attr
1295            && !metadata_matches(
1296                &span.metadata,
1297                &["session_id", "agent_session_id"],
1298                session_id,
1299            )
1300        {
1301            return false;
1302        }
1303    }
1304    true
1305}
1306
1307fn run_matches_query(run: &RunRecord, query: &SessionTimelineQuery) -> bool {
1308    if let Some(run_id) = query.run_id.as_deref() {
1309        if run.id != run_id {
1310            return false;
1311        }
1312    }
1313    if let Some(project_id) = query.project_id.as_deref() {
1314        if !metadata_matches(&run.metadata, &["project_id", "projectId"], project_id) {
1315            return false;
1316        }
1317    }
1318    true
1319}
1320
1321fn metadata_matches(
1322    metadata: &BTreeMap<String, serde_json::Value>,
1323    keys: &[&str],
1324    expected: &str,
1325) -> bool {
1326    keys.iter().any(|key| {
1327        metadata
1328            .get(*key)
1329            .and_then(serde_json::Value::as_str)
1330            .is_some_and(|value| value == expected)
1331    })
1332}
1333
1334fn event_status(value: &serde_json::Value) -> Option<&str> {
1335    value
1336        .get("status")
1337        .and_then(serde_json::Value::as_str)
1338        .or_else(|| value.get("verdict").and_then(serde_json::Value::as_str))
1339}
1340
1341fn duration_ms(value: &serde_json::Value) -> Option<u64> {
1342    value
1343        .get("duration_ms")
1344        .or_else(|| value.get("judge_duration_ms"))
1345        .and_then(serde_json::Value::as_u64)
1346}
1347
1348fn string_field(value: &serde_json::Value, key: &str) -> Option<String> {
1349    let value = value.get(key)?;
1350    if let Some(text) = value.as_str() {
1351        if !text.is_empty() {
1352            return Some(text.to_string());
1353        }
1354        return None;
1355    }
1356    value.as_u64().map(|number| number.to_string())
1357}
1358
1359fn channel_batch_links(payload: &serde_json::Value) -> Vec<SessionTimelineLink> {
1360    payload
1361        .get("batch")
1362        .and_then(|batch| batch.get("constituent_event_ids"))
1363        .and_then(serde_json::Value::as_array)
1364        .into_iter()
1365        .flatten()
1366        .filter_map(|value| value.as_str())
1367        .map(|event_id| SessionTimelineLink {
1368            kind: "channel_batch_member".to_string(),
1369            target_id: None,
1370            trace_id: None,
1371            span_id: None,
1372            event_id: Some(event_id.to_string()),
1373        })
1374        .collect()
1375}
1376
1377fn span_node_id(trace_id: &str, span_id: u64) -> String {
1378    format!("span:{trace_id}:{span_id}")
1379}
1380
1381fn event_ref(topic: &str, event_id: EventId) -> SessionTimelineReference {
1382    SessionTimelineReference {
1383        kind: "event_log".to_string(),
1384        id: None,
1385        topic: Some(topic.to_string()),
1386        event_id: Some(event_id),
1387    }
1388}
1389
1390fn static_topic(topic: &str) -> Topic {
1391    Topic::new(topic).expect("static session timeline topic should be valid")
1392}
1393
1394fn load_run_for_timeline(
1395    query: &SessionTimelineQuery,
1396) -> Result<Option<RunRecord>, SessionTimelineError> {
1397    if let Some(path) = query
1398        .run_path
1399        .as_deref()
1400        .map(str::trim)
1401        .filter(|path| !path.is_empty())
1402    {
1403        return load_run_record_for_timeline(Path::new(path), true);
1404    }
1405
1406    let Some(run_id) = query
1407        .run_id
1408        .as_deref()
1409        .map(str::trim)
1410        .filter(|run_id| !run_id.is_empty())
1411    else {
1412        return Ok(None);
1413    };
1414    let path = default_run_record_path(run_id)?;
1415    load_run_record_for_timeline(&path, false)
1416}
1417
1418fn load_run_record_for_timeline(
1419    path: &Path,
1420    explicit: bool,
1421) -> Result<Option<RunRecord>, SessionTimelineError> {
1422    if !path.exists() {
1423        if explicit {
1424            return Err(SessionTimelineError::RunRecord(format!(
1425                "session timeline run record not found: {}",
1426                path.display()
1427            )));
1428        }
1429        return Ok(None);
1430    }
1431    load_run_record(path).map(Some).map_err(|error| {
1432        SessionTimelineError::RunRecord(format!(
1433            "failed to load session timeline run record {}: {error}",
1434            path.display()
1435        ))
1436    })
1437}
1438
1439fn default_run_record_path(run_id: &str) -> Result<PathBuf, SessionTimelineError> {
1440    if run_id == "." || run_id == ".." || run_id.contains('/') || run_id.contains('\\') {
1441        return Err(SessionTimelineError::RunRecord(format!(
1442            "session timeline runId is not a valid default run-record filename: {run_id}"
1443        )));
1444    }
1445    let base = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1446    Ok(crate::runtime_paths::run_root(&base).join(format!("{run_id}.json")))
1447}
1448
1449#[cfg(test)]
1450#[path = "session_timeline_tests.rs"]
1451mod session_timeline_tests;