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::borrow::Cow;
10use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value as JsonValue};
15
16use crate::orchestration::{
17    new_id, now_rfc3339, ReplayFixture, RunCheckpointRecord, RunHitlQuestionRecord, RunRecord,
18    RunTraceSpanRecord, RunTransitionRecord, ToolCallRecord,
19};
20use crate::redact::{RedactionPolicy, REDACTED_PLACEHOLDER};
21
22mod schema;
23pub use schema::{session_bundle_schema, session_bundle_schema_pretty};
24
25#[cfg(test)]
26mod tests;
27
28pub const SESSION_BUNDLE_TYPE: &str = "harn_session_bundle";
29pub const SESSION_BUNDLE_SCHEMA_VERSION: u32 = 1;
30pub const SESSION_BUNDLE_SCHEMA_ID: &str = "https://harnlang.com/schemas/session-bundle.v1.json";
31pub const REPLAY_ONLY_PLACEHOLDER: &str = "[withheld]";
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum SessionBundleExportMode {
35    Local,
36    Sanitized,
37    ReplayOnly,
38}
39
40impl SessionBundleExportMode {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::Local => "local",
44            Self::Sanitized => "sanitized",
45            Self::ReplayOnly => "replay_only",
46        }
47    }
48}
49
50#[derive(Clone, Debug)]
51pub struct SessionBundleExportOptions {
52    pub mode: SessionBundleExportMode,
53    pub include_attachments: bool,
54    pub redaction_policy: RedactionPolicy,
55}
56
57impl Default for SessionBundleExportOptions {
58    fn default() -> Self {
59        Self {
60            mode: SessionBundleExportMode::Sanitized,
61            include_attachments: false,
62            redaction_policy: RedactionPolicy::default(),
63        }
64    }
65}
66
67#[derive(Clone, Debug, Default)]
68pub struct SessionBundleValidationOptions {
69    pub allow_unsafe_secret_markers: bool,
70    pub redaction_policy: RedactionPolicy,
71}
72
73#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
74#[serde(default)]
75pub struct SessionBundle {
76    #[serde(rename = "_type")]
77    pub type_name: String,
78    pub schema_version: u32,
79    pub bundle_id: String,
80    pub created_at: String,
81    pub producer: BundleProducer,
82    pub source: BundleSource,
83    pub runtime: BundleRuntime,
84    pub workspace: Option<BundleWorkspace>,
85    pub transcript: BundleTranscript,
86    pub tools: BundleTools,
87    pub permissions: Vec<BundlePermission>,
88    pub replay: BundleReplay,
89    pub redaction: RedactionManifest,
90    pub attachments: Vec<BundleAttachment>,
91    pub metadata: BTreeMap<String, JsonValue>,
92}
93
94impl Default for SessionBundle {
95    fn default() -> Self {
96        Self {
97            type_name: SESSION_BUNDLE_TYPE.to_string(),
98            schema_version: SESSION_BUNDLE_SCHEMA_VERSION,
99            bundle_id: String::new(),
100            created_at: String::new(),
101            producer: BundleProducer::default(),
102            source: BundleSource::default(),
103            runtime: BundleRuntime::default(),
104            workspace: None,
105            transcript: BundleTranscript::default(),
106            tools: BundleTools::default(),
107            permissions: Vec::new(),
108            replay: BundleReplay::default(),
109            redaction: RedactionManifest::default(),
110            attachments: Vec::new(),
111            metadata: BTreeMap::new(),
112        }
113    }
114}
115
116#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(default)]
118pub struct BundleProducer {
119    pub name: String,
120    pub version: String,
121    pub schema_id: String,
122}
123
124#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
125#[serde(default)]
126pub struct BundleSource {
127    pub kind: String,
128    pub run_record_id: String,
129    pub workflow_id: String,
130    pub workflow_name: Option<String>,
131    pub task: String,
132    pub status: String,
133    pub started_at: String,
134    pub finished_at: Option<String>,
135    pub persisted_path: Option<String>,
136    pub root_run_id: Option<String>,
137    pub parent_run_id: Option<String>,
138    pub child_run_count: usize,
139}
140
141#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
142#[serde(default)]
143pub struct BundleRuntime {
144    pub harn_version: String,
145    pub provider_models: Vec<String>,
146    pub usage: Option<BundleUsage>,
147    pub metadata: BTreeMap<String, JsonValue>,
148}
149
150#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
151#[serde(default)]
152pub struct BundleUsage {
153    pub input_tokens: i64,
154    pub output_tokens: i64,
155    pub call_count: i64,
156    pub total_duration_ms: i64,
157    pub total_cost: f64,
158    pub models: Vec<String>,
159}
160
161#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
162#[serde(default)]
163pub struct BundleWorkspace {
164    pub identity: Option<String>,
165    pub source_root: Option<String>,
166    pub policy: String,
167}
168
169#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
170#[serde(default)]
171pub struct BundleTranscript {
172    pub sections: Vec<BundleTranscriptSection>,
173}
174
175#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
176#[serde(default)]
177pub struct BundleTranscriptSection {
178    pub id: String,
179    pub label: String,
180    pub scope: String,
181    pub location: String,
182    pub summary: Option<String>,
183    pub messages: Vec<JsonValue>,
184    pub events: Vec<JsonValue>,
185    pub assets: Vec<JsonValue>,
186    pub metadata: BTreeMap<String, JsonValue>,
187}
188
189#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
190#[serde(default)]
191pub struct BundleTools {
192    pub schemas: Vec<BundleJsonEntry>,
193    pub calls: Vec<BundleToolCall>,
194}
195
196#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
197#[serde(default)]
198pub struct BundleJsonEntry {
199    pub source: String,
200    pub index: usize,
201    pub value: JsonValue,
202}
203
204#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
205#[serde(default)]
206pub struct BundleToolCall {
207    pub tool_name: String,
208    pub tool_use_id: String,
209    pub args_hash: String,
210    pub result: String,
211    pub is_rejected: bool,
212    pub duration_ms: u64,
213    pub iteration: usize,
214    pub timestamp: String,
215}
216
217impl From<&ToolCallRecord> for BundleToolCall {
218    fn from(record: &ToolCallRecord) -> Self {
219        Self {
220            tool_name: record.tool_name.clone(),
221            tool_use_id: record.tool_use_id.clone(),
222            args_hash: record.args_hash.clone(),
223            result: record.result.clone(),
224            is_rejected: record.is_rejected,
225            duration_ms: record.duration_ms,
226            iteration: record.iteration,
227            timestamp: record.timestamp.clone(),
228        }
229    }
230}
231
232#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
233#[serde(default)]
234pub struct BundlePermission {
235    pub kind: String,
236    pub source: String,
237    pub request_id: Option<String>,
238    pub agent: Option<String>,
239    pub payload: JsonValue,
240}
241
242#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
243#[serde(default)]
244pub struct BundleReplay {
245    pub replay_fixture: Option<ReplayFixture>,
246    pub run_record: Option<JsonValue>,
247    pub event_log_pointers: Vec<BundleEventLogPointer>,
248    pub transitions: Vec<RunTransitionRecord>,
249    pub checkpoints: Vec<RunCheckpointRecord>,
250    pub trace_spans: Vec<RunTraceSpanRecord>,
251    pub deterministic_events: Vec<BundleJsonEntry>,
252}
253
254#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
255#[serde(default)]
256pub struct BundleEventLogPointer {
257    pub kind: String,
258    pub topic: Option<String>,
259    pub path: Option<String>,
260    pub location: String,
261    pub available: bool,
262}
263
264#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
265#[serde(default)]
266pub struct RedactionManifest {
267    pub mode: String,
268    pub policy: String,
269    pub placeholder: String,
270    pub entries: Vec<RedactionEntry>,
271    pub unsafe_secret_markers_rejected: bool,
272}
273
274#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
275#[serde(default)]
276pub struct RedactionEntry {
277    pub path: String,
278    pub class: String,
279    pub action: String,
280    pub replacement: Option<String>,
281}
282
283#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
284#[serde(default)]
285pub struct BundleAttachment {
286    pub id: String,
287    pub kind: String,
288    pub title: Option<String>,
289    pub stage: Option<String>,
290    pub text: Option<String>,
291    pub data: Option<JsonValue>,
292    pub metadata: BTreeMap<String, JsonValue>,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub enum SessionBundleError {
297    Decode(String),
298    Encode(String),
299    MissingRequired(String),
300    UnsupportedSchemaVersion { found: u64, supported: u32 },
301    InvalidType { path: String, expected: String },
302    UnsafeSecretMarker { path: String, excerpt: String },
303    MissingRunRecord,
304}
305
306impl fmt::Display for SessionBundleError {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        match self {
309            Self::Decode(error) => write!(f, "failed to decode session bundle: {error}"),
310            Self::Encode(error) => write!(f, "failed to encode session bundle: {error}"),
311            Self::MissingRequired(path) => {
312                write!(f, "session bundle is missing required field {path}")
313            }
314            Self::UnsupportedSchemaVersion { found, supported } => write!(
315                f,
316                "unsupported session bundle schema_version {found}; this build supports <= {supported}"
317            ),
318            Self::InvalidType { path, expected } => {
319                write!(f, "session bundle field {path} must be {expected}")
320            }
321            Self::UnsafeSecretMarker { path, excerpt } => write!(
322                f,
323                "session bundle contains an unsafe unredacted secret marker at {path}: {excerpt}"
324            ),
325            Self::MissingRunRecord => write!(f, "session bundle does not include an importable run record"),
326        }
327    }
328}
329
330impl std::error::Error for SessionBundleError {}
331
332pub fn export_run_record_bundle(
333    run: &RunRecord,
334    options: &SessionBundleExportOptions,
335) -> Result<SessionBundle, SessionBundleError> {
336    let run_record_value =
337        serde_json::to_value(run).map_err(|error| SessionBundleError::Encode(error.to_string()))?;
338    let mut bundle = raw_bundle_from_run(run, run_record_value, options.include_attachments)?;
339    let mut bundle_value = serde_json::to_value(&bundle)
340        .map_err(|error| SessionBundleError::Encode(error.to_string()))?;
341
342    let mut manifest = RedactionManifest {
343        mode: options.mode.as_str().to_string(),
344        policy: if matches!(options.mode, SessionBundleExportMode::Local) {
345            "none".to_string()
346        } else {
347            "harn_vm::redact::RedactionPolicy::default+session_bundle_local_paths".to_string()
348        },
349        placeholder: REDACTED_PLACEHOLDER.to_string(),
350        entries: Vec::new(),
351        unsafe_secret_markers_rejected: !matches!(options.mode, SessionBundleExportMode::Local),
352    };
353
354    if !matches!(options.mode, SessionBundleExportMode::Local) {
355        let redaction_policy = bundle_redaction_policy(&options.redaction_policy);
356        redact_json_with_manifest(
357            &mut bundle_value,
358            "$",
359            &redaction_policy,
360            &mut manifest.entries,
361        );
362        redact_bundle_pointer_paths_json(&mut bundle_value, "$", &mut manifest.entries);
363    }
364    if matches!(options.mode, SessionBundleExportMode::ReplayOnly) {
365        withhold_replay_only_json(&mut bundle_value, "$", &mut manifest.entries);
366    }
367    set_json_path(
368        &mut bundle_value,
369        &["redaction"],
370        serde_json::to_value(&manifest)
371            .map_err(|error| SessionBundleError::Encode(error.to_string()))?,
372    );
373    bundle = serde_json::from_value(bundle_value)
374        .map_err(|error| SessionBundleError::Decode(error.to_string()))?;
375    Ok(bundle)
376}
377
378pub fn validate_session_bundle_value(
379    value: &JsonValue,
380    options: &SessionBundleValidationOptions,
381) -> Result<SessionBundle, SessionBundleError> {
382    require_field(value, "_type")?;
383    require_field(value, "schema_version")?;
384    require_field(value, "bundle_id")?;
385    require_field(value, "created_at")?;
386    require_field(value, "producer")?;
387    require_field(value, "source")?;
388    require_field(value, "runtime")?;
389    require_field(value, "transcript")?;
390    require_field(value, "tools")?;
391    require_field(value, "permissions")?;
392    require_field(value, "replay")?;
393    require_field(value, "redaction")?;
394    require_field(value, "attachments")?;
395    require_nested_field(value, &["producer", "name"])?;
396    require_nested_field(value, &["producer", "version"])?;
397    require_nested_field(value, &["producer", "schema_id"])?;
398    require_nested_field(value, &["source", "kind"])?;
399    require_nested_field(value, &["source", "run_record_id"])?;
400    require_nested_field(value, &["source", "workflow_id"])?;
401    require_nested_field(value, &["source", "task"])?;
402    require_nested_field(value, &["source", "status"])?;
403    require_nested_field(value, &["runtime", "harn_version"])?;
404    require_nested_field(value, &["runtime", "provider_models"])?;
405    require_nested_field(value, &["transcript", "sections"])?;
406    require_nested_field(value, &["tools", "schemas"])?;
407    require_nested_field(value, &["tools", "calls"])?;
408    require_nested_field(value, &["replay", "event_log_pointers"])?;
409    require_nested_field(value, &["replay", "transitions"])?;
410    require_nested_field(value, &["replay", "checkpoints"])?;
411    require_nested_field(value, &["replay", "trace_spans"])?;
412    require_nested_field(value, &["replay", "deterministic_events"])?;
413    require_nested_field(value, &["redaction", "mode"])?;
414    require_nested_field(value, &["redaction", "policy"])?;
415    require_nested_field(value, &["redaction", "placeholder"])?;
416    require_nested_field(value, &["redaction", "entries"])?;
417    require_nested_field(value, &["redaction", "unsafe_secret_markers_rejected"])?;
418
419    let type_name = value
420        .get("_type")
421        .and_then(JsonValue::as_str)
422        .ok_or_else(|| SessionBundleError::InvalidType {
423            path: "$._type".to_string(),
424            expected: "string".to_string(),
425        })?;
426    if type_name != SESSION_BUNDLE_TYPE {
427        return Err(SessionBundleError::InvalidType {
428            path: "$._type".to_string(),
429            expected: format!("\"{SESSION_BUNDLE_TYPE}\""),
430        });
431    }
432
433    let version = value
434        .get("schema_version")
435        .and_then(JsonValue::as_u64)
436        .ok_or_else(|| SessionBundleError::InvalidType {
437            path: "$.schema_version".to_string(),
438            expected: "positive integer".to_string(),
439        })?;
440    if version == 0 || version > u64::from(SESSION_BUNDLE_SCHEMA_VERSION) {
441        return Err(SessionBundleError::UnsupportedSchemaVersion {
442            found: version,
443            supported: SESSION_BUNDLE_SCHEMA_VERSION,
444        });
445    }
446
447    if !options.allow_unsafe_secret_markers {
448        reject_unredacted_secret_markers(value, "$", &options.redaction_policy)?;
449    }
450
451    serde_json::from_value::<SessionBundle>(value.clone())
452        .map_err(|error| SessionBundleError::Decode(error.to_string()))
453}
454
455pub fn validate_session_bundle_str(
456    content: &str,
457    options: &SessionBundleValidationOptions,
458) -> Result<SessionBundle, SessionBundleError> {
459    let value: JsonValue = serde_json::from_str(content)
460        .map_err(|error| SessionBundleError::Decode(error.to_string()))?;
461    validate_session_bundle_value(&value, options)
462}
463
464pub fn import_run_record_value(bundle: &SessionBundle) -> Result<JsonValue, SessionBundleError> {
465    if let Some(run_record) = bundle.replay.run_record.clone() {
466        return Ok(run_record);
467    }
468    if let Some(fixture) = &bundle.replay.replay_fixture {
469        let transcript = bundle.transcript.sections.first().map(|section| {
470            json!({
471                "_type": "transcript",
472                "messages": section.messages.clone(),
473                "events": section.events.clone(),
474                "assets": section.assets.clone(),
475                "summary": section.summary.clone(),
476                "metadata": section.metadata.clone(),
477            })
478        });
479        let hitl_questions = bundle
480            .permissions
481            .iter()
482            .filter(|permission| permission.kind == "hitl_question")
483            .map(|permission| permission.payload.clone())
484            .collect::<Vec<_>>();
485        return Ok(json!({
486            "_type": "run_record",
487            "id": bundle.source.run_record_id.clone(),
488            "workflow_id": bundle.source.workflow_id.clone(),
489            "workflow_name": bundle.source.workflow_name.clone(),
490            "task": bundle.source.task.clone(),
491            "status": bundle.source.status.clone(),
492            "started_at": bundle.source.started_at.clone(),
493            "finished_at": bundle.source.finished_at.clone(),
494            "stages": [],
495            "transitions": bundle.replay.transitions.clone(),
496            "checkpoints": bundle.replay.checkpoints.clone(),
497            "pending_nodes": [],
498            "completed_nodes": [],
499            "child_runs": [],
500            "artifacts": [],
501            "handoffs": [],
502            "policy": {},
503            "transcript": transcript,
504            "usage": bundle.runtime.usage.clone(),
505            "replay_fixture": fixture,
506            "trace_spans": bundle.replay.trace_spans.clone(),
507            "tool_recordings": bundle.tools.calls.clone(),
508            "hitl_questions": hitl_questions,
509            "persona_runtime": [],
510            "metadata": {
511                "imported_from_session_bundle": bundle.bundle_id.clone(),
512                "session_bundle_schema_version": bundle.schema_version,
513            }
514        }));
515    }
516    Err(SessionBundleError::MissingRunRecord)
517}
518
519fn raw_bundle_from_run(
520    run: &RunRecord,
521    run_record_value: JsonValue,
522    include_attachments: bool,
523) -> Result<SessionBundle, SessionBundleError> {
524    let mut bundle = SessionBundle {
525        bundle_id: new_id("bundle"),
526        created_at: now_rfc3339(),
527        producer: BundleProducer {
528            name: "harn".to_string(),
529            version: env!("CARGO_PKG_VERSION").to_string(),
530            schema_id: SESSION_BUNDLE_SCHEMA_ID.to_string(),
531        },
532        source: BundleSource {
533            kind: "run_record".to_string(),
534            run_record_id: run.id.clone(),
535            workflow_id: run.workflow_id.clone(),
536            workflow_name: run.workflow_name.clone(),
537            task: run.task.clone(),
538            status: run.status.clone(),
539            started_at: run.started_at.clone(),
540            finished_at: run.finished_at.clone(),
541            persisted_path: run.persisted_path.clone(),
542            root_run_id: run.root_run_id.clone(),
543            parent_run_id: run.parent_run_id.clone(),
544            child_run_count: run.child_runs.len(),
545        },
546        runtime: BundleRuntime {
547            harn_version: env!("CARGO_PKG_VERSION").to_string(),
548            provider_models: run
549                .usage
550                .as_ref()
551                .map(|usage| usage.models.clone())
552                .unwrap_or_default(),
553            usage: run.usage.as_ref().map(|usage| BundleUsage {
554                input_tokens: usage.input_tokens,
555                output_tokens: usage.output_tokens,
556                call_count: usage.call_count,
557                total_duration_ms: usage.total_duration_ms,
558                total_cost: usage.total_cost,
559                models: usage.models.clone(),
560            }),
561            metadata: BTreeMap::new(),
562        },
563        workspace: workspace_from_run(run),
564        transcript: transcript_from_run(run),
565        tools: BundleTools {
566            schemas: tool_schema_entries(run),
567            calls: run
568                .tool_recordings
569                .iter()
570                .map(BundleToolCall::from)
571                .collect(),
572        },
573        permissions: permissions_from_run(run),
574        replay: BundleReplay {
575            replay_fixture: run.replay_fixture.clone(),
576            run_record: Some(run_record_value),
577            event_log_pointers: event_log_pointers_from_run(run),
578            transitions: run.transitions.clone(),
579            checkpoints: run.checkpoints.clone(),
580            trace_spans: run.trace_spans.clone(),
581            deterministic_events: deterministic_events_from_run(run)?,
582        },
583        redaction: RedactionManifest {
584            mode: "sanitized".to_string(),
585            policy: "harn_vm::redact::RedactionPolicy::default+session_bundle_local_paths"
586                .to_string(),
587            placeholder: REDACTED_PLACEHOLDER.to_string(),
588            entries: Vec::new(),
589            unsafe_secret_markers_rejected: true,
590        },
591        attachments: if include_attachments {
592            attachments_from_run(run)
593        } else {
594            Vec::new()
595        },
596        ..SessionBundle::default()
597    };
598    bundle.metadata.insert(
599        "format_note".to_string(),
600        json!("Session bundles are portable JSON envelopes; hosted share links should reference sanitized bundles rather than raw run records."),
601    );
602    Ok(bundle)
603}
604
605fn bundle_redaction_policy(base: &RedactionPolicy) -> RedactionPolicy {
606    base.clone()
607        .with_extra_field("persisted_path")
608        .with_extra_field("project_root")
609        .with_extra_field("run_path")
610        .with_extra_field("snapshot_path")
611        .with_extra_field("source_root")
612        .with_extra_field("workspace_root")
613}
614
615fn workspace_from_run(run: &RunRecord) -> Option<BundleWorkspace> {
616    let source_root = run
617        .metadata
618        .get("project_root")
619        .and_then(JsonValue::as_str)
620        .or_else(|| {
621            run.metadata
622                .get("workspace_root")
623                .and_then(JsonValue::as_str)
624        })
625        .map(str::to_string);
626    let identity = run
627        .metadata
628        .get("workspace_id")
629        .and_then(JsonValue::as_str)
630        .map(str::to_string);
631    if source_root.is_none() && identity.is_none() {
632        return None;
633    }
634    Some(BundleWorkspace {
635        identity,
636        source_root,
637        policy: "safe_identity_only".to_string(),
638    })
639}
640
641fn transcript_from_run(run: &RunRecord) -> BundleTranscript {
642    let mut sections = Vec::new();
643    if let Some(transcript) = &run.transcript {
644        sections.push(transcript_section(
645            "run",
646            "Run transcript",
647            "run",
648            "$.transcript",
649            transcript,
650        ));
651    }
652    for (index, stage) in run.stages.iter().enumerate() {
653        if let Some(transcript) = &stage.transcript {
654            sections.push(transcript_section(
655                &stage.id,
656                &format!("Stage {}", stage.node_id),
657                "stage",
658                &format!("$.stages[{index}].transcript"),
659                transcript,
660            ));
661        }
662    }
663    BundleTranscript { sections }
664}
665
666fn transcript_section(
667    id: &str,
668    label: &str,
669    scope: &str,
670    location: &str,
671    transcript: &JsonValue,
672) -> BundleTranscriptSection {
673    BundleTranscriptSection {
674        id: id.to_string(),
675        label: label.to_string(),
676        scope: scope.to_string(),
677        location: location.to_string(),
678        summary: transcript
679            .get("summary")
680            .and_then(JsonValue::as_str)
681            .map(str::to_string),
682        messages: json_array(transcript.get("messages")),
683        events: json_array(transcript.get("events")),
684        assets: json_array(transcript.get("assets")),
685        metadata: transcript
686            .get("metadata")
687            .and_then(JsonValue::as_object)
688            .map(|map| {
689                map.iter()
690                    .map(|(key, value)| (key.clone(), value.clone()))
691                    .collect()
692            })
693            .unwrap_or_default(),
694    }
695}
696
697fn json_array(value: Option<&JsonValue>) -> Vec<JsonValue> {
698    value
699        .and_then(JsonValue::as_array)
700        .cloned()
701        .unwrap_or_default()
702}
703
704fn tool_schema_entries(run: &RunRecord) -> Vec<BundleJsonEntry> {
705    let mut entries = Vec::new();
706    collect_tool_schema_entries_from_transcript(&mut entries, "run.transcript", &run.transcript);
707    for stage in &run.stages {
708        collect_tool_schema_entries_from_transcript(
709            &mut entries,
710            &format!("stage.{}.transcript", stage.node_id),
711            &stage.transcript,
712        );
713        if let Some(tools) = stage
714            .metadata
715            .get("tool_schemas")
716            .or_else(|| stage.metadata.get("tools"))
717        {
718            entries.push(BundleJsonEntry {
719                source: format!("stage.{}.metadata", stage.node_id),
720                index: entries.len(),
721                value: tools.clone(),
722            });
723        }
724    }
725    entries
726}
727
728fn collect_tool_schema_entries_from_transcript(
729    entries: &mut Vec<BundleJsonEntry>,
730    source: &str,
731    transcript: &Option<JsonValue>,
732) {
733    let Some(transcript) = transcript else {
734        return;
735    };
736    for event in transcript
737        .get("events")
738        .and_then(JsonValue::as_array)
739        .into_iter()
740        .flatten()
741    {
742        let kind = event
743            .get("type")
744            .or_else(|| event.get("kind"))
745            .and_then(JsonValue::as_str)
746            .unwrap_or_default();
747        if kind == "tool_schemas" || kind == "tool_schema" {
748            entries.push(BundleJsonEntry {
749                source: source.to_string(),
750                index: entries.len(),
751                value: event.clone(),
752            });
753        }
754    }
755}
756
757fn permissions_from_run(run: &RunRecord) -> Vec<BundlePermission> {
758    let mut permissions = run
759        .hitl_questions
760        .iter()
761        .map(permission_from_hitl_question)
762        .collect::<Vec<_>>();
763    collect_permission_events(&mut permissions, "run.transcript", &run.transcript);
764    for stage in &run.stages {
765        collect_permission_events(
766            &mut permissions,
767            &format!("stage.{}.transcript", stage.node_id),
768            &stage.transcript,
769        );
770        if let Some(worker) = stage.metadata.get("worker") {
771            if let Some(policy) = worker
772                .get("audit")
773                .and_then(|audit| audit.get("approval_policy"))
774            {
775                permissions.push(BundlePermission {
776                    kind: "approval_policy".to_string(),
777                    source: format!("stage.{}.worker.audit", stage.node_id),
778                    request_id: None,
779                    agent: worker
780                        .get("name")
781                        .and_then(JsonValue::as_str)
782                        .map(str::to_string),
783                    payload: policy.clone(),
784                });
785            }
786        }
787    }
788    permissions
789}
790
791fn permission_from_hitl_question(question: &RunHitlQuestionRecord) -> BundlePermission {
792    BundlePermission {
793        kind: "hitl_question".to_string(),
794        source: "run.hitl_questions".to_string(),
795        request_id: Some(question.request_id.clone()),
796        agent: if question.agent.is_empty() {
797            None
798        } else {
799            Some(question.agent.clone())
800        },
801        payload: serde_json::to_value(question).unwrap_or(JsonValue::Null),
802    }
803}
804
805fn collect_permission_events(
806    permissions: &mut Vec<BundlePermission>,
807    source: &str,
808    transcript: &Option<JsonValue>,
809) {
810    let Some(transcript) = transcript else {
811        return;
812    };
813    for event in transcript
814        .get("events")
815        .and_then(JsonValue::as_array)
816        .into_iter()
817        .flatten()
818    {
819        let kind = event
820            .get("type")
821            .or_else(|| event.get("kind"))
822            .and_then(JsonValue::as_str)
823            .unwrap_or_default();
824        if kind.contains("permission") || kind.contains("approval") || kind.starts_with("hitl_") {
825            permissions.push(BundlePermission {
826                kind: kind.to_string(),
827                source: source.to_string(),
828                request_id: event
829                    .get("request_id")
830                    .or_else(|| event.get("id"))
831                    .and_then(JsonValue::as_str)
832                    .map(str::to_string),
833                agent: event
834                    .get("agent")
835                    .and_then(JsonValue::as_str)
836                    .map(str::to_string),
837                payload: event.clone(),
838            });
839        }
840    }
841}
842
843fn event_log_pointers_from_run(run: &RunRecord) -> Vec<BundleEventLogPointer> {
844    let mut pointers = Vec::new();
845    if let Some(observability) = &run.observability {
846        for pointer in &observability.transcript_pointers {
847            pointers.push(BundleEventLogPointer {
848                kind: pointer.kind.clone(),
849                topic: None,
850                path: pointer.path.clone(),
851                location: pointer.location.clone(),
852                available: pointer.available,
853            });
854        }
855        for worker in &observability.worker_lineage {
856            if let Some(session_id) = &worker.session_id {
857                pointers.push(BundleEventLogPointer {
858                    kind: "agent_events".to_string(),
859                    topic: Some(format!("observability.agent_events.{session_id}")),
860                    path: worker.snapshot_path.clone(),
861                    location: format!("worker.{}.session", worker.worker_id),
862                    available: worker.snapshot_path.is_some(),
863                });
864            }
865        }
866    }
867    pointers
868}
869
870fn deterministic_events_from_run(
871    run: &RunRecord,
872) -> Result<Vec<BundleJsonEntry>, SessionBundleError> {
873    let mut events = Vec::new();
874    for (index, transition) in run.transitions.iter().enumerate() {
875        events.push(BundleJsonEntry {
876            source: "run.transitions".to_string(),
877            index,
878            value: serde_json::to_value(transition)
879                .map_err(|error| SessionBundleError::Encode(error.to_string()))?,
880        });
881    }
882    for (index, checkpoint) in run.checkpoints.iter().enumerate() {
883        events.push(BundleJsonEntry {
884            source: "run.checkpoints".to_string(),
885            index,
886            value: serde_json::to_value(checkpoint)
887                .map_err(|error| SessionBundleError::Encode(error.to_string()))?,
888        });
889    }
890    Ok(events)
891}
892
893fn attachments_from_run(run: &RunRecord) -> Vec<BundleAttachment> {
894    run.artifacts
895        .iter()
896        .map(|artifact| BundleAttachment {
897            id: artifact.id.clone(),
898            kind: artifact.kind.clone(),
899            title: artifact.title.clone(),
900            stage: artifact.stage.clone(),
901            text: artifact.text.clone(),
902            data: artifact.data.clone(),
903            metadata: artifact.metadata.clone(),
904        })
905        .collect()
906}
907
908fn redact_json_with_manifest(
909    value: &mut JsonValue,
910    path: &str,
911    policy: &RedactionPolicy,
912    entries: &mut Vec<RedactionEntry>,
913) {
914    match value {
915        JsonValue::Object(map) => {
916            let keys = map.keys().cloned().collect::<Vec<_>>();
917            for key in keys {
918                let child_path = json_path_child(path, &key);
919                if policy.field_is_sensitive(&key) {
920                    map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
921                    entries.push(RedactionEntry {
922                        path: child_path,
923                        class: "sensitive_field".to_string(),
924                        action: "replaced".to_string(),
925                        replacement: Some(REDACTED_PLACEHOLDER.to_string()),
926                    });
927                } else if let Some(child) = map.get_mut(&key) {
928                    redact_json_with_manifest(child, &child_path, policy, entries);
929                }
930            }
931        }
932        JsonValue::Array(items) => {
933            for (index, item) in items.iter_mut().enumerate() {
934                redact_json_with_manifest(item, &format!("{path}[{index}]"), policy, entries);
935            }
936        }
937        JsonValue::String(text) => {
938            let redacted = policy.redact_string(text);
939            if let Cow::Owned(replacement) = redacted {
940                *text = replacement;
941                entries.push(RedactionEntry {
942                    path: path.to_string(),
943                    class: "secret_pattern_or_url".to_string(),
944                    action: "replaced".to_string(),
945                    replacement: Some(REDACTED_PLACEHOLDER.to_string()),
946                });
947            }
948        }
949        _ => {}
950    }
951}
952
953fn redact_bundle_pointer_paths_json(
954    value: &mut JsonValue,
955    path: &str,
956    entries: &mut Vec<RedactionEntry>,
957) {
958    match value {
959        JsonValue::Object(map) => {
960            let keys = map.keys().cloned().collect::<Vec<_>>();
961            for key in keys {
962                let child_path = json_path_child(path, &key);
963                if key == "path" && bundle_pointer_path_should_redact(&child_path) {
964                    if !map.get(&key).is_some_and(JsonValue::is_null) {
965                        map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
966                        entries.push(RedactionEntry {
967                            path: child_path,
968                            class: "local_pointer_path".to_string(),
969                            action: "replaced".to_string(),
970                            replacement: Some(REDACTED_PLACEHOLDER.to_string()),
971                        });
972                    }
973                } else if let Some(child) = map.get_mut(&key) {
974                    redact_bundle_pointer_paths_json(child, &child_path, entries);
975                }
976            }
977        }
978        JsonValue::Array(items) => {
979            for (index, item) in items.iter_mut().enumerate() {
980                redact_bundle_pointer_paths_json(item, &format!("{path}[{index}]"), entries);
981            }
982        }
983        _ => {}
984    }
985}
986
987fn bundle_pointer_path_should_redact(path: &str) -> bool {
988    path.contains(".event_log_pointers[") || path.contains(".transcript_pointers[")
989}
990
991fn withhold_replay_only_json(value: &mut JsonValue, path: &str, entries: &mut Vec<RedactionEntry>) {
992    match value {
993        JsonValue::Object(map) => {
994            let keys = map.keys().cloned().collect::<Vec<_>>();
995            for key in keys {
996                let child_path = json_path_child(path, &key);
997                if replay_only_field_is_prompt_payload(&key) {
998                    if !map.get(&key).is_some_and(JsonValue::is_null) {
999                        map.insert(key, JsonValue::String(REPLAY_ONLY_PLACEHOLDER.to_string()));
1000                        entries.push(RedactionEntry {
1001                            path: child_path,
1002                            class: "prompt_or_tool_payload".to_string(),
1003                            action: "withheld".to_string(),
1004                            replacement: Some(REPLAY_ONLY_PLACEHOLDER.to_string()),
1005                        });
1006                    }
1007                } else if let Some(child) = map.get_mut(&key) {
1008                    withhold_replay_only_json(child, &child_path, entries);
1009                }
1010            }
1011        }
1012        JsonValue::Array(items) => {
1013            for (index, item) in items.iter_mut().enumerate() {
1014                withhold_replay_only_json(item, &format!("{path}[{index}]"), entries);
1015            }
1016        }
1017        _ => {}
1018    }
1019}
1020
1021fn replay_only_field_is_prompt_payload(key: &str) -> bool {
1022    matches!(
1023        key,
1024        "args"
1025            | "arguments"
1026            | "blocks"
1027            | "content"
1028            | "data"
1029            | "private_reasoning"
1030            | "prompt"
1031            | "raw_input"
1032            | "raw_output"
1033            | "result"
1034            | "response_text"
1035            | "summary"
1036            | "system"
1037            | "system_prompt"
1038            | "task"
1039            | "text"
1040            | "thinking"
1041            | "visible_text"
1042    )
1043}
1044
1045fn set_json_path(value: &mut JsonValue, path: &[&str], replacement: JsonValue) {
1046    let Some((head, tail)) = path.split_first() else {
1047        *value = replacement;
1048        return;
1049    };
1050    if tail.is_empty() {
1051        if let JsonValue::Object(map) = value {
1052            map.insert((*head).to_string(), replacement);
1053        }
1054        return;
1055    }
1056    if let JsonValue::Object(map) = value {
1057        if let Some(child) = map.get_mut(*head) {
1058            set_json_path(child, tail, replacement);
1059        }
1060    }
1061}
1062
1063fn require_field(value: &JsonValue, field: &str) -> Result<(), SessionBundleError> {
1064    if value.get(field).is_some() {
1065        Ok(())
1066    } else {
1067        Err(SessionBundleError::MissingRequired(format!("$.{field}")))
1068    }
1069}
1070
1071fn require_nested_field(value: &JsonValue, path: &[&str]) -> Result<(), SessionBundleError> {
1072    let mut current = value;
1073    for segment in path {
1074        current = current
1075            .get(*segment)
1076            .ok_or_else(|| SessionBundleError::MissingRequired(json_path_from_segments(path)))?;
1077    }
1078    Ok(())
1079}
1080
1081fn json_path_from_segments(path: &[&str]) -> String {
1082    path.iter().fold("$".to_string(), |parent, segment| {
1083        json_path_child(&parent, segment)
1084    })
1085}
1086
1087fn reject_unredacted_secret_markers(
1088    value: &JsonValue,
1089    path: &str,
1090    policy: &RedactionPolicy,
1091) -> Result<(), SessionBundleError> {
1092    match value {
1093        JsonValue::Object(map) => {
1094            for (key, child) in map {
1095                reject_unredacted_secret_markers(child, &json_path_child(path, key), policy)?;
1096            }
1097        }
1098        JsonValue::Array(items) => {
1099            for (index, item) in items.iter().enumerate() {
1100                reject_unredacted_secret_markers(item, &format!("{path}[{index}]"), policy)?;
1101            }
1102        }
1103        JsonValue::String(text) => {
1104            if matches!(policy.redact_string(text), Cow::Owned(_)) {
1105                return Err(SessionBundleError::UnsafeSecretMarker {
1106                    path: path.to_string(),
1107                    excerpt: secret_excerpt(text),
1108                });
1109            }
1110        }
1111        _ => {}
1112    }
1113    Ok(())
1114}
1115
1116fn secret_excerpt(text: &str) -> String {
1117    let excerpt = text.chars().take(80).collect::<String>();
1118    if text.chars().count() > 80 {
1119        format!("{excerpt}...")
1120    } else {
1121        excerpt
1122    }
1123}
1124
1125fn json_path_child(parent: &str, key: &str) -> String {
1126    if key
1127        .chars()
1128        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1129    {
1130        format!("{parent}.{key}")
1131    } else {
1132        format!(
1133            "{parent}[{}]",
1134            serde_json::to_string(key).unwrap_or_default()
1135        )
1136    }
1137}