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 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    /// Terminal children without a canonical join receipt. `None` when the
154    /// selected event evidence is missing, malformed, or truncated.
155    pub unjoined: Option<usize>,
156    pub max_concurrent_children: Option<usize>,
157    /// Maximum observed parent wait, from the moment the parent began waiting
158    /// to the moment it collected the child (#6074). `None` when join evidence
159    /// is incomplete, or when no child was ever waited on — which is a real
160    /// state, not a gap, and is why this is not zero.
161    pub observed_wait_ms: Option<u64>,
162    /// Maximum observed child-terminal-to-parent-collection lag. `None` when
163    /// join evidence is incomplete or no valid lag was observed.
164    pub observed_join_ms: Option<u64>,
165    /// Maximum observed result-collapsing duration. `None` when join evidence
166    /// is incomplete, or when collection happened without collapsing a result.
167    ///
168    /// Kept apart from `observed_wait_ms` and `observed_join_ms` because these
169    /// are three different costs — scheduler wait, collection lag, and the
170    /// parent's own work — and one number covering all three cannot say which
171    /// one a slow run is paying.
172    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
242/// Validate that a report is the supported typed projection and that its
243/// content still matches the producer-owned projection hash.
244pub 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
261/// Recompute the logical report hash using the same canonical contract as the
262/// report producer. The hash field itself is cleared before canonicalization.
263pub 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        // Only claim an interval is unavailable when it actually is. Since
622        // #6074 the receipts carry wait and result-processing boundaries, so
623        // this check names the ones still missing rather than asserting a
624        // blanket gap that has been closed.
625        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    // Millisecond/second persistence can collapse a short run to a zero-length
920    // interval. Apply starts before finishes at the same timestamp so an
921    // observed child still contributes to the peak instead of disappearing.
922    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}