Skip to main content

harn_vm/
session_bundle.rs

1//! Canonical session bundle export/import support.
2//!
3//! A session bundle is the portable JSON envelope that downstream hosts use
4//! for local exports, sanitized shares, and replay-only handoffs. The bundle
5//! is intentionally built from existing Harn run-record surfaces so replay,
6//! transcripts, tool calls, HITL questions, and observability do not fork into
7//! a second persistence model.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt;
11use std::fs;
12use std::path::Path;
13
14use chrono::{DateTime, SecondsFormat, Utc};
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value as JsonValue};
17
18use crate::agent_events::AgentEvent;
19use crate::event_log::sanitize_topic_component;
20use crate::orchestration::{
21    derive_run_observability, new_id, now_unix_seconds_text, AgentSessionReplayEvent,
22    ReplayFixture, RunCheckpointRecord, RunChildRecord, RunExecutionRecord, RunHitlQuestionRecord,
23    RunObservabilityRecord, RunRecord, RunTraceSpanRecord, RunTransitionRecord,
24    RunVerificationOutcomeRecord, RunWorkerLineageRecord, ToolCallRecord,
25};
26use crate::redact::{json_path_child, RedactionEntry, RedactionPolicy, REDACTED_PLACEHOLDER};
27use crate::workspace_anchor::{anchor_from_transcript_metadata_json, MountedRoot, WorkspaceAnchor};
28
29mod permissions;
30mod schema;
31pub use schema::{session_bundle_schema, session_bundle_schema_pretty};
32
33#[cfg(test)]
34mod tests;
35
36pub const SESSION_BUNDLE_TYPE: &str = "harn_session_bundle";
37pub const SESSION_BUNDLE_SCHEMA_VERSION: u32 = 1;
38pub const SESSION_BUNDLE_SCHEMA_ID: &str = "https://harnlang.com/schemas/session-bundle.v1.json";
39pub const REPLAY_ONLY_PLACEHOLDER: &str = "[withheld]";
40
41/// Bundle `source.status` for a session reconstructed from an event stream that
42/// has no terminal `SessionClosed` event — a live loop frozen mid-turn for
43/// cross-compute migration (#3682), or a time-traveled prefix truncated before
44/// the close. The next action is still pending, so a consumer must CONTINUE the
45/// loop, not treat it as finished.
46pub const SESSION_BUNDLE_STATUS_SUSPENDED: &str = "suspended";
47
48/// Bundle `source.status` fallback for a session that closed without an
49/// explicit status on its terminal `SessionClosed` event.
50pub const SESSION_BUNDLE_STATUS_COMPLETED: &str = "completed";
51
52/// `metadata` key carrying the machine-readable liveness verdict
53/// (`"suspended"` | `"closed"`) so a resume host can decide continue-vs-replay
54/// without string-matching the human-facing `status`.
55pub const SESSION_BUNDLE_LIVENESS_KEY: &str = "session_liveness";
56
57/// Liveness of an agent session reconstructed from its event log.
58///
59/// Derived purely from the event stream: a session is [`Closed`] only when the
60/// stream contains a terminal `SessionClosed` event. Any other stream is
61/// [`Suspended`] — its next action is still pending.
62///
63/// [`Closed`]: AgentSessionLiveness::Closed
64/// [`Suspended`]: AgentSessionLiveness::Suspended
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub enum AgentSessionLiveness {
67    /// The stream ends in a `SessionClosed` event. `status` is that event's
68    /// status, or [`SESSION_BUNDLE_STATUS_COMPLETED`] when it was empty.
69    Closed { status: String, finished_at_ms: i64 },
70    /// No terminal `SessionClosed` event: the loop is frozen mid-flight and a
71    /// consumer must resume it rather than replay it as a finished run.
72    Suspended,
73}
74
75impl AgentSessionLiveness {
76    /// The human-facing `source.status` string for this liveness.
77    pub fn status(&self) -> &str {
78        match self {
79            AgentSessionLiveness::Closed { status, .. } => status,
80            AgentSessionLiveness::Suspended => SESSION_BUNDLE_STATUS_SUSPENDED,
81        }
82    }
83
84    /// The machine-readable [`SESSION_BUNDLE_LIVENESS_KEY`] tag.
85    pub fn tag(&self) -> &'static str {
86        match self {
87            AgentSessionLiveness::Closed { .. } => "closed",
88            AgentSessionLiveness::Suspended => "suspended",
89        }
90    }
91
92    /// Whether a consumer must CONTINUE the loop (vs. treat it as finished).
93    pub fn is_suspended(&self) -> bool {
94        matches!(self, AgentSessionLiveness::Suspended)
95    }
96}
97
98/// Classify an agent session's liveness from its replay event stream by finding
99/// the last terminal `SessionClosed` event. The keystone discriminator behind
100/// portable suspend/resume (#3682): without it, a frozen-mid-flight or
101/// time-traveled session is silently mislabeled `completed` and a resume host
102/// replays it instead of continuing the pending turn.
103pub fn agent_session_liveness(events: &[AgentSessionReplayEvent]) -> AgentSessionLiveness {
104    events
105        .iter()
106        .rev()
107        .find_map(|entry| match &entry.event {
108            AgentEvent::SessionClosed { status, .. } => Some(AgentSessionLiveness::Closed {
109                status: if status.is_empty() {
110                    SESSION_BUNDLE_STATUS_COMPLETED.to_string()
111                } else {
112                    status.clone()
113                },
114                finished_at_ms: entry.occurred_at_ms,
115            }),
116            _ => None,
117        })
118        .unwrap_or(AgentSessionLiveness::Suspended)
119}
120
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum SessionBundleExportMode {
123    Local,
124    Sanitized,
125    ReplayOnly,
126}
127
128impl SessionBundleExportMode {
129    pub fn as_str(self) -> &'static str {
130        match self {
131            Self::Local => "local",
132            Self::Sanitized => "sanitized",
133            Self::ReplayOnly => "replay_only",
134        }
135    }
136}
137
138#[derive(Clone, Debug)]
139pub struct SessionBundleExportOptions {
140    pub mode: SessionBundleExportMode,
141    pub include_attachments: bool,
142    pub redaction_policy: RedactionPolicy,
143}
144
145impl Default for SessionBundleExportOptions {
146    fn default() -> Self {
147        Self {
148            mode: SessionBundleExportMode::Sanitized,
149            include_attachments: false,
150            redaction_policy: RedactionPolicy::default(),
151        }
152    }
153}
154
155#[derive(Clone, Debug, Default)]
156pub struct SessionBundleValidationOptions {
157    pub allow_unsafe_secret_markers: bool,
158    pub redaction_policy: RedactionPolicy,
159}
160
161#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
162#[serde(default)]
163pub struct SessionBundle {
164    #[serde(rename = "_type")]
165    pub type_name: String,
166    pub schema_version: u32,
167    pub bundle_id: String,
168    pub created_at: String,
169    pub producer: BundleProducer,
170    pub source: BundleSource,
171    pub runtime: BundleRuntime,
172    pub workspace: Option<BundleWorkspace>,
173    pub transcript: BundleTranscript,
174    pub tools: BundleTools,
175    pub permissions: Vec<BundlePermission>,
176    pub replay: BundleReplay,
177    pub redaction: RedactionManifest,
178    pub attachments: Vec<BundleAttachment>,
179    pub metadata: BTreeMap<String, JsonValue>,
180}
181
182impl Default for SessionBundle {
183    fn default() -> Self {
184        Self {
185            type_name: SESSION_BUNDLE_TYPE.to_string(),
186            schema_version: SESSION_BUNDLE_SCHEMA_VERSION,
187            bundle_id: String::new(),
188            created_at: String::new(),
189            producer: BundleProducer::default(),
190            source: BundleSource::default(),
191            runtime: BundleRuntime::default(),
192            workspace: None,
193            transcript: BundleTranscript::default(),
194            tools: BundleTools::default(),
195            permissions: Vec::new(),
196            replay: BundleReplay::default(),
197            redaction: RedactionManifest::default(),
198            attachments: Vec::new(),
199            metadata: BTreeMap::new(),
200        }
201    }
202}
203
204#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
205#[serde(default)]
206pub struct BundleProducer {
207    pub name: String,
208    pub version: String,
209    pub schema_id: String,
210}
211
212#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
213#[serde(default)]
214pub struct BundleSource {
215    pub kind: String,
216    pub run_record_id: String,
217    pub workflow_id: String,
218    pub workflow_name: Option<String>,
219    pub task: String,
220    pub status: String,
221    pub started_at: String,
222    pub finished_at: Option<String>,
223    pub persisted_path: Option<String>,
224    pub root_run_id: Option<String>,
225    pub parent_run_id: Option<String>,
226    pub child_run_count: usize,
227}
228
229#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
230#[serde(default)]
231pub struct BundleRuntime {
232    pub harn_version: String,
233    pub provider_models: Vec<String>,
234    pub usage: Option<BundleUsage>,
235    pub metadata: BTreeMap<String, JsonValue>,
236}
237
238#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
239#[serde(default)]
240pub struct BundleUsage {
241    pub input_tokens: i64,
242    pub output_tokens: i64,
243    pub call_count: i64,
244    pub total_duration_ms: i64,
245    pub total_cost: f64,
246    pub models: Vec<String>,
247}
248
249#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
250#[serde(default)]
251pub struct BundleWorkspace {
252    /// Primary workspace path the session is anchored against.
253    pub primary: Option<String>,
254    /// Additional roots mounted alongside the primary anchor.
255    #[serde(default)]
256    pub additional_roots: Vec<BundleMountedRoot>,
257    /// RFC3339 timestamp when the anchor was set.
258    pub anchored_at: Option<String>,
259    /// Bundle workspace policy label. Currently always
260    /// `"safe_identity_only"`; future revisions may tie this to mount
261    /// modes once the PathScope matcher (#2216) lands.
262    pub policy: String,
263}
264
265#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
266#[serde(default)]
267pub struct BundleMountedRoot {
268    pub path: String,
269    pub mount_mode: String,
270    pub mounted_at: String,
271}
272
273impl From<&MountedRoot> for BundleMountedRoot {
274    fn from(root: &MountedRoot) -> Self {
275        Self {
276            path: root.path.to_string_lossy().into_owned(),
277            mount_mode: root.mount_mode.as_str().to_string(),
278            mounted_at: root.mounted_at.clone(),
279        }
280    }
281}
282
283impl From<&WorkspaceAnchor> for BundleWorkspace {
284    fn from(anchor: &WorkspaceAnchor) -> Self {
285        Self {
286            primary: Some(anchor.primary.to_string_lossy().into_owned()),
287            additional_roots: anchor.additional_roots.iter().map(Into::into).collect(),
288            anchored_at: Some(anchor.anchored_at.clone()),
289            policy: "safe_identity_only".to_string(),
290        }
291    }
292}
293
294#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
295#[serde(default)]
296pub struct BundleTranscript {
297    pub sections: Vec<BundleTranscriptSection>,
298}
299
300#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
301#[serde(default)]
302pub struct BundleTranscriptSection {
303    pub id: String,
304    pub label: String,
305    pub scope: String,
306    pub location: String,
307    pub summary: Option<String>,
308    pub messages: Vec<JsonValue>,
309    pub events: Vec<JsonValue>,
310    pub assets: Vec<JsonValue>,
311    pub metadata: BTreeMap<String, JsonValue>,
312}
313
314#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
315#[serde(default)]
316pub struct BundleTools {
317    pub schemas: Vec<BundleJsonEntry>,
318    pub calls: Vec<BundleToolCall>,
319}
320
321#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
322#[serde(default)]
323pub struct BundleJsonEntry {
324    pub source: String,
325    pub index: usize,
326    pub value: JsonValue,
327}
328
329#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
330#[serde(default)]
331pub struct BundleToolCall {
332    pub tool_name: String,
333    pub tool_use_id: String,
334    pub args_hash: String,
335    pub result: String,
336    pub is_rejected: bool,
337    pub duration_ms: Option<u64>,
338    pub iteration: usize,
339    pub timestamp: String,
340}
341
342impl From<&ToolCallRecord> for BundleToolCall {
343    fn from(record: &ToolCallRecord) -> Self {
344        Self {
345            tool_name: record.tool_name.clone(),
346            tool_use_id: record.tool_use_id.clone(),
347            args_hash: record.args_hash.clone(),
348            result: record.result.clone(),
349            is_rejected: record.is_rejected,
350            duration_ms: record.duration_ms,
351            iteration: record.iteration,
352            timestamp: record.timestamp.clone(),
353        }
354    }
355}
356
357#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
358#[serde(default)]
359pub struct BundlePermission {
360    pub kind: String,
361    pub source: String,
362    pub request_id: Option<String>,
363    pub agent: Option<String>,
364    pub payload: JsonValue,
365}
366
367// Not `Eq`: embeds `RunTraceSpanRecord`, which carries a float `cost_usd`
368// and is therefore only `PartialEq`.
369#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
370#[serde(default)]
371pub struct BundleReplay {
372    pub replay_fixture: Option<ReplayFixture>,
373    pub run_record: Option<JsonValue>,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub observability: Option<RunObservabilityRecord>,
376    pub verification_outcomes: Vec<RunVerificationOutcomeRecord>,
377    #[serde(skip_serializing_if = "Vec::is_empty")]
378    pub worker_snapshots: Vec<BundleWorkerSnapshot>,
379    pub event_log_pointers: Vec<BundleEventLogPointer>,
380    pub transitions: Vec<RunTransitionRecord>,
381    pub checkpoints: Vec<RunCheckpointRecord>,
382    pub trace_spans: Vec<RunTraceSpanRecord>,
383    pub deterministic_events: Vec<BundleJsonEntry>,
384}
385
386#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
387#[serde(default)]
388pub struct BundleWorkerSnapshot {
389    pub worker_id: String,
390    pub worker_name: String,
391    pub status: String,
392    pub snapshot_ref: String,
393    pub source_path: Option<String>,
394    pub value: JsonValue,
395}
396
397#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
398#[serde(default)]
399pub struct MaterializedWorkerSnapshot {
400    pub worker_id: String,
401    pub path: String,
402}
403
404#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
405#[serde(default)]
406pub struct BundleEventLogPointer {
407    pub kind: String,
408    pub topic: Option<String>,
409    pub path: Option<String>,
410    pub location: String,
411    pub available: bool,
412}
413
414#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
415#[serde(default)]
416pub struct RedactionManifest {
417    pub mode: String,
418    pub policy: String,
419    pub placeholder: String,
420    pub entries: Vec<RedactionEntry>,
421    pub unsafe_secret_markers_rejected: bool,
422}
423
424#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
425#[serde(default)]
426pub struct BundleAttachment {
427    pub id: String,
428    pub kind: String,
429    pub title: Option<String>,
430    pub stage: Option<String>,
431    pub text: Option<String>,
432    pub data: Option<JsonValue>,
433    pub metadata: BTreeMap<String, JsonValue>,
434}
435
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub enum SessionBundleError {
438    Decode(String),
439    Encode(String),
440    MissingRequired(String),
441    UnsupportedSchemaVersion { found: u64, supported: u32 },
442    InvalidType { path: String, expected: String },
443    UnsupportedCheckpointState { status: String },
444    UnsafeSecretMarker { path: String, excerpt: String },
445    MissingRunRecord,
446    MissingSessionEvents { session_id: String },
447}
448
449impl fmt::Display for SessionBundleError {
450    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
451        match self {
452            Self::Decode(error) => write!(f, "failed to decode session bundle: {error}"),
453            Self::Encode(error) => write!(f, "failed to encode session bundle: {error}"),
454            Self::MissingRequired(path) => {
455                write!(f, "session bundle is missing required field {path}")
456            }
457            Self::UnsupportedSchemaVersion { found, supported } => write!(
458                f,
459                "unsupported session bundle schema_version {found}; this build supports <= {supported}"
460            ),
461            Self::InvalidType { path, expected } => {
462                write!(f, "session bundle field {path} must be {expected}")
463            }
464            Self::UnsupportedCheckpointState { status } => write!(
465                f,
466                "worker snapshot status {status:?} is not checkpointable; suspend the worker at a turn boundary first"
467            ),
468            Self::UnsafeSecretMarker { path, excerpt } => write!(
469                f,
470                "session bundle contains an unsafe unredacted secret marker at {path}: {excerpt}"
471            ),
472            Self::MissingRunRecord => write!(f, "session bundle does not include an importable run record"),
473            Self::MissingSessionEvents { session_id } => write!(
474                f,
475                "event log does not contain replayable events for session_id {session_id:?}"
476            ),
477        }
478    }
479}
480
481impl std::error::Error for SessionBundleError {}
482
483pub fn export_run_record_bundle(
484    run: &RunRecord,
485    options: &SessionBundleExportOptions,
486) -> Result<SessionBundle, SessionBundleError> {
487    let run_record_value =
488        serde_json::to_value(run).map_err(|error| SessionBundleError::Encode(error.to_string()))?;
489    let mut bundle = raw_bundle_from_run(run, run_record_value, options.include_attachments)?;
490    let mut bundle_value = serde_json::to_value(&bundle)
491        .map_err(|error| SessionBundleError::Encode(error.to_string()))?;
492
493    let mut manifest = RedactionManifest {
494        mode: options.mode.as_str().to_string(),
495        policy: if matches!(options.mode, SessionBundleExportMode::Local) {
496            "none".to_string()
497        } else {
498            "harn_vm::redact::RedactionPolicy::default+session_bundle_local_paths".to_string()
499        },
500        placeholder: REDACTED_PLACEHOLDER.to_string(),
501        entries: Vec::new(),
502        unsafe_secret_markers_rejected: !matches!(options.mode, SessionBundleExportMode::Local),
503    };
504
505    if !matches!(options.mode, SessionBundleExportMode::Local) {
506        let redaction_policy = bundle_redaction_policy(&options.redaction_policy);
507        manifest
508            .entries
509            .extend(redaction_policy.redact_json_manifest(&mut bundle_value));
510        redact_bundle_pointer_paths_json(&mut bundle_value, "$", &mut manifest.entries);
511    }
512    if matches!(options.mode, SessionBundleExportMode::ReplayOnly) {
513        withhold_replay_only_json(&mut bundle_value, "$", &mut manifest.entries);
514    }
515    set_json_path(
516        &mut bundle_value,
517        &["redaction"],
518        serde_json::to_value(&manifest)
519            .map_err(|error| SessionBundleError::Encode(error.to_string()))?,
520    );
521    bundle = serde_json::from_value(bundle_value)
522        .map_err(|error| SessionBundleError::Decode(error.to_string()))?;
523    Ok(bundle)
524}
525
526pub fn export_worker_snapshot_bundle(
527    snapshot_path: &Path,
528    options: &SessionBundleExportOptions,
529) -> Result<SessionBundle, SessionBundleError> {
530    let run = run_record_from_worker_snapshot(snapshot_path)?;
531    export_run_record_bundle(&run, options)
532}
533
534pub fn run_record_from_worker_snapshot(
535    snapshot_path: &Path,
536) -> Result<RunRecord, SessionBundleError> {
537    let content = fs::read_to_string(snapshot_path).map_err(|error| {
538        SessionBundleError::Decode(format!(
539            "failed to read worker snapshot {}: {error}",
540            snapshot_path.display()
541        ))
542    })?;
543    let value: JsonValue = serde_json::from_str(&content)
544        .map_err(|error| SessionBundleError::Decode(error.to_string()))?;
545    run_record_from_worker_snapshot_value(snapshot_path, value)
546}
547
548pub fn validate_session_bundle_value(
549    value: &JsonValue,
550    options: &SessionBundleValidationOptions,
551) -> Result<SessionBundle, SessionBundleError> {
552    require_field(value, "_type")?;
553    require_field(value, "schema_version")?;
554    require_field(value, "bundle_id")?;
555    require_field(value, "created_at")?;
556    require_field(value, "producer")?;
557    require_field(value, "source")?;
558    require_field(value, "runtime")?;
559    require_field(value, "transcript")?;
560    require_field(value, "tools")?;
561    require_field(value, "permissions")?;
562    require_field(value, "replay")?;
563    require_field(value, "redaction")?;
564    require_field(value, "attachments")?;
565    require_nested_field(value, &["producer", "name"])?;
566    require_nested_field(value, &["producer", "version"])?;
567    require_nested_field(value, &["producer", "schema_id"])?;
568    require_nested_field(value, &["source", "kind"])?;
569    require_nested_field(value, &["source", "run_record_id"])?;
570    require_nested_field(value, &["source", "workflow_id"])?;
571    require_nested_field(value, &["source", "task"])?;
572    require_nested_field(value, &["source", "status"])?;
573    require_nested_field(value, &["runtime", "harn_version"])?;
574    require_nested_field(value, &["runtime", "provider_models"])?;
575    require_nested_field(value, &["transcript", "sections"])?;
576    require_nested_field(value, &["tools", "schemas"])?;
577    require_nested_field(value, &["tools", "calls"])?;
578    require_nested_field(value, &["replay", "event_log_pointers"])?;
579    require_nested_field(value, &["replay", "transitions"])?;
580    require_nested_field(value, &["replay", "checkpoints"])?;
581    require_nested_field(value, &["replay", "trace_spans"])?;
582    require_nested_field(value, &["replay", "deterministic_events"])?;
583    require_nested_field(value, &["redaction", "mode"])?;
584    require_nested_field(value, &["redaction", "policy"])?;
585    require_nested_field(value, &["redaction", "placeholder"])?;
586    require_nested_field(value, &["redaction", "entries"])?;
587    require_nested_field(value, &["redaction", "unsafe_secret_markers_rejected"])?;
588
589    let type_name = value
590        .get("_type")
591        .and_then(JsonValue::as_str)
592        .ok_or_else(|| SessionBundleError::InvalidType {
593            path: "$._type".to_string(),
594            expected: "string".to_string(),
595        })?;
596    if type_name != SESSION_BUNDLE_TYPE {
597        return Err(SessionBundleError::InvalidType {
598            path: "$._type".to_string(),
599            expected: format!("\"{SESSION_BUNDLE_TYPE}\""),
600        });
601    }
602
603    let version = value
604        .get("schema_version")
605        .and_then(JsonValue::as_u64)
606        .ok_or_else(|| SessionBundleError::InvalidType {
607            path: "$.schema_version".to_string(),
608            expected: "positive integer".to_string(),
609        })?;
610    if version == 0 || version > u64::from(SESSION_BUNDLE_SCHEMA_VERSION) {
611        return Err(SessionBundleError::UnsupportedSchemaVersion {
612            found: version,
613            supported: SESSION_BUNDLE_SCHEMA_VERSION,
614        });
615    }
616
617    if !options.allow_unsafe_secret_markers {
618        if let Some(found) = options.redaction_policy.find_unredacted_secret(value) {
619            return Err(SessionBundleError::UnsafeSecretMarker {
620                path: found.path,
621                excerpt: found.excerpt,
622            });
623        }
624    }
625
626    serde_json::from_value::<SessionBundle>(value.clone())
627        .map_err(|error| SessionBundleError::Decode(error.to_string()))
628}
629
630pub fn validate_session_bundle_str(
631    content: &str,
632    options: &SessionBundleValidationOptions,
633) -> Result<SessionBundle, SessionBundleError> {
634    let value: JsonValue = serde_json::from_str(content)
635        .map_err(|error| SessionBundleError::Decode(error.to_string()))?;
636    validate_session_bundle_value(&value, options)
637}
638
639pub fn import_run_record_value(bundle: &SessionBundle) -> Result<JsonValue, SessionBundleError> {
640    let replay_observability = replay_observability_for_import(&bundle.replay);
641    if let Some(mut run_record) = bundle.replay.run_record.clone() {
642        let should_fill_observability = match run_record.get("observability") {
643            Some(value) => value.is_null(),
644            None => true,
645        };
646        if should_fill_observability {
647            if let (JsonValue::Object(map), Some(observability)) =
648                (&mut run_record, replay_observability.as_ref())
649            {
650                map.insert(
651                    "observability".to_string(),
652                    serde_json::to_value(observability)
653                        .map_err(|error| SessionBundleError::Encode(error.to_string()))?,
654                );
655            }
656        }
657        return Ok(run_record);
658    }
659    if let Some(fixture) = &bundle.replay.replay_fixture {
660        let transcript = bundle.transcript.sections.first().map(|section| {
661            json!({
662                "_type": "transcript",
663                "messages": section.messages.clone(),
664                "events": section.events.clone(),
665                "assets": section.assets.clone(),
666                "summary": section.summary.clone(),
667                "metadata": section.metadata.clone(),
668            })
669        });
670        let hitl_questions = bundle
671            .permissions
672            .iter()
673            .filter(|permission| permission.kind == "hitl_question")
674            .map(|permission| permission.payload.clone())
675            .collect::<Vec<_>>();
676        return Ok(json!({
677            "_type": "run_record",
678            "id": bundle.source.run_record_id.clone(),
679            "workflow_id": bundle.source.workflow_id.clone(),
680            "workflow_name": bundle.source.workflow_name.clone(),
681            "task": bundle.source.task.clone(),
682            "status": bundle.source.status.clone(),
683            "started_at": bundle.source.started_at.clone(),
684            "finished_at": bundle.source.finished_at.clone(),
685            "stages": [],
686            "transitions": bundle.replay.transitions.clone(),
687            "checkpoints": bundle.replay.checkpoints.clone(),
688            "pending_nodes": [],
689            "completed_nodes": [],
690            "child_runs": [],
691            "artifacts": [],
692            "handoffs": [],
693            "policy": {},
694            "transcript": transcript,
695            "usage": bundle.runtime.usage.clone(),
696            "replay_fixture": fixture,
697            "observability": replay_observability,
698            "trace_spans": bundle.replay.trace_spans.clone(),
699            "tool_recordings": bundle.tools.calls.clone(),
700            "hitl_questions": hitl_questions,
701            "persona_runtime": [],
702            "metadata": {
703                "imported_from_session_bundle": bundle.bundle_id.clone(),
704                "session_bundle_schema_version": bundle.schema_version,
705                "worker_snapshot_count": bundle.replay.worker_snapshots.len(),
706            }
707        }));
708    }
709    Err(SessionBundleError::MissingRunRecord)
710}
711
712pub fn import_run_record_value_with_materialized_worker_snapshots(
713    bundle: &SessionBundle,
714    materialized: &[MaterializedWorkerSnapshot],
715) -> Result<JsonValue, SessionBundleError> {
716    let mut run_record = import_run_record_value(bundle)?;
717    apply_materialized_worker_snapshot_paths(&mut run_record, materialized);
718    Ok(run_record)
719}
720
721fn run_record_from_worker_snapshot_value(
722    snapshot_path: &Path,
723    value: JsonValue,
724) -> Result<RunRecord, SessionBundleError> {
725    require_worker_snapshot_marker(&value)?;
726    let status =
727        snapshot_string(&value, "status").ok_or_else(|| missing_worker_snapshot_field("status"))?;
728    if status != "suspended" {
729        return Err(SessionBundleError::UnsupportedCheckpointState { status });
730    }
731    require_worker_snapshot_object_field(&value, "config")?;
732    require_worker_snapshot_object_field(&value, "suspension")?;
733
734    let snapshot_path_string = snapshot_path.to_string_lossy().into_owned();
735    let worker_id =
736        snapshot_string(&value, "id").ok_or_else(|| missing_worker_snapshot_field("id"))?;
737    let worker_name = snapshot_string(&value, "name").unwrap_or_else(|| "worker".to_string());
738    let task = snapshot_string(&value, "task").unwrap_or_else(|| "Suspended worker".to_string());
739    let suspended_at = snapshot_pointer_string(&value, &["suspension", "suspended_at"]);
740    let started_at = snapshot_string(&value, "started_at")
741        .or_else(|| snapshot_string(&value, "created_at"))
742        .or_else(|| suspended_at.clone())
743        .unwrap_or_else(now_unix_seconds_text);
744    let finished_at = snapshot_string(&value, "finished_at");
745    let session_id = snapshot_pointer_string(&value, &["config", "spec", "session_id"])
746        .or_else(|| snapshot_pointer_string(&value, &["audit", "session_id"]));
747    let parent_session_id =
748        snapshot_pointer_string(&value, &["config", "spec", "parent_session_id"])
749            .or_else(|| snapshot_pointer_string(&value, &["audit", "parent_session_id"]));
750    let child_run_id = snapshot_string(&value, "child_run_id");
751    let child_run_path = snapshot_string(&value, "child_run_path");
752    let execution = value
753        .get("execution")
754        .cloned()
755        .and_then(|value| serde_json::from_value::<RunExecutionRecord>(value).ok());
756
757    let child = RunChildRecord {
758        worker_id: worker_id.clone(),
759        worker_name: worker_name.clone(),
760        parent_stage_id: snapshot_string(&value, "parent_stage_id"),
761        session_id: session_id.clone(),
762        parent_session_id: parent_session_id.clone(),
763        mutation_scope: snapshot_pointer_string(&value, &["audit", "mutation_scope"]),
764        approval_policy: None,
765        task: task.clone(),
766        request: value.get("request").cloned(),
767        provenance: value.get("provenance").cloned(),
768        status: status.clone(),
769        started_at: started_at.clone(),
770        finished_at: finished_at.clone(),
771        run_id: child_run_id.clone(),
772        run_path: child_run_path.clone(),
773        snapshot_path: Some(snapshot_path_string.clone()),
774        execution,
775    };
776    let lineage = RunWorkerLineageRecord {
777        worker_id: worker_id.clone(),
778        worker_name,
779        parent_stage_id: child.parent_stage_id.clone(),
780        task: task.clone(),
781        status: status.clone(),
782        session_id,
783        parent_session_id,
784        run_id: child_run_id,
785        run_path: child_run_path,
786        snapshot_path: Some(snapshot_path_string.clone()),
787    };
788
789    let run_id = format!("checkpoint_{}", sanitize_topic_component(&worker_id));
790    let workflow_id = "worker_snapshot_checkpoint".to_string();
791    let workflow_name = Some("Worker snapshot checkpoint".to_string());
792    let checkpoint_id = format!("{run_id}_turn_boundary");
793    let checkpointed_at = suspended_at
794        .or_else(|| finished_at.clone())
795        .unwrap_or_else(|| started_at.clone());
796
797    Ok(RunRecord {
798        type_name: "run_record".to_string(),
799        id: run_id.clone(),
800        workflow_id: workflow_id.clone(),
801        workflow_name: workflow_name.clone(),
802        task,
803        status,
804        started_at,
805        finished_at,
806        checkpoints: vec![RunCheckpointRecord {
807            id: checkpoint_id,
808            ready_nodes: vec!["worker_snapshot_resume".to_string()],
809            completed_nodes: Vec::new(),
810            last_stage_id: None,
811            persisted_at: checkpointed_at.clone(),
812            reason: "suspended_worker_snapshot_turn_boundary".to_string(),
813        }],
814        child_runs: vec![child],
815        transcript: value.get("transcript").cloned(),
816        replay_fixture: Some(ReplayFixture {
817            type_name: "replay_fixture".to_string(),
818            id: format!("fixture_{run_id}"),
819            source_run_id: run_id,
820            workflow_id,
821            workflow_name,
822            created_at: checkpointed_at,
823            eval_kind: Some("worker_snapshot_checkpoint".to_string()),
824            expected_status: "suspended".to_string(),
825            ..ReplayFixture::default()
826        }),
827        observability: Some(RunObservabilityRecord {
828            schema_version: 4,
829            worker_lineage: vec![lineage],
830            ..RunObservabilityRecord::default()
831        }),
832        metadata: BTreeMap::from([
833            ("checkpoint_kind".to_string(), json!("worker_snapshot")),
834            (
835                "worker_snapshot_path".to_string(),
836                json!(snapshot_path_string),
837            ),
838        ]),
839        ..RunRecord::default()
840    })
841}
842
843fn snapshot_string(value: &JsonValue, key: &str) -> Option<String> {
844    value
845        .get(key)
846        .and_then(JsonValue::as_str)
847        .filter(|value| !value.is_empty())
848        .map(str::to_string)
849}
850
851fn missing_worker_snapshot_field(field: &str) -> SessionBundleError {
852    SessionBundleError::MissingRequired(format!("$.worker_snapshot.{field}"))
853}
854
855fn require_worker_snapshot_marker(value: &JsonValue) -> Result<(), SessionBundleError> {
856    match snapshot_string(value, "_type").as_deref() {
857        Some("worker_snapshot") => Ok(()),
858        Some(_) => Err(SessionBundleError::InvalidType {
859            path: "$.worker_snapshot._type".to_string(),
860            expected: "\"worker_snapshot\"".to_string(),
861        }),
862        None => Err(missing_worker_snapshot_field("_type")),
863    }
864}
865
866fn require_worker_snapshot_object_field(
867    value: &JsonValue,
868    field: &str,
869) -> Result<(), SessionBundleError> {
870    match value.get(field) {
871        Some(JsonValue::Object(_)) => Ok(()),
872        Some(_) => Err(SessionBundleError::InvalidType {
873            path: format!("$.worker_snapshot.{field}"),
874            expected: "object".to_string(),
875        }),
876        None => Err(missing_worker_snapshot_field(field)),
877    }
878}
879
880fn snapshot_pointer_string(value: &JsonValue, path: &[&str]) -> Option<String> {
881    let mut current = value;
882    for component in path {
883        current = current.get(*component)?;
884    }
885    current
886        .as_str()
887        .filter(|value| !value.is_empty())
888        .map(str::to_string)
889}
890
891pub fn materialize_worker_snapshots(
892    bundle: &SessionBundle,
893    out_dir: &Path,
894) -> Result<Vec<MaterializedWorkerSnapshot>, SessionBundleError> {
895    if bundle.replay.worker_snapshots.is_empty() {
896        return Ok(Vec::new());
897    }
898    fs::create_dir_all(out_dir).map_err(|error| {
899        SessionBundleError::Encode(format!(
900            "failed to create worker snapshot directory {}: {error}",
901            out_dir.display()
902        ))
903    })?;
904
905    let mut materialized = Vec::new();
906    for (index, snapshot) in bundle.replay.worker_snapshots.iter().enumerate() {
907        let worker_id = if snapshot.worker_id.trim().is_empty() {
908            format!("worker_{index}")
909        } else {
910            snapshot.worker_id.clone()
911        };
912        let path = out_dir.join(worker_snapshot_file_name(&worker_id, index));
913        let value = worker_snapshot_value_for_import(&snapshot.value, &path);
914        let rendered = serde_json::to_string_pretty(&value)
915            .map(|json| format!("{json}\n"))
916            .map_err(|error| SessionBundleError::Encode(error.to_string()))?;
917        fs::write(&path, rendered).map_err(|error| {
918            SessionBundleError::Encode(format!(
919                "failed to write worker snapshot {}: {error}",
920                path.display()
921            ))
922        })?;
923        materialized.push(MaterializedWorkerSnapshot {
924            worker_id,
925            path: path.to_string_lossy().into_owned(),
926        });
927    }
928    Ok(materialized)
929}
930
931fn apply_materialized_worker_snapshot_paths(
932    run_record: &mut JsonValue,
933    materialized: &[MaterializedWorkerSnapshot],
934) {
935    if materialized.is_empty() {
936        return;
937    }
938
939    let paths_by_worker_id = materialized
940        .iter()
941        .filter(|snapshot| !snapshot.worker_id.is_empty())
942        .map(|snapshot| (snapshot.worker_id.as_str(), snapshot.path.as_str()))
943        .collect::<BTreeMap<_, _>>();
944    if paths_by_worker_id.is_empty() {
945        return;
946    }
947
948    rewrite_worker_snapshot_paths(run_record.get_mut("child_runs"), &paths_by_worker_id);
949    rewrite_worker_snapshot_paths(
950        run_record
951            .get_mut("observability")
952            .and_then(|observability| observability.get_mut("worker_lineage")),
953        &paths_by_worker_id,
954    );
955    rewrite_checkpoint_metadata_snapshot_path(run_record, materialized);
956}
957
958fn rewrite_worker_snapshot_paths(
959    records: Option<&mut JsonValue>,
960    paths_by_worker_id: &BTreeMap<&str, &str>,
961) {
962    let Some(records) = records.and_then(JsonValue::as_array_mut) else {
963        return;
964    };
965    for record in records {
966        let Some(worker_id) = record.get("worker_id").and_then(JsonValue::as_str) else {
967            continue;
968        };
969        let Some(path) = paths_by_worker_id.get(worker_id) else {
970            continue;
971        };
972        if let JsonValue::Object(map) = record {
973            map.insert(
974                "snapshot_path".to_string(),
975                JsonValue::String((*path).to_string()),
976            );
977        }
978    }
979}
980
981fn rewrite_checkpoint_metadata_snapshot_path(
982    run_record: &mut JsonValue,
983    materialized: &[MaterializedWorkerSnapshot],
984) {
985    let Some(snapshot) = materialized.first() else {
986        return;
987    };
988    let Some(metadata) = run_record
989        .get_mut("metadata")
990        .and_then(JsonValue::as_object_mut)
991    else {
992        return;
993    };
994    if metadata.contains_key("worker_snapshot_path") {
995        metadata.insert(
996            "worker_snapshot_path".to_string(),
997            JsonValue::String(snapshot.path.clone()),
998        );
999    }
1000}
1001
1002fn replay_observability_for_import(replay: &BundleReplay) -> Option<RunObservabilityRecord> {
1003    let mut observability = replay.observability.clone().unwrap_or_default();
1004    let has_observability = replay.observability.is_some();
1005    let has_verification_outcomes = !replay.verification_outcomes.is_empty();
1006    if !has_observability && !has_verification_outcomes {
1007        return None;
1008    }
1009    if observability.schema_version == 0 {
1010        observability.schema_version = 4;
1011    }
1012    if observability.verification_outcomes.is_empty() && has_verification_outcomes {
1013        observability.verification_outcomes = replay.verification_outcomes.clone();
1014    }
1015    Some(observability)
1016}
1017
1018pub fn session_bundle_from_agent_session_events(
1019    session_id: &str,
1020    events: &[AgentSessionReplayEvent],
1021) -> Result<SessionBundle, SessionBundleError> {
1022    if events.is_empty() {
1023        return Err(SessionBundleError::MissingSessionEvents {
1024            session_id: session_id.to_string(),
1025        });
1026    }
1027
1028    let stable_id = sanitize_topic_component(session_id);
1029    let started_at = rfc3339_from_epoch_ms(events[0].occurred_at_ms);
1030    // Liveness, not a `completed` default: a stream without a terminal
1031    // `SessionClosed` event (a frozen-mid-flight loop, or a time-traveled
1032    // prefix) is `suspended`, so `finished_at` stays null and a resume host
1033    // continues the pending turn rather than replaying a "finished" run.
1034    let liveness = agent_session_liveness(events);
1035    let finished_at = match &liveness {
1036        AgentSessionLiveness::Closed { finished_at_ms, .. } => {
1037            Some(rfc3339_from_epoch_ms(*finished_at_ms))
1038        }
1039        AgentSessionLiveness::Suspended => None,
1040    };
1041    let status = liveness.status().to_string();
1042    let run_id = session_id.to_string();
1043    let workflow_id = "agent_session".to_string();
1044    let created_at = finished_at.clone().unwrap_or_else(|| started_at.clone());
1045    let transcript_events = transcript_events_from_agent_session(events)?;
1046    let transcript_messages = transcript_messages_from_agent_session(events);
1047    let mut transcript_metadata = BTreeMap::new();
1048    transcript_metadata.insert("session_id".to_string(), json!(session_id));
1049    transcript_metadata.insert(
1050        "source".to_string(),
1051        json!("events.sqlite observability.agent_events topic"),
1052    );
1053
1054    let replay_fixture = ReplayFixture {
1055        type_name: "replay_fixture".to_string(),
1056        id: format!("fixture_from_session_{stable_id}"),
1057        source_run_id: run_id.clone(),
1058        workflow_id: workflow_id.clone(),
1059        workflow_name: Some(format!("Agent session {session_id}")),
1060        created_at: created_at.clone(),
1061        eval_kind: Some("replay".to_string()),
1062        expected_status: status.clone(),
1063        ..ReplayFixture::default()
1064    };
1065
1066    Ok(SessionBundle {
1067        bundle_id: format!("bundle_from_session_{stable_id}"),
1068        created_at,
1069        producer: BundleProducer {
1070            name: "harn".to_string(),
1071            version: env!("CARGO_PKG_VERSION").to_string(),
1072            schema_id: SESSION_BUNDLE_SCHEMA_ID.to_string(),
1073        },
1074        source: BundleSource {
1075            kind: "event_log_session".to_string(),
1076            run_record_id: run_id,
1077            workflow_id,
1078            workflow_name: Some(format!("Agent session {session_id}")),
1079            task: task_from_agent_session(events)
1080                .unwrap_or_else(|| format!("Agent session {session_id}")),
1081            status,
1082            started_at,
1083            finished_at,
1084            ..BundleSource::default()
1085        },
1086        runtime: BundleRuntime {
1087            harn_version: env!("CARGO_PKG_VERSION").to_string(),
1088            ..BundleRuntime::default()
1089        },
1090        transcript: BundleTranscript {
1091            sections: vec![BundleTranscriptSection {
1092                id: "agent_events".to_string(),
1093                label: "Agent event log".to_string(),
1094                scope: "session".to_string(),
1095                location: format!(
1096                    "observability.agent_events.{}",
1097                    sanitize_topic_component(session_id)
1098                ),
1099                summary: None,
1100                messages: transcript_messages,
1101                events: transcript_events,
1102                assets: Vec::new(),
1103                metadata: transcript_metadata,
1104            }],
1105        },
1106        permissions: permissions_from_agent_session(events),
1107        replay: BundleReplay {
1108            replay_fixture: Some(replay_fixture),
1109            event_log_pointers: vec![BundleEventLogPointer {
1110                kind: "agent_events".to_string(),
1111                topic: Some(format!(
1112                    "observability.agent_events.{}",
1113                    sanitize_topic_component(session_id)
1114                )),
1115                path: None,
1116                location: "events.sqlite".to_string(),
1117                available: true,
1118            }],
1119            deterministic_events: deterministic_events_from_agent_session(events)?,
1120            ..BundleReplay::default()
1121        },
1122        metadata: BTreeMap::from([(
1123            SESSION_BUNDLE_LIVENESS_KEY.to_string(),
1124            json!(liveness.tag()),
1125        )]),
1126        ..SessionBundle::default()
1127    })
1128}
1129
1130pub fn import_run_record_from_agent_session_events(
1131    session_id: &str,
1132    events: &[AgentSessionReplayEvent],
1133) -> Result<RunRecord, SessionBundleError> {
1134    let bundle = session_bundle_from_agent_session_events(session_id, events)?;
1135    let run_record = import_run_record_value(&bundle)?;
1136    serde_json::from_value(run_record)
1137        .map_err(|error| SessionBundleError::Decode(error.to_string()))
1138}
1139
1140fn transcript_events_from_agent_session(
1141    events: &[AgentSessionReplayEvent],
1142) -> Result<Vec<JsonValue>, SessionBundleError> {
1143    events
1144        .iter()
1145        .map(|entry| {
1146            let event = serde_json::to_value(&entry.event)
1147                .map_err(|error| SessionBundleError::Encode(error.to_string()))?;
1148            Ok(json!({
1149                "event_id": entry.event_id,
1150                "kind": entry.kind,
1151                "occurred_at_ms": entry.occurred_at_ms,
1152                "event": event,
1153            }))
1154        })
1155        .collect()
1156}
1157
1158fn transcript_messages_from_agent_session(events: &[AgentSessionReplayEvent]) -> Vec<JsonValue> {
1159    events
1160        .iter()
1161        .filter_map(|entry| match &entry.event {
1162            AgentEvent::UserMessage { content, .. } => Some(json!({
1163                "role": "user",
1164                "content": content,
1165            })),
1166            AgentEvent::AgentMessageChunk { content, .. } if !content.is_empty() => Some(json!({
1167                "role": "assistant",
1168                "content": content,
1169            })),
1170            _ => None,
1171        })
1172        .collect()
1173}
1174
1175fn permissions_from_agent_session(events: &[AgentSessionReplayEvent]) -> Vec<BundlePermission> {
1176    let mut permissions = Vec::new();
1177    for entry in events {
1178        if let AgentEvent::HitlRequested {
1179            request_id,
1180            kind,
1181            payload,
1182            ..
1183        } = &entry.event
1184        {
1185            permissions.push(BundlePermission {
1186                kind: "hitl_question".to_string(),
1187                source: "agent_events".to_string(),
1188                request_id: Some(request_id.clone()),
1189                agent: None,
1190                payload: json!({
1191                    "kind": kind,
1192                    "payload": payload,
1193                    "event_id": entry.event_id,
1194                    "occurred_at_ms": entry.occurred_at_ms,
1195                }),
1196            });
1197        }
1198    }
1199    permissions
1200}
1201
1202fn deterministic_events_from_agent_session(
1203    events: &[AgentSessionReplayEvent],
1204) -> Result<Vec<BundleJsonEntry>, SessionBundleError> {
1205    transcript_events_from_agent_session(events).map(|entries| {
1206        entries
1207            .into_iter()
1208            .enumerate()
1209            .map(|(index, value)| BundleJsonEntry {
1210                source: "events.sqlite.agent_events".to_string(),
1211                index,
1212                value,
1213            })
1214            .collect()
1215    })
1216}
1217
1218fn task_from_agent_session(events: &[AgentSessionReplayEvent]) -> Option<String> {
1219    events.iter().find_map(|entry| match &entry.event {
1220        AgentEvent::UserMessage { content, .. } => user_message_text(content),
1221        _ => None,
1222    })
1223}
1224
1225fn user_message_text(content: &[JsonValue]) -> Option<String> {
1226    let parts = content
1227        .iter()
1228        .filter_map(|value| {
1229            value
1230                .get("text")
1231                .and_then(JsonValue::as_str)
1232                .or_else(|| value.as_str())
1233                .map(str::to_string)
1234        })
1235        .filter(|text| !text.trim().is_empty())
1236        .collect::<Vec<_>>();
1237    if parts.is_empty() {
1238        None
1239    } else {
1240        Some(parts.join("\n"))
1241    }
1242}
1243
1244fn rfc3339_from_epoch_ms(ms: i64) -> String {
1245    DateTime::<Utc>::from_timestamp_millis(ms)
1246        .unwrap_or_else(|| DateTime::<Utc>::from_timestamp(0, 0).expect("unix epoch is valid"))
1247        .to_rfc3339_opts(SecondsFormat::Millis, true)
1248}
1249
1250fn raw_bundle_from_run(
1251    run: &RunRecord,
1252    run_record_value: JsonValue,
1253    include_attachments: bool,
1254) -> Result<SessionBundle, SessionBundleError> {
1255    let mut bundle = SessionBundle {
1256        bundle_id: new_id("bundle"),
1257        created_at: now_unix_seconds_text(),
1258        producer: BundleProducer {
1259            name: "harn".to_string(),
1260            version: env!("CARGO_PKG_VERSION").to_string(),
1261            schema_id: SESSION_BUNDLE_SCHEMA_ID.to_string(),
1262        },
1263        source: BundleSource {
1264            kind: "run_record".to_string(),
1265            run_record_id: run.id.clone(),
1266            workflow_id: run.workflow_id.clone(),
1267            workflow_name: run.workflow_name.clone(),
1268            task: run.task.clone(),
1269            status: run.status.clone(),
1270            started_at: run.started_at.clone(),
1271            finished_at: run.finished_at.clone(),
1272            persisted_path: run.persisted_path.clone(),
1273            root_run_id: run.root_run_id.clone(),
1274            parent_run_id: run.parent_run_id.clone(),
1275            child_run_count: run.child_runs.len(),
1276        },
1277        runtime: BundleRuntime {
1278            harn_version: env!("CARGO_PKG_VERSION").to_string(),
1279            provider_models: run
1280                .usage
1281                .as_ref()
1282                .map(|usage| usage.models.clone())
1283                .unwrap_or_default(),
1284            usage: run.usage.as_ref().map(|usage| BundleUsage {
1285                input_tokens: usage.input_tokens,
1286                output_tokens: usage.output_tokens,
1287                call_count: usage.call_count,
1288                total_duration_ms: usage.total_duration_ms,
1289                total_cost: usage.total_cost,
1290                models: usage.models.clone(),
1291            }),
1292            metadata: BTreeMap::new(),
1293        },
1294        workspace: workspace_from_run(run),
1295        transcript: transcript_from_run(run),
1296        tools: BundleTools {
1297            schemas: tool_schema_entries(run),
1298            calls: run
1299                .tool_recordings
1300                .iter()
1301                .map(BundleToolCall::from)
1302                .collect(),
1303        },
1304        permissions: permissions_from_run(run),
1305        replay: BundleReplay {
1306            replay_fixture: run.replay_fixture.clone(),
1307            run_record: Some(run_record_value),
1308            observability: run.observability.clone(),
1309            verification_outcomes: verification_outcomes_for_run(run),
1310            worker_snapshots: worker_snapshots_from_run(run),
1311            event_log_pointers: event_log_pointers_from_run(run),
1312            transitions: run.transitions.clone(),
1313            checkpoints: run.checkpoints.clone(),
1314            trace_spans: run.evidence.trace_spans.clone(),
1315            deterministic_events: deterministic_events_from_run(run)?,
1316        },
1317        redaction: RedactionManifest {
1318            mode: "sanitized".to_string(),
1319            policy: "harn_vm::redact::RedactionPolicy::default+session_bundle_local_paths"
1320                .to_string(),
1321            placeholder: REDACTED_PLACEHOLDER.to_string(),
1322            entries: Vec::new(),
1323            unsafe_secret_markers_rejected: true,
1324        },
1325        attachments: if include_attachments {
1326            attachments_from_run(run)
1327        } else {
1328            Vec::new()
1329        },
1330        ..SessionBundle::default()
1331    };
1332    bundle.metadata.insert(
1333        "format_note".to_string(),
1334        json!("Session bundles are portable JSON envelopes; hosted share links should reference sanitized bundles rather than raw run records."),
1335    );
1336    Ok(bundle)
1337}
1338
1339fn verification_outcomes_for_run(run: &RunRecord) -> Vec<RunVerificationOutcomeRecord> {
1340    if let Some(observability) = run.observability.as_ref() {
1341        return observability.verification_outcomes.clone();
1342    }
1343    derive_run_observability(run, run.persisted_path.as_deref().map(Path::new))
1344        .verification_outcomes
1345}
1346
1347fn bundle_redaction_policy(base: &RedactionPolicy) -> RedactionPolicy {
1348    base.clone()
1349        .with_extra_field("persisted_path")
1350        .with_extra_field("primary")
1351        .with_extra_field("run_path")
1352        .with_extra_field("snapshot_ref")
1353        .with_extra_field("snapshot_path")
1354        .with_extra_field("source_path")
1355}
1356
1357fn workspace_from_run(run: &RunRecord) -> Option<BundleWorkspace> {
1358    let anchor = run
1359        .transcript
1360        .as_ref()
1361        .and_then(|transcript| transcript.get("metadata"))
1362        .and_then(anchor_from_transcript_metadata_json)?;
1363    Some(BundleWorkspace::from(&anchor))
1364}
1365
1366fn transcript_from_run(run: &RunRecord) -> BundleTranscript {
1367    let mut sections = Vec::new();
1368    if let Some(transcript) = &run.transcript {
1369        sections.push(transcript_section(
1370            "run",
1371            "Run transcript",
1372            "run",
1373            "$.transcript",
1374            transcript,
1375        ));
1376    }
1377    for (index, stage) in run.stages.iter().enumerate() {
1378        if let Some(transcript) = &stage.transcript {
1379            sections.push(transcript_section(
1380                &stage.id,
1381                &format!("Stage {}", stage.node_id),
1382                "stage",
1383                &format!("$.stages[{index}].transcript"),
1384                transcript,
1385            ));
1386        }
1387    }
1388    BundleTranscript { sections }
1389}
1390
1391fn transcript_section(
1392    id: &str,
1393    label: &str,
1394    scope: &str,
1395    location: &str,
1396    transcript: &JsonValue,
1397) -> BundleTranscriptSection {
1398    BundleTranscriptSection {
1399        id: id.to_string(),
1400        label: label.to_string(),
1401        scope: scope.to_string(),
1402        location: location.to_string(),
1403        summary: transcript
1404            .get("summary")
1405            .and_then(JsonValue::as_str)
1406            .map(str::to_string),
1407        messages: json_array(transcript.get("messages")),
1408        events: json_array(transcript.get("events")),
1409        assets: json_array(transcript.get("assets")),
1410        metadata: transcript
1411            .get("metadata")
1412            .and_then(JsonValue::as_object)
1413            .map(|map| {
1414                map.iter()
1415                    .map(|(key, value)| (key.clone(), value.clone()))
1416                    .collect()
1417            })
1418            .unwrap_or_default(),
1419    }
1420}
1421
1422fn json_array(value: Option<&JsonValue>) -> Vec<JsonValue> {
1423    value
1424        .and_then(JsonValue::as_array)
1425        .cloned()
1426        .unwrap_or_default()
1427}
1428
1429fn tool_schema_entries(run: &RunRecord) -> Vec<BundleJsonEntry> {
1430    let mut entries = Vec::new();
1431    collect_tool_schema_entries_from_transcript(&mut entries, "run.transcript", &run.transcript);
1432    for stage in &run.stages {
1433        collect_tool_schema_entries_from_transcript(
1434            &mut entries,
1435            &format!("stage.{}.transcript", stage.node_id),
1436            &stage.transcript,
1437        );
1438        if let Some(tools) = stage
1439            .metadata
1440            .get("tool_schemas")
1441            .or_else(|| stage.metadata.get("tools"))
1442        {
1443            entries.push(BundleJsonEntry {
1444                source: format!("stage.{}.metadata", stage.node_id),
1445                index: entries.len(),
1446                value: tools.clone(),
1447            });
1448        }
1449    }
1450    entries
1451}
1452
1453fn collect_tool_schema_entries_from_transcript(
1454    entries: &mut Vec<BundleJsonEntry>,
1455    source: &str,
1456    transcript: &Option<JsonValue>,
1457) {
1458    let Some(transcript) = transcript else {
1459        return;
1460    };
1461    for event in transcript
1462        .get("events")
1463        .and_then(JsonValue::as_array)
1464        .into_iter()
1465        .flatten()
1466    {
1467        let kind = event
1468            .get("type")
1469            .or_else(|| event.get("kind"))
1470            .and_then(JsonValue::as_str)
1471            .unwrap_or_default();
1472        if kind == "tool_schemas" || kind == "tool_schema" {
1473            entries.push(BundleJsonEntry {
1474                source: source.to_string(),
1475                index: entries.len(),
1476                value: event.clone(),
1477            });
1478        }
1479    }
1480}
1481
1482fn permissions_from_run(run: &RunRecord) -> Vec<BundlePermission> {
1483    let mut permissions = run
1484        .hitl_questions
1485        .iter()
1486        .map(permission_from_hitl_question)
1487        .collect::<Vec<_>>();
1488    permissions::collect_permission_events(&mut permissions, "run.transcript", &run.transcript);
1489    for stage in &run.stages {
1490        permissions::collect_permission_events(
1491            &mut permissions,
1492            &format!("stage.{}.transcript", stage.node_id),
1493            &stage.transcript,
1494        );
1495        if let Some(worker) = stage.metadata.get("worker") {
1496            if let Some(policy) = worker
1497                .get("audit")
1498                .and_then(|audit| audit.get("approval_policy"))
1499            {
1500                permissions.push(BundlePermission {
1501                    kind: "approval_policy".to_string(),
1502                    source: format!("stage.{}.worker.audit", stage.node_id),
1503                    request_id: None,
1504                    agent: worker
1505                        .get("name")
1506                        .and_then(JsonValue::as_str)
1507                        .map(str::to_string),
1508                    payload: policy.clone(),
1509                });
1510            }
1511        }
1512    }
1513    permissions
1514}
1515
1516fn permission_from_hitl_question(question: &RunHitlQuestionRecord) -> BundlePermission {
1517    BundlePermission {
1518        kind: "hitl_question".to_string(),
1519        source: "run.hitl_questions".to_string(),
1520        request_id: Some(question.request_id.clone()),
1521        agent: if question.agent.is_empty() {
1522            None
1523        } else {
1524            Some(question.agent.clone())
1525        },
1526        payload: serde_json::to_value(question).unwrap_or(JsonValue::Null),
1527    }
1528}
1529
1530fn event_log_pointers_from_run(run: &RunRecord) -> Vec<BundleEventLogPointer> {
1531    let mut pointers = Vec::new();
1532    if let Some(observability) = &run.observability {
1533        for pointer in &observability.transcript_pointers {
1534            pointers.push(BundleEventLogPointer {
1535                kind: pointer.kind.clone(),
1536                topic: None,
1537                path: pointer.path.clone(),
1538                location: pointer.location.clone(),
1539                available: pointer.available,
1540            });
1541        }
1542        for worker in &observability.worker_lineage {
1543            if let Some(session_id) = &worker.session_id {
1544                pointers.push(BundleEventLogPointer {
1545                    kind: "agent_events".to_string(),
1546                    topic: Some(format!("observability.agent_events.{session_id}")),
1547                    path: worker.snapshot_path.clone(),
1548                    location: format!("worker.{}.session", worker.worker_id),
1549                    available: worker.snapshot_path.is_some(),
1550                });
1551            }
1552        }
1553    }
1554    pointers
1555}
1556
1557fn worker_snapshots_from_run(run: &RunRecord) -> Vec<BundleWorkerSnapshot> {
1558    let mut snapshots = Vec::new();
1559    let mut seen_paths = BTreeSet::new();
1560    for child in &run.child_runs {
1561        let Some(path) = child.snapshot_path.as_deref() else {
1562            continue;
1563        };
1564        if !seen_paths.insert(path.to_string()) {
1565            continue;
1566        }
1567        if let Some(snapshot) = worker_snapshot_from_path(
1568            &child.worker_id,
1569            &child.worker_name,
1570            &child.status,
1571            Path::new(path),
1572        ) {
1573            snapshots.push(snapshot);
1574        }
1575    }
1576    if let Some(observability) = run.observability.as_ref() {
1577        for worker in &observability.worker_lineage {
1578            let Some(path) = worker.snapshot_path.as_deref() else {
1579                continue;
1580            };
1581            if !seen_paths.insert(path.to_string()) {
1582                continue;
1583            }
1584            if let Some(snapshot) = worker_snapshot_from_path(
1585                &worker.worker_id,
1586                &worker.worker_name,
1587                &worker.status,
1588                Path::new(path),
1589            ) {
1590                snapshots.push(snapshot);
1591            }
1592        }
1593    }
1594    snapshots
1595}
1596
1597fn worker_snapshot_from_path(
1598    worker_id: &str,
1599    worker_name: &str,
1600    status: &str,
1601    path: &Path,
1602) -> Option<BundleWorkerSnapshot> {
1603    let content = fs::read_to_string(path).ok()?;
1604    let value = serde_json::from_str::<JsonValue>(&content).ok()?;
1605    Some(BundleWorkerSnapshot {
1606        worker_id: if worker_id.is_empty() {
1607            value
1608                .get("id")
1609                .and_then(JsonValue::as_str)
1610                .unwrap_or_default()
1611                .to_string()
1612        } else {
1613            worker_id.to_string()
1614        },
1615        worker_name: if worker_name.is_empty() {
1616            value
1617                .get("name")
1618                .and_then(JsonValue::as_str)
1619                .unwrap_or("worker")
1620                .to_string()
1621        } else {
1622            worker_name.to_string()
1623        },
1624        status: if status.is_empty() {
1625            value
1626                .get("status")
1627                .and_then(JsonValue::as_str)
1628                .unwrap_or_default()
1629                .to_string()
1630        } else {
1631            status.to_string()
1632        },
1633        snapshot_ref: value
1634            .get("suspension")
1635            .and_then(|value| value.get("snapshot_ref"))
1636            .and_then(JsonValue::as_str)
1637            .or_else(|| value.get("snapshot_path").and_then(JsonValue::as_str))
1638            .unwrap_or_else(|| path.to_str().unwrap_or_default())
1639            .to_string(),
1640        source_path: Some(path.to_string_lossy().into_owned()),
1641        value,
1642    })
1643}
1644
1645fn worker_snapshot_file_name(worker_id: &str, index: usize) -> String {
1646    let component = sanitize_topic_component(worker_id);
1647    let component = if component.is_empty() {
1648        format!("worker_{index}")
1649    } else {
1650        component
1651    };
1652    format!("{component}.json")
1653}
1654
1655fn worker_snapshot_value_for_import(value: &JsonValue, path: &Path) -> JsonValue {
1656    let mut value = value.clone();
1657    let path = path.to_string_lossy().into_owned();
1658    if let JsonValue::Object(map) = &mut value {
1659        map.insert("snapshot_path".to_string(), JsonValue::String(path.clone()));
1660        if let Some(JsonValue::Object(suspension)) = map.get_mut("suspension") {
1661            suspension.insert("snapshot_ref".to_string(), JsonValue::String(path));
1662        }
1663    }
1664    value
1665}
1666
1667fn deterministic_events_from_run(
1668    run: &RunRecord,
1669) -> Result<Vec<BundleJsonEntry>, SessionBundleError> {
1670    let mut events = Vec::new();
1671    for (index, transition) in run.transitions.iter().enumerate() {
1672        events.push(BundleJsonEntry {
1673            source: "run.transitions".to_string(),
1674            index,
1675            value: serde_json::to_value(transition)
1676                .map_err(|error| SessionBundleError::Encode(error.to_string()))?,
1677        });
1678    }
1679    for (index, checkpoint) in run.checkpoints.iter().enumerate() {
1680        events.push(BundleJsonEntry {
1681            source: "run.checkpoints".to_string(),
1682            index,
1683            value: serde_json::to_value(checkpoint)
1684                .map_err(|error| SessionBundleError::Encode(error.to_string()))?,
1685        });
1686    }
1687    Ok(events)
1688}
1689
1690fn attachments_from_run(run: &RunRecord) -> Vec<BundleAttachment> {
1691    run.artifacts
1692        .iter()
1693        .map(|artifact| BundleAttachment {
1694            id: artifact.id.clone(),
1695            kind: artifact.kind.clone(),
1696            title: artifact.title.clone(),
1697            stage: artifact.stage.clone(),
1698            text: artifact.text.clone(),
1699            data: artifact.data.clone(),
1700            metadata: artifact.metadata.clone(),
1701        })
1702        .collect()
1703}
1704
1705fn redact_bundle_pointer_paths_json(
1706    value: &mut JsonValue,
1707    path: &str,
1708    entries: &mut Vec<RedactionEntry>,
1709) {
1710    match value {
1711        JsonValue::Object(map) => {
1712            let keys = map.keys().cloned().collect::<Vec<_>>();
1713            for key in keys {
1714                let child_path = json_path_child(path, &key);
1715                if key == "path" && bundle_pointer_path_should_redact(&child_path) {
1716                    if !map.get(&key).is_some_and(JsonValue::is_null) {
1717                        map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
1718                        entries.push(RedactionEntry {
1719                            path: child_path,
1720                            class: "local_pointer_path".to_string(),
1721                            action: "replaced".to_string(),
1722                            replacement: Some(REDACTED_PLACEHOLDER.to_string()),
1723                        });
1724                    }
1725                } else if let Some(child) = map.get_mut(&key) {
1726                    redact_bundle_pointer_paths_json(child, &child_path, entries);
1727                }
1728            }
1729        }
1730        JsonValue::Array(items) => {
1731            for (index, item) in items.iter_mut().enumerate() {
1732                redact_bundle_pointer_paths_json(item, &format!("{path}[{index}]"), entries);
1733            }
1734        }
1735        _ => {}
1736    }
1737}
1738
1739fn bundle_pointer_path_should_redact(path: &str) -> bool {
1740    path.contains(".event_log_pointers[") || path.contains(".transcript_pointers[")
1741}
1742
1743fn withhold_replay_only_json(value: &mut JsonValue, path: &str, entries: &mut Vec<RedactionEntry>) {
1744    match value {
1745        JsonValue::Object(map) => {
1746            let keys = map.keys().cloned().collect::<Vec<_>>();
1747            for key in keys {
1748                let child_path = json_path_child(path, &key);
1749                if replay_only_field_is_prompt_payload(&key) {
1750                    if !map.get(&key).is_some_and(JsonValue::is_null) {
1751                        map.insert(key, JsonValue::String(REPLAY_ONLY_PLACEHOLDER.to_string()));
1752                        entries.push(RedactionEntry {
1753                            path: child_path,
1754                            class: "prompt_or_tool_payload".to_string(),
1755                            action: "withheld".to_string(),
1756                            replacement: Some(REPLAY_ONLY_PLACEHOLDER.to_string()),
1757                        });
1758                    }
1759                } else if let Some(child) = map.get_mut(&key) {
1760                    withhold_replay_only_json(child, &child_path, entries);
1761                }
1762            }
1763        }
1764        JsonValue::Array(items) => {
1765            for (index, item) in items.iter_mut().enumerate() {
1766                withhold_replay_only_json(item, &format!("{path}[{index}]"), entries);
1767            }
1768        }
1769        _ => {}
1770    }
1771}
1772
1773fn replay_only_field_is_prompt_payload(key: &str) -> bool {
1774    matches!(
1775        key,
1776        "args"
1777            | "arguments"
1778            | "blocks"
1779            | "content"
1780            | "data"
1781            | "private_reasoning"
1782            | "prompt"
1783            | "raw_input"
1784            | "raw_output"
1785            | "result"
1786            | "response_text"
1787            | "summary"
1788            | "system"
1789            | "system_prompt"
1790            | "task"
1791            | "text"
1792            | "thinking"
1793            | "visible_text"
1794    )
1795}
1796
1797fn set_json_path(value: &mut JsonValue, path: &[&str], replacement: JsonValue) {
1798    let Some((head, tail)) = path.split_first() else {
1799        *value = replacement;
1800        return;
1801    };
1802    if tail.is_empty() {
1803        if let JsonValue::Object(map) = value {
1804            map.insert((*head).to_string(), replacement);
1805        }
1806        return;
1807    }
1808    if let JsonValue::Object(map) = value {
1809        if let Some(child) = map.get_mut(*head) {
1810            set_json_path(child, tail, replacement);
1811        }
1812    }
1813}
1814
1815fn require_field(value: &JsonValue, field: &str) -> Result<(), SessionBundleError> {
1816    if value.get(field).is_some() {
1817        Ok(())
1818    } else {
1819        Err(SessionBundleError::MissingRequired(format!("$.{field}")))
1820    }
1821}
1822
1823fn require_nested_field(value: &JsonValue, path: &[&str]) -> Result<(), SessionBundleError> {
1824    let mut current = value;
1825    for segment in path {
1826        current = current
1827            .get(*segment)
1828            .ok_or_else(|| SessionBundleError::MissingRequired(json_path_from_segments(path)))?;
1829    }
1830    Ok(())
1831}
1832
1833fn json_path_from_segments(path: &[&str]) -> String {
1834    path.iter().fold("$".to_string(), |parent, segment| {
1835        json_path_child(&parent, segment)
1836    })
1837}