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