1use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36
37use harn_session_store::{
38 EventId, ListFilter, ReadRange, SessionMeta, SessionStatus, SessionStore, StoreError,
39 StoredEvent, MAX_READ_BATCH,
40};
41use rust_decimal::prelude::ToPrimitive;
42use rust_decimal::Decimal;
43use serde_json::json;
44
45use super::types::{LlmUsageRecord, RunChildRecord, RunRecord, RunTraceSpanRecord, ToolCallRecord};
46use crate::agent_sessions::event_facts as facts;
47use crate::value::VmError;
48
49pub const PROJECTION_SOURCE: &str = "harn.session_store.v1";
52
53pub const AGENT_SESSION_WORKFLOW_ID: &str = "agent-session";
59
60pub const UNRECOVERABLE_FIELDS: [&str; 4] = [
73 "usage.total_duration_ms",
74 "trace_spans[].duration_ms",
75 "policy",
76 "replay_fixture",
77];
78
79pub async fn project_run_record_from_session(
84 store: &dyn SessionStore,
85 session_id: &str,
86) -> Result<RunRecord, VmError> {
87 let meta = store
88 .describe(session_id)
89 .await
90 .map_err(|error| match error {
91 StoreError::NotFound(_) => VmError::Runtime(format!(
92 "runs: no session '{session_id}' in this store. `harn session list` shows the \
93 sessions this workspace has persisted."
94 )),
95 other => VmError::Runtime(format!("runs: failed to describe session: {other}")),
96 })?;
97 let events = drain_events(store, session_id).await?;
98 let children = child_records(store, session_id).await?;
99 let root = root_session_id(store, &meta).await?;
100 Ok(assemble(meta, events, children, root))
101}
102
103async fn root_session_id(store: &dyn SessionStore, meta: &SessionMeta) -> Result<String, VmError> {
110 let mut visited = std::collections::HashSet::from([meta.id.clone()]);
111 let mut current = meta.parent_session_id.clone();
112 let mut root = meta.id.clone();
113 while let Some(parent) = current {
114 if !visited.insert(parent.clone()) {
115 break;
116 }
117 match store.describe(&parent).await {
118 Ok(parent_meta) => {
119 root = parent_meta.id.clone();
120 current = parent_meta.parent_session_id;
121 }
122 Err(StoreError::NotFound(_)) => {
125 root = parent;
126 break;
127 }
128 Err(error) => {
129 return Err(VmError::Runtime(format!(
130 "runs: failed to walk session lineage: {error}"
131 )))
132 }
133 }
134 }
135 Ok(root)
136}
137
138pub async fn materialize_session_run_record(
144 root: &Path,
145 session_id: &str,
146 out: Option<&Path>,
147) -> Result<String, VmError> {
148 let store =
149 crate::stdlib::session_store::open_existing_canonical_store(root)?.ok_or_else(|| {
150 VmError::Runtime(format!(
151 "runs: no session store under {}. A projected run record needs \
152 `.harn/session-store.sqlite`; pass the workspace root that holds it.",
153 root.display()
154 ))
155 })?;
156 let run = project_run_record_from_session(&store, session_id).await?;
157 let path = out
158 .map(Path::to_path_buf)
159 .unwrap_or_else(|| default_projection_path(root, session_id));
160 super::persistence::save_run_record(&run, Some(&path.to_string_lossy()))
161}
162
163pub fn default_projection_path(root: &Path, session_id: &str) -> PathBuf {
169 crate::runtime_paths::run_root(root).join(format!("{session_id}.json"))
170}
171
172#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
178pub struct SessionRunSummary {
179 pub session_id: String,
180 pub session_status: String,
184 pub title: Option<String>,
185 pub parent_session_id: Option<String>,
186 pub created_at: String,
187 pub updated_at: String,
188 pub event_count: usize,
189 pub input_tokens: u64,
190 pub output_tokens: u64,
191 pub cost_usd_micros: u64,
192 pub run_record_path: Option<String>,
195}
196
197pub async fn list_session_runs(
203 root: &Path,
204 limit: Option<usize>,
205) -> Result<Vec<SessionRunSummary>, VmError> {
206 let Some(store) = crate::stdlib::session_store::open_existing_canonical_store(root)? else {
207 return Ok(Vec::new());
208 };
209 let sessions = store
210 .list(ListFilter {
211 limit,
212 sort_by: harn_session_store::ListSortKey::CreatedAt,
213 order: harn_session_store::ListOrder::Descending,
214 ..ListFilter::default()
215 })
216 .await
217 .map_err(|error| VmError::Runtime(format!("runs: failed to list sessions: {error}")))?;
218 Ok(sessions
219 .into_iter()
220 .map(|meta| {
221 let record = default_projection_path(root, &meta.id);
222 SessionRunSummary {
223 session_status: status_discriminator(&meta.status).to_string(),
224 title: meta.title.clone(),
225 parent_session_id: meta.parent_session_id.clone(),
226 created_at: meta.created_at.clone(),
227 updated_at: meta.updated_at.clone(),
228 event_count: meta.event_count,
229 input_tokens: meta.usage_input,
230 output_tokens: meta.usage_output,
231 cost_usd_micros: meta.usage_cost_usd_micros,
232 run_record_path: record
233 .is_file()
234 .then(|| record.to_string_lossy().into_owned()),
235 session_id: meta.id,
236 }
237 })
238 .collect())
239}
240
241async fn drain_events(
242 store: &dyn SessionStore,
243 session_id: &str,
244) -> Result<Vec<StoredEvent>, VmError> {
245 let mut all = Vec::new();
246 let mut cursor: Option<EventId> = None;
247 loop {
248 let page = store
249 .read(
250 session_id,
251 ReadRange {
252 from_event_id: cursor,
253 to_event_id: None,
254 limit: Some(MAX_READ_BATCH),
255 },
256 )
257 .await
258 .map_err(|error| {
259 VmError::Runtime(format!("runs: failed to read session events: {error}"))
260 })?;
261 let next = page.next_cursor;
262 all.extend(page.events);
263 match next {
264 Some(next_cursor) => cursor = Some(next_cursor),
265 None => break,
266 }
267 }
268 Ok(all)
269}
270
271async fn child_records(
277 store: &dyn SessionStore,
278 session_id: &str,
279) -> Result<Vec<RunChildRecord>, VmError> {
280 let children = store
281 .list(ListFilter {
282 parent_session_id: Some(session_id.to_string()),
283 ..ListFilter::default()
284 })
285 .await
286 .map_err(|error| {
287 VmError::Runtime(format!("runs: failed to list child sessions: {error}"))
288 })?;
289 Ok(children
290 .into_iter()
291 .map(|child| RunChildRecord {
292 worker_id: child.id.clone(),
293 worker_name: child.persona.clone().unwrap_or_default(),
294 session_id: Some(child.id.clone()),
295 parent_session_id: Some(session_id.to_string()),
296 task: child.title.clone().unwrap_or_default(),
297 status: run_status_for(&child.status, None, None).to_string(),
298 started_at: child.created_at.clone(),
299 finished_at: child.closed_at.clone(),
300 run_id: Some(child.id.clone()),
301 ..RunChildRecord::default()
302 })
303 .collect())
304}
305
306#[derive(Default)]
308struct SessionFold {
309 task: Option<String>,
310 usage: LlmUsageRecord,
311 models: Vec<String>,
312 providers: Vec<String>,
313 cache_read_tokens: i64,
314 cache_write_tokens: i64,
315 provider_attempts: i64,
320 rate_limited_attempts: i64,
321 empty_completion_attempts: i64,
322 other_retry_attempts: i64,
323 total_cost: Decimal,
330 tools: Vec<ToolCallRecord>,
331 tool_index: BTreeMap<String, usize>,
334 iteration: usize,
335 max_iteration: usize,
336 terminal: Option<TerminalFacts>,
337 llm_calls: Vec<LlmCallFacts>,
338}
339
340struct LlmCallFacts {
346 at_ms: i64,
347 model: Option<String>,
348 provider: Option<String>,
349 input_tokens: i64,
350 output_tokens: i64,
351 cache_read_tokens: i64,
352 cache_write_tokens: i64,
353 cost_usd: Option<f64>,
354}
355
356struct TerminalFacts {
357 final_status: Option<String>,
358 stop_reason: Option<String>,
359 error: Option<String>,
360 class: Option<String>,
361 kind: Option<crate::agent_events::AgentTerminalKind>,
362 owner: Option<String>,
363 reason: Option<String>,
364 at: String,
365}
366
367fn assemble(
368 meta: SessionMeta,
369 events: Vec<StoredEvent>,
370 children: Vec<RunChildRecord>,
371 root_run_id: String,
372) -> RunRecord {
373 let mut fold = SessionFold::default();
374 for event in &events {
375 fold.absorb(event);
376 }
377
378 let status = run_status_for(
379 &meta.status,
380 fold.terminal.as_ref().and_then(|terminal| terminal.kind),
381 fold.terminal
382 .as_ref()
383 .and_then(|t| t.final_status.as_deref()),
384 )
385 .to_string();
386 let finished_at = meta
391 .closed_at
392 .clone()
393 .or_else(|| fold.terminal.as_ref().map(|t| t.at.clone()));
394
395 let mut metadata = BTreeMap::new();
396 metadata.insert(
397 "projected_from".to_string(),
398 json!({
399 "source": PROJECTION_SOURCE,
400 "session_id": meta.id,
401 "session_status": status_discriminator(&meta.status),
402 "session_event_count": meta.event_count,
403 "not_recoverable_from_session": UNRECOVERABLE_FIELDS,
410 }),
411 );
412 metadata.insert(
417 "wall_clock_ms".to_string(),
418 json!(meta.updated_at_ms.saturating_sub(meta.created_at_ms)),
419 );
420 if fold.max_iteration > 0 {
421 metadata.insert("iterations".to_string(), json!(fold.max_iteration));
422 }
423 if fold.cache_read_tokens > 0 || fold.cache_write_tokens > 0 {
424 metadata.insert(
425 "cache_tokens".to_string(),
426 json!({"read": fold.cache_read_tokens, "write": fold.cache_write_tokens}),
427 );
428 }
429 if !fold.providers.is_empty() {
430 metadata.insert("providers".to_string(), json!(fold.providers));
431 }
432 if fold.provider_attempts > fold.usage.call_count {
436 metadata.insert(
437 "provider_attempts".to_string(),
438 json!({
439 "total": fold.provider_attempts,
440 "retries": fold.provider_attempts - fold.usage.call_count,
441 "rate_limited": fold.rate_limited_attempts,
442 "empty_completion": fold.empty_completion_attempts,
443 "other": fold.other_retry_attempts,
444 }),
445 );
446 }
447 if let Some(terminal) = &fold.terminal {
448 if let Some(stop_reason) = &terminal.stop_reason {
449 metadata.insert("stop_reason".to_string(), json!(stop_reason));
450 }
451 if let Some(class) = &terminal.class {
452 metadata.insert("terminal_class".to_string(), json!(class));
453 }
454 if let Some(error) = &terminal.error {
455 metadata.insert("terminal_error".to_string(), json!(error));
456 }
457 if let Some(kind) = terminal.kind {
458 metadata.insert(
459 "terminal".to_string(),
460 json!({
461 "kind": kind.as_str(),
462 "reason": terminal.reason.as_deref().or(terminal.stop_reason.as_deref()),
463 "owner": terminal.owner.as_deref().unwrap_or_else(|| kind.owner()),
464 }),
465 );
466 }
467 }
468
469 let usage = LlmUsageRecord {
470 models: fold.models.clone(),
471 total_cost: fold.total_cost.to_f64().unwrap_or_default(),
472 ..fold.usage
473 };
474
475 RunRecord {
476 type_name: "run".to_string(),
477 id: meta.id.clone(),
478 workflow_id: AGENT_SESSION_WORKFLOW_ID.to_string(),
479 workflow_name: meta.persona.clone(),
480 task: meta.title.clone().or(fold.task).unwrap_or_default(),
484 status,
485 started_at: meta.created_at.clone(),
486 finished_at,
487 parent_run_id: meta.parent_session_id.clone(),
488 root_run_id: Some(root_run_id),
489 child_runs: children,
490 usage: (usage.call_count > 0).then_some(usage),
491 trace_spans: llm_call_spans(&meta, &fold.llm_calls),
492 tool_recordings: fold.tools,
493 execution: None,
494 metadata,
495 ..RunRecord::default()
496 }
497}
498
499impl SessionFold {
500 fn absorb(&mut self, event: &StoredEvent) {
501 match event.kind.discriminator() {
502 "message" => self.absorb_message(event),
503 "tool_call" => self.absorb_tool_call(event),
504 "tool_call_update" => self.absorb_tool_update(event),
505 "tool_result" => self.absorb_tool_result(event),
506 "llm_call" => self.absorb_llm_call(event),
507 "loop_checkpoint" => self.absorb_checkpoint(event),
508 "agent_run_terminal" => self.absorb_terminal(event),
509 _ => {}
510 }
511 }
512
513 fn absorb_message(&mut self, event: &StoredEvent) {
514 if self.task.is_some() {
515 return;
516 }
517 let is_user = event.actor.as_deref() == Some("user")
518 || facts::semantic_string(&event.payload, &facts::ROLE).as_deref() == Some("user");
519 if is_user {
520 self.task = facts::semantic_string(&event.payload, &facts::TEXT);
521 }
522 }
523
524 fn absorb_llm_call(&mut self, event: &StoredEvent) {
525 let payload = &event.payload;
526 self.llm_calls.push(LlmCallFacts {
527 at_ms: event.ts_ms,
528 model: facts::string_at(payload, facts::MODEL),
529 provider: facts::string_at(payload, facts::PROVIDER),
530 input_tokens: facts::i64_at(payload, facts::INPUT_TOKENS).unwrap_or(0),
531 output_tokens: facts::i64_at(payload, facts::OUTPUT_TOKENS).unwrap_or(0),
532 cache_read_tokens: facts::i64_at(payload, facts::CACHE_READ_TOKENS).unwrap_or(0),
533 cache_write_tokens: facts::i64_at(payload, facts::CACHE_WRITE_TOKENS).unwrap_or(0),
534 cost_usd: facts::f64_at(payload, facts::COST_USD),
535 });
536 self.usage.call_count += 1;
537 self.usage.input_tokens += facts::i64_at(payload, facts::INPUT_TOKENS).unwrap_or(0);
538 self.usage.output_tokens += facts::i64_at(payload, facts::OUTPUT_TOKENS).unwrap_or(0);
539 if let Some(cost) = facts::f64_at(payload, facts::COST_USD) {
540 self.total_cost += Decimal::from_f64_retain(cost).unwrap_or_default();
541 }
542 self.cache_read_tokens += facts::i64_at(payload, facts::CACHE_READ_TOKENS).unwrap_or(0);
543 self.cache_write_tokens += facts::i64_at(payload, facts::CACHE_WRITE_TOKENS).unwrap_or(0);
544 self.provider_attempts += facts::i64_at(payload, facts::PROVIDER_ATTEMPTS_TOTAL)
548 .filter(|total| *total > 0)
549 .unwrap_or(1);
550 self.rate_limited_attempts +=
551 facts::i64_at(payload, facts::PROVIDER_ATTEMPTS_RATE_LIMITED).unwrap_or(0);
552 self.empty_completion_attempts +=
553 facts::i64_at(payload, facts::PROVIDER_ATTEMPTS_EMPTY).unwrap_or(0);
554 self.other_retry_attempts +=
555 facts::i64_at(payload, facts::PROVIDER_ATTEMPTS_OTHER).unwrap_or(0);
556 if let Some(model) = facts::string_at(payload, facts::MODEL) {
557 push_distinct(&mut self.models, model);
558 }
559 if let Some(provider) = facts::string_at(payload, facts::PROVIDER) {
560 push_distinct(&mut self.providers, provider);
561 }
562 }
563
564 fn absorb_checkpoint(&mut self, event: &StoredEvent) {
565 if facts::string_at(&event.payload, facts::CHECKPOINT_KIND).as_deref()
566 != Some("iteration_start")
567 {
568 return;
569 }
570 if let Some(iteration) = facts::i64_at(&event.payload, facts::ITERATION) {
571 self.iteration = usize::try_from(iteration).unwrap_or(0);
572 self.max_iteration = self.max_iteration.max(self.iteration);
573 }
574 }
575
576 fn absorb_tool_call(&mut self, event: &StoredEvent) {
577 let payload = &event.payload;
578 let Some(tool_call_id) = facts::string_at(payload, facts::TOOL_CALL_ID) else {
579 return;
580 };
581 if self.tool_index.contains_key(&tool_call_id) {
582 return;
583 }
584 let args = facts::semantic_value(payload, &[facts::TOOL_RAW_INPUT])
585 .unwrap_or(serde_json::Value::Null);
586 let tool_name = facts::semantic_string(payload, &facts::TOOL_NAME_ANY).unwrap_or_default();
587 self.tool_index
588 .insert(tool_call_id.clone(), self.tools.len());
589 self.tools.push(ToolCallRecord {
590 args_hash: super::types::tool_fixture_hash(&tool_name, &args),
591 tool_name,
592 tool_use_id: tool_call_id,
593 iteration: self.iteration,
594 timestamp: event.ts.clone(),
595 ..ToolCallRecord::default()
596 });
597 }
598
599 fn absorb_tool_update(&mut self, event: &StoredEvent) {
600 let payload = &event.payload;
601 let Some(record) = self.tool_for(payload) else {
602 return;
603 };
604 let status = facts::string_at(payload, facts::TOOL_STATUS);
605 match status.as_deref() {
608 Some("completed") | Some("failed") | Some("rejected") => {
609 record.is_rejected = status.as_deref() == Some("rejected");
610 if let Some(duration) = facts::i64_at(payload, facts::TOOL_DURATION_MS) {
611 record.duration_ms = u64::try_from(duration).unwrap_or(0);
612 }
613 }
614 _ => {}
615 }
616 }
617
618 fn absorb_tool_result(&mut self, event: &StoredEvent) {
626 let payload = &event.payload;
627 let text = facts::semantic_string(payload, &facts::TEXT).unwrap_or_default();
628 let Some(record) = self.tool_for(payload) else {
629 return;
630 };
631 record.result = text;
632 }
633
634 fn tool_for(&mut self, payload: &serde_json::Value) -> Option<&mut ToolCallRecord> {
638 let tool_call_id = facts::string_at(payload, facts::TOOL_CALL_ID)?;
639 let index = *self.tool_index.get(&tool_call_id)?;
640 self.tools.get_mut(index)
641 }
642
643 fn absorb_terminal(&mut self, event: &StoredEvent) {
644 self.terminal = Some(TerminalFacts {
645 final_status: facts::string_at(&event.payload, facts::FINAL_STATUS),
646 stop_reason: facts::string_at(&event.payload, facts::STOP_REASON),
647 error: facts::string_at(&event.payload, facts::TERMINAL_ERROR),
648 class: facts::string_at(&event.payload, facts::TERMINAL_CLASS),
649 kind: facts::string_at(&event.payload, facts::TERMINAL_KIND)
650 .as_deref()
651 .and_then(crate::agent_events::AgentTerminalKind::from_wire),
652 owner: facts::string_at(&event.payload, facts::TERMINAL_OWNER),
653 reason: facts::string_at(&event.payload, facts::TERMINAL_REASON),
654 at: event.ts.clone(),
655 });
656 }
657}
658
659fn run_status_for(
666 session_status: &SessionStatus,
667 terminal_kind: Option<crate::agent_events::AgentTerminalKind>,
668 final_status: Option<&str>,
669) -> &'static str {
670 if let Some(kind) = terminal_kind {
671 return kind.lifecycle_state().wire_name();
672 }
673 if let Some(final_status) = final_status.filter(|value| !value.is_empty()) {
674 return if crate::llm::session_status_indicates_error(final_status) {
675 "failed"
676 } else {
677 "completed"
678 };
679 }
680 match session_status {
681 SessionStatus::Open => "running",
682 SessionStatus::Closed => "completed",
683 SessionStatus::SoftDeleted | SessionStatus::HardDeleted => "deleted",
684 }
685}
686
687fn status_discriminator(status: &SessionStatus) -> &'static str {
688 match status {
689 SessionStatus::Open => "open",
690 SessionStatus::Closed => "closed",
691 SessionStatus::SoftDeleted => "soft_deleted",
692 SessionStatus::HardDeleted => "hard_deleted",
693 }
694}
695
696fn llm_call_spans(meta: &SessionMeta, calls: &[LlmCallFacts]) -> Vec<RunTraceSpanRecord> {
712 calls
713 .iter()
714 .enumerate()
715 .map(|(index, call)| {
716 let mut metadata = BTreeMap::from([
717 (
718 crate::tracing::meta::INPUT_TOKENS.to_string(),
719 json!(call.input_tokens),
720 ),
721 (
722 crate::tracing::meta::OUTPUT_TOKENS.to_string(),
723 json!(call.output_tokens),
724 ),
725 (
726 crate::tracing::meta::CACHE_READ_TOKENS.to_string(),
727 json!(call.cache_read_tokens),
728 ),
729 (
730 crate::tracing::meta::CACHE_WRITE_TOKENS.to_string(),
731 json!(call.cache_write_tokens),
732 ),
733 ("duration_available".to_string(), json!(false)),
734 ]);
735 if let Some(model) = &call.model {
736 metadata.insert(crate::tracing::meta::MODEL.to_string(), json!(model));
737 }
738 if let Some(provider) = &call.provider {
739 metadata.insert(crate::tracing::meta::PROVIDER.to_string(), json!(provider));
740 }
741 RunTraceSpanRecord {
742 trace_id: meta.id.clone(),
743 span_id: index as u64 + 1,
746 parent_id: None,
747 kind: "llm_call".to_string(),
748 name: call.model.clone().unwrap_or_else(|| "llm_call".to_string()),
749 start_ms: u64::try_from(call.at_ms.saturating_sub(meta.created_at_ms)).unwrap_or(0),
750 duration_ms: 0,
751 ttft_ms: None,
752 metadata,
753 links: Vec::new(),
754 cost_usd: call.cost_usd,
755 }
756 })
757 .collect()
758}
759
760fn push_distinct(values: &mut Vec<String>, value: String) {
761 if !values.contains(&value) {
762 values.push(value);
763 }
764}
765
766#[cfg(test)]
767mod tests;