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