1use 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
238pub 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
253pub 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
306pub 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 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;