1use std::{
2 path::Path,
3 time::{Instant, SystemTime, UNIX_EPOCH},
4};
5
6use murmur_artifact::{ContainmentClass, LockOrigin, 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 types::InstalledArtifactSummary,
22};
23
24pub(crate) struct TraceWriter {
25 writer: BufWriter<File>,
26 session_id: String,
27 capsule_name: String,
28 capsule_version: String,
29 model: String,
30 capabilities: Vec<String>,
31 effective_grants: ScopeReport,
37 capture: TraceCapture,
41 blobs: BlobStore,
44 system_prompt_source: &'static str,
49 system_prompt_sha256: Option<String>,
53 credential_source: &'static str,
56 gateways: Vec<SessionGateway>,
59 control: Option<SessionControl>,
62 inference_choices: Vec<SessionInferenceChoice>,
65 tool_refresh: Option<&'static str>,
68 runtime_artifacts: Vec<RuntimeArtifact>,
71 resumed_from: Option<String>,
75 context_id: Option<String>,
78 spawned_by: Option<String>,
82 spawn_delegation_id: Option<String>,
83 session_start_time: Instant,
84 session_event_id: String,
89 session_started: bool,
90 session_ended: bool,
91 task_event_id: Option<String>,
94 turn_event_id: Option<String>,
98 total_turns: u32,
100 total_input_tokens: u64,
101 total_output_tokens: u64,
102 total_tool_calls: u32,
103 total_shell_calls: u32,
104 task_turns: u32,
106 task_input_tokens: u64,
107 task_output_tokens: u64,
108 task_tool_calls: u32,
109 task_shell_calls: u32,
110 task_start_instant: Option<Instant>,
111 pub(crate) active_task_id: Option<String>,
112 task_failures_written: u64,
115 last_task_failure: Option<String>,
117 max_session_tokens: Option<u64>,
119 machine_tokens_per_day: Option<u64>,
120}
121
122#[derive(Debug, Clone)]
127pub(crate) struct InferenceOrigin {
128 pub(crate) source: String,
130 pub(crate) model: String,
132}
133
134pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
137
138pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
141
142pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
144
145pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
148
149pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
152
153pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
155
156pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
159
160pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
163
164pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
167
168pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
171
172pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
174
175pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
177
178pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
180
181pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
184
185pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
187
188pub(crate) fn new_event_id() -> String {
196 format!("evt_{}", uuid::Uuid::now_v7().simple())
197}
198
199#[derive(Serialize)]
213struct SessionStartEvent {
214 event_type: &'static str,
215 event_id: String,
216 parent_id: Option<String>,
217 session_id: String,
218 timestamp: u64,
219 capsule_name: String,
220 capsule_version: String,
221 model: String,
222 max_turns: u32,
223 max_session_tokens: Option<u64>,
226 machine_tokens_per_day: Option<u64>,
229 capabilities: Vec<String>,
230 tools_declared: Vec<String>,
231 containment_declared: ContainmentClass,
233 containment_achieved: ContainmentClass,
237 workdir_exec: bool,
245 userns_grant: Option<crate::sealed::UsernsGrant>,
257 effective_grants: ScopeReport,
266 system_prompt_source: &'static str,
274 system_prompt_sha256: Option<String>,
283 credential_source: &'static str,
286 gateways: Vec<SessionGateway>,
290 #[serde(skip_serializing_if = "Option::is_none")]
293 control: Option<SessionControl>,
294 #[serde(skip_serializing_if = "Vec::is_empty")]
298 inference_choices: Vec<SessionInferenceChoice>,
299 tool_refresh: Option<&'static str>,
302 runtime_artifacts: Vec<RuntimeArtifact>,
306 resumed_from: Option<String>,
313 context_id: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
323 spawned_by: Option<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
327 delegation_id: Option<String>,
328}
329
330#[derive(Serialize)]
331struct InferenceEvent {
332 event_type: &'static str,
333 event_id: String,
334 parent_id: Option<String>,
335 session_id: String,
336 timestamp: u64,
337 turn: u32,
338 task_id: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
351 input_tokens: Option<u64>,
352 #[serde(skip_serializing_if = "Option::is_none")]
356 output_tokens: Option<u64>,
357 decision: String,
358 #[serde(skip_serializing_if = "Option::is_none")]
364 stop_reason: Option<String>,
365 tool_name: Option<String>,
366 #[serde(skip_serializing_if = "Option::is_none")]
369 input_tokens_actual: Option<u64>,
370 #[serde(skip_serializing_if = "Option::is_none")]
371 output_tokens_actual: Option<u64>,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 cached_tokens: Option<u64>,
374 #[serde(skip_serializing_if = "Option::is_none")]
375 cache_write_tokens: Option<u64>,
376 #[serde(skip_serializing_if = "Option::is_none")]
379 thinking_tokens: Option<u64>,
380 #[serde(skip_serializing_if = "Option::is_none")]
385 origin: Option<String>,
386 #[serde(skip_serializing_if = "Option::is_none")]
389 driver_choice: Option<String>,
390 #[serde(skip_serializing_if = "Option::is_none")]
395 model: Option<String>,
396 #[serde(skip_serializing_if = "Vec::is_empty")]
401 message_ids: Vec<String>,
402 #[serde(skip_serializing_if = "Option::is_none")]
410 system_sha: Option<String>,
411 #[serde(skip_serializing_if = "Option::is_none")]
414 tools_sha: Option<String>,
415 #[serde(skip_serializing_if = "Option::is_none")]
418 response_sha: Option<String>,
419 #[serde(skip_serializing_if = "Vec::is_empty")]
427 message_shas: Vec<String>,
428}
429
430pub(crate) struct WireCapture {
440 system: Vec<u8>,
443 tools: Vec<u8>,
445 messages: Vec<Vec<u8>>,
449 response: Vec<u8>,
451}
452
453impl WireCapture {
454 pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
460 Self {
461 system: payload
462 .get("system")
463 .and_then(Value::as_str)
464 .unwrap_or_default()
465 .as_bytes()
466 .to_vec(),
467 tools: payload
468 .get("tools")
469 .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
470 .unwrap_or_default(),
471 messages: payload
472 .get("messages")
473 .and_then(Value::as_array)
474 .map(|messages| {
475 messages
476 .iter()
477 .map(|message| serde_json::to_vec(message).unwrap_or_default())
478 .collect()
479 })
480 .unwrap_or_default(),
481 response: response.as_bytes().to_vec(),
482 }
483 }
484}
485
486#[derive(Serialize)]
487struct ToolCallEvent {
488 event_type: &'static str,
489 event_id: String,
490 parent_id: Option<String>,
491 session_id: String,
492 timestamp: u64,
493 turn: u32,
494 task_id: Option<String>,
495 tool_name: String,
496 tool_call_id: Option<String>,
500 input: Value,
501 input_bytes: u64,
502 #[serde(skip_serializing_if = "Option::is_none")]
503 output: Option<String>,
504 output_bytes: u64,
505 duration_ms: u64,
506 status: String,
507 #[serde(skip_serializing_if = "Option::is_none")]
511 state_effect: Option<String>,
512 #[serde(skip_serializing_if = "Option::is_none")]
517 resource_id: Option<String>,
518}
519
520#[derive(Serialize)]
521struct SkillCallEvent {
522 event_type: &'static str,
523 event_id: String,
524 parent_id: Option<String>,
525 session_id: String,
526 timestamp: u64,
527 turn: u32,
528 task_id: Option<String>,
529 skill_name: String,
530 output_bytes: u64,
531 duration_ms: u64,
532 status: String,
533 origin: &'static str,
535 trust: &'static str,
538}
539
540#[derive(Serialize)]
541struct ShellEvent {
542 event_type: &'static str,
543 event_id: String,
544 parent_id: Option<String>,
545 session_id: String,
546 timestamp: u64,
547 turn: u32,
548 task_id: Option<String>,
549 binary: String,
553 command: String,
554 exit_code: i32,
555 stdout_bytes: u64,
556 stderr_bytes: u64,
557 duration_ms: u64,
558 #[serde(skip_serializing_if = "Option::is_none")]
564 resource_limit: Option<String>,
565}
566
567#[derive(Serialize)]
570struct ShellDetachedEvent {
571 event_type: &'static str,
572 event_id: String,
573 parent_id: Option<String>,
574 session_id: String,
575 timestamp: u64,
576 turn: u32,
577 task_id: Option<String>,
578 work_id: String,
579 binary: String,
580 command: String,
581 grace_ms: u64,
583}
584
585#[derive(Serialize)]
588struct ShellCompletedEvent {
589 event_type: &'static str,
590 event_id: String,
591 parent_id: Option<String>,
592 session_id: String,
593 timestamp: u64,
594 work_id: String,
595 binary: String,
596 command: String,
597 exit_code: i32,
598 duration_ms: u64,
599 output_path: String,
601 output_bytes: u64,
602 #[serde(skip_serializing_if = "Option::is_none")]
604 resource_limit: Option<String>,
605 status: String,
608 completion_task_id: String,
611}
612
613#[derive(Serialize)]
622struct ShellAbandonedEvent {
623 event_type: &'static str,
624 event_id: String,
625 parent_id: Option<String>,
626 session_id: String,
627 timestamp: u64,
628 work_id: String,
629 binary: String,
630 command: String,
631 running_ms: u64,
634 #[serde(skip_serializing_if = "Option::is_none")]
637 exit_code: Option<i32>,
638 #[serde(skip_serializing_if = "Option::is_none")]
642 output_path: Option<String>,
643 #[serde(skip_serializing_if = "Option::is_none")]
645 output_bytes: Option<u64>,
646}
647
648#[derive(Serialize)]
660struct ShellLostEvent {
661 event_type: &'static str,
662 event_id: String,
663 #[serde(skip_serializing_if = "Option::is_none")]
666 parent_id: Option<String>,
667 session_id: String,
669 timestamp: u64,
670 work_id: String,
671 binary: String,
672 command: String,
673 detached_at_ms: u64,
675 reconciled_by_session: String,
677 reconciled_task_id: String,
680}
681
682#[derive(Serialize)]
688struct ShellDetachUnrecordedEvent {
689 event_type: &'static str,
690 event_id: String,
691 parent_id: Option<String>,
692 session_id: String,
693 timestamp: u64,
694 turn: u32,
695 task_id: Option<String>,
696 work_id: String,
697 binary: String,
698 reason: String,
700}
701
702#[derive(Serialize)]
703struct CompactionEvent {
704 event_type: &'static str,
705 event_id: String,
706 parent_id: Option<String>,
707 session_id: String,
708 timestamp: u64,
709 turn: u32,
710 task_id: Option<String>,
711 tokens_before: u64,
712 tokens_after: u64,
713}
714
715#[derive(Serialize)]
719struct CompactionDeclinedEvent {
720 event_type: &'static str,
721 event_id: String,
722 parent_id: Option<String>,
723 session_id: String,
724 timestamp: u64,
725 turn: u32,
726 task_id: Option<String>,
727 tokens: u64,
730 reason: String,
733}
734
735#[derive(Serialize)]
742struct ContextSeedEvent {
743 event_type: &'static str,
744 event_id: String,
745 parent_id: Option<String>,
746 session_id: String,
747 timestamp: u64,
748 task_id: Option<String>,
749 hook_name: String,
751 tokens: u64,
754 proposed_tokens: u64,
757 budget_tokens: u64,
760 outcome: String,
763 #[serde(skip_serializing_if = "Option::is_none")]
766 reason: Option<String>,
767 message_ids: Vec<String>,
771}
772
773#[derive(Serialize)]
781struct RetentionEvent {
782 event_type: &'static str,
783 event_id: String,
784 parent_id: Option<String>,
785 session_id: String,
786 timestamp: u64,
787 store: String,
789 reason: String,
792 removed: u32,
796 targets: Vec<String>,
798 #[serde(skip_serializing_if = "Option::is_none")]
801 messages_dropped: Option<u64>,
802}
803
804#[derive(Serialize)]
805struct A2aTaskReceivedEvent {
806 event_type: &'static str,
807 event_id: String,
808 parent_id: Option<String>,
809 session_id: String,
810 timestamp: u64,
811 task_id: String,
812 context_id: String,
813 message_id: String,
814 traceparent_from_caller: Option<String>,
815}
816
817#[derive(Serialize)]
818struct A2aSendEvent {
819 event_type: &'static str,
820 event_id: String,
821 parent_id: Option<String>,
822 session_id: String,
823 timestamp: u64,
824 peer_url: String,
825 message_id: String,
826 task_id: String,
827 context_id: String,
828 traceparent: Option<String>,
829 trust: String,
832}
833
834#[derive(Serialize)]
837struct ArtifactPulledEvent {
838 event_type: &'static str,
839 event_id: String,
840 parent_id: Option<String>,
841 session_id: String,
842 timestamp: u64,
843 name: String,
844 version: String,
845 runtime: &'static str,
847 origin: &'static str,
850 session: Option<String>,
853 trust: &'static str,
854}
855
856#[derive(Serialize)]
857struct SessionEndEvent {
858 event_type: &'static str,
859 event_id: String,
860 parent_id: Option<String>,
861 session_id: String,
862 timestamp: u64,
863 total_turns: u32,
864 total_input_tokens: u64,
865 total_output_tokens: u64,
866 total_tool_calls: u32,
867 total_shell_calls: u32,
868 duration_ms: u64,
869 exit_status: String,
870}
871
872#[derive(Serialize)]
873struct TaskStartEvent {
874 event_type: &'static str,
875 event_id: String,
876 parent_id: Option<String>,
877 session_id: String,
878 timestamp: u64,
879 task_id: String,
880 context_id: String,
881 source: String,
884 origin: String,
885 trust: String,
886 lane: String,
889 #[serde(skip_serializing_if = "Option::is_none")]
893 delegation_id: Option<String>,
894 message_parts_bytes: u64,
895}
896
897#[derive(Serialize)]
898struct TaskEndEvent {
899 event_type: &'static str,
900 event_id: String,
901 parent_id: Option<String>,
902 session_id: String,
903 timestamp: u64,
904 task_id: String,
905 exit_status: String,
906 duration_ms: u64,
907 turns: u32,
908 input_tokens: u64,
909 output_tokens: u64,
910 tool_calls: u32,
911 shell_calls: u32,
912 reopen_count: u32,
915}
916
917#[derive(Serialize)]
924struct TaskCanceledEvent {
925 event_type: &'static str,
926 event_id: String,
927 parent_id: Option<String>,
928 session_id: String,
929 timestamp: u64,
930 task_id: String,
931 #[serde(skip_serializing_if = "Option::is_none")]
933 turn: Option<u32>,
934 phase: String,
937 detached_work_ids: Vec<String>,
940 delegation_ids: Vec<String>,
943}
944
945pub(crate) const TASK_REJECTED_SESSION_ENDED: &str = "session_ended";
948
949pub(crate) const TASK_REJECTED_SESSION_STOPPED: &str = "session_stopped";
951
952#[derive(Serialize)]
959struct TaskRejectedEvent {
960 event_type: &'static str,
961 event_id: String,
962 parent_id: Option<String>,
963 session_id: String,
964 timestamp: u64,
965 task_id: String,
966 context_id: String,
967 source: String,
970 cause: String,
972 reason: String,
974}
975
976pub(crate) const TASK_FAILED_DRIVER_ERROR: &str = "driver_error";
979
980pub(crate) const TASK_FAILED_CREDENTIAL_REJECTED: &str = "credential_rejected";
983
984pub(crate) const TASK_FAILED_MALFORMED_RESPONSE: &str = "malformed_response";
987
988pub(crate) const TASK_FAILED_COMPACTION_HOOK: &str = "compaction_hook";
990
991pub(crate) const TASK_FAILED_INPUT_TIMEOUT: &str = "input_timeout";
993
994pub(crate) const TASK_FAILED_REOPEN_BUDGET_EXHAUSTED: &str = "reopen_budget_exhausted";
997
998pub(crate) const TASK_FAILED_RUNTIME_ERROR: &str = "runtime_error";
1001
1002pub(crate) const MAX_TASK_FAILURE_REASON_BYTES: usize = 2_000;
1005
1006#[derive(Serialize)]
1014struct TaskFailedEvent {
1015 event_type: &'static str,
1016 event_id: String,
1017 parent_id: Option<String>,
1018 session_id: String,
1019 timestamp: u64,
1020 #[serde(skip_serializing_if = "Option::is_none")]
1023 task_id: Option<String>,
1024 #[serde(skip_serializing_if = "Option::is_none")]
1027 turn: Option<u32>,
1028 cause: String,
1029 reason: String,
1031}
1032
1033pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
1036
1037pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
1040
1041#[derive(Serialize)]
1046struct TaskReopenedEvent {
1047 event_type: &'static str,
1048 event_id: String,
1049 parent_id: Option<String>,
1050 session_id: String,
1051 timestamp: u64,
1052 task_id: String,
1053 hook_name: String,
1055 reason: String,
1057 reopen_number: u32,
1059 attempt_context: &'static str,
1061 turns_remaining: u32,
1064}
1065
1066#[derive(Serialize)]
1069struct SpendCeilingReachedEvent<'a> {
1070 event_type: &'static str,
1071 event_id: String,
1072 parent_id: Option<String>,
1073 session_id: String,
1074 timestamp: u64,
1075 turn: u32,
1076 task_id: Option<String>,
1077 #[serde(flatten)]
1078 refusal: &'a crate::spend::SpendRefusal,
1079 #[serde(skip_serializing_if = "Option::is_none")]
1081 origin: Option<&'a str>,
1082}
1083
1084#[derive(Serialize)]
1089pub(crate) struct HarnessStart {
1090 pub driver: String,
1092 pub driver_version: String,
1093 pub harness: String,
1095 pub binary: String,
1097 pub binary_source: String,
1099 pub harness_version: Option<String>,
1101 pub version_tested: bool,
1103 pub harness_session_id: String,
1105 pub session_mode: String,
1106 pub args_count: usize,
1109 pub env_names: Vec<String>,
1111 pub files: Vec<String>,
1113 pub bridge_tools: Vec<String>,
1115 pub stdin_bytes: usize,
1117 pub keep_stdin_open: bool,
1118}
1119
1120#[derive(Serialize)]
1121struct HarnessStartEvent {
1122 event_type: &'static str,
1123 event_id: String,
1124 parent_id: Option<String>,
1125 session_id: String,
1126 timestamp: u64,
1127 task_id: Option<String>,
1128 #[serde(flatten)]
1129 start: HarnessStart,
1130}
1131
1132#[derive(Serialize)]
1135struct HarnessWarningEvent {
1136 event_type: &'static str,
1137 event_id: String,
1138 parent_id: Option<String>,
1139 session_id: String,
1140 timestamp: u64,
1141 task_id: Option<String>,
1142 code: String,
1143 message: String,
1144}
1145
1146#[derive(Serialize)]
1149struct HarnessSessionEvent {
1150 event_type: &'static str,
1151 event_id: String,
1152 parent_id: Option<String>,
1153 session_id: String,
1154 timestamp: u64,
1155 task_id: Option<String>,
1156 harness_session_id: String,
1157 auth: String,
1159 model: Option<String>,
1161}
1162
1163#[derive(Serialize)]
1167struct HarnessSessionForgottenEvent {
1168 event_type: &'static str,
1169 event_id: String,
1170 parent_id: Option<String>,
1171 session_id: String,
1172 timestamp: u64,
1173 task_id: Option<String>,
1174 context_id: String,
1176 harness_session_id: String,
1178 requested_by: String,
1180}
1181
1182#[derive(Serialize)]
1183struct HarnessRetryEvent {
1184 event_type: &'static str,
1185 event_id: String,
1186 parent_id: Option<String>,
1187 session_id: String,
1188 timestamp: u64,
1189 task_id: Option<String>,
1190 attempt: u32,
1191 reason: String,
1192}
1193
1194#[derive(Serialize)]
1195struct HarnessNoteEvent {
1196 event_type: &'static str,
1197 event_id: String,
1198 parent_id: Option<String>,
1199 session_id: String,
1200 timestamp: u64,
1201 task_id: Option<String>,
1202 text: String,
1203}
1204
1205#[derive(Serialize)]
1206struct HarnessFailedEvent {
1207 event_type: &'static str,
1208 event_id: String,
1209 parent_id: Option<String>,
1210 session_id: String,
1211 timestamp: u64,
1212 task_id: Option<String>,
1213 kind: String,
1215 message: String,
1216 source: String,
1218}
1219
1220#[derive(Serialize)]
1222struct HarnessInterruptEvent {
1223 event_type: &'static str,
1224 event_id: String,
1225 parent_id: Option<String>,
1226 session_id: String,
1227 timestamp: u64,
1228 task_id: Option<String>,
1229 method: String,
1231 delivered: bool,
1233 grace_ms: u64,
1236}
1237
1238#[derive(Serialize)]
1240pub(crate) struct HarnessExit {
1241 pub code: Option<i32>,
1243 pub signal: Option<i32>,
1245 pub cause: String,
1248 pub killed: bool,
1250 pub duration_ms: u64,
1252}
1253
1254#[derive(Serialize)]
1255struct HarnessExitEvent {
1256 event_type: &'static str,
1257 event_id: String,
1258 parent_id: Option<String>,
1259 session_id: String,
1260 timestamp: u64,
1261 task_id: Option<String>,
1262 #[serde(flatten)]
1263 exit: HarnessExit,
1264}
1265
1266#[derive(Serialize)]
1270struct CallDeniedEvent {
1271 event_type: &'static str,
1272 event_id: String,
1273 parent_id: Option<String>,
1274 session_id: String,
1275 timestamp: u64,
1276 turn: u32,
1277 event: String,
1280 hook_name: String,
1282 target: String,
1285 reason: String,
1287}
1288
1289#[derive(Serialize)]
1294struct ProtectedPathDeniedEvent {
1295 event_type: &'static str,
1296 event_id: String,
1297 parent_id: Option<String>,
1298 session_id: String,
1299 timestamp: u64,
1300 turn: u32,
1301 call: String,
1303 target: String,
1305 path: String,
1308 rule: String,
1310 signal: String,
1313 reason: String,
1315}
1316
1317#[derive(Serialize)]
1318struct HookDispatchErrorEvent {
1319 event_type: &'static str,
1320 event_id: String,
1321 parent_id: Option<String>,
1322 session_id: String,
1323 timestamp: u64,
1324 hook_name: String,
1325 event: String,
1326 arm: String,
1327}
1328
1329#[derive(Serialize)]
1330struct ResourceListEvent {
1331 event_type: &'static str,
1332 event_id: String,
1333 parent_id: Option<String>,
1334 session_id: String,
1335 timestamp: u64,
1336 root: String,
1339 entry_count: usize,
1340 total_bytes: u64,
1341 generation: u64,
1343 containment_achieved: ContainmentClass,
1344 outcome: String,
1346 reason: Option<String>,
1348}
1349
1350#[derive(Serialize)]
1351struct ResourceReadEvent {
1352 event_type: &'static str,
1353 event_id: String,
1354 parent_id: Option<String>,
1355 session_id: String,
1356 timestamp: u64,
1357 path: String,
1360 outcome: String,
1361 bytes: Option<u64>,
1363 sha256: Option<String>,
1365 generation: u64,
1366 containment_achieved: ContainmentClass,
1367 reason: Option<String>,
1368}
1369
1370#[derive(Serialize)]
1372struct PeerHandleMintEvent {
1373 event_type: &'static str,
1374 event_id: String,
1375 parent_id: Option<String>,
1376 session_id: String,
1377 timestamp: u64,
1378 handle_id: Option<String>,
1381 path: String,
1384 audience: String,
1385 expires_at_ms: Option<u64>,
1387 outcome: String,
1388 reason: Option<String>,
1389}
1390
1391#[derive(Serialize)]
1394struct PeerHandleRedeemEvent {
1395 event_type: &'static str,
1396 event_id: String,
1397 parent_id: Option<String>,
1398 session_id: String,
1399 timestamp: u64,
1400 handle_id: String,
1401 path: Option<String>,
1404 generation: u64,
1407 audience_asserted: Option<String>,
1409 bytes: Option<u64>,
1411 sha256: Option<String>,
1413 outcome: String,
1414 reason: Option<String>,
1415}
1416
1417#[derive(Serialize)]
1419struct PeerFileFetchEvent {
1420 event_type: &'static str,
1421 event_id: String,
1422 parent_id: Option<String>,
1423 session_id: String,
1424 timestamp: u64,
1425 peer: String,
1426 handle_id: String,
1427 stored_path: Option<String>,
1430 bytes: Option<u64>,
1431 sha256: Option<String>,
1432 outcome: String,
1433 reason: Option<String>,
1434}
1435
1436#[derive(Serialize)]
1447struct DelegationStartEvent {
1448 event_type: &'static str,
1449 event_id: String,
1450 parent_id: Option<String>,
1451 session_id: String,
1452 timestamp: u64,
1453 delegation_id: String,
1456 capsule: String,
1457 version: String,
1458 child_session_id: String,
1460 child_workdir: String,
1463}
1464
1465impl DelegationStartEvent {
1466 fn new(
1469 session_id: &str,
1470 session_event_id: &str,
1471 delegation_id: &str,
1472 capsule: &str,
1473 version: &str,
1474 child_session_id: &str,
1475 child_workdir: &str,
1476 ) -> Self {
1477 Self {
1478 event_type: "delegation_start",
1479 event_id: new_event_id(),
1480 parent_id: Some(session_event_id.to_string()),
1481 session_id: session_id.to_string(),
1482 timestamp: timestamp_ms(),
1483 delegation_id: delegation_id.to_string(),
1484 capsule: capsule.to_string(),
1485 version: version.to_string(),
1486 child_session_id: child_session_id.to_string(),
1487 child_workdir: child_workdir.to_string(),
1488 }
1489 }
1490}
1491
1492#[derive(Serialize)]
1503struct DelegationEvent {
1504 event_type: &'static str,
1505 event_id: String,
1506 parent_id: Option<String>,
1507 session_id: String,
1508 timestamp: u64,
1509 capsule: String,
1511 version: String,
1512 delegation_id: Option<String>,
1514 child_session_id: Option<String>,
1516 duration_ms: u64,
1519 outcome: String,
1529 reason: Option<String>,
1532}
1533
1534impl DelegationEvent {
1535 #[allow(clippy::too_many_arguments)]
1538 fn new(
1539 session_id: &str,
1540 session_event_id: &str,
1541 capsule: &str,
1542 version: &str,
1543 delegation_id: Option<String>,
1544 child_session_id: Option<String>,
1545 duration_ms: u64,
1546 outcome: &str,
1547 reason: Option<String>,
1548 ) -> Self {
1549 Self {
1550 event_type: "delegation",
1551 event_id: new_event_id(),
1552 parent_id: Some(session_event_id.to_string()),
1553 session_id: session_id.to_string(),
1554 timestamp: timestamp_ms(),
1555 capsule: capsule.to_string(),
1556 version: version.to_string(),
1557 delegation_id,
1558 child_session_id,
1559 duration_ms,
1560 outcome: outcome.to_string(),
1561 reason,
1562 }
1563 }
1564}
1565
1566impl TraceWriter {
1569 #[allow(clippy::too_many_arguments)]
1570 pub(crate) async fn open(
1571 workdir: &Path,
1572 session_id: String,
1573 capsule_name: String,
1574 capsule_version: String,
1575 model: String,
1576 capabilities: Vec<String>,
1577 effective_grants: ScopeReport,
1578 capture: TraceCapture,
1579 system_prompt: Option<String>,
1580 system_prompt_overridden: bool,
1581 resumed_from: Option<String>,
1582 context_id: Option<String>,
1583 spawned_by: Option<String>,
1584 spawn_delegation_id: Option<String>,
1585 ) -> std::io::Result<Self> {
1586 let file = OpenOptions::new()
1587 .create(true)
1588 .append(true)
1589 .open(workdir.join("trace.jsonl"))
1590 .await?;
1591 let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
1594 (true, _) => "cli",
1595 (false, true) => "manifest",
1596 (false, false) => "none",
1597 };
1598 let system_prompt_sha256 = system_prompt
1599 .as_ref()
1600 .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
1601 let blobs = BlobStore::new(workdir);
1602 if let (true, Some(prompt), Some(sha)) = (
1606 capture.captures_content(),
1607 system_prompt.as_ref(),
1608 system_prompt_sha256.as_deref(),
1609 ) {
1610 blobs.put(sha, prompt.as_bytes()).await?;
1611 }
1612 Ok(Self {
1613 writer: BufWriter::new(file),
1614 session_id,
1615 capsule_name,
1616 capsule_version,
1617 model,
1618 capabilities,
1619 effective_grants,
1620 capture,
1621 blobs,
1622 system_prompt_source,
1623 system_prompt_sha256,
1624 credential_source: "none",
1625 gateways: Vec::new(),
1626 control: None,
1627 inference_choices: Vec::new(),
1628 tool_refresh: None,
1629 runtime_artifacts: Vec::new(),
1630 resumed_from,
1631 context_id,
1632 spawned_by,
1633 spawn_delegation_id,
1634 session_start_time: Instant::now(),
1635 session_event_id: new_event_id(),
1636 session_started: false,
1637 session_ended: false,
1638 task_event_id: None,
1639 turn_event_id: None,
1640 total_turns: 0,
1641 total_input_tokens: 0,
1642 total_output_tokens: 0,
1643 total_tool_calls: 0,
1644 total_shell_calls: 0,
1645 task_turns: 0,
1646 task_input_tokens: 0,
1647 task_output_tokens: 0,
1648 task_tool_calls: 0,
1649 task_shell_calls: 0,
1650 task_start_instant: None,
1651 active_task_id: None,
1652 task_failures_written: 0,
1653 last_task_failure: None,
1654 max_session_tokens: None,
1655 machine_tokens_per_day: None,
1656 })
1657 }
1658
1659 pub(crate) fn set_spend_ceilings(
1661 &mut self,
1662 max_session_tokens: Option<u64>,
1663 machine_tokens_per_day: Option<u64>,
1664 ) {
1665 self.max_session_tokens = max_session_tokens;
1666 self.machine_tokens_per_day = machine_tokens_per_day;
1667 }
1668
1669 pub(crate) fn session_event_id(&self) -> &str {
1673 &self.session_event_id
1674 }
1675
1676 pub(crate) fn captures_wire(&self) -> bool {
1680 self.capture.captures_hashes()
1681 }
1682
1683 fn session_parent(&self) -> Option<String> {
1688 self.session_started.then(|| self.session_event_id.clone())
1689 }
1690
1691 fn task_parent(&self) -> Option<String> {
1694 self.task_event_id.clone().or_else(|| self.session_parent())
1695 }
1696
1697 fn turn_parent(&self) -> Option<String> {
1701 self.turn_event_id.clone().or_else(|| self.task_parent())
1702 }
1703
1704 pub(crate) fn set_credential_source(&mut self, source: &'static str) {
1707 self.credential_source = source;
1708 }
1709
1710 pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
1712 self.gateways = gateways;
1713 }
1714
1715 pub(crate) fn set_control(&mut self, control: Option<SessionControl>) {
1717 self.control = control;
1718 }
1719
1720 pub(crate) fn set_inference_choices(&mut self, choices: Vec<SessionInferenceChoice>) {
1723 self.inference_choices = choices;
1724 }
1725
1726 pub(crate) fn set_runtime_artifacts(&mut self, installed: &[InstalledArtifactSummary]) {
1729 self.runtime_artifacts = installed
1730 .iter()
1731 .filter_map(|artifact| match &artifact.origin {
1732 LockOrigin::Runtime { session } => Some(RuntimeArtifact {
1733 name: artifact.name.clone(),
1734 version: artifact.version.clone(),
1735 origin: artifact.origin.as_str(),
1736 session: session.clone(),
1737 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
1738 }),
1739 LockOrigin::Operator => None,
1740 })
1741 .collect();
1742 }
1743
1744 pub(crate) async fn write_control_applied(
1751 &mut self,
1752 turn: u32,
1753 name: &str,
1754 value: Value,
1755 change_id: &str,
1756 ) -> std::io::Result<()> {
1757 let event = ControlAppliedEvent {
1758 event_type: "control_applied",
1759 event_id: new_event_id(),
1760 parent_id: self.task_parent(),
1761 session_id: self.session_id.clone(),
1762 timestamp: timestamp_ms(),
1763 turn,
1764 task_id: self.active_task_id.clone(),
1765 name,
1766 value,
1767 change_id,
1768 };
1769 self.write_event(&event).await
1770 }
1771
1772 pub(crate) fn set_tool_refresh(&mut self, tool_refresh: Option<&'static str>) {
1775 self.tool_refresh = tool_refresh;
1776 }
1777
1778 pub(crate) async fn write_tools_refreshed(
1785 &mut self,
1786 turn: u32,
1787 trigger: &str,
1788 added: &[String],
1789 removed: &[String],
1790 tools: &[String],
1791 ) -> std::io::Result<()> {
1792 let event = ToolsRefreshedEvent {
1793 event_type: "tools_refreshed",
1794 event_id: new_event_id(),
1795 parent_id: self.task_parent(),
1796 session_id: self.session_id.clone(),
1797 timestamp: timestamp_ms(),
1798 turn,
1799 task_id: self.active_task_id.clone(),
1800 trigger,
1801 added,
1802 removed,
1803 tools,
1804 };
1805 self.write_event(&event).await
1806 }
1807
1808 pub(crate) async fn write_session_start(
1809 &mut self,
1810 max_turns: u32,
1811 tools_declared: Vec<String>,
1812 ) -> std::io::Result<()> {
1813 let event = SessionStartEvent {
1814 event_type: "session_start",
1815 event_id: self.session_event_id.clone(),
1816 parent_id: None,
1817 session_id: self.session_id.clone(),
1818 timestamp: timestamp_ms(),
1819 capsule_name: self.capsule_name.clone(),
1820 capsule_version: self.capsule_version.clone(),
1821 model: self.model.clone(),
1822 max_turns,
1823 max_session_tokens: self.max_session_tokens,
1824 machine_tokens_per_day: self.machine_tokens_per_day,
1825 capabilities: self.capabilities.clone(),
1826 tools_declared,
1827 containment_declared: self.effective_grants.declared_containment,
1828 containment_achieved: self.effective_grants.achieved_containment,
1829 workdir_exec: self.effective_grants.workdir_exec,
1830 userns_grant: self.effective_grants.userns_grant,
1831 effective_grants: self.effective_grants.clone(),
1832 system_prompt_source: self.system_prompt_source,
1833 system_prompt_sha256: self.system_prompt_sha256.clone(),
1834 credential_source: self.credential_source,
1835 gateways: self.gateways.clone(),
1836 control: self.control.clone(),
1837 inference_choices: self.inference_choices.clone(),
1838 tool_refresh: self.tool_refresh,
1839 runtime_artifacts: self.runtime_artifacts.clone(),
1840 resumed_from: self.resumed_from.clone(),
1841 context_id: self.context_id.clone(),
1842 spawned_by: self.spawned_by.clone(),
1843 delegation_id: self.spawn_delegation_id.clone(),
1844 };
1845 self.write_event(&event).await?;
1846 self.session_started = true;
1847 Ok(())
1848 }
1849
1850 #[allow(clippy::too_many_arguments)]
1860 pub(crate) async fn write_inference(
1861 &mut self,
1862 turn: u32,
1863 input_tokens: Option<u64>,
1864 output_tokens: Option<u64>,
1865 decision: String,
1866 stop_reason: Option<&str>,
1867 tool_name: Option<String>,
1868 origin: Option<&InferenceOrigin>,
1869 usage: Option<&DriverUsage>,
1870 message_ids: Vec<String>,
1871 wire: Option<&WireCapture>,
1872 ) -> std::io::Result<()> {
1873 self.write_inference_event(
1874 turn,
1875 input_tokens,
1876 output_tokens,
1877 decision,
1878 stop_reason,
1879 tool_name,
1880 origin,
1881 usage,
1882 message_ids,
1883 wire,
1884 None,
1885 )
1886 .await
1887 }
1888
1889 #[allow(clippy::too_many_arguments)]
1893 pub(crate) async fn write_turn_inference(
1894 &mut self,
1895 turn: u32,
1896 input_tokens: Option<u64>,
1897 output_tokens: Option<u64>,
1898 decision: String,
1899 stop_reason: Option<&str>,
1900 tool_name: Option<String>,
1901 usage: Option<&DriverUsage>,
1902 message_ids: Vec<String>,
1903 wire: Option<&WireCapture>,
1904 choice: Option<&crate::driver_choice::DriverChoice>,
1905 ) -> std::io::Result<()> {
1906 self.write_inference_event(
1907 turn,
1908 input_tokens,
1909 output_tokens,
1910 decision,
1911 stop_reason,
1912 tool_name,
1913 None,
1914 usage,
1915 message_ids,
1916 wire,
1917 choice,
1918 )
1919 .await
1920 }
1921
1922 #[allow(clippy::too_many_arguments)]
1923 async fn write_inference_event(
1924 &mut self,
1925 turn: u32,
1926 input_tokens: Option<u64>,
1927 output_tokens: Option<u64>,
1928 decision: String,
1929 stop_reason: Option<&str>,
1930 tool_name: Option<String>,
1931 origin: Option<&InferenceOrigin>,
1932 usage: Option<&DriverUsage>,
1933 message_ids: Vec<String>,
1934 wire: Option<&WireCapture>,
1935 choice: Option<&crate::driver_choice::DriverChoice>,
1936 ) -> std::io::Result<()> {
1937 let event_id = new_event_id();
1938 let parent_id = if origin.is_none() {
1942 let parent = self.task_parent();
1943 self.turn_event_id = Some(event_id.clone());
1944 parent
1945 } else {
1946 self.turn_parent()
1947 };
1948 let (system_sha, tools_sha, response_sha, message_shas) =
1949 match wire.filter(|_| self.capture.captures_hashes()) {
1950 Some(wire) => self.record_wire(wire).await?,
1951 None => (None, None, None, Vec::new()),
1952 };
1953 let event = InferenceEvent {
1954 event_type: "inference",
1955 event_id,
1956 parent_id,
1957 session_id: self.session_id.clone(),
1958 timestamp: timestamp_ms(),
1959 turn,
1960 task_id: self.active_task_id.clone(),
1961 input_tokens,
1962 output_tokens,
1963 decision,
1964 stop_reason: stop_reason.map(str::to_string),
1965 tool_name,
1966 input_tokens_actual: usage.and_then(|u| u.input_tokens),
1967 output_tokens_actual: usage.and_then(|u| u.output_tokens),
1968 cached_tokens: usage.and_then(|u| u.cached_tokens),
1969 cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
1970 thinking_tokens: usage.and_then(|u| u.thinking_tokens),
1971 origin: origin.map(|o| o.source.clone()),
1972 driver_choice: choice.map(|choice| choice.name.clone()),
1973 model: origin
1974 .map(|o| o.model.clone())
1975 .or_else(|| choice.map(|choice| choice.model.clone())),
1976 message_ids,
1977 system_sha,
1978 tools_sha,
1979 response_sha,
1980 message_shas,
1981 };
1982 self.write_event(&event).await?;
1983 let (input_tokens, output_tokens) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
1987 self.total_turns = self.total_turns.saturating_add(1);
1988 self.total_input_tokens = self.total_input_tokens.saturating_add(input_tokens);
1989 self.total_output_tokens = self.total_output_tokens.saturating_add(output_tokens);
1990 self.task_turns = self.task_turns.saturating_add(1);
1991 self.task_input_tokens = self.task_input_tokens.saturating_add(input_tokens);
1992 self.task_output_tokens = self.task_output_tokens.saturating_add(output_tokens);
1993 Ok(())
1994 }
1995
1996 async fn record_wire(
2003 &mut self,
2004 wire: &WireCapture,
2005 ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
2006 let system_sha = murmur_artifact::sha256_hex(&wire.system);
2007 let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
2008 let response_sha = murmur_artifact::sha256_hex(&wire.response);
2009 let message_shas: Vec<String> = wire
2010 .messages
2011 .iter()
2012 .map(|message| murmur_artifact::sha256_hex(message))
2013 .collect();
2014
2015 if self.capture.captures_content() {
2016 self.blobs.put(&system_sha, &wire.system).await?;
2020 self.blobs.put(&tools_sha, &wire.tools).await?;
2021 self.blobs.put(&response_sha, &wire.response).await?;
2022 for (sha, body) in message_shas.iter().zip(&wire.messages) {
2023 self.blobs.put(sha, body).await?;
2024 }
2025 }
2026
2027 Ok((
2028 Some(system_sha),
2029 Some(tools_sha),
2030 Some(response_sha),
2031 message_shas,
2032 ))
2033 }
2034
2035 #[allow(clippy::too_many_arguments)]
2036 pub(crate) async fn write_tool_call(
2037 &mut self,
2038 turn: u32,
2039 tool_name: String,
2040 tool_call_id: Option<String>,
2041 mut input: Value,
2042 input_bytes: u64,
2043 output: &str,
2044 output_bytes: u64,
2045 duration_ms: u64,
2046 status: String,
2047 state_effect: Option<String>,
2048 resource_id: Option<String>,
2049 ) -> std::io::Result<()> {
2050 crate::peer_handoff::redact_handles_in_json(&mut input);
2055 let event = ToolCallEvent {
2056 event_type: "tool_call",
2057 event_id: new_event_id(),
2058 parent_id: self.turn_parent(),
2059 session_id: self.session_id.clone(),
2060 timestamp: timestamp_ms(),
2061 turn,
2062 task_id: self.active_task_id.clone(),
2063 tool_name,
2064 tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
2067 input,
2068 input_bytes,
2069 output: self
2070 .capture
2071 .captures_content()
2072 .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
2073 output_bytes,
2074 duration_ms,
2075 status,
2076 state_effect,
2077 resource_id,
2078 };
2079 self.write_event(&event).await?;
2080 self.total_tool_calls = self.total_tool_calls.saturating_add(1);
2081 self.task_tool_calls = self.task_tool_calls.saturating_add(1);
2082 Ok(())
2083 }
2084
2085 pub(crate) async fn write_skill_call(
2091 &mut self,
2092 turn: u32,
2093 skill_name: String,
2094 origin: &LockOrigin,
2095 output_bytes: u64,
2096 duration_ms: u64,
2097 status: String,
2098 ) -> std::io::Result<()> {
2099 let event = SkillCallEvent {
2100 event_type: "skill_call",
2101 event_id: new_event_id(),
2102 parent_id: self.turn_parent(),
2103 session_id: self.session_id.clone(),
2104 timestamp: timestamp_ms(),
2105 turn,
2106 task_id: self.active_task_id.clone(),
2107 skill_name,
2108 output_bytes,
2109 duration_ms,
2110 status,
2111 origin: origin.as_str(),
2112 trust: crate::origin::artifact_trust(origin).as_str(),
2113 };
2114 self.write_event(&event).await
2115 }
2117
2118 #[allow(clippy::too_many_arguments)]
2121 pub(crate) async fn write_shell(
2122 &mut self,
2123 turn: u32,
2124 binary: String,
2125 command: String,
2126 exit_code: i32,
2127 stdout_bytes: u64,
2128 stderr_bytes: u64,
2129 duration_ms: u64,
2130 resource_limit: Option<String>,
2131 ) -> std::io::Result<()> {
2132 let event = ShellEvent {
2133 event_type: "shell",
2134 event_id: new_event_id(),
2135 parent_id: self.turn_parent(),
2136 session_id: self.session_id.clone(),
2137 timestamp: timestamp_ms(),
2138 turn,
2139 task_id: self.active_task_id.clone(),
2140 binary,
2141 command,
2142 exit_code,
2143 stdout_bytes,
2144 stderr_bytes,
2145 duration_ms,
2146 resource_limit,
2147 };
2148 self.write_event(&event).await?;
2149 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
2150 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
2151 Ok(())
2152 }
2153
2154 pub(crate) async fn write_shell_detached(
2161 &mut self,
2162 turn: u32,
2163 work_id: &str,
2164 binary: &str,
2165 command: &str,
2166 grace_ms: u64,
2167 ) -> std::io::Result<()> {
2168 let event = ShellDetachedEvent {
2169 event_type: "shell_detached",
2170 event_id: new_event_id(),
2171 parent_id: self.turn_parent(),
2172 session_id: self.session_id.clone(),
2173 timestamp: timestamp_ms(),
2174 turn,
2175 task_id: self.active_task_id.clone(),
2176 work_id: work_id.to_string(),
2177 binary: binary.to_string(),
2178 command: command.to_string(),
2179 grace_ms,
2180 };
2181 self.write_event(&event).await?;
2182 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
2183 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
2184 Ok(())
2185 }
2186
2187 #[allow(clippy::too_many_arguments)]
2190 pub(crate) async fn write_shell_completed(
2191 &mut self,
2192 work_id: &str,
2193 binary: &str,
2194 command: &str,
2195 exit_code: i32,
2196 duration_ms: u64,
2197 output_path: &str,
2198 output_bytes: u64,
2199 resource_limit: Option<String>,
2200 status: &str,
2201 completion_task_id: &str,
2202 ) -> std::io::Result<()> {
2203 let event = ShellCompletedEvent {
2204 event_type: "shell_completed",
2205 event_id: new_event_id(),
2206 parent_id: self.session_parent(),
2207 session_id: self.session_id.clone(),
2208 timestamp: timestamp_ms(),
2209 work_id: work_id.to_string(),
2210 binary: binary.to_string(),
2211 command: command.to_string(),
2212 exit_code,
2213 duration_ms,
2214 output_path: output_path.to_string(),
2215 output_bytes,
2216 resource_limit,
2217 status: status.to_string(),
2218 completion_task_id: completion_task_id.to_string(),
2219 };
2220 self.write_event(&event).await
2221 }
2222
2223 pub(crate) async fn write_shell_abandoned(
2230 &mut self,
2231 work_id: &str,
2232 binary: &str,
2233 command: &str,
2234 running_ms: u64,
2235 recovered: Option<(i32, &str, u64)>,
2236 ) -> std::io::Result<()> {
2237 let event = ShellAbandonedEvent {
2238 event_type: "shell_abandoned",
2239 event_id: new_event_id(),
2240 parent_id: self.session_parent(),
2241 session_id: self.session_id.clone(),
2242 timestamp: timestamp_ms(),
2243 work_id: work_id.to_string(),
2244 binary: binary.to_string(),
2245 command: command.to_string(),
2246 running_ms,
2247 exit_code: recovered.map(|(exit_code, _, _)| exit_code),
2248 output_path: recovered.map(|(_, path, _)| path.to_string()),
2249 output_bytes: recovered.map(|(_, _, bytes)| bytes),
2250 };
2251 self.write_event(&event).await
2252 }
2253
2254 pub(crate) async fn write_shell_detach_unrecorded(
2256 &mut self,
2257 turn: u32,
2258 work_id: &str,
2259 binary: &str,
2260 reason: &str,
2261 ) -> std::io::Result<()> {
2262 let event = ShellDetachUnrecordedEvent {
2263 event_type: "shell_detach_unrecorded",
2264 event_id: new_event_id(),
2265 parent_id: self.turn_parent(),
2266 session_id: self.session_id.clone(),
2267 timestamp: timestamp_ms(),
2268 turn,
2269 task_id: self.active_task_id.clone(),
2270 work_id: work_id.to_string(),
2271 binary: binary.to_string(),
2272 reason: reason.to_string(),
2273 };
2274 self.write_event(&event).await
2275 }
2276
2277 pub(crate) async fn write_a2a_task_received(
2278 &mut self,
2279 task_id: &str,
2280 context_id: &str,
2281 message_id: &str,
2282 traceparent_from_caller: Option<&str>,
2283 ) -> std::io::Result<()> {
2284 let event = A2aTaskReceivedEvent {
2285 event_type: "a2a_task_received",
2286 event_id: new_event_id(),
2287 parent_id: self.session_parent(),
2288 session_id: self.session_id.clone(),
2289 timestamp: timestamp_ms(),
2290 task_id: task_id.to_string(),
2291 context_id: context_id.to_string(),
2292 message_id: message_id.to_string(),
2293 traceparent_from_caller: traceparent_from_caller.map(str::to_string),
2294 };
2295 self.write_event(&event).await
2296 }
2297
2298 pub(crate) async fn write_a2a_send(
2299 &mut self,
2300 peer_url: &str,
2301 message_id: &str,
2302 task_id: &str,
2303 context_id: &str,
2304 traceparent: Option<&str>,
2305 trust: TrustClass,
2306 ) -> std::io::Result<()> {
2307 let event = A2aSendEvent {
2308 event_type: "a2a_send",
2309 event_id: new_event_id(),
2310 parent_id: self.session_parent(),
2311 session_id: self.session_id.clone(),
2312 timestamp: timestamp_ms(),
2313 peer_url: peer_url.to_string(),
2314 message_id: message_id.to_string(),
2315 task_id: task_id.to_string(),
2316 context_id: context_id.to_string(),
2317 traceparent: traceparent.map(str::to_string),
2318 trust: trust.as_str().to_string(),
2319 };
2320 self.write_event(&event).await
2321 }
2322
2323 pub(crate) async fn write_artifact_pulled(
2328 &mut self,
2329 artifact: &InstalledArtifactSummary,
2330 ) -> std::io::Result<()> {
2331 let session = match &artifact.origin {
2332 LockOrigin::Runtime { session } => Some(session.clone()),
2333 LockOrigin::Operator => None,
2334 };
2335 let event = ArtifactPulledEvent {
2336 event_type: "artifact_pulled",
2337 event_id: new_event_id(),
2338 parent_id: self.session_parent(),
2339 session_id: self.session_id.clone(),
2340 timestamp: timestamp_ms(),
2341 name: artifact.name.clone(),
2342 version: artifact.version.clone(),
2343 runtime: artifact.runtime.as_str(),
2344 origin: artifact.origin.as_str(),
2345 session,
2346 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2347 };
2348 self.write_event(&event).await
2349 }
2350
2351 pub(crate) async fn write_task_start(
2352 &mut self,
2353 task_id: &str,
2354 context_id: &str,
2355 source: &str,
2356 provenance: TaskProvenance,
2357 delegation_id: Option<&str>,
2358 message_parts_bytes: u64,
2359 ) -> std::io::Result<()> {
2360 self.task_turns = 0;
2362 self.task_input_tokens = 0;
2363 self.task_output_tokens = 0;
2364 self.task_tool_calls = 0;
2365 self.task_shell_calls = 0;
2366 self.task_start_instant = Some(Instant::now());
2367 self.active_task_id = Some(task_id.to_string());
2368
2369 let event_id = new_event_id();
2370 self.task_event_id = Some(event_id.clone());
2371 self.turn_event_id = None;
2372
2373 let event = TaskStartEvent {
2374 event_type: "task_start",
2375 event_id: event_id.clone(),
2376 parent_id: self.session_parent(),
2377 session_id: self.session_id.clone(),
2378 timestamp: timestamp_ms(),
2379 task_id: task_id.to_string(),
2380 context_id: context_id.to_string(),
2381 source: source.to_string(),
2382 origin: provenance.origin().as_str().to_string(),
2383 trust: provenance.trust().as_str().to_string(),
2384 lane: TaskLane::for_origin(provenance.origin())
2387 .as_str()
2388 .to_string(),
2389 delegation_id: delegation_id.map(str::to_string),
2390 message_parts_bytes,
2391 };
2392 self.write_event(&event).await
2393 }
2394
2395 pub(crate) async fn write_task_end(
2396 &mut self,
2397 task_id: &str,
2398 exit_status: &str,
2399 reopen_count: u32,
2400 ) -> std::io::Result<()> {
2401 let duration_ms = self
2402 .task_start_instant
2403 .take()
2404 .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
2405 .unwrap_or(0);
2406
2407 let event = TaskEndEvent {
2408 event_type: "task_end",
2409 event_id: new_event_id(),
2410 parent_id: self.task_parent(),
2411 session_id: self.session_id.clone(),
2412 timestamp: timestamp_ms(),
2413 task_id: task_id.to_string(),
2414 exit_status: exit_status.to_string(),
2415 duration_ms,
2416 turns: self.task_turns,
2417 input_tokens: self.task_input_tokens,
2418 output_tokens: self.task_output_tokens,
2419 tool_calls: self.task_tool_calls,
2420 shell_calls: self.task_shell_calls,
2421 reopen_count,
2422 };
2423 self.active_task_id = None;
2424 self.task_event_id = None;
2425 self.turn_event_id = None;
2426 self.write_event(&event).await
2427 }
2428
2429 pub(crate) async fn write_task_canceled(
2435 &mut self,
2436 task_id: &str,
2437 turn: Option<u32>,
2438 phase: &str,
2439 detached_work_ids: Vec<String>,
2440 delegation_ids: Vec<String>,
2441 ) -> std::io::Result<()> {
2442 let event = TaskCanceledEvent {
2443 event_type: "task_canceled",
2444 event_id: new_event_id(),
2445 parent_id: self.task_parent(),
2446 session_id: self.session_id.clone(),
2447 timestamp: timestamp_ms(),
2448 task_id: task_id.to_string(),
2449 turn,
2450 phase: phase.to_string(),
2451 detached_work_ids,
2452 delegation_ids,
2453 };
2454 self.write_event(&event).await
2455 }
2456
2457 pub(crate) async fn write_task_rejected(
2463 &mut self,
2464 task_id: &str,
2465 context_id: &str,
2466 source: &str,
2467 cause: &str,
2468 reason: &str,
2469 ) -> std::io::Result<()> {
2470 let event = TaskRejectedEvent {
2471 event_type: "task_rejected",
2472 event_id: new_event_id(),
2473 parent_id: self.task_parent(),
2474 session_id: self.session_id.clone(),
2475 timestamp: timestamp_ms(),
2476 task_id: task_id.to_string(),
2477 context_id: context_id.to_string(),
2478 source: source.to_string(),
2479 cause: cause.to_string(),
2480 reason: reason.to_string(),
2481 };
2482 self.write_event(&event).await
2483 }
2484
2485 pub(crate) async fn write_task_failed(
2491 &mut self,
2492 turn: Option<u32>,
2493 cause: &str,
2494 reason: &str,
2495 ) -> std::io::Result<()> {
2496 let reason = crate::agent::truncate_on_char_boundary(reason, MAX_TASK_FAILURE_REASON_BYTES)
2497 .to_string();
2498 let event = TaskFailedEvent {
2499 event_type: "task_failed",
2500 event_id: new_event_id(),
2501 parent_id: self.task_parent(),
2502 session_id: self.session_id.clone(),
2503 timestamp: timestamp_ms(),
2504 task_id: self.active_task_id.clone(),
2505 turn,
2506 cause: cause.to_string(),
2507 reason: reason.clone(),
2508 };
2509 self.task_failures_written += 1;
2510 self.last_task_failure = Some(reason);
2511 self.write_event(&event).await
2512 }
2513
2514 pub(crate) fn last_task_failure(&self) -> Option<&str> {
2516 self.last_task_failure.as_deref()
2517 }
2518
2519 pub(crate) fn task_failures_written(&self) -> u64 {
2522 self.task_failures_written
2523 }
2524
2525 pub(crate) async fn write_task_reopened(
2529 &mut self,
2530 task_id: &str,
2531 hook_name: &str,
2532 reason: &str,
2533 reopen_number: u32,
2534 attempt_context: &'static str,
2535 turns_remaining: u32,
2536 ) -> std::io::Result<()> {
2537 let event = TaskReopenedEvent {
2538 event_type: "task_reopened",
2539 event_id: new_event_id(),
2540 parent_id: self.task_parent(),
2541 session_id: self.session_id.clone(),
2542 timestamp: timestamp_ms(),
2543 task_id: task_id.to_string(),
2544 hook_name: hook_name.to_string(),
2545 reason: reason.to_string(),
2546 reopen_number,
2547 attempt_context,
2548 turns_remaining,
2549 };
2550 self.write_event(&event).await
2551 }
2552
2553 pub(crate) fn task_turns(&self) -> u32 {
2558 self.task_turns
2559 }
2560
2561 pub(crate) async fn write_compaction(
2562 &mut self,
2563 turn: u32,
2564 tokens_before: u64,
2565 tokens_after: u64,
2566 ) -> std::io::Result<()> {
2567 let event = CompactionEvent {
2568 event_type: "compaction",
2569 event_id: new_event_id(),
2570 parent_id: self.turn_parent(),
2571 session_id: self.session_id.clone(),
2572 timestamp: timestamp_ms(),
2573 turn,
2574 task_id: self.active_task_id.clone(),
2575 tokens_before,
2576 tokens_after,
2577 };
2578 self.write_event(&event).await
2579 }
2580
2581 pub(crate) async fn write_compaction_declined(
2586 &mut self,
2587 turn: u32,
2588 tokens: u64,
2589 reason: &str,
2590 ) -> std::io::Result<()> {
2591 let event = CompactionDeclinedEvent {
2592 event_type: "compaction_declined",
2593 event_id: new_event_id(),
2594 parent_id: self.turn_parent(),
2595 session_id: self.session_id.clone(),
2596 timestamp: timestamp_ms(),
2597 turn,
2598 task_id: self.active_task_id.clone(),
2599 tokens,
2600 reason: reason.to_string(),
2601 };
2602 self.write_event(&event).await
2603 }
2604
2605 #[allow(clippy::too_many_arguments)]
2612 pub(crate) async fn write_context_seed(
2613 &mut self,
2614 hook_name: &str,
2615 tokens: u64,
2616 proposed_tokens: u64,
2617 budget_tokens: u64,
2618 outcome: &str,
2619 reason: Option<&str>,
2620 message_ids: Vec<String>,
2621 ) -> std::io::Result<()> {
2622 let event = ContextSeedEvent {
2623 event_type: "context_seed",
2624 event_id: new_event_id(),
2625 parent_id: self.task_parent(),
2626 session_id: self.session_id.clone(),
2627 timestamp: timestamp_ms(),
2628 task_id: self.active_task_id.clone(),
2629 hook_name: hook_name.to_string(),
2630 tokens,
2631 proposed_tokens,
2632 budget_tokens,
2633 outcome: outcome.to_string(),
2634 reason: reason.map(str::to_string),
2635 message_ids,
2636 };
2637 self.write_event(&event).await
2638 }
2639
2640 pub(crate) async fn write_retention(
2646 &mut self,
2647 store: &str,
2648 reason: &str,
2649 targets: Vec<String>,
2650 messages_dropped: Option<u64>,
2651 ) -> std::io::Result<()> {
2652 let event = RetentionEvent {
2653 event_type: "retention",
2654 event_id: new_event_id(),
2655 parent_id: self.session_parent(),
2656 session_id: self.session_id.clone(),
2657 timestamp: timestamp_ms(),
2658 store: store.to_string(),
2659 reason: reason.to_string(),
2660 removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
2661 targets,
2662 messages_dropped,
2663 };
2664 self.write_event(&event).await
2665 }
2666
2667 pub(crate) async fn write_call_denied(
2671 &mut self,
2672 turn: u32,
2673 event: &str,
2674 hook_name: &str,
2675 target: &str,
2676 reason: &str,
2677 ) -> std::io::Result<()> {
2678 let event = CallDeniedEvent {
2679 event_type: "call_denied",
2680 event_id: new_event_id(),
2681 parent_id: self.turn_parent(),
2682 session_id: self.session_id.clone(),
2683 timestamp: timestamp_ms(),
2684 turn,
2685 event: event.to_string(),
2686 hook_name: hook_name.to_string(),
2687 target: target.to_string(),
2688 reason: reason.to_string(),
2689 };
2690 self.write_event(&event).await
2691 }
2692
2693 pub(crate) async fn write_spend_ceiling_reached(
2696 &mut self,
2697 turn: u32,
2698 refusal: &crate::spend::SpendRefusal,
2699 origin: Option<&str>,
2700 ) -> std::io::Result<()> {
2701 let event = SpendCeilingReachedEvent {
2702 event_type: "spend_ceiling_reached",
2703 event_id: new_event_id(),
2704 parent_id: self.turn_parent(),
2705 session_id: self.session_id.clone(),
2706 timestamp: timestamp_ms(),
2707 turn,
2708 task_id: self.active_task_id.clone(),
2709 refusal,
2710 origin,
2711 };
2712 self.write_event(&event).await
2713 }
2714
2715 #[allow(clippy::too_many_arguments)]
2719 pub(crate) async fn write_protected_path_denied(
2720 &mut self,
2721 turn: u32,
2722 call: &str,
2723 target: &str,
2724 path: &str,
2725 rule: &str,
2726 signal: &str,
2727 reason: &str,
2728 ) -> std::io::Result<()> {
2729 let event = ProtectedPathDeniedEvent {
2730 event_type: "protected_path_denied",
2731 event_id: new_event_id(),
2732 parent_id: self.turn_parent(),
2733 session_id: self.session_id.clone(),
2734 timestamp: timestamp_ms(),
2735 turn,
2736 call: call.to_string(),
2737 target: target.to_string(),
2738 path: path.to_string(),
2739 rule: rule.to_string(),
2740 signal: signal.to_string(),
2741 reason: reason.to_string(),
2742 };
2743 self.write_event(&event).await
2744 }
2745
2746 pub(crate) async fn write_hook_dispatch_error(
2752 &mut self,
2753 hook_name: &str,
2754 event: &str,
2755 arm: &str,
2756 ) -> std::io::Result<()> {
2757 let event = HookDispatchErrorEvent {
2758 event_type: "hook_dispatch_error",
2759 event_id: new_event_id(),
2760 parent_id: self.session_parent(),
2761 session_id: self.session_id.clone(),
2762 timestamp: timestamp_ms(),
2763 hook_name: hook_name.to_string(),
2764 event: event.to_string(),
2765 arm: arm.to_string(),
2766 };
2767 self.write_event(&event).await
2768 }
2769
2770 pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
2780 let event = HarnessStartEvent {
2781 event_type: "harness_start",
2782 event_id: new_event_id(),
2783 parent_id: self.task_parent(),
2784 session_id: self.session_id.clone(),
2785 timestamp: timestamp_ms(),
2786 task_id: self.active_task_id.clone(),
2787 start,
2788 };
2789 self.write_event(&event).await
2790 }
2791
2792 pub(crate) async fn write_harness_warning(
2794 &mut self,
2795 code: &str,
2796 message: &str,
2797 ) -> std::io::Result<()> {
2798 let event = HarnessWarningEvent {
2799 event_type: "harness_warning",
2800 event_id: new_event_id(),
2801 parent_id: self.task_parent(),
2802 session_id: self.session_id.clone(),
2803 timestamp: timestamp_ms(),
2804 task_id: self.active_task_id.clone(),
2805 code: code.to_string(),
2806 message: message.to_string(),
2807 };
2808 self.write_event(&event).await
2809 }
2810
2811 pub(crate) async fn write_harness_session(
2813 &mut self,
2814 harness_session_id: &str,
2815 auth: &str,
2816 model: Option<&str>,
2817 ) -> std::io::Result<()> {
2818 let event = HarnessSessionEvent {
2819 event_type: "harness_session",
2820 event_id: new_event_id(),
2821 parent_id: self.task_parent(),
2822 session_id: self.session_id.clone(),
2823 timestamp: timestamp_ms(),
2824 task_id: self.active_task_id.clone(),
2825 harness_session_id: harness_session_id.to_string(),
2826 auth: auth.to_string(),
2827 model: model.map(str::to_string),
2828 };
2829 self.write_event(&event).await
2830 }
2831
2832 pub(crate) async fn write_harness_session_forgotten(
2834 &mut self,
2835 context_id: &str,
2836 harness_session_id: &str,
2837 requested_by: &str,
2838 ) -> std::io::Result<()> {
2839 let event = HarnessSessionForgottenEvent {
2840 event_type: "harness_session_forgotten",
2841 event_id: new_event_id(),
2842 parent_id: self.task_parent(),
2843 session_id: self.session_id.clone(),
2844 timestamp: timestamp_ms(),
2845 task_id: self.active_task_id.clone(),
2846 context_id: context_id.to_string(),
2847 harness_session_id: harness_session_id.to_string(),
2848 requested_by: requested_by.to_string(),
2849 };
2850 self.write_event(&event).await
2851 }
2852
2853 pub(crate) async fn write_harness_retry(
2855 &mut self,
2856 attempt: u32,
2857 reason: &str,
2858 ) -> std::io::Result<()> {
2859 let event = HarnessRetryEvent {
2860 event_type: "harness_retry",
2861 event_id: new_event_id(),
2862 parent_id: self.task_parent(),
2863 session_id: self.session_id.clone(),
2864 timestamp: timestamp_ms(),
2865 task_id: self.active_task_id.clone(),
2866 attempt,
2867 reason: reason.to_string(),
2868 };
2869 self.write_event(&event).await
2870 }
2871
2872 pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
2875 let event = HarnessNoteEvent {
2876 event_type: "harness_note",
2877 event_id: new_event_id(),
2878 parent_id: self.task_parent(),
2879 session_id: self.session_id.clone(),
2880 timestamp: timestamp_ms(),
2881 task_id: self.active_task_id.clone(),
2882 text: text.to_string(),
2883 };
2884 self.write_event(&event).await
2885 }
2886
2887 pub(crate) async fn write_harness_failed(
2890 &mut self,
2891 kind: &str,
2892 message: &str,
2893 source: &str,
2894 ) -> std::io::Result<()> {
2895 let event = HarnessFailedEvent {
2896 event_type: "harness_failed",
2897 event_id: new_event_id(),
2898 parent_id: self.task_parent(),
2899 session_id: self.session_id.clone(),
2900 timestamp: timestamp_ms(),
2901 task_id: self.active_task_id.clone(),
2902 kind: kind.to_string(),
2903 message: message.to_string(),
2904 source: source.to_string(),
2905 };
2906 self.write_event(&event).await
2907 }
2908
2909 pub(crate) async fn write_harness_interrupt(
2912 &mut self,
2913 method: &str,
2914 delivered: bool,
2915 grace_ms: u64,
2916 ) -> std::io::Result<()> {
2917 let event = HarnessInterruptEvent {
2918 event_type: "harness_interrupt",
2919 event_id: new_event_id(),
2920 parent_id: self.task_parent(),
2921 session_id: self.session_id.clone(),
2922 timestamp: timestamp_ms(),
2923 task_id: self.active_task_id.clone(),
2924 method: method.to_string(),
2925 delivered,
2926 grace_ms,
2927 };
2928 self.write_event(&event).await
2929 }
2930
2931 pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
2933 let event = HarnessExitEvent {
2934 event_type: "harness_exit",
2935 event_id: new_event_id(),
2936 parent_id: self.task_parent(),
2937 session_id: self.session_id.clone(),
2938 timestamp: timestamp_ms(),
2939 task_id: self.active_task_id.clone(),
2940 exit,
2941 };
2942 self.write_event(&event).await
2943 }
2944
2945 pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
2946 let duration_ms = self
2947 .session_start_time
2948 .elapsed()
2949 .as_millis()
2950 .try_into()
2951 .unwrap_or(u64::MAX);
2952 let event = SessionEndEvent {
2953 event_type: "session_end",
2954 event_id: new_event_id(),
2955 parent_id: self.session_parent(),
2956 session_id: self.session_id.clone(),
2957 timestamp: timestamp_ms(),
2958 total_turns: self.total_turns,
2959 total_input_tokens: self.total_input_tokens,
2960 total_output_tokens: self.total_output_tokens,
2961 total_tool_calls: self.total_tool_calls,
2962 total_shell_calls: self.total_shell_calls,
2963 duration_ms,
2964 exit_status: exit_status.to_string(),
2965 };
2966 self.write_event(&event).await?;
2967 self.session_ended = true;
2968 Ok(())
2969 }
2970
2971 pub(crate) async fn write_session_end_if_not_ended(
2974 &mut self,
2975 exit_status: &str,
2976 ) -> std::io::Result<()> {
2977 if self.session_started && !self.session_ended {
2978 self.write_session_end(exit_status).await?;
2979 }
2980 Ok(())
2981 }
2982
2983 pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
2984 self.writer.flush().await
2985 }
2986
2987 async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
2995 let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
2996 line.push('\n');
2997 self.writer.write_all(line.as_bytes()).await?;
2998 self.writer.flush().await
2999 }
3000}
3001
3002#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3004pub(crate) struct SessionGateway {
3005 pub(crate) artifact: String,
3007 pub(crate) host: String,
3009 pub(crate) credential_source: &'static str,
3011 pub(crate) metered: bool,
3014}
3015
3016#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3019pub(crate) struct RuntimeArtifact {
3020 name: String,
3021 version: String,
3022 origin: &'static str,
3024 session: String,
3026 trust: &'static str,
3028}
3029
3030#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3032pub(crate) struct SessionControl {
3033 pub(crate) settings: Vec<&'static str>,
3035 pub(crate) secrets: Vec<String>,
3037 #[serde(skip_serializing_if = "Vec::is_empty")]
3040 pub(crate) agent_settings: Vec<&'static str>,
3041}
3042
3043#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3046pub(crate) struct SessionInferenceChoice {
3047 pub(crate) name: String,
3048 pub(crate) driver: String,
3049 pub(crate) model: String,
3050 pub(crate) credential_source: &'static str,
3053 pub(crate) available: bool,
3056}
3057
3058pub(crate) struct ControlChange<'a> {
3062 pub(crate) event_id: String,
3063 pub(crate) principal: crate::control_plane::Principal,
3065 pub(crate) token_id: Option<&'a str>,
3068 pub(crate) kind: &'static str,
3070 pub(crate) name: &'a str,
3071 pub(crate) action: &'static str,
3073 pub(crate) previous: Option<Value>,
3075 pub(crate) value: Option<Value>,
3076 pub(crate) applies_from: Option<&'static str>,
3078 pub(crate) replaced: Option<bool>,
3080}
3081
3082#[derive(Serialize)]
3085struct ControlChangeEvent<'a> {
3086 event_type: &'static str,
3087 event_id: String,
3088 parent_id: Option<String>,
3089 session_id: String,
3090 timestamp: u64,
3091 principal: &'static str,
3092 #[serde(skip_serializing_if = "Option::is_none")]
3093 token_id: Option<&'a str>,
3094 kind: &'static str,
3095 name: &'a str,
3096 action: &'static str,
3097 #[serde(skip_serializing_if = "Option::is_none")]
3098 previous: Option<Value>,
3099 #[serde(skip_serializing_if = "Option::is_none")]
3100 value: Option<Value>,
3101 #[serde(skip_serializing_if = "Option::is_none")]
3102 applies_from: Option<&'static str>,
3103 #[serde(skip_serializing_if = "Option::is_none")]
3104 replaced: Option<bool>,
3105}
3106
3107#[derive(Serialize)]
3111struct ControlRefusedEvent<'a> {
3112 event_type: &'static str,
3113 event_id: String,
3114 parent_id: Option<String>,
3115 session_id: String,
3116 timestamp: u64,
3117 principal: &'static str,
3118 status: u16,
3119 reason: &'static str,
3120 #[serde(skip_serializing_if = "Option::is_none")]
3121 kind: Option<&'static str>,
3122 #[serde(skip_serializing_if = "Option::is_none")]
3123 name: Option<&'a str>,
3124 #[serde(skip_serializing_if = "Option::is_none")]
3125 token_id: Option<&'a str>,
3126}
3127
3128#[derive(Serialize)]
3130struct ControlAppliedEvent<'a> {
3131 event_type: &'static str,
3132 event_id: String,
3133 parent_id: Option<String>,
3134 session_id: String,
3135 timestamp: u64,
3136 turn: u32,
3137 task_id: Option<String>,
3138 name: &'a str,
3139 value: Value,
3140 change_id: &'a str,
3141}
3142
3143#[derive(Serialize)]
3145struct ToolsRefreshedEvent<'a> {
3146 event_type: &'static str,
3147 event_id: String,
3148 parent_id: Option<String>,
3149 session_id: String,
3150 timestamp: u64,
3151 turn: u32,
3152 task_id: Option<String>,
3153 trigger: &'a str,
3154 added: &'a [String],
3155 removed: &'a [String],
3156 tools: &'a [String],
3157}
3158
3159#[derive(Serialize)]
3162struct GatewayCredentialEvent<'a> {
3163 event_type: &'static str,
3164 event_id: String,
3165 parent_id: Option<String>,
3166 session_id: String,
3167 timestamp: u64,
3168 artifact: &'a str,
3169 source: &'static str,
3170 #[serde(skip_serializing_if = "Option::is_none")]
3171 credential: Option<&'a str>,
3172 change: &'static str,
3173 #[serde(skip_serializing_if = "Option::is_none")]
3174 trigger: Option<&'static str>,
3175 #[serde(skip_serializing_if = "Option::is_none")]
3176 status: Option<u16>,
3177 #[serde(skip_serializing_if = "Option::is_none")]
3178 retried: Option<bool>,
3179 #[serde(skip_serializing_if = "Option::is_none")]
3180 reason: Option<&'static str>,
3181}
3182
3183#[derive(Serialize)]
3187struct InferenceCredentialEvent<'a> {
3188 event_type: &'static str,
3189 event_id: String,
3190 parent_id: Option<String>,
3191 session_id: String,
3192 timestamp: u64,
3193 source: &'static str,
3194 #[serde(skip_serializing_if = "Option::is_none")]
3195 credential: Option<&'a str>,
3196 change: &'static str,
3197 #[serde(skip_serializing_if = "Option::is_none")]
3198 trigger: Option<&'static str>,
3199 #[serde(skip_serializing_if = "Option::is_none")]
3200 status: Option<u16>,
3201 #[serde(skip_serializing_if = "Option::is_none")]
3202 retried: Option<bool>,
3203 #[serde(skip_serializing_if = "Option::is_none")]
3204 reason: Option<&'static str>,
3205}
3206
3207#[allow(clippy::type_complexity)]
3210fn credential_change_fields(
3211 change: CredentialChange,
3212) -> (
3213 &'static str,
3214 Option<&'static str>,
3215 Option<u16>,
3216 Option<bool>,
3217 Option<&'static str>,
3218) {
3219 match change {
3220 CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
3221 CredentialChange::Rejected { status, retried } => {
3222 ("rejected", None, Some(status), Some(retried), None)
3223 }
3224 CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
3225 }
3226}
3227
3228pub struct ResourceTraceAppender {
3243 file: tokio::sync::Mutex<File>,
3245 session_id: String,
3246 session_event_id: String,
3251}
3252
3253impl ResourceTraceAppender {
3254 pub async fn open(
3255 workdir: &Path,
3256 session_id: String,
3257 session_event_id: String,
3258 ) -> std::io::Result<Self> {
3259 let file = OpenOptions::new()
3260 .create(true)
3261 .append(true)
3262 .open(workdir.join("trace.jsonl"))
3263 .await?;
3264 Ok(Self {
3265 file: tokio::sync::Mutex::new(file),
3266 session_id,
3267 session_event_id,
3268 })
3269 }
3270
3271 #[allow(clippy::too_many_arguments)]
3275 pub(crate) async fn write_resource_list(
3276 &self,
3277 root: &str,
3278 entry_count: usize,
3279 total_bytes: u64,
3280 generation: u64,
3281 containment_achieved: ContainmentClass,
3282 outcome: &str,
3283 reason: Option<String>,
3284 ) {
3285 let event = ResourceListEvent {
3286 event_type: "resource_list",
3287 event_id: new_event_id(),
3288 parent_id: Some(self.session_event_id.clone()),
3289 session_id: self.session_id.clone(),
3290 timestamp: timestamp_ms(),
3291 root: root.to_string(),
3292 entry_count,
3293 total_bytes,
3294 generation,
3295 containment_achieved,
3296 outcome: outcome.to_string(),
3297 reason,
3298 };
3299 self.append(&event).await;
3300 }
3301
3302 #[allow(clippy::too_many_arguments)]
3305 pub(crate) async fn write_resource_read(
3306 &self,
3307 path: &str,
3308 outcome: &str,
3309 bytes: Option<u64>,
3310 sha256: Option<String>,
3311 generation: u64,
3312 containment_achieved: ContainmentClass,
3313 reason: Option<String>,
3314 ) {
3315 let event = ResourceReadEvent {
3316 event_type: "resource_read",
3317 event_id: new_event_id(),
3318 parent_id: Some(self.session_event_id.clone()),
3319 session_id: self.session_id.clone(),
3320 timestamp: timestamp_ms(),
3321 path: path.to_string(),
3322 outcome: outcome.to_string(),
3323 bytes,
3324 sha256,
3325 generation,
3326 containment_achieved,
3327 reason,
3328 };
3329 self.append(&event).await;
3330 }
3331
3332 #[allow(clippy::too_many_arguments)]
3335 pub(crate) async fn write_peer_handle_mint(
3336 &self,
3337 handle_id: Option<String>,
3338 path: &str,
3339 audience: &str,
3340 expires_at_ms: Option<u64>,
3341 outcome: &str,
3342 reason: Option<String>,
3343 ) {
3344 let event = PeerHandleMintEvent {
3345 event_type: "peer_handle_mint",
3346 event_id: new_event_id(),
3347 parent_id: Some(self.session_event_id.clone()),
3348 session_id: self.session_id.clone(),
3349 timestamp: timestamp_ms(),
3350 handle_id,
3351 path: path.to_string(),
3352 audience: audience.to_string(),
3353 expires_at_ms,
3354 outcome: outcome.to_string(),
3355 reason,
3356 };
3357 self.append(&event).await;
3358 }
3359
3360 #[allow(clippy::too_many_arguments)]
3363 pub(crate) async fn write_peer_handle_redeem(
3364 &self,
3365 handle_id: &str,
3366 path: Option<String>,
3367 generation: u64,
3368 audience_asserted: Option<String>,
3369 bytes: Option<u64>,
3370 sha256: Option<String>,
3371 outcome: &str,
3372 reason: Option<String>,
3373 ) {
3374 let event = PeerHandleRedeemEvent {
3375 event_type: "peer_handle_redeem",
3376 event_id: new_event_id(),
3377 parent_id: Some(self.session_event_id.clone()),
3378 session_id: self.session_id.clone(),
3379 timestamp: timestamp_ms(),
3380 handle_id: handle_id.to_string(),
3381 path,
3382 generation,
3383 audience_asserted,
3384 bytes,
3385 sha256,
3386 outcome: outcome.to_string(),
3387 reason,
3388 };
3389 self.append(&event).await;
3390 }
3391
3392 #[allow(clippy::too_many_arguments)]
3394 pub(crate) async fn write_peer_file_fetch(
3395 &self,
3396 peer: &str,
3397 handle_id: &str,
3398 stored_path: Option<String>,
3399 bytes: Option<u64>,
3400 sha256: Option<String>,
3401 outcome: &str,
3402 reason: Option<String>,
3403 ) {
3404 let event = PeerFileFetchEvent {
3405 event_type: "peer_file_fetch",
3406 event_id: new_event_id(),
3407 parent_id: Some(self.session_event_id.clone()),
3408 session_id: self.session_id.clone(),
3409 timestamp: timestamp_ms(),
3410 peer: peer.to_string(),
3411 handle_id: handle_id.to_string(),
3412 stored_path,
3413 bytes,
3414 sha256,
3415 outcome: outcome.to_string(),
3416 reason,
3417 };
3418 self.append(&event).await;
3419 }
3420
3421 pub(crate) async fn write_delegation_start(
3426 &self,
3427 delegation_id: &str,
3428 capsule: &str,
3429 version: &str,
3430 child_session_id: &str,
3431 child_workdir: &str,
3432 ) {
3433 let event = DelegationStartEvent::new(
3434 &self.session_id,
3435 &self.session_event_id,
3436 delegation_id,
3437 capsule,
3438 version,
3439 child_session_id,
3440 child_workdir,
3441 );
3442 self.append(&event).await;
3443 }
3444
3445 #[allow(clippy::too_many_arguments)]
3449 pub(crate) async fn write_delegation(
3450 &self,
3451 capsule: &str,
3452 version: &str,
3453 delegation_id: Option<String>,
3454 child_session_id: Option<String>,
3455 duration_ms: u64,
3456 outcome: &str,
3457 reason: Option<String>,
3458 ) {
3459 let event = DelegationEvent::new(
3460 &self.session_id,
3461 &self.session_event_id,
3462 capsule,
3463 version,
3464 delegation_id,
3465 child_session_id,
3466 duration_ms,
3467 outcome,
3468 reason,
3469 );
3470 self.append(&event).await;
3471 }
3472
3473 pub(crate) async fn write_inference_credential(
3476 &self,
3477 source: &'static str,
3478 credential: Option<&str>,
3479 change: CredentialChange,
3480 ) {
3481 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
3482 let event = InferenceCredentialEvent {
3483 event_type: "inference_credential",
3484 event_id: new_event_id(),
3485 parent_id: Some(self.session_event_id.clone()),
3486 session_id: self.session_id.clone(),
3487 timestamp: timestamp_ms(),
3488 source,
3489 credential,
3490 change: change_name,
3491 trigger,
3492 status,
3493 retried,
3494 reason,
3495 };
3496 self.append(&event).await;
3497 }
3498
3499 pub(crate) async fn write_gateway_credential(
3502 &self,
3503 artifact: &str,
3504 source: &'static str,
3505 credential: Option<&str>,
3506 change: CredentialChange,
3507 ) {
3508 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
3509 let event = GatewayCredentialEvent {
3510 event_type: "gateway_credential",
3511 event_id: new_event_id(),
3512 parent_id: Some(self.session_event_id.clone()),
3513 session_id: self.session_id.clone(),
3514 timestamp: timestamp_ms(),
3515 artifact,
3516 source,
3517 credential,
3518 change: change_name,
3519 trigger,
3520 status,
3521 retried,
3522 reason,
3523 };
3524 self.append(&event).await;
3525 }
3526
3527 pub(crate) async fn write_control_change(&self, change: ControlChange<'_>) {
3529 let event = ControlChangeEvent {
3530 event_type: "control_change",
3531 event_id: change.event_id,
3532 parent_id: Some(self.session_event_id.clone()),
3533 session_id: self.session_id.clone(),
3534 timestamp: timestamp_ms(),
3535 principal: change.principal.as_str(),
3536 token_id: change.token_id,
3537 kind: change.kind,
3538 name: change.name,
3539 action: change.action,
3540 previous: change.previous,
3541 value: change.value,
3542 applies_from: change.applies_from,
3543 replaced: change.replaced,
3544 };
3545 self.append(&event).await;
3546 }
3547
3548 pub(crate) async fn write_control_refused(
3551 &self,
3552 principal: crate::control_plane::Principal,
3553 status: u16,
3554 reason: &'static str,
3555 kind: Option<&'static str>,
3556 name: Option<&str>,
3557 token_id: Option<&str>,
3558 ) {
3559 let event = ControlRefusedEvent {
3560 event_type: "control_refused",
3561 event_id: new_event_id(),
3562 parent_id: Some(self.session_event_id.clone()),
3563 session_id: self.session_id.clone(),
3564 timestamp: timestamp_ms(),
3565 principal: principal.as_str(),
3566 status,
3567 reason,
3568 kind,
3569 name,
3570 token_id,
3571 };
3572 self.append(&event).await;
3573 }
3574
3575 async fn append(&self, event: &impl Serialize) {
3576 let Ok(mut line) = serde_json::to_string(event) else {
3577 return;
3578 };
3579 line.push('\n');
3580 let mut file = self.file.lock().await;
3581 if file.write_all(line.as_bytes()).await.is_ok() {
3582 let _ = file.flush().await;
3583 }
3584 }
3585}
3586
3587pub(crate) struct PriorSessionTraceAppender {
3602 file: File,
3603 session_id: String,
3605 session_event_id: Option<String>,
3607 terminate_torn_tail: bool,
3611}
3612
3613impl PriorSessionTraceAppender {
3614 pub(crate) async fn open(
3617 trace_path: &Path,
3618 session_id: String,
3619 session_event_id: Option<String>,
3620 ) -> std::io::Result<Self> {
3621 let terminate_torn_tail = !ends_with_newline(trace_path)?;
3622 let file = OpenOptions::new().append(true).open(trace_path).await?;
3623 Ok(Self {
3624 file,
3625 session_id,
3626 session_event_id,
3627 terminate_torn_tail,
3628 })
3629 }
3630
3631 pub(crate) async fn write_shell_lost(
3637 &mut self,
3638 work: &crate::detached::LostWork,
3639 reconciled_by_session: &str,
3640 reconciled_task_id: &str,
3641 ) -> std::io::Result<()> {
3642 let event = ShellLostEvent {
3643 event_type: "shell_lost",
3644 event_id: new_event_id(),
3645 parent_id: self.session_event_id.clone(),
3646 session_id: self.session_id.clone(),
3647 timestamp: timestamp_ms(),
3648 work_id: work.work_id.clone(),
3649 binary: work.binary.clone(),
3650 command: work.command.clone(),
3651 detached_at_ms: work.detached_at_ms,
3652 reconciled_by_session: reconciled_by_session.to_string(),
3653 reconciled_task_id: reconciled_task_id.to_string(),
3654 };
3655 let mut line = if self.terminate_torn_tail {
3656 "\n".to_string()
3657 } else {
3658 String::new()
3659 };
3660 line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
3661 line.push('\n');
3662 self.file.write_all(line.as_bytes()).await?;
3663 self.file.flush().await?;
3664 self.terminate_torn_tail = false;
3665 Ok(())
3666 }
3667}
3668
3669fn ends_with_newline(path: &Path) -> std::io::Result<bool> {
3672 use std::io::{Read, Seek, SeekFrom};
3673
3674 let mut file = std::fs::File::open(path)?;
3675 let length = file.metadata()?.len();
3676 if length == 0 {
3677 return Ok(true);
3678 }
3679 file.seek(SeekFrom::End(-1))?;
3680 let mut last = [0u8; 1];
3681 file.read_exact(&mut last)?;
3682 Ok(last[0] == b'\n')
3683}
3684
3685pub(crate) fn timestamp_ms() -> u64 {
3686 SystemTime::now()
3687 .duration_since(UNIX_EPOCH)
3688 .unwrap_or_default()
3689 .as_millis() as u64
3690}
3691
3692#[derive(Serialize, Clone)]
3699pub(crate) struct PlanStepShape {
3700 pub(crate) step_id: String,
3701 pub(crate) kind: &'static str,
3704 pub(crate) depends_on: Vec<String>,
3705 pub(crate) has_condition: bool,
3707}
3708
3709#[derive(Serialize)]
3712struct PlanStartEvent {
3713 event_type: &'static str,
3714 event_id: String,
3715 parent_id: Option<String>,
3716 session_id: String,
3717 timestamp: u64,
3718 plan_id: String,
3719 step_count: usize,
3720 steps: Vec<PlanStepShape>,
3721}
3722
3723#[derive(Serialize)]
3729struct PlanStepStartEvent {
3730 event_type: &'static str,
3731 event_id: String,
3732 parent_id: Option<String>,
3733 session_id: String,
3734 timestamp: u64,
3735 plan_id: String,
3736 step_id: String,
3737 kind: &'static str,
3738 depends_on: Vec<String>,
3739}
3740
3741#[derive(Serialize)]
3745struct PlanStepEvent {
3746 event_type: &'static str,
3747 event_id: String,
3748 parent_id: Option<String>,
3749 session_id: String,
3750 timestamp: u64,
3751 plan_id: String,
3752 step_id: String,
3753 kind: &'static str,
3754 status: &'static str,
3755 attempts: u32,
3756 duration_ms: u64,
3757 #[serde(skip_serializing_if = "Option::is_none")]
3758 error: Option<String>,
3759 #[serde(skip_serializing_if = "Option::is_none")]
3762 input: Option<Value>,
3763 #[serde(skip_serializing_if = "Option::is_none")]
3767 state_effect: Option<String>,
3768 #[serde(skip_serializing_if = "Option::is_none")]
3770 resource_id: Option<String>,
3771}
3772
3773#[derive(Serialize)]
3775struct PlanEndEvent {
3776 event_type: &'static str,
3777 event_id: String,
3778 parent_id: Option<String>,
3779 session_id: String,
3780 timestamp: u64,
3781 plan_id: String,
3782 outcome: &'static str,
3783 #[serde(skip_serializing_if = "Option::is_none")]
3784 failed_step: Option<String>,
3785 steps_total: usize,
3786 steps_succeeded: usize,
3787 steps_failed: usize,
3788 steps_skipped: usize,
3789 duration_ms: u64,
3790 #[serde(skip_serializing_if = "Option::is_none")]
3793 reason: Option<String>,
3794}
3795
3796pub(crate) struct PlanStepRecord {
3798 pub(crate) plan_id: String,
3799 pub(crate) step_id: String,
3800 pub(crate) kind: &'static str,
3801 pub(crate) status: &'static str,
3804 pub(crate) attempts: u32,
3806 pub(crate) duration_ms: u64,
3807 pub(crate) error: Option<String>,
3808 pub(crate) input: Option<Value>,
3809 pub(crate) state_effect: Option<String>,
3810 pub(crate) resource_id: Option<String>,
3811}
3812
3813pub(crate) struct PlanEndRecord {
3815 pub(crate) plan_id: String,
3816 pub(crate) outcome: &'static str,
3817 pub(crate) failed_step: Option<String>,
3818 pub(crate) steps_total: usize,
3819 pub(crate) steps_succeeded: usize,
3820 pub(crate) steps_failed: usize,
3821 pub(crate) steps_skipped: usize,
3822 pub(crate) duration_ms: u64,
3823 pub(crate) reason: Option<String>,
3824}
3825
3826pub struct PlanTraceAppender {
3837 file: std::sync::Mutex<std::fs::File>,
3838 session_id: String,
3839 session_event_id: String,
3842}
3843
3844impl PlanTraceAppender {
3845 pub fn open(
3846 workdir: &Path,
3847 session_id: String,
3848 session_event_id: String,
3849 ) -> std::io::Result<Self> {
3850 let file = std::fs::OpenOptions::new()
3851 .create(true)
3852 .append(true)
3853 .open(workdir.join("trace.jsonl"))?;
3854 Ok(Self {
3855 file: std::sync::Mutex::new(file),
3856 session_id,
3857 session_event_id,
3858 })
3859 }
3860
3861 pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
3863 let event_id = new_event_id();
3864 let event = PlanStartEvent {
3865 event_type: "plan_start",
3866 event_id: event_id.clone(),
3867 parent_id: Some(self.session_event_id.clone()),
3868 session_id: self.session_id.clone(),
3869 timestamp: timestamp_ms(),
3870 plan_id: plan_id.to_string(),
3871 step_count: steps.len(),
3872 steps,
3873 };
3874 self.append(&event);
3875 event_id
3876 }
3877
3878 pub(crate) fn write_plan_step_start(
3879 &self,
3880 plan_node: &str,
3881 plan_id: &str,
3882 step_id: &str,
3883 kind: &'static str,
3884 depends_on: Vec<String>,
3885 ) {
3886 let event = PlanStepStartEvent {
3887 event_type: "plan_step_start",
3888 event_id: new_event_id(),
3889 parent_id: Some(plan_node.to_string()),
3890 session_id: self.session_id.clone(),
3891 timestamp: timestamp_ms(),
3892 plan_id: plan_id.to_string(),
3893 step_id: step_id.to_string(),
3894 kind,
3895 depends_on,
3896 };
3897 self.append(&event);
3898 }
3899
3900 pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
3901 let input = record.input.map(|mut value| {
3904 crate::peer_handoff::redact_handles_in_json(&mut value);
3905 value
3906 });
3907 let event = PlanStepEvent {
3908 event_type: "plan_step",
3909 event_id: new_event_id(),
3910 parent_id: Some(plan_node.to_string()),
3911 session_id: self.session_id.clone(),
3912 timestamp: timestamp_ms(),
3913 plan_id: record.plan_id,
3914 step_id: record.step_id,
3915 kind: record.kind,
3916 status: record.status,
3917 attempts: record.attempts,
3918 duration_ms: record.duration_ms,
3919 error: record.error,
3920 input,
3921 state_effect: record.state_effect,
3922 resource_id: record.resource_id,
3923 };
3924 self.append(&event);
3925 }
3926
3927 pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
3930 let event = PlanEndEvent {
3931 event_type: "plan_end",
3932 event_id: new_event_id(),
3933 parent_id: Some(
3934 plan_node
3935 .map(str::to_string)
3936 .unwrap_or_else(|| self.session_event_id.clone()),
3937 ),
3938 session_id: self.session_id.clone(),
3939 timestamp: timestamp_ms(),
3940 plan_id: record.plan_id,
3941 outcome: record.outcome,
3942 failed_step: record.failed_step,
3943 steps_total: record.steps_total,
3944 steps_succeeded: record.steps_succeeded,
3945 steps_failed: record.steps_failed,
3946 steps_skipped: record.steps_skipped,
3947 duration_ms: record.duration_ms,
3948 reason: record.reason,
3949 };
3950 self.append(&event);
3951 }
3952
3953 pub(crate) fn write_delegation_start(
3959 &self,
3960 delegation_id: &str,
3961 capsule: &str,
3962 version: &str,
3963 child_session_id: &str,
3964 child_workdir: &str,
3965 ) {
3966 let event = DelegationStartEvent::new(
3967 &self.session_id,
3968 &self.session_event_id,
3969 delegation_id,
3970 capsule,
3971 version,
3972 child_session_id,
3973 child_workdir,
3974 );
3975 self.append(&event);
3976 }
3977
3978 #[allow(clippy::too_many_arguments)]
3986 pub(crate) fn write_delegation(
3987 &self,
3988 capsule: &str,
3989 version: &str,
3990 delegation_id: Option<String>,
3991 child_session_id: Option<String>,
3992 duration_ms: u64,
3993 outcome: &str,
3994 reason: Option<String>,
3995 ) {
3996 let event = DelegationEvent::new(
3997 &self.session_id,
3998 &self.session_event_id,
3999 capsule,
4000 version,
4001 delegation_id,
4002 child_session_id,
4003 duration_ms,
4004 outcome,
4005 reason,
4006 );
4007 self.append(&event);
4008 }
4009
4010 fn append(&self, event: &impl Serialize) {
4014 use std::io::Write as _;
4015
4016 let Ok(mut line) = serde_json::to_string(event) else {
4017 return;
4018 };
4019 line.push('\n');
4020 let Ok(mut file) = self.file.lock() else {
4021 return;
4022 };
4023 if file.write_all(line.as_bytes()).is_ok() {
4024 let _ = file.flush();
4025 }
4026 }
4027}
4028
4029#[cfg(test)]
4032mod tests {
4033 use super::*;
4034 use crate::{
4035 containment::scope_report_for_tier, sandbox::EnforcementTier, sealed::UsernsGrant,
4036 trace_blobs::BLOB_DIR_NAME, types::CapabilityPolicy,
4037 };
4038 use murmur_artifact::{ArtifactRuntime, InterpreterRuntimeDir, InterpreterRuntimeGrant};
4039 use serde_json::Value;
4040
4041 use crate::origin::TaskOrigin;
4042
4043 fn event_provenance() -> TaskProvenance {
4045 TaskProvenance::derive(TaskOrigin::Event, None)
4046 }
4047
4048 fn report_for(
4052 declared: ContainmentClass,
4053 tier: EnforcementTier,
4054 workdir_exec: bool,
4055 ) -> ScopeReport {
4056 scope_report_for_tier(
4057 &CapabilityPolicy {
4058 workdir_exec_allowed: workdir_exec,
4059 ..CapabilityPolicy::default()
4060 },
4061 declared,
4062 tier,
4063 None,
4064 None,
4065 None,
4066 Vec::new(),
4067 Vec::new(),
4068 Vec::new(),
4069 crate::cgroup::IoMaxReport::default(),
4070 )
4071 }
4072
4073 fn demotion_info() -> crate::detached::DetachedDispatchInfo {
4074 crate::detached::DetachedDispatchInfo {
4075 work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
4076 binary: "/usr/bin/bash".to_string(),
4077 command: "make -j8".to_string(),
4078 grace_ms: 1_000,
4079 }
4080 }
4081
4082 #[tokio::test]
4085 async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
4086 let dir = tempfile::tempdir().unwrap();
4087 std::os::unix::fs::symlink("/dev/full", dir.path().join("trace.jsonl")).unwrap();
4090 let mut writer = make_writer(dir.path()).await;
4091 let info = demotion_info();
4092
4093 assert!(
4094 writer
4095 .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
4096 .await
4097 .is_err(),
4098 "the sink under this writer must be unwritable for the claim below to mean anything"
4099 );
4100 crate::agent::record_demotion(&mut writer, 1, &info).await;
4101 }
4102
4103 #[tokio::test]
4105 async fn a_recorded_demotion_writes_only_its_own_marker() {
4106 let dir = tempfile::tempdir().unwrap();
4107 let mut writer = make_writer(dir.path()).await;
4108 crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
4109
4110 let events = read_events(dir.path());
4111 assert_eq!(events.len(), 1);
4112 assert_eq!(events[0]["event_type"], "shell_detached");
4113 assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
4114 assert_eq!(events[0]["turn"], 3);
4115 }
4116
4117 #[tokio::test]
4118 async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
4119 let dir = tempfile::tempdir().unwrap();
4120 let mut writer = make_writer(dir.path()).await;
4121 writer
4122 .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
4123 .await
4124 .unwrap();
4125
4126 let events = read_events(dir.path());
4127 assert_eq!(events.len(), 1);
4128 assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
4129 assert_eq!(events[0]["turn"], 2);
4130 assert_eq!(events[0]["work_id"], "wrk_0a1b");
4131 assert_eq!(events[0]["binary"], "/usr/bin/bash");
4132 assert_eq!(events[0]["reason"], "No space left");
4133 }
4134
4135 #[tokio::test]
4139 async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
4140 let dir = tempfile::tempdir().unwrap();
4141 let mut writer = make_writer(dir.path()).await;
4142 writer
4143 .write_shell_abandoned(
4144 "wrk_0a1b2c3d4e5f6a7b",
4145 "/usr/bin/bash",
4146 "sleep 45; echo done",
4147 45_012,
4148 None,
4149 )
4150 .await
4151 .unwrap();
4152
4153 let events = read_events(dir.path());
4154 assert_eq!(events.len(), 1);
4155 let event = &events[0];
4156 assert_eq!(event["event_type"], "shell_abandoned");
4157 assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
4158 assert_eq!(event["binary"], "/usr/bin/bash");
4159 assert_eq!(event["command"], "sleep 45; echo done");
4160 assert_eq!(event["running_ms"], 45_012);
4161 for absent in ["exit_code", "output_path", "output_bytes"] {
4162 assert!(
4163 event.get(absent).is_none(),
4164 "{absent} must be omitted, not written as null: {event}"
4165 );
4166 }
4167 }
4168
4169 #[tokio::test]
4173 async fn an_abandoned_command_that_finished_carries_its_result() {
4174 let dir = tempfile::tempdir().unwrap();
4175 let mut writer = make_writer(dir.path()).await;
4176 writer
4177 .write_shell_abandoned(
4178 "wrk_0a1b2c3d4e5f6a7b",
4179 "/usr/bin/bash",
4180 "sleep 2; echo done",
4181 2_014,
4182 Some((0, "logs/wrk_0a1b2c3d4e5f6a7b.log", 91)),
4183 )
4184 .await
4185 .unwrap();
4186
4187 let event = &read_events(dir.path())[0];
4188 assert_eq!(event["event_type"], "shell_abandoned");
4189 assert_eq!(event["running_ms"], 2_014);
4190 assert_eq!(event["exit_code"], 0);
4191 assert_eq!(event["output_path"], "logs/wrk_0a1b2c3d4e5f6a7b.log");
4192 assert_eq!(event["output_bytes"], 91);
4193 }
4194
4195 async fn make_writer(dir: &std::path::Path) -> TraceWriter {
4196 make_writer_with_opts(dir, TraceCapture::Meta).await
4197 }
4198
4199 async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
4200 make_writer_with_prompt(dir, capture, None, false).await
4201 }
4202
4203 async fn make_writer_with_prompt(
4204 dir: &std::path::Path,
4205 capture: TraceCapture,
4206 system_prompt: Option<&str>,
4207 system_prompt_overridden: bool,
4208 ) -> TraceWriter {
4209 TraceWriter::open(
4210 dir,
4211 "test-session-id".to_string(),
4212 "test-capsule".to_string(),
4213 "1.0.0".to_string(),
4214 "claude-test".to_string(),
4215 vec!["shell".to_string()],
4216 report_for(
4217 ContainmentClass::Advisory,
4218 EnforcementTier::EnvironmentOnly,
4219 false,
4220 ),
4221 capture,
4222 system_prompt.map(str::to_string),
4223 system_prompt_overridden,
4224 None,
4225 None,
4226 None,
4227 None,
4228 )
4229 .await
4230 .unwrap()
4231 }
4232
4233 fn read_events(dir: &std::path::Path) -> Vec<Value> {
4234 let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
4235 content
4236 .lines()
4237 .filter(|l| !l.is_empty())
4238 .map(|l| serde_json::from_str(l).unwrap())
4239 .collect()
4240 }
4241
4242 #[tokio::test]
4243 async fn session_start_fields() {
4244 let dir = tempfile::tempdir().unwrap();
4245 let mut w = make_writer(dir.path()).await;
4246 w.write_session_start(10, vec!["bash".to_string()])
4247 .await
4248 .unwrap();
4249 w.flush().await.unwrap();
4250
4251 let events = read_events(dir.path());
4252 assert_eq!(events.len(), 1);
4253 let e = &events[0];
4254 assert_eq!(e["event_type"], "session_start");
4255 assert_eq!(e["session_id"], "test-session-id");
4256 assert_eq!(e["capsule_name"], "test-capsule");
4257 assert_eq!(e["capsule_version"], "1.0.0");
4258 assert_eq!(e["model"], "claude-test");
4259 assert_eq!(e["max_turns"], 10);
4260 assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
4261 assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
4262 assert_eq!(e["containment_declared"], "advisory");
4263 assert_eq!(e["containment_achieved"], "advisory");
4264 assert_eq!(e["workdir_exec"], false);
4265 assert!(e["timestamp"].as_u64().unwrap() > 0);
4266 }
4267
4268 #[tokio::test]
4273 async fn session_start_records_system_prompt_source_and_hash() {
4274 let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
4275
4276 for (prompt, overridden, source, sha) in [
4277 (Some("Be terse."), false, "manifest", Some(&expected_sha)),
4278 (Some("Be terse."), true, "cli", Some(&expected_sha)),
4279 (None, false, "none", None),
4280 (None, true, "cli", None),
4283 ] {
4284 let dir = tempfile::tempdir().unwrap();
4285 let mut w =
4286 make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
4287 w.write_session_start(1, Vec::new()).await.unwrap();
4288 w.flush().await.unwrap();
4289
4290 let e = &read_events(dir.path())[0];
4291 assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
4292 match sha {
4293 Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
4294 None => assert!(
4295 e["system_prompt_sha256"].is_null(),
4296 "no prompt in effect must hash to null, got {}",
4297 e["system_prompt_sha256"]
4298 ),
4299 }
4300 }
4301 }
4302
4303 #[tokio::test]
4307 async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
4308 let sha = murmur_artifact::sha256_hex(b"Be terse.");
4309
4310 let dir = tempfile::tempdir().unwrap();
4311 let mut w =
4312 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
4313 w.write_session_start(1, Vec::new()).await.unwrap();
4314 w.flush().await.unwrap();
4315
4316 let e = &read_events(dir.path())[0];
4317 assert!(
4318 e.get("system_prompt").is_none(),
4319 "the verbatim prompt is not a session_start field, got {e}"
4320 );
4321 assert_eq!(e["system_prompt_sha256"], sha.as_str());
4322 assert!(
4323 !dir.path().join(BLOB_DIR_NAME).exists(),
4324 "meta stores no bodies"
4325 );
4326
4327 let opted_in = tempfile::tempdir().unwrap();
4328 let mut w = make_writer_with_prompt(
4329 opted_in.path(),
4330 TraceCapture::Content,
4331 Some("Be terse."),
4332 false,
4333 )
4334 .await;
4335 w.write_session_start(1, Vec::new()).await.unwrap();
4336 w.flush().await.unwrap();
4337
4338 let event = &read_events(opted_in.path())[0];
4339 assert!(event.get("system_prompt").is_none());
4340 assert_eq!(event["system_prompt_sha256"], sha.as_str());
4341 let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
4342 assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
4343 }
4344
4345 #[tokio::test]
4348 async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
4349 let dir = tempfile::tempdir().unwrap();
4350 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
4351 w.write_session_start(1, Vec::new()).await.unwrap();
4352 w.flush().await.unwrap();
4353
4354 let e = &read_events(dir.path())[0];
4355 assert!(e.get("system_prompt").is_none());
4356 assert!(e["system_prompt_sha256"].is_null());
4357 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
4358 }
4359
4360 #[tokio::test]
4362 async fn every_task_session_start_repeats_the_same_system_prompt_record() {
4363 let dir = tempfile::tempdir().unwrap();
4364 let mut w =
4365 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
4366 w.write_session_start(1, Vec::new()).await.unwrap();
4367 w.write_session_start(1, Vec::new()).await.unwrap();
4368 w.flush().await.unwrap();
4369
4370 let events = read_events(dir.path());
4371 assert_eq!(events.len(), 2);
4372 assert_eq!(
4373 events[0]["system_prompt_source"],
4374 events[1]["system_prompt_source"]
4375 );
4376 assert_eq!(
4377 events[0]["system_prompt_sha256"],
4378 events[1]["system_prompt_sha256"]
4379 );
4380 assert_eq!(events[1]["system_prompt_source"], "cli");
4381 }
4382
4383 #[tokio::test]
4388 async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
4389 let dir = tempfile::tempdir().unwrap();
4390 let mut w = TraceWriter::open(
4391 dir.path(),
4392 "test-session-id".to_string(),
4393 "test-capsule".to_string(),
4394 "1.0.0".to_string(),
4395 "claude-test".to_string(),
4396 Vec::new(),
4397 report_for(
4399 ContainmentClass::Advisory,
4400 EnforcementTier::KernelFull,
4401 true,
4402 ),
4403 TraceCapture::Meta,
4404 None,
4405 false,
4406 None,
4407 None,
4408 None,
4409 None,
4410 )
4411 .await
4412 .unwrap();
4413 w.write_session_start(1, Vec::new()).await.unwrap();
4414 w.flush().await.unwrap();
4415
4416 let events = read_events(dir.path());
4417 assert_eq!(events[0]["workdir_exec"], true);
4418 assert_eq!(events[0]["containment_achieved"], "advisory");
4419 }
4420
4421 #[tokio::test]
4424 async fn session_start_records_declared_and_achieved_containment_separately() {
4425 let dir = tempfile::tempdir().unwrap();
4426 let mut w = TraceWriter::open(
4427 dir.path(),
4428 "test-session-id".to_string(),
4429 "test-capsule".to_string(),
4430 "1.0.0".to_string(),
4431 "claude-test".to_string(),
4432 Vec::new(),
4433 report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
4434 TraceCapture::Meta,
4435 None,
4436 false,
4437 None,
4438 None,
4439 None,
4440 None,
4441 )
4442 .await
4443 .unwrap();
4444 w.write_session_start(1, Vec::new()).await.unwrap();
4445 w.flush().await.unwrap();
4446
4447 let events = read_events(dir.path());
4448 assert_eq!(events[0]["containment_declared"], "sealed");
4449 assert_eq!(events[0]["containment_achieved"], "scoped");
4450 }
4451
4452 #[tokio::test]
4460 async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
4461 async fn event_for(grant: UsernsGrant) -> Value {
4462 let report = scope_report_for_tier(
4463 &CapabilityPolicy::default(),
4464 ContainmentClass::Sealed,
4465 EnforcementTier::KernelSealed,
4466 None,
4467 Some(grant),
4468 None,
4469 Vec::new(),
4470 Vec::new(),
4471 Vec::new(),
4472 crate::cgroup::IoMaxReport::default(),
4473 );
4474 let dir = tempfile::tempdir().unwrap();
4475 let mut w = TraceWriter::open(
4476 dir.path(),
4477 "test-session-id".to_string(),
4478 "test-capsule".to_string(),
4479 "1.0.0".to_string(),
4480 "claude-test".to_string(),
4481 Vec::new(),
4482 report,
4483 TraceCapture::Meta,
4484 None,
4485 false,
4486 None,
4487 None,
4488 None,
4489 None,
4490 )
4491 .await
4492 .unwrap();
4493 w.write_session_start(1, Vec::new()).await.unwrap();
4494 w.flush().await.unwrap();
4495 let mut event = read_events(dir.path()).remove(0);
4496 event.as_object_mut().unwrap().remove("timestamp");
4499 event
4500 }
4501
4502 let through_profile = event_for(UsernsGrant::ProfileConfining).await;
4503 let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
4504
4505 assert_eq!(through_profile["containment_achieved"], "sealed");
4506 assert_eq!(host_wide["containment_achieved"], "sealed");
4507 assert_eq!(through_profile["userns_grant"], "profile_confining");
4508 assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
4509 assert_ne!(
4510 through_profile, host_wide,
4511 "two hosts granting the user namespace by different mechanisms must not write the \
4512 same session_start record"
4513 );
4514
4515 let unprobed = scope_report_for_tier(
4518 &CapabilityPolicy::default(),
4519 ContainmentClass::Advisory,
4520 EnforcementTier::EnvironmentOnly,
4521 None,
4522 None,
4523 None,
4524 Vec::new(),
4525 Vec::new(),
4526 Vec::new(),
4527 crate::cgroup::IoMaxReport::default(),
4528 );
4529 assert!(
4530 serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
4531 "off Linux the key is present and null, not absent"
4532 );
4533 }
4534
4535 #[tokio::test]
4543 async fn session_start_records_the_whole_scope_report_as_effective_grants() {
4544 let policy = CapabilityPolicy {
4545 network_allow: vec!["https://api.example.com".to_string()],
4546 unix_sockets_allowed: false,
4547 filesystem_scope: Some("workdir".to_string()),
4548 shell_allow: vec!["python3".to_string()],
4549 spawn_allow: vec!["helper".to_string()],
4550 env_allow: vec!["TZ".to_string()],
4551 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
4552 binary: "python3".to_string(),
4553 dirs: vec![InterpreterRuntimeDir {
4554 path: "/usr/lib/python3.11".to_string(),
4555 list_dir: true,
4556 }],
4557 }],
4558 ..CapabilityPolicy::default()
4559 };
4560 let expected = scope_report_for_tier(
4561 &policy,
4562 ContainmentClass::Scoped,
4563 EnforcementTier::KernelFull,
4564 None,
4565 None,
4566 None,
4567 vec![crate::containment::StateStoreReport {
4570 artifact: "notes-tool".to_string(),
4571 store: "shey".to_string(),
4572 host_path: "/home/dev/.murmur/state/shey".to_string(),
4573 }],
4574 vec!["config-echo".to_string()],
4576 vec![crate::containment::PreopenReport {
4577 artifact: "notes-tool".to_string(),
4578 role: "tool".to_string(),
4579 scope: None,
4580 surface: crate::containment::PreopenSurface::WholeWorkdir,
4581 }],
4582 crate::cgroup::IoMaxReport::default(),
4583 );
4584
4585 let dir = tempfile::tempdir().unwrap();
4586 let mut w = TraceWriter::open(
4587 dir.path(),
4588 "test-session-id".to_string(),
4589 "test-capsule".to_string(),
4590 "1.0.0".to_string(),
4591 "claude-test".to_string(),
4592 vec!["network".to_string(), "shell".to_string()],
4593 expected.clone(),
4594 TraceCapture::Meta,
4595 None,
4596 false,
4597 None,
4598 None,
4599 None,
4600 None,
4601 )
4602 .await
4603 .unwrap();
4604 w.write_session_start(1, Vec::new()).await.unwrap();
4605 w.flush().await.unwrap();
4606
4607 let events = read_events(dir.path());
4608 assert_eq!(
4609 events[0]["effective_grants"],
4610 serde_json::to_value(&expected).unwrap()
4611 );
4612
4613 assert_eq!(
4616 events[0]["capabilities"],
4617 serde_json::json!(["network", "shell"])
4618 );
4619 assert_eq!(
4620 events[0]["effective_grants"]["state_stores"],
4621 serde_json::json!([{
4622 "artifact": "notes-tool",
4623 "store": "shey",
4624 "host_path": "/home/dev/.murmur/state/shey",
4625 }])
4626 );
4627 assert_eq!(
4630 events[0]["effective_grants"]["configured_artifacts"],
4631 serde_json::json!(["config-echo"])
4632 );
4633 assert_eq!(
4637 events[0]["effective_grants"]["preopens"],
4638 serde_json::json!([{
4639 "artifact": "notes-tool",
4640 "role": "tool",
4641 "scope": null,
4642 "surface": "whole-workdir",
4643 }])
4644 );
4645 assert_eq!(events[0]["containment_declared"], "scoped");
4646 assert_eq!(events[0]["containment_achieved"], "scoped");
4647 assert_eq!(events[0]["workdir_exec"], false);
4648 assert_eq!(
4649 events[0]["effective_grants"]["network_allow"],
4650 serde_json::json!(["https://api.example.com"])
4651 );
4652 assert_eq!(
4653 events[0]["effective_grants"]["interpreter_runtime_grants"],
4654 serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
4655 );
4656 }
4657
4658 #[tokio::test]
4659 async fn inference_fields_and_snake_case() {
4660 let dir = tempfile::tempdir().unwrap();
4661 let mut w = make_writer(dir.path()).await;
4662 w.write_inference(
4663 0,
4664 Some(100),
4665 Some(50),
4666 "tool_call".to_string(),
4667 Some("tool_call"),
4668 Some("bash".to_string()),
4669 None,
4670 None,
4671 vec!["msg_0000000000000000000000000000000a".to_string()],
4672 None,
4673 )
4674 .await
4675 .unwrap();
4676 w.flush().await.unwrap();
4677
4678 let events = read_events(dir.path());
4679 let e = &events[0];
4680 assert_eq!(e["event_type"], "inference");
4681 assert_eq!(
4682 e["message_ids"],
4683 serde_json::json!(["msg_0000000000000000000000000000000a"]),
4684 "an agent-loop turn names the messages its request embedded"
4685 );
4686 assert_eq!(e["turn"], 0);
4687 assert_eq!(e["input_tokens"], 100);
4688 assert_eq!(e["output_tokens"], 50);
4689 assert_eq!(e["decision"], "tool_call");
4690 assert_eq!(e["tool_name"], "bash");
4691 assert!(e.get("inputTokens").is_none(), "must use snake_case");
4693 assert!(e.get("toolName").is_none(), "must use snake_case");
4694 for key in [
4696 "input_tokens_actual",
4697 "output_tokens_actual",
4698 "cached_tokens",
4699 "cache_write_tokens",
4700 ] {
4701 assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
4702 }
4703 }
4704
4705 #[tokio::test]
4708 async fn inference_writes_only_the_reported_usage_members() {
4709 let dir = tempfile::tempdir().unwrap();
4710 let mut w = make_writer(dir.path()).await;
4711 let usage = DriverUsage {
4712 input_tokens: Some(12043),
4713 output_tokens: None,
4714 cached_tokens: Some(0),
4715 cache_write_tokens: None,
4716 thinking_tokens: None,
4717 };
4718 w.write_inference(
4719 0,
4720 Some(100),
4721 Some(50),
4722 "end_turn".to_string(),
4723 Some("end_turn"),
4724 None,
4725 None,
4726 Some(&usage),
4727 Vec::new(),
4728 None,
4729 )
4730 .await
4731 .unwrap();
4732 w.flush().await.unwrap();
4733
4734 let events = read_events(dir.path());
4735 let e = &events[0];
4736 assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
4737 assert_eq!(e["input_tokens_actual"], 12043);
4738 assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
4739 assert!(e.get("output_tokens_actual").is_none());
4740 assert!(e.get("cache_write_tokens").is_none());
4741 }
4742
4743 #[tokio::test]
4744 async fn tool_call_fields() {
4745 let dir = tempfile::tempdir().unwrap();
4746 let mut w = make_writer(dir.path()).await;
4747 w.write_tool_call(
4748 1,
4749 "bash".to_string(),
4750 None,
4751 serde_json::json!({"command": "echo hi"}),
4752 42,
4753 "hi\n",
4754 100,
4755 55,
4756 "ok".to_string(),
4757 None,
4758 None,
4759 )
4760 .await
4761 .unwrap();
4762 w.flush().await.unwrap();
4763
4764 let events = read_events(dir.path());
4765 let e = &events[0];
4766 assert_eq!(e["event_type"], "tool_call");
4767 assert_eq!(e["turn"], 1);
4768 assert_eq!(e["tool_name"], "bash");
4769 assert_eq!(e["input_bytes"], 42);
4770 assert_eq!(e["output_bytes"], 100);
4771 assert_eq!(e["duration_ms"], 55);
4772 assert_eq!(e["status"], "ok");
4773 assert!(
4774 e.get("state_effect").is_none(),
4775 "state_effect must be omitted when the tool declared nothing"
4776 );
4777 }
4778
4779 #[tokio::test]
4780 async fn tool_call_records_declared_state_effect() {
4781 let dir = tempfile::tempdir().unwrap();
4782 let mut w = make_writer(dir.path()).await;
4783 w.write_tool_call(
4784 0,
4785 "murmur-tool-editor".to_string(),
4786 None,
4787 serde_json::json!({"operation": "read_file", "path": "a.txt"}),
4788 10,
4789 "hi\n",
4790 3,
4791 5,
4792 "ok".to_string(),
4793 Some("read".to_string()),
4794 None,
4795 )
4796 .await
4797 .unwrap();
4798 w.flush().await.unwrap();
4799
4800 let events = read_events(dir.path());
4801 assert_eq!(
4802 events[0]["state_effect"], "read",
4803 "declared state_effect must be recorded verbatim on the tool_call event"
4804 );
4805 }
4806
4807 #[tokio::test]
4808 async fn tool_call_records_declared_resource_id() {
4809 let dir = tempfile::tempdir().unwrap();
4810 let mut w = make_writer(dir.path()).await;
4811 w.write_tool_call(
4812 0,
4813 "murmur-tool-code-graph".to_string(),
4814 None,
4815 serde_json::json!({"symbol": "Foo::bar"}),
4816 10,
4817 "hi\n",
4818 3,
4819 5,
4820 "ok".to_string(),
4821 Some("read".to_string()),
4822 Some("sym:Foo".to_string()),
4823 )
4824 .await
4825 .unwrap();
4826 w.flush().await.unwrap();
4827
4828 let events = read_events(dir.path());
4829 assert_eq!(
4830 events[0]["resource_id"], "sym:Foo",
4831 "declared resource_id must reach trace.jsonl verbatim, unparsed"
4832 );
4833 }
4834
4835 #[tokio::test]
4836 async fn tool_call_omits_undeclared_resource_id() {
4837 let dir = tempfile::tempdir().unwrap();
4838 let mut w = make_writer(dir.path()).await;
4839 w.write_tool_call(
4840 0,
4841 "bash".to_string(),
4842 None,
4843 serde_json::json!({"command": "echo hi"}),
4844 10,
4845 "hi\n",
4846 3,
4847 5,
4848 "ok".to_string(),
4849 None,
4850 None,
4851 )
4852 .await
4853 .unwrap();
4854 w.flush().await.unwrap();
4855
4856 let events = read_events(dir.path());
4857 assert!(
4858 events[0].get("resource_id").is_none(),
4859 "resource_id must be omitted entirely (not null) when the tool declared nothing"
4860 );
4861 }
4862
4863 #[tokio::test]
4864 async fn shell_fields() {
4865 let dir = tempfile::tempdir().unwrap();
4866 let mut w = make_writer(dir.path()).await;
4867 w.write_shell(
4868 0,
4869 "/usr/bin/echo".to_string(),
4870 "echo hi".to_string(),
4871 0,
4872 7,
4873 0,
4874 10,
4875 None,
4876 )
4877 .await
4878 .unwrap();
4879 w.flush().await.unwrap();
4880
4881 let events = read_events(dir.path());
4882 let e = &events[0];
4883 assert_eq!(e["event_type"], "shell");
4884 assert_eq!(e["binary"], "/usr/bin/echo");
4887 assert_eq!(e["command"], "echo hi");
4888 assert_eq!(e["exit_code"], 0);
4889 assert_eq!(e["stdout_bytes"], 7);
4890 assert_eq!(e["stderr_bytes"], 0);
4891 assert_eq!(e["duration_ms"], 10);
4892 }
4893
4894 #[tokio::test]
4895 async fn compaction_fields() {
4896 let dir = tempfile::tempdir().unwrap();
4897 let mut w = make_writer(dir.path()).await;
4898 w.write_compaction(3, 80000, 20000).await.unwrap();
4899 w.flush().await.unwrap();
4900
4901 let events = read_events(dir.path());
4902 let e = &events[0];
4903 assert_eq!(e["event_type"], "compaction");
4904 assert_eq!(e["turn"], 3);
4905 assert_eq!(e["tokens_before"], 80000);
4906 assert_eq!(e["tokens_after"], 20000);
4907 }
4908
4909 #[tokio::test]
4910 async fn hook_dispatch_error_fields() {
4911 let dir = tempfile::tempdir().unwrap();
4912 let mut w = make_writer(dir.path()).await;
4913 w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
4914 .await
4915 .unwrap();
4916 w.flush().await.unwrap();
4917
4918 let events = read_events(dir.path());
4919 let e = &events[0];
4920 assert_eq!(e["event_type"], "hook_dispatch_error");
4921 assert_eq!(e["session_id"], "test-session-id");
4922 assert_eq!(e["hook_name"], "my-hook");
4923 assert_eq!(e["event"], "on-tool-call");
4924 assert_eq!(e["arm"], "write-manifests");
4925 assert!(e["timestamp"].as_u64().unwrap() > 0);
4926 assert!(e.get("hookName").is_none(), "must use snake_case");
4928 }
4929
4930 #[tokio::test]
4931 async fn session_end_fields_and_snake_case() {
4932 let dir = tempfile::tempdir().unwrap();
4933 let mut w = make_writer(dir.path()).await;
4934 w.write_inference(
4935 0,
4936 Some(100),
4937 Some(50),
4938 "tool_call".to_string(),
4939 Some("tool_call"),
4940 None,
4941 None,
4942 None,
4943 Vec::new(),
4944 None,
4945 )
4946 .await
4947 .unwrap();
4948 w.write_tool_call(
4949 0,
4950 "bash".to_string(),
4951 None,
4952 serde_json::json!({"command": "ls"}),
4953 10,
4954 "file.txt\n",
4955 20,
4956 5,
4957 "ok".to_string(),
4958 None,
4959 None,
4960 )
4961 .await
4962 .unwrap();
4963 w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
4964 .await
4965 .unwrap();
4966 w.write_session_end("ok").await.unwrap();
4967 w.flush().await.unwrap();
4968
4969 let events = read_events(dir.path());
4970 let se = events.last().unwrap();
4971 assert_eq!(se["event_type"], "session_end");
4972 assert_eq!(se["total_turns"], 1);
4973 assert_eq!(se["total_input_tokens"], 100);
4974 assert_eq!(se["total_output_tokens"], 50);
4975 assert_eq!(se["total_tool_calls"], 1);
4976 assert_eq!(se["total_shell_calls"], 1);
4977 assert_eq!(se["exit_status"], "ok");
4978 assert!(se["duration_ms"].as_u64().is_some());
4979 assert!(se.get("exitStatus").is_none(), "must use snake_case");
4981 assert!(se.get("totalTurns").is_none(), "must use snake_case");
4982 }
4983
4984 #[tokio::test]
4985 async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
4986 let dir = tempfile::tempdir().unwrap();
4987 let mut w = make_writer(dir.path()).await;
4988 w.write_session_start(10, vec![]).await.unwrap();
4989 w.write_session_end("ok").await.unwrap();
4990 w.write_session_end_if_not_ended("failed").await.unwrap();
4991 w.flush().await.unwrap();
4992
4993 let events = read_events(dir.path());
4994 assert_eq!(
4995 events
4996 .iter()
4997 .filter(|e| e["event_type"] == "session_end")
4998 .count(),
4999 1,
5000 "session_end should appear exactly once"
5001 );
5002 }
5003
5004 #[tokio::test]
5005 async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
5006 let dir = tempfile::tempdir().unwrap();
5007 let mut w = make_writer(dir.path()).await;
5008 w.write_session_start(10, vec![]).await.unwrap();
5009 w.write_session_end_if_not_ended("failed").await.unwrap();
5011 w.flush().await.unwrap();
5012
5013 let events = read_events(dir.path());
5014 let se = events
5015 .iter()
5016 .find(|e| e["event_type"] == "session_end")
5017 .unwrap();
5018 assert_eq!(se["exit_status"], "failed");
5019 }
5020
5021 #[tokio::test]
5022 async fn no_session_end_written_when_session_never_started() {
5023 let dir = tempfile::tempdir().unwrap();
5024 let mut w = make_writer(dir.path()).await;
5025 w.write_session_end_if_not_ended("failed").await.unwrap();
5027 w.flush().await.unwrap();
5028
5029 let events = read_events(dir.path());
5030 assert!(
5031 events.iter().all(|e| e["event_type"] != "session_end"),
5032 "session_end must not appear when session_start was never written"
5033 );
5034 }
5035
5036 #[tokio::test]
5037 async fn session_id_on_every_event() {
5038 let dir = tempfile::tempdir().unwrap();
5039 let mut w = make_writer(dir.path()).await;
5040 w.write_session_start(10, vec!["bash".to_string()])
5041 .await
5042 .unwrap();
5043 w.write_inference(
5044 0,
5045 Some(10),
5046 Some(5),
5047 "end_turn".to_string(),
5048 Some("end_turn"),
5049 None,
5050 None,
5051 None,
5052 Vec::new(),
5053 None,
5054 )
5055 .await
5056 .unwrap();
5057 w.write_session_end("ok").await.unwrap();
5058 w.flush().await.unwrap();
5059
5060 let events = read_events(dir.path());
5061 for e in &events {
5062 assert_eq!(e["session_id"], "test-session-id");
5063 }
5064 }
5065
5066 #[tokio::test]
5067 async fn counts_match_per_event_sum() {
5068 let dir = tempfile::tempdir().unwrap();
5069 let mut w = make_writer(dir.path()).await;
5070 w.write_session_start(10, vec![]).await.unwrap();
5071 w.write_inference(
5072 0,
5073 Some(50),
5074 Some(25),
5075 "tool_call".to_string(),
5076 Some("tool_call"),
5077 Some("bash".to_string()),
5078 None,
5079 None,
5080 vec!["msg_0000000000000000000000000000000a".to_string()],
5081 None,
5082 )
5083 .await
5084 .unwrap();
5085 w.write_tool_call(
5086 0,
5087 "bash".to_string(),
5088 None,
5089 serde_json::json!({"command": "echo"}),
5090 5,
5091 "ok",
5092 10,
5093 3,
5094 "ok".to_string(),
5095 None,
5096 None,
5097 )
5098 .await
5099 .unwrap();
5100 w.write_shell(
5101 0,
5102 "/bin/echo".to_string(),
5103 "echo".to_string(),
5104 0,
5105 4,
5106 0,
5107 1,
5108 None,
5109 )
5110 .await
5111 .unwrap();
5112 w.write_inference(
5113 1,
5114 Some(60),
5115 Some(30),
5116 "end_turn".to_string(),
5117 Some("end_turn"),
5118 None,
5119 None,
5120 None,
5121 Vec::new(),
5122 None,
5123 )
5124 .await
5125 .unwrap();
5126 w.write_session_end("ok").await.unwrap();
5127 w.flush().await.unwrap();
5128
5129 let events = read_events(dir.path());
5130 let inference_count = events
5131 .iter()
5132 .filter(|e| e["event_type"] == "inference")
5133 .count();
5134 let tool_count = events
5135 .iter()
5136 .filter(|e| e["event_type"] == "tool_call")
5137 .count();
5138 let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
5139
5140 let se = events
5141 .iter()
5142 .find(|e| e["event_type"] == "session_end")
5143 .unwrap();
5144 assert_eq!(
5145 se["total_turns"].as_u64().unwrap() as usize,
5146 inference_count
5147 );
5148 assert_eq!(
5149 se["total_tool_calls"].as_u64().unwrap() as usize,
5150 tool_count
5151 );
5152 assert_eq!(
5153 se["total_shell_calls"].as_u64().unwrap() as usize,
5154 shell_count
5155 );
5156 }
5157
5158 #[tokio::test]
5159 async fn tool_call_input_always_present() {
5160 let dir = tempfile::tempdir().unwrap();
5161 let mut w = make_writer(dir.path()).await; w.write_tool_call(
5163 0,
5164 "bash".to_string(),
5165 None,
5166 serde_json::json!({"command": "ls -la"}),
5167 20,
5168 "total 8\n",
5169 80,
5170 12,
5171 "ok".to_string(),
5172 None,
5173 None,
5174 )
5175 .await
5176 .unwrap();
5177 w.flush().await.unwrap();
5178
5179 let events = read_events(dir.path());
5180 let e = &events[0];
5181 assert_eq!(e["event_type"], "tool_call");
5182 assert_eq!(e["input"]["command"], "ls -la");
5183 assert!(e.get("input").is_some(), "input must always be present");
5184 }
5185
5186 #[tokio::test]
5187 async fn tool_call_output_absent_by_default() {
5188 let dir = tempfile::tempdir().unwrap();
5189 let mut w = make_writer(dir.path()).await; w.write_tool_call(
5191 0,
5192 "bash".to_string(),
5193 None,
5194 serde_json::json!({"command": "ls"}),
5195 10,
5196 "file.txt\n",
5197 90,
5198 8,
5199 "ok".to_string(),
5200 None,
5201 None,
5202 )
5203 .await
5204 .unwrap();
5205 w.flush().await.unwrap();
5206
5207 let events = read_events(dir.path());
5208 let e = &events[0];
5209 assert!(
5210 e.get("output").is_none(),
5211 "output must be absent under a capture mode that stores no bodies"
5212 );
5213 assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
5214 }
5215
5216 #[tokio::test]
5217 async fn skill_call_writes_skill_call_event() {
5218 let dir = tempfile::tempdir().unwrap();
5219 let mut w = make_writer(dir.path()).await;
5220 w.write_skill_call(
5221 2,
5222 "my-skill".to_string(),
5223 &LockOrigin::Operator,
5224 1024,
5225 8,
5226 "ok".to_string(),
5227 )
5228 .await
5229 .unwrap();
5230 w.flush().await.unwrap();
5231
5232 let events = read_events(dir.path());
5233 let e = &events[0];
5234 assert_eq!(e["event_type"], "skill_call");
5235 assert_eq!(e["turn"], 2);
5236 assert_eq!(e["skill_name"], "my-skill");
5237 assert_eq!(e["output_bytes"], 1024);
5238 assert_eq!(e["duration_ms"], 8);
5239 assert_eq!(e["status"], "ok");
5240 }
5241
5242 #[tokio::test]
5243 async fn skill_call_does_not_increment_total_tool_calls() {
5244 let dir = tempfile::tempdir().unwrap();
5245 let mut w = make_writer(dir.path()).await;
5246 w.write_session_start(10, vec![]).await.unwrap();
5247 w.write_tool_call(
5248 0,
5249 "real-tool".to_string(),
5250 None,
5251 serde_json::json!({}),
5252 2,
5253 "data",
5254 4,
5255 5,
5256 "ok".to_string(),
5257 None,
5258 None,
5259 )
5260 .await
5261 .unwrap();
5262 w.write_skill_call(
5263 1,
5264 "my-skill".to_string(),
5265 &LockOrigin::Operator,
5266 512,
5267 3,
5268 "ok".to_string(),
5269 )
5270 .await
5271 .unwrap();
5272 w.write_session_end("ok").await.unwrap();
5273 w.flush().await.unwrap();
5274
5275 let events = read_events(dir.path());
5276 let se = events
5277 .iter()
5278 .find(|e| e["event_type"] == "session_end")
5279 .unwrap();
5280 assert_eq!(
5281 se["total_tool_calls"], 1,
5282 "skill_call must not inflate total_tool_calls"
5283 );
5284 }
5285
5286 #[tokio::test]
5287 async fn tool_call_output_present_when_opted_in() {
5288 let dir = tempfile::tempdir().unwrap();
5289 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5290 w.write_tool_call(
5291 0,
5292 "bash".to_string(),
5293 None,
5294 serde_json::json!({"command": "echo hello"}),
5295 22,
5296 "hello\n",
5297 60,
5298 5,
5299 "ok".to_string(),
5300 None,
5301 None,
5302 )
5303 .await
5304 .unwrap();
5305 w.flush().await.unwrap();
5306
5307 let events = read_events(dir.path());
5308 let e = &events[0];
5309 assert_eq!(
5310 e["output"].as_str().unwrap(),
5311 "hello\n",
5312 "output must be present under capture: content"
5313 );
5314 assert_eq!(e["output_bytes"], 60);
5315 }
5316
5317 #[tokio::test]
5318 async fn task_end_carries_reopen_count() {
5319 let dir = tempfile::tempdir().unwrap();
5320 let mut w = make_writer(dir.path()).await;
5321 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
5322 .await
5323 .unwrap();
5324 w.write_task_end("tsk_1", "ok", 2).await.unwrap();
5325 w.flush().await.unwrap();
5326
5327 let events = read_events(dir.path());
5328 let end = events
5329 .iter()
5330 .find(|e| e["event_type"] == "task_end")
5331 .unwrap();
5332 assert_eq!(end["task_id"], "tsk_1");
5333 assert_eq!(end["exit_status"], "ok");
5334 assert_eq!(end["reopen_count"], 2);
5335 }
5336
5337 #[tokio::test]
5338 async fn task_reopened_names_hook_reason_and_ordinal() {
5339 let dir = tempfile::tempdir().unwrap();
5340 let mut w = make_writer(dir.path()).await;
5341 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
5342 .await
5343 .unwrap();
5344 w.write_task_reopened(
5345 "tsk_1",
5346 "gatekeeper",
5347 "tests still fail",
5348 1,
5349 REOPEN_CONTEXT_CONTINUED,
5350 7,
5351 )
5352 .await
5353 .unwrap();
5354 w.flush().await.unwrap();
5355
5356 let events = read_events(dir.path());
5357 let re = events
5358 .iter()
5359 .find(|e| e["event_type"] == "task_reopened")
5360 .unwrap();
5361 assert_eq!(re["task_id"], "tsk_1");
5362 assert_eq!(re["hook_name"], "gatekeeper");
5363 assert_eq!(re["reason"], "tests still fail");
5364 assert_eq!(re["reopen_number"], 1);
5365 assert_eq!(re["attempt_context"], "continued");
5366 assert_eq!(re["turns_remaining"], 7);
5367 assert!(re["timestamp"].as_u64().unwrap() > 0);
5368 }
5369
5370 #[tokio::test]
5374 async fn task_turns_accessor_tracks_and_resets() {
5375 let dir = tempfile::tempdir().unwrap();
5376 let mut w = make_writer(dir.path()).await;
5377 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
5378 .await
5379 .unwrap();
5380 assert_eq!(w.task_turns(), 0);
5381 w.write_inference(
5382 0,
5383 Some(10),
5384 Some(5),
5385 "end_turn".to_string(),
5386 Some("end_turn"),
5387 None,
5388 None,
5389 None,
5390 Vec::new(),
5391 None,
5392 )
5393 .await
5394 .unwrap();
5395 w.write_inference(
5396 1,
5397 Some(10),
5398 Some(5),
5399 "end_turn".to_string(),
5400 Some("end_turn"),
5401 None,
5402 None,
5403 None,
5404 Vec::new(),
5405 None,
5406 )
5407 .await
5408 .unwrap();
5409 assert_eq!(w.task_turns(), 2);
5410 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), None, 3)
5412 .await
5413 .unwrap();
5414 assert_eq!(w.task_turns(), 0);
5415 }
5416
5417 #[tokio::test]
5423 async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
5424 let dir = tempfile::tempdir().unwrap();
5425 let mut w = make_writer(dir.path()).await;
5426 w.write_session_start(10, vec!["bash".to_string()])
5427 .await
5428 .unwrap();
5429 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
5430 .await
5431 .unwrap();
5432 w.write_inference(
5433 0,
5434 Some(10),
5435 Some(5),
5436 "tool_call".to_string(),
5437 Some("tool_call"),
5438 None,
5439 None,
5440 None,
5441 Vec::new(),
5442 None,
5443 )
5444 .await
5445 .unwrap();
5446 w.write_tool_call(
5447 0,
5448 "bash".to_string(),
5449 Some("toolu_1".to_string()),
5450 serde_json::json!({"command": "echo hi"}),
5451 10,
5452 "hi\n",
5453 3,
5454 5,
5455 "ok".to_string(),
5456 None,
5457 None,
5458 )
5459 .await
5460 .unwrap();
5461 w.write_shell(
5462 0,
5463 "/bin/bash".to_string(),
5464 "echo hi".to_string(),
5465 0,
5466 3,
5467 0,
5468 2,
5469 None,
5470 )
5471 .await
5472 .unwrap();
5473 w.write_artifact_pulled(&runtime_summary("pulled-style", ArtifactRuntime::Skill))
5474 .await
5475 .unwrap();
5476 w.write_task_end("tsk_1", "ok", 0).await.unwrap();
5477 w.write_session_end("ok").await.unwrap();
5478 w.flush().await.unwrap();
5479
5480 let events = read_events(dir.path());
5481 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5482 for (i, event) in events.iter().enumerate() {
5483 let id = event["event_id"].as_str().unwrap();
5484 assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
5485 assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
5486 assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
5487 assert!(
5488 event.get("parent_id").is_some(),
5489 "event {i} has no parent_id"
5490 );
5491 if let Some(parent) = event["parent_id"].as_str() {
5492 assert!(seen.contains(parent), "event {i} names dangling {parent}");
5493 }
5494 }
5495
5496 let by_type = |ty: &str| {
5497 events
5498 .iter()
5499 .find(|e| e["event_type"] == ty)
5500 .unwrap_or_else(|| panic!("no {ty}"))
5501 .clone()
5502 };
5503 let session = by_type("session_start");
5504 assert!(session["parent_id"].is_null());
5505 assert_eq!(session["event_id"], w.session_event_id());
5506
5507 let task = by_type("task_start");
5508 assert_eq!(task["parent_id"], session["event_id"]);
5509
5510 let turn = by_type("inference");
5511 assert_eq!(turn["parent_id"], task["event_id"]);
5512
5513 assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
5514 assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
5515 assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
5516 assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
5517 assert_eq!(by_type("artifact_pulled")["parent_id"], session["event_id"]);
5518 }
5519
5520 fn runtime_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
5521 InstalledArtifactSummary {
5522 name: name.to_string(),
5523 version: "1.0.0".to_string(),
5524 runtime,
5525 implementation: None,
5526 origin: LockOrigin::Runtime {
5527 session: "ses_puller".to_string(),
5528 },
5529 }
5530 }
5531
5532 fn operator_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
5533 InstalledArtifactSummary {
5534 origin: LockOrigin::Operator,
5535 ..runtime_summary(name, runtime)
5536 }
5537 }
5538
5539 #[tokio::test]
5542 async fn runtime_origin_session_start_lists_nothing_for_an_operator_session() {
5543 for set in [false, true] {
5544 let dir = tempfile::tempdir().unwrap();
5545 let mut w = make_writer(dir.path()).await;
5546 if set {
5547 w.set_runtime_artifacts(&[
5548 operator_summary("house-style", ArtifactRuntime::Skill),
5549 operator_summary("fetcher", ArtifactRuntime::Tool),
5550 ]);
5551 }
5552 w.write_session_start(10, Vec::new()).await.unwrap();
5553 w.flush().await.unwrap();
5554 let events = read_events(dir.path());
5555 assert_eq!(events[0]["runtime_artifacts"], serde_json::json!([]));
5556 }
5557 }
5558
5559 #[tokio::test]
5562 async fn runtime_origin_session_start_lists_runtime_artifacts_in_order() {
5563 let dir = tempfile::tempdir().unwrap();
5564 let mut w = make_writer(dir.path()).await;
5565 w.set_runtime_artifacts(&[
5566 runtime_summary("zeta-tool", ArtifactRuntime::Tool),
5567 operator_summary("house-style", ArtifactRuntime::Skill),
5568 InstalledArtifactSummary {
5569 origin: LockOrigin::Runtime {
5570 session: "ses_other".to_string(),
5571 },
5572 ..runtime_summary("alpha-style", ArtifactRuntime::Skill)
5573 },
5574 ]);
5575 w.write_session_start(10, Vec::new()).await.unwrap();
5576 w.flush().await.unwrap();
5577
5578 let events = read_events(dir.path());
5579 assert_eq!(
5580 events[0]["runtime_artifacts"],
5581 serde_json::json!([
5582 {"name": "zeta-tool", "version": "1.0.0", "origin": "runtime",
5583 "session": "ses_puller", "trust": "untrusted"},
5584 {"name": "alpha-style", "version": "1.0.0", "origin": "runtime",
5585 "session": "ses_other", "trust": "untrusted"},
5586 ])
5587 );
5588 }
5589
5590 #[tokio::test]
5591 async fn runtime_origin_skill_call_carries_origin_and_trust() {
5592 let dir = tempfile::tempdir().unwrap();
5593 let mut w = make_writer(dir.path()).await;
5594 for origin in [
5595 LockOrigin::Operator,
5596 LockOrigin::Runtime {
5597 session: "ses_puller".to_string(),
5598 },
5599 ] {
5600 w.write_skill_call(0, "a-skill".to_string(), &origin, 1, 1, "ok".to_string())
5601 .await
5602 .unwrap();
5603 }
5604 w.flush().await.unwrap();
5605
5606 let events = read_events(dir.path());
5607 assert_eq!(events[0]["origin"], "operator");
5608 assert_eq!(events[0]["trust"], "trusted");
5609 assert_eq!(events[1]["origin"], "runtime");
5610 assert_eq!(events[1]["trust"], "untrusted");
5611 }
5612
5613 #[tokio::test]
5615 async fn runtime_origin_artifact_pulled_writes_every_key() {
5616 let dir = tempfile::tempdir().unwrap();
5617 let mut w = make_writer(dir.path()).await;
5618 w.write_artifact_pulled(&runtime_summary("pulled-style", ArtifactRuntime::Skill))
5619 .await
5620 .unwrap();
5621 w.write_artifact_pulled(&operator_summary("fetcher", ArtifactRuntime::Tool))
5622 .await
5623 .unwrap();
5624 w.flush().await.unwrap();
5625
5626 let events = read_events(dir.path());
5627 let runtime = &events[0];
5628 assert_eq!(runtime["event_type"], "artifact_pulled");
5629 assert_eq!(runtime["name"], "pulled-style");
5630 assert_eq!(runtime["version"], "1.0.0");
5631 assert_eq!(runtime["runtime"], "skill");
5632 assert_eq!(runtime["origin"], "runtime");
5633 assert_eq!(runtime["session"], "ses_puller");
5634 assert_eq!(runtime["trust"], "untrusted");
5635 assert!(
5636 runtime["parent_id"].is_null(),
5637 "no session_start was written"
5638 );
5639
5640 let operator = &events[1];
5641 assert_eq!(operator["runtime"], "tool");
5642 assert_eq!(operator["origin"], "operator");
5643 assert!(operator.get("session").is_some_and(Value::is_null));
5644 assert_eq!(operator["trust"], "trusted");
5645 }
5646
5647 #[tokio::test]
5651 async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
5652 let dir = tempfile::tempdir().unwrap();
5653 let mut w = make_writer(dir.path()).await;
5654 w.write_session_start(10, Vec::new()).await.unwrap();
5655 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
5656 .await
5657 .unwrap();
5658 w.write_inference(
5659 0,
5660 Some(10),
5661 Some(5),
5662 "tool_call".to_string(),
5663 Some("tool_call"),
5664 None,
5665 None,
5666 None,
5667 Vec::new(),
5668 None,
5669 )
5670 .await
5671 .unwrap();
5672 let origin = InferenceOrigin {
5673 source: "hook:gatekeeper".to_string(),
5674 model: "claude-test".to_string(),
5675 };
5676 w.write_inference(
5677 0,
5678 Some(1),
5679 Some(1),
5680 "end_turn".to_string(),
5681 None,
5683 None,
5684 Some(&origin),
5685 None,
5686 Vec::new(),
5687 None,
5688 )
5689 .await
5690 .unwrap();
5691 w.flush().await.unwrap();
5692
5693 let events = read_events(dir.path());
5694 let turn = &events[2];
5695 let hook = &events[3];
5696 assert!(turn["origin"].is_null());
5697 assert_eq!(hook["origin"], "hook:gatekeeper");
5698 assert_eq!(hook["parent_id"], turn["event_id"]);
5699 assert!(hook.get("message_ids").is_none(), "{hook}");
5702 }
5703
5704 #[tokio::test]
5708 async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
5709 let bare = tempfile::tempdir().unwrap();
5710 let mut w = make_writer(bare.path()).await;
5711 w.write_a2a_send(
5712 "http://peer",
5713 "msg_1",
5714 "tsk_1",
5715 "ctx_1",
5716 None,
5717 TrustClass::Untrusted,
5718 )
5719 .await
5720 .unwrap();
5721 w.flush().await.unwrap();
5722 assert!(read_events(bare.path())[0]["parent_id"].is_null());
5723
5724 let framed = tempfile::tempdir().unwrap();
5725 let mut w = make_writer(framed.path()).await;
5726 w.write_session_start(1, Vec::new()).await.unwrap();
5727 w.write_a2a_send(
5728 "http://peer",
5729 "msg_1",
5730 "tsk_1",
5731 "ctx_1",
5732 None,
5733 TrustClass::Untrusted,
5734 )
5735 .await
5736 .unwrap();
5737 w.flush().await.unwrap();
5738 let events = read_events(framed.path());
5739 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
5740 }
5741
5742 #[tokio::test]
5744 async fn task_rejected_names_the_task_source_cause_and_reason_under_the_session() {
5745 let dir = tempfile::tempdir().unwrap();
5746 let mut w = make_writer(dir.path()).await;
5747 w.write_session_start(10, Vec::new()).await.unwrap();
5748 w.write_task_start("tsk_ran", "ctx_ran", "a2a", event_provenance(), None, 3)
5749 .await
5750 .unwrap();
5751 w.write_task_end("tsk_ran", "ok", 0).await.unwrap();
5752 w.write_task_rejected(
5753 "tsk_1",
5754 "ctx_1",
5755 "detached_shell",
5756 TASK_REJECTED_SESSION_ENDED,
5757 "task rejected: the session ended before this task started",
5758 )
5759 .await
5760 .unwrap();
5761 w.flush().await.unwrap();
5762
5763 let events = read_events(dir.path());
5764 let session = events
5765 .iter()
5766 .find(|e| e["event_type"] == "session_start")
5767 .unwrap();
5768 let rejected: Vec<_> = events
5769 .iter()
5770 .filter(|e| e["event_type"] == "task_rejected")
5771 .collect();
5772 assert_eq!(rejected.len(), 1);
5773 let rejected = rejected[0];
5774 assert_eq!(rejected["parent_id"], session["event_id"], "{rejected}");
5775 assert_eq!(rejected["session_id"], session["session_id"]);
5776 assert_eq!(rejected["task_id"], "tsk_1");
5777 assert_eq!(rejected["context_id"], "ctx_1");
5778 assert_eq!(rejected["source"], "detached_shell");
5779 assert_eq!(rejected["cause"], "session_ended");
5780 assert_eq!(
5781 rejected["reason"],
5782 "task rejected: the session ended before this task started"
5783 );
5784 assert!(rejected["event_id"].is_string());
5785 assert!(rejected["timestamp"].is_u64());
5786 assert_eq!(rejected.as_object().unwrap().len(), 10, "{rejected}");
5787 }
5788
5789 #[tokio::test]
5792 async fn task_failed_names_the_task_turn_cause_and_capped_reason() {
5793 let dir = tempfile::tempdir().unwrap();
5794 let mut w = make_writer(dir.path()).await;
5795 w.write_session_start(10, Vec::new()).await.unwrap();
5796 assert_eq!(w.last_task_failure(), None);
5797 w.write_task_failed(None, TASK_FAILED_RUNTIME_ERROR, "before any task")
5798 .await
5799 .unwrap();
5800 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
5801 .await
5802 .unwrap();
5803 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
5804 w.write_task_failed(Some(2), TASK_FAILED_DRIVER_ERROR, &long)
5805 .await
5806 .unwrap();
5807 w.write_task_end("tsk_1", "failed", 0).await.unwrap();
5808 w.flush().await.unwrap();
5809
5810 assert_eq!(w.task_failures_written(), 2);
5811 let events = read_events(dir.path());
5812 let failed: Vec<_> = events
5813 .iter()
5814 .filter(|e| e["event_type"] == "task_failed")
5815 .collect();
5816 assert_eq!(failed.len(), 2);
5817
5818 assert!(failed[0].get("task_id").is_none(), "{}", failed[0]);
5819 assert!(failed[0].get("turn").is_none(), "{}", failed[0]);
5820 assert_eq!(failed[0]["cause"], "runtime_error");
5821 assert_eq!(failed[0]["reason"], "before any task");
5822
5823 let start = events
5824 .iter()
5825 .find(|e| e["event_type"] == "task_start")
5826 .unwrap();
5827 assert_eq!(failed[1]["task_id"], "tsk_1");
5828 assert_eq!(failed[1]["turn"], 2);
5829 assert_eq!(failed[1]["cause"], "driver_error");
5830 assert_eq!(failed[1]["parent_id"], start["event_id"]);
5831 assert_eq!(failed[1]["session_id"], start["session_id"]);
5832 assert!(failed[1]["event_id"].is_string());
5833 assert!(failed[1]["timestamp"].is_u64());
5834 let reason = failed[1]["reason"].as_str().unwrap();
5835 assert!(reason.len() <= MAX_TASK_FAILURE_REASON_BYTES);
5836 assert!(reason.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
5837 assert_eq!(w.last_task_failure(), Some(reason));
5838 }
5839
5840 #[tokio::test]
5843 async fn turn_level_events_carry_the_active_task_id() {
5844 let dir = tempfile::tempdir().unwrap();
5845 let mut w = make_writer(dir.path()).await;
5846 w.write_session_start(10, Vec::new()).await.unwrap();
5847 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
5848 .await
5849 .unwrap();
5850 w.write_inference(
5851 0,
5852 Some(10),
5853 Some(5),
5854 "tool_call".to_string(),
5855 Some("tool_call"),
5856 None,
5857 None,
5858 None,
5859 Vec::new(),
5860 None,
5861 )
5862 .await
5863 .unwrap();
5864 w.write_skill_call(
5865 0,
5866 "house-style".to_string(),
5867 &LockOrigin::Operator,
5868 12,
5869 1,
5870 "ok".to_string(),
5871 )
5872 .await
5873 .unwrap();
5874 w.write_compaction(0, 100, 40).await.unwrap();
5875 w.write_task_end("tsk_1", "ok", 0).await.unwrap();
5876 w.write_inference(
5877 1,
5878 Some(1),
5879 Some(1),
5880 "end_turn".to_string(),
5881 Some("end_turn"),
5882 None,
5883 None,
5884 None,
5885 Vec::new(),
5886 None,
5887 )
5888 .await
5889 .unwrap();
5890 w.flush().await.unwrap();
5891
5892 let events = read_events(dir.path());
5893 for event in events
5894 .iter()
5895 .filter(|e| {
5896 matches!(
5897 e["event_type"].as_str(),
5898 Some("inference" | "skill_call" | "compaction")
5899 )
5900 })
5901 .take(3)
5902 {
5903 assert_eq!(event["task_id"], "tsk_1", "{event}");
5904 }
5905 let after = events.last().unwrap();
5906 assert_eq!(after["event_type"], "inference");
5907 assert!(
5908 after["task_id"].is_null(),
5909 "an inference outside any task carries a null task_id, not the last task's"
5910 );
5911 assert_eq!(after["parent_id"], events[0]["event_id"]);
5913 }
5914
5915 #[tokio::test]
5918 async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
5919 for (given, expected) in [
5920 (Some("toolu_1".to_string()), Some("toolu_1")),
5921 (Some(String::new()), None),
5922 (None, None),
5923 ] {
5924 let dir = tempfile::tempdir().unwrap();
5925 let mut w = make_writer(dir.path()).await;
5926 w.write_tool_call(
5927 0,
5928 "bash".to_string(),
5929 given.clone(),
5930 serde_json::json!({}),
5931 2,
5932 "",
5933 0,
5934 1,
5935 "ok".to_string(),
5936 None,
5937 None,
5938 )
5939 .await
5940 .unwrap();
5941 w.flush().await.unwrap();
5942 let e = &read_events(dir.path())[0];
5943 match expected {
5944 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
5945 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
5946 }
5947 }
5948 }
5949
5950 #[tokio::test]
5954 async fn tools_refreshed_event_records_trigger_names_and_task_parent() {
5955 let dir = tempfile::tempdir().unwrap();
5956 let mut w = make_writer(dir.path()).await;
5957 w.set_tool_refresh(Some("immediate"));
5958 w.write_session_start(10, Vec::new()).await.unwrap();
5959 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
5960 .await
5961 .unwrap();
5962 w.write_inference(
5963 0,
5964 Some(10),
5965 Some(5),
5966 "tool_call".to_string(),
5967 Some("tool_call"),
5968 None,
5969 None,
5970 None,
5971 Vec::new(),
5972 None,
5973 )
5974 .await
5975 .unwrap();
5976 w.write_tools_refreshed(
5977 1,
5978 "immediate",
5979 &["aaa-late-skill".to_string()],
5980 &[],
5981 &[
5982 "aaa-late-skill".to_string(),
5983 "zzz-existing-tool".to_string(),
5984 ],
5985 )
5986 .await
5987 .unwrap();
5988 w.flush().await.unwrap();
5989
5990 let events = read_events(dir.path());
5991 assert_eq!(events[0]["tool_refresh"], "immediate");
5992 let task = events
5993 .iter()
5994 .find(|e| e["event_type"] == "task_start")
5995 .unwrap();
5996 let refreshed: Vec<&Value> = events
5997 .iter()
5998 .filter(|e| e["event_type"] == "tools_refreshed")
5999 .collect();
6000 assert_eq!(refreshed.len(), 1);
6001 let r = refreshed[0];
6002 assert!(r["event_id"].as_str().unwrap().starts_with("evt_"));
6003 assert_eq!(r["parent_id"], task["event_id"]);
6004 assert_eq!(r["session_id"], "test-session-id");
6005 assert!(r["timestamp"].as_u64().is_some());
6006 assert_eq!(r["turn"], 1);
6007 assert_eq!(r["task_id"], "tsk_1");
6008 assert_eq!(r["trigger"], "immediate");
6009 assert_eq!(r["added"], serde_json::json!(["aaa-late-skill"]));
6010 assert_eq!(r["removed"], serde_json::json!([]));
6011 assert_eq!(
6012 r["tools"],
6013 serde_json::json!(["aaa-late-skill", "zzz-existing-tool"])
6014 );
6015
6016 let process_dir = tempfile::tempdir().unwrap();
6017 let mut process = make_writer(process_dir.path()).await;
6018 process.set_tool_refresh(None);
6019 process.write_session_start(10, Vec::new()).await.unwrap();
6020 process.flush().await.unwrap();
6021 let start = &read_events(process_dir.path())[0];
6022 assert!(start.as_object().unwrap().contains_key("tool_refresh"));
6023 assert!(start["tool_refresh"].is_null());
6024 }
6025
6026 #[tokio::test]
6029 async fn compaction_declined_records_turn_task_tokens_and_reason() {
6030 let dir = tempfile::tempdir().unwrap();
6031 let mut w = make_writer(dir.path()).await;
6032 w.write_session_start(10, Vec::new()).await.unwrap();
6033 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), None, 3)
6034 .await
6035 .unwrap();
6036 w.write_inference(
6037 2,
6038 Some(10),
6039 Some(5),
6040 "tool_call".to_string(),
6041 Some("tool_call"),
6042 None,
6043 None,
6044 None,
6045 Vec::new(),
6046 None,
6047 )
6048 .await
6049 .unwrap();
6050 w.write_compaction_declined(2, 4321, COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL)
6051 .await
6052 .unwrap();
6053 w.flush().await.unwrap();
6054
6055 let events = read_events(dir.path());
6056 let declined: Vec<&Value> = events
6057 .iter()
6058 .filter(|e| e["event_type"] == "compaction_declined")
6059 .collect();
6060 assert_eq!(declined.len(), 1);
6061 let d = declined[0];
6062 assert_eq!(d["reason"], "unresolved_tool_call");
6063 assert_eq!(d["turn"], 2);
6064 assert_eq!(d["task_id"], "tsk_1");
6065 assert_eq!(d["tokens"], 4321);
6066 assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
6067 assert_eq!(
6068 d["parent_id"],
6069 events
6070 .iter()
6071 .find(|e| e["event_type"] == "inference")
6072 .unwrap()["event_id"]
6073 );
6074 }
6075
6076 #[tokio::test]
6079 async fn event_ids_sort_by_mint_time() {
6080 let dir = tempfile::tempdir().unwrap();
6081 let mut w = make_writer(dir.path()).await;
6082 for turn in 0..5 {
6083 w.write_inference(
6084 turn,
6085 Some(1),
6086 Some(1),
6087 "end_turn".to_string(),
6088 Some("end_turn"),
6089 None,
6090 None,
6091 None,
6092 Vec::new(),
6093 None,
6094 )
6095 .await
6096 .unwrap();
6097 }
6098 w.flush().await.unwrap();
6099
6100 let ids: Vec<String> = read_events(dir.path())
6101 .iter()
6102 .map(|e| e["event_id"].as_str().unwrap().to_string())
6103 .collect();
6104 let mut sorted = ids.clone();
6105 sorted.sort();
6106 assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
6107 }
6108
6109 fn wire_payload(system: &str, messages: usize) -> Value {
6113 let messages: Vec<Value> = (0..messages)
6114 .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
6115 .collect();
6116 serde_json::json!({
6117 "model": "claude-test",
6118 "max_tokens": 1024,
6119 "messages": messages,
6120 "tools": [{"name": "bash"}],
6121 "params": {},
6122 "system": system,
6123 })
6124 }
6125
6126 async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
6127 let wire = WireCapture::from_driver_payload(payload, response);
6128 w.write_inference(
6129 turn,
6130 Some(10),
6131 Some(5),
6132 "end_turn".to_string(),
6133 Some("end_turn"),
6134 None,
6135 None,
6136 None,
6137 vec!["msg_0000000000000000000000000000000a".to_string()],
6138 Some(&wire),
6139 )
6140 .await
6141 .unwrap();
6142 }
6143
6144 fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
6145 dir.join(BLOB_DIR_NAME).join(sha)
6146 }
6147
6148 #[tokio::test]
6151 async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
6152 let dir = tempfile::tempdir().unwrap();
6153 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6154 let payload = wire_payload("You are a capsule.", 3);
6155 write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
6156 w.flush().await.unwrap();
6157
6158 let e = &read_events(dir.path())[0];
6159 let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
6160 .iter()
6161 .map(|key| {
6162 e[*key]
6163 .as_str()
6164 .unwrap_or_else(|| panic!("{key} missing from {e}"))
6165 .to_string()
6166 })
6167 .collect();
6168 let shas = e["message_shas"].as_array().expect("message_shas");
6169 assert_eq!(shas.len(), 3);
6170 named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
6171
6172 for sha in &named {
6173 let path = blob_path(dir.path(), sha);
6174 let bytes =
6175 std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
6176 assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
6177 assert_eq!(sha.len(), 64, "{sha}");
6180 assert!(
6181 sha.chars()
6182 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
6183 "{sha}"
6184 );
6185 }
6186 }
6187
6188 #[tokio::test]
6191 async fn the_system_blob_holds_the_prompt_as_readable_text() {
6192 let dir = tempfile::tempdir().unwrap();
6193 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6194 let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
6195 write_turn(&mut w, 0, &payload, "{}").await;
6196 w.flush().await.unwrap();
6197
6198 let sha = read_events(dir.path())[0]["system_sha"]
6199 .as_str()
6200 .unwrap()
6201 .to_string();
6202 assert_eq!(
6203 std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
6204 "[Capsule] you are cap.\nBe terse."
6205 );
6206 }
6207
6208 #[tokio::test]
6211 async fn meta_writes_the_same_hashes_and_no_bodies() {
6212 let payload = wire_payload("You are a capsule.", 2);
6213 let response = r#"{"stop_reason":"end_turn"}"#;
6214
6215 let meta = tempfile::tempdir().unwrap();
6216 let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
6217 write_turn(&mut w, 0, &payload, response).await;
6218 w.flush().await.unwrap();
6219
6220 let content = tempfile::tempdir().unwrap();
6221 let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
6222 write_turn(&mut w, 0, &payload, response).await;
6223 w.flush().await.unwrap();
6224
6225 let meta_event = read_events(meta.path()).remove(0);
6226 let content_event = read_events(content.path()).remove(0);
6227 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
6228 assert_eq!(meta_event[key], content_event[key], "{key}");
6229 }
6230 assert!(
6231 !meta.path().join(BLOB_DIR_NAME).exists(),
6232 "meta must not create the blob directory"
6233 );
6234 assert!(content.path().join(BLOB_DIR_NAME).is_dir());
6235 }
6236
6237 #[tokio::test]
6239 async fn the_default_capture_mode_is_meta() {
6240 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
6241 let dir = tempfile::tempdir().unwrap();
6242 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
6243 write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
6244 w.flush().await.unwrap();
6245
6246 let e = &read_events(dir.path())[0];
6247 assert!(e.get("system_sha").is_some());
6248 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
6249 }
6250
6251 #[tokio::test]
6253 async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
6254 let payload = wire_payload("You are a capsule.", 2);
6255
6256 let off = tempfile::tempdir().unwrap();
6257 let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
6258 write_turn(&mut w, 3, &payload, "{}").await;
6259 w.flush().await.unwrap();
6260
6261 let e = read_events(off.path()).remove(0);
6262 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
6263 assert!(
6264 e.get(key).is_none(),
6265 "{key} must be absent under none, got {e}"
6266 );
6267 }
6268 assert!(!off.path().join(BLOB_DIR_NAME).exists());
6269 for key in [
6270 "event_type",
6271 "event_id",
6272 "parent_id",
6273 "session_id",
6274 "timestamp",
6275 "turn",
6276 "task_id",
6277 "input_tokens",
6278 "output_tokens",
6279 "decision",
6280 "tool_name",
6281 "message_ids",
6282 ] {
6283 assert!(
6284 e.get(key).is_some(),
6285 "{key} must survive under none, got {e}"
6286 );
6287 }
6288 assert_eq!(e["turn"], 3);
6289 assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
6290 }
6291
6292 #[tokio::test]
6295 async fn an_unchanged_system_prompt_is_stored_once() {
6296 let dir = tempfile::tempdir().unwrap();
6297 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6298 let payload = wire_payload("You are a capsule.", 1);
6299 write_turn(&mut w, 0, &payload, "{}").await;
6300
6301 let sha = {
6302 w.flush().await.unwrap();
6303 read_events(dir.path())[0]["system_sha"]
6304 .as_str()
6305 .unwrap()
6306 .to_string()
6307 };
6308 let path = blob_path(dir.path(), &sha);
6309 std::fs::write(&path, b"sentinel").unwrap();
6310
6311 for turn in 1..10 {
6312 write_turn(&mut w, turn, &payload, "{}").await;
6313 }
6314 w.flush().await.unwrap();
6315
6316 let events = read_events(dir.path());
6317 assert_eq!(events.len(), 10);
6318 for e in &events {
6319 assert_eq!(e["system_sha"], sha.as_str());
6320 }
6321 assert_eq!(
6322 std::fs::read(&path).unwrap(),
6323 b"sentinel",
6324 "an existing blob is never rewritten"
6325 );
6326 let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
6327 .unwrap()
6328 .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
6329 .count();
6330 assert_eq!(matching, 1, "exactly one file bears that name");
6331 }
6332
6333 #[tokio::test]
6338 async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
6339 let dir = tempfile::tempdir().unwrap();
6340 let mut w = make_writer(dir.path()).await;
6341 w.write_inference(
6342 0,
6343 Some(10),
6344 Some(5),
6345 "end_turn".to_string(),
6346 None,
6347 None,
6348 None,
6349 None,
6350 Vec::new(),
6351 None,
6352 )
6353 .await
6354 .unwrap();
6355 w.flush().await.unwrap();
6356
6357 let event = read_events(dir.path()).remove(0);
6358 assert!(
6359 event.get("stop_reason").is_none(),
6360 "stop_reason must be absent, got {event}"
6361 );
6362 }
6363
6364 #[tokio::test]
6367 async fn a_hook_origin_record_carries_no_hashes() {
6368 let dir = tempfile::tempdir().unwrap();
6369 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6370 let origin = InferenceOrigin {
6371 source: "hook:gatekeeper".to_string(),
6372 model: "claude-test".to_string(),
6373 };
6374 w.write_inference(
6375 0,
6376 Some(1),
6377 Some(1),
6378 "end_turn".to_string(),
6379 None,
6381 None,
6382 Some(&origin),
6383 None,
6384 Vec::new(),
6385 None,
6386 )
6387 .await
6388 .unwrap();
6389 w.flush().await.unwrap();
6390
6391 let e = read_events(dir.path()).remove(0);
6392 assert_eq!(e["origin"], "hook:gatekeeper");
6393 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
6394 assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
6395 }
6396 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
6397 }
6398
6399 #[tokio::test]
6402 async fn message_shas_locate_the_index_two_runs_diverge_at() {
6403 async fn shas_for(payload: &Value) -> Vec<String> {
6404 let dir = tempfile::tempdir().unwrap();
6405 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
6406 write_turn(&mut w, 0, payload, "{}").await;
6407 w.flush().await.unwrap();
6408 read_events(dir.path())[0]["message_shas"]
6409 .as_array()
6410 .unwrap()
6411 .iter()
6412 .map(|s| s.as_str().unwrap().to_string())
6413 .collect()
6414 }
6415
6416 let first = wire_payload("You are a capsule.", 4);
6417 let mut second = first.clone();
6418 second["messages"][2]["content"] = serde_json::json!("diverged");
6419
6420 let a = shas_for(&first).await;
6421 let b = shas_for(&second).await;
6422 assert_eq!(a.len(), 4);
6423 let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
6424 assert_eq!(divergence, Some(2));
6425 assert_eq!(a[3], b[3], "only the changed message changes its own sha");
6426 }
6427
6428 #[tokio::test]
6431 async fn identical_messages_share_one_sha_and_one_blob() {
6432 let dir = tempfile::tempdir().unwrap();
6433 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6434 let payload = serde_json::json!({
6435 "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
6436 "tools": [],
6437 "system": "s",
6438 });
6439 write_turn(&mut w, 0, &payload, "{}").await;
6440 w.flush().await.unwrap();
6441
6442 let shas = read_events(dir.path())[0]["message_shas"]
6443 .as_array()
6444 .unwrap()
6445 .iter()
6446 .map(|s| s.as_str().unwrap().to_string())
6447 .collect::<Vec<_>>();
6448 assert_eq!(shas[0], shas[1]);
6449 assert!(blob_path(dir.path(), &shas[0]).is_file());
6450 }
6451
6452 #[tokio::test]
6455 async fn a_message_blob_is_the_body_verbatim() {
6456 let dir = tempfile::tempdir().unwrap();
6457 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6458 let message = serde_json::json!({"role": "user", "content": "hello"});
6459 let payload = serde_json::json!({
6460 "messages": [message.clone()],
6461 "tools": [],
6462 "system": "s",
6463 });
6464 write_turn(&mut w, 0, &payload, "{}").await;
6465 w.flush().await.unwrap();
6466
6467 let sha = read_events(dir.path())[0]["message_shas"][0]
6468 .as_str()
6469 .unwrap()
6470 .to_string();
6471 assert_eq!(
6472 std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
6473 serde_json::to_vec(&message).unwrap()
6474 );
6475 }
6476
6477 #[tokio::test]
6485 async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
6486 let plan_dir = tempfile::tempdir().unwrap();
6487 let tool_dir = tempfile::tempdir().unwrap();
6488 let session = "ses_0000000000004000800000000000plan".to_string();
6489 let node = "evt_0000000000004000800000000000node".to_string();
6490
6491 let plan = PlanTraceAppender::open(plan_dir.path(), session.clone(), node.clone()).unwrap();
6492 let tool = ResourceTraceAppender::open(tool_dir.path(), session.clone(), node.clone())
6493 .await
6494 .unwrap();
6495
6496 plan.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c");
6497 tool.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c")
6498 .await;
6499 plan.write_delegation(
6500 "worker",
6501 "0.1.0",
6502 Some("dlg_abc123".to_string()),
6503 Some("ses_child".to_string()),
6504 42,
6505 "completed",
6506 None,
6507 );
6508 tool.write_delegation(
6509 "worker",
6510 "0.1.0",
6511 Some("dlg_abc123".to_string()),
6512 Some("ses_child".to_string()),
6513 42,
6514 "completed",
6515 None,
6516 )
6517 .await;
6518
6519 let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
6520 std::fs::read_to_string(dir.join("trace.jsonl"))
6521 .unwrap()
6522 .lines()
6523 .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
6524 .collect()
6525 };
6526 let from_plan = read(plan_dir.path());
6527 let from_tool = read(tool_dir.path());
6528 assert_eq!(from_plan.len(), 2);
6529 assert_eq!(from_tool.len(), 2);
6530
6531 for (planned, called, event_type) in [
6532 (&from_plan[0], &from_tool[0], "delegation_start"),
6533 (&from_plan[1], &from_tool[1], "delegation"),
6534 ] {
6535 assert_eq!(planned["event_type"], event_type);
6536 assert_eq!(called["event_type"], event_type);
6537 assert_eq!(
6538 planned.keys().collect::<Vec<_>>(),
6539 called.keys().collect::<Vec<_>>(),
6540 "{planned:?} vs {called:?}"
6541 );
6542 assert_eq!(planned["parent_id"], node.as_str());
6543 assert_eq!(called["parent_id"], node.as_str());
6544 for (key, value) in planned {
6545 if key == "event_id" || key == "timestamp" {
6546 continue;
6547 }
6548 assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
6549 }
6550 for key in ["plan_id", "step_id", "surface", "kind"] {
6551 assert!(!planned.contains_key(key), "{key} is on {planned:?}");
6552 assert!(!called.contains_key(key), "{key} is on {called:?}");
6553 }
6554 }
6555 }
6556}