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