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};
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}