Skip to main content

harn_vm/orchestration/records/
report.rs

1//! One typed, redacted report over a persisted run tree and its event timeline.
2
3use 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    /// Empty for a trusted local CLI call. Adapters that accept remote paths
37    /// must provide every root from which the report may read.
38    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    /// Terminal children without a canonical join receipt. `None` when the
169    /// selected event evidence is missing, malformed, or truncated.
170    pub unjoined: Option<usize>,
171    pub max_concurrent_children: Option<usize>,
172    /// Maximum observed parent wait, from the moment the parent began waiting
173    /// to the moment it collected the child (#6074). `None` when join evidence
174    /// is incomplete, or when no child was ever waited on — which is a real
175    /// state, not a gap, and is why this is not zero.
176    pub observed_wait_ms: Option<u64>,
177    /// Maximum observed child-terminal-to-parent-collection lag. `None` when
178    /// join evidence is incomplete or no valid lag was observed.
179    pub observed_join_ms: Option<u64>,
180    /// Maximum observed result-collapsing duration. `None` when join evidence
181    /// is incomplete, or when collection happened without collapsing a result.
182    ///
183    /// Kept apart from `observed_wait_ms` and `observed_join_ms` because these
184    /// are three different costs — scheduler wait, collection lag, and the
185    /// parent's own work — and one number covering all three cannot say which
186    /// one a slow run is paying.
187    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
257/// Validate that a report is the supported typed projection and that its
258/// content still matches the producer-owned projection hash.
259pub 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
276/// Recompute the logical report hash using the same canonical contract as the
277/// report producer. The hash field itself is cleared before canonicalization.
278pub 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        // Only claim an interval is unavailable when it actually is. Since
644        // #6074 the receipts carry wait and result-processing boundaries, so
645        // this check names the ones still missing rather than asserting a
646        // blanket gap that has been closed.
647        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    // Millisecond/second persistence can collapse a short run to a zero-length
964    // interval. Apply starts before finishes at the same timestamp so an
965    // observed child still contributes to the peak instead of disappearing.
966    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}