1use std::{
2 path::Path,
3 time::{Instant, SystemTime, UNIX_EPOCH},
4};
5
6use murmur_artifact::{ContainmentClass, TraceCapture};
7use serde::Serialize;
8use serde_json::Value;
9use tokio::{
10 fs::{File, OpenOptions},
11 io::{AsyncWriteExt, BufWriter},
12};
13
14use crate::{
15 agent::DriverUsage,
16 containment::ScopeReport,
17 inference_credential::CredentialChange,
18 lanes::TaskLane,
19 origin::{TaskProvenance, TrustClass},
20 trace_blobs::BlobStore,
21};
22
23pub(crate) struct TraceWriter {
24 writer: BufWriter<File>,
25 session_id: String,
26 capsule_name: String,
27 capsule_version: String,
28 model: String,
29 capabilities: Vec<String>,
30 effective_grants: ScopeReport,
36 capture: TraceCapture,
40 blobs: BlobStore,
43 system_prompt_source: &'static str,
48 system_prompt_sha256: Option<String>,
52 credential_source: &'static str,
55 resumed_from: Option<String>,
59 context_id: Option<String>,
62 spawned_by: Option<String>,
66 spawn_delegation_id: Option<String>,
67 session_start_time: Instant,
68 session_event_id: String,
73 session_started: bool,
74 session_ended: bool,
75 task_event_id: Option<String>,
78 turn_event_id: Option<String>,
82 total_turns: u32,
84 total_input_tokens: u64,
85 total_output_tokens: u64,
86 total_tool_calls: u32,
87 total_shell_calls: u32,
88 task_turns: u32,
90 task_input_tokens: u64,
91 task_output_tokens: u64,
92 task_tool_calls: u32,
93 task_shell_calls: u32,
94 task_start_instant: Option<Instant>,
95 pub(crate) active_task_id: Option<String>,
96 max_session_tokens: Option<u64>,
98 machine_tokens_per_day: Option<u64>,
99}
100
101#[derive(Debug, Clone)]
106pub(crate) struct InferenceOrigin {
107 pub(crate) source: String,
109 pub(crate) model: String,
111}
112
113pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
116
117pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
120
121pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
123
124pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
127
128pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
131
132pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
134
135pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
138
139pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
142
143pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
146
147pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
150
151pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
153
154pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
156
157pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
159
160pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
163
164pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
166
167fn new_event_id() -> String {
175 format!("evt_{}", uuid::Uuid::now_v7().simple())
176}
177
178#[derive(Serialize)]
192struct SessionStartEvent {
193 event_type: &'static str,
194 event_id: String,
195 parent_id: Option<String>,
196 session_id: String,
197 timestamp: u64,
198 capsule_name: String,
199 capsule_version: String,
200 model: String,
201 max_turns: u32,
202 max_session_tokens: Option<u64>,
205 machine_tokens_per_day: Option<u64>,
208 capabilities: Vec<String>,
209 tools_declared: Vec<String>,
210 containment_declared: ContainmentClass,
212 containment_achieved: ContainmentClass,
216 workdir_exec: bool,
224 userns_grant: Option<crate::sealed::UsernsGrant>,
236 effective_grants: ScopeReport,
245 system_prompt_source: &'static str,
253 system_prompt_sha256: Option<String>,
262 credential_source: &'static str,
265 resumed_from: Option<String>,
272 context_id: Option<String>,
278 #[serde(skip_serializing_if = "Option::is_none")]
282 spawned_by: Option<String>,
283 #[serde(skip_serializing_if = "Option::is_none")]
286 delegation_id: Option<String>,
287}
288
289#[derive(Serialize)]
290struct InferenceEvent {
291 event_type: &'static str,
292 event_id: String,
293 parent_id: Option<String>,
294 session_id: String,
295 timestamp: u64,
296 turn: u32,
297 task_id: Option<String>,
298 input_tokens: u64,
301 output_tokens: u64,
303 decision: String,
304 #[serde(skip_serializing_if = "Option::is_none")]
310 stop_reason: Option<String>,
311 tool_name: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
315 input_tokens_actual: Option<u64>,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 output_tokens_actual: Option<u64>,
318 #[serde(skip_serializing_if = "Option::is_none")]
319 cached_tokens: Option<u64>,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 cache_write_tokens: Option<u64>,
322 #[serde(skip_serializing_if = "Option::is_none")]
327 origin: Option<String>,
328 #[serde(skip_serializing_if = "Option::is_none")]
332 model: Option<String>,
333 #[serde(skip_serializing_if = "Vec::is_empty")]
338 message_ids: Vec<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
347 system_sha: Option<String>,
348 #[serde(skip_serializing_if = "Option::is_none")]
351 tools_sha: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
355 response_sha: Option<String>,
356 #[serde(skip_serializing_if = "Vec::is_empty")]
364 message_shas: Vec<String>,
365}
366
367pub(crate) struct WireCapture {
377 system: Vec<u8>,
380 tools: Vec<u8>,
382 messages: Vec<Vec<u8>>,
386 response: Vec<u8>,
388}
389
390impl WireCapture {
391 pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
397 Self {
398 system: payload
399 .get("system")
400 .and_then(Value::as_str)
401 .unwrap_or_default()
402 .as_bytes()
403 .to_vec(),
404 tools: payload
405 .get("tools")
406 .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
407 .unwrap_or_default(),
408 messages: payload
409 .get("messages")
410 .and_then(Value::as_array)
411 .map(|messages| {
412 messages
413 .iter()
414 .map(|message| serde_json::to_vec(message).unwrap_or_default())
415 .collect()
416 })
417 .unwrap_or_default(),
418 response: response.as_bytes().to_vec(),
419 }
420 }
421}
422
423#[derive(Serialize)]
424struct ToolCallEvent {
425 event_type: &'static str,
426 event_id: String,
427 parent_id: Option<String>,
428 session_id: String,
429 timestamp: u64,
430 turn: u32,
431 task_id: Option<String>,
432 tool_name: String,
433 tool_call_id: Option<String>,
438 input: Value,
439 input_bytes: u64,
440 #[serde(skip_serializing_if = "Option::is_none")]
441 output: Option<String>,
442 output_bytes: u64,
443 duration_ms: u64,
444 status: String,
445 #[serde(skip_serializing_if = "Option::is_none")]
449 state_effect: Option<String>,
450 #[serde(skip_serializing_if = "Option::is_none")]
455 resource_id: Option<String>,
456}
457
458#[derive(Serialize)]
459struct SkillCallEvent {
460 event_type: &'static str,
461 event_id: String,
462 parent_id: Option<String>,
463 session_id: String,
464 timestamp: u64,
465 turn: u32,
466 task_id: Option<String>,
467 skill_name: String,
468 output_bytes: u64,
469 duration_ms: u64,
470 status: String,
471}
472
473#[derive(Serialize)]
474struct ShellEvent {
475 event_type: &'static str,
476 event_id: String,
477 parent_id: Option<String>,
478 session_id: String,
479 timestamp: u64,
480 turn: u32,
481 task_id: Option<String>,
482 binary: String,
486 command: String,
487 exit_code: i32,
488 stdout_bytes: u64,
489 stderr_bytes: u64,
490 duration_ms: u64,
491 #[serde(skip_serializing_if = "Option::is_none")]
497 resource_limit: Option<String>,
498}
499
500#[derive(Serialize)]
503struct ShellDetachedEvent {
504 event_type: &'static str,
505 event_id: String,
506 parent_id: Option<String>,
507 session_id: String,
508 timestamp: u64,
509 turn: u32,
510 task_id: Option<String>,
511 work_id: String,
512 binary: String,
513 command: String,
514 grace_ms: u64,
516}
517
518#[derive(Serialize)]
521struct ShellCompletedEvent {
522 event_type: &'static str,
523 event_id: String,
524 parent_id: Option<String>,
525 session_id: String,
526 timestamp: u64,
527 work_id: String,
528 binary: String,
529 command: String,
530 exit_code: i32,
531 duration_ms: u64,
532 output_path: String,
534 output_bytes: u64,
535 #[serde(skip_serializing_if = "Option::is_none")]
537 resource_limit: Option<String>,
538 status: String,
541 completion_task_id: String,
544}
545
546#[derive(Serialize)]
555struct ShellAbandonedEvent {
556 event_type: &'static str,
557 event_id: String,
558 parent_id: Option<String>,
559 session_id: String,
560 timestamp: u64,
561 work_id: String,
562 binary: String,
563 command: String,
564 running_ms: u64,
567 #[serde(skip_serializing_if = "Option::is_none")]
570 exit_code: Option<i32>,
571 #[serde(skip_serializing_if = "Option::is_none")]
575 output_path: Option<String>,
576 #[serde(skip_serializing_if = "Option::is_none")]
578 output_bytes: Option<u64>,
579}
580
581#[derive(Serialize)]
593struct ShellLostEvent {
594 event_type: &'static str,
595 event_id: String,
596 #[serde(skip_serializing_if = "Option::is_none")]
599 parent_id: Option<String>,
600 session_id: String,
602 timestamp: u64,
603 work_id: String,
604 binary: String,
605 command: String,
606 detached_at_ms: u64,
608 reconciled_by_session: String,
610 reconciled_task_id: String,
613}
614
615#[derive(Serialize)]
621struct ShellDetachUnrecordedEvent {
622 event_type: &'static str,
623 event_id: String,
624 parent_id: Option<String>,
625 session_id: String,
626 timestamp: u64,
627 turn: u32,
628 task_id: Option<String>,
629 work_id: String,
630 binary: String,
631 reason: String,
633}
634
635#[derive(Serialize)]
636struct CompactionEvent {
637 event_type: &'static str,
638 event_id: String,
639 parent_id: Option<String>,
640 session_id: String,
641 timestamp: u64,
642 turn: u32,
643 task_id: Option<String>,
644 tokens_before: u64,
645 tokens_after: u64,
646}
647
648#[derive(Serialize)]
652struct CompactionDeclinedEvent {
653 event_type: &'static str,
654 event_id: String,
655 parent_id: Option<String>,
656 session_id: String,
657 timestamp: u64,
658 turn: u32,
659 task_id: Option<String>,
660 tokens: u64,
663 reason: String,
666}
667
668#[derive(Serialize)]
675struct ContextSeedEvent {
676 event_type: &'static str,
677 event_id: String,
678 parent_id: Option<String>,
679 session_id: String,
680 timestamp: u64,
681 task_id: Option<String>,
682 hook_name: String,
684 tokens: u64,
687 proposed_tokens: u64,
690 budget_tokens: u64,
693 outcome: String,
696 #[serde(skip_serializing_if = "Option::is_none")]
699 reason: Option<String>,
700 message_ids: Vec<String>,
704}
705
706#[derive(Serialize)]
714struct RetentionEvent {
715 event_type: &'static str,
716 event_id: String,
717 parent_id: Option<String>,
718 session_id: String,
719 timestamp: u64,
720 store: String,
722 reason: String,
725 removed: u32,
729 targets: Vec<String>,
731 #[serde(skip_serializing_if = "Option::is_none")]
734 messages_dropped: Option<u64>,
735}
736
737#[derive(Serialize)]
738struct A2aTaskReceivedEvent {
739 event_type: &'static str,
740 event_id: String,
741 parent_id: Option<String>,
742 session_id: String,
743 timestamp: u64,
744 task_id: String,
745 context_id: String,
746 message_id: String,
747 traceparent_from_caller: Option<String>,
748}
749
750#[derive(Serialize)]
751struct A2aSendEvent {
752 event_type: &'static str,
753 event_id: String,
754 parent_id: Option<String>,
755 session_id: String,
756 timestamp: u64,
757 peer_url: String,
758 message_id: String,
759 task_id: String,
760 context_id: String,
761 traceparent: Option<String>,
762 trust: String,
765}
766
767#[derive(Serialize)]
768struct SessionEndEvent {
769 event_type: &'static str,
770 event_id: String,
771 parent_id: Option<String>,
772 session_id: String,
773 timestamp: u64,
774 total_turns: u32,
775 total_input_tokens: u64,
776 total_output_tokens: u64,
777 total_tool_calls: u32,
778 total_shell_calls: u32,
779 duration_ms: u64,
780 exit_status: String,
781}
782
783#[derive(Serialize)]
784struct TaskStartEvent {
785 event_type: &'static str,
786 event_id: String,
787 parent_id: Option<String>,
788 session_id: String,
789 timestamp: u64,
790 task_id: String,
791 context_id: String,
792 source: String,
795 origin: String,
796 trust: String,
797 lane: String,
800 #[serde(skip_serializing_if = "Option::is_none")]
804 delegation_id: Option<String>,
805 message_parts_bytes: u64,
806}
807
808#[derive(Serialize)]
809struct TaskEndEvent {
810 event_type: &'static str,
811 event_id: String,
812 parent_id: Option<String>,
813 session_id: String,
814 timestamp: u64,
815 task_id: String,
816 exit_status: String,
817 duration_ms: u64,
818 turns: u32,
819 input_tokens: u64,
820 output_tokens: u64,
821 tool_calls: u32,
822 shell_calls: u32,
823 reopen_count: u32,
826}
827
828#[derive(Serialize)]
835struct TaskCanceledEvent {
836 event_type: &'static str,
837 event_id: String,
838 parent_id: Option<String>,
839 session_id: String,
840 timestamp: u64,
841 task_id: String,
842 #[serde(skip_serializing_if = "Option::is_none")]
844 turn: Option<u32>,
845 phase: String,
847 detached_work_ids: Vec<String>,
850 delegation_ids: Vec<String>,
853}
854
855#[derive(Serialize)]
859struct TaskReopenedEvent {
860 event_type: &'static str,
861 event_id: String,
862 parent_id: Option<String>,
863 session_id: String,
864 timestamp: u64,
865 task_id: String,
866 hook_name: String,
868 reason: String,
870 reopen_number: u32,
872}
873
874#[derive(Serialize)]
877struct SpendCeilingReachedEvent<'a> {
878 event_type: &'static str,
879 event_id: String,
880 parent_id: Option<String>,
881 session_id: String,
882 timestamp: u64,
883 turn: u32,
884 task_id: Option<String>,
885 #[serde(flatten)]
886 refusal: &'a crate::spend::SpendRefusal,
887 #[serde(skip_serializing_if = "Option::is_none")]
889 origin: Option<&'a str>,
890}
891
892#[derive(Serialize)]
896struct CallDeniedEvent {
897 event_type: &'static str,
898 event_id: String,
899 parent_id: Option<String>,
900 session_id: String,
901 timestamp: u64,
902 turn: u32,
903 event: String,
906 hook_name: String,
908 target: String,
911 reason: String,
913}
914
915#[derive(Serialize)]
920struct ProtectedPathDeniedEvent {
921 event_type: &'static str,
922 event_id: String,
923 parent_id: Option<String>,
924 session_id: String,
925 timestamp: u64,
926 turn: u32,
927 call: String,
929 target: String,
931 path: String,
934 rule: String,
936 signal: String,
939 reason: String,
941}
942
943#[derive(Serialize)]
944struct HookDispatchErrorEvent {
945 event_type: &'static str,
946 event_id: String,
947 parent_id: Option<String>,
948 session_id: String,
949 timestamp: u64,
950 hook_name: String,
951 event: String,
952 arm: String,
953}
954
955#[derive(Serialize)]
956struct ResourceListEvent {
957 event_type: &'static str,
958 event_id: String,
959 parent_id: Option<String>,
960 session_id: String,
961 timestamp: u64,
962 root: String,
965 entry_count: usize,
966 total_bytes: u64,
967 generation: u64,
969 containment_achieved: ContainmentClass,
970 outcome: String,
972 reason: Option<String>,
974}
975
976#[derive(Serialize)]
977struct ResourceReadEvent {
978 event_type: &'static str,
979 event_id: String,
980 parent_id: Option<String>,
981 session_id: String,
982 timestamp: u64,
983 path: String,
986 outcome: String,
987 bytes: Option<u64>,
989 sha256: Option<String>,
991 generation: u64,
992 containment_achieved: ContainmentClass,
993 reason: Option<String>,
994}
995
996#[derive(Serialize)]
998struct PeerHandleMintEvent {
999 event_type: &'static str,
1000 event_id: String,
1001 parent_id: Option<String>,
1002 session_id: String,
1003 timestamp: u64,
1004 handle_id: Option<String>,
1007 path: String,
1010 audience: String,
1011 expires_at_ms: Option<u64>,
1013 outcome: String,
1014 reason: Option<String>,
1015}
1016
1017#[derive(Serialize)]
1020struct PeerHandleRedeemEvent {
1021 event_type: &'static str,
1022 event_id: String,
1023 parent_id: Option<String>,
1024 session_id: String,
1025 timestamp: u64,
1026 handle_id: String,
1027 path: Option<String>,
1030 generation: u64,
1033 audience_asserted: Option<String>,
1035 bytes: Option<u64>,
1037 sha256: Option<String>,
1039 outcome: String,
1040 reason: Option<String>,
1041}
1042
1043#[derive(Serialize)]
1045struct PeerFileFetchEvent {
1046 event_type: &'static str,
1047 event_id: String,
1048 parent_id: Option<String>,
1049 session_id: String,
1050 timestamp: u64,
1051 peer: String,
1052 handle_id: String,
1053 stored_path: Option<String>,
1056 bytes: Option<u64>,
1057 sha256: Option<String>,
1058 outcome: String,
1059 reason: Option<String>,
1060}
1061
1062#[derive(Serialize)]
1073struct DelegationStartEvent {
1074 event_type: &'static str,
1075 event_id: String,
1076 parent_id: Option<String>,
1077 session_id: String,
1078 timestamp: u64,
1079 delegation_id: String,
1082 capsule: String,
1083 version: String,
1084 child_session_id: String,
1086 child_workdir: String,
1089}
1090
1091impl DelegationStartEvent {
1092 fn new(
1095 session_id: &str,
1096 session_event_id: &str,
1097 delegation_id: &str,
1098 capsule: &str,
1099 version: &str,
1100 child_session_id: &str,
1101 child_workdir: &str,
1102 ) -> Self {
1103 Self {
1104 event_type: "delegation_start",
1105 event_id: new_event_id(),
1106 parent_id: Some(session_event_id.to_string()),
1107 session_id: session_id.to_string(),
1108 timestamp: timestamp_ms(),
1109 delegation_id: delegation_id.to_string(),
1110 capsule: capsule.to_string(),
1111 version: version.to_string(),
1112 child_session_id: child_session_id.to_string(),
1113 child_workdir: child_workdir.to_string(),
1114 }
1115 }
1116}
1117
1118#[derive(Serialize)]
1129struct DelegationEvent {
1130 event_type: &'static str,
1131 event_id: String,
1132 parent_id: Option<String>,
1133 session_id: String,
1134 timestamp: u64,
1135 capsule: String,
1137 version: String,
1138 delegation_id: Option<String>,
1140 child_session_id: Option<String>,
1142 duration_ms: u64,
1145 outcome: String,
1155 reason: Option<String>,
1158}
1159
1160impl DelegationEvent {
1161 #[allow(clippy::too_many_arguments)]
1164 fn new(
1165 session_id: &str,
1166 session_event_id: &str,
1167 capsule: &str,
1168 version: &str,
1169 delegation_id: Option<String>,
1170 child_session_id: Option<String>,
1171 duration_ms: u64,
1172 outcome: &str,
1173 reason: Option<String>,
1174 ) -> Self {
1175 Self {
1176 event_type: "delegation",
1177 event_id: new_event_id(),
1178 parent_id: Some(session_event_id.to_string()),
1179 session_id: session_id.to_string(),
1180 timestamp: timestamp_ms(),
1181 capsule: capsule.to_string(),
1182 version: version.to_string(),
1183 delegation_id,
1184 child_session_id,
1185 duration_ms,
1186 outcome: outcome.to_string(),
1187 reason,
1188 }
1189 }
1190}
1191
1192impl TraceWriter {
1195 #[allow(clippy::too_many_arguments)]
1196 pub(crate) async fn open(
1197 workdir: &Path,
1198 session_id: String,
1199 capsule_name: String,
1200 capsule_version: String,
1201 model: String,
1202 capabilities: Vec<String>,
1203 effective_grants: ScopeReport,
1204 capture: TraceCapture,
1205 system_prompt: Option<String>,
1206 system_prompt_overridden: bool,
1207 resumed_from: Option<String>,
1208 context_id: Option<String>,
1209 spawned_by: Option<String>,
1210 spawn_delegation_id: Option<String>,
1211 ) -> std::io::Result<Self> {
1212 let file = OpenOptions::new()
1213 .create(true)
1214 .append(true)
1215 .open(workdir.join("trace.jsonl"))
1216 .await?;
1217 let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
1220 (true, _) => "cli",
1221 (false, true) => "manifest",
1222 (false, false) => "none",
1223 };
1224 let system_prompt_sha256 = system_prompt
1225 .as_ref()
1226 .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
1227 let blobs = BlobStore::new(workdir);
1228 if let (true, Some(prompt), Some(sha)) = (
1232 capture.captures_content(),
1233 system_prompt.as_ref(),
1234 system_prompt_sha256.as_deref(),
1235 ) {
1236 blobs.put(sha, prompt.as_bytes()).await?;
1237 }
1238 Ok(Self {
1239 writer: BufWriter::new(file),
1240 session_id,
1241 capsule_name,
1242 capsule_version,
1243 model,
1244 capabilities,
1245 effective_grants,
1246 capture,
1247 blobs,
1248 system_prompt_source,
1249 system_prompt_sha256,
1250 credential_source: "none",
1251 resumed_from,
1252 context_id,
1253 spawned_by,
1254 spawn_delegation_id,
1255 session_start_time: Instant::now(),
1256 session_event_id: new_event_id(),
1257 session_started: false,
1258 session_ended: false,
1259 task_event_id: None,
1260 turn_event_id: None,
1261 total_turns: 0,
1262 total_input_tokens: 0,
1263 total_output_tokens: 0,
1264 total_tool_calls: 0,
1265 total_shell_calls: 0,
1266 task_turns: 0,
1267 task_input_tokens: 0,
1268 task_output_tokens: 0,
1269 task_tool_calls: 0,
1270 task_shell_calls: 0,
1271 task_start_instant: None,
1272 active_task_id: None,
1273 max_session_tokens: None,
1274 machine_tokens_per_day: None,
1275 })
1276 }
1277
1278 pub(crate) fn set_spend_ceilings(
1280 &mut self,
1281 max_session_tokens: Option<u64>,
1282 machine_tokens_per_day: Option<u64>,
1283 ) {
1284 self.max_session_tokens = max_session_tokens;
1285 self.machine_tokens_per_day = machine_tokens_per_day;
1286 }
1287
1288 pub(crate) fn session_event_id(&self) -> &str {
1292 &self.session_event_id
1293 }
1294
1295 pub(crate) fn captures_wire(&self) -> bool {
1299 self.capture.captures_hashes()
1300 }
1301
1302 fn session_parent(&self) -> Option<String> {
1307 self.session_started.then(|| self.session_event_id.clone())
1308 }
1309
1310 fn task_parent(&self) -> Option<String> {
1313 self.task_event_id.clone().or_else(|| self.session_parent())
1314 }
1315
1316 fn turn_parent(&self) -> Option<String> {
1320 self.turn_event_id.clone().or_else(|| self.task_parent())
1321 }
1322
1323 pub(crate) fn set_credential_source(&mut self, source: &'static str) {
1326 self.credential_source = source;
1327 }
1328
1329 pub(crate) async fn write_session_start(
1330 &mut self,
1331 max_turns: u32,
1332 tools_declared: Vec<String>,
1333 ) -> std::io::Result<()> {
1334 let event = SessionStartEvent {
1335 event_type: "session_start",
1336 event_id: self.session_event_id.clone(),
1337 parent_id: None,
1338 session_id: self.session_id.clone(),
1339 timestamp: timestamp_ms(),
1340 capsule_name: self.capsule_name.clone(),
1341 capsule_version: self.capsule_version.clone(),
1342 model: self.model.clone(),
1343 max_turns,
1344 max_session_tokens: self.max_session_tokens,
1345 machine_tokens_per_day: self.machine_tokens_per_day,
1346 capabilities: self.capabilities.clone(),
1347 tools_declared,
1348 containment_declared: self.effective_grants.declared_containment,
1349 containment_achieved: self.effective_grants.achieved_containment,
1350 workdir_exec: self.effective_grants.workdir_exec,
1351 userns_grant: self.effective_grants.userns_grant,
1352 effective_grants: self.effective_grants.clone(),
1353 system_prompt_source: self.system_prompt_source,
1354 system_prompt_sha256: self.system_prompt_sha256.clone(),
1355 credential_source: self.credential_source,
1356 resumed_from: self.resumed_from.clone(),
1357 context_id: self.context_id.clone(),
1358 spawned_by: self.spawned_by.clone(),
1359 delegation_id: self.spawn_delegation_id.clone(),
1360 };
1361 self.write_event(&event).await?;
1362 self.session_started = true;
1363 Ok(())
1364 }
1365
1366 #[allow(clippy::too_many_arguments)]
1370 pub(crate) async fn write_inference(
1371 &mut self,
1372 turn: u32,
1373 input_tokens: u64,
1374 output_tokens: u64,
1375 decision: String,
1376 stop_reason: Option<&str>,
1377 tool_name: Option<String>,
1378 origin: Option<&InferenceOrigin>,
1379 usage: Option<&DriverUsage>,
1380 message_ids: Vec<String>,
1381 wire: Option<&WireCapture>,
1382 ) -> std::io::Result<()> {
1383 let event_id = new_event_id();
1384 let parent_id = if origin.is_none() {
1388 let parent = self.task_parent();
1389 self.turn_event_id = Some(event_id.clone());
1390 parent
1391 } else {
1392 self.turn_parent()
1393 };
1394 let (system_sha, tools_sha, response_sha, message_shas) =
1395 match wire.filter(|_| self.capture.captures_hashes()) {
1396 Some(wire) => self.record_wire(wire).await?,
1397 None => (None, None, None, Vec::new()),
1398 };
1399 let event = InferenceEvent {
1400 event_type: "inference",
1401 event_id,
1402 parent_id,
1403 session_id: self.session_id.clone(),
1404 timestamp: timestamp_ms(),
1405 turn,
1406 task_id: self.active_task_id.clone(),
1407 input_tokens,
1408 output_tokens,
1409 decision,
1410 stop_reason: stop_reason.map(str::to_string),
1411 tool_name,
1412 input_tokens_actual: usage.and_then(|u| u.input_tokens),
1413 output_tokens_actual: usage.and_then(|u| u.output_tokens),
1414 cached_tokens: usage.and_then(|u| u.cached_tokens),
1415 cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
1416 origin: origin.map(|o| o.source.clone()),
1417 model: origin.map(|o| o.model.clone()),
1418 message_ids,
1419 system_sha,
1420 tools_sha,
1421 response_sha,
1422 message_shas,
1423 };
1424 self.write_event(&event).await?;
1425 self.total_turns = self.total_turns.saturating_add(1);
1426 self.total_input_tokens = self.total_input_tokens.saturating_add(input_tokens);
1427 self.total_output_tokens = self.total_output_tokens.saturating_add(output_tokens);
1428 self.task_turns = self.task_turns.saturating_add(1);
1429 self.task_input_tokens = self.task_input_tokens.saturating_add(input_tokens);
1430 self.task_output_tokens = self.task_output_tokens.saturating_add(output_tokens);
1431 Ok(())
1432 }
1433
1434 async fn record_wire(
1441 &mut self,
1442 wire: &WireCapture,
1443 ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
1444 let system_sha = murmur_artifact::sha256_hex(&wire.system);
1445 let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
1446 let response_sha = murmur_artifact::sha256_hex(&wire.response);
1447 let message_shas: Vec<String> = wire
1448 .messages
1449 .iter()
1450 .map(|message| murmur_artifact::sha256_hex(message))
1451 .collect();
1452
1453 if self.capture.captures_content() {
1454 self.blobs.put(&system_sha, &wire.system).await?;
1458 self.blobs.put(&tools_sha, &wire.tools).await?;
1459 self.blobs.put(&response_sha, &wire.response).await?;
1460 for (sha, body) in message_shas.iter().zip(&wire.messages) {
1461 self.blobs.put(sha, body).await?;
1462 }
1463 }
1464
1465 Ok((
1466 Some(system_sha),
1467 Some(tools_sha),
1468 Some(response_sha),
1469 message_shas,
1470 ))
1471 }
1472
1473 #[allow(clippy::too_many_arguments)]
1474 pub(crate) async fn write_tool_call(
1475 &mut self,
1476 turn: u32,
1477 tool_name: String,
1478 tool_call_id: Option<String>,
1479 mut input: Value,
1480 input_bytes: u64,
1481 output: &str,
1482 output_bytes: u64,
1483 duration_ms: u64,
1484 status: String,
1485 state_effect: Option<String>,
1486 resource_id: Option<String>,
1487 ) -> std::io::Result<()> {
1488 crate::peer_handoff::redact_handles_in_json(&mut input);
1493 let event = ToolCallEvent {
1494 event_type: "tool_call",
1495 event_id: new_event_id(),
1496 parent_id: self.turn_parent(),
1497 session_id: self.session_id.clone(),
1498 timestamp: timestamp_ms(),
1499 turn,
1500 task_id: self.active_task_id.clone(),
1501 tool_name,
1502 tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
1505 input,
1506 input_bytes,
1507 output: self
1508 .capture
1509 .captures_content()
1510 .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
1511 output_bytes,
1512 duration_ms,
1513 status,
1514 state_effect,
1515 resource_id,
1516 };
1517 self.write_event(&event).await?;
1518 self.total_tool_calls = self.total_tool_calls.saturating_add(1);
1519 self.task_tool_calls = self.task_tool_calls.saturating_add(1);
1520 Ok(())
1521 }
1522
1523 pub(crate) async fn write_skill_call(
1526 &mut self,
1527 turn: u32,
1528 skill_name: String,
1529 output_bytes: u64,
1530 duration_ms: u64,
1531 status: String,
1532 ) -> std::io::Result<()> {
1533 let event = SkillCallEvent {
1534 event_type: "skill_call",
1535 event_id: new_event_id(),
1536 parent_id: self.turn_parent(),
1537 session_id: self.session_id.clone(),
1538 timestamp: timestamp_ms(),
1539 turn,
1540 task_id: self.active_task_id.clone(),
1541 skill_name,
1542 output_bytes,
1543 duration_ms,
1544 status,
1545 };
1546 self.write_event(&event).await
1547 }
1549
1550 #[allow(clippy::too_many_arguments)]
1553 pub(crate) async fn write_shell(
1554 &mut self,
1555 turn: u32,
1556 binary: String,
1557 command: String,
1558 exit_code: i32,
1559 stdout_bytes: u64,
1560 stderr_bytes: u64,
1561 duration_ms: u64,
1562 resource_limit: Option<String>,
1563 ) -> std::io::Result<()> {
1564 let event = ShellEvent {
1565 event_type: "shell",
1566 event_id: new_event_id(),
1567 parent_id: self.turn_parent(),
1568 session_id: self.session_id.clone(),
1569 timestamp: timestamp_ms(),
1570 turn,
1571 task_id: self.active_task_id.clone(),
1572 binary,
1573 command,
1574 exit_code,
1575 stdout_bytes,
1576 stderr_bytes,
1577 duration_ms,
1578 resource_limit,
1579 };
1580 self.write_event(&event).await?;
1581 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
1582 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
1583 Ok(())
1584 }
1585
1586 pub(crate) async fn write_shell_detached(
1593 &mut self,
1594 turn: u32,
1595 work_id: &str,
1596 binary: &str,
1597 command: &str,
1598 grace_ms: u64,
1599 ) -> std::io::Result<()> {
1600 let event = ShellDetachedEvent {
1601 event_type: "shell_detached",
1602 event_id: new_event_id(),
1603 parent_id: self.turn_parent(),
1604 session_id: self.session_id.clone(),
1605 timestamp: timestamp_ms(),
1606 turn,
1607 task_id: self.active_task_id.clone(),
1608 work_id: work_id.to_string(),
1609 binary: binary.to_string(),
1610 command: command.to_string(),
1611 grace_ms,
1612 };
1613 self.write_event(&event).await?;
1614 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
1615 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
1616 Ok(())
1617 }
1618
1619 #[allow(clippy::too_many_arguments)]
1622 pub(crate) async fn write_shell_completed(
1623 &mut self,
1624 work_id: &str,
1625 binary: &str,
1626 command: &str,
1627 exit_code: i32,
1628 duration_ms: u64,
1629 output_path: &str,
1630 output_bytes: u64,
1631 resource_limit: Option<String>,
1632 status: &str,
1633 completion_task_id: &str,
1634 ) -> std::io::Result<()> {
1635 let event = ShellCompletedEvent {
1636 event_type: "shell_completed",
1637 event_id: new_event_id(),
1638 parent_id: self.session_parent(),
1639 session_id: self.session_id.clone(),
1640 timestamp: timestamp_ms(),
1641 work_id: work_id.to_string(),
1642 binary: binary.to_string(),
1643 command: command.to_string(),
1644 exit_code,
1645 duration_ms,
1646 output_path: output_path.to_string(),
1647 output_bytes,
1648 resource_limit,
1649 status: status.to_string(),
1650 completion_task_id: completion_task_id.to_string(),
1651 };
1652 self.write_event(&event).await
1653 }
1654
1655 pub(crate) async fn write_shell_abandoned(
1662 &mut self,
1663 work_id: &str,
1664 binary: &str,
1665 command: &str,
1666 running_ms: u64,
1667 recovered: Option<(i32, &str, u64)>,
1668 ) -> std::io::Result<()> {
1669 let event = ShellAbandonedEvent {
1670 event_type: "shell_abandoned",
1671 event_id: new_event_id(),
1672 parent_id: self.session_parent(),
1673 session_id: self.session_id.clone(),
1674 timestamp: timestamp_ms(),
1675 work_id: work_id.to_string(),
1676 binary: binary.to_string(),
1677 command: command.to_string(),
1678 running_ms,
1679 exit_code: recovered.map(|(exit_code, _, _)| exit_code),
1680 output_path: recovered.map(|(_, path, _)| path.to_string()),
1681 output_bytes: recovered.map(|(_, _, bytes)| bytes),
1682 };
1683 self.write_event(&event).await
1684 }
1685
1686 pub(crate) async fn write_shell_detach_unrecorded(
1688 &mut self,
1689 turn: u32,
1690 work_id: &str,
1691 binary: &str,
1692 reason: &str,
1693 ) -> std::io::Result<()> {
1694 let event = ShellDetachUnrecordedEvent {
1695 event_type: "shell_detach_unrecorded",
1696 event_id: new_event_id(),
1697 parent_id: self.turn_parent(),
1698 session_id: self.session_id.clone(),
1699 timestamp: timestamp_ms(),
1700 turn,
1701 task_id: self.active_task_id.clone(),
1702 work_id: work_id.to_string(),
1703 binary: binary.to_string(),
1704 reason: reason.to_string(),
1705 };
1706 self.write_event(&event).await
1707 }
1708
1709 pub(crate) async fn write_a2a_task_received(
1710 &mut self,
1711 task_id: &str,
1712 context_id: &str,
1713 message_id: &str,
1714 traceparent_from_caller: Option<&str>,
1715 ) -> std::io::Result<()> {
1716 let event = A2aTaskReceivedEvent {
1717 event_type: "a2a_task_received",
1718 event_id: new_event_id(),
1719 parent_id: self.session_parent(),
1720 session_id: self.session_id.clone(),
1721 timestamp: timestamp_ms(),
1722 task_id: task_id.to_string(),
1723 context_id: context_id.to_string(),
1724 message_id: message_id.to_string(),
1725 traceparent_from_caller: traceparent_from_caller.map(str::to_string),
1726 };
1727 self.write_event(&event).await
1728 }
1729
1730 pub(crate) async fn write_a2a_send(
1731 &mut self,
1732 peer_url: &str,
1733 message_id: &str,
1734 task_id: &str,
1735 context_id: &str,
1736 traceparent: Option<&str>,
1737 trust: TrustClass,
1738 ) -> std::io::Result<()> {
1739 let event = A2aSendEvent {
1740 event_type: "a2a_send",
1741 event_id: new_event_id(),
1742 parent_id: self.session_parent(),
1743 session_id: self.session_id.clone(),
1744 timestamp: timestamp_ms(),
1745 peer_url: peer_url.to_string(),
1746 message_id: message_id.to_string(),
1747 task_id: task_id.to_string(),
1748 context_id: context_id.to_string(),
1749 traceparent: traceparent.map(str::to_string),
1750 trust: trust.as_str().to_string(),
1751 };
1752 self.write_event(&event).await
1753 }
1754
1755 pub(crate) async fn write_task_start(
1756 &mut self,
1757 task_id: &str,
1758 context_id: &str,
1759 source: &str,
1760 provenance: TaskProvenance,
1761 delegation_id: Option<&str>,
1762 message_parts_bytes: u64,
1763 ) -> std::io::Result<()> {
1764 self.task_turns = 0;
1766 self.task_input_tokens = 0;
1767 self.task_output_tokens = 0;
1768 self.task_tool_calls = 0;
1769 self.task_shell_calls = 0;
1770 self.task_start_instant = Some(Instant::now());
1771 self.active_task_id = Some(task_id.to_string());
1772
1773 let event_id = new_event_id();
1774 self.task_event_id = Some(event_id.clone());
1775 self.turn_event_id = None;
1776
1777 let event = TaskStartEvent {
1778 event_type: "task_start",
1779 event_id: event_id.clone(),
1780 parent_id: self.session_parent(),
1781 session_id: self.session_id.clone(),
1782 timestamp: timestamp_ms(),
1783 task_id: task_id.to_string(),
1784 context_id: context_id.to_string(),
1785 source: source.to_string(),
1786 origin: provenance.origin().as_str().to_string(),
1787 trust: provenance.trust().as_str().to_string(),
1788 lane: TaskLane::for_origin(provenance.origin())
1791 .as_str()
1792 .to_string(),
1793 delegation_id: delegation_id.map(str::to_string),
1794 message_parts_bytes,
1795 };
1796 self.write_event(&event).await
1797 }
1798
1799 pub(crate) async fn write_task_end(
1800 &mut self,
1801 task_id: &str,
1802 exit_status: &str,
1803 reopen_count: u32,
1804 ) -> std::io::Result<()> {
1805 let duration_ms = self
1806 .task_start_instant
1807 .take()
1808 .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
1809 .unwrap_or(0);
1810
1811 let event = TaskEndEvent {
1812 event_type: "task_end",
1813 event_id: new_event_id(),
1814 parent_id: self.task_parent(),
1815 session_id: self.session_id.clone(),
1816 timestamp: timestamp_ms(),
1817 task_id: task_id.to_string(),
1818 exit_status: exit_status.to_string(),
1819 duration_ms,
1820 turns: self.task_turns,
1821 input_tokens: self.task_input_tokens,
1822 output_tokens: self.task_output_tokens,
1823 tool_calls: self.task_tool_calls,
1824 shell_calls: self.task_shell_calls,
1825 reopen_count,
1826 };
1827 self.active_task_id = None;
1828 self.task_event_id = None;
1829 self.turn_event_id = None;
1830 self.write_event(&event).await
1831 }
1832
1833 pub(crate) async fn write_task_canceled(
1839 &mut self,
1840 task_id: &str,
1841 turn: Option<u32>,
1842 phase: &str,
1843 detached_work_ids: Vec<String>,
1844 delegation_ids: Vec<String>,
1845 ) -> std::io::Result<()> {
1846 let event = TaskCanceledEvent {
1847 event_type: "task_canceled",
1848 event_id: new_event_id(),
1849 parent_id: self.task_parent(),
1850 session_id: self.session_id.clone(),
1851 timestamp: timestamp_ms(),
1852 task_id: task_id.to_string(),
1853 turn,
1854 phase: phase.to_string(),
1855 detached_work_ids,
1856 delegation_ids,
1857 };
1858 self.write_event(&event).await
1859 }
1860
1861 pub(crate) async fn write_task_reopened(
1865 &mut self,
1866 task_id: &str,
1867 hook_name: &str,
1868 reason: &str,
1869 reopen_number: u32,
1870 ) -> std::io::Result<()> {
1871 let event = TaskReopenedEvent {
1872 event_type: "task_reopened",
1873 event_id: new_event_id(),
1874 parent_id: self.task_parent(),
1875 session_id: self.session_id.clone(),
1876 timestamp: timestamp_ms(),
1877 task_id: task_id.to_string(),
1878 hook_name: hook_name.to_string(),
1879 reason: reason.to_string(),
1880 reopen_number,
1881 };
1882 self.write_event(&event).await
1883 }
1884
1885 pub(crate) fn task_turns(&self) -> u32 {
1890 self.task_turns
1891 }
1892
1893 pub(crate) async fn write_compaction(
1894 &mut self,
1895 turn: u32,
1896 tokens_before: u64,
1897 tokens_after: u64,
1898 ) -> std::io::Result<()> {
1899 let event = CompactionEvent {
1900 event_type: "compaction",
1901 event_id: new_event_id(),
1902 parent_id: self.turn_parent(),
1903 session_id: self.session_id.clone(),
1904 timestamp: timestamp_ms(),
1905 turn,
1906 task_id: self.active_task_id.clone(),
1907 tokens_before,
1908 tokens_after,
1909 };
1910 self.write_event(&event).await
1911 }
1912
1913 pub(crate) async fn write_compaction_declined(
1918 &mut self,
1919 turn: u32,
1920 tokens: u64,
1921 reason: &str,
1922 ) -> std::io::Result<()> {
1923 let event = CompactionDeclinedEvent {
1924 event_type: "compaction_declined",
1925 event_id: new_event_id(),
1926 parent_id: self.turn_parent(),
1927 session_id: self.session_id.clone(),
1928 timestamp: timestamp_ms(),
1929 turn,
1930 task_id: self.active_task_id.clone(),
1931 tokens,
1932 reason: reason.to_string(),
1933 };
1934 self.write_event(&event).await
1935 }
1936
1937 #[allow(clippy::too_many_arguments)]
1944 pub(crate) async fn write_context_seed(
1945 &mut self,
1946 hook_name: &str,
1947 tokens: u64,
1948 proposed_tokens: u64,
1949 budget_tokens: u64,
1950 outcome: &str,
1951 reason: Option<&str>,
1952 message_ids: Vec<String>,
1953 ) -> std::io::Result<()> {
1954 let event = ContextSeedEvent {
1955 event_type: "context_seed",
1956 event_id: new_event_id(),
1957 parent_id: self.task_parent(),
1958 session_id: self.session_id.clone(),
1959 timestamp: timestamp_ms(),
1960 task_id: self.active_task_id.clone(),
1961 hook_name: hook_name.to_string(),
1962 tokens,
1963 proposed_tokens,
1964 budget_tokens,
1965 outcome: outcome.to_string(),
1966 reason: reason.map(str::to_string),
1967 message_ids,
1968 };
1969 self.write_event(&event).await
1970 }
1971
1972 pub(crate) async fn write_retention(
1978 &mut self,
1979 store: &str,
1980 reason: &str,
1981 targets: Vec<String>,
1982 messages_dropped: Option<u64>,
1983 ) -> std::io::Result<()> {
1984 let event = RetentionEvent {
1985 event_type: "retention",
1986 event_id: new_event_id(),
1987 parent_id: self.session_parent(),
1988 session_id: self.session_id.clone(),
1989 timestamp: timestamp_ms(),
1990 store: store.to_string(),
1991 reason: reason.to_string(),
1992 removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
1993 targets,
1994 messages_dropped,
1995 };
1996 self.write_event(&event).await
1997 }
1998
1999 pub(crate) async fn write_call_denied(
2003 &mut self,
2004 turn: u32,
2005 event: &str,
2006 hook_name: &str,
2007 target: &str,
2008 reason: &str,
2009 ) -> std::io::Result<()> {
2010 let event = CallDeniedEvent {
2011 event_type: "call_denied",
2012 event_id: new_event_id(),
2013 parent_id: self.turn_parent(),
2014 session_id: self.session_id.clone(),
2015 timestamp: timestamp_ms(),
2016 turn,
2017 event: event.to_string(),
2018 hook_name: hook_name.to_string(),
2019 target: target.to_string(),
2020 reason: reason.to_string(),
2021 };
2022 self.write_event(&event).await
2023 }
2024
2025 pub(crate) async fn write_spend_ceiling_reached(
2028 &mut self,
2029 turn: u32,
2030 refusal: &crate::spend::SpendRefusal,
2031 origin: Option<&str>,
2032 ) -> std::io::Result<()> {
2033 let event = SpendCeilingReachedEvent {
2034 event_type: "spend_ceiling_reached",
2035 event_id: new_event_id(),
2036 parent_id: self.turn_parent(),
2037 session_id: self.session_id.clone(),
2038 timestamp: timestamp_ms(),
2039 turn,
2040 task_id: self.active_task_id.clone(),
2041 refusal,
2042 origin,
2043 };
2044 self.write_event(&event).await
2045 }
2046
2047 #[allow(clippy::too_many_arguments)]
2051 pub(crate) async fn write_protected_path_denied(
2052 &mut self,
2053 turn: u32,
2054 call: &str,
2055 target: &str,
2056 path: &str,
2057 rule: &str,
2058 signal: &str,
2059 reason: &str,
2060 ) -> std::io::Result<()> {
2061 let event = ProtectedPathDeniedEvent {
2062 event_type: "protected_path_denied",
2063 event_id: new_event_id(),
2064 parent_id: self.turn_parent(),
2065 session_id: self.session_id.clone(),
2066 timestamp: timestamp_ms(),
2067 turn,
2068 call: call.to_string(),
2069 target: target.to_string(),
2070 path: path.to_string(),
2071 rule: rule.to_string(),
2072 signal: signal.to_string(),
2073 reason: reason.to_string(),
2074 };
2075 self.write_event(&event).await
2076 }
2077
2078 pub(crate) async fn write_hook_dispatch_error(
2084 &mut self,
2085 hook_name: &str,
2086 event: &str,
2087 arm: &str,
2088 ) -> std::io::Result<()> {
2089 let event = HookDispatchErrorEvent {
2090 event_type: "hook_dispatch_error",
2091 event_id: new_event_id(),
2092 parent_id: self.session_parent(),
2093 session_id: self.session_id.clone(),
2094 timestamp: timestamp_ms(),
2095 hook_name: hook_name.to_string(),
2096 event: event.to_string(),
2097 arm: arm.to_string(),
2098 };
2099 self.write_event(&event).await
2100 }
2101
2102 pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
2103 let duration_ms = self
2104 .session_start_time
2105 .elapsed()
2106 .as_millis()
2107 .try_into()
2108 .unwrap_or(u64::MAX);
2109 let event = SessionEndEvent {
2110 event_type: "session_end",
2111 event_id: new_event_id(),
2112 parent_id: self.session_parent(),
2113 session_id: self.session_id.clone(),
2114 timestamp: timestamp_ms(),
2115 total_turns: self.total_turns,
2116 total_input_tokens: self.total_input_tokens,
2117 total_output_tokens: self.total_output_tokens,
2118 total_tool_calls: self.total_tool_calls,
2119 total_shell_calls: self.total_shell_calls,
2120 duration_ms,
2121 exit_status: exit_status.to_string(),
2122 };
2123 self.write_event(&event).await?;
2124 self.session_ended = true;
2125 Ok(())
2126 }
2127
2128 pub(crate) async fn write_session_end_if_not_ended(
2131 &mut self,
2132 exit_status: &str,
2133 ) -> std::io::Result<()> {
2134 if self.session_started && !self.session_ended {
2135 self.write_session_end(exit_status).await?;
2136 }
2137 Ok(())
2138 }
2139
2140 pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
2141 self.writer.flush().await
2142 }
2143
2144 async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
2152 let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
2153 line.push('\n');
2154 self.writer.write_all(line.as_bytes()).await?;
2155 self.writer.flush().await
2156 }
2157}
2158
2159#[derive(Serialize)]
2163struct InferenceCredentialEvent<'a> {
2164 event_type: &'static str,
2165 event_id: String,
2166 parent_id: Option<String>,
2167 session_id: String,
2168 timestamp: u64,
2169 source: &'static str,
2170 #[serde(skip_serializing_if = "Option::is_none")]
2171 credential: Option<&'a str>,
2172 change: &'static str,
2173 #[serde(skip_serializing_if = "Option::is_none")]
2174 trigger: Option<&'static str>,
2175 #[serde(skip_serializing_if = "Option::is_none")]
2176 status: Option<u16>,
2177 #[serde(skip_serializing_if = "Option::is_none")]
2178 retried: Option<bool>,
2179 #[serde(skip_serializing_if = "Option::is_none")]
2180 reason: Option<&'static str>,
2181}
2182
2183pub struct ResourceTraceAppender {
2198 file: tokio::sync::Mutex<File>,
2200 session_id: String,
2201 session_event_id: String,
2206}
2207
2208impl ResourceTraceAppender {
2209 pub async fn open(
2210 workdir: &Path,
2211 session_id: String,
2212 session_event_id: String,
2213 ) -> std::io::Result<Self> {
2214 let file = OpenOptions::new()
2215 .create(true)
2216 .append(true)
2217 .open(workdir.join("trace.jsonl"))
2218 .await?;
2219 Ok(Self {
2220 file: tokio::sync::Mutex::new(file),
2221 session_id,
2222 session_event_id,
2223 })
2224 }
2225
2226 #[allow(clippy::too_many_arguments)]
2230 pub(crate) async fn write_resource_list(
2231 &self,
2232 root: &str,
2233 entry_count: usize,
2234 total_bytes: u64,
2235 generation: u64,
2236 containment_achieved: ContainmentClass,
2237 outcome: &str,
2238 reason: Option<String>,
2239 ) {
2240 let event = ResourceListEvent {
2241 event_type: "resource_list",
2242 event_id: new_event_id(),
2243 parent_id: Some(self.session_event_id.clone()),
2244 session_id: self.session_id.clone(),
2245 timestamp: timestamp_ms(),
2246 root: root.to_string(),
2247 entry_count,
2248 total_bytes,
2249 generation,
2250 containment_achieved,
2251 outcome: outcome.to_string(),
2252 reason,
2253 };
2254 self.append(&event).await;
2255 }
2256
2257 #[allow(clippy::too_many_arguments)]
2260 pub(crate) async fn write_resource_read(
2261 &self,
2262 path: &str,
2263 outcome: &str,
2264 bytes: Option<u64>,
2265 sha256: Option<String>,
2266 generation: u64,
2267 containment_achieved: ContainmentClass,
2268 reason: Option<String>,
2269 ) {
2270 let event = ResourceReadEvent {
2271 event_type: "resource_read",
2272 event_id: new_event_id(),
2273 parent_id: Some(self.session_event_id.clone()),
2274 session_id: self.session_id.clone(),
2275 timestamp: timestamp_ms(),
2276 path: path.to_string(),
2277 outcome: outcome.to_string(),
2278 bytes,
2279 sha256,
2280 generation,
2281 containment_achieved,
2282 reason,
2283 };
2284 self.append(&event).await;
2285 }
2286
2287 #[allow(clippy::too_many_arguments)]
2290 pub(crate) async fn write_peer_handle_mint(
2291 &self,
2292 handle_id: Option<String>,
2293 path: &str,
2294 audience: &str,
2295 expires_at_ms: Option<u64>,
2296 outcome: &str,
2297 reason: Option<String>,
2298 ) {
2299 let event = PeerHandleMintEvent {
2300 event_type: "peer_handle_mint",
2301 event_id: new_event_id(),
2302 parent_id: Some(self.session_event_id.clone()),
2303 session_id: self.session_id.clone(),
2304 timestamp: timestamp_ms(),
2305 handle_id,
2306 path: path.to_string(),
2307 audience: audience.to_string(),
2308 expires_at_ms,
2309 outcome: outcome.to_string(),
2310 reason,
2311 };
2312 self.append(&event).await;
2313 }
2314
2315 #[allow(clippy::too_many_arguments)]
2318 pub(crate) async fn write_peer_handle_redeem(
2319 &self,
2320 handle_id: &str,
2321 path: Option<String>,
2322 generation: u64,
2323 audience_asserted: Option<String>,
2324 bytes: Option<u64>,
2325 sha256: Option<String>,
2326 outcome: &str,
2327 reason: Option<String>,
2328 ) {
2329 let event = PeerHandleRedeemEvent {
2330 event_type: "peer_handle_redeem",
2331 event_id: new_event_id(),
2332 parent_id: Some(self.session_event_id.clone()),
2333 session_id: self.session_id.clone(),
2334 timestamp: timestamp_ms(),
2335 handle_id: handle_id.to_string(),
2336 path,
2337 generation,
2338 audience_asserted,
2339 bytes,
2340 sha256,
2341 outcome: outcome.to_string(),
2342 reason,
2343 };
2344 self.append(&event).await;
2345 }
2346
2347 #[allow(clippy::too_many_arguments)]
2349 pub(crate) async fn write_peer_file_fetch(
2350 &self,
2351 peer: &str,
2352 handle_id: &str,
2353 stored_path: Option<String>,
2354 bytes: Option<u64>,
2355 sha256: Option<String>,
2356 outcome: &str,
2357 reason: Option<String>,
2358 ) {
2359 let event = PeerFileFetchEvent {
2360 event_type: "peer_file_fetch",
2361 event_id: new_event_id(),
2362 parent_id: Some(self.session_event_id.clone()),
2363 session_id: self.session_id.clone(),
2364 timestamp: timestamp_ms(),
2365 peer: peer.to_string(),
2366 handle_id: handle_id.to_string(),
2367 stored_path,
2368 bytes,
2369 sha256,
2370 outcome: outcome.to_string(),
2371 reason,
2372 };
2373 self.append(&event).await;
2374 }
2375
2376 pub(crate) async fn write_delegation_start(
2381 &self,
2382 delegation_id: &str,
2383 capsule: &str,
2384 version: &str,
2385 child_session_id: &str,
2386 child_workdir: &str,
2387 ) {
2388 let event = DelegationStartEvent::new(
2389 &self.session_id,
2390 &self.session_event_id,
2391 delegation_id,
2392 capsule,
2393 version,
2394 child_session_id,
2395 child_workdir,
2396 );
2397 self.append(&event).await;
2398 }
2399
2400 #[allow(clippy::too_many_arguments)]
2404 pub(crate) async fn write_delegation(
2405 &self,
2406 capsule: &str,
2407 version: &str,
2408 delegation_id: Option<String>,
2409 child_session_id: Option<String>,
2410 duration_ms: u64,
2411 outcome: &str,
2412 reason: Option<String>,
2413 ) {
2414 let event = DelegationEvent::new(
2415 &self.session_id,
2416 &self.session_event_id,
2417 capsule,
2418 version,
2419 delegation_id,
2420 child_session_id,
2421 duration_ms,
2422 outcome,
2423 reason,
2424 );
2425 self.append(&event).await;
2426 }
2427
2428 pub(crate) async fn write_inference_credential(
2431 &self,
2432 source: &'static str,
2433 credential: Option<&str>,
2434 change: CredentialChange,
2435 ) {
2436 let (change_name, trigger, status, retried, reason) = match change {
2437 CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
2438 CredentialChange::Rejected { status, retried } => {
2439 ("rejected", None, Some(status), Some(retried), None)
2440 }
2441 CredentialChange::Unreadable { reason } => {
2442 ("unreadable", None, None, None, Some(reason))
2443 }
2444 };
2445 let event = InferenceCredentialEvent {
2446 event_type: "inference_credential",
2447 event_id: new_event_id(),
2448 parent_id: Some(self.session_event_id.clone()),
2449 session_id: self.session_id.clone(),
2450 timestamp: timestamp_ms(),
2451 source,
2452 credential,
2453 change: change_name,
2454 trigger,
2455 status,
2456 retried,
2457 reason,
2458 };
2459 self.append(&event).await;
2460 }
2461
2462 async fn append(&self, event: &impl Serialize) {
2463 let Ok(mut line) = serde_json::to_string(event) else {
2464 return;
2465 };
2466 line.push('\n');
2467 let mut file = self.file.lock().await;
2468 if file.write_all(line.as_bytes()).await.is_ok() {
2469 let _ = file.flush().await;
2470 }
2471 }
2472}
2473
2474pub(crate) struct PriorSessionTraceAppender {
2489 file: File,
2490 session_id: String,
2492 session_event_id: Option<String>,
2494 terminate_torn_tail: bool,
2498}
2499
2500impl PriorSessionTraceAppender {
2501 pub(crate) async fn open(
2504 trace_path: &Path,
2505 session_id: String,
2506 session_event_id: Option<String>,
2507 ) -> std::io::Result<Self> {
2508 let terminate_torn_tail = !ends_with_newline(trace_path)?;
2509 let file = OpenOptions::new().append(true).open(trace_path).await?;
2510 Ok(Self {
2511 file,
2512 session_id,
2513 session_event_id,
2514 terminate_torn_tail,
2515 })
2516 }
2517
2518 pub(crate) async fn write_shell_lost(
2524 &mut self,
2525 work: &crate::detached::LostWork,
2526 reconciled_by_session: &str,
2527 reconciled_task_id: &str,
2528 ) -> std::io::Result<()> {
2529 let event = ShellLostEvent {
2530 event_type: "shell_lost",
2531 event_id: new_event_id(),
2532 parent_id: self.session_event_id.clone(),
2533 session_id: self.session_id.clone(),
2534 timestamp: timestamp_ms(),
2535 work_id: work.work_id.clone(),
2536 binary: work.binary.clone(),
2537 command: work.command.clone(),
2538 detached_at_ms: work.detached_at_ms,
2539 reconciled_by_session: reconciled_by_session.to_string(),
2540 reconciled_task_id: reconciled_task_id.to_string(),
2541 };
2542 let mut line = if self.terminate_torn_tail {
2543 "\n".to_string()
2544 } else {
2545 String::new()
2546 };
2547 line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
2548 line.push('\n');
2549 self.file.write_all(line.as_bytes()).await?;
2550 self.file.flush().await?;
2551 self.terminate_torn_tail = false;
2552 Ok(())
2553 }
2554}
2555
2556fn ends_with_newline(path: &Path) -> std::io::Result<bool> {
2559 use std::io::{Read, Seek, SeekFrom};
2560
2561 let mut file = std::fs::File::open(path)?;
2562 let length = file.metadata()?.len();
2563 if length == 0 {
2564 return Ok(true);
2565 }
2566 file.seek(SeekFrom::End(-1))?;
2567 let mut last = [0u8; 1];
2568 file.read_exact(&mut last)?;
2569 Ok(last[0] == b'\n')
2570}
2571
2572pub(crate) fn timestamp_ms() -> u64 {
2573 SystemTime::now()
2574 .duration_since(UNIX_EPOCH)
2575 .unwrap_or_default()
2576 .as_millis() as u64
2577}
2578
2579#[derive(Serialize, Clone)]
2586pub(crate) struct PlanStepShape {
2587 pub(crate) step_id: String,
2588 pub(crate) kind: &'static str,
2591 pub(crate) depends_on: Vec<String>,
2592 pub(crate) has_condition: bool,
2594}
2595
2596#[derive(Serialize)]
2599struct PlanStartEvent {
2600 event_type: &'static str,
2601 event_id: String,
2602 parent_id: Option<String>,
2603 session_id: String,
2604 timestamp: u64,
2605 plan_id: String,
2606 step_count: usize,
2607 steps: Vec<PlanStepShape>,
2608}
2609
2610#[derive(Serialize)]
2616struct PlanStepStartEvent {
2617 event_type: &'static str,
2618 event_id: String,
2619 parent_id: Option<String>,
2620 session_id: String,
2621 timestamp: u64,
2622 plan_id: String,
2623 step_id: String,
2624 kind: &'static str,
2625 depends_on: Vec<String>,
2626}
2627
2628#[derive(Serialize)]
2632struct PlanStepEvent {
2633 event_type: &'static str,
2634 event_id: String,
2635 parent_id: Option<String>,
2636 session_id: String,
2637 timestamp: u64,
2638 plan_id: String,
2639 step_id: String,
2640 kind: &'static str,
2641 status: &'static str,
2642 attempts: u32,
2643 duration_ms: u64,
2644 #[serde(skip_serializing_if = "Option::is_none")]
2645 error: Option<String>,
2646 #[serde(skip_serializing_if = "Option::is_none")]
2649 input: Option<Value>,
2650 #[serde(skip_serializing_if = "Option::is_none")]
2654 state_effect: Option<String>,
2655 #[serde(skip_serializing_if = "Option::is_none")]
2657 resource_id: Option<String>,
2658}
2659
2660#[derive(Serialize)]
2662struct PlanEndEvent {
2663 event_type: &'static str,
2664 event_id: String,
2665 parent_id: Option<String>,
2666 session_id: String,
2667 timestamp: u64,
2668 plan_id: String,
2669 outcome: &'static str,
2670 #[serde(skip_serializing_if = "Option::is_none")]
2671 failed_step: Option<String>,
2672 steps_total: usize,
2673 steps_succeeded: usize,
2674 steps_failed: usize,
2675 steps_skipped: usize,
2676 duration_ms: u64,
2677 #[serde(skip_serializing_if = "Option::is_none")]
2680 reason: Option<String>,
2681}
2682
2683pub(crate) struct PlanStepRecord {
2685 pub(crate) plan_id: String,
2686 pub(crate) step_id: String,
2687 pub(crate) kind: &'static str,
2688 pub(crate) status: &'static str,
2691 pub(crate) attempts: u32,
2693 pub(crate) duration_ms: u64,
2694 pub(crate) error: Option<String>,
2695 pub(crate) input: Option<Value>,
2696 pub(crate) state_effect: Option<String>,
2697 pub(crate) resource_id: Option<String>,
2698}
2699
2700pub(crate) struct PlanEndRecord {
2702 pub(crate) plan_id: String,
2703 pub(crate) outcome: &'static str,
2704 pub(crate) failed_step: Option<String>,
2705 pub(crate) steps_total: usize,
2706 pub(crate) steps_succeeded: usize,
2707 pub(crate) steps_failed: usize,
2708 pub(crate) steps_skipped: usize,
2709 pub(crate) duration_ms: u64,
2710 pub(crate) reason: Option<String>,
2711}
2712
2713pub struct PlanTraceAppender {
2724 file: std::sync::Mutex<std::fs::File>,
2725 session_id: String,
2726 session_event_id: String,
2729}
2730
2731impl PlanTraceAppender {
2732 pub fn open(
2733 workdir: &Path,
2734 session_id: String,
2735 session_event_id: String,
2736 ) -> std::io::Result<Self> {
2737 let file = std::fs::OpenOptions::new()
2738 .create(true)
2739 .append(true)
2740 .open(workdir.join("trace.jsonl"))?;
2741 Ok(Self {
2742 file: std::sync::Mutex::new(file),
2743 session_id,
2744 session_event_id,
2745 })
2746 }
2747
2748 pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
2750 let event_id = new_event_id();
2751 let event = PlanStartEvent {
2752 event_type: "plan_start",
2753 event_id: event_id.clone(),
2754 parent_id: Some(self.session_event_id.clone()),
2755 session_id: self.session_id.clone(),
2756 timestamp: timestamp_ms(),
2757 plan_id: plan_id.to_string(),
2758 step_count: steps.len(),
2759 steps,
2760 };
2761 self.append(&event);
2762 event_id
2763 }
2764
2765 pub(crate) fn write_plan_step_start(
2766 &self,
2767 plan_node: &str,
2768 plan_id: &str,
2769 step_id: &str,
2770 kind: &'static str,
2771 depends_on: Vec<String>,
2772 ) {
2773 let event = PlanStepStartEvent {
2774 event_type: "plan_step_start",
2775 event_id: new_event_id(),
2776 parent_id: Some(plan_node.to_string()),
2777 session_id: self.session_id.clone(),
2778 timestamp: timestamp_ms(),
2779 plan_id: plan_id.to_string(),
2780 step_id: step_id.to_string(),
2781 kind,
2782 depends_on,
2783 };
2784 self.append(&event);
2785 }
2786
2787 pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
2788 let input = record.input.map(|mut value| {
2791 crate::peer_handoff::redact_handles_in_json(&mut value);
2792 value
2793 });
2794 let event = PlanStepEvent {
2795 event_type: "plan_step",
2796 event_id: new_event_id(),
2797 parent_id: Some(plan_node.to_string()),
2798 session_id: self.session_id.clone(),
2799 timestamp: timestamp_ms(),
2800 plan_id: record.plan_id,
2801 step_id: record.step_id,
2802 kind: record.kind,
2803 status: record.status,
2804 attempts: record.attempts,
2805 duration_ms: record.duration_ms,
2806 error: record.error,
2807 input,
2808 state_effect: record.state_effect,
2809 resource_id: record.resource_id,
2810 };
2811 self.append(&event);
2812 }
2813
2814 pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
2817 let event = PlanEndEvent {
2818 event_type: "plan_end",
2819 event_id: new_event_id(),
2820 parent_id: Some(
2821 plan_node
2822 .map(str::to_string)
2823 .unwrap_or_else(|| self.session_event_id.clone()),
2824 ),
2825 session_id: self.session_id.clone(),
2826 timestamp: timestamp_ms(),
2827 plan_id: record.plan_id,
2828 outcome: record.outcome,
2829 failed_step: record.failed_step,
2830 steps_total: record.steps_total,
2831 steps_succeeded: record.steps_succeeded,
2832 steps_failed: record.steps_failed,
2833 steps_skipped: record.steps_skipped,
2834 duration_ms: record.duration_ms,
2835 reason: record.reason,
2836 };
2837 self.append(&event);
2838 }
2839
2840 pub(crate) fn write_delegation_start(
2846 &self,
2847 delegation_id: &str,
2848 capsule: &str,
2849 version: &str,
2850 child_session_id: &str,
2851 child_workdir: &str,
2852 ) {
2853 let event = DelegationStartEvent::new(
2854 &self.session_id,
2855 &self.session_event_id,
2856 delegation_id,
2857 capsule,
2858 version,
2859 child_session_id,
2860 child_workdir,
2861 );
2862 self.append(&event);
2863 }
2864
2865 #[allow(clippy::too_many_arguments)]
2873 pub(crate) fn write_delegation(
2874 &self,
2875 capsule: &str,
2876 version: &str,
2877 delegation_id: Option<String>,
2878 child_session_id: Option<String>,
2879 duration_ms: u64,
2880 outcome: &str,
2881 reason: Option<String>,
2882 ) {
2883 let event = DelegationEvent::new(
2884 &self.session_id,
2885 &self.session_event_id,
2886 capsule,
2887 version,
2888 delegation_id,
2889 child_session_id,
2890 duration_ms,
2891 outcome,
2892 reason,
2893 );
2894 self.append(&event);
2895 }
2896
2897 fn append(&self, event: &impl Serialize) {
2901 use std::io::Write as _;
2902
2903 let Ok(mut line) = serde_json::to_string(event) else {
2904 return;
2905 };
2906 line.push('\n');
2907 let Ok(mut file) = self.file.lock() else {
2908 return;
2909 };
2910 if file.write_all(line.as_bytes()).is_ok() {
2911 let _ = file.flush();
2912 }
2913 }
2914}
2915
2916#[cfg(test)]
2919mod tests {
2920 use super::*;
2921 use crate::{
2922 containment::scope_report_for_tier, sandbox::EnforcementTier, sealed::UsernsGrant,
2923 trace_blobs::BLOB_DIR_NAME, types::CapabilityPolicy,
2924 };
2925 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
2926 use serde_json::Value;
2927
2928 use crate::origin::TaskOrigin;
2929
2930 fn event_provenance() -> TaskProvenance {
2932 TaskProvenance::derive(TaskOrigin::Event, None)
2933 }
2934
2935 fn report_for(
2939 declared: ContainmentClass,
2940 tier: EnforcementTier,
2941 workdir_exec: bool,
2942 ) -> ScopeReport {
2943 scope_report_for_tier(
2944 &CapabilityPolicy {
2945 workdir_exec_allowed: workdir_exec,
2946 ..CapabilityPolicy::default()
2947 },
2948 declared,
2949 tier,
2950 None,
2951 None,
2952 None,
2953 Vec::new(),
2954 Vec::new(),
2955 Vec::new(),
2956 crate::cgroup::IoMaxReport::default(),
2957 )
2958 }
2959
2960 fn demotion_info() -> crate::detached::DetachedDispatchInfo {
2961 crate::detached::DetachedDispatchInfo {
2962 work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
2963 binary: "/usr/bin/bash".to_string(),
2964 command: "make -j8".to_string(),
2965 grace_ms: 1_000,
2966 }
2967 }
2968
2969 #[tokio::test]
2972 async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
2973 let dir = tempfile::tempdir().unwrap();
2974 std::os::unix::fs::symlink("/dev/full", dir.path().join("trace.jsonl")).unwrap();
2977 let mut writer = make_writer(dir.path()).await;
2978 let info = demotion_info();
2979
2980 assert!(
2981 writer
2982 .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
2983 .await
2984 .is_err(),
2985 "the sink under this writer must be unwritable for the claim below to mean anything"
2986 );
2987 crate::agent::record_demotion(&mut writer, 1, &info).await;
2988 }
2989
2990 #[tokio::test]
2992 async fn a_recorded_demotion_writes_only_its_own_marker() {
2993 let dir = tempfile::tempdir().unwrap();
2994 let mut writer = make_writer(dir.path()).await;
2995 crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
2996
2997 let events = read_events(dir.path());
2998 assert_eq!(events.len(), 1);
2999 assert_eq!(events[0]["event_type"], "shell_detached");
3000 assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
3001 assert_eq!(events[0]["turn"], 3);
3002 }
3003
3004 #[tokio::test]
3005 async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
3006 let dir = tempfile::tempdir().unwrap();
3007 let mut writer = make_writer(dir.path()).await;
3008 writer
3009 .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
3010 .await
3011 .unwrap();
3012
3013 let events = read_events(dir.path());
3014 assert_eq!(events.len(), 1);
3015 assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
3016 assert_eq!(events[0]["turn"], 2);
3017 assert_eq!(events[0]["work_id"], "wrk_0a1b");
3018 assert_eq!(events[0]["binary"], "/usr/bin/bash");
3019 assert_eq!(events[0]["reason"], "No space left");
3020 }
3021
3022 #[tokio::test]
3026 async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
3027 let dir = tempfile::tempdir().unwrap();
3028 let mut writer = make_writer(dir.path()).await;
3029 writer
3030 .write_shell_abandoned(
3031 "wrk_0a1b2c3d4e5f6a7b",
3032 "/usr/bin/bash",
3033 "sleep 45; echo done",
3034 45_012,
3035 None,
3036 )
3037 .await
3038 .unwrap();
3039
3040 let events = read_events(dir.path());
3041 assert_eq!(events.len(), 1);
3042 let event = &events[0];
3043 assert_eq!(event["event_type"], "shell_abandoned");
3044 assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
3045 assert_eq!(event["binary"], "/usr/bin/bash");
3046 assert_eq!(event["command"], "sleep 45; echo done");
3047 assert_eq!(event["running_ms"], 45_012);
3048 for absent in ["exit_code", "output_path", "output_bytes"] {
3049 assert!(
3050 event.get(absent).is_none(),
3051 "{absent} must be omitted, not written as null: {event}"
3052 );
3053 }
3054 }
3055
3056 #[tokio::test]
3060 async fn an_abandoned_command_that_finished_carries_its_result() {
3061 let dir = tempfile::tempdir().unwrap();
3062 let mut writer = make_writer(dir.path()).await;
3063 writer
3064 .write_shell_abandoned(
3065 "wrk_0a1b2c3d4e5f6a7b",
3066 "/usr/bin/bash",
3067 "sleep 2; echo done",
3068 2_014,
3069 Some((0, "logs/wrk_0a1b2c3d4e5f6a7b.log", 91)),
3070 )
3071 .await
3072 .unwrap();
3073
3074 let event = &read_events(dir.path())[0];
3075 assert_eq!(event["event_type"], "shell_abandoned");
3076 assert_eq!(event["running_ms"], 2_014);
3077 assert_eq!(event["exit_code"], 0);
3078 assert_eq!(event["output_path"], "logs/wrk_0a1b2c3d4e5f6a7b.log");
3079 assert_eq!(event["output_bytes"], 91);
3080 }
3081
3082 async fn make_writer(dir: &std::path::Path) -> TraceWriter {
3083 make_writer_with_opts(dir, TraceCapture::Meta).await
3084 }
3085
3086 async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
3087 make_writer_with_prompt(dir, capture, None, false).await
3088 }
3089
3090 async fn make_writer_with_prompt(
3091 dir: &std::path::Path,
3092 capture: TraceCapture,
3093 system_prompt: Option<&str>,
3094 system_prompt_overridden: bool,
3095 ) -> TraceWriter {
3096 TraceWriter::open(
3097 dir,
3098 "test-session-id".to_string(),
3099 "test-capsule".to_string(),
3100 "1.0.0".to_string(),
3101 "claude-test".to_string(),
3102 vec!["shell".to_string()],
3103 report_for(
3104 ContainmentClass::Advisory,
3105 EnforcementTier::EnvironmentOnly,
3106 false,
3107 ),
3108 capture,
3109 system_prompt.map(str::to_string),
3110 system_prompt_overridden,
3111 None,
3112 None,
3113 None,
3114 None,
3115 )
3116 .await
3117 .unwrap()
3118 }
3119
3120 fn read_events(dir: &std::path::Path) -> Vec<Value> {
3121 let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
3122 content
3123 .lines()
3124 .filter(|l| !l.is_empty())
3125 .map(|l| serde_json::from_str(l).unwrap())
3126 .collect()
3127 }
3128
3129 #[tokio::test]
3130 async fn session_start_fields() {
3131 let dir = tempfile::tempdir().unwrap();
3132 let mut w = make_writer(dir.path()).await;
3133 w.write_session_start(10, vec!["bash".to_string()])
3134 .await
3135 .unwrap();
3136 w.flush().await.unwrap();
3137
3138 let events = read_events(dir.path());
3139 assert_eq!(events.len(), 1);
3140 let e = &events[0];
3141 assert_eq!(e["event_type"], "session_start");
3142 assert_eq!(e["session_id"], "test-session-id");
3143 assert_eq!(e["capsule_name"], "test-capsule");
3144 assert_eq!(e["capsule_version"], "1.0.0");
3145 assert_eq!(e["model"], "claude-test");
3146 assert_eq!(e["max_turns"], 10);
3147 assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
3148 assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
3149 assert_eq!(e["containment_declared"], "advisory");
3150 assert_eq!(e["containment_achieved"], "advisory");
3151 assert_eq!(e["workdir_exec"], false);
3152 assert!(e["timestamp"].as_u64().unwrap() > 0);
3153 }
3154
3155 #[tokio::test]
3160 async fn session_start_records_system_prompt_source_and_hash() {
3161 let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
3162
3163 for (prompt, overridden, source, sha) in [
3164 (Some("Be terse."), false, "manifest", Some(&expected_sha)),
3165 (Some("Be terse."), true, "cli", Some(&expected_sha)),
3166 (None, false, "none", None),
3167 (None, true, "cli", None),
3170 ] {
3171 let dir = tempfile::tempdir().unwrap();
3172 let mut w =
3173 make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
3174 w.write_session_start(1, Vec::new()).await.unwrap();
3175 w.flush().await.unwrap();
3176
3177 let e = &read_events(dir.path())[0];
3178 assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
3179 match sha {
3180 Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
3181 None => assert!(
3182 e["system_prompt_sha256"].is_null(),
3183 "no prompt in effect must hash to null, got {}",
3184 e["system_prompt_sha256"]
3185 ),
3186 }
3187 }
3188 }
3189
3190 #[tokio::test]
3194 async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
3195 let sha = murmur_artifact::sha256_hex(b"Be terse.");
3196
3197 let dir = tempfile::tempdir().unwrap();
3198 let mut w =
3199 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
3200 w.write_session_start(1, Vec::new()).await.unwrap();
3201 w.flush().await.unwrap();
3202
3203 let e = &read_events(dir.path())[0];
3204 assert!(
3205 e.get("system_prompt").is_none(),
3206 "the verbatim prompt is not a session_start field, got {e}"
3207 );
3208 assert_eq!(e["system_prompt_sha256"], sha.as_str());
3209 assert!(
3210 !dir.path().join(BLOB_DIR_NAME).exists(),
3211 "meta stores no bodies"
3212 );
3213
3214 let opted_in = tempfile::tempdir().unwrap();
3215 let mut w = make_writer_with_prompt(
3216 opted_in.path(),
3217 TraceCapture::Content,
3218 Some("Be terse."),
3219 false,
3220 )
3221 .await;
3222 w.write_session_start(1, Vec::new()).await.unwrap();
3223 w.flush().await.unwrap();
3224
3225 let event = &read_events(opted_in.path())[0];
3226 assert!(event.get("system_prompt").is_none());
3227 assert_eq!(event["system_prompt_sha256"], sha.as_str());
3228 let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
3229 assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
3230 }
3231
3232 #[tokio::test]
3235 async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
3236 let dir = tempfile::tempdir().unwrap();
3237 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
3238 w.write_session_start(1, Vec::new()).await.unwrap();
3239 w.flush().await.unwrap();
3240
3241 let e = &read_events(dir.path())[0];
3242 assert!(e.get("system_prompt").is_none());
3243 assert!(e["system_prompt_sha256"].is_null());
3244 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
3245 }
3246
3247 #[tokio::test]
3249 async fn every_task_session_start_repeats_the_same_system_prompt_record() {
3250 let dir = tempfile::tempdir().unwrap();
3251 let mut w =
3252 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
3253 w.write_session_start(1, Vec::new()).await.unwrap();
3254 w.write_session_start(1, Vec::new()).await.unwrap();
3255 w.flush().await.unwrap();
3256
3257 let events = read_events(dir.path());
3258 assert_eq!(events.len(), 2);
3259 assert_eq!(
3260 events[0]["system_prompt_source"],
3261 events[1]["system_prompt_source"]
3262 );
3263 assert_eq!(
3264 events[0]["system_prompt_sha256"],
3265 events[1]["system_prompt_sha256"]
3266 );
3267 assert_eq!(events[1]["system_prompt_source"], "cli");
3268 }
3269
3270 #[tokio::test]
3275 async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
3276 let dir = tempfile::tempdir().unwrap();
3277 let mut w = TraceWriter::open(
3278 dir.path(),
3279 "test-session-id".to_string(),
3280 "test-capsule".to_string(),
3281 "1.0.0".to_string(),
3282 "claude-test".to_string(),
3283 Vec::new(),
3284 report_for(
3286 ContainmentClass::Advisory,
3287 EnforcementTier::KernelFull,
3288 true,
3289 ),
3290 TraceCapture::Meta,
3291 None,
3292 false,
3293 None,
3294 None,
3295 None,
3296 None,
3297 )
3298 .await
3299 .unwrap();
3300 w.write_session_start(1, Vec::new()).await.unwrap();
3301 w.flush().await.unwrap();
3302
3303 let events = read_events(dir.path());
3304 assert_eq!(events[0]["workdir_exec"], true);
3305 assert_eq!(events[0]["containment_achieved"], "advisory");
3306 }
3307
3308 #[tokio::test]
3311 async fn session_start_records_declared_and_achieved_containment_separately() {
3312 let dir = tempfile::tempdir().unwrap();
3313 let mut w = TraceWriter::open(
3314 dir.path(),
3315 "test-session-id".to_string(),
3316 "test-capsule".to_string(),
3317 "1.0.0".to_string(),
3318 "claude-test".to_string(),
3319 Vec::new(),
3320 report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
3321 TraceCapture::Meta,
3322 None,
3323 false,
3324 None,
3325 None,
3326 None,
3327 None,
3328 )
3329 .await
3330 .unwrap();
3331 w.write_session_start(1, Vec::new()).await.unwrap();
3332 w.flush().await.unwrap();
3333
3334 let events = read_events(dir.path());
3335 assert_eq!(events[0]["containment_declared"], "sealed");
3336 assert_eq!(events[0]["containment_achieved"], "scoped");
3337 }
3338
3339 #[tokio::test]
3347 async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
3348 async fn event_for(grant: UsernsGrant) -> Value {
3349 let report = scope_report_for_tier(
3350 &CapabilityPolicy::default(),
3351 ContainmentClass::Sealed,
3352 EnforcementTier::KernelSealed,
3353 None,
3354 Some(grant),
3355 None,
3356 Vec::new(),
3357 Vec::new(),
3358 Vec::new(),
3359 crate::cgroup::IoMaxReport::default(),
3360 );
3361 let dir = tempfile::tempdir().unwrap();
3362 let mut w = TraceWriter::open(
3363 dir.path(),
3364 "test-session-id".to_string(),
3365 "test-capsule".to_string(),
3366 "1.0.0".to_string(),
3367 "claude-test".to_string(),
3368 Vec::new(),
3369 report,
3370 TraceCapture::Meta,
3371 None,
3372 false,
3373 None,
3374 None,
3375 None,
3376 None,
3377 )
3378 .await
3379 .unwrap();
3380 w.write_session_start(1, Vec::new()).await.unwrap();
3381 w.flush().await.unwrap();
3382 let mut event = read_events(dir.path()).remove(0);
3383 event.as_object_mut().unwrap().remove("timestamp");
3386 event
3387 }
3388
3389 let through_profile = event_for(UsernsGrant::ProfileConfining).await;
3390 let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
3391
3392 assert_eq!(through_profile["containment_achieved"], "sealed");
3393 assert_eq!(host_wide["containment_achieved"], "sealed");
3394 assert_eq!(through_profile["userns_grant"], "profile_confining");
3395 assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
3396 assert_ne!(
3397 through_profile, host_wide,
3398 "two hosts granting the user namespace by different mechanisms must not write the \
3399 same session_start record"
3400 );
3401
3402 let unprobed = scope_report_for_tier(
3405 &CapabilityPolicy::default(),
3406 ContainmentClass::Advisory,
3407 EnforcementTier::EnvironmentOnly,
3408 None,
3409 None,
3410 None,
3411 Vec::new(),
3412 Vec::new(),
3413 Vec::new(),
3414 crate::cgroup::IoMaxReport::default(),
3415 );
3416 assert!(
3417 serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
3418 "off Linux the key is present and null, not absent"
3419 );
3420 }
3421
3422 #[tokio::test]
3430 async fn session_start_records_the_whole_scope_report_as_effective_grants() {
3431 let policy = CapabilityPolicy {
3432 network_allow: vec!["https://api.example.com".to_string()],
3433 unix_sockets_allowed: false,
3434 filesystem_scope: Some("workdir".to_string()),
3435 shell_allow: vec!["python3".to_string()],
3436 spawn_allow: vec!["helper".to_string()],
3437 env_allow: vec!["TZ".to_string()],
3438 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
3439 binary: "python3".to_string(),
3440 dirs: vec![InterpreterRuntimeDir {
3441 path: "/usr/lib/python3.11".to_string(),
3442 list_dir: true,
3443 }],
3444 }],
3445 ..CapabilityPolicy::default()
3446 };
3447 let expected = scope_report_for_tier(
3448 &policy,
3449 ContainmentClass::Scoped,
3450 EnforcementTier::KernelFull,
3451 None,
3452 None,
3453 None,
3454 vec![crate::containment::StateStoreReport {
3457 artifact: "notes-tool".to_string(),
3458 store: "shey".to_string(),
3459 host_path: "/home/dev/.murmur/state/shey".to_string(),
3460 }],
3461 vec!["config-echo".to_string()],
3463 vec![crate::containment::PreopenReport {
3464 artifact: "notes-tool".to_string(),
3465 role: "tool".to_string(),
3466 scope: None,
3467 surface: crate::containment::PreopenSurface::WholeWorkdir,
3468 }],
3469 crate::cgroup::IoMaxReport::default(),
3470 );
3471
3472 let dir = tempfile::tempdir().unwrap();
3473 let mut w = TraceWriter::open(
3474 dir.path(),
3475 "test-session-id".to_string(),
3476 "test-capsule".to_string(),
3477 "1.0.0".to_string(),
3478 "claude-test".to_string(),
3479 vec!["network".to_string(), "shell".to_string()],
3480 expected.clone(),
3481 TraceCapture::Meta,
3482 None,
3483 false,
3484 None,
3485 None,
3486 None,
3487 None,
3488 )
3489 .await
3490 .unwrap();
3491 w.write_session_start(1, Vec::new()).await.unwrap();
3492 w.flush().await.unwrap();
3493
3494 let events = read_events(dir.path());
3495 assert_eq!(
3496 events[0]["effective_grants"],
3497 serde_json::to_value(&expected).unwrap()
3498 );
3499
3500 assert_eq!(
3503 events[0]["capabilities"],
3504 serde_json::json!(["network", "shell"])
3505 );
3506 assert_eq!(
3507 events[0]["effective_grants"]["state_stores"],
3508 serde_json::json!([{
3509 "artifact": "notes-tool",
3510 "store": "shey",
3511 "host_path": "/home/dev/.murmur/state/shey",
3512 }])
3513 );
3514 assert_eq!(
3517 events[0]["effective_grants"]["configured_artifacts"],
3518 serde_json::json!(["config-echo"])
3519 );
3520 assert_eq!(
3524 events[0]["effective_grants"]["preopens"],
3525 serde_json::json!([{
3526 "artifact": "notes-tool",
3527 "role": "tool",
3528 "scope": null,
3529 "surface": "whole-workdir",
3530 }])
3531 );
3532 assert_eq!(events[0]["containment_declared"], "scoped");
3533 assert_eq!(events[0]["containment_achieved"], "scoped");
3534 assert_eq!(events[0]["workdir_exec"], false);
3535 assert_eq!(
3536 events[0]["effective_grants"]["network_allow"],
3537 serde_json::json!(["https://api.example.com"])
3538 );
3539 assert_eq!(
3540 events[0]["effective_grants"]["interpreter_runtime_grants"],
3541 serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
3542 );
3543 }
3544
3545 #[tokio::test]
3546 async fn inference_fields_and_snake_case() {
3547 let dir = tempfile::tempdir().unwrap();
3548 let mut w = make_writer(dir.path()).await;
3549 w.write_inference(
3550 0,
3551 100,
3552 50,
3553 "tool_call".to_string(),
3554 Some("tool_call"),
3555 Some("bash".to_string()),
3556 None,
3557 None,
3558 vec!["msg_0000000000000000000000000000000a".to_string()],
3559 None,
3560 )
3561 .await
3562 .unwrap();
3563 w.flush().await.unwrap();
3564
3565 let events = read_events(dir.path());
3566 let e = &events[0];
3567 assert_eq!(e["event_type"], "inference");
3568 assert_eq!(
3569 e["message_ids"],
3570 serde_json::json!(["msg_0000000000000000000000000000000a"]),
3571 "an agent-loop turn names the messages its request embedded"
3572 );
3573 assert_eq!(e["turn"], 0);
3574 assert_eq!(e["input_tokens"], 100);
3575 assert_eq!(e["output_tokens"], 50);
3576 assert_eq!(e["decision"], "tool_call");
3577 assert_eq!(e["tool_name"], "bash");
3578 assert!(e.get("inputTokens").is_none(), "must use snake_case");
3580 assert!(e.get("toolName").is_none(), "must use snake_case");
3581 for key in [
3583 "input_tokens_actual",
3584 "output_tokens_actual",
3585 "cached_tokens",
3586 "cache_write_tokens",
3587 ] {
3588 assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
3589 }
3590 }
3591
3592 #[tokio::test]
3595 async fn inference_writes_only_the_reported_usage_members() {
3596 let dir = tempfile::tempdir().unwrap();
3597 let mut w = make_writer(dir.path()).await;
3598 let usage = DriverUsage {
3599 input_tokens: Some(12043),
3600 output_tokens: None,
3601 cached_tokens: Some(0),
3602 cache_write_tokens: None,
3603 };
3604 w.write_inference(
3605 0,
3606 100,
3607 50,
3608 "end_turn".to_string(),
3609 Some("end_turn"),
3610 None,
3611 None,
3612 Some(&usage),
3613 Vec::new(),
3614 None,
3615 )
3616 .await
3617 .unwrap();
3618 w.flush().await.unwrap();
3619
3620 let events = read_events(dir.path());
3621 let e = &events[0];
3622 assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
3623 assert_eq!(e["input_tokens_actual"], 12043);
3624 assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
3625 assert!(e.get("output_tokens_actual").is_none());
3626 assert!(e.get("cache_write_tokens").is_none());
3627 }
3628
3629 #[tokio::test]
3630 async fn tool_call_fields() {
3631 let dir = tempfile::tempdir().unwrap();
3632 let mut w = make_writer(dir.path()).await;
3633 w.write_tool_call(
3634 1,
3635 "bash".to_string(),
3636 None,
3637 serde_json::json!({"command": "echo hi"}),
3638 42,
3639 "hi\n",
3640 100,
3641 55,
3642 "ok".to_string(),
3643 None,
3644 None,
3645 )
3646 .await
3647 .unwrap();
3648 w.flush().await.unwrap();
3649
3650 let events = read_events(dir.path());
3651 let e = &events[0];
3652 assert_eq!(e["event_type"], "tool_call");
3653 assert_eq!(e["turn"], 1);
3654 assert_eq!(e["tool_name"], "bash");
3655 assert_eq!(e["input_bytes"], 42);
3656 assert_eq!(e["output_bytes"], 100);
3657 assert_eq!(e["duration_ms"], 55);
3658 assert_eq!(e["status"], "ok");
3659 assert!(
3660 e.get("state_effect").is_none(),
3661 "state_effect must be omitted when the tool declared nothing"
3662 );
3663 }
3664
3665 #[tokio::test]
3666 async fn tool_call_records_declared_state_effect() {
3667 let dir = tempfile::tempdir().unwrap();
3668 let mut w = make_writer(dir.path()).await;
3669 w.write_tool_call(
3670 0,
3671 "murmur-tool-editor".to_string(),
3672 None,
3673 serde_json::json!({"operation": "read_file", "path": "a.txt"}),
3674 10,
3675 "hi\n",
3676 3,
3677 5,
3678 "ok".to_string(),
3679 Some("read".to_string()),
3680 None,
3681 )
3682 .await
3683 .unwrap();
3684 w.flush().await.unwrap();
3685
3686 let events = read_events(dir.path());
3687 assert_eq!(
3688 events[0]["state_effect"], "read",
3689 "declared state_effect must be recorded verbatim on the tool_call event"
3690 );
3691 }
3692
3693 #[tokio::test]
3694 async fn tool_call_records_declared_resource_id() {
3695 let dir = tempfile::tempdir().unwrap();
3696 let mut w = make_writer(dir.path()).await;
3697 w.write_tool_call(
3698 0,
3699 "murmur-tool-code-graph".to_string(),
3700 None,
3701 serde_json::json!({"symbol": "Foo::bar"}),
3702 10,
3703 "hi\n",
3704 3,
3705 5,
3706 "ok".to_string(),
3707 Some("read".to_string()),
3708 Some("sym:Foo".to_string()),
3709 )
3710 .await
3711 .unwrap();
3712 w.flush().await.unwrap();
3713
3714 let events = read_events(dir.path());
3715 assert_eq!(
3716 events[0]["resource_id"], "sym:Foo",
3717 "declared resource_id must reach trace.jsonl verbatim, unparsed"
3718 );
3719 }
3720
3721 #[tokio::test]
3722 async fn tool_call_omits_undeclared_resource_id() {
3723 let dir = tempfile::tempdir().unwrap();
3724 let mut w = make_writer(dir.path()).await;
3725 w.write_tool_call(
3726 0,
3727 "bash".to_string(),
3728 None,
3729 serde_json::json!({"command": "echo hi"}),
3730 10,
3731 "hi\n",
3732 3,
3733 5,
3734 "ok".to_string(),
3735 None,
3736 None,
3737 )
3738 .await
3739 .unwrap();
3740 w.flush().await.unwrap();
3741
3742 let events = read_events(dir.path());
3743 assert!(
3744 events[0].get("resource_id").is_none(),
3745 "resource_id must be omitted entirely (not null) when the tool declared nothing"
3746 );
3747 }
3748
3749 #[tokio::test]
3750 async fn shell_fields() {
3751 let dir = tempfile::tempdir().unwrap();
3752 let mut w = make_writer(dir.path()).await;
3753 w.write_shell(
3754 0,
3755 "/usr/bin/echo".to_string(),
3756 "echo hi".to_string(),
3757 0,
3758 7,
3759 0,
3760 10,
3761 None,
3762 )
3763 .await
3764 .unwrap();
3765 w.flush().await.unwrap();
3766
3767 let events = read_events(dir.path());
3768 let e = &events[0];
3769 assert_eq!(e["event_type"], "shell");
3770 assert_eq!(e["binary"], "/usr/bin/echo");
3773 assert_eq!(e["command"], "echo hi");
3774 assert_eq!(e["exit_code"], 0);
3775 assert_eq!(e["stdout_bytes"], 7);
3776 assert_eq!(e["stderr_bytes"], 0);
3777 assert_eq!(e["duration_ms"], 10);
3778 }
3779
3780 #[tokio::test]
3781 async fn compaction_fields() {
3782 let dir = tempfile::tempdir().unwrap();
3783 let mut w = make_writer(dir.path()).await;
3784 w.write_compaction(3, 80000, 20000).await.unwrap();
3785 w.flush().await.unwrap();
3786
3787 let events = read_events(dir.path());
3788 let e = &events[0];
3789 assert_eq!(e["event_type"], "compaction");
3790 assert_eq!(e["turn"], 3);
3791 assert_eq!(e["tokens_before"], 80000);
3792 assert_eq!(e["tokens_after"], 20000);
3793 }
3794
3795 #[tokio::test]
3796 async fn hook_dispatch_error_fields() {
3797 let dir = tempfile::tempdir().unwrap();
3798 let mut w = make_writer(dir.path()).await;
3799 w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
3800 .await
3801 .unwrap();
3802 w.flush().await.unwrap();
3803
3804 let events = read_events(dir.path());
3805 let e = &events[0];
3806 assert_eq!(e["event_type"], "hook_dispatch_error");
3807 assert_eq!(e["session_id"], "test-session-id");
3808 assert_eq!(e["hook_name"], "my-hook");
3809 assert_eq!(e["event"], "on-tool-call");
3810 assert_eq!(e["arm"], "write-manifests");
3811 assert!(e["timestamp"].as_u64().unwrap() > 0);
3812 assert!(e.get("hookName").is_none(), "must use snake_case");
3814 }
3815
3816 #[tokio::test]
3817 async fn session_end_fields_and_snake_case() {
3818 let dir = tempfile::tempdir().unwrap();
3819 let mut w = make_writer(dir.path()).await;
3820 w.write_inference(
3821 0,
3822 100,
3823 50,
3824 "tool_call".to_string(),
3825 Some("tool_call"),
3826 None,
3827 None,
3828 None,
3829 Vec::new(),
3830 None,
3831 )
3832 .await
3833 .unwrap();
3834 w.write_tool_call(
3835 0,
3836 "bash".to_string(),
3837 None,
3838 serde_json::json!({"command": "ls"}),
3839 10,
3840 "file.txt\n",
3841 20,
3842 5,
3843 "ok".to_string(),
3844 None,
3845 None,
3846 )
3847 .await
3848 .unwrap();
3849 w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
3850 .await
3851 .unwrap();
3852 w.write_session_end("ok").await.unwrap();
3853 w.flush().await.unwrap();
3854
3855 let events = read_events(dir.path());
3856 let se = events.last().unwrap();
3857 assert_eq!(se["event_type"], "session_end");
3858 assert_eq!(se["total_turns"], 1);
3859 assert_eq!(se["total_input_tokens"], 100);
3860 assert_eq!(se["total_output_tokens"], 50);
3861 assert_eq!(se["total_tool_calls"], 1);
3862 assert_eq!(se["total_shell_calls"], 1);
3863 assert_eq!(se["exit_status"], "ok");
3864 assert!(se["duration_ms"].as_u64().is_some());
3865 assert!(se.get("exitStatus").is_none(), "must use snake_case");
3867 assert!(se.get("totalTurns").is_none(), "must use snake_case");
3868 }
3869
3870 #[tokio::test]
3871 async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
3872 let dir = tempfile::tempdir().unwrap();
3873 let mut w = make_writer(dir.path()).await;
3874 w.write_session_start(10, vec![]).await.unwrap();
3875 w.write_session_end("ok").await.unwrap();
3876 w.write_session_end_if_not_ended("failed").await.unwrap();
3877 w.flush().await.unwrap();
3878
3879 let events = read_events(dir.path());
3880 assert_eq!(
3881 events
3882 .iter()
3883 .filter(|e| e["event_type"] == "session_end")
3884 .count(),
3885 1,
3886 "session_end should appear exactly once"
3887 );
3888 }
3889
3890 #[tokio::test]
3891 async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
3892 let dir = tempfile::tempdir().unwrap();
3893 let mut w = make_writer(dir.path()).await;
3894 w.write_session_start(10, vec![]).await.unwrap();
3895 w.write_session_end_if_not_ended("failed").await.unwrap();
3897 w.flush().await.unwrap();
3898
3899 let events = read_events(dir.path());
3900 let se = events
3901 .iter()
3902 .find(|e| e["event_type"] == "session_end")
3903 .unwrap();
3904 assert_eq!(se["exit_status"], "failed");
3905 }
3906
3907 #[tokio::test]
3908 async fn no_session_end_written_when_session_never_started() {
3909 let dir = tempfile::tempdir().unwrap();
3910 let mut w = make_writer(dir.path()).await;
3911 w.write_session_end_if_not_ended("failed").await.unwrap();
3913 w.flush().await.unwrap();
3914
3915 let events = read_events(dir.path());
3916 assert!(
3917 events.iter().all(|e| e["event_type"] != "session_end"),
3918 "session_end must not appear when session_start was never written"
3919 );
3920 }
3921
3922 #[tokio::test]
3923 async fn session_id_on_every_event() {
3924 let dir = tempfile::tempdir().unwrap();
3925 let mut w = make_writer(dir.path()).await;
3926 w.write_session_start(10, vec!["bash".to_string()])
3927 .await
3928 .unwrap();
3929 w.write_inference(
3930 0,
3931 10,
3932 5,
3933 "end_turn".to_string(),
3934 Some("end_turn"),
3935 None,
3936 None,
3937 None,
3938 Vec::new(),
3939 None,
3940 )
3941 .await
3942 .unwrap();
3943 w.write_session_end("ok").await.unwrap();
3944 w.flush().await.unwrap();
3945
3946 let events = read_events(dir.path());
3947 for e in &events {
3948 assert_eq!(e["session_id"], "test-session-id");
3949 }
3950 }
3951
3952 #[tokio::test]
3953 async fn counts_match_per_event_sum() {
3954 let dir = tempfile::tempdir().unwrap();
3955 let mut w = make_writer(dir.path()).await;
3956 w.write_session_start(10, vec![]).await.unwrap();
3957 w.write_inference(
3958 0,
3959 50,
3960 25,
3961 "tool_call".to_string(),
3962 Some("tool_call"),
3963 Some("bash".to_string()),
3964 None,
3965 None,
3966 vec!["msg_0000000000000000000000000000000a".to_string()],
3967 None,
3968 )
3969 .await
3970 .unwrap();
3971 w.write_tool_call(
3972 0,
3973 "bash".to_string(),
3974 None,
3975 serde_json::json!({"command": "echo"}),
3976 5,
3977 "ok",
3978 10,
3979 3,
3980 "ok".to_string(),
3981 None,
3982 None,
3983 )
3984 .await
3985 .unwrap();
3986 w.write_shell(
3987 0,
3988 "/bin/echo".to_string(),
3989 "echo".to_string(),
3990 0,
3991 4,
3992 0,
3993 1,
3994 None,
3995 )
3996 .await
3997 .unwrap();
3998 w.write_inference(
3999 1,
4000 60,
4001 30,
4002 "end_turn".to_string(),
4003 Some("end_turn"),
4004 None,
4005 None,
4006 None,
4007 Vec::new(),
4008 None,
4009 )
4010 .await
4011 .unwrap();
4012 w.write_session_end("ok").await.unwrap();
4013 w.flush().await.unwrap();
4014
4015 let events = read_events(dir.path());
4016 let inference_count = events
4017 .iter()
4018 .filter(|e| e["event_type"] == "inference")
4019 .count();
4020 let tool_count = events
4021 .iter()
4022 .filter(|e| e["event_type"] == "tool_call")
4023 .count();
4024 let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
4025
4026 let se = events
4027 .iter()
4028 .find(|e| e["event_type"] == "session_end")
4029 .unwrap();
4030 assert_eq!(
4031 se["total_turns"].as_u64().unwrap() as usize,
4032 inference_count
4033 );
4034 assert_eq!(
4035 se["total_tool_calls"].as_u64().unwrap() as usize,
4036 tool_count
4037 );
4038 assert_eq!(
4039 se["total_shell_calls"].as_u64().unwrap() as usize,
4040 shell_count
4041 );
4042 }
4043
4044 #[tokio::test]
4045 async fn tool_call_input_always_present() {
4046 let dir = tempfile::tempdir().unwrap();
4047 let mut w = make_writer(dir.path()).await; w.write_tool_call(
4049 0,
4050 "bash".to_string(),
4051 None,
4052 serde_json::json!({"command": "ls -la"}),
4053 20,
4054 "total 8\n",
4055 80,
4056 12,
4057 "ok".to_string(),
4058 None,
4059 None,
4060 )
4061 .await
4062 .unwrap();
4063 w.flush().await.unwrap();
4064
4065 let events = read_events(dir.path());
4066 let e = &events[0];
4067 assert_eq!(e["event_type"], "tool_call");
4068 assert_eq!(e["input"]["command"], "ls -la");
4069 assert!(e.get("input").is_some(), "input must always be present");
4070 }
4071
4072 #[tokio::test]
4073 async fn tool_call_output_absent_by_default() {
4074 let dir = tempfile::tempdir().unwrap();
4075 let mut w = make_writer(dir.path()).await; w.write_tool_call(
4077 0,
4078 "bash".to_string(),
4079 None,
4080 serde_json::json!({"command": "ls"}),
4081 10,
4082 "file.txt\n",
4083 90,
4084 8,
4085 "ok".to_string(),
4086 None,
4087 None,
4088 )
4089 .await
4090 .unwrap();
4091 w.flush().await.unwrap();
4092
4093 let events = read_events(dir.path());
4094 let e = &events[0];
4095 assert!(
4096 e.get("output").is_none(),
4097 "output must be absent under a capture mode that stores no bodies"
4098 );
4099 assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
4100 }
4101
4102 #[tokio::test]
4103 async fn skill_call_writes_skill_call_event() {
4104 let dir = tempfile::tempdir().unwrap();
4105 let mut w = make_writer(dir.path()).await;
4106 w.write_skill_call(2, "my-skill".to_string(), 1024, 8, "ok".to_string())
4107 .await
4108 .unwrap();
4109 w.flush().await.unwrap();
4110
4111 let events = read_events(dir.path());
4112 let e = &events[0];
4113 assert_eq!(e["event_type"], "skill_call");
4114 assert_eq!(e["turn"], 2);
4115 assert_eq!(e["skill_name"], "my-skill");
4116 assert_eq!(e["output_bytes"], 1024);
4117 assert_eq!(e["duration_ms"], 8);
4118 assert_eq!(e["status"], "ok");
4119 }
4120
4121 #[tokio::test]
4122 async fn skill_call_does_not_increment_total_tool_calls() {
4123 let dir = tempfile::tempdir().unwrap();
4124 let mut w = make_writer(dir.path()).await;
4125 w.write_session_start(10, vec![]).await.unwrap();
4126 w.write_tool_call(
4127 0,
4128 "real-tool".to_string(),
4129 None,
4130 serde_json::json!({}),
4131 2,
4132 "data",
4133 4,
4134 5,
4135 "ok".to_string(),
4136 None,
4137 None,
4138 )
4139 .await
4140 .unwrap();
4141 w.write_skill_call(1, "my-skill".to_string(), 512, 3, "ok".to_string())
4142 .await
4143 .unwrap();
4144 w.write_session_end("ok").await.unwrap();
4145 w.flush().await.unwrap();
4146
4147 let events = read_events(dir.path());
4148 let se = events
4149 .iter()
4150 .find(|e| e["event_type"] == "session_end")
4151 .unwrap();
4152 assert_eq!(
4153 se["total_tool_calls"], 1,
4154 "skill_call must not inflate total_tool_calls"
4155 );
4156 }
4157
4158 #[tokio::test]
4159 async fn tool_call_output_present_when_opted_in() {
4160 let dir = tempfile::tempdir().unwrap();
4161 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4162 w.write_tool_call(
4163 0,
4164 "bash".to_string(),
4165 None,
4166 serde_json::json!({"command": "echo hello"}),
4167 22,
4168 "hello\n",
4169 60,
4170 5,
4171 "ok".to_string(),
4172 None,
4173 None,
4174 )
4175 .await
4176 .unwrap();
4177 w.flush().await.unwrap();
4178
4179 let events = read_events(dir.path());
4180 let e = &events[0];
4181 assert_eq!(
4182 e["output"].as_str().unwrap(),
4183 "hello\n",
4184 "output must be present under capture: content"
4185 );
4186 assert_eq!(e["output_bytes"], 60);
4187 }
4188
4189 #[tokio::test]
4190 async fn task_end_carries_reopen_count() {
4191 let dir = tempfile::tempdir().unwrap();
4192 let mut w = make_writer(dir.path()).await;
4193 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4194 .await
4195 .unwrap();
4196 w.write_task_end("tsk_1", "ok", 2).await.unwrap();
4197 w.flush().await.unwrap();
4198
4199 let events = read_events(dir.path());
4200 let end = events
4201 .iter()
4202 .find(|e| e["event_type"] == "task_end")
4203 .unwrap();
4204 assert_eq!(end["task_id"], "tsk_1");
4205 assert_eq!(end["exit_status"], "ok");
4206 assert_eq!(end["reopen_count"], 2);
4207 }
4208
4209 #[tokio::test]
4210 async fn task_reopened_names_hook_reason_and_ordinal() {
4211 let dir = tempfile::tempdir().unwrap();
4212 let mut w = make_writer(dir.path()).await;
4213 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4214 .await
4215 .unwrap();
4216 w.write_task_reopened("tsk_1", "gatekeeper", "tests still fail", 1)
4217 .await
4218 .unwrap();
4219 w.flush().await.unwrap();
4220
4221 let events = read_events(dir.path());
4222 let re = events
4223 .iter()
4224 .find(|e| e["event_type"] == "task_reopened")
4225 .unwrap();
4226 assert_eq!(re["task_id"], "tsk_1");
4227 assert_eq!(re["hook_name"], "gatekeeper");
4228 assert_eq!(re["reason"], "tests still fail");
4229 assert_eq!(re["reopen_number"], 1);
4230 assert!(re["timestamp"].as_u64().unwrap() > 0);
4231 }
4232
4233 #[tokio::test]
4237 async fn task_turns_accessor_tracks_and_resets() {
4238 let dir = tempfile::tempdir().unwrap();
4239 let mut w = make_writer(dir.path()).await;
4240 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4241 .await
4242 .unwrap();
4243 assert_eq!(w.task_turns(), 0);
4244 w.write_inference(
4245 0,
4246 10,
4247 5,
4248 "end_turn".to_string(),
4249 Some("end_turn"),
4250 None,
4251 None,
4252 None,
4253 Vec::new(),
4254 None,
4255 )
4256 .await
4257 .unwrap();
4258 w.write_inference(
4259 1,
4260 10,
4261 5,
4262 "end_turn".to_string(),
4263 Some("end_turn"),
4264 None,
4265 None,
4266 None,
4267 Vec::new(),
4268 None,
4269 )
4270 .await
4271 .unwrap();
4272 assert_eq!(w.task_turns(), 2);
4273 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), None, 3)
4275 .await
4276 .unwrap();
4277 assert_eq!(w.task_turns(), 0);
4278 }
4279
4280 #[tokio::test]
4286 async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
4287 let dir = tempfile::tempdir().unwrap();
4288 let mut w = make_writer(dir.path()).await;
4289 w.write_session_start(10, vec!["bash".to_string()])
4290 .await
4291 .unwrap();
4292 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4293 .await
4294 .unwrap();
4295 w.write_inference(
4296 0,
4297 10,
4298 5,
4299 "tool_call".to_string(),
4300 Some("tool_call"),
4301 None,
4302 None,
4303 None,
4304 Vec::new(),
4305 None,
4306 )
4307 .await
4308 .unwrap();
4309 w.write_tool_call(
4310 0,
4311 "bash".to_string(),
4312 Some("toolu_1".to_string()),
4313 serde_json::json!({"command": "echo hi"}),
4314 10,
4315 "hi\n",
4316 3,
4317 5,
4318 "ok".to_string(),
4319 None,
4320 None,
4321 )
4322 .await
4323 .unwrap();
4324 w.write_shell(
4325 0,
4326 "/bin/bash".to_string(),
4327 "echo hi".to_string(),
4328 0,
4329 3,
4330 0,
4331 2,
4332 None,
4333 )
4334 .await
4335 .unwrap();
4336 w.write_task_end("tsk_1", "ok", 0).await.unwrap();
4337 w.write_session_end("ok").await.unwrap();
4338 w.flush().await.unwrap();
4339
4340 let events = read_events(dir.path());
4341 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
4342 for (i, event) in events.iter().enumerate() {
4343 let id = event["event_id"].as_str().unwrap();
4344 assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
4345 assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
4346 assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
4347 assert!(
4348 event.get("parent_id").is_some(),
4349 "event {i} has no parent_id"
4350 );
4351 if let Some(parent) = event["parent_id"].as_str() {
4352 assert!(seen.contains(parent), "event {i} names dangling {parent}");
4353 }
4354 }
4355
4356 let by_type = |ty: &str| {
4357 events
4358 .iter()
4359 .find(|e| e["event_type"] == ty)
4360 .unwrap_or_else(|| panic!("no {ty}"))
4361 .clone()
4362 };
4363 let session = by_type("session_start");
4364 assert!(session["parent_id"].is_null());
4365 assert_eq!(session["event_id"], w.session_event_id());
4366
4367 let task = by_type("task_start");
4368 assert_eq!(task["parent_id"], session["event_id"]);
4369
4370 let turn = by_type("inference");
4371 assert_eq!(turn["parent_id"], task["event_id"]);
4372
4373 assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
4374 assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
4375 assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
4376 assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
4377 }
4378
4379 #[tokio::test]
4383 async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
4384 let dir = tempfile::tempdir().unwrap();
4385 let mut w = make_writer(dir.path()).await;
4386 w.write_session_start(10, Vec::new()).await.unwrap();
4387 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4388 .await
4389 .unwrap();
4390 w.write_inference(
4391 0,
4392 10,
4393 5,
4394 "tool_call".to_string(),
4395 Some("tool_call"),
4396 None,
4397 None,
4398 None,
4399 Vec::new(),
4400 None,
4401 )
4402 .await
4403 .unwrap();
4404 let origin = InferenceOrigin {
4405 source: "hook:gatekeeper".to_string(),
4406 model: "claude-test".to_string(),
4407 };
4408 w.write_inference(
4409 0,
4410 1,
4411 1,
4412 "end_turn".to_string(),
4413 None,
4415 None,
4416 Some(&origin),
4417 None,
4418 Vec::new(),
4419 None,
4420 )
4421 .await
4422 .unwrap();
4423 w.flush().await.unwrap();
4424
4425 let events = read_events(dir.path());
4426 let turn = &events[2];
4427 let hook = &events[3];
4428 assert!(turn["origin"].is_null());
4429 assert_eq!(hook["origin"], "hook:gatekeeper");
4430 assert_eq!(hook["parent_id"], turn["event_id"]);
4431 assert!(hook.get("message_ids").is_none(), "{hook}");
4434 }
4435
4436 #[tokio::test]
4440 async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
4441 let bare = tempfile::tempdir().unwrap();
4442 let mut w = make_writer(bare.path()).await;
4443 w.write_a2a_send(
4444 "http://peer",
4445 "msg_1",
4446 "tsk_1",
4447 "ctx_1",
4448 None,
4449 TrustClass::Untrusted,
4450 )
4451 .await
4452 .unwrap();
4453 w.flush().await.unwrap();
4454 assert!(read_events(bare.path())[0]["parent_id"].is_null());
4455
4456 let framed = tempfile::tempdir().unwrap();
4457 let mut w = make_writer(framed.path()).await;
4458 w.write_session_start(1, Vec::new()).await.unwrap();
4459 w.write_a2a_send(
4460 "http://peer",
4461 "msg_1",
4462 "tsk_1",
4463 "ctx_1",
4464 None,
4465 TrustClass::Untrusted,
4466 )
4467 .await
4468 .unwrap();
4469 w.flush().await.unwrap();
4470 let events = read_events(framed.path());
4471 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
4472 }
4473
4474 #[tokio::test]
4477 async fn turn_level_events_carry_the_active_task_id() {
4478 let dir = tempfile::tempdir().unwrap();
4479 let mut w = make_writer(dir.path()).await;
4480 w.write_session_start(10, Vec::new()).await.unwrap();
4481 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4482 .await
4483 .unwrap();
4484 w.write_inference(
4485 0,
4486 10,
4487 5,
4488 "tool_call".to_string(),
4489 Some("tool_call"),
4490 None,
4491 None,
4492 None,
4493 Vec::new(),
4494 None,
4495 )
4496 .await
4497 .unwrap();
4498 w.write_skill_call(0, "house-style".to_string(), 12, 1, "ok".to_string())
4499 .await
4500 .unwrap();
4501 w.write_compaction(0, 100, 40).await.unwrap();
4502 w.write_task_end("tsk_1", "ok", 0).await.unwrap();
4503 w.write_inference(
4504 1,
4505 1,
4506 1,
4507 "end_turn".to_string(),
4508 Some("end_turn"),
4509 None,
4510 None,
4511 None,
4512 Vec::new(),
4513 None,
4514 )
4515 .await
4516 .unwrap();
4517 w.flush().await.unwrap();
4518
4519 let events = read_events(dir.path());
4520 for event in events
4521 .iter()
4522 .filter(|e| {
4523 matches!(
4524 e["event_type"].as_str(),
4525 Some("inference" | "skill_call" | "compaction")
4526 )
4527 })
4528 .take(3)
4529 {
4530 assert_eq!(event["task_id"], "tsk_1", "{event}");
4531 }
4532 let after = events.last().unwrap();
4533 assert_eq!(after["event_type"], "inference");
4534 assert!(
4535 after["task_id"].is_null(),
4536 "an inference outside any task carries a null task_id, not the last task's"
4537 );
4538 assert_eq!(after["parent_id"], events[0]["event_id"]);
4540 }
4541
4542 #[tokio::test]
4545 async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
4546 for (given, expected) in [
4547 (Some("toolu_1".to_string()), Some("toolu_1")),
4548 (Some(String::new()), None),
4549 (None, None),
4550 ] {
4551 let dir = tempfile::tempdir().unwrap();
4552 let mut w = make_writer(dir.path()).await;
4553 w.write_tool_call(
4554 0,
4555 "bash".to_string(),
4556 given.clone(),
4557 serde_json::json!({}),
4558 2,
4559 "",
4560 0,
4561 1,
4562 "ok".to_string(),
4563 None,
4564 None,
4565 )
4566 .await
4567 .unwrap();
4568 w.flush().await.unwrap();
4569 let e = &read_events(dir.path())[0];
4570 match expected {
4571 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
4572 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
4573 }
4574 }
4575 }
4576
4577 #[tokio::test]
4580 async fn compaction_declined_records_turn_task_tokens_and_reason() {
4581 let dir = tempfile::tempdir().unwrap();
4582 let mut w = make_writer(dir.path()).await;
4583 w.write_session_start(10, Vec::new()).await.unwrap();
4584 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4585 .await
4586 .unwrap();
4587 w.write_inference(
4588 2,
4589 10,
4590 5,
4591 "tool_call".to_string(),
4592 Some("tool_call"),
4593 None,
4594 None,
4595 None,
4596 Vec::new(),
4597 None,
4598 )
4599 .await
4600 .unwrap();
4601 w.write_compaction_declined(2, 4321, COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL)
4602 .await
4603 .unwrap();
4604 w.flush().await.unwrap();
4605
4606 let events = read_events(dir.path());
4607 let declined: Vec<&Value> = events
4608 .iter()
4609 .filter(|e| e["event_type"] == "compaction_declined")
4610 .collect();
4611 assert_eq!(declined.len(), 1);
4612 let d = declined[0];
4613 assert_eq!(d["reason"], "unresolved_tool_call");
4614 assert_eq!(d["turn"], 2);
4615 assert_eq!(d["task_id"], "tsk_1");
4616 assert_eq!(d["tokens"], 4321);
4617 assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
4618 assert_eq!(
4619 d["parent_id"],
4620 events
4621 .iter()
4622 .find(|e| e["event_type"] == "inference")
4623 .unwrap()["event_id"]
4624 );
4625 }
4626
4627 #[tokio::test]
4630 async fn event_ids_sort_by_mint_time() {
4631 let dir = tempfile::tempdir().unwrap();
4632 let mut w = make_writer(dir.path()).await;
4633 for turn in 0..5 {
4634 w.write_inference(
4635 turn,
4636 1,
4637 1,
4638 "end_turn".to_string(),
4639 Some("end_turn"),
4640 None,
4641 None,
4642 None,
4643 Vec::new(),
4644 None,
4645 )
4646 .await
4647 .unwrap();
4648 }
4649 w.flush().await.unwrap();
4650
4651 let ids: Vec<String> = read_events(dir.path())
4652 .iter()
4653 .map(|e| e["event_id"].as_str().unwrap().to_string())
4654 .collect();
4655 let mut sorted = ids.clone();
4656 sorted.sort();
4657 assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
4658 }
4659
4660 fn wire_payload(system: &str, messages: usize) -> Value {
4664 let messages: Vec<Value> = (0..messages)
4665 .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
4666 .collect();
4667 serde_json::json!({
4668 "model": "claude-test",
4669 "max_tokens": 1024,
4670 "messages": messages,
4671 "tools": [{"name": "bash"}],
4672 "params": {},
4673 "system": system,
4674 })
4675 }
4676
4677 async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
4678 let wire = WireCapture::from_driver_payload(payload, response);
4679 w.write_inference(
4680 turn,
4681 10,
4682 5,
4683 "end_turn".to_string(),
4684 Some("end_turn"),
4685 None,
4686 None,
4687 None,
4688 vec!["msg_0000000000000000000000000000000a".to_string()],
4689 Some(&wire),
4690 )
4691 .await
4692 .unwrap();
4693 }
4694
4695 fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
4696 dir.join(BLOB_DIR_NAME).join(sha)
4697 }
4698
4699 #[tokio::test]
4702 async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
4703 let dir = tempfile::tempdir().unwrap();
4704 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4705 let payload = wire_payload("You are a capsule.", 3);
4706 write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
4707 w.flush().await.unwrap();
4708
4709 let e = &read_events(dir.path())[0];
4710 let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
4711 .iter()
4712 .map(|key| {
4713 e[*key]
4714 .as_str()
4715 .unwrap_or_else(|| panic!("{key} missing from {e}"))
4716 .to_string()
4717 })
4718 .collect();
4719 let shas = e["message_shas"].as_array().expect("message_shas");
4720 assert_eq!(shas.len(), 3);
4721 named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
4722
4723 for sha in &named {
4724 let path = blob_path(dir.path(), sha);
4725 let bytes =
4726 std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
4727 assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
4728 assert_eq!(sha.len(), 64, "{sha}");
4731 assert!(
4732 sha.chars()
4733 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
4734 "{sha}"
4735 );
4736 }
4737 }
4738
4739 #[tokio::test]
4742 async fn the_system_blob_holds_the_prompt_as_readable_text() {
4743 let dir = tempfile::tempdir().unwrap();
4744 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4745 let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
4746 write_turn(&mut w, 0, &payload, "{}").await;
4747 w.flush().await.unwrap();
4748
4749 let sha = read_events(dir.path())[0]["system_sha"]
4750 .as_str()
4751 .unwrap()
4752 .to_string();
4753 assert_eq!(
4754 std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
4755 "[Capsule] you are cap.\nBe terse."
4756 );
4757 }
4758
4759 #[tokio::test]
4762 async fn meta_writes_the_same_hashes_and_no_bodies() {
4763 let payload = wire_payload("You are a capsule.", 2);
4764 let response = r#"{"stop_reason":"end_turn"}"#;
4765
4766 let meta = tempfile::tempdir().unwrap();
4767 let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
4768 write_turn(&mut w, 0, &payload, response).await;
4769 w.flush().await.unwrap();
4770
4771 let content = tempfile::tempdir().unwrap();
4772 let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
4773 write_turn(&mut w, 0, &payload, response).await;
4774 w.flush().await.unwrap();
4775
4776 let meta_event = read_events(meta.path()).remove(0);
4777 let content_event = read_events(content.path()).remove(0);
4778 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
4779 assert_eq!(meta_event[key], content_event[key], "{key}");
4780 }
4781 assert!(
4782 !meta.path().join(BLOB_DIR_NAME).exists(),
4783 "meta must not create the blob directory"
4784 );
4785 assert!(content.path().join(BLOB_DIR_NAME).is_dir());
4786 }
4787
4788 #[tokio::test]
4790 async fn the_default_capture_mode_is_meta() {
4791 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
4792 let dir = tempfile::tempdir().unwrap();
4793 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
4794 write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
4795 w.flush().await.unwrap();
4796
4797 let e = &read_events(dir.path())[0];
4798 assert!(e.get("system_sha").is_some());
4799 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
4800 }
4801
4802 #[tokio::test]
4804 async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
4805 let payload = wire_payload("You are a capsule.", 2);
4806
4807 let off = tempfile::tempdir().unwrap();
4808 let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
4809 write_turn(&mut w, 3, &payload, "{}").await;
4810 w.flush().await.unwrap();
4811
4812 let e = read_events(off.path()).remove(0);
4813 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
4814 assert!(
4815 e.get(key).is_none(),
4816 "{key} must be absent under none, got {e}"
4817 );
4818 }
4819 assert!(!off.path().join(BLOB_DIR_NAME).exists());
4820 for key in [
4821 "event_type",
4822 "event_id",
4823 "parent_id",
4824 "session_id",
4825 "timestamp",
4826 "turn",
4827 "task_id",
4828 "input_tokens",
4829 "output_tokens",
4830 "decision",
4831 "tool_name",
4832 "message_ids",
4833 ] {
4834 assert!(
4835 e.get(key).is_some(),
4836 "{key} must survive under none, got {e}"
4837 );
4838 }
4839 assert_eq!(e["turn"], 3);
4840 assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
4841 }
4842
4843 #[tokio::test]
4846 async fn an_unchanged_system_prompt_is_stored_once() {
4847 let dir = tempfile::tempdir().unwrap();
4848 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4849 let payload = wire_payload("You are a capsule.", 1);
4850 write_turn(&mut w, 0, &payload, "{}").await;
4851
4852 let sha = {
4853 w.flush().await.unwrap();
4854 read_events(dir.path())[0]["system_sha"]
4855 .as_str()
4856 .unwrap()
4857 .to_string()
4858 };
4859 let path = blob_path(dir.path(), &sha);
4860 std::fs::write(&path, b"sentinel").unwrap();
4861
4862 for turn in 1..10 {
4863 write_turn(&mut w, turn, &payload, "{}").await;
4864 }
4865 w.flush().await.unwrap();
4866
4867 let events = read_events(dir.path());
4868 assert_eq!(events.len(), 10);
4869 for e in &events {
4870 assert_eq!(e["system_sha"], sha.as_str());
4871 }
4872 assert_eq!(
4873 std::fs::read(&path).unwrap(),
4874 b"sentinel",
4875 "an existing blob is never rewritten"
4876 );
4877 let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
4878 .unwrap()
4879 .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
4880 .count();
4881 assert_eq!(matching, 1, "exactly one file bears that name");
4882 }
4883
4884 #[tokio::test]
4889 async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
4890 let dir = tempfile::tempdir().unwrap();
4891 let mut w = make_writer(dir.path()).await;
4892 w.write_inference(
4893 0,
4894 10,
4895 5,
4896 "end_turn".to_string(),
4897 None,
4898 None,
4899 None,
4900 None,
4901 Vec::new(),
4902 None,
4903 )
4904 .await
4905 .unwrap();
4906 w.flush().await.unwrap();
4907
4908 let event = read_events(dir.path()).remove(0);
4909 assert!(
4910 event.get("stop_reason").is_none(),
4911 "stop_reason must be absent, got {event}"
4912 );
4913 }
4914
4915 #[tokio::test]
4918 async fn a_hook_origin_record_carries_no_hashes() {
4919 let dir = tempfile::tempdir().unwrap();
4920 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4921 let origin = InferenceOrigin {
4922 source: "hook:gatekeeper".to_string(),
4923 model: "claude-test".to_string(),
4924 };
4925 w.write_inference(
4926 0,
4927 1,
4928 1,
4929 "end_turn".to_string(),
4930 None,
4932 None,
4933 Some(&origin),
4934 None,
4935 Vec::new(),
4936 None,
4937 )
4938 .await
4939 .unwrap();
4940 w.flush().await.unwrap();
4941
4942 let e = read_events(dir.path()).remove(0);
4943 assert_eq!(e["origin"], "hook:gatekeeper");
4944 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
4945 assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
4946 }
4947 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
4948 }
4949
4950 #[tokio::test]
4953 async fn message_shas_locate_the_index_two_runs_diverge_at() {
4954 async fn shas_for(payload: &Value) -> Vec<String> {
4955 let dir = tempfile::tempdir().unwrap();
4956 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
4957 write_turn(&mut w, 0, payload, "{}").await;
4958 w.flush().await.unwrap();
4959 read_events(dir.path())[0]["message_shas"]
4960 .as_array()
4961 .unwrap()
4962 .iter()
4963 .map(|s| s.as_str().unwrap().to_string())
4964 .collect()
4965 }
4966
4967 let first = wire_payload("You are a capsule.", 4);
4968 let mut second = first.clone();
4969 second["messages"][2]["content"] = serde_json::json!("diverged");
4970
4971 let a = shas_for(&first).await;
4972 let b = shas_for(&second).await;
4973 assert_eq!(a.len(), 4);
4974 let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
4975 assert_eq!(divergence, Some(2));
4976 assert_eq!(a[3], b[3], "only the changed message changes its own sha");
4977 }
4978
4979 #[tokio::test]
4982 async fn identical_messages_share_one_sha_and_one_blob() {
4983 let dir = tempfile::tempdir().unwrap();
4984 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4985 let payload = serde_json::json!({
4986 "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
4987 "tools": [],
4988 "system": "s",
4989 });
4990 write_turn(&mut w, 0, &payload, "{}").await;
4991 w.flush().await.unwrap();
4992
4993 let shas = read_events(dir.path())[0]["message_shas"]
4994 .as_array()
4995 .unwrap()
4996 .iter()
4997 .map(|s| s.as_str().unwrap().to_string())
4998 .collect::<Vec<_>>();
4999 assert_eq!(shas[0], shas[1]);
5000 assert!(blob_path(dir.path(), &shas[0]).is_file());
5001 }
5002
5003 #[tokio::test]
5006 async fn a_message_blob_is_the_body_verbatim() {
5007 let dir = tempfile::tempdir().unwrap();
5008 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5009 let message = serde_json::json!({"role": "user", "content": "hello"});
5010 let payload = serde_json::json!({
5011 "messages": [message.clone()],
5012 "tools": [],
5013 "system": "s",
5014 });
5015 write_turn(&mut w, 0, &payload, "{}").await;
5016 w.flush().await.unwrap();
5017
5018 let sha = read_events(dir.path())[0]["message_shas"][0]
5019 .as_str()
5020 .unwrap()
5021 .to_string();
5022 assert_eq!(
5023 std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
5024 serde_json::to_vec(&message).unwrap()
5025 );
5026 }
5027
5028 #[tokio::test]
5036 async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
5037 let plan_dir = tempfile::tempdir().unwrap();
5038 let tool_dir = tempfile::tempdir().unwrap();
5039 let session = "ses_0000000000004000800000000000plan".to_string();
5040 let node = "evt_0000000000004000800000000000node".to_string();
5041
5042 let plan = PlanTraceAppender::open(plan_dir.path(), session.clone(), node.clone()).unwrap();
5043 let tool = ResourceTraceAppender::open(tool_dir.path(), session.clone(), node.clone())
5044 .await
5045 .unwrap();
5046
5047 plan.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c");
5048 tool.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c")
5049 .await;
5050 plan.write_delegation(
5051 "worker",
5052 "0.1.0",
5053 Some("dlg_abc123".to_string()),
5054 Some("ses_child".to_string()),
5055 42,
5056 "completed",
5057 None,
5058 );
5059 tool.write_delegation(
5060 "worker",
5061 "0.1.0",
5062 Some("dlg_abc123".to_string()),
5063 Some("ses_child".to_string()),
5064 42,
5065 "completed",
5066 None,
5067 )
5068 .await;
5069
5070 let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
5071 std::fs::read_to_string(dir.join("trace.jsonl"))
5072 .unwrap()
5073 .lines()
5074 .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
5075 .collect()
5076 };
5077 let from_plan = read(plan_dir.path());
5078 let from_tool = read(tool_dir.path());
5079 assert_eq!(from_plan.len(), 2);
5080 assert_eq!(from_tool.len(), 2);
5081
5082 for (planned, called, event_type) in [
5083 (&from_plan[0], &from_tool[0], "delegation_start"),
5084 (&from_plan[1], &from_tool[1], "delegation"),
5085 ] {
5086 assert_eq!(planned["event_type"], event_type);
5087 assert_eq!(called["event_type"], event_type);
5088 assert_eq!(
5089 planned.keys().collect::<Vec<_>>(),
5090 called.keys().collect::<Vec<_>>(),
5091 "{planned:?} vs {called:?}"
5092 );
5093 assert_eq!(planned["parent_id"], node.as_str());
5094 assert_eq!(called["parent_id"], node.as_str());
5095 for (key, value) in planned {
5096 if key == "event_id" || key == "timestamp" {
5097 continue;
5098 }
5099 assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
5100 }
5101 for key in ["plan_id", "step_id", "surface", "kind"] {
5102 assert!(!planned.contains_key(key), "{key} is on {planned:?}");
5103 assert!(!called.contains_key(key), "{key} is on {called:?}");
5104 }
5105 }
5106 }
5107}