1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9
10use crate::event_log::{AnyEventLog, SqliteEventLog};
11use crate::redact::current_policy;
12use crate::session_timeline::{
13 query_session_timeline, SessionTimelineQuery, SessionTimelineSnapshot,
14};
15
16use super::persistence::load_run_record_snapshot;
17use super::time::parse_timestamp_ms;
18use super::{build_run_view_with_event_log, RunRecord, RunView, RunViewUsage, ViewProducer};
19
20mod join_evidence;
21#[cfg(test)]
22mod join_evidence_tests;
23
24use join_evidence::{project_join_evidence, JoinEvidenceProjection};
25
26pub const RUN_REPORT_SCHEMA: &str = "harn.run_report.v1";
27pub const RUN_REPORT_SCHEMA_VERSION: u32 = 1;
28const MAX_RUN_TREE_DEPTH: usize = 64;
29const MAX_RUN_TREE_NODES: usize = 1024;
30const MAX_RUN_TREE_BYTES: usize = 32 * 1024 * 1024;
31
32#[derive(Clone, Debug, Default)]
33pub struct RunReportRequest {
34 pub run_record_path: PathBuf,
35 pub events_db: Option<PathBuf>,
36 pub allowed_roots: Vec<PathBuf>,
39 pub source_root: Option<PathBuf>,
40}
41
42#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
43#[serde(default)]
44pub struct RunReport {
45 pub schema: String,
46 pub schema_version: u32,
47 pub producer: ViewProducer,
48 pub projection: RunReportProjection,
49 pub root_run_id: String,
50 pub agents: Vec<RunReportAgent>,
51 pub delegations: Vec<RunReportDelegation>,
52 pub llm_calls: Vec<RunReportLlmCall>,
53 pub tool_calls: Vec<RunReportToolCall>,
54 pub coordination: RunReportCoordination,
55 pub timelines: Vec<SessionTimelineSnapshot>,
56 pub sources: Vec<RunReportSource>,
57 pub checks: Vec<RunReportCheck>,
58}
59
60#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(default)]
62pub struct RunReportProjection {
63 pub id: String,
64 pub hash: String,
65}
66
67#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
68#[serde(default)]
69pub struct RunReportAgent {
70 pub agent_id: String,
71 pub worker_id: Option<String>,
72 pub run_id: Option<String>,
73 pub session_id: Option<String>,
74 pub parent_agent_id: Option<String>,
75 pub status: String,
76 pub task: String,
77 pub started_at: Option<String>,
78 pub finished_at: Option<String>,
79 pub duration_ms: Option<u64>,
80 pub usage: RunViewUsage,
81 pub visible_output: Option<String>,
82 pub execution: Option<RunReportExecution>,
83 pub capability_policy: Option<super::super::CapabilityPolicy>,
84 pub mutation_scope: Option<String>,
85 pub approval_policy: Option<super::super::ToolApprovalPolicy>,
86}
87
88#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(default)]
90pub struct RunReportExecution {
91 pub cwd: Option<String>,
92 pub project_root: Option<String>,
93 pub repo_path: Option<String>,
94 pub worktree_path: Option<String>,
95 pub branch: Option<String>,
96 pub environment_policy: crate::security::EnvironmentPolicyKind,
97 pub grants: Vec<crate::security::GrantReceipt>,
98}
99
100impl From<&super::RunExecutionRecord> for RunReportExecution {
101 fn from(execution: &super::RunExecutionRecord) -> Self {
102 Self {
103 cwd: execution.cwd.clone(),
104 project_root: execution.project_root.clone(),
105 repo_path: execution.repo_path.clone(),
106 worktree_path: execution.worktree_path.clone(),
107 branch: execution.branch.clone(),
108 environment_policy: execution.environment_policy,
109 grants: execution.grants.clone(),
110 }
111 }
112}
113
114#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
115#[serde(default)]
116pub struct RunReportDelegation {
117 pub parent_agent_id: String,
118 pub child_agent_id: String,
119 pub worker_id: String,
120 pub status: String,
121 pub parent_observed_status: String,
122 pub child_observed_status: Option<String>,
123 pub started_at: Option<String>,
124 pub finished_at: Option<String>,
125 pub forward_pointer: bool,
126 pub back_pointer: bool,
127 pub session_pointer_consistent: Option<bool>,
128}
129
130#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
131#[serde(default)]
132pub struct RunReportLlmCall {
133 pub agent_id: String,
134 pub call_id: String,
135 pub provider: Option<String>,
136 pub model: Option<String>,
137 pub start_ms: u64,
138 pub duration_ms: u64,
139 pub ttft_ms: Option<u64>,
140 pub input_tokens: Option<i64>,
141 pub output_tokens: Option<i64>,
142 pub cache_read_tokens: Option<i64>,
143 pub cache_write_tokens: Option<i64>,
144 pub cost_usd: Option<f64>,
145}
146
147#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
148#[serde(default)]
149pub struct RunReportToolCall {
150 pub agent_id: String,
151 pub call_id: String,
152 pub tool_name: String,
153 pub args_hash: String,
154 pub result: String,
155 pub is_rejected: bool,
156 pub duration_ms: u64,
157 pub iteration: usize,
158 pub timestamp: String,
159}
160
161#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
162#[serde(default)]
163pub struct RunReportCoordination {
164 pub spawned: usize,
165 pub terminal: usize,
166 pub open: usize,
167 pub orphaned: usize,
168 pub unjoined: Option<usize>,
171 pub max_concurrent_children: Option<usize>,
172 pub observed_wait_ms: Option<u64>,
177 pub observed_join_ms: Option<u64>,
180 pub observed_result_processing_ms: Option<u64>,
188}
189
190#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
191#[serde(default)]
192pub struct RunReportSource {
193 pub id: String,
194 pub agent_id: Option<String>,
195 pub kind: String,
196 pub path: Option<String>,
197 pub sha256: Option<String>,
198 pub status: String,
199 pub error: Option<String>,
200}
201
202#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
203#[serde(default)]
204pub struct RunReportCheck {
205 pub code: String,
206 pub severity: String,
207 pub status: String,
208 pub agent_id: Option<String>,
209 pub message: String,
210}
211
212#[derive(Debug)]
213pub enum RunReportError {
214 Read(String),
215 EventLog(String),
216 Encode(String),
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum RunReportValidationError {
221 Schema(String),
222 Hash(String),
223 Encode(String),
224}
225
226#[derive(Debug)]
227struct LoadedRunTree {
228 records: Vec<RunRecord>,
229 paths: Vec<PathBuf>,
230 hashes: Vec<String>,
231}
232
233impl std::fmt::Display for RunReportError {
234 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 match self {
236 Self::Read(message) | Self::EventLog(message) | Self::Encode(message) => {
237 formatter.write_str(message)
238 }
239 }
240 }
241}
242
243impl std::error::Error for RunReportError {}
244
245impl std::fmt::Display for RunReportValidationError {
246 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 Self::Schema(message) | Self::Hash(message) | Self::Encode(message) => {
249 formatter.write_str(message)
250 }
251 }
252 }
253}
254
255impl std::error::Error for RunReportValidationError {}
256
257pub fn validate_run_report(report: &RunReport) -> Result<(), RunReportValidationError> {
260 if report.schema != RUN_REPORT_SCHEMA || report.schema_version != RUN_REPORT_SCHEMA_VERSION {
261 return Err(RunReportValidationError::Schema(format!(
262 "expected {RUN_REPORT_SCHEMA} schema version {RUN_REPORT_SCHEMA_VERSION}, got {:?} version {}",
263 report.schema, report.schema_version
264 )));
265 }
266 let expected = run_report_projection_hash(report)?;
267 if report.projection.hash != expected {
268 return Err(RunReportValidationError::Hash(format!(
269 "run report projection hash mismatch: expected {expected}, got {:?}",
270 report.projection.hash
271 )));
272 }
273 Ok(())
274}
275
276pub fn run_report_projection_hash(report: &RunReport) -> Result<String, RunReportValidationError> {
279 let value = serde_json::to_value(report)
280 .map_err(|error| RunReportValidationError::Encode(error.to_string()))?;
281 Ok(run_report_projection_hash_value(&value))
282}
283
284fn run_report_projection_hash_value(value: &Value) -> String {
285 let mut value = value.clone();
286 value["projection"]["hash"] = Value::String(String::new());
287 let digest = Sha256::digest(crate::canonical_json::to_vec(&value));
288 format!("sha256:{}", hex::encode(digest))
289}
290
291pub async fn build_run_report(request: RunReportRequest) -> Result<RunReport, RunReportError> {
292 let allowed_roots = canonical_allowed_roots(&request.allowed_roots)?;
293 let root_path = checked_path(&request.run_record_path, &allowed_roots)?;
294 let source_root = request
295 .source_root
296 .as_deref()
297 .and_then(|path| path.canonicalize().ok());
298 let events_db_path = request
299 .events_db
300 .as_deref()
301 .map(|path| checked_path(path, &allowed_roots))
302 .transpose()?;
303 let event_log = match events_db_path.as_deref() {
304 Some(path) => Some(AnyEventLog::Sqlite(
305 SqliteEventLog::open_read_only(path.to_path_buf(), 16)
306 .map_err(|error| RunReportError::EventLog(error.to_string()))?,
307 )),
308 None => None,
309 };
310
311 let tree = tokio::task::spawn_blocking(move || load_run_tree(&root_path, &allowed_roots))
312 .await
313 .map_err(|error| RunReportError::Read(format!("load run tree task: {error}")))??;
314 let root_run_id = tree
315 .records
316 .first()
317 .map(|record| record.id.clone())
318 .unwrap_or_default();
319
320 let mut views = Vec::with_capacity(tree.records.len());
321 let mut timelines = Vec::new();
322 for (record, path) in tree.records.iter().zip(&tree.paths) {
323 let view = build_run_view_with_event_log(record, None::<String>, event_log.as_ref())
324 .await
325 .map_err(|error| RunReportError::EventLog(error.to_string()))?;
326 let query = SessionTimelineQuery {
327 session_id: view.run.session_id.clone(),
328 run_id: Some(record.id.clone()),
329 run_path: Some(path.to_string_lossy().into_owned()),
330 ..SessionTimelineQuery::default()
331 };
332 let timeline = query_session_timeline(event_log.as_ref(), Some(record), query)
333 .await
334 .map_err(|error| RunReportError::EventLog(error.to_string()))?;
335 if event_log.is_some() || !timeline.nodes.is_empty() {
336 timelines.push(timeline);
337 }
338 views.push(view);
339 }
340
341 let report = assemble_report(
342 root_run_id,
343 &tree.records,
344 &tree.paths,
345 &tree.hashes,
346 &views,
347 timelines,
348 source_root.as_deref(),
349 events_db_path.as_deref(),
350 )?;
351 let mut value =
352 serde_json::to_value(&report).map_err(|error| RunReportError::Encode(error.to_string()))?;
353 value["projection"]["hash"] = Value::String(String::new());
354 current_policy().redact_json_in_place(&mut value);
355 value["projection"]["hash"] = Value::String(run_report_projection_hash_value(&value));
356 serde_json::from_value(value).map_err(|error| RunReportError::Encode(error.to_string()))
357}
358
359fn assemble_report(
360 root_run_id: String,
361 records: &[RunRecord],
362 record_paths: &[PathBuf],
363 record_hashes: &[String],
364 views: &[RunView],
365 timelines: Vec<SessionTimelineSnapshot>,
366 source_root: Option<&Path>,
367 events_db_path: Option<&Path>,
368) -> Result<RunReport, RunReportError> {
369 let views_by_id: BTreeMap<_, _> = views
370 .iter()
371 .map(|view| (view.run.run_id.as_str(), view))
372 .collect();
373 let records_by_id: BTreeMap<_, _> = records
374 .iter()
375 .map(|record| (record.id.as_str(), record))
376 .collect();
377 if records_by_id.len() != records.len() {
378 return Err(RunReportError::Read(
379 "run tree contains duplicate run ids; lineage would be ambiguous".to_string(),
380 ));
381 }
382 let records_by_path: BTreeMap<_, _> = record_paths
383 .iter()
384 .zip(records)
385 .map(|(path, record)| (path.clone(), record))
386 .collect();
387 let mut checks = Vec::new();
388 let mut agents = Vec::new();
389 let mut delegations = Vec::new();
390 let mut llm_calls = Vec::new();
391 let mut tool_calls = Vec::new();
392 let mut sources = Vec::new();
393 let mut forward_edges = BTreeSet::new();
394
395 for (((record, path), hash), view) in records
396 .iter()
397 .zip(record_paths)
398 .zip(record_hashes)
399 .zip(views)
400 {
401 agents.push(agent_from_view(view, None, record.policy.clone()));
402 sources.push(source_for_snapshot(
403 format!("run:{}", record.id),
404 "run_record",
405 path,
406 hash,
407 source_root,
408 Some(format!("run:{}", record.id)),
409 ));
410 if let Some(observability) = &record.observability {
411 for pointer in &observability.transcript_pointers {
412 sources.push(RunReportSource {
413 id: format!("run:{}:{}", record.id, pointer.id),
414 agent_id: Some(format!("run:{}", record.id)),
415 kind: pointer.kind.clone(),
416 path: pointer
417 .path
418 .as_deref()
419 .map(|path| display_path(Path::new(path), source_root)),
420 sha256: pointer
421 .descriptor
422 .as_ref()
423 .map(|descriptor| descriptor.sha256.clone()),
424 status: pointer.verification_status.clone(),
425 error: pointer.verification_error.clone(),
426 });
427 }
428 }
429 for span in &record.trace_spans {
430 if span.kind != "llm_call" {
431 continue;
432 }
433 llm_calls.push(llm_call_from_span(&record.id, span));
434 }
435 tool_calls.extend(
436 record
437 .tool_recordings
438 .iter()
439 .map(|tool| tool_call_from_record(&record.id, tool)),
440 );
441 for child in &record.child_runs {
442 let path_record = child
443 .run_path
444 .as_deref()
445 .map(PathBuf::from)
446 .map(|child_path| resolve_child_path(path, child_path))
447 .and_then(|child_path| child_path.canonicalize().ok())
448 .and_then(|child_path| records_by_path.get(&child_path).copied());
449 let id_record = child
450 .run_id
451 .as_deref()
452 .and_then(|id| records_by_id.get(id).copied());
453 let child_record = path_record.or(id_record);
454 let child_agent_id = child_record
455 .map(|record| format!("run:{}", record.id))
456 .or_else(|| child.run_id.as_ref().map(|run_id| format!("run:{run_id}")))
457 .unwrap_or_else(|| format!("worker:{}", child.worker_id));
458 let child_view =
459 child_record.and_then(|record| views_by_id.get(record.id.as_str()).copied());
460 let back_pointer = child_record
461 .and_then(|child_record| child_record.parent_run_id.as_deref())
462 == Some(record.id.as_str());
463 let session_pointer_consistent = session_pointers_consistent(view, child, child_view);
464 let child_observed_status = child_record.map(|record| record.status.clone());
465 let status = child_observed_status
466 .as_deref()
467 .filter(|status| !status.is_empty())
468 .unwrap_or(&child.status)
469 .to_string();
470 delegations.push(RunReportDelegation {
471 parent_agent_id: format!("run:{}", record.id),
472 child_agent_id: child_agent_id.clone(),
473 worker_id: child.worker_id.clone(),
474 status,
475 parent_observed_status: child.status.clone(),
476 child_observed_status: child_observed_status.clone(),
477 started_at: child_record
478 .and_then(|record| nonempty(&record.started_at))
479 .or_else(|| nonempty(&child.started_at)),
480 finished_at: child_record
481 .and_then(|record| record.finished_at.clone())
482 .or_else(|| child.finished_at.clone()),
483 forward_pointer: true,
484 back_pointer,
485 session_pointer_consistent,
486 });
487 if let Some(child_record) = child_record {
488 forward_edges.insert((record.id.clone(), child_record.id.clone()));
489 if child.run_id.is_none() {
490 checks.push(check(
491 "child_run_id_missing",
492 "warning",
493 &child_agent_id,
494 format!(
495 "worker {} was correlated by run_path because its forward run_id is missing",
496 child.worker_id
497 ),
498 ));
499 } else if child.run_id.as_deref() != Some(child_record.id.as_str()) {
500 checks.push(check(
501 "child_run_id_mismatch",
502 "error",
503 &child_agent_id,
504 format!(
505 "worker {} points to run id {:?}, but run_path contains {}",
506 child.worker_id, child.run_id, child_record.id
507 ),
508 ));
509 }
510 if let Some(agent) = agents
511 .iter_mut()
512 .find(|agent| agent.agent_id == child_agent_id)
513 {
514 agent.worker_id = Some(child.worker_id.clone());
515 agent.mutation_scope = child.mutation_scope.clone();
516 agent.approval_policy = child.approval_policy.clone();
517 }
518 }
519 if child_record.is_none() {
520 agents.push(agent_from_child(child, &record.id));
521 checks.push(check(
522 "child_run_missing",
523 "error",
524 &child_agent_id,
525 format!(
526 "worker {} has no readable child run record",
527 child.worker_id
528 ),
529 ));
530 } else if !back_pointer {
531 checks.push(check(
532 "child_back_pointer_mismatch",
533 "error",
534 &child_agent_id,
535 format!("child does not point back to parent run {}", record.id),
536 ));
537 }
538 if child_observed_status
539 .as_deref()
540 .is_some_and(|status| !child.status.is_empty() && status != child.status)
541 {
542 checks.push(check(
543 "child_status_mismatch",
544 "warning",
545 &child_agent_id,
546 format!(
547 "parent recorded status {:?}, while the child run recorded {:?}",
548 child.status, child_observed_status
549 ),
550 ));
551 }
552 if session_pointer_consistent == Some(false) {
553 checks.push(check(
554 "child_session_pointer_mismatch",
555 "error",
556 &child_agent_id,
557 "child and parent disagree about parent session".to_string(),
558 ));
559 }
560 }
561 }
562
563 if let Some(path) = events_db_path {
564 sources.push(RunReportSource {
565 id: "events:sqlite".to_string(),
566 agent_id: None,
567 kind: "event_log".to_string(),
568 path: Some(display_path(path, source_root)),
569 sha256: None,
570 status: "queried_read_only".to_string(),
571 error: Some(
572 "live SQLite evidence is cursor-scoped; no unstable whole-file hash was claimed"
573 .to_string(),
574 ),
575 });
576 }
577
578 for record in records {
579 if let Some(parent_id) = record.parent_run_id.as_deref() {
580 if !records_by_id.contains_key(parent_id) {
581 checks.push(check(
582 "parent_run_missing",
583 "error",
584 &format!("run:{}", record.id),
585 format!("parent run {parent_id} is not present in the report"),
586 ));
587 } else if !forward_edges.contains(&(parent_id.to_string(), record.id.clone())) {
588 delegations.push(RunReportDelegation {
589 parent_agent_id: format!("run:{parent_id}"),
590 child_agent_id: format!("run:{}", record.id),
591 back_pointer: true,
592 ..RunReportDelegation::default()
593 });
594 checks.push(check(
595 "parent_forward_pointer_missing",
596 "error",
597 &format!("run:{}", record.id),
598 format!("parent run {parent_id} does not list this child"),
599 ));
600 }
601 }
602 }
603
604 for timeline in &timelines {
605 if !timeline.coverage.truncated {
606 continue;
607 }
608 let agent_id = timeline
609 .query
610 .run_id
611 .as_deref()
612 .map(|run_id| format!("run:{run_id}"))
613 .unwrap_or_else(|| format!("run:{root_run_id}"));
614 let availability = timeline
615 .coverage
616 .available
617 .map(|available| format!(" of {available} available"))
618 .unwrap_or_else(|| ", with the total available count unknown".to_string());
619 checks.push(check(
620 "timeline_truncated",
621 "warning",
622 &agent_id,
623 format!(
624 "timeline returned {}{}; later evidence may be omitted, so absence must not be inferred",
625 timeline.coverage.returned, availability
626 ),
627 ));
628 }
629
630 let join_evidence = project_join_evidence(&delegations, &timelines, events_db_path.is_some());
631 checks.extend(join_evidence.checks.iter().cloned());
632 let coordination = coordination_summary(&delegations, &checks, &join_evidence);
633 if !delegations.is_empty() {
634 if coordination.max_concurrent_children.is_none() {
635 checks.push(RunReportCheck {
636 code: "coordination_intervals_incomplete".to_string(),
637 severity: "info".to_string(),
638 status: "unavailable".to_string(),
639 agent_id: Some(format!("run:{root_run_id}")),
640 message: "one or more child intervals lack a parseable start or finish timestamp, so peak concurrency is unknown".to_string(),
641 });
642 }
643 let unavailable: Vec<&str> = [
648 ("parent wait", coordination.observed_wait_ms.is_none()),
649 (
650 "terminal-to-collection lag",
651 coordination.observed_join_ms.is_none(),
652 ),
653 (
654 "result-processing time",
655 coordination.observed_result_processing_ms.is_none(),
656 ),
657 ]
658 .into_iter()
659 .filter_map(|(label, missing)| missing.then_some(label))
660 .collect();
661 if !unavailable.is_empty() {
662 checks.push(RunReportCheck {
663 code: "coordination_timing_unavailable".to_string(),
664 severity: "info".to_string(),
665 status: "unavailable".to_string(),
666 agent_id: Some(format!("run:{root_run_id}")),
667 message: if join_evidence.complete {
668 format!(
669 "no canonical boundary was observed for {}, so {} remain{} unknown",
670 unavailable.join(", "),
671 if unavailable.len() == 1 { "it" } else { "they" },
672 if unavailable.len() == 1 { "s" } else { "" },
673 )
674 } else {
675 "canonical join evidence is missing, malformed, or truncated, so unjoined children, parent wait, terminal-to-collection lag, and result-processing time remain unknown".to_string()
676 },
677 });
678 }
679 }
680
681 agents.sort_by(|left, right| left.agent_id.cmp(&right.agent_id));
682 agents.dedup_by(|left, right| left.agent_id == right.agent_id);
683 delegations.sort_by(|left, right| {
684 (&left.parent_agent_id, &left.child_agent_id, &left.worker_id).cmp(&(
685 &right.parent_agent_id,
686 &right.child_agent_id,
687 &right.worker_id,
688 ))
689 });
690 llm_calls.sort_by_key(|call| (call.start_ms, call.call_id.clone()));
691 tool_calls.sort_by(|left, right| {
692 (&left.timestamp, &left.agent_id, &left.call_id).cmp(&(
693 &right.timestamp,
694 &right.agent_id,
695 &right.call_id,
696 ))
697 });
698 sources.sort_by(|left, right| left.id.cmp(&right.id));
699 checks.sort_by(|left, right| (&left.code, &left.agent_id).cmp(&(&right.code, &right.agent_id)));
700
701 Ok(RunReport {
702 schema: RUN_REPORT_SCHEMA.to_string(),
703 schema_version: RUN_REPORT_SCHEMA_VERSION,
704 producer: ViewProducer::default(),
705 projection: RunReportProjection {
706 id: format!("run_report:{root_run_id}"),
707 hash: String::new(),
708 },
709 root_run_id,
710 agents,
711 delegations,
712 llm_calls,
713 tool_calls,
714 coordination,
715 timelines,
716 sources,
717 checks,
718 })
719}
720
721fn load_run_tree(path: &Path, allowed_roots: &[PathBuf]) -> Result<LoadedRunTree, RunReportError> {
722 let mut tree = LoadedRunTree {
723 records: Vec::new(),
724 paths: Vec::new(),
725 hashes: Vec::new(),
726 };
727 let mut seen_paths = BTreeSet::new();
728 let mut pending = vec![(path.to_path_buf(), 0_usize)];
729 let mut total_bytes = 0_usize;
730
731 while let Some((candidate, depth)) = pending.pop() {
732 if depth > MAX_RUN_TREE_DEPTH {
733 return Err(RunReportError::Read(format!(
734 "run tree exceeds maximum depth {MAX_RUN_TREE_DEPTH}"
735 )));
736 }
737 let path = checked_path(&candidate, allowed_roots)?;
738 if !seen_paths.insert(path.clone()) {
739 continue;
740 }
741 if seen_paths.len() > MAX_RUN_TREE_NODES {
742 return Err(RunReportError::Read(format!(
743 "run tree exceeds maximum node count {MAX_RUN_TREE_NODES}"
744 )));
745 }
746 let (record, bytes) = load_run_record_snapshot(&path).map_err(|error| {
747 RunReportError::Read(format!("load run record {}: {error}", path.display()))
748 })?;
749 total_bytes = total_bytes.saturating_add(bytes.len());
750 if total_bytes > MAX_RUN_TREE_BYTES {
751 return Err(RunReportError::Read(format!(
752 "run tree exceeds maximum persisted size {MAX_RUN_TREE_BYTES} bytes"
753 )));
754 }
755 let mut child_paths = record
756 .child_runs
757 .iter()
758 .filter_map(|child| child.run_path.as_deref())
759 .map(PathBuf::from)
760 .map(|child| resolve_child_path(&path, child))
761 .filter(|child| child.exists())
762 .map(|child| (child, depth + 1))
763 .collect::<Vec<_>>();
764 child_paths.reverse();
765 pending.extend(child_paths);
766 tree.hashes
767 .push(format!("sha256:{}", hex::encode(Sha256::digest(&bytes))));
768 tree.records.push(record);
769 tree.paths.push(path);
770 }
771 Ok(tree)
772}
773
774fn resolve_child_path(parent_path: &Path, child: PathBuf) -> PathBuf {
775 if child.is_absolute() {
776 child
777 } else {
778 parent_path.parent().unwrap_or(Path::new(".")).join(child)
779 }
780}
781
782fn canonical_allowed_roots(roots: &[PathBuf]) -> Result<Vec<PathBuf>, RunReportError> {
783 roots
784 .iter()
785 .map(|root| {
786 root.canonicalize().map_err(|error| {
787 RunReportError::Read(format!("resolve allowed root {}: {error}", root.display()))
788 })
789 })
790 .collect()
791}
792
793fn checked_path(path: &Path, allowed_roots: &[PathBuf]) -> Result<PathBuf, RunReportError> {
794 let canonical = path
795 .canonicalize()
796 .map_err(|error| RunReportError::Read(format!("resolve {}: {error}", path.display())))?;
797 if !allowed_roots.is_empty() && !allowed_roots.iter().any(|root| canonical.starts_with(root)) {
798 return Err(RunReportError::Read(format!(
799 "path {} is outside the report's allowed roots",
800 path.display()
801 )));
802 }
803 Ok(canonical)
804}
805
806pub(crate) fn read_checked_run_report_bytes(
807 path: &Path,
808 allowed_roots: &[PathBuf],
809) -> Result<Vec<u8>, RunReportError> {
810 let allowed_roots = canonical_allowed_roots(allowed_roots)?;
811 let path = checked_path(path, &allowed_roots)?;
812 std::fs::read(&path).map_err(|error| {
813 RunReportError::Read(format!("read run report {}: {error}", path.display()))
814 })
815}
816
817fn agent_from_view(
818 view: &RunView,
819 worker_id: Option<String>,
820 capability_policy: super::super::CapabilityPolicy,
821) -> RunReportAgent {
822 RunReportAgent {
823 agent_id: format!("run:{}", view.run.run_id),
824 worker_id,
825 run_id: Some(view.run.run_id.clone()),
826 session_id: view.run.session_id.clone(),
827 parent_agent_id: view
828 .run
829 .parent_run_id
830 .as_ref()
831 .map(|id| format!("run:{id}")),
832 status: view.run.status.clone(),
833 task: view.run.task.clone(),
834 started_at: nonempty(&view.run.started_at),
835 finished_at: view.run.finished_at.clone(),
836 duration_ms: view.run.duration_ms,
837 usage: view.usage.clone(),
838 visible_output: view.visible_text.clone(),
839 execution: view
840 .metadata
841 .execution
842 .as_ref()
843 .map(RunReportExecution::from),
844 capability_policy: Some(capability_policy),
845 mutation_scope: None,
846 approval_policy: None,
847 }
848}
849
850fn session_pointers_consistent(
851 parent_view: &RunView,
852 child: &super::RunChildRecord,
853 child_view: Option<&RunView>,
854) -> Option<bool> {
855 let mut comparisons = Vec::new();
856 if let (Some(actual), Some(declared)) = (
857 parent_view.run.session_id.as_deref(),
858 child.parent_session_id.as_deref(),
859 ) {
860 comparisons.push(actual == declared);
861 }
862 if let (Some(actual), Some(child_view)) = (parent_view.run.session_id.as_deref(), child_view) {
863 if let Some(declared) = child_view.run.parent_session_id.as_deref() {
864 comparisons.push(actual == declared);
865 }
866 }
867 if let (Some(declared), Some(child_view)) = (child.session_id.as_deref(), child_view) {
868 if let Some(actual) = child_view.run.session_id.as_deref() {
869 comparisons.push(actual == declared);
870 }
871 }
872 (!comparisons.is_empty()).then(|| comparisons.into_iter().all(|matches| matches))
873}
874
875fn agent_from_child(child: &super::RunChildRecord, parent_run_id: &str) -> RunReportAgent {
876 let policy = current_policy();
877 RunReportAgent {
878 agent_id: child
879 .run_id
880 .as_ref()
881 .map(|run_id| format!("run:{run_id}"))
882 .unwrap_or_else(|| format!("worker:{}", child.worker_id)),
883 worker_id: Some(child.worker_id.clone()),
884 run_id: child.run_id.clone(),
885 session_id: child.session_id.clone(),
886 parent_agent_id: Some(format!("run:{parent_run_id}")),
887 status: child.status.clone(),
888 task: policy.redact_string(&child.task).into_owned(),
889 started_at: nonempty(&child.started_at),
890 finished_at: child.finished_at.clone(),
891 execution: child.execution.as_ref().map(RunReportExecution::from),
892 capability_policy: None,
893 mutation_scope: child.mutation_scope.clone(),
894 approval_policy: child.approval_policy.clone(),
895 ..RunReportAgent::default()
896 }
897}
898
899fn llm_call_from_span(run_id: &str, span: &super::RunTraceSpanRecord) -> RunReportLlmCall {
900 let integer = |key: &str| span.metadata.get(key).and_then(Value::as_i64);
901 let number = |key: &str| span.metadata.get(key).and_then(Value::as_f64);
902 let text = |key: &str| {
903 span.metadata
904 .get(key)
905 .and_then(Value::as_str)
906 .map(str::to_string)
907 };
908 RunReportLlmCall {
909 agent_id: format!("run:{run_id}"),
910 call_id: format!("{}:{}", span.trace_id, span.span_id),
911 provider: text(crate::tracing::meta::PROVIDER),
912 model: text(crate::tracing::meta::MODEL),
913 start_ms: span.start_ms,
914 duration_ms: span.duration_ms,
915 ttft_ms: span.ttft_ms,
916 input_tokens: integer(crate::tracing::meta::INPUT_TOKENS),
917 output_tokens: integer(crate::tracing::meta::OUTPUT_TOKENS),
918 cache_read_tokens: integer(crate::tracing::meta::CACHE_READ_TOKENS),
919 cache_write_tokens: integer(crate::tracing::meta::CACHE_WRITE_TOKENS),
920 cost_usd: span
921 .cost_usd
922 .or_else(|| number(crate::tracing::meta::COST_USD)),
923 }
924}
925
926fn tool_call_from_record(run_id: &str, tool: &super::ToolCallRecord) -> RunReportToolCall {
927 RunReportToolCall {
928 agent_id: format!("run:{run_id}"),
929 call_id: tool.tool_use_id.clone(),
930 tool_name: tool.tool_name.clone(),
931 args_hash: tool.args_hash.clone(),
932 result: tool.result.clone(),
933 is_rejected: tool.is_rejected,
934 duration_ms: tool.duration_ms,
935 iteration: tool.iteration,
936 timestamp: tool.timestamp.clone(),
937 }
938}
939
940fn coordination_summary(
941 delegations: &[RunReportDelegation],
942 checks: &[RunReportCheck],
943 join_evidence: &JoinEvidenceProjection,
944) -> RunReportCoordination {
945 let terminal = delegations
946 .iter()
947 .filter(|delegation| {
948 crate::agent_events::WorkerEvent::status_is_terminal(&delegation.status)
949 })
950 .count();
951 let intervals: Vec<(i128, i128)> = delegations
952 .iter()
953 .filter_map(|delegation| {
954 let start = parse_timestamp_ms(delegation.started_at.as_deref()?)?;
955 let finish = parse_timestamp_ms(delegation.finished_at.as_deref()?)?;
956 (finish >= start).then_some((start, finish))
957 })
958 .collect();
959 let mut points = intervals
960 .iter()
961 .flat_map(|(start, finish)| [(*start, 1_i32), (*finish, -1_i32)])
962 .collect::<Vec<_>>();
963 points.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| right.1.cmp(&left.1)));
967 let max_concurrent_children = if delegations.is_empty() {
968 Some(0)
969 } else if intervals.len() == delegations.len() {
970 Some(
971 points
972 .into_iter()
973 .fold((0_i32, 0_i32), |(active, max), (_, delta)| {
974 let active = active + delta;
975 (active, max.max(active))
976 })
977 .1
978 .max(0) as usize,
979 )
980 } else {
981 None
982 };
983 RunReportCoordination {
984 spawned: delegations.len(),
985 terminal,
986 open: delegations.len().saturating_sub(terminal),
987 orphaned: checks
988 .iter()
989 .filter(|check| check.code == "parent_run_missing")
990 .count(),
991 unjoined: join_evidence.complete.then(|| {
992 delegations
993 .iter()
994 .filter(|delegation| {
995 crate::agent_events::WorkerEvent::status_is_terminal(&delegation.status)
996 && !join_evidence.joined(delegation)
997 })
998 .count()
999 }),
1000 max_concurrent_children,
1001 observed_wait_ms: if join_evidence.complete {
1002 join_evidence.max_wait_ms
1003 } else {
1004 None
1005 },
1006 observed_join_ms: if join_evidence.complete {
1007 join_evidence.max_terminal_to_collection_ms
1008 } else {
1009 None
1010 },
1011 observed_result_processing_ms: if join_evidence.complete {
1012 join_evidence.max_result_processing_ms
1013 } else {
1014 None
1015 },
1016 }
1017}
1018
1019fn source_for_snapshot(
1020 id: String,
1021 kind: &str,
1022 path: &Path,
1023 sha256: &str,
1024 source_root: Option<&Path>,
1025 agent_id: Option<String>,
1026) -> RunReportSource {
1027 RunReportSource {
1028 id,
1029 agent_id,
1030 kind: kind.to_string(),
1031 path: Some(display_path(path, source_root)),
1032 sha256: Some(sha256.to_string()),
1033 status: "verified".to_string(),
1034 error: None,
1035 }
1036}
1037
1038fn display_path(path: &Path, source_root: Option<&Path>) -> String {
1039 let normalized = canonicalize_with_missing_suffix(path).unwrap_or_else(|| path.to_path_buf());
1040 source_root
1041 .and_then(|root| normalized.strip_prefix(root).ok())
1042 .unwrap_or(&normalized)
1043 .to_string_lossy()
1044 .into_owned()
1045}
1046
1047fn canonicalize_with_missing_suffix(path: &Path) -> Option<PathBuf> {
1048 let mut cursor = path;
1049 let mut suffix = Vec::new();
1050 while !cursor.exists() {
1051 suffix.push(cursor.file_name()?.to_os_string());
1052 cursor = cursor.parent()?;
1053 }
1054 let mut normalized = cursor.canonicalize().ok()?;
1055 for component in suffix.into_iter().rev() {
1056 normalized.push(component);
1057 }
1058 Some(normalized)
1059}
1060
1061fn check(code: &str, severity: &str, agent_id: &str, message: String) -> RunReportCheck {
1062 RunReportCheck {
1063 code: code.to_string(),
1064 severity: severity.to_string(),
1065 status: "failed".to_string(),
1066 agent_id: Some(agent_id.to_string()),
1067 message,
1068 }
1069}
1070
1071fn nonempty(value: &str) -> Option<String> {
1072 (!value.is_empty()).then(|| value.to_string())
1073}
1074
1075#[cfg(test)]
1076#[path = "report/tool_call_tests.rs"]
1077mod tool_call_tests;
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082 use crate::orchestration::{save_run_record, RunChildRecord, RunTraceSpanRecord};
1083 use std::fs;
1084
1085 fn temp_dir(label: &str) -> PathBuf {
1086 let path = std::env::temp_dir().join(format!("harn-{label}-{}", uuid::Uuid::now_v7()));
1087 fs::create_dir_all(&path).unwrap();
1088 path
1089 }
1090
1091 #[tokio::test]
1092 async fn report_correlates_parent_and_child_bidirectionally() {
1093 let dir = temp_dir("run-report-lineage");
1094 let parent_path = dir.join("parent.json");
1095 let child_path = dir.join("child.json");
1096 let child = RunRecord {
1097 type_name: "workflow_run".to_string(),
1098 id: "child".to_string(),
1099 workflow_id: "child-workflow".to_string(),
1100 task: "child task".to_string(),
1101 status: "completed".to_string(),
1102 started_at: "2026-08-02T10:00:01Z".to_string(),
1103 finished_at: Some("2026-08-02T10:00:03Z".to_string()),
1104 parent_run_id: Some("parent".to_string()),
1105 root_run_id: Some("parent".to_string()),
1106 metadata: BTreeMap::from([
1107 (
1108 "session_id".to_string(),
1109 Value::String("child-session".to_string()),
1110 ),
1111 (
1112 "parent_session_id".to_string(),
1113 Value::String("parent-session".to_string()),
1114 ),
1115 ]),
1116 ..RunRecord::default()
1117 };
1118 let parent = RunRecord {
1119 type_name: "workflow_run".to_string(),
1120 id: "parent".to_string(),
1121 workflow_id: "parent-workflow".to_string(),
1122 task: "parent task".to_string(),
1123 status: "completed".to_string(),
1124 started_at: "2026-08-02T10:00:00Z".to_string(),
1125 finished_at: Some("2026-08-02T10:00:04Z".to_string()),
1126 root_run_id: Some("parent".to_string()),
1127 child_runs: vec![RunChildRecord {
1128 worker_id: "worker-1".to_string(),
1129 worker_name: "child".to_string(),
1130 task: "child task".to_string(),
1131 status: "completed".to_string(),
1132 started_at: "2026-08-02T10:00:01Z".to_string(),
1133 finished_at: Some("2026-08-02T10:00:03Z".to_string()),
1134 session_id: Some("child-session".to_string()),
1135 parent_session_id: Some("parent-session".to_string()),
1136 run_id: Some("child".to_string()),
1137 run_path: Some(child_path.to_string_lossy().into_owned()),
1138 ..RunChildRecord::default()
1139 }],
1140 metadata: BTreeMap::from([(
1141 "session_id".to_string(),
1142 Value::String("parent-session".to_string()),
1143 )]),
1144 ..RunRecord::default()
1145 };
1146 save_run_record(&child, Some(child_path.to_str().unwrap())).unwrap();
1147 save_run_record(&parent, Some(parent_path.to_str().unwrap())).unwrap();
1148
1149 let report = build_run_report(RunReportRequest {
1150 run_record_path: parent_path,
1151 allowed_roots: vec![dir.clone()],
1152 source_root: Some(dir.clone()),
1153 ..RunReportRequest::default()
1154 })
1155 .await
1156 .unwrap();
1157
1158 assert_eq!(report.agents.len(), 2);
1159 assert_eq!(report.delegations.len(), 1);
1160 assert!(report.delegations[0].forward_pointer);
1161 assert!(report.delegations[0].back_pointer);
1162 assert_eq!(report.delegations[0].session_pointer_consistent, Some(true));
1163 assert_eq!(report.coordination.max_concurrent_children, Some(1));
1164 assert_eq!(report.coordination.unjoined, None);
1165 assert!(report
1166 .checks
1167 .iter()
1168 .any(|check| check.code == "coordination_timing_unavailable"));
1169 assert!(report.projection.hash.starts_with("sha256:"));
1170 assert_eq!(
1171 report
1172 .sources
1173 .iter()
1174 .map(|source| source.id.as_str())
1175 .collect::<BTreeSet<_>>()
1176 .len(),
1177 report.sources.len()
1178 );
1179 assert!(
1180 report.sources.iter().all(|source| {
1181 source
1182 .path
1183 .as_deref()
1184 .is_none_or(|path| !Path::new(path).is_absolute())
1185 }),
1186 "sources={:?}",
1187 report.sources
1188 );
1189
1190 fs::remove_dir_all(dir).unwrap();
1191 }
1192
1193 #[tokio::test]
1194 async fn report_flags_missing_child_record_without_inventing_a_join() {
1195 let dir = temp_dir("run-report-missing-child");
1196 let parent_path = dir.join("parent.json");
1197 let parent = RunRecord {
1198 type_name: "workflow_run".to_string(),
1199 id: "parent".to_string(),
1200 workflow_id: "parent-workflow".to_string(),
1201 status: "completed".to_string(),
1202 child_runs: vec![RunChildRecord {
1203 worker_id: "worker-1".to_string(),
1204 worker_name: "child".to_string(),
1205 status: "running".to_string(),
1206 run_id: Some("missing-child".to_string()),
1207 run_path: Some(dir.join("missing.json").to_string_lossy().into_owned()),
1208 ..RunChildRecord::default()
1209 }],
1210 ..RunRecord::default()
1211 };
1212 save_run_record(&parent, Some(parent_path.to_str().unwrap())).unwrap();
1213
1214 let report = build_run_report(RunReportRequest {
1215 run_record_path: parent_path,
1216 allowed_roots: vec![dir.clone()],
1217 source_root: Some(dir.clone()),
1218 ..RunReportRequest::default()
1219 })
1220 .await
1221 .unwrap();
1222
1223 assert_eq!(report.coordination.open, 1);
1224 assert_eq!(report.coordination.unjoined, None);
1225 assert!(report
1226 .agents
1227 .iter()
1228 .any(|agent| agent.agent_id == "run:missing-child"));
1229 assert!(report
1230 .checks
1231 .iter()
1232 .any(|check| check.code == "child_run_missing"));
1233
1234 fs::remove_dir_all(dir).unwrap();
1235 }
1236
1237 #[test]
1238 fn suspended_child_is_open_until_resumed_or_stopped() {
1239 let coordination = coordination_summary(
1240 &[RunReportDelegation {
1241 status: "suspended".to_string(),
1242 ..RunReportDelegation::default()
1243 }],
1244 &[],
1245 &JoinEvidenceProjection::default(),
1246 );
1247
1248 assert_eq!(coordination.terminal, 0);
1249 assert_eq!(coordination.open, 1);
1250 }
1251
1252 #[tokio::test]
1253 async fn report_recovers_missing_forward_run_id_from_child_path() {
1254 let dir = temp_dir("run-report-path-lineage");
1255 let parent_path = dir.join("parent.json");
1256 let child_path = dir.join("child.json");
1257 let child = RunRecord {
1258 type_name: "workflow_run".to_string(),
1259 id: "child".to_string(),
1260 workflow_id: "child-workflow".to_string(),
1261 status: "completed".to_string(),
1262 parent_run_id: Some("parent".to_string()),
1263 started_at: uuid::Uuid::now_v7().to_string(),
1264 finished_at: Some(uuid::Uuid::now_v7().to_string()),
1265 ..RunRecord::default()
1266 };
1267 let parent = RunRecord {
1268 type_name: "workflow_run".to_string(),
1269 id: "parent".to_string(),
1270 workflow_id: "parent-workflow".to_string(),
1271 status: "completed".to_string(),
1272 child_runs: vec![RunChildRecord {
1273 worker_id: "worker-1".to_string(),
1274 status: "running".to_string(),
1275 run_id: None,
1276 run_path: Some(child_path.to_string_lossy().into_owned()),
1277 started_at: child.started_at.clone(),
1278 finished_at: child.finished_at.clone(),
1279 ..RunChildRecord::default()
1280 }],
1281 ..RunRecord::default()
1282 };
1283 save_run_record(&child, Some(child_path.to_str().unwrap())).unwrap();
1284 save_run_record(&parent, Some(parent_path.to_str().unwrap())).unwrap();
1285
1286 let report = build_run_report(RunReportRequest {
1287 run_record_path: parent_path,
1288 allowed_roots: vec![dir.clone()],
1289 source_root: Some(dir.clone()),
1290 ..RunReportRequest::default()
1291 })
1292 .await
1293 .unwrap();
1294
1295 assert_eq!(report.agents.len(), 2);
1296 assert_eq!(report.delegations.len(), 1);
1297 assert_eq!(report.delegations[0].child_agent_id, "run:child");
1298 assert_eq!(report.delegations[0].status, "completed");
1299 assert_eq!(report.delegations[0].parent_observed_status, "running");
1300 assert_eq!(report.coordination.terminal, 1);
1301 assert_eq!(report.coordination.max_concurrent_children, Some(1));
1302 assert!(report
1303 .checks
1304 .iter()
1305 .any(|check| check.code == "child_run_id_missing"));
1306 assert!(report
1307 .checks
1308 .iter()
1309 .any(|check| check.code == "child_status_mismatch"));
1310
1311 fs::remove_dir_all(dir).unwrap();
1312 }
1313
1314 #[tokio::test]
1315 async fn report_flags_timeline_truncation_before_a_late_llm_call() {
1316 let dir = temp_dir("run-report-timeline-truncation");
1317 let run_path = dir.join("run.json");
1318 let mut trace_spans = (1..=1025)
1319 .map(|span_id| RunTraceSpanRecord {
1320 trace_id: "trace-root".to_string(),
1321 span_id,
1322 kind: "import".to_string(),
1323 name: format!("import-{span_id}"),
1324 start_ms: span_id,
1325 duration_ms: 1,
1326 ..RunTraceSpanRecord::default()
1327 })
1328 .collect::<Vec<_>>();
1329 trace_spans.push(RunTraceSpanRecord {
1330 trace_id: "trace-root".to_string(),
1331 span_id: 1026,
1332 kind: "llm_call".to_string(),
1333 name: "late-llm-call".to_string(),
1334 start_ms: 1026,
1335 duration_ms: 2,
1336 ..RunTraceSpanRecord::default()
1337 });
1338 let run = RunRecord {
1339 type_name: "workflow_run".to_string(),
1340 id: "root".to_string(),
1341 status: "completed".to_string(),
1342 trace_spans,
1343 ..RunRecord::default()
1344 };
1345 save_run_record(&run, Some(run_path.to_str().unwrap())).unwrap();
1346
1347 let report = build_run_report(RunReportRequest {
1348 run_record_path: run_path,
1349 allowed_roots: vec![dir.clone()],
1350 source_root: Some(dir.clone()),
1351 ..RunReportRequest::default()
1352 })
1353 .await
1354 .unwrap();
1355
1356 assert_eq!(report.timelines.len(), 1);
1357 let timeline = &report.timelines[0];
1358 assert_eq!(timeline.coverage.returned, 1024);
1359 assert_eq!(timeline.coverage.available, Some(1026));
1360 assert!(timeline.coverage.truncated);
1361 assert!(!timeline.nodes.iter().any(|node| node.kind == "llm_call"));
1362 assert_eq!(report.llm_calls.len(), 1);
1363 let check = report
1364 .checks
1365 .iter()
1366 .find(|check| check.code == "timeline_truncated")
1367 .expect("explicit truncation check");
1368 assert_eq!(check.severity, "warning");
1369 assert!(check.message.contains("1024 of 1026 available"));
1370 assert!(check.message.contains("absence must not be inferred"));
1371
1372 fs::remove_dir_all(dir).unwrap();
1373 }
1374
1375 #[tokio::test]
1376 async fn report_redacts_returned_projection_and_hash_is_reproducible() {
1377 let dir = temp_dir("run-report-redaction");
1378 let run_path = dir.join("run.json");
1379 let secret = "sk-proj-test-abcdefghijklmnopqrstuvwxyz123456";
1380 let run = RunRecord {
1381 type_name: "workflow_run".to_string(),
1382 id: "root".to_string(),
1383 workflow_id: "workflow".to_string(),
1384 task: format!("do not expose {secret}"),
1385 status: "completed".to_string(),
1386 transcript: Some(serde_json::json!({
1387 "events": [{
1388 "kind": "message",
1389 "role": "assistant",
1390 "visibility": "public",
1391 "blocks": [
1392 {"type": "output_text", "text": format!("safe answer {secret}"), "visibility": "public"},
1393 {"type": "reasoning", "text": "private chain of thought", "visibility": "private"}
1394 ]
1395 }]
1396 })),
1397 ..RunRecord::default()
1398 };
1399 fs::write(&run_path, serde_json::to_vec(&run).unwrap()).unwrap();
1400
1401 let report = build_run_report(RunReportRequest {
1402 run_record_path: run_path,
1403 allowed_roots: vec![dir.clone()],
1404 source_root: Some(dir.clone()),
1405 ..RunReportRequest::default()
1406 })
1407 .await
1408 .unwrap();
1409 let rendered = serde_json::to_string(&report).unwrap();
1410 assert!(!rendered.contains(secret));
1411 let visible = report.agents[0]
1412 .visible_output
1413 .as_deref()
1414 .expect("public transcript output");
1415 assert!(visible.starts_with("safe answer "));
1416 assert!(visible.contains("<redacted:openai_key:"));
1417 assert!(!rendered.contains("private chain of thought"));
1418
1419 let expected_hash = report.projection.hash.clone();
1420 let mut value = serde_json::to_value(&report).unwrap();
1421 value["projection"]["hash"] = Value::String(String::new());
1422 let actual_hash = format!(
1423 "sha256:{}",
1424 hex::encode(Sha256::digest(crate::canonical_json::to_vec(&value)))
1425 );
1426 assert_eq!(actual_hash, expected_hash);
1427
1428 fs::remove_dir_all(dir).unwrap();
1429 }
1430
1431 #[cfg(unix)]
1432 #[tokio::test]
1433 async fn snapshot_report_does_not_follow_implicit_sidecar_symlinks() {
1434 let dir = temp_dir("run-report-sidecar-symlink");
1435 let outside = temp_dir("run-report-sidecar-outside");
1436 let run_path = dir.join("root.json");
1437 let run = RunRecord {
1438 type_name: "workflow_run".to_string(),
1439 id: "root".to_string(),
1440 workflow_id: "workflow".to_string(),
1441 status: "completed".to_string(),
1442 ..RunRecord::default()
1443 };
1444 fs::write(&run_path, serde_json::to_vec(&run).unwrap()).unwrap();
1445 fs::write(
1446 outside.join("llm_transcript.jsonl"),
1447 "{\"type\":\"daemon_event\",\"secret\":\"outside\"}\n",
1448 )
1449 .unwrap();
1450 std::os::unix::fs::symlink(&outside, dir.join("root-llm")).unwrap();
1451
1452 let report = build_run_report(RunReportRequest {
1453 run_record_path: run_path,
1454 allowed_roots: vec![dir.clone()],
1455 source_root: Some(dir.clone()),
1456 ..RunReportRequest::default()
1457 })
1458 .await
1459 .unwrap();
1460
1461 assert_eq!(report.sources.len(), 1);
1462 assert_eq!(report.sources[0].kind, "run_record");
1463 fs::remove_dir_all(dir).unwrap();
1464 fs::remove_dir_all(outside).unwrap();
1465 }
1466}