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 gateway_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 gateways: Vec<SessionGateway>,
58 resumed_from: Option<String>,
62 context_id: Option<String>,
65 spawned_by: Option<String>,
69 spawn_delegation_id: Option<String>,
70 session_start_time: Instant,
71 session_event_id: String,
76 session_started: bool,
77 session_ended: bool,
78 task_event_id: Option<String>,
81 turn_event_id: Option<String>,
85 total_turns: u32,
87 total_input_tokens: u64,
88 total_output_tokens: u64,
89 total_tool_calls: u32,
90 total_shell_calls: u32,
91 task_turns: u32,
93 task_input_tokens: u64,
94 task_output_tokens: u64,
95 task_tool_calls: u32,
96 task_shell_calls: u32,
97 task_start_instant: Option<Instant>,
98 pub(crate) active_task_id: Option<String>,
99 max_session_tokens: Option<u64>,
101 machine_tokens_per_day: Option<u64>,
102}
103
104#[derive(Debug, Clone)]
109pub(crate) struct InferenceOrigin {
110 pub(crate) source: String,
112 pub(crate) model: String,
114}
115
116pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
119
120pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
123
124pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
126
127pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
130
131pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
134
135pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
137
138pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
141
142pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
145
146pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
149
150pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
153
154pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
156
157pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
159
160pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
162
163pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
166
167pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
169
170fn new_event_id() -> String {
178 format!("evt_{}", uuid::Uuid::now_v7().simple())
179}
180
181#[derive(Serialize)]
195struct SessionStartEvent {
196 event_type: &'static str,
197 event_id: String,
198 parent_id: Option<String>,
199 session_id: String,
200 timestamp: u64,
201 capsule_name: String,
202 capsule_version: String,
203 model: String,
204 max_turns: u32,
205 max_session_tokens: Option<u64>,
208 machine_tokens_per_day: Option<u64>,
211 capabilities: Vec<String>,
212 tools_declared: Vec<String>,
213 containment_declared: ContainmentClass,
215 containment_achieved: ContainmentClass,
219 workdir_exec: bool,
227 userns_grant: Option<crate::sealed::UsernsGrant>,
239 effective_grants: ScopeReport,
248 system_prompt_source: &'static str,
256 system_prompt_sha256: Option<String>,
265 credential_source: &'static str,
268 gateways: Vec<SessionGateway>,
272 resumed_from: Option<String>,
279 context_id: Option<String>,
285 #[serde(skip_serializing_if = "Option::is_none")]
289 spawned_by: Option<String>,
290 #[serde(skip_serializing_if = "Option::is_none")]
293 delegation_id: Option<String>,
294}
295
296#[derive(Serialize)]
297struct InferenceEvent {
298 event_type: &'static str,
299 event_id: String,
300 parent_id: Option<String>,
301 session_id: String,
302 timestamp: u64,
303 turn: u32,
304 task_id: Option<String>,
305 #[serde(skip_serializing_if = "Option::is_none")]
317 input_tokens: Option<u64>,
318 #[serde(skip_serializing_if = "Option::is_none")]
322 output_tokens: Option<u64>,
323 decision: String,
324 #[serde(skip_serializing_if = "Option::is_none")]
330 stop_reason: Option<String>,
331 tool_name: Option<String>,
332 #[serde(skip_serializing_if = "Option::is_none")]
335 input_tokens_actual: Option<u64>,
336 #[serde(skip_serializing_if = "Option::is_none")]
337 output_tokens_actual: Option<u64>,
338 #[serde(skip_serializing_if = "Option::is_none")]
339 cached_tokens: Option<u64>,
340 #[serde(skip_serializing_if = "Option::is_none")]
341 cache_write_tokens: Option<u64>,
342 #[serde(skip_serializing_if = "Option::is_none")]
345 thinking_tokens: Option<u64>,
346 #[serde(skip_serializing_if = "Option::is_none")]
351 origin: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
356 model: Option<String>,
357 #[serde(skip_serializing_if = "Vec::is_empty")]
362 message_ids: Vec<String>,
363 #[serde(skip_serializing_if = "Option::is_none")]
371 system_sha: Option<String>,
372 #[serde(skip_serializing_if = "Option::is_none")]
375 tools_sha: Option<String>,
376 #[serde(skip_serializing_if = "Option::is_none")]
379 response_sha: Option<String>,
380 #[serde(skip_serializing_if = "Vec::is_empty")]
388 message_shas: Vec<String>,
389}
390
391pub(crate) struct WireCapture {
401 system: Vec<u8>,
404 tools: Vec<u8>,
406 messages: Vec<Vec<u8>>,
410 response: Vec<u8>,
412}
413
414impl WireCapture {
415 pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
421 Self {
422 system: payload
423 .get("system")
424 .and_then(Value::as_str)
425 .unwrap_or_default()
426 .as_bytes()
427 .to_vec(),
428 tools: payload
429 .get("tools")
430 .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
431 .unwrap_or_default(),
432 messages: payload
433 .get("messages")
434 .and_then(Value::as_array)
435 .map(|messages| {
436 messages
437 .iter()
438 .map(|message| serde_json::to_vec(message).unwrap_or_default())
439 .collect()
440 })
441 .unwrap_or_default(),
442 response: response.as_bytes().to_vec(),
443 }
444 }
445}
446
447#[derive(Serialize)]
448struct ToolCallEvent {
449 event_type: &'static str,
450 event_id: String,
451 parent_id: Option<String>,
452 session_id: String,
453 timestamp: u64,
454 turn: u32,
455 task_id: Option<String>,
456 tool_name: String,
457 tool_call_id: Option<String>,
461 input: Value,
462 input_bytes: u64,
463 #[serde(skip_serializing_if = "Option::is_none")]
464 output: Option<String>,
465 output_bytes: u64,
466 duration_ms: u64,
467 status: String,
468 #[serde(skip_serializing_if = "Option::is_none")]
472 state_effect: Option<String>,
473 #[serde(skip_serializing_if = "Option::is_none")]
478 resource_id: Option<String>,
479}
480
481#[derive(Serialize)]
482struct SkillCallEvent {
483 event_type: &'static str,
484 event_id: String,
485 parent_id: Option<String>,
486 session_id: String,
487 timestamp: u64,
488 turn: u32,
489 task_id: Option<String>,
490 skill_name: String,
491 output_bytes: u64,
492 duration_ms: u64,
493 status: String,
494}
495
496#[derive(Serialize)]
497struct ShellEvent {
498 event_type: &'static str,
499 event_id: String,
500 parent_id: Option<String>,
501 session_id: String,
502 timestamp: u64,
503 turn: u32,
504 task_id: Option<String>,
505 binary: String,
509 command: String,
510 exit_code: i32,
511 stdout_bytes: u64,
512 stderr_bytes: u64,
513 duration_ms: u64,
514 #[serde(skip_serializing_if = "Option::is_none")]
520 resource_limit: Option<String>,
521}
522
523#[derive(Serialize)]
526struct ShellDetachedEvent {
527 event_type: &'static str,
528 event_id: String,
529 parent_id: Option<String>,
530 session_id: String,
531 timestamp: u64,
532 turn: u32,
533 task_id: Option<String>,
534 work_id: String,
535 binary: String,
536 command: String,
537 grace_ms: u64,
539}
540
541#[derive(Serialize)]
544struct ShellCompletedEvent {
545 event_type: &'static str,
546 event_id: String,
547 parent_id: Option<String>,
548 session_id: String,
549 timestamp: u64,
550 work_id: String,
551 binary: String,
552 command: String,
553 exit_code: i32,
554 duration_ms: u64,
555 output_path: String,
557 output_bytes: u64,
558 #[serde(skip_serializing_if = "Option::is_none")]
560 resource_limit: Option<String>,
561 status: String,
564 completion_task_id: String,
567}
568
569#[derive(Serialize)]
578struct ShellAbandonedEvent {
579 event_type: &'static str,
580 event_id: String,
581 parent_id: Option<String>,
582 session_id: String,
583 timestamp: u64,
584 work_id: String,
585 binary: String,
586 command: String,
587 running_ms: u64,
590 #[serde(skip_serializing_if = "Option::is_none")]
593 exit_code: Option<i32>,
594 #[serde(skip_serializing_if = "Option::is_none")]
598 output_path: Option<String>,
599 #[serde(skip_serializing_if = "Option::is_none")]
601 output_bytes: Option<u64>,
602}
603
604#[derive(Serialize)]
616struct ShellLostEvent {
617 event_type: &'static str,
618 event_id: String,
619 #[serde(skip_serializing_if = "Option::is_none")]
622 parent_id: Option<String>,
623 session_id: String,
625 timestamp: u64,
626 work_id: String,
627 binary: String,
628 command: String,
629 detached_at_ms: u64,
631 reconciled_by_session: String,
633 reconciled_task_id: String,
636}
637
638#[derive(Serialize)]
644struct ShellDetachUnrecordedEvent {
645 event_type: &'static str,
646 event_id: String,
647 parent_id: Option<String>,
648 session_id: String,
649 timestamp: u64,
650 turn: u32,
651 task_id: Option<String>,
652 work_id: String,
653 binary: String,
654 reason: String,
656}
657
658#[derive(Serialize)]
659struct CompactionEvent {
660 event_type: &'static str,
661 event_id: String,
662 parent_id: Option<String>,
663 session_id: String,
664 timestamp: u64,
665 turn: u32,
666 task_id: Option<String>,
667 tokens_before: u64,
668 tokens_after: u64,
669}
670
671#[derive(Serialize)]
675struct CompactionDeclinedEvent {
676 event_type: &'static str,
677 event_id: String,
678 parent_id: Option<String>,
679 session_id: String,
680 timestamp: u64,
681 turn: u32,
682 task_id: Option<String>,
683 tokens: u64,
686 reason: String,
689}
690
691#[derive(Serialize)]
698struct ContextSeedEvent {
699 event_type: &'static str,
700 event_id: String,
701 parent_id: Option<String>,
702 session_id: String,
703 timestamp: u64,
704 task_id: Option<String>,
705 hook_name: String,
707 tokens: u64,
710 proposed_tokens: u64,
713 budget_tokens: u64,
716 outcome: String,
719 #[serde(skip_serializing_if = "Option::is_none")]
722 reason: Option<String>,
723 message_ids: Vec<String>,
727}
728
729#[derive(Serialize)]
737struct RetentionEvent {
738 event_type: &'static str,
739 event_id: String,
740 parent_id: Option<String>,
741 session_id: String,
742 timestamp: u64,
743 store: String,
745 reason: String,
748 removed: u32,
752 targets: Vec<String>,
754 #[serde(skip_serializing_if = "Option::is_none")]
757 messages_dropped: Option<u64>,
758}
759
760#[derive(Serialize)]
761struct A2aTaskReceivedEvent {
762 event_type: &'static str,
763 event_id: String,
764 parent_id: Option<String>,
765 session_id: String,
766 timestamp: u64,
767 task_id: String,
768 context_id: String,
769 message_id: String,
770 traceparent_from_caller: Option<String>,
771}
772
773#[derive(Serialize)]
774struct A2aSendEvent {
775 event_type: &'static str,
776 event_id: String,
777 parent_id: Option<String>,
778 session_id: String,
779 timestamp: u64,
780 peer_url: String,
781 message_id: String,
782 task_id: String,
783 context_id: String,
784 traceparent: Option<String>,
785 trust: String,
788}
789
790#[derive(Serialize)]
791struct SessionEndEvent {
792 event_type: &'static str,
793 event_id: String,
794 parent_id: Option<String>,
795 session_id: String,
796 timestamp: u64,
797 total_turns: u32,
798 total_input_tokens: u64,
799 total_output_tokens: u64,
800 total_tool_calls: u32,
801 total_shell_calls: u32,
802 duration_ms: u64,
803 exit_status: String,
804}
805
806#[derive(Serialize)]
807struct TaskStartEvent {
808 event_type: &'static str,
809 event_id: String,
810 parent_id: Option<String>,
811 session_id: String,
812 timestamp: u64,
813 task_id: String,
814 context_id: String,
815 source: String,
818 origin: String,
819 trust: String,
820 lane: String,
823 #[serde(skip_serializing_if = "Option::is_none")]
827 delegation_id: Option<String>,
828 message_parts_bytes: u64,
829}
830
831#[derive(Serialize)]
832struct TaskEndEvent {
833 event_type: &'static str,
834 event_id: String,
835 parent_id: Option<String>,
836 session_id: String,
837 timestamp: u64,
838 task_id: String,
839 exit_status: String,
840 duration_ms: u64,
841 turns: u32,
842 input_tokens: u64,
843 output_tokens: u64,
844 tool_calls: u32,
845 shell_calls: u32,
846 reopen_count: u32,
849}
850
851#[derive(Serialize)]
858struct TaskCanceledEvent {
859 event_type: &'static str,
860 event_id: String,
861 parent_id: Option<String>,
862 session_id: String,
863 timestamp: u64,
864 task_id: String,
865 #[serde(skip_serializing_if = "Option::is_none")]
867 turn: Option<u32>,
868 phase: String,
871 detached_work_ids: Vec<String>,
874 delegation_ids: Vec<String>,
877}
878
879pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
882
883pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
886
887#[derive(Serialize)]
892struct TaskReopenedEvent {
893 event_type: &'static str,
894 event_id: String,
895 parent_id: Option<String>,
896 session_id: String,
897 timestamp: u64,
898 task_id: String,
899 hook_name: String,
901 reason: String,
903 reopen_number: u32,
905 attempt_context: &'static str,
907 turns_remaining: u32,
910}
911
912#[derive(Serialize)]
915struct SpendCeilingReachedEvent<'a> {
916 event_type: &'static str,
917 event_id: String,
918 parent_id: Option<String>,
919 session_id: String,
920 timestamp: u64,
921 turn: u32,
922 task_id: Option<String>,
923 #[serde(flatten)]
924 refusal: &'a crate::spend::SpendRefusal,
925 #[serde(skip_serializing_if = "Option::is_none")]
927 origin: Option<&'a str>,
928}
929
930#[derive(Serialize)]
935pub(crate) struct HarnessStart {
936 pub driver: String,
938 pub driver_version: String,
939 pub harness: String,
941 pub binary: String,
943 pub binary_source: String,
945 pub harness_version: Option<String>,
947 pub version_tested: bool,
949 pub harness_session_id: String,
951 pub session_mode: String,
952 pub args_count: usize,
955 pub env_names: Vec<String>,
957 pub files: Vec<String>,
959 pub bridge_tools: Vec<String>,
961 pub stdin_bytes: usize,
963 pub keep_stdin_open: bool,
964}
965
966#[derive(Serialize)]
967struct HarnessStartEvent {
968 event_type: &'static str,
969 event_id: String,
970 parent_id: Option<String>,
971 session_id: String,
972 timestamp: u64,
973 task_id: Option<String>,
974 #[serde(flatten)]
975 start: HarnessStart,
976}
977
978#[derive(Serialize)]
981struct HarnessWarningEvent {
982 event_type: &'static str,
983 event_id: String,
984 parent_id: Option<String>,
985 session_id: String,
986 timestamp: u64,
987 task_id: Option<String>,
988 code: String,
989 message: String,
990}
991
992#[derive(Serialize)]
995struct HarnessSessionEvent {
996 event_type: &'static str,
997 event_id: String,
998 parent_id: Option<String>,
999 session_id: String,
1000 timestamp: u64,
1001 task_id: Option<String>,
1002 harness_session_id: String,
1003 auth: String,
1005 model: Option<String>,
1007}
1008
1009#[derive(Serialize)]
1013struct HarnessSessionForgottenEvent {
1014 event_type: &'static str,
1015 event_id: String,
1016 parent_id: Option<String>,
1017 session_id: String,
1018 timestamp: u64,
1019 task_id: Option<String>,
1020 context_id: String,
1022 harness_session_id: String,
1024 requested_by: String,
1026}
1027
1028#[derive(Serialize)]
1029struct HarnessRetryEvent {
1030 event_type: &'static str,
1031 event_id: String,
1032 parent_id: Option<String>,
1033 session_id: String,
1034 timestamp: u64,
1035 task_id: Option<String>,
1036 attempt: u32,
1037 reason: String,
1038}
1039
1040#[derive(Serialize)]
1041struct HarnessNoteEvent {
1042 event_type: &'static str,
1043 event_id: String,
1044 parent_id: Option<String>,
1045 session_id: String,
1046 timestamp: u64,
1047 task_id: Option<String>,
1048 text: String,
1049}
1050
1051#[derive(Serialize)]
1052struct HarnessFailedEvent {
1053 event_type: &'static str,
1054 event_id: String,
1055 parent_id: Option<String>,
1056 session_id: String,
1057 timestamp: u64,
1058 task_id: Option<String>,
1059 kind: String,
1061 message: String,
1062 source: String,
1064}
1065
1066#[derive(Serialize)]
1068struct HarnessInterruptEvent {
1069 event_type: &'static str,
1070 event_id: String,
1071 parent_id: Option<String>,
1072 session_id: String,
1073 timestamp: u64,
1074 task_id: Option<String>,
1075 method: String,
1077 delivered: bool,
1079 grace_ms: u64,
1082}
1083
1084#[derive(Serialize)]
1086pub(crate) struct HarnessExit {
1087 pub code: Option<i32>,
1089 pub signal: Option<i32>,
1091 pub cause: String,
1094 pub killed: bool,
1096 pub duration_ms: u64,
1098}
1099
1100#[derive(Serialize)]
1101struct HarnessExitEvent {
1102 event_type: &'static str,
1103 event_id: String,
1104 parent_id: Option<String>,
1105 session_id: String,
1106 timestamp: u64,
1107 task_id: Option<String>,
1108 #[serde(flatten)]
1109 exit: HarnessExit,
1110}
1111
1112#[derive(Serialize)]
1116struct CallDeniedEvent {
1117 event_type: &'static str,
1118 event_id: String,
1119 parent_id: Option<String>,
1120 session_id: String,
1121 timestamp: u64,
1122 turn: u32,
1123 event: String,
1126 hook_name: String,
1128 target: String,
1131 reason: String,
1133}
1134
1135#[derive(Serialize)]
1140struct ProtectedPathDeniedEvent {
1141 event_type: &'static str,
1142 event_id: String,
1143 parent_id: Option<String>,
1144 session_id: String,
1145 timestamp: u64,
1146 turn: u32,
1147 call: String,
1149 target: String,
1151 path: String,
1154 rule: String,
1156 signal: String,
1159 reason: String,
1161}
1162
1163#[derive(Serialize)]
1164struct HookDispatchErrorEvent {
1165 event_type: &'static str,
1166 event_id: String,
1167 parent_id: Option<String>,
1168 session_id: String,
1169 timestamp: u64,
1170 hook_name: String,
1171 event: String,
1172 arm: String,
1173}
1174
1175#[derive(Serialize)]
1176struct ResourceListEvent {
1177 event_type: &'static str,
1178 event_id: String,
1179 parent_id: Option<String>,
1180 session_id: String,
1181 timestamp: u64,
1182 root: String,
1185 entry_count: usize,
1186 total_bytes: u64,
1187 generation: u64,
1189 containment_achieved: ContainmentClass,
1190 outcome: String,
1192 reason: Option<String>,
1194}
1195
1196#[derive(Serialize)]
1197struct ResourceReadEvent {
1198 event_type: &'static str,
1199 event_id: String,
1200 parent_id: Option<String>,
1201 session_id: String,
1202 timestamp: u64,
1203 path: String,
1206 outcome: String,
1207 bytes: Option<u64>,
1209 sha256: Option<String>,
1211 generation: u64,
1212 containment_achieved: ContainmentClass,
1213 reason: Option<String>,
1214}
1215
1216#[derive(Serialize)]
1218struct PeerHandleMintEvent {
1219 event_type: &'static str,
1220 event_id: String,
1221 parent_id: Option<String>,
1222 session_id: String,
1223 timestamp: u64,
1224 handle_id: Option<String>,
1227 path: String,
1230 audience: String,
1231 expires_at_ms: Option<u64>,
1233 outcome: String,
1234 reason: Option<String>,
1235}
1236
1237#[derive(Serialize)]
1240struct PeerHandleRedeemEvent {
1241 event_type: &'static str,
1242 event_id: String,
1243 parent_id: Option<String>,
1244 session_id: String,
1245 timestamp: u64,
1246 handle_id: String,
1247 path: Option<String>,
1250 generation: u64,
1253 audience_asserted: Option<String>,
1255 bytes: Option<u64>,
1257 sha256: Option<String>,
1259 outcome: String,
1260 reason: Option<String>,
1261}
1262
1263#[derive(Serialize)]
1265struct PeerFileFetchEvent {
1266 event_type: &'static str,
1267 event_id: String,
1268 parent_id: Option<String>,
1269 session_id: String,
1270 timestamp: u64,
1271 peer: String,
1272 handle_id: String,
1273 stored_path: Option<String>,
1276 bytes: Option<u64>,
1277 sha256: Option<String>,
1278 outcome: String,
1279 reason: Option<String>,
1280}
1281
1282#[derive(Serialize)]
1293struct DelegationStartEvent {
1294 event_type: &'static str,
1295 event_id: String,
1296 parent_id: Option<String>,
1297 session_id: String,
1298 timestamp: u64,
1299 delegation_id: String,
1302 capsule: String,
1303 version: String,
1304 child_session_id: String,
1306 child_workdir: String,
1309}
1310
1311impl DelegationStartEvent {
1312 fn new(
1315 session_id: &str,
1316 session_event_id: &str,
1317 delegation_id: &str,
1318 capsule: &str,
1319 version: &str,
1320 child_session_id: &str,
1321 child_workdir: &str,
1322 ) -> Self {
1323 Self {
1324 event_type: "delegation_start",
1325 event_id: new_event_id(),
1326 parent_id: Some(session_event_id.to_string()),
1327 session_id: session_id.to_string(),
1328 timestamp: timestamp_ms(),
1329 delegation_id: delegation_id.to_string(),
1330 capsule: capsule.to_string(),
1331 version: version.to_string(),
1332 child_session_id: child_session_id.to_string(),
1333 child_workdir: child_workdir.to_string(),
1334 }
1335 }
1336}
1337
1338#[derive(Serialize)]
1349struct DelegationEvent {
1350 event_type: &'static str,
1351 event_id: String,
1352 parent_id: Option<String>,
1353 session_id: String,
1354 timestamp: u64,
1355 capsule: String,
1357 version: String,
1358 delegation_id: Option<String>,
1360 child_session_id: Option<String>,
1362 duration_ms: u64,
1365 outcome: String,
1375 reason: Option<String>,
1378}
1379
1380impl DelegationEvent {
1381 #[allow(clippy::too_many_arguments)]
1384 fn new(
1385 session_id: &str,
1386 session_event_id: &str,
1387 capsule: &str,
1388 version: &str,
1389 delegation_id: Option<String>,
1390 child_session_id: Option<String>,
1391 duration_ms: u64,
1392 outcome: &str,
1393 reason: Option<String>,
1394 ) -> Self {
1395 Self {
1396 event_type: "delegation",
1397 event_id: new_event_id(),
1398 parent_id: Some(session_event_id.to_string()),
1399 session_id: session_id.to_string(),
1400 timestamp: timestamp_ms(),
1401 capsule: capsule.to_string(),
1402 version: version.to_string(),
1403 delegation_id,
1404 child_session_id,
1405 duration_ms,
1406 outcome: outcome.to_string(),
1407 reason,
1408 }
1409 }
1410}
1411
1412impl TraceWriter {
1415 #[allow(clippy::too_many_arguments)]
1416 pub(crate) async fn open(
1417 workdir: &Path,
1418 session_id: String,
1419 capsule_name: String,
1420 capsule_version: String,
1421 model: String,
1422 capabilities: Vec<String>,
1423 effective_grants: ScopeReport,
1424 capture: TraceCapture,
1425 system_prompt: Option<String>,
1426 system_prompt_overridden: bool,
1427 resumed_from: Option<String>,
1428 context_id: Option<String>,
1429 spawned_by: Option<String>,
1430 spawn_delegation_id: Option<String>,
1431 ) -> std::io::Result<Self> {
1432 let file = OpenOptions::new()
1433 .create(true)
1434 .append(true)
1435 .open(workdir.join("trace.jsonl"))
1436 .await?;
1437 let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
1440 (true, _) => "cli",
1441 (false, true) => "manifest",
1442 (false, false) => "none",
1443 };
1444 let system_prompt_sha256 = system_prompt
1445 .as_ref()
1446 .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
1447 let blobs = BlobStore::new(workdir);
1448 if let (true, Some(prompt), Some(sha)) = (
1452 capture.captures_content(),
1453 system_prompt.as_ref(),
1454 system_prompt_sha256.as_deref(),
1455 ) {
1456 blobs.put(sha, prompt.as_bytes()).await?;
1457 }
1458 Ok(Self {
1459 writer: BufWriter::new(file),
1460 session_id,
1461 capsule_name,
1462 capsule_version,
1463 model,
1464 capabilities,
1465 effective_grants,
1466 capture,
1467 blobs,
1468 system_prompt_source,
1469 system_prompt_sha256,
1470 credential_source: "none",
1471 gateways: Vec::new(),
1472 resumed_from,
1473 context_id,
1474 spawned_by,
1475 spawn_delegation_id,
1476 session_start_time: Instant::now(),
1477 session_event_id: new_event_id(),
1478 session_started: false,
1479 session_ended: false,
1480 task_event_id: None,
1481 turn_event_id: None,
1482 total_turns: 0,
1483 total_input_tokens: 0,
1484 total_output_tokens: 0,
1485 total_tool_calls: 0,
1486 total_shell_calls: 0,
1487 task_turns: 0,
1488 task_input_tokens: 0,
1489 task_output_tokens: 0,
1490 task_tool_calls: 0,
1491 task_shell_calls: 0,
1492 task_start_instant: None,
1493 active_task_id: None,
1494 max_session_tokens: None,
1495 machine_tokens_per_day: None,
1496 })
1497 }
1498
1499 pub(crate) fn set_spend_ceilings(
1501 &mut self,
1502 max_session_tokens: Option<u64>,
1503 machine_tokens_per_day: Option<u64>,
1504 ) {
1505 self.max_session_tokens = max_session_tokens;
1506 self.machine_tokens_per_day = machine_tokens_per_day;
1507 }
1508
1509 pub(crate) fn session_event_id(&self) -> &str {
1513 &self.session_event_id
1514 }
1515
1516 pub(crate) fn captures_wire(&self) -> bool {
1520 self.capture.captures_hashes()
1521 }
1522
1523 fn session_parent(&self) -> Option<String> {
1528 self.session_started.then(|| self.session_event_id.clone())
1529 }
1530
1531 fn task_parent(&self) -> Option<String> {
1534 self.task_event_id.clone().or_else(|| self.session_parent())
1535 }
1536
1537 fn turn_parent(&self) -> Option<String> {
1541 self.turn_event_id.clone().or_else(|| self.task_parent())
1542 }
1543
1544 pub(crate) fn set_credential_source(&mut self, source: &'static str) {
1547 self.credential_source = source;
1548 }
1549
1550 pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
1552 self.gateways = gateways;
1553 }
1554
1555 pub(crate) async fn write_session_start(
1556 &mut self,
1557 max_turns: u32,
1558 tools_declared: Vec<String>,
1559 ) -> std::io::Result<()> {
1560 let event = SessionStartEvent {
1561 event_type: "session_start",
1562 event_id: self.session_event_id.clone(),
1563 parent_id: None,
1564 session_id: self.session_id.clone(),
1565 timestamp: timestamp_ms(),
1566 capsule_name: self.capsule_name.clone(),
1567 capsule_version: self.capsule_version.clone(),
1568 model: self.model.clone(),
1569 max_turns,
1570 max_session_tokens: self.max_session_tokens,
1571 machine_tokens_per_day: self.machine_tokens_per_day,
1572 capabilities: self.capabilities.clone(),
1573 tools_declared,
1574 containment_declared: self.effective_grants.declared_containment,
1575 containment_achieved: self.effective_grants.achieved_containment,
1576 workdir_exec: self.effective_grants.workdir_exec,
1577 userns_grant: self.effective_grants.userns_grant,
1578 effective_grants: self.effective_grants.clone(),
1579 system_prompt_source: self.system_prompt_source,
1580 system_prompt_sha256: self.system_prompt_sha256.clone(),
1581 credential_source: self.credential_source,
1582 gateways: self.gateways.clone(),
1583 resumed_from: self.resumed_from.clone(),
1584 context_id: self.context_id.clone(),
1585 spawned_by: self.spawned_by.clone(),
1586 delegation_id: self.spawn_delegation_id.clone(),
1587 };
1588 self.write_event(&event).await?;
1589 self.session_started = true;
1590 Ok(())
1591 }
1592
1593 #[allow(clippy::too_many_arguments)]
1603 pub(crate) async fn write_inference(
1604 &mut self,
1605 turn: u32,
1606 input_tokens: Option<u64>,
1607 output_tokens: Option<u64>,
1608 decision: String,
1609 stop_reason: Option<&str>,
1610 tool_name: Option<String>,
1611 origin: Option<&InferenceOrigin>,
1612 usage: Option<&DriverUsage>,
1613 message_ids: Vec<String>,
1614 wire: Option<&WireCapture>,
1615 ) -> std::io::Result<()> {
1616 let event_id = new_event_id();
1617 let parent_id = if origin.is_none() {
1621 let parent = self.task_parent();
1622 self.turn_event_id = Some(event_id.clone());
1623 parent
1624 } else {
1625 self.turn_parent()
1626 };
1627 let (system_sha, tools_sha, response_sha, message_shas) =
1628 match wire.filter(|_| self.capture.captures_hashes()) {
1629 Some(wire) => self.record_wire(wire).await?,
1630 None => (None, None, None, Vec::new()),
1631 };
1632 let event = InferenceEvent {
1633 event_type: "inference",
1634 event_id,
1635 parent_id,
1636 session_id: self.session_id.clone(),
1637 timestamp: timestamp_ms(),
1638 turn,
1639 task_id: self.active_task_id.clone(),
1640 input_tokens,
1641 output_tokens,
1642 decision,
1643 stop_reason: stop_reason.map(str::to_string),
1644 tool_name,
1645 input_tokens_actual: usage.and_then(|u| u.input_tokens),
1646 output_tokens_actual: usage.and_then(|u| u.output_tokens),
1647 cached_tokens: usage.and_then(|u| u.cached_tokens),
1648 cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
1649 thinking_tokens: usage.and_then(|u| u.thinking_tokens),
1650 origin: origin.map(|o| o.source.clone()),
1651 model: origin.map(|o| o.model.clone()),
1652 message_ids,
1653 system_sha,
1654 tools_sha,
1655 response_sha,
1656 message_shas,
1657 };
1658 self.write_event(&event).await?;
1659 let (input_tokens, output_tokens) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
1663 self.total_turns = self.total_turns.saturating_add(1);
1664 self.total_input_tokens = self.total_input_tokens.saturating_add(input_tokens);
1665 self.total_output_tokens = self.total_output_tokens.saturating_add(output_tokens);
1666 self.task_turns = self.task_turns.saturating_add(1);
1667 self.task_input_tokens = self.task_input_tokens.saturating_add(input_tokens);
1668 self.task_output_tokens = self.task_output_tokens.saturating_add(output_tokens);
1669 Ok(())
1670 }
1671
1672 async fn record_wire(
1679 &mut self,
1680 wire: &WireCapture,
1681 ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
1682 let system_sha = murmur_artifact::sha256_hex(&wire.system);
1683 let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
1684 let response_sha = murmur_artifact::sha256_hex(&wire.response);
1685 let message_shas: Vec<String> = wire
1686 .messages
1687 .iter()
1688 .map(|message| murmur_artifact::sha256_hex(message))
1689 .collect();
1690
1691 if self.capture.captures_content() {
1692 self.blobs.put(&system_sha, &wire.system).await?;
1696 self.blobs.put(&tools_sha, &wire.tools).await?;
1697 self.blobs.put(&response_sha, &wire.response).await?;
1698 for (sha, body) in message_shas.iter().zip(&wire.messages) {
1699 self.blobs.put(sha, body).await?;
1700 }
1701 }
1702
1703 Ok((
1704 Some(system_sha),
1705 Some(tools_sha),
1706 Some(response_sha),
1707 message_shas,
1708 ))
1709 }
1710
1711 #[allow(clippy::too_many_arguments)]
1712 pub(crate) async fn write_tool_call(
1713 &mut self,
1714 turn: u32,
1715 tool_name: String,
1716 tool_call_id: Option<String>,
1717 mut input: Value,
1718 input_bytes: u64,
1719 output: &str,
1720 output_bytes: u64,
1721 duration_ms: u64,
1722 status: String,
1723 state_effect: Option<String>,
1724 resource_id: Option<String>,
1725 ) -> std::io::Result<()> {
1726 crate::peer_handoff::redact_handles_in_json(&mut input);
1731 let event = ToolCallEvent {
1732 event_type: "tool_call",
1733 event_id: new_event_id(),
1734 parent_id: self.turn_parent(),
1735 session_id: self.session_id.clone(),
1736 timestamp: timestamp_ms(),
1737 turn,
1738 task_id: self.active_task_id.clone(),
1739 tool_name,
1740 tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
1743 input,
1744 input_bytes,
1745 output: self
1746 .capture
1747 .captures_content()
1748 .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
1749 output_bytes,
1750 duration_ms,
1751 status,
1752 state_effect,
1753 resource_id,
1754 };
1755 self.write_event(&event).await?;
1756 self.total_tool_calls = self.total_tool_calls.saturating_add(1);
1757 self.task_tool_calls = self.task_tool_calls.saturating_add(1);
1758 Ok(())
1759 }
1760
1761 pub(crate) async fn write_skill_call(
1764 &mut self,
1765 turn: u32,
1766 skill_name: String,
1767 output_bytes: u64,
1768 duration_ms: u64,
1769 status: String,
1770 ) -> std::io::Result<()> {
1771 let event = SkillCallEvent {
1772 event_type: "skill_call",
1773 event_id: new_event_id(),
1774 parent_id: self.turn_parent(),
1775 session_id: self.session_id.clone(),
1776 timestamp: timestamp_ms(),
1777 turn,
1778 task_id: self.active_task_id.clone(),
1779 skill_name,
1780 output_bytes,
1781 duration_ms,
1782 status,
1783 };
1784 self.write_event(&event).await
1785 }
1787
1788 #[allow(clippy::too_many_arguments)]
1791 pub(crate) async fn write_shell(
1792 &mut self,
1793 turn: u32,
1794 binary: String,
1795 command: String,
1796 exit_code: i32,
1797 stdout_bytes: u64,
1798 stderr_bytes: u64,
1799 duration_ms: u64,
1800 resource_limit: Option<String>,
1801 ) -> std::io::Result<()> {
1802 let event = ShellEvent {
1803 event_type: "shell",
1804 event_id: new_event_id(),
1805 parent_id: self.turn_parent(),
1806 session_id: self.session_id.clone(),
1807 timestamp: timestamp_ms(),
1808 turn,
1809 task_id: self.active_task_id.clone(),
1810 binary,
1811 command,
1812 exit_code,
1813 stdout_bytes,
1814 stderr_bytes,
1815 duration_ms,
1816 resource_limit,
1817 };
1818 self.write_event(&event).await?;
1819 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
1820 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
1821 Ok(())
1822 }
1823
1824 pub(crate) async fn write_shell_detached(
1831 &mut self,
1832 turn: u32,
1833 work_id: &str,
1834 binary: &str,
1835 command: &str,
1836 grace_ms: u64,
1837 ) -> std::io::Result<()> {
1838 let event = ShellDetachedEvent {
1839 event_type: "shell_detached",
1840 event_id: new_event_id(),
1841 parent_id: self.turn_parent(),
1842 session_id: self.session_id.clone(),
1843 timestamp: timestamp_ms(),
1844 turn,
1845 task_id: self.active_task_id.clone(),
1846 work_id: work_id.to_string(),
1847 binary: binary.to_string(),
1848 command: command.to_string(),
1849 grace_ms,
1850 };
1851 self.write_event(&event).await?;
1852 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
1853 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
1854 Ok(())
1855 }
1856
1857 #[allow(clippy::too_many_arguments)]
1860 pub(crate) async fn write_shell_completed(
1861 &mut self,
1862 work_id: &str,
1863 binary: &str,
1864 command: &str,
1865 exit_code: i32,
1866 duration_ms: u64,
1867 output_path: &str,
1868 output_bytes: u64,
1869 resource_limit: Option<String>,
1870 status: &str,
1871 completion_task_id: &str,
1872 ) -> std::io::Result<()> {
1873 let event = ShellCompletedEvent {
1874 event_type: "shell_completed",
1875 event_id: new_event_id(),
1876 parent_id: self.session_parent(),
1877 session_id: self.session_id.clone(),
1878 timestamp: timestamp_ms(),
1879 work_id: work_id.to_string(),
1880 binary: binary.to_string(),
1881 command: command.to_string(),
1882 exit_code,
1883 duration_ms,
1884 output_path: output_path.to_string(),
1885 output_bytes,
1886 resource_limit,
1887 status: status.to_string(),
1888 completion_task_id: completion_task_id.to_string(),
1889 };
1890 self.write_event(&event).await
1891 }
1892
1893 pub(crate) async fn write_shell_abandoned(
1900 &mut self,
1901 work_id: &str,
1902 binary: &str,
1903 command: &str,
1904 running_ms: u64,
1905 recovered: Option<(i32, &str, u64)>,
1906 ) -> std::io::Result<()> {
1907 let event = ShellAbandonedEvent {
1908 event_type: "shell_abandoned",
1909 event_id: new_event_id(),
1910 parent_id: self.session_parent(),
1911 session_id: self.session_id.clone(),
1912 timestamp: timestamp_ms(),
1913 work_id: work_id.to_string(),
1914 binary: binary.to_string(),
1915 command: command.to_string(),
1916 running_ms,
1917 exit_code: recovered.map(|(exit_code, _, _)| exit_code),
1918 output_path: recovered.map(|(_, path, _)| path.to_string()),
1919 output_bytes: recovered.map(|(_, _, bytes)| bytes),
1920 };
1921 self.write_event(&event).await
1922 }
1923
1924 pub(crate) async fn write_shell_detach_unrecorded(
1926 &mut self,
1927 turn: u32,
1928 work_id: &str,
1929 binary: &str,
1930 reason: &str,
1931 ) -> std::io::Result<()> {
1932 let event = ShellDetachUnrecordedEvent {
1933 event_type: "shell_detach_unrecorded",
1934 event_id: new_event_id(),
1935 parent_id: self.turn_parent(),
1936 session_id: self.session_id.clone(),
1937 timestamp: timestamp_ms(),
1938 turn,
1939 task_id: self.active_task_id.clone(),
1940 work_id: work_id.to_string(),
1941 binary: binary.to_string(),
1942 reason: reason.to_string(),
1943 };
1944 self.write_event(&event).await
1945 }
1946
1947 pub(crate) async fn write_a2a_task_received(
1948 &mut self,
1949 task_id: &str,
1950 context_id: &str,
1951 message_id: &str,
1952 traceparent_from_caller: Option<&str>,
1953 ) -> std::io::Result<()> {
1954 let event = A2aTaskReceivedEvent {
1955 event_type: "a2a_task_received",
1956 event_id: new_event_id(),
1957 parent_id: self.session_parent(),
1958 session_id: self.session_id.clone(),
1959 timestamp: timestamp_ms(),
1960 task_id: task_id.to_string(),
1961 context_id: context_id.to_string(),
1962 message_id: message_id.to_string(),
1963 traceparent_from_caller: traceparent_from_caller.map(str::to_string),
1964 };
1965 self.write_event(&event).await
1966 }
1967
1968 pub(crate) async fn write_a2a_send(
1969 &mut self,
1970 peer_url: &str,
1971 message_id: &str,
1972 task_id: &str,
1973 context_id: &str,
1974 traceparent: Option<&str>,
1975 trust: TrustClass,
1976 ) -> std::io::Result<()> {
1977 let event = A2aSendEvent {
1978 event_type: "a2a_send",
1979 event_id: new_event_id(),
1980 parent_id: self.session_parent(),
1981 session_id: self.session_id.clone(),
1982 timestamp: timestamp_ms(),
1983 peer_url: peer_url.to_string(),
1984 message_id: message_id.to_string(),
1985 task_id: task_id.to_string(),
1986 context_id: context_id.to_string(),
1987 traceparent: traceparent.map(str::to_string),
1988 trust: trust.as_str().to_string(),
1989 };
1990 self.write_event(&event).await
1991 }
1992
1993 pub(crate) async fn write_task_start(
1994 &mut self,
1995 task_id: &str,
1996 context_id: &str,
1997 source: &str,
1998 provenance: TaskProvenance,
1999 delegation_id: Option<&str>,
2000 message_parts_bytes: u64,
2001 ) -> std::io::Result<()> {
2002 self.task_turns = 0;
2004 self.task_input_tokens = 0;
2005 self.task_output_tokens = 0;
2006 self.task_tool_calls = 0;
2007 self.task_shell_calls = 0;
2008 self.task_start_instant = Some(Instant::now());
2009 self.active_task_id = Some(task_id.to_string());
2010
2011 let event_id = new_event_id();
2012 self.task_event_id = Some(event_id.clone());
2013 self.turn_event_id = None;
2014
2015 let event = TaskStartEvent {
2016 event_type: "task_start",
2017 event_id: event_id.clone(),
2018 parent_id: self.session_parent(),
2019 session_id: self.session_id.clone(),
2020 timestamp: timestamp_ms(),
2021 task_id: task_id.to_string(),
2022 context_id: context_id.to_string(),
2023 source: source.to_string(),
2024 origin: provenance.origin().as_str().to_string(),
2025 trust: provenance.trust().as_str().to_string(),
2026 lane: TaskLane::for_origin(provenance.origin())
2029 .as_str()
2030 .to_string(),
2031 delegation_id: delegation_id.map(str::to_string),
2032 message_parts_bytes,
2033 };
2034 self.write_event(&event).await
2035 }
2036
2037 pub(crate) async fn write_task_end(
2038 &mut self,
2039 task_id: &str,
2040 exit_status: &str,
2041 reopen_count: u32,
2042 ) -> std::io::Result<()> {
2043 let duration_ms = self
2044 .task_start_instant
2045 .take()
2046 .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
2047 .unwrap_or(0);
2048
2049 let event = TaskEndEvent {
2050 event_type: "task_end",
2051 event_id: new_event_id(),
2052 parent_id: self.task_parent(),
2053 session_id: self.session_id.clone(),
2054 timestamp: timestamp_ms(),
2055 task_id: task_id.to_string(),
2056 exit_status: exit_status.to_string(),
2057 duration_ms,
2058 turns: self.task_turns,
2059 input_tokens: self.task_input_tokens,
2060 output_tokens: self.task_output_tokens,
2061 tool_calls: self.task_tool_calls,
2062 shell_calls: self.task_shell_calls,
2063 reopen_count,
2064 };
2065 self.active_task_id = None;
2066 self.task_event_id = None;
2067 self.turn_event_id = None;
2068 self.write_event(&event).await
2069 }
2070
2071 pub(crate) async fn write_task_canceled(
2077 &mut self,
2078 task_id: &str,
2079 turn: Option<u32>,
2080 phase: &str,
2081 detached_work_ids: Vec<String>,
2082 delegation_ids: Vec<String>,
2083 ) -> std::io::Result<()> {
2084 let event = TaskCanceledEvent {
2085 event_type: "task_canceled",
2086 event_id: new_event_id(),
2087 parent_id: self.task_parent(),
2088 session_id: self.session_id.clone(),
2089 timestamp: timestamp_ms(),
2090 task_id: task_id.to_string(),
2091 turn,
2092 phase: phase.to_string(),
2093 detached_work_ids,
2094 delegation_ids,
2095 };
2096 self.write_event(&event).await
2097 }
2098
2099 pub(crate) async fn write_task_reopened(
2103 &mut self,
2104 task_id: &str,
2105 hook_name: &str,
2106 reason: &str,
2107 reopen_number: u32,
2108 attempt_context: &'static str,
2109 turns_remaining: u32,
2110 ) -> std::io::Result<()> {
2111 let event = TaskReopenedEvent {
2112 event_type: "task_reopened",
2113 event_id: new_event_id(),
2114 parent_id: self.task_parent(),
2115 session_id: self.session_id.clone(),
2116 timestamp: timestamp_ms(),
2117 task_id: task_id.to_string(),
2118 hook_name: hook_name.to_string(),
2119 reason: reason.to_string(),
2120 reopen_number,
2121 attempt_context,
2122 turns_remaining,
2123 };
2124 self.write_event(&event).await
2125 }
2126
2127 pub(crate) fn task_turns(&self) -> u32 {
2132 self.task_turns
2133 }
2134
2135 pub(crate) async fn write_compaction(
2136 &mut self,
2137 turn: u32,
2138 tokens_before: u64,
2139 tokens_after: u64,
2140 ) -> std::io::Result<()> {
2141 let event = CompactionEvent {
2142 event_type: "compaction",
2143 event_id: new_event_id(),
2144 parent_id: self.turn_parent(),
2145 session_id: self.session_id.clone(),
2146 timestamp: timestamp_ms(),
2147 turn,
2148 task_id: self.active_task_id.clone(),
2149 tokens_before,
2150 tokens_after,
2151 };
2152 self.write_event(&event).await
2153 }
2154
2155 pub(crate) async fn write_compaction_declined(
2160 &mut self,
2161 turn: u32,
2162 tokens: u64,
2163 reason: &str,
2164 ) -> std::io::Result<()> {
2165 let event = CompactionDeclinedEvent {
2166 event_type: "compaction_declined",
2167 event_id: new_event_id(),
2168 parent_id: self.turn_parent(),
2169 session_id: self.session_id.clone(),
2170 timestamp: timestamp_ms(),
2171 turn,
2172 task_id: self.active_task_id.clone(),
2173 tokens,
2174 reason: reason.to_string(),
2175 };
2176 self.write_event(&event).await
2177 }
2178
2179 #[allow(clippy::too_many_arguments)]
2186 pub(crate) async fn write_context_seed(
2187 &mut self,
2188 hook_name: &str,
2189 tokens: u64,
2190 proposed_tokens: u64,
2191 budget_tokens: u64,
2192 outcome: &str,
2193 reason: Option<&str>,
2194 message_ids: Vec<String>,
2195 ) -> std::io::Result<()> {
2196 let event = ContextSeedEvent {
2197 event_type: "context_seed",
2198 event_id: new_event_id(),
2199 parent_id: self.task_parent(),
2200 session_id: self.session_id.clone(),
2201 timestamp: timestamp_ms(),
2202 task_id: self.active_task_id.clone(),
2203 hook_name: hook_name.to_string(),
2204 tokens,
2205 proposed_tokens,
2206 budget_tokens,
2207 outcome: outcome.to_string(),
2208 reason: reason.map(str::to_string),
2209 message_ids,
2210 };
2211 self.write_event(&event).await
2212 }
2213
2214 pub(crate) async fn write_retention(
2220 &mut self,
2221 store: &str,
2222 reason: &str,
2223 targets: Vec<String>,
2224 messages_dropped: Option<u64>,
2225 ) -> std::io::Result<()> {
2226 let event = RetentionEvent {
2227 event_type: "retention",
2228 event_id: new_event_id(),
2229 parent_id: self.session_parent(),
2230 session_id: self.session_id.clone(),
2231 timestamp: timestamp_ms(),
2232 store: store.to_string(),
2233 reason: reason.to_string(),
2234 removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
2235 targets,
2236 messages_dropped,
2237 };
2238 self.write_event(&event).await
2239 }
2240
2241 pub(crate) async fn write_call_denied(
2245 &mut self,
2246 turn: u32,
2247 event: &str,
2248 hook_name: &str,
2249 target: &str,
2250 reason: &str,
2251 ) -> std::io::Result<()> {
2252 let event = CallDeniedEvent {
2253 event_type: "call_denied",
2254 event_id: new_event_id(),
2255 parent_id: self.turn_parent(),
2256 session_id: self.session_id.clone(),
2257 timestamp: timestamp_ms(),
2258 turn,
2259 event: event.to_string(),
2260 hook_name: hook_name.to_string(),
2261 target: target.to_string(),
2262 reason: reason.to_string(),
2263 };
2264 self.write_event(&event).await
2265 }
2266
2267 pub(crate) async fn write_spend_ceiling_reached(
2270 &mut self,
2271 turn: u32,
2272 refusal: &crate::spend::SpendRefusal,
2273 origin: Option<&str>,
2274 ) -> std::io::Result<()> {
2275 let event = SpendCeilingReachedEvent {
2276 event_type: "spend_ceiling_reached",
2277 event_id: new_event_id(),
2278 parent_id: self.turn_parent(),
2279 session_id: self.session_id.clone(),
2280 timestamp: timestamp_ms(),
2281 turn,
2282 task_id: self.active_task_id.clone(),
2283 refusal,
2284 origin,
2285 };
2286 self.write_event(&event).await
2287 }
2288
2289 #[allow(clippy::too_many_arguments)]
2293 pub(crate) async fn write_protected_path_denied(
2294 &mut self,
2295 turn: u32,
2296 call: &str,
2297 target: &str,
2298 path: &str,
2299 rule: &str,
2300 signal: &str,
2301 reason: &str,
2302 ) -> std::io::Result<()> {
2303 let event = ProtectedPathDeniedEvent {
2304 event_type: "protected_path_denied",
2305 event_id: new_event_id(),
2306 parent_id: self.turn_parent(),
2307 session_id: self.session_id.clone(),
2308 timestamp: timestamp_ms(),
2309 turn,
2310 call: call.to_string(),
2311 target: target.to_string(),
2312 path: path.to_string(),
2313 rule: rule.to_string(),
2314 signal: signal.to_string(),
2315 reason: reason.to_string(),
2316 };
2317 self.write_event(&event).await
2318 }
2319
2320 pub(crate) async fn write_hook_dispatch_error(
2326 &mut self,
2327 hook_name: &str,
2328 event: &str,
2329 arm: &str,
2330 ) -> std::io::Result<()> {
2331 let event = HookDispatchErrorEvent {
2332 event_type: "hook_dispatch_error",
2333 event_id: new_event_id(),
2334 parent_id: self.session_parent(),
2335 session_id: self.session_id.clone(),
2336 timestamp: timestamp_ms(),
2337 hook_name: hook_name.to_string(),
2338 event: event.to_string(),
2339 arm: arm.to_string(),
2340 };
2341 self.write_event(&event).await
2342 }
2343
2344 pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
2354 let event = HarnessStartEvent {
2355 event_type: "harness_start",
2356 event_id: new_event_id(),
2357 parent_id: self.task_parent(),
2358 session_id: self.session_id.clone(),
2359 timestamp: timestamp_ms(),
2360 task_id: self.active_task_id.clone(),
2361 start,
2362 };
2363 self.write_event(&event).await
2364 }
2365
2366 pub(crate) async fn write_harness_warning(
2368 &mut self,
2369 code: &str,
2370 message: &str,
2371 ) -> std::io::Result<()> {
2372 let event = HarnessWarningEvent {
2373 event_type: "harness_warning",
2374 event_id: new_event_id(),
2375 parent_id: self.task_parent(),
2376 session_id: self.session_id.clone(),
2377 timestamp: timestamp_ms(),
2378 task_id: self.active_task_id.clone(),
2379 code: code.to_string(),
2380 message: message.to_string(),
2381 };
2382 self.write_event(&event).await
2383 }
2384
2385 pub(crate) async fn write_harness_session(
2387 &mut self,
2388 harness_session_id: &str,
2389 auth: &str,
2390 model: Option<&str>,
2391 ) -> std::io::Result<()> {
2392 let event = HarnessSessionEvent {
2393 event_type: "harness_session",
2394 event_id: new_event_id(),
2395 parent_id: self.task_parent(),
2396 session_id: self.session_id.clone(),
2397 timestamp: timestamp_ms(),
2398 task_id: self.active_task_id.clone(),
2399 harness_session_id: harness_session_id.to_string(),
2400 auth: auth.to_string(),
2401 model: model.map(str::to_string),
2402 };
2403 self.write_event(&event).await
2404 }
2405
2406 pub(crate) async fn write_harness_session_forgotten(
2408 &mut self,
2409 context_id: &str,
2410 harness_session_id: &str,
2411 requested_by: &str,
2412 ) -> std::io::Result<()> {
2413 let event = HarnessSessionForgottenEvent {
2414 event_type: "harness_session_forgotten",
2415 event_id: new_event_id(),
2416 parent_id: self.task_parent(),
2417 session_id: self.session_id.clone(),
2418 timestamp: timestamp_ms(),
2419 task_id: self.active_task_id.clone(),
2420 context_id: context_id.to_string(),
2421 harness_session_id: harness_session_id.to_string(),
2422 requested_by: requested_by.to_string(),
2423 };
2424 self.write_event(&event).await
2425 }
2426
2427 pub(crate) async fn write_harness_retry(
2429 &mut self,
2430 attempt: u32,
2431 reason: &str,
2432 ) -> std::io::Result<()> {
2433 let event = HarnessRetryEvent {
2434 event_type: "harness_retry",
2435 event_id: new_event_id(),
2436 parent_id: self.task_parent(),
2437 session_id: self.session_id.clone(),
2438 timestamp: timestamp_ms(),
2439 task_id: self.active_task_id.clone(),
2440 attempt,
2441 reason: reason.to_string(),
2442 };
2443 self.write_event(&event).await
2444 }
2445
2446 pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
2449 let event = HarnessNoteEvent {
2450 event_type: "harness_note",
2451 event_id: new_event_id(),
2452 parent_id: self.task_parent(),
2453 session_id: self.session_id.clone(),
2454 timestamp: timestamp_ms(),
2455 task_id: self.active_task_id.clone(),
2456 text: text.to_string(),
2457 };
2458 self.write_event(&event).await
2459 }
2460
2461 pub(crate) async fn write_harness_failed(
2464 &mut self,
2465 kind: &str,
2466 message: &str,
2467 source: &str,
2468 ) -> std::io::Result<()> {
2469 let event = HarnessFailedEvent {
2470 event_type: "harness_failed",
2471 event_id: new_event_id(),
2472 parent_id: self.task_parent(),
2473 session_id: self.session_id.clone(),
2474 timestamp: timestamp_ms(),
2475 task_id: self.active_task_id.clone(),
2476 kind: kind.to_string(),
2477 message: message.to_string(),
2478 source: source.to_string(),
2479 };
2480 self.write_event(&event).await
2481 }
2482
2483 pub(crate) async fn write_harness_interrupt(
2486 &mut self,
2487 method: &str,
2488 delivered: bool,
2489 grace_ms: u64,
2490 ) -> std::io::Result<()> {
2491 let event = HarnessInterruptEvent {
2492 event_type: "harness_interrupt",
2493 event_id: new_event_id(),
2494 parent_id: self.task_parent(),
2495 session_id: self.session_id.clone(),
2496 timestamp: timestamp_ms(),
2497 task_id: self.active_task_id.clone(),
2498 method: method.to_string(),
2499 delivered,
2500 grace_ms,
2501 };
2502 self.write_event(&event).await
2503 }
2504
2505 pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
2507 let event = HarnessExitEvent {
2508 event_type: "harness_exit",
2509 event_id: new_event_id(),
2510 parent_id: self.task_parent(),
2511 session_id: self.session_id.clone(),
2512 timestamp: timestamp_ms(),
2513 task_id: self.active_task_id.clone(),
2514 exit,
2515 };
2516 self.write_event(&event).await
2517 }
2518
2519 pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
2520 let duration_ms = self
2521 .session_start_time
2522 .elapsed()
2523 .as_millis()
2524 .try_into()
2525 .unwrap_or(u64::MAX);
2526 let event = SessionEndEvent {
2527 event_type: "session_end",
2528 event_id: new_event_id(),
2529 parent_id: self.session_parent(),
2530 session_id: self.session_id.clone(),
2531 timestamp: timestamp_ms(),
2532 total_turns: self.total_turns,
2533 total_input_tokens: self.total_input_tokens,
2534 total_output_tokens: self.total_output_tokens,
2535 total_tool_calls: self.total_tool_calls,
2536 total_shell_calls: self.total_shell_calls,
2537 duration_ms,
2538 exit_status: exit_status.to_string(),
2539 };
2540 self.write_event(&event).await?;
2541 self.session_ended = true;
2542 Ok(())
2543 }
2544
2545 pub(crate) async fn write_session_end_if_not_ended(
2548 &mut self,
2549 exit_status: &str,
2550 ) -> std::io::Result<()> {
2551 if self.session_started && !self.session_ended {
2552 self.write_session_end(exit_status).await?;
2553 }
2554 Ok(())
2555 }
2556
2557 pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
2558 self.writer.flush().await
2559 }
2560
2561 async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
2569 let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
2570 line.push('\n');
2571 self.writer.write_all(line.as_bytes()).await?;
2572 self.writer.flush().await
2573 }
2574}
2575
2576#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2578pub(crate) struct SessionGateway {
2579 pub(crate) artifact: String,
2581 pub(crate) host: String,
2583 pub(crate) credential_source: &'static str,
2585 pub(crate) metered: bool,
2588}
2589
2590#[derive(Serialize)]
2593struct GatewayCredentialEvent<'a> {
2594 event_type: &'static str,
2595 event_id: String,
2596 parent_id: Option<String>,
2597 session_id: String,
2598 timestamp: u64,
2599 artifact: &'a str,
2600 source: &'static str,
2601 #[serde(skip_serializing_if = "Option::is_none")]
2602 credential: Option<&'a str>,
2603 change: &'static str,
2604 #[serde(skip_serializing_if = "Option::is_none")]
2605 trigger: Option<&'static str>,
2606 #[serde(skip_serializing_if = "Option::is_none")]
2607 status: Option<u16>,
2608 #[serde(skip_serializing_if = "Option::is_none")]
2609 retried: Option<bool>,
2610 #[serde(skip_serializing_if = "Option::is_none")]
2611 reason: Option<&'static str>,
2612}
2613
2614#[derive(Serialize)]
2618struct InferenceCredentialEvent<'a> {
2619 event_type: &'static str,
2620 event_id: String,
2621 parent_id: Option<String>,
2622 session_id: String,
2623 timestamp: u64,
2624 source: &'static str,
2625 #[serde(skip_serializing_if = "Option::is_none")]
2626 credential: Option<&'a str>,
2627 change: &'static str,
2628 #[serde(skip_serializing_if = "Option::is_none")]
2629 trigger: Option<&'static str>,
2630 #[serde(skip_serializing_if = "Option::is_none")]
2631 status: Option<u16>,
2632 #[serde(skip_serializing_if = "Option::is_none")]
2633 retried: Option<bool>,
2634 #[serde(skip_serializing_if = "Option::is_none")]
2635 reason: Option<&'static str>,
2636}
2637
2638#[allow(clippy::type_complexity)]
2641fn credential_change_fields(
2642 change: CredentialChange,
2643) -> (
2644 &'static str,
2645 Option<&'static str>,
2646 Option<u16>,
2647 Option<bool>,
2648 Option<&'static str>,
2649) {
2650 match change {
2651 CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
2652 CredentialChange::Rejected { status, retried } => {
2653 ("rejected", None, Some(status), Some(retried), None)
2654 }
2655 CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
2656 }
2657}
2658
2659pub struct ResourceTraceAppender {
2674 file: tokio::sync::Mutex<File>,
2676 session_id: String,
2677 session_event_id: String,
2682}
2683
2684impl ResourceTraceAppender {
2685 pub async fn open(
2686 workdir: &Path,
2687 session_id: String,
2688 session_event_id: String,
2689 ) -> std::io::Result<Self> {
2690 let file = OpenOptions::new()
2691 .create(true)
2692 .append(true)
2693 .open(workdir.join("trace.jsonl"))
2694 .await?;
2695 Ok(Self {
2696 file: tokio::sync::Mutex::new(file),
2697 session_id,
2698 session_event_id,
2699 })
2700 }
2701
2702 #[allow(clippy::too_many_arguments)]
2706 pub(crate) async fn write_resource_list(
2707 &self,
2708 root: &str,
2709 entry_count: usize,
2710 total_bytes: u64,
2711 generation: u64,
2712 containment_achieved: ContainmentClass,
2713 outcome: &str,
2714 reason: Option<String>,
2715 ) {
2716 let event = ResourceListEvent {
2717 event_type: "resource_list",
2718 event_id: new_event_id(),
2719 parent_id: Some(self.session_event_id.clone()),
2720 session_id: self.session_id.clone(),
2721 timestamp: timestamp_ms(),
2722 root: root.to_string(),
2723 entry_count,
2724 total_bytes,
2725 generation,
2726 containment_achieved,
2727 outcome: outcome.to_string(),
2728 reason,
2729 };
2730 self.append(&event).await;
2731 }
2732
2733 #[allow(clippy::too_many_arguments)]
2736 pub(crate) async fn write_resource_read(
2737 &self,
2738 path: &str,
2739 outcome: &str,
2740 bytes: Option<u64>,
2741 sha256: Option<String>,
2742 generation: u64,
2743 containment_achieved: ContainmentClass,
2744 reason: Option<String>,
2745 ) {
2746 let event = ResourceReadEvent {
2747 event_type: "resource_read",
2748 event_id: new_event_id(),
2749 parent_id: Some(self.session_event_id.clone()),
2750 session_id: self.session_id.clone(),
2751 timestamp: timestamp_ms(),
2752 path: path.to_string(),
2753 outcome: outcome.to_string(),
2754 bytes,
2755 sha256,
2756 generation,
2757 containment_achieved,
2758 reason,
2759 };
2760 self.append(&event).await;
2761 }
2762
2763 #[allow(clippy::too_many_arguments)]
2766 pub(crate) async fn write_peer_handle_mint(
2767 &self,
2768 handle_id: Option<String>,
2769 path: &str,
2770 audience: &str,
2771 expires_at_ms: Option<u64>,
2772 outcome: &str,
2773 reason: Option<String>,
2774 ) {
2775 let event = PeerHandleMintEvent {
2776 event_type: "peer_handle_mint",
2777 event_id: new_event_id(),
2778 parent_id: Some(self.session_event_id.clone()),
2779 session_id: self.session_id.clone(),
2780 timestamp: timestamp_ms(),
2781 handle_id,
2782 path: path.to_string(),
2783 audience: audience.to_string(),
2784 expires_at_ms,
2785 outcome: outcome.to_string(),
2786 reason,
2787 };
2788 self.append(&event).await;
2789 }
2790
2791 #[allow(clippy::too_many_arguments)]
2794 pub(crate) async fn write_peer_handle_redeem(
2795 &self,
2796 handle_id: &str,
2797 path: Option<String>,
2798 generation: u64,
2799 audience_asserted: Option<String>,
2800 bytes: Option<u64>,
2801 sha256: Option<String>,
2802 outcome: &str,
2803 reason: Option<String>,
2804 ) {
2805 let event = PeerHandleRedeemEvent {
2806 event_type: "peer_handle_redeem",
2807 event_id: new_event_id(),
2808 parent_id: Some(self.session_event_id.clone()),
2809 session_id: self.session_id.clone(),
2810 timestamp: timestamp_ms(),
2811 handle_id: handle_id.to_string(),
2812 path,
2813 generation,
2814 audience_asserted,
2815 bytes,
2816 sha256,
2817 outcome: outcome.to_string(),
2818 reason,
2819 };
2820 self.append(&event).await;
2821 }
2822
2823 #[allow(clippy::too_many_arguments)]
2825 pub(crate) async fn write_peer_file_fetch(
2826 &self,
2827 peer: &str,
2828 handle_id: &str,
2829 stored_path: Option<String>,
2830 bytes: Option<u64>,
2831 sha256: Option<String>,
2832 outcome: &str,
2833 reason: Option<String>,
2834 ) {
2835 let event = PeerFileFetchEvent {
2836 event_type: "peer_file_fetch",
2837 event_id: new_event_id(),
2838 parent_id: Some(self.session_event_id.clone()),
2839 session_id: self.session_id.clone(),
2840 timestamp: timestamp_ms(),
2841 peer: peer.to_string(),
2842 handle_id: handle_id.to_string(),
2843 stored_path,
2844 bytes,
2845 sha256,
2846 outcome: outcome.to_string(),
2847 reason,
2848 };
2849 self.append(&event).await;
2850 }
2851
2852 pub(crate) async fn write_delegation_start(
2857 &self,
2858 delegation_id: &str,
2859 capsule: &str,
2860 version: &str,
2861 child_session_id: &str,
2862 child_workdir: &str,
2863 ) {
2864 let event = DelegationStartEvent::new(
2865 &self.session_id,
2866 &self.session_event_id,
2867 delegation_id,
2868 capsule,
2869 version,
2870 child_session_id,
2871 child_workdir,
2872 );
2873 self.append(&event).await;
2874 }
2875
2876 #[allow(clippy::too_many_arguments)]
2880 pub(crate) async fn write_delegation(
2881 &self,
2882 capsule: &str,
2883 version: &str,
2884 delegation_id: Option<String>,
2885 child_session_id: Option<String>,
2886 duration_ms: u64,
2887 outcome: &str,
2888 reason: Option<String>,
2889 ) {
2890 let event = DelegationEvent::new(
2891 &self.session_id,
2892 &self.session_event_id,
2893 capsule,
2894 version,
2895 delegation_id,
2896 child_session_id,
2897 duration_ms,
2898 outcome,
2899 reason,
2900 );
2901 self.append(&event).await;
2902 }
2903
2904 pub(crate) async fn write_inference_credential(
2907 &self,
2908 source: &'static str,
2909 credential: Option<&str>,
2910 change: CredentialChange,
2911 ) {
2912 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
2913 let event = InferenceCredentialEvent {
2914 event_type: "inference_credential",
2915 event_id: new_event_id(),
2916 parent_id: Some(self.session_event_id.clone()),
2917 session_id: self.session_id.clone(),
2918 timestamp: timestamp_ms(),
2919 source,
2920 credential,
2921 change: change_name,
2922 trigger,
2923 status,
2924 retried,
2925 reason,
2926 };
2927 self.append(&event).await;
2928 }
2929
2930 pub(crate) async fn write_gateway_credential(
2933 &self,
2934 artifact: &str,
2935 source: &'static str,
2936 credential: Option<&str>,
2937 change: CredentialChange,
2938 ) {
2939 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
2940 let event = GatewayCredentialEvent {
2941 event_type: "gateway_credential",
2942 event_id: new_event_id(),
2943 parent_id: Some(self.session_event_id.clone()),
2944 session_id: self.session_id.clone(),
2945 timestamp: timestamp_ms(),
2946 artifact,
2947 source,
2948 credential,
2949 change: change_name,
2950 trigger,
2951 status,
2952 retried,
2953 reason,
2954 };
2955 self.append(&event).await;
2956 }
2957
2958 async fn append(&self, event: &impl Serialize) {
2959 let Ok(mut line) = serde_json::to_string(event) else {
2960 return;
2961 };
2962 line.push('\n');
2963 let mut file = self.file.lock().await;
2964 if file.write_all(line.as_bytes()).await.is_ok() {
2965 let _ = file.flush().await;
2966 }
2967 }
2968}
2969
2970pub(crate) struct PriorSessionTraceAppender {
2985 file: File,
2986 session_id: String,
2988 session_event_id: Option<String>,
2990 terminate_torn_tail: bool,
2994}
2995
2996impl PriorSessionTraceAppender {
2997 pub(crate) async fn open(
3000 trace_path: &Path,
3001 session_id: String,
3002 session_event_id: Option<String>,
3003 ) -> std::io::Result<Self> {
3004 let terminate_torn_tail = !ends_with_newline(trace_path)?;
3005 let file = OpenOptions::new().append(true).open(trace_path).await?;
3006 Ok(Self {
3007 file,
3008 session_id,
3009 session_event_id,
3010 terminate_torn_tail,
3011 })
3012 }
3013
3014 pub(crate) async fn write_shell_lost(
3020 &mut self,
3021 work: &crate::detached::LostWork,
3022 reconciled_by_session: &str,
3023 reconciled_task_id: &str,
3024 ) -> std::io::Result<()> {
3025 let event = ShellLostEvent {
3026 event_type: "shell_lost",
3027 event_id: new_event_id(),
3028 parent_id: self.session_event_id.clone(),
3029 session_id: self.session_id.clone(),
3030 timestamp: timestamp_ms(),
3031 work_id: work.work_id.clone(),
3032 binary: work.binary.clone(),
3033 command: work.command.clone(),
3034 detached_at_ms: work.detached_at_ms,
3035 reconciled_by_session: reconciled_by_session.to_string(),
3036 reconciled_task_id: reconciled_task_id.to_string(),
3037 };
3038 let mut line = if self.terminate_torn_tail {
3039 "\n".to_string()
3040 } else {
3041 String::new()
3042 };
3043 line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
3044 line.push('\n');
3045 self.file.write_all(line.as_bytes()).await?;
3046 self.file.flush().await?;
3047 self.terminate_torn_tail = false;
3048 Ok(())
3049 }
3050}
3051
3052fn ends_with_newline(path: &Path) -> std::io::Result<bool> {
3055 use std::io::{Read, Seek, SeekFrom};
3056
3057 let mut file = std::fs::File::open(path)?;
3058 let length = file.metadata()?.len();
3059 if length == 0 {
3060 return Ok(true);
3061 }
3062 file.seek(SeekFrom::End(-1))?;
3063 let mut last = [0u8; 1];
3064 file.read_exact(&mut last)?;
3065 Ok(last[0] == b'\n')
3066}
3067
3068pub(crate) fn timestamp_ms() -> u64 {
3069 SystemTime::now()
3070 .duration_since(UNIX_EPOCH)
3071 .unwrap_or_default()
3072 .as_millis() as u64
3073}
3074
3075#[derive(Serialize, Clone)]
3082pub(crate) struct PlanStepShape {
3083 pub(crate) step_id: String,
3084 pub(crate) kind: &'static str,
3087 pub(crate) depends_on: Vec<String>,
3088 pub(crate) has_condition: bool,
3090}
3091
3092#[derive(Serialize)]
3095struct PlanStartEvent {
3096 event_type: &'static str,
3097 event_id: String,
3098 parent_id: Option<String>,
3099 session_id: String,
3100 timestamp: u64,
3101 plan_id: String,
3102 step_count: usize,
3103 steps: Vec<PlanStepShape>,
3104}
3105
3106#[derive(Serialize)]
3112struct PlanStepStartEvent {
3113 event_type: &'static str,
3114 event_id: String,
3115 parent_id: Option<String>,
3116 session_id: String,
3117 timestamp: u64,
3118 plan_id: String,
3119 step_id: String,
3120 kind: &'static str,
3121 depends_on: Vec<String>,
3122}
3123
3124#[derive(Serialize)]
3128struct PlanStepEvent {
3129 event_type: &'static str,
3130 event_id: String,
3131 parent_id: Option<String>,
3132 session_id: String,
3133 timestamp: u64,
3134 plan_id: String,
3135 step_id: String,
3136 kind: &'static str,
3137 status: &'static str,
3138 attempts: u32,
3139 duration_ms: u64,
3140 #[serde(skip_serializing_if = "Option::is_none")]
3141 error: Option<String>,
3142 #[serde(skip_serializing_if = "Option::is_none")]
3145 input: Option<Value>,
3146 #[serde(skip_serializing_if = "Option::is_none")]
3150 state_effect: Option<String>,
3151 #[serde(skip_serializing_if = "Option::is_none")]
3153 resource_id: Option<String>,
3154}
3155
3156#[derive(Serialize)]
3158struct PlanEndEvent {
3159 event_type: &'static str,
3160 event_id: String,
3161 parent_id: Option<String>,
3162 session_id: String,
3163 timestamp: u64,
3164 plan_id: String,
3165 outcome: &'static str,
3166 #[serde(skip_serializing_if = "Option::is_none")]
3167 failed_step: Option<String>,
3168 steps_total: usize,
3169 steps_succeeded: usize,
3170 steps_failed: usize,
3171 steps_skipped: usize,
3172 duration_ms: u64,
3173 #[serde(skip_serializing_if = "Option::is_none")]
3176 reason: Option<String>,
3177}
3178
3179pub(crate) struct PlanStepRecord {
3181 pub(crate) plan_id: String,
3182 pub(crate) step_id: String,
3183 pub(crate) kind: &'static str,
3184 pub(crate) status: &'static str,
3187 pub(crate) attempts: u32,
3189 pub(crate) duration_ms: u64,
3190 pub(crate) error: Option<String>,
3191 pub(crate) input: Option<Value>,
3192 pub(crate) state_effect: Option<String>,
3193 pub(crate) resource_id: Option<String>,
3194}
3195
3196pub(crate) struct PlanEndRecord {
3198 pub(crate) plan_id: String,
3199 pub(crate) outcome: &'static str,
3200 pub(crate) failed_step: Option<String>,
3201 pub(crate) steps_total: usize,
3202 pub(crate) steps_succeeded: usize,
3203 pub(crate) steps_failed: usize,
3204 pub(crate) steps_skipped: usize,
3205 pub(crate) duration_ms: u64,
3206 pub(crate) reason: Option<String>,
3207}
3208
3209pub struct PlanTraceAppender {
3220 file: std::sync::Mutex<std::fs::File>,
3221 session_id: String,
3222 session_event_id: String,
3225}
3226
3227impl PlanTraceAppender {
3228 pub fn open(
3229 workdir: &Path,
3230 session_id: String,
3231 session_event_id: String,
3232 ) -> std::io::Result<Self> {
3233 let file = std::fs::OpenOptions::new()
3234 .create(true)
3235 .append(true)
3236 .open(workdir.join("trace.jsonl"))?;
3237 Ok(Self {
3238 file: std::sync::Mutex::new(file),
3239 session_id,
3240 session_event_id,
3241 })
3242 }
3243
3244 pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
3246 let event_id = new_event_id();
3247 let event = PlanStartEvent {
3248 event_type: "plan_start",
3249 event_id: event_id.clone(),
3250 parent_id: Some(self.session_event_id.clone()),
3251 session_id: self.session_id.clone(),
3252 timestamp: timestamp_ms(),
3253 plan_id: plan_id.to_string(),
3254 step_count: steps.len(),
3255 steps,
3256 };
3257 self.append(&event);
3258 event_id
3259 }
3260
3261 pub(crate) fn write_plan_step_start(
3262 &self,
3263 plan_node: &str,
3264 plan_id: &str,
3265 step_id: &str,
3266 kind: &'static str,
3267 depends_on: Vec<String>,
3268 ) {
3269 let event = PlanStepStartEvent {
3270 event_type: "plan_step_start",
3271 event_id: new_event_id(),
3272 parent_id: Some(plan_node.to_string()),
3273 session_id: self.session_id.clone(),
3274 timestamp: timestamp_ms(),
3275 plan_id: plan_id.to_string(),
3276 step_id: step_id.to_string(),
3277 kind,
3278 depends_on,
3279 };
3280 self.append(&event);
3281 }
3282
3283 pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
3284 let input = record.input.map(|mut value| {
3287 crate::peer_handoff::redact_handles_in_json(&mut value);
3288 value
3289 });
3290 let event = PlanStepEvent {
3291 event_type: "plan_step",
3292 event_id: new_event_id(),
3293 parent_id: Some(plan_node.to_string()),
3294 session_id: self.session_id.clone(),
3295 timestamp: timestamp_ms(),
3296 plan_id: record.plan_id,
3297 step_id: record.step_id,
3298 kind: record.kind,
3299 status: record.status,
3300 attempts: record.attempts,
3301 duration_ms: record.duration_ms,
3302 error: record.error,
3303 input,
3304 state_effect: record.state_effect,
3305 resource_id: record.resource_id,
3306 };
3307 self.append(&event);
3308 }
3309
3310 pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
3313 let event = PlanEndEvent {
3314 event_type: "plan_end",
3315 event_id: new_event_id(),
3316 parent_id: Some(
3317 plan_node
3318 .map(str::to_string)
3319 .unwrap_or_else(|| self.session_event_id.clone()),
3320 ),
3321 session_id: self.session_id.clone(),
3322 timestamp: timestamp_ms(),
3323 plan_id: record.plan_id,
3324 outcome: record.outcome,
3325 failed_step: record.failed_step,
3326 steps_total: record.steps_total,
3327 steps_succeeded: record.steps_succeeded,
3328 steps_failed: record.steps_failed,
3329 steps_skipped: record.steps_skipped,
3330 duration_ms: record.duration_ms,
3331 reason: record.reason,
3332 };
3333 self.append(&event);
3334 }
3335
3336 pub(crate) fn write_delegation_start(
3342 &self,
3343 delegation_id: &str,
3344 capsule: &str,
3345 version: &str,
3346 child_session_id: &str,
3347 child_workdir: &str,
3348 ) {
3349 let event = DelegationStartEvent::new(
3350 &self.session_id,
3351 &self.session_event_id,
3352 delegation_id,
3353 capsule,
3354 version,
3355 child_session_id,
3356 child_workdir,
3357 );
3358 self.append(&event);
3359 }
3360
3361 #[allow(clippy::too_many_arguments)]
3369 pub(crate) fn write_delegation(
3370 &self,
3371 capsule: &str,
3372 version: &str,
3373 delegation_id: Option<String>,
3374 child_session_id: Option<String>,
3375 duration_ms: u64,
3376 outcome: &str,
3377 reason: Option<String>,
3378 ) {
3379 let event = DelegationEvent::new(
3380 &self.session_id,
3381 &self.session_event_id,
3382 capsule,
3383 version,
3384 delegation_id,
3385 child_session_id,
3386 duration_ms,
3387 outcome,
3388 reason,
3389 );
3390 self.append(&event);
3391 }
3392
3393 fn append(&self, event: &impl Serialize) {
3397 use std::io::Write as _;
3398
3399 let Ok(mut line) = serde_json::to_string(event) else {
3400 return;
3401 };
3402 line.push('\n');
3403 let Ok(mut file) = self.file.lock() else {
3404 return;
3405 };
3406 if file.write_all(line.as_bytes()).is_ok() {
3407 let _ = file.flush();
3408 }
3409 }
3410}
3411
3412#[cfg(test)]
3415mod tests {
3416 use super::*;
3417 use crate::{
3418 containment::scope_report_for_tier, sandbox::EnforcementTier, sealed::UsernsGrant,
3419 trace_blobs::BLOB_DIR_NAME, types::CapabilityPolicy,
3420 };
3421 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
3422 use serde_json::Value;
3423
3424 use crate::origin::TaskOrigin;
3425
3426 fn event_provenance() -> TaskProvenance {
3428 TaskProvenance::derive(TaskOrigin::Event, None)
3429 }
3430
3431 fn report_for(
3435 declared: ContainmentClass,
3436 tier: EnforcementTier,
3437 workdir_exec: bool,
3438 ) -> ScopeReport {
3439 scope_report_for_tier(
3440 &CapabilityPolicy {
3441 workdir_exec_allowed: workdir_exec,
3442 ..CapabilityPolicy::default()
3443 },
3444 declared,
3445 tier,
3446 None,
3447 None,
3448 None,
3449 Vec::new(),
3450 Vec::new(),
3451 Vec::new(),
3452 crate::cgroup::IoMaxReport::default(),
3453 )
3454 }
3455
3456 fn demotion_info() -> crate::detached::DetachedDispatchInfo {
3457 crate::detached::DetachedDispatchInfo {
3458 work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
3459 binary: "/usr/bin/bash".to_string(),
3460 command: "make -j8".to_string(),
3461 grace_ms: 1_000,
3462 }
3463 }
3464
3465 #[tokio::test]
3468 async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
3469 let dir = tempfile::tempdir().unwrap();
3470 std::os::unix::fs::symlink("/dev/full", dir.path().join("trace.jsonl")).unwrap();
3473 let mut writer = make_writer(dir.path()).await;
3474 let info = demotion_info();
3475
3476 assert!(
3477 writer
3478 .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
3479 .await
3480 .is_err(),
3481 "the sink under this writer must be unwritable for the claim below to mean anything"
3482 );
3483 crate::agent::record_demotion(&mut writer, 1, &info).await;
3484 }
3485
3486 #[tokio::test]
3488 async fn a_recorded_demotion_writes_only_its_own_marker() {
3489 let dir = tempfile::tempdir().unwrap();
3490 let mut writer = make_writer(dir.path()).await;
3491 crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
3492
3493 let events = read_events(dir.path());
3494 assert_eq!(events.len(), 1);
3495 assert_eq!(events[0]["event_type"], "shell_detached");
3496 assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
3497 assert_eq!(events[0]["turn"], 3);
3498 }
3499
3500 #[tokio::test]
3501 async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
3502 let dir = tempfile::tempdir().unwrap();
3503 let mut writer = make_writer(dir.path()).await;
3504 writer
3505 .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
3506 .await
3507 .unwrap();
3508
3509 let events = read_events(dir.path());
3510 assert_eq!(events.len(), 1);
3511 assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
3512 assert_eq!(events[0]["turn"], 2);
3513 assert_eq!(events[0]["work_id"], "wrk_0a1b");
3514 assert_eq!(events[0]["binary"], "/usr/bin/bash");
3515 assert_eq!(events[0]["reason"], "No space left");
3516 }
3517
3518 #[tokio::test]
3522 async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
3523 let dir = tempfile::tempdir().unwrap();
3524 let mut writer = make_writer(dir.path()).await;
3525 writer
3526 .write_shell_abandoned(
3527 "wrk_0a1b2c3d4e5f6a7b",
3528 "/usr/bin/bash",
3529 "sleep 45; echo done",
3530 45_012,
3531 None,
3532 )
3533 .await
3534 .unwrap();
3535
3536 let events = read_events(dir.path());
3537 assert_eq!(events.len(), 1);
3538 let event = &events[0];
3539 assert_eq!(event["event_type"], "shell_abandoned");
3540 assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
3541 assert_eq!(event["binary"], "/usr/bin/bash");
3542 assert_eq!(event["command"], "sleep 45; echo done");
3543 assert_eq!(event["running_ms"], 45_012);
3544 for absent in ["exit_code", "output_path", "output_bytes"] {
3545 assert!(
3546 event.get(absent).is_none(),
3547 "{absent} must be omitted, not written as null: {event}"
3548 );
3549 }
3550 }
3551
3552 #[tokio::test]
3556 async fn an_abandoned_command_that_finished_carries_its_result() {
3557 let dir = tempfile::tempdir().unwrap();
3558 let mut writer = make_writer(dir.path()).await;
3559 writer
3560 .write_shell_abandoned(
3561 "wrk_0a1b2c3d4e5f6a7b",
3562 "/usr/bin/bash",
3563 "sleep 2; echo done",
3564 2_014,
3565 Some((0, "logs/wrk_0a1b2c3d4e5f6a7b.log", 91)),
3566 )
3567 .await
3568 .unwrap();
3569
3570 let event = &read_events(dir.path())[0];
3571 assert_eq!(event["event_type"], "shell_abandoned");
3572 assert_eq!(event["running_ms"], 2_014);
3573 assert_eq!(event["exit_code"], 0);
3574 assert_eq!(event["output_path"], "logs/wrk_0a1b2c3d4e5f6a7b.log");
3575 assert_eq!(event["output_bytes"], 91);
3576 }
3577
3578 async fn make_writer(dir: &std::path::Path) -> TraceWriter {
3579 make_writer_with_opts(dir, TraceCapture::Meta).await
3580 }
3581
3582 async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
3583 make_writer_with_prompt(dir, capture, None, false).await
3584 }
3585
3586 async fn make_writer_with_prompt(
3587 dir: &std::path::Path,
3588 capture: TraceCapture,
3589 system_prompt: Option<&str>,
3590 system_prompt_overridden: bool,
3591 ) -> TraceWriter {
3592 TraceWriter::open(
3593 dir,
3594 "test-session-id".to_string(),
3595 "test-capsule".to_string(),
3596 "1.0.0".to_string(),
3597 "claude-test".to_string(),
3598 vec!["shell".to_string()],
3599 report_for(
3600 ContainmentClass::Advisory,
3601 EnforcementTier::EnvironmentOnly,
3602 false,
3603 ),
3604 capture,
3605 system_prompt.map(str::to_string),
3606 system_prompt_overridden,
3607 None,
3608 None,
3609 None,
3610 None,
3611 )
3612 .await
3613 .unwrap()
3614 }
3615
3616 fn read_events(dir: &std::path::Path) -> Vec<Value> {
3617 let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
3618 content
3619 .lines()
3620 .filter(|l| !l.is_empty())
3621 .map(|l| serde_json::from_str(l).unwrap())
3622 .collect()
3623 }
3624
3625 #[tokio::test]
3626 async fn session_start_fields() {
3627 let dir = tempfile::tempdir().unwrap();
3628 let mut w = make_writer(dir.path()).await;
3629 w.write_session_start(10, vec!["bash".to_string()])
3630 .await
3631 .unwrap();
3632 w.flush().await.unwrap();
3633
3634 let events = read_events(dir.path());
3635 assert_eq!(events.len(), 1);
3636 let e = &events[0];
3637 assert_eq!(e["event_type"], "session_start");
3638 assert_eq!(e["session_id"], "test-session-id");
3639 assert_eq!(e["capsule_name"], "test-capsule");
3640 assert_eq!(e["capsule_version"], "1.0.0");
3641 assert_eq!(e["model"], "claude-test");
3642 assert_eq!(e["max_turns"], 10);
3643 assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
3644 assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
3645 assert_eq!(e["containment_declared"], "advisory");
3646 assert_eq!(e["containment_achieved"], "advisory");
3647 assert_eq!(e["workdir_exec"], false);
3648 assert!(e["timestamp"].as_u64().unwrap() > 0);
3649 }
3650
3651 #[tokio::test]
3656 async fn session_start_records_system_prompt_source_and_hash() {
3657 let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
3658
3659 for (prompt, overridden, source, sha) in [
3660 (Some("Be terse."), false, "manifest", Some(&expected_sha)),
3661 (Some("Be terse."), true, "cli", Some(&expected_sha)),
3662 (None, false, "none", None),
3663 (None, true, "cli", None),
3666 ] {
3667 let dir = tempfile::tempdir().unwrap();
3668 let mut w =
3669 make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
3670 w.write_session_start(1, Vec::new()).await.unwrap();
3671 w.flush().await.unwrap();
3672
3673 let e = &read_events(dir.path())[0];
3674 assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
3675 match sha {
3676 Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
3677 None => assert!(
3678 e["system_prompt_sha256"].is_null(),
3679 "no prompt in effect must hash to null, got {}",
3680 e["system_prompt_sha256"]
3681 ),
3682 }
3683 }
3684 }
3685
3686 #[tokio::test]
3690 async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
3691 let sha = murmur_artifact::sha256_hex(b"Be terse.");
3692
3693 let dir = tempfile::tempdir().unwrap();
3694 let mut w =
3695 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
3696 w.write_session_start(1, Vec::new()).await.unwrap();
3697 w.flush().await.unwrap();
3698
3699 let e = &read_events(dir.path())[0];
3700 assert!(
3701 e.get("system_prompt").is_none(),
3702 "the verbatim prompt is not a session_start field, got {e}"
3703 );
3704 assert_eq!(e["system_prompt_sha256"], sha.as_str());
3705 assert!(
3706 !dir.path().join(BLOB_DIR_NAME).exists(),
3707 "meta stores no bodies"
3708 );
3709
3710 let opted_in = tempfile::tempdir().unwrap();
3711 let mut w = make_writer_with_prompt(
3712 opted_in.path(),
3713 TraceCapture::Content,
3714 Some("Be terse."),
3715 false,
3716 )
3717 .await;
3718 w.write_session_start(1, Vec::new()).await.unwrap();
3719 w.flush().await.unwrap();
3720
3721 let event = &read_events(opted_in.path())[0];
3722 assert!(event.get("system_prompt").is_none());
3723 assert_eq!(event["system_prompt_sha256"], sha.as_str());
3724 let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
3725 assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
3726 }
3727
3728 #[tokio::test]
3731 async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
3732 let dir = tempfile::tempdir().unwrap();
3733 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
3734 w.write_session_start(1, Vec::new()).await.unwrap();
3735 w.flush().await.unwrap();
3736
3737 let e = &read_events(dir.path())[0];
3738 assert!(e.get("system_prompt").is_none());
3739 assert!(e["system_prompt_sha256"].is_null());
3740 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
3741 }
3742
3743 #[tokio::test]
3745 async fn every_task_session_start_repeats_the_same_system_prompt_record() {
3746 let dir = tempfile::tempdir().unwrap();
3747 let mut w =
3748 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
3749 w.write_session_start(1, Vec::new()).await.unwrap();
3750 w.write_session_start(1, Vec::new()).await.unwrap();
3751 w.flush().await.unwrap();
3752
3753 let events = read_events(dir.path());
3754 assert_eq!(events.len(), 2);
3755 assert_eq!(
3756 events[0]["system_prompt_source"],
3757 events[1]["system_prompt_source"]
3758 );
3759 assert_eq!(
3760 events[0]["system_prompt_sha256"],
3761 events[1]["system_prompt_sha256"]
3762 );
3763 assert_eq!(events[1]["system_prompt_source"], "cli");
3764 }
3765
3766 #[tokio::test]
3771 async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
3772 let dir = tempfile::tempdir().unwrap();
3773 let mut w = TraceWriter::open(
3774 dir.path(),
3775 "test-session-id".to_string(),
3776 "test-capsule".to_string(),
3777 "1.0.0".to_string(),
3778 "claude-test".to_string(),
3779 Vec::new(),
3780 report_for(
3782 ContainmentClass::Advisory,
3783 EnforcementTier::KernelFull,
3784 true,
3785 ),
3786 TraceCapture::Meta,
3787 None,
3788 false,
3789 None,
3790 None,
3791 None,
3792 None,
3793 )
3794 .await
3795 .unwrap();
3796 w.write_session_start(1, Vec::new()).await.unwrap();
3797 w.flush().await.unwrap();
3798
3799 let events = read_events(dir.path());
3800 assert_eq!(events[0]["workdir_exec"], true);
3801 assert_eq!(events[0]["containment_achieved"], "advisory");
3802 }
3803
3804 #[tokio::test]
3807 async fn session_start_records_declared_and_achieved_containment_separately() {
3808 let dir = tempfile::tempdir().unwrap();
3809 let mut w = TraceWriter::open(
3810 dir.path(),
3811 "test-session-id".to_string(),
3812 "test-capsule".to_string(),
3813 "1.0.0".to_string(),
3814 "claude-test".to_string(),
3815 Vec::new(),
3816 report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
3817 TraceCapture::Meta,
3818 None,
3819 false,
3820 None,
3821 None,
3822 None,
3823 None,
3824 )
3825 .await
3826 .unwrap();
3827 w.write_session_start(1, Vec::new()).await.unwrap();
3828 w.flush().await.unwrap();
3829
3830 let events = read_events(dir.path());
3831 assert_eq!(events[0]["containment_declared"], "sealed");
3832 assert_eq!(events[0]["containment_achieved"], "scoped");
3833 }
3834
3835 #[tokio::test]
3843 async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
3844 async fn event_for(grant: UsernsGrant) -> Value {
3845 let report = scope_report_for_tier(
3846 &CapabilityPolicy::default(),
3847 ContainmentClass::Sealed,
3848 EnforcementTier::KernelSealed,
3849 None,
3850 Some(grant),
3851 None,
3852 Vec::new(),
3853 Vec::new(),
3854 Vec::new(),
3855 crate::cgroup::IoMaxReport::default(),
3856 );
3857 let dir = tempfile::tempdir().unwrap();
3858 let mut w = TraceWriter::open(
3859 dir.path(),
3860 "test-session-id".to_string(),
3861 "test-capsule".to_string(),
3862 "1.0.0".to_string(),
3863 "claude-test".to_string(),
3864 Vec::new(),
3865 report,
3866 TraceCapture::Meta,
3867 None,
3868 false,
3869 None,
3870 None,
3871 None,
3872 None,
3873 )
3874 .await
3875 .unwrap();
3876 w.write_session_start(1, Vec::new()).await.unwrap();
3877 w.flush().await.unwrap();
3878 let mut event = read_events(dir.path()).remove(0);
3879 event.as_object_mut().unwrap().remove("timestamp");
3882 event
3883 }
3884
3885 let through_profile = event_for(UsernsGrant::ProfileConfining).await;
3886 let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
3887
3888 assert_eq!(through_profile["containment_achieved"], "sealed");
3889 assert_eq!(host_wide["containment_achieved"], "sealed");
3890 assert_eq!(through_profile["userns_grant"], "profile_confining");
3891 assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
3892 assert_ne!(
3893 through_profile, host_wide,
3894 "two hosts granting the user namespace by different mechanisms must not write the \
3895 same session_start record"
3896 );
3897
3898 let unprobed = scope_report_for_tier(
3901 &CapabilityPolicy::default(),
3902 ContainmentClass::Advisory,
3903 EnforcementTier::EnvironmentOnly,
3904 None,
3905 None,
3906 None,
3907 Vec::new(),
3908 Vec::new(),
3909 Vec::new(),
3910 crate::cgroup::IoMaxReport::default(),
3911 );
3912 assert!(
3913 serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
3914 "off Linux the key is present and null, not absent"
3915 );
3916 }
3917
3918 #[tokio::test]
3926 async fn session_start_records_the_whole_scope_report_as_effective_grants() {
3927 let policy = CapabilityPolicy {
3928 network_allow: vec!["https://api.example.com".to_string()],
3929 unix_sockets_allowed: false,
3930 filesystem_scope: Some("workdir".to_string()),
3931 shell_allow: vec!["python3".to_string()],
3932 spawn_allow: vec!["helper".to_string()],
3933 env_allow: vec!["TZ".to_string()],
3934 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
3935 binary: "python3".to_string(),
3936 dirs: vec![InterpreterRuntimeDir {
3937 path: "/usr/lib/python3.11".to_string(),
3938 list_dir: true,
3939 }],
3940 }],
3941 ..CapabilityPolicy::default()
3942 };
3943 let expected = scope_report_for_tier(
3944 &policy,
3945 ContainmentClass::Scoped,
3946 EnforcementTier::KernelFull,
3947 None,
3948 None,
3949 None,
3950 vec![crate::containment::StateStoreReport {
3953 artifact: "notes-tool".to_string(),
3954 store: "shey".to_string(),
3955 host_path: "/home/dev/.murmur/state/shey".to_string(),
3956 }],
3957 vec!["config-echo".to_string()],
3959 vec![crate::containment::PreopenReport {
3960 artifact: "notes-tool".to_string(),
3961 role: "tool".to_string(),
3962 scope: None,
3963 surface: crate::containment::PreopenSurface::WholeWorkdir,
3964 }],
3965 crate::cgroup::IoMaxReport::default(),
3966 );
3967
3968 let dir = tempfile::tempdir().unwrap();
3969 let mut w = TraceWriter::open(
3970 dir.path(),
3971 "test-session-id".to_string(),
3972 "test-capsule".to_string(),
3973 "1.0.0".to_string(),
3974 "claude-test".to_string(),
3975 vec!["network".to_string(), "shell".to_string()],
3976 expected.clone(),
3977 TraceCapture::Meta,
3978 None,
3979 false,
3980 None,
3981 None,
3982 None,
3983 None,
3984 )
3985 .await
3986 .unwrap();
3987 w.write_session_start(1, Vec::new()).await.unwrap();
3988 w.flush().await.unwrap();
3989
3990 let events = read_events(dir.path());
3991 assert_eq!(
3992 events[0]["effective_grants"],
3993 serde_json::to_value(&expected).unwrap()
3994 );
3995
3996 assert_eq!(
3999 events[0]["capabilities"],
4000 serde_json::json!(["network", "shell"])
4001 );
4002 assert_eq!(
4003 events[0]["effective_grants"]["state_stores"],
4004 serde_json::json!([{
4005 "artifact": "notes-tool",
4006 "store": "shey",
4007 "host_path": "/home/dev/.murmur/state/shey",
4008 }])
4009 );
4010 assert_eq!(
4013 events[0]["effective_grants"]["configured_artifacts"],
4014 serde_json::json!(["config-echo"])
4015 );
4016 assert_eq!(
4020 events[0]["effective_grants"]["preopens"],
4021 serde_json::json!([{
4022 "artifact": "notes-tool",
4023 "role": "tool",
4024 "scope": null,
4025 "surface": "whole-workdir",
4026 }])
4027 );
4028 assert_eq!(events[0]["containment_declared"], "scoped");
4029 assert_eq!(events[0]["containment_achieved"], "scoped");
4030 assert_eq!(events[0]["workdir_exec"], false);
4031 assert_eq!(
4032 events[0]["effective_grants"]["network_allow"],
4033 serde_json::json!(["https://api.example.com"])
4034 );
4035 assert_eq!(
4036 events[0]["effective_grants"]["interpreter_runtime_grants"],
4037 serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
4038 );
4039 }
4040
4041 #[tokio::test]
4042 async fn inference_fields_and_snake_case() {
4043 let dir = tempfile::tempdir().unwrap();
4044 let mut w = make_writer(dir.path()).await;
4045 w.write_inference(
4046 0,
4047 Some(100),
4048 Some(50),
4049 "tool_call".to_string(),
4050 Some("tool_call"),
4051 Some("bash".to_string()),
4052 None,
4053 None,
4054 vec!["msg_0000000000000000000000000000000a".to_string()],
4055 None,
4056 )
4057 .await
4058 .unwrap();
4059 w.flush().await.unwrap();
4060
4061 let events = read_events(dir.path());
4062 let e = &events[0];
4063 assert_eq!(e["event_type"], "inference");
4064 assert_eq!(
4065 e["message_ids"],
4066 serde_json::json!(["msg_0000000000000000000000000000000a"]),
4067 "an agent-loop turn names the messages its request embedded"
4068 );
4069 assert_eq!(e["turn"], 0);
4070 assert_eq!(e["input_tokens"], 100);
4071 assert_eq!(e["output_tokens"], 50);
4072 assert_eq!(e["decision"], "tool_call");
4073 assert_eq!(e["tool_name"], "bash");
4074 assert!(e.get("inputTokens").is_none(), "must use snake_case");
4076 assert!(e.get("toolName").is_none(), "must use snake_case");
4077 for key in [
4079 "input_tokens_actual",
4080 "output_tokens_actual",
4081 "cached_tokens",
4082 "cache_write_tokens",
4083 ] {
4084 assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
4085 }
4086 }
4087
4088 #[tokio::test]
4091 async fn inference_writes_only_the_reported_usage_members() {
4092 let dir = tempfile::tempdir().unwrap();
4093 let mut w = make_writer(dir.path()).await;
4094 let usage = DriverUsage {
4095 input_tokens: Some(12043),
4096 output_tokens: None,
4097 cached_tokens: Some(0),
4098 cache_write_tokens: None,
4099 thinking_tokens: None,
4100 };
4101 w.write_inference(
4102 0,
4103 Some(100),
4104 Some(50),
4105 "end_turn".to_string(),
4106 Some("end_turn"),
4107 None,
4108 None,
4109 Some(&usage),
4110 Vec::new(),
4111 None,
4112 )
4113 .await
4114 .unwrap();
4115 w.flush().await.unwrap();
4116
4117 let events = read_events(dir.path());
4118 let e = &events[0];
4119 assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
4120 assert_eq!(e["input_tokens_actual"], 12043);
4121 assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
4122 assert!(e.get("output_tokens_actual").is_none());
4123 assert!(e.get("cache_write_tokens").is_none());
4124 }
4125
4126 #[tokio::test]
4127 async fn tool_call_fields() {
4128 let dir = tempfile::tempdir().unwrap();
4129 let mut w = make_writer(dir.path()).await;
4130 w.write_tool_call(
4131 1,
4132 "bash".to_string(),
4133 None,
4134 serde_json::json!({"command": "echo hi"}),
4135 42,
4136 "hi\n",
4137 100,
4138 55,
4139 "ok".to_string(),
4140 None,
4141 None,
4142 )
4143 .await
4144 .unwrap();
4145 w.flush().await.unwrap();
4146
4147 let events = read_events(dir.path());
4148 let e = &events[0];
4149 assert_eq!(e["event_type"], "tool_call");
4150 assert_eq!(e["turn"], 1);
4151 assert_eq!(e["tool_name"], "bash");
4152 assert_eq!(e["input_bytes"], 42);
4153 assert_eq!(e["output_bytes"], 100);
4154 assert_eq!(e["duration_ms"], 55);
4155 assert_eq!(e["status"], "ok");
4156 assert!(
4157 e.get("state_effect").is_none(),
4158 "state_effect must be omitted when the tool declared nothing"
4159 );
4160 }
4161
4162 #[tokio::test]
4163 async fn tool_call_records_declared_state_effect() {
4164 let dir = tempfile::tempdir().unwrap();
4165 let mut w = make_writer(dir.path()).await;
4166 w.write_tool_call(
4167 0,
4168 "murmur-tool-editor".to_string(),
4169 None,
4170 serde_json::json!({"operation": "read_file", "path": "a.txt"}),
4171 10,
4172 "hi\n",
4173 3,
4174 5,
4175 "ok".to_string(),
4176 Some("read".to_string()),
4177 None,
4178 )
4179 .await
4180 .unwrap();
4181 w.flush().await.unwrap();
4182
4183 let events = read_events(dir.path());
4184 assert_eq!(
4185 events[0]["state_effect"], "read",
4186 "declared state_effect must be recorded verbatim on the tool_call event"
4187 );
4188 }
4189
4190 #[tokio::test]
4191 async fn tool_call_records_declared_resource_id() {
4192 let dir = tempfile::tempdir().unwrap();
4193 let mut w = make_writer(dir.path()).await;
4194 w.write_tool_call(
4195 0,
4196 "murmur-tool-code-graph".to_string(),
4197 None,
4198 serde_json::json!({"symbol": "Foo::bar"}),
4199 10,
4200 "hi\n",
4201 3,
4202 5,
4203 "ok".to_string(),
4204 Some("read".to_string()),
4205 Some("sym:Foo".to_string()),
4206 )
4207 .await
4208 .unwrap();
4209 w.flush().await.unwrap();
4210
4211 let events = read_events(dir.path());
4212 assert_eq!(
4213 events[0]["resource_id"], "sym:Foo",
4214 "declared resource_id must reach trace.jsonl verbatim, unparsed"
4215 );
4216 }
4217
4218 #[tokio::test]
4219 async fn tool_call_omits_undeclared_resource_id() {
4220 let dir = tempfile::tempdir().unwrap();
4221 let mut w = make_writer(dir.path()).await;
4222 w.write_tool_call(
4223 0,
4224 "bash".to_string(),
4225 None,
4226 serde_json::json!({"command": "echo hi"}),
4227 10,
4228 "hi\n",
4229 3,
4230 5,
4231 "ok".to_string(),
4232 None,
4233 None,
4234 )
4235 .await
4236 .unwrap();
4237 w.flush().await.unwrap();
4238
4239 let events = read_events(dir.path());
4240 assert!(
4241 events[0].get("resource_id").is_none(),
4242 "resource_id must be omitted entirely (not null) when the tool declared nothing"
4243 );
4244 }
4245
4246 #[tokio::test]
4247 async fn shell_fields() {
4248 let dir = tempfile::tempdir().unwrap();
4249 let mut w = make_writer(dir.path()).await;
4250 w.write_shell(
4251 0,
4252 "/usr/bin/echo".to_string(),
4253 "echo hi".to_string(),
4254 0,
4255 7,
4256 0,
4257 10,
4258 None,
4259 )
4260 .await
4261 .unwrap();
4262 w.flush().await.unwrap();
4263
4264 let events = read_events(dir.path());
4265 let e = &events[0];
4266 assert_eq!(e["event_type"], "shell");
4267 assert_eq!(e["binary"], "/usr/bin/echo");
4270 assert_eq!(e["command"], "echo hi");
4271 assert_eq!(e["exit_code"], 0);
4272 assert_eq!(e["stdout_bytes"], 7);
4273 assert_eq!(e["stderr_bytes"], 0);
4274 assert_eq!(e["duration_ms"], 10);
4275 }
4276
4277 #[tokio::test]
4278 async fn compaction_fields() {
4279 let dir = tempfile::tempdir().unwrap();
4280 let mut w = make_writer(dir.path()).await;
4281 w.write_compaction(3, 80000, 20000).await.unwrap();
4282 w.flush().await.unwrap();
4283
4284 let events = read_events(dir.path());
4285 let e = &events[0];
4286 assert_eq!(e["event_type"], "compaction");
4287 assert_eq!(e["turn"], 3);
4288 assert_eq!(e["tokens_before"], 80000);
4289 assert_eq!(e["tokens_after"], 20000);
4290 }
4291
4292 #[tokio::test]
4293 async fn hook_dispatch_error_fields() {
4294 let dir = tempfile::tempdir().unwrap();
4295 let mut w = make_writer(dir.path()).await;
4296 w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
4297 .await
4298 .unwrap();
4299 w.flush().await.unwrap();
4300
4301 let events = read_events(dir.path());
4302 let e = &events[0];
4303 assert_eq!(e["event_type"], "hook_dispatch_error");
4304 assert_eq!(e["session_id"], "test-session-id");
4305 assert_eq!(e["hook_name"], "my-hook");
4306 assert_eq!(e["event"], "on-tool-call");
4307 assert_eq!(e["arm"], "write-manifests");
4308 assert!(e["timestamp"].as_u64().unwrap() > 0);
4309 assert!(e.get("hookName").is_none(), "must use snake_case");
4311 }
4312
4313 #[tokio::test]
4314 async fn session_end_fields_and_snake_case() {
4315 let dir = tempfile::tempdir().unwrap();
4316 let mut w = make_writer(dir.path()).await;
4317 w.write_inference(
4318 0,
4319 Some(100),
4320 Some(50),
4321 "tool_call".to_string(),
4322 Some("tool_call"),
4323 None,
4324 None,
4325 None,
4326 Vec::new(),
4327 None,
4328 )
4329 .await
4330 .unwrap();
4331 w.write_tool_call(
4332 0,
4333 "bash".to_string(),
4334 None,
4335 serde_json::json!({"command": "ls"}),
4336 10,
4337 "file.txt\n",
4338 20,
4339 5,
4340 "ok".to_string(),
4341 None,
4342 None,
4343 )
4344 .await
4345 .unwrap();
4346 w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
4347 .await
4348 .unwrap();
4349 w.write_session_end("ok").await.unwrap();
4350 w.flush().await.unwrap();
4351
4352 let events = read_events(dir.path());
4353 let se = events.last().unwrap();
4354 assert_eq!(se["event_type"], "session_end");
4355 assert_eq!(se["total_turns"], 1);
4356 assert_eq!(se["total_input_tokens"], 100);
4357 assert_eq!(se["total_output_tokens"], 50);
4358 assert_eq!(se["total_tool_calls"], 1);
4359 assert_eq!(se["total_shell_calls"], 1);
4360 assert_eq!(se["exit_status"], "ok");
4361 assert!(se["duration_ms"].as_u64().is_some());
4362 assert!(se.get("exitStatus").is_none(), "must use snake_case");
4364 assert!(se.get("totalTurns").is_none(), "must use snake_case");
4365 }
4366
4367 #[tokio::test]
4368 async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
4369 let dir = tempfile::tempdir().unwrap();
4370 let mut w = make_writer(dir.path()).await;
4371 w.write_session_start(10, vec![]).await.unwrap();
4372 w.write_session_end("ok").await.unwrap();
4373 w.write_session_end_if_not_ended("failed").await.unwrap();
4374 w.flush().await.unwrap();
4375
4376 let events = read_events(dir.path());
4377 assert_eq!(
4378 events
4379 .iter()
4380 .filter(|e| e["event_type"] == "session_end")
4381 .count(),
4382 1,
4383 "session_end should appear exactly once"
4384 );
4385 }
4386
4387 #[tokio::test]
4388 async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
4389 let dir = tempfile::tempdir().unwrap();
4390 let mut w = make_writer(dir.path()).await;
4391 w.write_session_start(10, vec![]).await.unwrap();
4392 w.write_session_end_if_not_ended("failed").await.unwrap();
4394 w.flush().await.unwrap();
4395
4396 let events = read_events(dir.path());
4397 let se = events
4398 .iter()
4399 .find(|e| e["event_type"] == "session_end")
4400 .unwrap();
4401 assert_eq!(se["exit_status"], "failed");
4402 }
4403
4404 #[tokio::test]
4405 async fn no_session_end_written_when_session_never_started() {
4406 let dir = tempfile::tempdir().unwrap();
4407 let mut w = make_writer(dir.path()).await;
4408 w.write_session_end_if_not_ended("failed").await.unwrap();
4410 w.flush().await.unwrap();
4411
4412 let events = read_events(dir.path());
4413 assert!(
4414 events.iter().all(|e| e["event_type"] != "session_end"),
4415 "session_end must not appear when session_start was never written"
4416 );
4417 }
4418
4419 #[tokio::test]
4420 async fn session_id_on_every_event() {
4421 let dir = tempfile::tempdir().unwrap();
4422 let mut w = make_writer(dir.path()).await;
4423 w.write_session_start(10, vec!["bash".to_string()])
4424 .await
4425 .unwrap();
4426 w.write_inference(
4427 0,
4428 Some(10),
4429 Some(5),
4430 "end_turn".to_string(),
4431 Some("end_turn"),
4432 None,
4433 None,
4434 None,
4435 Vec::new(),
4436 None,
4437 )
4438 .await
4439 .unwrap();
4440 w.write_session_end("ok").await.unwrap();
4441 w.flush().await.unwrap();
4442
4443 let events = read_events(dir.path());
4444 for e in &events {
4445 assert_eq!(e["session_id"], "test-session-id");
4446 }
4447 }
4448
4449 #[tokio::test]
4450 async fn counts_match_per_event_sum() {
4451 let dir = tempfile::tempdir().unwrap();
4452 let mut w = make_writer(dir.path()).await;
4453 w.write_session_start(10, vec![]).await.unwrap();
4454 w.write_inference(
4455 0,
4456 Some(50),
4457 Some(25),
4458 "tool_call".to_string(),
4459 Some("tool_call"),
4460 Some("bash".to_string()),
4461 None,
4462 None,
4463 vec!["msg_0000000000000000000000000000000a".to_string()],
4464 None,
4465 )
4466 .await
4467 .unwrap();
4468 w.write_tool_call(
4469 0,
4470 "bash".to_string(),
4471 None,
4472 serde_json::json!({"command": "echo"}),
4473 5,
4474 "ok",
4475 10,
4476 3,
4477 "ok".to_string(),
4478 None,
4479 None,
4480 )
4481 .await
4482 .unwrap();
4483 w.write_shell(
4484 0,
4485 "/bin/echo".to_string(),
4486 "echo".to_string(),
4487 0,
4488 4,
4489 0,
4490 1,
4491 None,
4492 )
4493 .await
4494 .unwrap();
4495 w.write_inference(
4496 1,
4497 Some(60),
4498 Some(30),
4499 "end_turn".to_string(),
4500 Some("end_turn"),
4501 None,
4502 None,
4503 None,
4504 Vec::new(),
4505 None,
4506 )
4507 .await
4508 .unwrap();
4509 w.write_session_end("ok").await.unwrap();
4510 w.flush().await.unwrap();
4511
4512 let events = read_events(dir.path());
4513 let inference_count = events
4514 .iter()
4515 .filter(|e| e["event_type"] == "inference")
4516 .count();
4517 let tool_count = events
4518 .iter()
4519 .filter(|e| e["event_type"] == "tool_call")
4520 .count();
4521 let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
4522
4523 let se = events
4524 .iter()
4525 .find(|e| e["event_type"] == "session_end")
4526 .unwrap();
4527 assert_eq!(
4528 se["total_turns"].as_u64().unwrap() as usize,
4529 inference_count
4530 );
4531 assert_eq!(
4532 se["total_tool_calls"].as_u64().unwrap() as usize,
4533 tool_count
4534 );
4535 assert_eq!(
4536 se["total_shell_calls"].as_u64().unwrap() as usize,
4537 shell_count
4538 );
4539 }
4540
4541 #[tokio::test]
4542 async fn tool_call_input_always_present() {
4543 let dir = tempfile::tempdir().unwrap();
4544 let mut w = make_writer(dir.path()).await; w.write_tool_call(
4546 0,
4547 "bash".to_string(),
4548 None,
4549 serde_json::json!({"command": "ls -la"}),
4550 20,
4551 "total 8\n",
4552 80,
4553 12,
4554 "ok".to_string(),
4555 None,
4556 None,
4557 )
4558 .await
4559 .unwrap();
4560 w.flush().await.unwrap();
4561
4562 let events = read_events(dir.path());
4563 let e = &events[0];
4564 assert_eq!(e["event_type"], "tool_call");
4565 assert_eq!(e["input"]["command"], "ls -la");
4566 assert!(e.get("input").is_some(), "input must always be present");
4567 }
4568
4569 #[tokio::test]
4570 async fn tool_call_output_absent_by_default() {
4571 let dir = tempfile::tempdir().unwrap();
4572 let mut w = make_writer(dir.path()).await; w.write_tool_call(
4574 0,
4575 "bash".to_string(),
4576 None,
4577 serde_json::json!({"command": "ls"}),
4578 10,
4579 "file.txt\n",
4580 90,
4581 8,
4582 "ok".to_string(),
4583 None,
4584 None,
4585 )
4586 .await
4587 .unwrap();
4588 w.flush().await.unwrap();
4589
4590 let events = read_events(dir.path());
4591 let e = &events[0];
4592 assert!(
4593 e.get("output").is_none(),
4594 "output must be absent under a capture mode that stores no bodies"
4595 );
4596 assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
4597 }
4598
4599 #[tokio::test]
4600 async fn skill_call_writes_skill_call_event() {
4601 let dir = tempfile::tempdir().unwrap();
4602 let mut w = make_writer(dir.path()).await;
4603 w.write_skill_call(2, "my-skill".to_string(), 1024, 8, "ok".to_string())
4604 .await
4605 .unwrap();
4606 w.flush().await.unwrap();
4607
4608 let events = read_events(dir.path());
4609 let e = &events[0];
4610 assert_eq!(e["event_type"], "skill_call");
4611 assert_eq!(e["turn"], 2);
4612 assert_eq!(e["skill_name"], "my-skill");
4613 assert_eq!(e["output_bytes"], 1024);
4614 assert_eq!(e["duration_ms"], 8);
4615 assert_eq!(e["status"], "ok");
4616 }
4617
4618 #[tokio::test]
4619 async fn skill_call_does_not_increment_total_tool_calls() {
4620 let dir = tempfile::tempdir().unwrap();
4621 let mut w = make_writer(dir.path()).await;
4622 w.write_session_start(10, vec![]).await.unwrap();
4623 w.write_tool_call(
4624 0,
4625 "real-tool".to_string(),
4626 None,
4627 serde_json::json!({}),
4628 2,
4629 "data",
4630 4,
4631 5,
4632 "ok".to_string(),
4633 None,
4634 None,
4635 )
4636 .await
4637 .unwrap();
4638 w.write_skill_call(1, "my-skill".to_string(), 512, 3, "ok".to_string())
4639 .await
4640 .unwrap();
4641 w.write_session_end("ok").await.unwrap();
4642 w.flush().await.unwrap();
4643
4644 let events = read_events(dir.path());
4645 let se = events
4646 .iter()
4647 .find(|e| e["event_type"] == "session_end")
4648 .unwrap();
4649 assert_eq!(
4650 se["total_tool_calls"], 1,
4651 "skill_call must not inflate total_tool_calls"
4652 );
4653 }
4654
4655 #[tokio::test]
4656 async fn tool_call_output_present_when_opted_in() {
4657 let dir = tempfile::tempdir().unwrap();
4658 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
4659 w.write_tool_call(
4660 0,
4661 "bash".to_string(),
4662 None,
4663 serde_json::json!({"command": "echo hello"}),
4664 22,
4665 "hello\n",
4666 60,
4667 5,
4668 "ok".to_string(),
4669 None,
4670 None,
4671 )
4672 .await
4673 .unwrap();
4674 w.flush().await.unwrap();
4675
4676 let events = read_events(dir.path());
4677 let e = &events[0];
4678 assert_eq!(
4679 e["output"].as_str().unwrap(),
4680 "hello\n",
4681 "output must be present under capture: content"
4682 );
4683 assert_eq!(e["output_bytes"], 60);
4684 }
4685
4686 #[tokio::test]
4687 async fn task_end_carries_reopen_count() {
4688 let dir = tempfile::tempdir().unwrap();
4689 let mut w = make_writer(dir.path()).await;
4690 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4691 .await
4692 .unwrap();
4693 w.write_task_end("tsk_1", "ok", 2).await.unwrap();
4694 w.flush().await.unwrap();
4695
4696 let events = read_events(dir.path());
4697 let end = events
4698 .iter()
4699 .find(|e| e["event_type"] == "task_end")
4700 .unwrap();
4701 assert_eq!(end["task_id"], "tsk_1");
4702 assert_eq!(end["exit_status"], "ok");
4703 assert_eq!(end["reopen_count"], 2);
4704 }
4705
4706 #[tokio::test]
4707 async fn task_reopened_names_hook_reason_and_ordinal() {
4708 let dir = tempfile::tempdir().unwrap();
4709 let mut w = make_writer(dir.path()).await;
4710 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4711 .await
4712 .unwrap();
4713 w.write_task_reopened(
4714 "tsk_1",
4715 "gatekeeper",
4716 "tests still fail",
4717 1,
4718 REOPEN_CONTEXT_CONTINUED,
4719 7,
4720 )
4721 .await
4722 .unwrap();
4723 w.flush().await.unwrap();
4724
4725 let events = read_events(dir.path());
4726 let re = events
4727 .iter()
4728 .find(|e| e["event_type"] == "task_reopened")
4729 .unwrap();
4730 assert_eq!(re["task_id"], "tsk_1");
4731 assert_eq!(re["hook_name"], "gatekeeper");
4732 assert_eq!(re["reason"], "tests still fail");
4733 assert_eq!(re["reopen_number"], 1);
4734 assert_eq!(re["attempt_context"], "continued");
4735 assert_eq!(re["turns_remaining"], 7);
4736 assert!(re["timestamp"].as_u64().unwrap() > 0);
4737 }
4738
4739 #[tokio::test]
4743 async fn task_turns_accessor_tracks_and_resets() {
4744 let dir = tempfile::tempdir().unwrap();
4745 let mut w = make_writer(dir.path()).await;
4746 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4747 .await
4748 .unwrap();
4749 assert_eq!(w.task_turns(), 0);
4750 w.write_inference(
4751 0,
4752 Some(10),
4753 Some(5),
4754 "end_turn".to_string(),
4755 Some("end_turn"),
4756 None,
4757 None,
4758 None,
4759 Vec::new(),
4760 None,
4761 )
4762 .await
4763 .unwrap();
4764 w.write_inference(
4765 1,
4766 Some(10),
4767 Some(5),
4768 "end_turn".to_string(),
4769 Some("end_turn"),
4770 None,
4771 None,
4772 None,
4773 Vec::new(),
4774 None,
4775 )
4776 .await
4777 .unwrap();
4778 assert_eq!(w.task_turns(), 2);
4779 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), None, 3)
4781 .await
4782 .unwrap();
4783 assert_eq!(w.task_turns(), 0);
4784 }
4785
4786 #[tokio::test]
4792 async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
4793 let dir = tempfile::tempdir().unwrap();
4794 let mut w = make_writer(dir.path()).await;
4795 w.write_session_start(10, vec!["bash".to_string()])
4796 .await
4797 .unwrap();
4798 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4799 .await
4800 .unwrap();
4801 w.write_inference(
4802 0,
4803 Some(10),
4804 Some(5),
4805 "tool_call".to_string(),
4806 Some("tool_call"),
4807 None,
4808 None,
4809 None,
4810 Vec::new(),
4811 None,
4812 )
4813 .await
4814 .unwrap();
4815 w.write_tool_call(
4816 0,
4817 "bash".to_string(),
4818 Some("toolu_1".to_string()),
4819 serde_json::json!({"command": "echo hi"}),
4820 10,
4821 "hi\n",
4822 3,
4823 5,
4824 "ok".to_string(),
4825 None,
4826 None,
4827 )
4828 .await
4829 .unwrap();
4830 w.write_shell(
4831 0,
4832 "/bin/bash".to_string(),
4833 "echo hi".to_string(),
4834 0,
4835 3,
4836 0,
4837 2,
4838 None,
4839 )
4840 .await
4841 .unwrap();
4842 w.write_task_end("tsk_1", "ok", 0).await.unwrap();
4843 w.write_session_end("ok").await.unwrap();
4844 w.flush().await.unwrap();
4845
4846 let events = read_events(dir.path());
4847 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
4848 for (i, event) in events.iter().enumerate() {
4849 let id = event["event_id"].as_str().unwrap();
4850 assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
4851 assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
4852 assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
4853 assert!(
4854 event.get("parent_id").is_some(),
4855 "event {i} has no parent_id"
4856 );
4857 if let Some(parent) = event["parent_id"].as_str() {
4858 assert!(seen.contains(parent), "event {i} names dangling {parent}");
4859 }
4860 }
4861
4862 let by_type = |ty: &str| {
4863 events
4864 .iter()
4865 .find(|e| e["event_type"] == ty)
4866 .unwrap_or_else(|| panic!("no {ty}"))
4867 .clone()
4868 };
4869 let session = by_type("session_start");
4870 assert!(session["parent_id"].is_null());
4871 assert_eq!(session["event_id"], w.session_event_id());
4872
4873 let task = by_type("task_start");
4874 assert_eq!(task["parent_id"], session["event_id"]);
4875
4876 let turn = by_type("inference");
4877 assert_eq!(turn["parent_id"], task["event_id"]);
4878
4879 assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
4880 assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
4881 assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
4882 assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
4883 }
4884
4885 #[tokio::test]
4889 async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
4890 let dir = tempfile::tempdir().unwrap();
4891 let mut w = make_writer(dir.path()).await;
4892 w.write_session_start(10, Vec::new()).await.unwrap();
4893 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4894 .await
4895 .unwrap();
4896 w.write_inference(
4897 0,
4898 Some(10),
4899 Some(5),
4900 "tool_call".to_string(),
4901 Some("tool_call"),
4902 None,
4903 None,
4904 None,
4905 Vec::new(),
4906 None,
4907 )
4908 .await
4909 .unwrap();
4910 let origin = InferenceOrigin {
4911 source: "hook:gatekeeper".to_string(),
4912 model: "claude-test".to_string(),
4913 };
4914 w.write_inference(
4915 0,
4916 Some(1),
4917 Some(1),
4918 "end_turn".to_string(),
4919 None,
4921 None,
4922 Some(&origin),
4923 None,
4924 Vec::new(),
4925 None,
4926 )
4927 .await
4928 .unwrap();
4929 w.flush().await.unwrap();
4930
4931 let events = read_events(dir.path());
4932 let turn = &events[2];
4933 let hook = &events[3];
4934 assert!(turn["origin"].is_null());
4935 assert_eq!(hook["origin"], "hook:gatekeeper");
4936 assert_eq!(hook["parent_id"], turn["event_id"]);
4937 assert!(hook.get("message_ids").is_none(), "{hook}");
4940 }
4941
4942 #[tokio::test]
4946 async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
4947 let bare = tempfile::tempdir().unwrap();
4948 let mut w = make_writer(bare.path()).await;
4949 w.write_a2a_send(
4950 "http://peer",
4951 "msg_1",
4952 "tsk_1",
4953 "ctx_1",
4954 None,
4955 TrustClass::Untrusted,
4956 )
4957 .await
4958 .unwrap();
4959 w.flush().await.unwrap();
4960 assert!(read_events(bare.path())[0]["parent_id"].is_null());
4961
4962 let framed = tempfile::tempdir().unwrap();
4963 let mut w = make_writer(framed.path()).await;
4964 w.write_session_start(1, Vec::new()).await.unwrap();
4965 w.write_a2a_send(
4966 "http://peer",
4967 "msg_1",
4968 "tsk_1",
4969 "ctx_1",
4970 None,
4971 TrustClass::Untrusted,
4972 )
4973 .await
4974 .unwrap();
4975 w.flush().await.unwrap();
4976 let events = read_events(framed.path());
4977 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
4978 }
4979
4980 #[tokio::test]
4983 async fn turn_level_events_carry_the_active_task_id() {
4984 let dir = tempfile::tempdir().unwrap();
4985 let mut w = make_writer(dir.path()).await;
4986 w.write_session_start(10, Vec::new()).await.unwrap();
4987 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
4988 .await
4989 .unwrap();
4990 w.write_inference(
4991 0,
4992 Some(10),
4993 Some(5),
4994 "tool_call".to_string(),
4995 Some("tool_call"),
4996 None,
4997 None,
4998 None,
4999 Vec::new(),
5000 None,
5001 )
5002 .await
5003 .unwrap();
5004 w.write_skill_call(0, "house-style".to_string(), 12, 1, "ok".to_string())
5005 .await
5006 .unwrap();
5007 w.write_compaction(0, 100, 40).await.unwrap();
5008 w.write_task_end("tsk_1", "ok", 0).await.unwrap();
5009 w.write_inference(
5010 1,
5011 Some(1),
5012 Some(1),
5013 "end_turn".to_string(),
5014 Some("end_turn"),
5015 None,
5016 None,
5017 None,
5018 Vec::new(),
5019 None,
5020 )
5021 .await
5022 .unwrap();
5023 w.flush().await.unwrap();
5024
5025 let events = read_events(dir.path());
5026 for event in events
5027 .iter()
5028 .filter(|e| {
5029 matches!(
5030 e["event_type"].as_str(),
5031 Some("inference" | "skill_call" | "compaction")
5032 )
5033 })
5034 .take(3)
5035 {
5036 assert_eq!(event["task_id"], "tsk_1", "{event}");
5037 }
5038 let after = events.last().unwrap();
5039 assert_eq!(after["event_type"], "inference");
5040 assert!(
5041 after["task_id"].is_null(),
5042 "an inference outside any task carries a null task_id, not the last task's"
5043 );
5044 assert_eq!(after["parent_id"], events[0]["event_id"]);
5046 }
5047
5048 #[tokio::test]
5051 async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
5052 for (given, expected) in [
5053 (Some("toolu_1".to_string()), Some("toolu_1")),
5054 (Some(String::new()), None),
5055 (None, None),
5056 ] {
5057 let dir = tempfile::tempdir().unwrap();
5058 let mut w = make_writer(dir.path()).await;
5059 w.write_tool_call(
5060 0,
5061 "bash".to_string(),
5062 given.clone(),
5063 serde_json::json!({}),
5064 2,
5065 "",
5066 0,
5067 1,
5068 "ok".to_string(),
5069 None,
5070 None,
5071 )
5072 .await
5073 .unwrap();
5074 w.flush().await.unwrap();
5075 let e = &read_events(dir.path())[0];
5076 match expected {
5077 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
5078 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
5079 }
5080 }
5081 }
5082
5083 #[tokio::test]
5086 async fn compaction_declined_records_turn_task_tokens_and_reason() {
5087 let dir = tempfile::tempdir().unwrap();
5088 let mut w = make_writer(dir.path()).await;
5089 w.write_session_start(10, Vec::new()).await.unwrap();
5090 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
5091 .await
5092 .unwrap();
5093 w.write_inference(
5094 2,
5095 Some(10),
5096 Some(5),
5097 "tool_call".to_string(),
5098 Some("tool_call"),
5099 None,
5100 None,
5101 None,
5102 Vec::new(),
5103 None,
5104 )
5105 .await
5106 .unwrap();
5107 w.write_compaction_declined(2, 4321, COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL)
5108 .await
5109 .unwrap();
5110 w.flush().await.unwrap();
5111
5112 let events = read_events(dir.path());
5113 let declined: Vec<&Value> = events
5114 .iter()
5115 .filter(|e| e["event_type"] == "compaction_declined")
5116 .collect();
5117 assert_eq!(declined.len(), 1);
5118 let d = declined[0];
5119 assert_eq!(d["reason"], "unresolved_tool_call");
5120 assert_eq!(d["turn"], 2);
5121 assert_eq!(d["task_id"], "tsk_1");
5122 assert_eq!(d["tokens"], 4321);
5123 assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
5124 assert_eq!(
5125 d["parent_id"],
5126 events
5127 .iter()
5128 .find(|e| e["event_type"] == "inference")
5129 .unwrap()["event_id"]
5130 );
5131 }
5132
5133 #[tokio::test]
5136 async fn event_ids_sort_by_mint_time() {
5137 let dir = tempfile::tempdir().unwrap();
5138 let mut w = make_writer(dir.path()).await;
5139 for turn in 0..5 {
5140 w.write_inference(
5141 turn,
5142 Some(1),
5143 Some(1),
5144 "end_turn".to_string(),
5145 Some("end_turn"),
5146 None,
5147 None,
5148 None,
5149 Vec::new(),
5150 None,
5151 )
5152 .await
5153 .unwrap();
5154 }
5155 w.flush().await.unwrap();
5156
5157 let ids: Vec<String> = read_events(dir.path())
5158 .iter()
5159 .map(|e| e["event_id"].as_str().unwrap().to_string())
5160 .collect();
5161 let mut sorted = ids.clone();
5162 sorted.sort();
5163 assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
5164 }
5165
5166 fn wire_payload(system: &str, messages: usize) -> Value {
5170 let messages: Vec<Value> = (0..messages)
5171 .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
5172 .collect();
5173 serde_json::json!({
5174 "model": "claude-test",
5175 "max_tokens": 1024,
5176 "messages": messages,
5177 "tools": [{"name": "bash"}],
5178 "params": {},
5179 "system": system,
5180 })
5181 }
5182
5183 async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
5184 let wire = WireCapture::from_driver_payload(payload, response);
5185 w.write_inference(
5186 turn,
5187 Some(10),
5188 Some(5),
5189 "end_turn".to_string(),
5190 Some("end_turn"),
5191 None,
5192 None,
5193 None,
5194 vec!["msg_0000000000000000000000000000000a".to_string()],
5195 Some(&wire),
5196 )
5197 .await
5198 .unwrap();
5199 }
5200
5201 fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
5202 dir.join(BLOB_DIR_NAME).join(sha)
5203 }
5204
5205 #[tokio::test]
5208 async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
5209 let dir = tempfile::tempdir().unwrap();
5210 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5211 let payload = wire_payload("You are a capsule.", 3);
5212 write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
5213 w.flush().await.unwrap();
5214
5215 let e = &read_events(dir.path())[0];
5216 let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
5217 .iter()
5218 .map(|key| {
5219 e[*key]
5220 .as_str()
5221 .unwrap_or_else(|| panic!("{key} missing from {e}"))
5222 .to_string()
5223 })
5224 .collect();
5225 let shas = e["message_shas"].as_array().expect("message_shas");
5226 assert_eq!(shas.len(), 3);
5227 named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
5228
5229 for sha in &named {
5230 let path = blob_path(dir.path(), sha);
5231 let bytes =
5232 std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
5233 assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
5234 assert_eq!(sha.len(), 64, "{sha}");
5237 assert!(
5238 sha.chars()
5239 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
5240 "{sha}"
5241 );
5242 }
5243 }
5244
5245 #[tokio::test]
5248 async fn the_system_blob_holds_the_prompt_as_readable_text() {
5249 let dir = tempfile::tempdir().unwrap();
5250 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5251 let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
5252 write_turn(&mut w, 0, &payload, "{}").await;
5253 w.flush().await.unwrap();
5254
5255 let sha = read_events(dir.path())[0]["system_sha"]
5256 .as_str()
5257 .unwrap()
5258 .to_string();
5259 assert_eq!(
5260 std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
5261 "[Capsule] you are cap.\nBe terse."
5262 );
5263 }
5264
5265 #[tokio::test]
5268 async fn meta_writes_the_same_hashes_and_no_bodies() {
5269 let payload = wire_payload("You are a capsule.", 2);
5270 let response = r#"{"stop_reason":"end_turn"}"#;
5271
5272 let meta = tempfile::tempdir().unwrap();
5273 let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
5274 write_turn(&mut w, 0, &payload, response).await;
5275 w.flush().await.unwrap();
5276
5277 let content = tempfile::tempdir().unwrap();
5278 let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
5279 write_turn(&mut w, 0, &payload, response).await;
5280 w.flush().await.unwrap();
5281
5282 let meta_event = read_events(meta.path()).remove(0);
5283 let content_event = read_events(content.path()).remove(0);
5284 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
5285 assert_eq!(meta_event[key], content_event[key], "{key}");
5286 }
5287 assert!(
5288 !meta.path().join(BLOB_DIR_NAME).exists(),
5289 "meta must not create the blob directory"
5290 );
5291 assert!(content.path().join(BLOB_DIR_NAME).is_dir());
5292 }
5293
5294 #[tokio::test]
5296 async fn the_default_capture_mode_is_meta() {
5297 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
5298 let dir = tempfile::tempdir().unwrap();
5299 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
5300 write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
5301 w.flush().await.unwrap();
5302
5303 let e = &read_events(dir.path())[0];
5304 assert!(e.get("system_sha").is_some());
5305 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
5306 }
5307
5308 #[tokio::test]
5310 async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
5311 let payload = wire_payload("You are a capsule.", 2);
5312
5313 let off = tempfile::tempdir().unwrap();
5314 let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
5315 write_turn(&mut w, 3, &payload, "{}").await;
5316 w.flush().await.unwrap();
5317
5318 let e = read_events(off.path()).remove(0);
5319 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
5320 assert!(
5321 e.get(key).is_none(),
5322 "{key} must be absent under none, got {e}"
5323 );
5324 }
5325 assert!(!off.path().join(BLOB_DIR_NAME).exists());
5326 for key in [
5327 "event_type",
5328 "event_id",
5329 "parent_id",
5330 "session_id",
5331 "timestamp",
5332 "turn",
5333 "task_id",
5334 "input_tokens",
5335 "output_tokens",
5336 "decision",
5337 "tool_name",
5338 "message_ids",
5339 ] {
5340 assert!(
5341 e.get(key).is_some(),
5342 "{key} must survive under none, got {e}"
5343 );
5344 }
5345 assert_eq!(e["turn"], 3);
5346 assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
5347 }
5348
5349 #[tokio::test]
5352 async fn an_unchanged_system_prompt_is_stored_once() {
5353 let dir = tempfile::tempdir().unwrap();
5354 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5355 let payload = wire_payload("You are a capsule.", 1);
5356 write_turn(&mut w, 0, &payload, "{}").await;
5357
5358 let sha = {
5359 w.flush().await.unwrap();
5360 read_events(dir.path())[0]["system_sha"]
5361 .as_str()
5362 .unwrap()
5363 .to_string()
5364 };
5365 let path = blob_path(dir.path(), &sha);
5366 std::fs::write(&path, b"sentinel").unwrap();
5367
5368 for turn in 1..10 {
5369 write_turn(&mut w, turn, &payload, "{}").await;
5370 }
5371 w.flush().await.unwrap();
5372
5373 let events = read_events(dir.path());
5374 assert_eq!(events.len(), 10);
5375 for e in &events {
5376 assert_eq!(e["system_sha"], sha.as_str());
5377 }
5378 assert_eq!(
5379 std::fs::read(&path).unwrap(),
5380 b"sentinel",
5381 "an existing blob is never rewritten"
5382 );
5383 let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
5384 .unwrap()
5385 .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
5386 .count();
5387 assert_eq!(matching, 1, "exactly one file bears that name");
5388 }
5389
5390 #[tokio::test]
5395 async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
5396 let dir = tempfile::tempdir().unwrap();
5397 let mut w = make_writer(dir.path()).await;
5398 w.write_inference(
5399 0,
5400 Some(10),
5401 Some(5),
5402 "end_turn".to_string(),
5403 None,
5404 None,
5405 None,
5406 None,
5407 Vec::new(),
5408 None,
5409 )
5410 .await
5411 .unwrap();
5412 w.flush().await.unwrap();
5413
5414 let event = read_events(dir.path()).remove(0);
5415 assert!(
5416 event.get("stop_reason").is_none(),
5417 "stop_reason must be absent, got {event}"
5418 );
5419 }
5420
5421 #[tokio::test]
5424 async fn a_hook_origin_record_carries_no_hashes() {
5425 let dir = tempfile::tempdir().unwrap();
5426 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5427 let origin = InferenceOrigin {
5428 source: "hook:gatekeeper".to_string(),
5429 model: "claude-test".to_string(),
5430 };
5431 w.write_inference(
5432 0,
5433 Some(1),
5434 Some(1),
5435 "end_turn".to_string(),
5436 None,
5438 None,
5439 Some(&origin),
5440 None,
5441 Vec::new(),
5442 None,
5443 )
5444 .await
5445 .unwrap();
5446 w.flush().await.unwrap();
5447
5448 let e = read_events(dir.path()).remove(0);
5449 assert_eq!(e["origin"], "hook:gatekeeper");
5450 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
5451 assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
5452 }
5453 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
5454 }
5455
5456 #[tokio::test]
5459 async fn message_shas_locate_the_index_two_runs_diverge_at() {
5460 async fn shas_for(payload: &Value) -> Vec<String> {
5461 let dir = tempfile::tempdir().unwrap();
5462 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
5463 write_turn(&mut w, 0, payload, "{}").await;
5464 w.flush().await.unwrap();
5465 read_events(dir.path())[0]["message_shas"]
5466 .as_array()
5467 .unwrap()
5468 .iter()
5469 .map(|s| s.as_str().unwrap().to_string())
5470 .collect()
5471 }
5472
5473 let first = wire_payload("You are a capsule.", 4);
5474 let mut second = first.clone();
5475 second["messages"][2]["content"] = serde_json::json!("diverged");
5476
5477 let a = shas_for(&first).await;
5478 let b = shas_for(&second).await;
5479 assert_eq!(a.len(), 4);
5480 let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
5481 assert_eq!(divergence, Some(2));
5482 assert_eq!(a[3], b[3], "only the changed message changes its own sha");
5483 }
5484
5485 #[tokio::test]
5488 async fn identical_messages_share_one_sha_and_one_blob() {
5489 let dir = tempfile::tempdir().unwrap();
5490 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5491 let payload = serde_json::json!({
5492 "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
5493 "tools": [],
5494 "system": "s",
5495 });
5496 write_turn(&mut w, 0, &payload, "{}").await;
5497 w.flush().await.unwrap();
5498
5499 let shas = read_events(dir.path())[0]["message_shas"]
5500 .as_array()
5501 .unwrap()
5502 .iter()
5503 .map(|s| s.as_str().unwrap().to_string())
5504 .collect::<Vec<_>>();
5505 assert_eq!(shas[0], shas[1]);
5506 assert!(blob_path(dir.path(), &shas[0]).is_file());
5507 }
5508
5509 #[tokio::test]
5512 async fn a_message_blob_is_the_body_verbatim() {
5513 let dir = tempfile::tempdir().unwrap();
5514 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5515 let message = serde_json::json!({"role": "user", "content": "hello"});
5516 let payload = serde_json::json!({
5517 "messages": [message.clone()],
5518 "tools": [],
5519 "system": "s",
5520 });
5521 write_turn(&mut w, 0, &payload, "{}").await;
5522 w.flush().await.unwrap();
5523
5524 let sha = read_events(dir.path())[0]["message_shas"][0]
5525 .as_str()
5526 .unwrap()
5527 .to_string();
5528 assert_eq!(
5529 std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
5530 serde_json::to_vec(&message).unwrap()
5531 );
5532 }
5533
5534 #[tokio::test]
5542 async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
5543 let plan_dir = tempfile::tempdir().unwrap();
5544 let tool_dir = tempfile::tempdir().unwrap();
5545 let session = "ses_0000000000004000800000000000plan".to_string();
5546 let node = "evt_0000000000004000800000000000node".to_string();
5547
5548 let plan = PlanTraceAppender::open(plan_dir.path(), session.clone(), node.clone()).unwrap();
5549 let tool = ResourceTraceAppender::open(tool_dir.path(), session.clone(), node.clone())
5550 .await
5551 .unwrap();
5552
5553 plan.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c");
5554 tool.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c")
5555 .await;
5556 plan.write_delegation(
5557 "worker",
5558 "0.1.0",
5559 Some("dlg_abc123".to_string()),
5560 Some("ses_child".to_string()),
5561 42,
5562 "completed",
5563 None,
5564 );
5565 tool.write_delegation(
5566 "worker",
5567 "0.1.0",
5568 Some("dlg_abc123".to_string()),
5569 Some("ses_child".to_string()),
5570 42,
5571 "completed",
5572 None,
5573 )
5574 .await;
5575
5576 let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
5577 std::fs::read_to_string(dir.join("trace.jsonl"))
5578 .unwrap()
5579 .lines()
5580 .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
5581 .collect()
5582 };
5583 let from_plan = read(plan_dir.path());
5584 let from_tool = read(tool_dir.path());
5585 assert_eq!(from_plan.len(), 2);
5586 assert_eq!(from_tool.len(), 2);
5587
5588 for (planned, called, event_type) in [
5589 (&from_plan[0], &from_tool[0], "delegation_start"),
5590 (&from_plan[1], &from_tool[1], "delegation"),
5591 ] {
5592 assert_eq!(planned["event_type"], event_type);
5593 assert_eq!(called["event_type"], event_type);
5594 assert_eq!(
5595 planned.keys().collect::<Vec<_>>(),
5596 called.keys().collect::<Vec<_>>(),
5597 "{planned:?} vs {called:?}"
5598 );
5599 assert_eq!(planned["parent_id"], node.as_str());
5600 assert_eq!(called["parent_id"], node.as_str());
5601 for (key, value) in planned {
5602 if key == "event_id" || key == "timestamp" {
5603 continue;
5604 }
5605 assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
5606 }
5607 for key in ["plan_id", "step_id", "surface", "kind"] {
5608 assert!(!planned.contains_key(key), "{key} is on {planned:?}");
5609 assert!(!called.contains_key(key), "{key} is on {called:?}");
5610 }
5611 }
5612 }
5613}