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 formation::FormationId,
18 gateway_credential::CredentialChange,
19 lanes::TaskLane,
20 origin::{TaskProvenance, TrustClass},
21 trace_blobs::BlobStore,
22 types::InstalledArtifactSummary,
23};
24
25pub(crate) struct TraceWriter {
26 writer: BufWriter<File>,
27 session_id: String,
28 capsule_name: String,
29 capsule_version: String,
30 model: String,
31 capabilities: Vec<String>,
32 effective_grants: ScopeReport,
38 capture: TraceCapture,
42 blobs: BlobStore,
45 system_prompt_source: &'static str,
50 system_prompt_sha256: Option<String>,
54 credential_source: &'static str,
57 gateways: Vec<SessionGateway>,
60 control: Option<SessionControl>,
63 inference_choices: Vec<SessionInferenceChoice>,
66 tool_refresh: Option<&'static str>,
69 runtime_artifacts: Vec<RuntimeArtifact>,
72 resumed_from: Option<String>,
76 context_id: Option<String>,
79 spawned_by: Option<String>,
83 spawn_delegation_id: Option<String>,
84 formation_id: Option<FormationId>,
87 formation_member: Option<(String, Vec<String>)>,
90 session_start_time: Instant,
91 session_event_id: String,
96 session_started: bool,
97 session_ended: bool,
98 task_event_id: Option<String>,
101 turn_event_id: Option<String>,
105 total_turns: u32,
107 total_input_tokens: u64,
108 total_output_tokens: u64,
109 total_tool_calls: u32,
110 total_shell_calls: u32,
111 task_turns: u32,
113 task_input_tokens: u64,
114 task_output_tokens: u64,
115 task_tool_calls: u32,
116 task_shell_calls: u32,
117 task_agent_turns: u32,
122 task_hook_inferences: u32,
125 task_start_instant: Option<Instant>,
126 pub(crate) active_task_id: Option<String>,
127 task_failures_written: u64,
130 last_task_failure: Option<String>,
132 max_session_tokens: Option<u64>,
134 machine_tokens_per_day: Option<u64>,
135}
136
137#[derive(Debug, Clone)]
142pub(crate) struct InferenceOrigin {
143 pub(crate) source: String,
145 pub(crate) model: String,
147}
148
149pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
152
153pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
156
157pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
159
160pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
163
164pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
167
168pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
170
171pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
174
175pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
178
179pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
182
183pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
186
187pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
189
190pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
192
193pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
195
196pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
199
200pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
202
203pub(crate) fn new_event_id() -> String {
211 format!("evt_{}", uuid::Uuid::now_v7().simple())
212}
213
214#[derive(Serialize)]
228struct SessionStartEvent {
229 event_type: &'static str,
230 event_id: String,
231 parent_id: Option<String>,
232 session_id: String,
233 timestamp: u64,
234 capsule_name: String,
235 capsule_version: String,
236 model: String,
237 max_turns: u32,
238 max_session_tokens: Option<u64>,
241 machine_tokens_per_day: Option<u64>,
244 capabilities: Vec<String>,
245 tools_declared: Vec<String>,
246 containment_declared: ContainmentClass,
248 containment_achieved: ContainmentClass,
252 workdir_exec: bool,
260 userns_grant: Option<crate::sealed::UsernsGrant>,
272 effective_grants: ScopeReport,
281 system_prompt_source: &'static str,
289 system_prompt_sha256: Option<String>,
298 credential_source: &'static str,
301 gateways: Vec<SessionGateway>,
305 #[serde(skip_serializing_if = "Option::is_none")]
308 control: Option<SessionControl>,
309 #[serde(skip_serializing_if = "Vec::is_empty")]
313 inference_choices: Vec<SessionInferenceChoice>,
314 tool_refresh: Option<&'static str>,
317 runtime_artifacts: Vec<RuntimeArtifact>,
321 resumed_from: Option<String>,
328 context_id: Option<String>,
334 #[serde(skip_serializing_if = "Option::is_none")]
338 spawned_by: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
342 delegation_id: Option<String>,
343 #[serde(skip_serializing_if = "Option::is_none")]
347 formation_id: Option<FormationId>,
348 #[serde(skip_serializing_if = "Option::is_none")]
351 formation_member: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
355 formation_callees: Option<Vec<String>>,
356}
357
358#[derive(Serialize)]
359struct InferenceEvent {
360 event_type: &'static str,
361 event_id: String,
362 parent_id: Option<String>,
363 session_id: String,
364 timestamp: u64,
365 turn: u32,
366 task_id: Option<String>,
367 #[serde(skip_serializing_if = "Option::is_none")]
380 input_tokens: Option<u64>,
381 #[serde(skip_serializing_if = "Option::is_none")]
385 output_tokens: Option<u64>,
386 decision: String,
387 #[serde(skip_serializing_if = "Option::is_none")]
394 stop_reason: Option<String>,
395 tool_name: Option<String>,
396 #[serde(skip_serializing_if = "Option::is_none")]
399 input_tokens_actual: Option<u64>,
400 #[serde(skip_serializing_if = "Option::is_none")]
401 output_tokens_actual: Option<u64>,
402 #[serde(skip_serializing_if = "Option::is_none")]
403 cached_tokens: Option<u64>,
404 #[serde(skip_serializing_if = "Option::is_none")]
405 cache_write_tokens: Option<u64>,
406 #[serde(skip_serializing_if = "Option::is_none")]
409 thinking_tokens: Option<u64>,
410 #[serde(skip_serializing_if = "Option::is_none")]
415 origin: Option<String>,
416 #[serde(skip_serializing_if = "Option::is_none")]
419 driver_choice: Option<String>,
420 #[serde(skip_serializing_if = "Option::is_none")]
425 model: Option<String>,
426 #[serde(skip_serializing_if = "Vec::is_empty")]
431 message_ids: Vec<String>,
432 #[serde(skip_serializing_if = "Option::is_none")]
440 system_sha: Option<String>,
441 #[serde(skip_serializing_if = "Option::is_none")]
444 tools_sha: Option<String>,
445 #[serde(skip_serializing_if = "Option::is_none")]
448 response_sha: Option<String>,
449 #[serde(skip_serializing_if = "Vec::is_empty")]
457 message_shas: Vec<String>,
458 #[serde(skip_serializing_if = "Option::is_none")]
462 error_code: Option<String>,
463 #[serde(skip_serializing_if = "Option::is_none")]
467 error: Option<String>,
468 #[serde(skip_serializing_if = "Option::is_none")]
472 provider_status: Option<u16>,
473}
474
475pub(crate) struct WireCapture {
485 system: Vec<u8>,
488 tools: Vec<u8>,
490 messages: Vec<Vec<u8>>,
494 response: Vec<u8>,
496}
497
498impl WireCapture {
499 pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
505 Self {
506 system: payload
507 .get("system")
508 .and_then(Value::as_str)
509 .unwrap_or_default()
510 .as_bytes()
511 .to_vec(),
512 tools: payload
513 .get("tools")
514 .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
515 .unwrap_or_default(),
516 messages: payload
517 .get("messages")
518 .and_then(Value::as_array)
519 .map(|messages| {
520 messages
521 .iter()
522 .map(|message| serde_json::to_vec(message).unwrap_or_default())
523 .collect()
524 })
525 .unwrap_or_default(),
526 response: response.as_bytes().to_vec(),
527 }
528 }
529}
530
531#[derive(Serialize)]
532struct ToolCallEvent {
533 event_type: &'static str,
534 event_id: String,
535 parent_id: Option<String>,
536 session_id: String,
537 timestamp: u64,
538 turn: u32,
539 task_id: Option<String>,
540 tool_name: String,
541 tool_call_id: Option<String>,
545 input: Value,
546 input_bytes: u64,
547 #[serde(skip_serializing_if = "Option::is_none")]
548 output: Option<String>,
549 output_bytes: u64,
550 duration_ms: u64,
551 status: String,
552 #[serde(skip_serializing_if = "Option::is_none")]
556 state_effect: Option<String>,
557 #[serde(skip_serializing_if = "Option::is_none")]
562 resource_id: Option<String>,
563}
564
565#[derive(Serialize)]
566struct SkillCallEvent {
567 event_type: &'static str,
568 event_id: String,
569 parent_id: Option<String>,
570 session_id: String,
571 timestamp: u64,
572 turn: u32,
573 task_id: Option<String>,
574 skill_name: String,
575 output_bytes: u64,
576 duration_ms: u64,
577 status: String,
578 origin: &'static str,
580 trust: &'static str,
583}
584
585#[derive(Serialize)]
586struct ShellEvent {
587 event_type: &'static str,
588 event_id: String,
589 parent_id: Option<String>,
590 session_id: String,
591 timestamp: u64,
592 turn: u32,
593 task_id: Option<String>,
594 binary: String,
598 command: String,
599 exit_code: i32,
600 stdout_bytes: u64,
601 stderr_bytes: u64,
602 duration_ms: u64,
603 #[serde(skip_serializing_if = "Option::is_none")]
609 resource_limit: Option<String>,
610}
611
612#[derive(Serialize)]
615struct ShellDetachedEvent {
616 event_type: &'static str,
617 event_id: String,
618 parent_id: Option<String>,
619 session_id: String,
620 timestamp: u64,
621 turn: u32,
622 task_id: Option<String>,
623 work_id: String,
624 binary: String,
625 command: String,
626 grace_ms: u64,
628}
629
630#[derive(Serialize)]
633struct ShellCompletedEvent {
634 event_type: &'static str,
635 event_id: String,
636 parent_id: Option<String>,
637 session_id: String,
638 timestamp: u64,
639 work_id: String,
640 binary: String,
641 command: String,
642 exit_code: i32,
643 duration_ms: u64,
644 output_path: String,
646 output_bytes: u64,
647 #[serde(skip_serializing_if = "Option::is_none")]
649 resource_limit: Option<String>,
650 status: String,
653 completion_task_id: String,
656}
657
658#[derive(Serialize)]
667struct ShellAbandonedEvent {
668 event_type: &'static str,
669 event_id: String,
670 parent_id: Option<String>,
671 session_id: String,
672 timestamp: u64,
673 work_id: String,
674 binary: String,
675 command: String,
676 running_ms: u64,
679 #[serde(skip_serializing_if = "Option::is_none")]
682 exit_code: Option<i32>,
683 #[serde(skip_serializing_if = "Option::is_none")]
687 output_path: Option<String>,
688 #[serde(skip_serializing_if = "Option::is_none")]
690 output_bytes: Option<u64>,
691}
692
693#[derive(Serialize)]
705struct ShellLostEvent {
706 event_type: &'static str,
707 event_id: String,
708 #[serde(skip_serializing_if = "Option::is_none")]
711 parent_id: Option<String>,
712 session_id: String,
714 timestamp: u64,
715 work_id: String,
716 binary: String,
717 command: String,
718 detached_at_ms: u64,
720 reconciled_by_session: String,
722 reconciled_task_id: String,
725}
726
727#[derive(Serialize)]
733struct ShellDetachUnrecordedEvent {
734 event_type: &'static str,
735 event_id: String,
736 parent_id: Option<String>,
737 session_id: String,
738 timestamp: u64,
739 turn: u32,
740 task_id: Option<String>,
741 work_id: String,
742 binary: String,
743 reason: String,
745}
746
747#[derive(Serialize)]
748struct CompactionEvent {
749 event_type: &'static str,
750 event_id: String,
751 parent_id: Option<String>,
752 session_id: String,
753 timestamp: u64,
754 turn: u32,
755 task_id: Option<String>,
756 tokens_before: u64,
757 tokens_after: u64,
758}
759
760#[derive(Serialize)]
764struct CompactionDeclinedEvent {
765 event_type: &'static str,
766 event_id: String,
767 parent_id: Option<String>,
768 session_id: String,
769 timestamp: u64,
770 turn: u32,
771 task_id: Option<String>,
772 tokens: u64,
775 reason: String,
778}
779
780#[derive(Serialize)]
787struct ContextSeedEvent {
788 event_type: &'static str,
789 event_id: String,
790 parent_id: Option<String>,
791 session_id: String,
792 timestamp: u64,
793 task_id: Option<String>,
794 hook_name: String,
796 tokens: u64,
799 proposed_tokens: u64,
802 budget_tokens: u64,
805 outcome: String,
808 #[serde(skip_serializing_if = "Option::is_none")]
811 reason: Option<String>,
812 message_ids: Vec<String>,
816}
817
818#[derive(Serialize)]
826struct RetentionEvent {
827 event_type: &'static str,
828 event_id: String,
829 parent_id: Option<String>,
830 session_id: String,
831 timestamp: u64,
832 store: String,
834 reason: String,
837 removed: u32,
841 targets: Vec<String>,
843 #[serde(skip_serializing_if = "Option::is_none")]
846 messages_dropped: Option<u64>,
847}
848
849#[derive(Serialize)]
850struct A2aTaskReceivedEvent {
851 event_type: &'static str,
852 event_id: String,
853 parent_id: Option<String>,
854 session_id: String,
855 timestamp: u64,
856 task_id: String,
857 context_id: String,
858 message_id: String,
859 traceparent_from_caller: Option<String>,
860 #[serde(skip_serializing_if = "Option::is_none")]
863 caller_member: Option<String>,
864}
865
866#[derive(Serialize)]
867struct A2aSendEvent {
868 event_type: &'static str,
869 event_id: String,
870 parent_id: Option<String>,
871 session_id: String,
872 timestamp: u64,
873 peer_url: String,
874 message_id: String,
875 task_id: String,
876 context_id: String,
877 traceparent: Option<String>,
878 trust: String,
881}
882
883#[derive(Serialize)]
886struct ArtifactPulledEvent {
887 event_type: &'static str,
888 event_id: String,
889 parent_id: Option<String>,
890 session_id: String,
891 timestamp: u64,
892 name: String,
893 version: String,
894 runtime: &'static str,
896 origin: &'static str,
899 session: Option<String>,
902 trust: &'static str,
903}
904
905#[derive(Serialize)]
906struct SessionEndEvent {
907 event_type: &'static str,
908 event_id: String,
909 parent_id: Option<String>,
910 session_id: String,
911 timestamp: u64,
912 total_turns: u32,
913 total_input_tokens: u64,
914 total_output_tokens: u64,
915 total_tool_calls: u32,
916 total_shell_calls: u32,
917 duration_ms: u64,
918 exit_status: String,
919}
920
921#[derive(Serialize)]
922struct TaskStartEvent {
923 event_type: &'static str,
924 event_id: String,
925 parent_id: Option<String>,
926 session_id: String,
927 timestamp: u64,
928 task_id: String,
929 context_id: String,
930 source: String,
933 origin: String,
934 trust: String,
935 lane: String,
938 message_parts_bytes: u64,
939}
940
941#[derive(Serialize)]
942struct TaskEndEvent {
943 event_type: &'static str,
944 event_id: String,
945 parent_id: Option<String>,
946 session_id: String,
947 timestamp: u64,
948 task_id: String,
949 exit_status: String,
950 duration_ms: u64,
951 turns: u32,
952 input_tokens: u64,
953 output_tokens: u64,
954 tool_calls: u32,
955 shell_calls: u32,
956 reopen_count: u32,
959}
960
961#[derive(Serialize)]
968struct TaskCanceledEvent {
969 event_type: &'static str,
970 event_id: String,
971 parent_id: Option<String>,
972 session_id: String,
973 timestamp: u64,
974 task_id: String,
975 #[serde(skip_serializing_if = "Option::is_none")]
977 turn: Option<u32>,
978 phase: String,
981 detached_work_ids: Vec<String>,
984 delegation_ids: Vec<String>,
986}
987
988pub(crate) const TASK_REJECTED_SESSION_ENDED: &str = "session_ended";
991
992pub(crate) const TASK_REJECTED_SESSION_STOPPED: &str = "session_stopped";
994
995#[derive(Serialize)]
1002struct TaskRejectedEvent {
1003 event_type: &'static str,
1004 event_id: String,
1005 parent_id: Option<String>,
1006 session_id: String,
1007 timestamp: u64,
1008 task_id: String,
1009 context_id: String,
1010 source: String,
1013 cause: String,
1015 reason: String,
1017}
1018
1019pub(crate) const TASK_FAILED_DRIVER_ERROR: &str = "driver_error";
1022
1023pub(crate) const TASK_FAILED_CREDENTIAL_REJECTED: &str = "credential_rejected";
1026
1027pub(crate) const TASK_FAILED_MALFORMED_RESPONSE: &str = "malformed_response";
1030
1031pub(crate) const TASK_FAILED_COMPACTION_HOOK: &str = "compaction_hook";
1033
1034pub(crate) const TASK_FAILED_INPUT_TIMEOUT: &str = "input_timeout";
1036
1037pub(crate) const TASK_FAILED_REOPEN_BUDGET_EXHAUSTED: &str = "reopen_budget_exhausted";
1040
1041pub(crate) const TASK_FAILED_RUNTIME_ERROR: &str = "runtime_error";
1044
1045pub(crate) const INFERENCE_ERROR_CREDENTIAL_REJECTED: &str = TASK_FAILED_CREDENTIAL_REJECTED;
1049
1050pub(crate) const INFERENCE_ERROR_DRIVER_ERROR: &str = TASK_FAILED_DRIVER_ERROR;
1054
1055pub(crate) const INFERENCE_ERROR_DRIVER_FAILED: &str = "driver_failed";
1058
1059pub(crate) const INFERENCE_ERROR_MALFORMED_RESPONSE: &str = TASK_FAILED_MALFORMED_RESPONSE;
1062
1063pub(crate) const INFERENCE_ERROR_HARNESS_AUTH: &str = "harness_auth";
1065
1066pub(crate) const INFERENCE_ERROR_HARNESS_QUOTA: &str = "harness_quota";
1068
1069pub(crate) const INFERENCE_ERROR_HARNESS_ERROR: &str = "harness_error";
1072
1073pub(crate) const INFERENCE_ERROR_HARNESS_OTHER: &str = "harness_other";
1075
1076pub(crate) const INFERENCE_ERROR_HARNESS_INACTIVE: &str = "harness_inactive";
1078
1079pub(crate) struct FailedInference<'a> {
1081 pub(crate) turn: u32,
1082 pub(crate) error_code: &'static str,
1084 pub(crate) error: &'a str,
1086 pub(crate) provider_status: Option<u16>,
1088 pub(crate) usage: Option<&'a DriverUsage>,
1090 pub(crate) origin: Option<&'a InferenceOrigin>,
1092 pub(crate) choice: Option<&'a crate::driver_choice::DriverChoice>,
1094 pub(crate) message_ids: Vec<String>,
1096 pub(crate) tool_name: Option<String>,
1097 pub(crate) spent: Option<(Option<u64>, Option<u64>)>,
1100}
1101
1102struct InferenceLine<'a> {
1105 turn: u32,
1106 input_tokens: Option<u64>,
1107 output_tokens: Option<u64>,
1108 decision: String,
1109 stop_reason: Option<&'a str>,
1110 tool_name: Option<String>,
1111 origin: Option<&'a InferenceOrigin>,
1112 usage: Option<&'a DriverUsage>,
1113 message_ids: Vec<String>,
1114 wire: Option<&'a WireCapture>,
1115 choice: Option<&'a crate::driver_choice::DriverChoice>,
1116 failure: Option<(&'static str, String, Option<u16>)>,
1118 counted: bool,
1120}
1121
1122pub(crate) const MAX_TASK_FAILURE_REASON_BYTES: usize = 2_000;
1125
1126#[derive(Serialize)]
1134struct TaskFailedEvent {
1135 event_type: &'static str,
1136 event_id: String,
1137 parent_id: Option<String>,
1138 session_id: String,
1139 timestamp: u64,
1140 #[serde(skip_serializing_if = "Option::is_none")]
1143 task_id: Option<String>,
1144 #[serde(skip_serializing_if = "Option::is_none")]
1147 turn: Option<u32>,
1148 cause: String,
1149 reason: String,
1151}
1152
1153pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
1156
1157pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
1160
1161#[derive(Serialize)]
1166struct TaskReopenedEvent {
1167 event_type: &'static str,
1168 event_id: String,
1169 parent_id: Option<String>,
1170 session_id: String,
1171 timestamp: u64,
1172 task_id: String,
1173 hook_name: String,
1175 reason: String,
1177 reopen_number: u32,
1179 attempt_context: &'static str,
1181 turns_remaining: u32,
1184}
1185
1186#[derive(Serialize)]
1191struct TaskContinuedEvent<'a> {
1192 event_type: &'static str,
1193 event_id: String,
1194 parent_id: Option<String>,
1195 session_id: String,
1196 timestamp: u64,
1197 task_id: String,
1198 continuation_number: u32,
1200 member_calls: &'a [String],
1202 delegations: &'a [String],
1205 waited_ms: u64,
1207 turns_remaining: u32,
1210}
1211
1212#[derive(Serialize)]
1215struct SpendCeilingReachedEvent<'a> {
1216 event_type: &'static str,
1217 event_id: String,
1218 parent_id: Option<String>,
1219 session_id: String,
1220 timestamp: u64,
1221 turn: u32,
1222 task_id: Option<String>,
1223 #[serde(flatten)]
1224 refusal: &'a crate::spend::SpendRefusal,
1225 #[serde(skip_serializing_if = "Option::is_none")]
1227 origin: Option<&'a str>,
1228}
1229
1230#[derive(Serialize)]
1235pub(crate) struct HarnessStart {
1236 pub driver: String,
1238 pub driver_version: String,
1239 pub harness: String,
1241 pub binary: String,
1243 pub binary_source: String,
1245 pub harness_version: Option<String>,
1247 pub version_tested: bool,
1249 pub harness_session_id: String,
1251 pub session_mode: String,
1252 pub args_count: usize,
1255 pub env_names: Vec<String>,
1257 pub files: Vec<String>,
1259 pub bridge_tools: Vec<String>,
1261 pub stdin_bytes: usize,
1263 pub keep_stdin_open: bool,
1264}
1265
1266#[derive(Serialize)]
1267struct HarnessStartEvent {
1268 event_type: &'static str,
1269 event_id: String,
1270 parent_id: Option<String>,
1271 session_id: String,
1272 timestamp: u64,
1273 task_id: Option<String>,
1274 #[serde(flatten)]
1275 start: HarnessStart,
1276}
1277
1278#[derive(Serialize)]
1281struct HarnessWarningEvent {
1282 event_type: &'static str,
1283 event_id: String,
1284 parent_id: Option<String>,
1285 session_id: String,
1286 timestamp: u64,
1287 task_id: Option<String>,
1288 code: String,
1289 message: String,
1290}
1291
1292#[derive(Serialize)]
1295struct HarnessSessionEvent {
1296 event_type: &'static str,
1297 event_id: String,
1298 parent_id: Option<String>,
1299 session_id: String,
1300 timestamp: u64,
1301 task_id: Option<String>,
1302 harness_session_id: String,
1303 auth: String,
1305 model: Option<String>,
1307}
1308
1309#[derive(Serialize)]
1313struct HarnessSessionForgottenEvent {
1314 event_type: &'static str,
1315 event_id: String,
1316 parent_id: Option<String>,
1317 session_id: String,
1318 timestamp: u64,
1319 task_id: Option<String>,
1320 context_id: String,
1322 harness_session_id: String,
1324 requested_by: String,
1326}
1327
1328#[derive(Serialize)]
1329struct HarnessRetryEvent {
1330 event_type: &'static str,
1331 event_id: String,
1332 parent_id: Option<String>,
1333 session_id: String,
1334 timestamp: u64,
1335 task_id: Option<String>,
1336 attempt: u32,
1337 reason: String,
1338}
1339
1340#[derive(Serialize)]
1341struct HarnessNoteEvent {
1342 event_type: &'static str,
1343 event_id: String,
1344 parent_id: Option<String>,
1345 session_id: String,
1346 timestamp: u64,
1347 task_id: Option<String>,
1348 text: String,
1349}
1350
1351#[derive(Serialize)]
1352struct HarnessFailedEvent {
1353 event_type: &'static str,
1354 event_id: String,
1355 parent_id: Option<String>,
1356 session_id: String,
1357 timestamp: u64,
1358 task_id: Option<String>,
1359 kind: String,
1361 message: String,
1362 source: String,
1364}
1365
1366#[derive(Serialize)]
1368struct HarnessInterruptEvent {
1369 event_type: &'static str,
1370 event_id: String,
1371 parent_id: Option<String>,
1372 session_id: String,
1373 timestamp: u64,
1374 task_id: Option<String>,
1375 method: String,
1377 delivered: bool,
1379 grace_ms: u64,
1382}
1383
1384#[derive(Serialize)]
1386pub(crate) struct HarnessExit {
1387 pub code: Option<i32>,
1389 pub signal: Option<i32>,
1391 pub cause: String,
1394 pub killed: bool,
1396 pub duration_ms: u64,
1398}
1399
1400#[derive(Serialize)]
1401struct HarnessExitEvent {
1402 event_type: &'static str,
1403 event_id: String,
1404 parent_id: Option<String>,
1405 session_id: String,
1406 timestamp: u64,
1407 task_id: Option<String>,
1408 #[serde(flatten)]
1409 exit: HarnessExit,
1410}
1411
1412#[derive(Serialize)]
1416struct CallDeniedEvent {
1417 event_type: &'static str,
1418 event_id: String,
1419 parent_id: Option<String>,
1420 session_id: String,
1421 timestamp: u64,
1422 turn: u32,
1423 event: String,
1426 hook_name: String,
1428 target: String,
1431 reason: String,
1433}
1434
1435#[derive(Serialize)]
1439struct ToolInputRefusedEvent {
1440 event_type: &'static str,
1441 event_id: String,
1442 parent_id: Option<String>,
1443 session_id: String,
1444 timestamp: u64,
1445 turn: u32,
1446 tool_name: String,
1447 tool_call_id: Option<String>,
1450 missing: Vec<String>,
1452 reason: String,
1454}
1455
1456#[derive(Serialize)]
1461struct ProtectedPathDeniedEvent {
1462 event_type: &'static str,
1463 event_id: String,
1464 parent_id: Option<String>,
1465 session_id: String,
1466 timestamp: u64,
1467 turn: u32,
1468 call: String,
1470 target: String,
1472 path: String,
1475 rule: String,
1477 signal: String,
1480 reason: String,
1482}
1483
1484#[derive(Serialize)]
1485struct HookDispatchErrorEvent {
1486 event_type: &'static str,
1487 event_id: String,
1488 parent_id: Option<String>,
1489 session_id: String,
1490 timestamp: u64,
1491 hook_name: String,
1492 event: String,
1493 arm: String,
1494}
1495
1496#[derive(Serialize)]
1500struct FormationEndedEvent {
1501 event_type: &'static str,
1502 event_id: String,
1503 parent_id: Option<String>,
1504 session_id: String,
1505 timestamp: u64,
1506 formation_id: String,
1507}
1508
1509#[derive(Serialize)]
1516struct SpawnerEndedEvent {
1517 event_type: &'static str,
1518 event_id: String,
1519 parent_id: Option<String>,
1520 session_id: String,
1521 timestamp: u64,
1522 #[serde(skip_serializing_if = "Option::is_none")]
1523 spawned_by: Option<String>,
1524 #[serde(skip_serializing_if = "Option::is_none")]
1525 delegation_id: Option<String>,
1526}
1527
1528#[derive(Serialize)]
1529struct ResourceListEvent {
1530 event_type: &'static str,
1531 event_id: String,
1532 parent_id: Option<String>,
1533 session_id: String,
1534 timestamp: u64,
1535 root: String,
1538 entry_count: usize,
1539 total_bytes: u64,
1540 generation: u64,
1542 containment_achieved: ContainmentClass,
1543 outcome: String,
1545 reason: Option<String>,
1547}
1548
1549#[derive(Serialize)]
1550struct ResourceReadEvent {
1551 event_type: &'static str,
1552 event_id: String,
1553 parent_id: Option<String>,
1554 session_id: String,
1555 timestamp: u64,
1556 path: String,
1559 outcome: String,
1560 bytes: Option<u64>,
1562 sha256: Option<String>,
1564 generation: u64,
1565 containment_achieved: ContainmentClass,
1566 reason: Option<String>,
1567}
1568
1569#[derive(Serialize)]
1571struct PeerHandleMintEvent {
1572 event_type: &'static str,
1573 event_id: String,
1574 parent_id: Option<String>,
1575 session_id: String,
1576 timestamp: u64,
1577 handle_id: Option<String>,
1580 path: String,
1583 audience: String,
1584 expires_at_ms: Option<u64>,
1586 outcome: String,
1587 reason: Option<String>,
1588}
1589
1590#[derive(Serialize)]
1593struct PeerHandleRedeemEvent {
1594 event_type: &'static str,
1595 event_id: String,
1596 parent_id: Option<String>,
1597 session_id: String,
1598 timestamp: u64,
1599 handle_id: String,
1600 path: Option<String>,
1603 generation: u64,
1606 audience_asserted: Option<String>,
1608 bytes: Option<u64>,
1610 sha256: Option<String>,
1612 outcome: String,
1613 reason: Option<String>,
1614}
1615
1616#[derive(Serialize)]
1618struct PeerFileFetchEvent {
1619 event_type: &'static str,
1620 event_id: String,
1621 parent_id: Option<String>,
1622 session_id: String,
1623 timestamp: u64,
1624 peer: String,
1625 handle_id: String,
1626 stored_path: Option<String>,
1629 bytes: Option<u64>,
1630 sha256: Option<String>,
1631 outcome: String,
1632 reason: Option<String>,
1633}
1634
1635#[derive(Serialize)]
1646struct DelegationStartEvent {
1647 event_type: &'static str,
1648 event_id: String,
1649 parent_id: Option<String>,
1650 session_id: String,
1651 timestamp: u64,
1652 delegation_id: String,
1655 capsule: String,
1656 version: String,
1657 child_session_id: String,
1659 child_workdir: String,
1662}
1663
1664impl DelegationStartEvent {
1665 fn new(
1668 session_id: &str,
1669 session_event_id: &str,
1670 delegation_id: &str,
1671 capsule: &str,
1672 version: &str,
1673 child_session_id: &str,
1674 child_workdir: &str,
1675 ) -> Self {
1676 Self {
1677 event_type: "delegation_start",
1678 event_id: new_event_id(),
1679 parent_id: Some(session_event_id.to_string()),
1680 session_id: session_id.to_string(),
1681 timestamp: timestamp_ms(),
1682 delegation_id: delegation_id.to_string(),
1683 capsule: capsule.to_string(),
1684 version: version.to_string(),
1685 child_session_id: child_session_id.to_string(),
1686 child_workdir: child_workdir.to_string(),
1687 }
1688 }
1689}
1690
1691#[derive(Serialize)]
1697struct MemberCallStartEvent {
1698 event_type: &'static str,
1699 event_id: String,
1700 parent_id: Option<String>,
1701 session_id: String,
1702 timestamp: u64,
1703 task_id: String,
1705 call_id: String,
1707 member: String,
1709 member_task_id: String,
1711}
1712
1713#[derive(Serialize)]
1719struct MemberCallBusyEvent {
1720 event_type: &'static str,
1721 event_id: String,
1722 parent_id: Option<String>,
1723 session_id: String,
1724 timestamp: u64,
1725 task_id: String,
1727 call_id: String,
1729 member: String,
1731 offer: u32,
1733 waited_ms: u64,
1735 message: String,
1737}
1738
1739#[derive(Serialize)]
1745struct MemberCallEvent {
1746 event_type: &'static str,
1747 event_id: String,
1748 parent_id: Option<String>,
1749 session_id: String,
1750 timestamp: u64,
1751 task_id: String,
1752 call_id: String,
1753 member: String,
1754 #[serde(skip_serializing_if = "Option::is_none")]
1756 member_task_id: Option<String>,
1757 status: String,
1760 duration_ms: u64,
1761 output: String,
1763 truncated: bool,
1764 delivered: bool,
1767 #[serde(skip_serializing_if = "Vec::is_empty")]
1770 no_answer_below: Vec<NoAnswerBelow>,
1771}
1772
1773#[derive(Serialize)]
1775struct NoAnswerBelow {
1776 member: String,
1777 status: String,
1779}
1780
1781#[derive(Serialize)]
1792struct DelegationEvent {
1793 event_type: &'static str,
1794 event_id: String,
1795 parent_id: Option<String>,
1796 session_id: String,
1797 timestamp: u64,
1798 capsule: String,
1800 version: String,
1801 delegation_id: Option<String>,
1803 child_session_id: Option<String>,
1805 duration_ms: u64,
1808 outcome: String,
1818 reason: Option<String>,
1821}
1822
1823impl DelegationEvent {
1824 #[allow(clippy::too_many_arguments)]
1827 fn new(
1828 session_id: &str,
1829 session_event_id: &str,
1830 capsule: &str,
1831 version: &str,
1832 delegation_id: Option<String>,
1833 child_session_id: Option<String>,
1834 duration_ms: u64,
1835 outcome: &str,
1836 reason: Option<String>,
1837 ) -> Self {
1838 Self {
1839 event_type: "delegation",
1840 event_id: new_event_id(),
1841 parent_id: Some(session_event_id.to_string()),
1842 session_id: session_id.to_string(),
1843 timestamp: timestamp_ms(),
1844 capsule: capsule.to_string(),
1845 version: version.to_string(),
1846 delegation_id,
1847 child_session_id,
1848 duration_ms,
1849 outcome: outcome.to_string(),
1850 reason,
1851 }
1852 }
1853}
1854
1855impl TraceWriter {
1858 #[allow(clippy::too_many_arguments)]
1859 pub(crate) async fn open(
1860 workdir: &Path,
1861 session_id: String,
1862 capsule_name: String,
1863 capsule_version: String,
1864 model: String,
1865 capabilities: Vec<String>,
1866 effective_grants: ScopeReport,
1867 capture: TraceCapture,
1868 system_prompt: Option<String>,
1869 system_prompt_overridden: bool,
1870 resumed_from: Option<String>,
1871 context_id: Option<String>,
1872 spawned_by: Option<String>,
1873 spawn_delegation_id: Option<String>,
1874 ) -> std::io::Result<Self> {
1875 let file = OpenOptions::new()
1876 .create(true)
1877 .append(true)
1878 .open(workdir.join("trace.jsonl"))
1879 .await?;
1880 let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
1883 (true, _) => "cli",
1884 (false, true) => "manifest",
1885 (false, false) => "none",
1886 };
1887 let system_prompt_sha256 = system_prompt
1888 .as_ref()
1889 .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
1890 let blobs = BlobStore::new(workdir);
1891 if let (true, Some(prompt), Some(sha)) = (
1895 capture.captures_content(),
1896 system_prompt.as_ref(),
1897 system_prompt_sha256.as_deref(),
1898 ) {
1899 blobs.put(sha, prompt.as_bytes()).await?;
1900 }
1901 Ok(Self {
1902 writer: BufWriter::new(file),
1903 session_id,
1904 capsule_name,
1905 capsule_version,
1906 model,
1907 capabilities,
1908 effective_grants,
1909 capture,
1910 blobs,
1911 system_prompt_source,
1912 system_prompt_sha256,
1913 credential_source: "none",
1914 gateways: Vec::new(),
1915 control: None,
1916 inference_choices: Vec::new(),
1917 tool_refresh: None,
1918 runtime_artifacts: Vec::new(),
1919 resumed_from,
1920 context_id,
1921 spawned_by,
1922 spawn_delegation_id,
1923 formation_id: None,
1924 formation_member: None,
1925 session_start_time: Instant::now(),
1926 session_event_id: new_event_id(),
1927 session_started: false,
1928 session_ended: false,
1929 task_event_id: None,
1930 turn_event_id: None,
1931 total_turns: 0,
1932 total_input_tokens: 0,
1933 total_output_tokens: 0,
1934 total_tool_calls: 0,
1935 total_shell_calls: 0,
1936 task_turns: 0,
1937 task_input_tokens: 0,
1938 task_output_tokens: 0,
1939 task_tool_calls: 0,
1940 task_shell_calls: 0,
1941 task_agent_turns: 0,
1942 task_hook_inferences: 0,
1943 task_start_instant: None,
1944 active_task_id: None,
1945 task_failures_written: 0,
1946 last_task_failure: None,
1947 max_session_tokens: None,
1948 machine_tokens_per_day: None,
1949 })
1950 }
1951
1952 pub(crate) fn set_spend_ceilings(
1954 &mut self,
1955 max_session_tokens: Option<u64>,
1956 machine_tokens_per_day: Option<u64>,
1957 ) {
1958 self.max_session_tokens = max_session_tokens;
1959 self.machine_tokens_per_day = machine_tokens_per_day;
1960 }
1961
1962 pub(crate) fn session_event_id(&self) -> &str {
1966 &self.session_event_id
1967 }
1968
1969 pub(crate) fn captures_wire(&self) -> bool {
1973 self.capture.captures_hashes()
1974 }
1975
1976 fn session_parent(&self) -> Option<String> {
1981 self.session_started.then(|| self.session_event_id.clone())
1982 }
1983
1984 fn task_parent(&self) -> Option<String> {
1987 self.task_event_id.clone().or_else(|| self.session_parent())
1988 }
1989
1990 fn turn_parent(&self) -> Option<String> {
1994 self.turn_event_id.clone().or_else(|| self.task_parent())
1995 }
1996
1997 pub(crate) fn set_credential_source(&mut self, source: &'static str) {
2000 self.credential_source = source;
2001 }
2002
2003 pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
2005 self.gateways = gateways;
2006 }
2007
2008 pub(crate) fn set_control(&mut self, control: Option<SessionControl>) {
2010 self.control = control;
2011 }
2012
2013 pub(crate) fn set_inference_choices(&mut self, choices: Vec<SessionInferenceChoice>) {
2016 self.inference_choices = choices;
2017 }
2018
2019 pub(crate) fn set_runtime_artifacts(&mut self, installed: &[InstalledArtifactSummary]) {
2022 self.runtime_artifacts = installed
2023 .iter()
2024 .filter_map(|artifact| match &artifact.origin {
2025 LockOrigin::Runtime { session } => Some(RuntimeArtifact {
2026 name: artifact.name.clone(),
2027 version: artifact.version.clone(),
2028 origin: artifact.origin.as_str(),
2029 session: session.clone(),
2030 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2031 }),
2032 LockOrigin::Operator => None,
2033 })
2034 .collect();
2035 }
2036
2037 pub(crate) async fn write_control_applied(
2044 &mut self,
2045 turn: u32,
2046 name: &str,
2047 value: Value,
2048 change_id: &str,
2049 ) -> std::io::Result<()> {
2050 let event = ControlAppliedEvent {
2051 event_type: "control_applied",
2052 event_id: new_event_id(),
2053 parent_id: self.task_parent(),
2054 session_id: self.session_id.clone(),
2055 timestamp: timestamp_ms(),
2056 turn,
2057 task_id: self.active_task_id.clone(),
2058 name,
2059 value,
2060 change_id,
2061 };
2062 self.write_event(&event).await
2063 }
2064
2065 pub(crate) fn set_formation_id(&mut self, formation_id: Option<&FormationId>) {
2067 self.formation_id = formation_id.cloned();
2068 }
2069
2070 pub(crate) fn set_formation_member(
2073 &mut self,
2074 member: Option<&crate::formation_credentials::FormationMember>,
2075 ) {
2076 self.formation_member = member.map(|member| {
2077 (
2078 member.name().to_string(),
2079 member.callees().map(str::to_string).collect(),
2080 )
2081 });
2082 }
2083
2084 pub(crate) fn set_tool_refresh(&mut self, tool_refresh: Option<&'static str>) {
2087 self.tool_refresh = tool_refresh;
2088 }
2089
2090 pub(crate) async fn write_tools_refreshed(
2097 &mut self,
2098 turn: u32,
2099 trigger: &str,
2100 added: &[String],
2101 removed: &[String],
2102 tools: &[String],
2103 ) -> std::io::Result<()> {
2104 let event = ToolsRefreshedEvent {
2105 event_type: "tools_refreshed",
2106 event_id: new_event_id(),
2107 parent_id: self.task_parent(),
2108 session_id: self.session_id.clone(),
2109 timestamp: timestamp_ms(),
2110 turn,
2111 task_id: self.active_task_id.clone(),
2112 trigger,
2113 added,
2114 removed,
2115 tools,
2116 };
2117 self.write_event(&event).await
2118 }
2119
2120 pub(crate) async fn write_session_start(
2121 &mut self,
2122 max_turns: u32,
2123 tools_declared: Vec<String>,
2124 ) -> std::io::Result<()> {
2125 let event = SessionStartEvent {
2126 event_type: "session_start",
2127 event_id: self.session_event_id.clone(),
2128 parent_id: None,
2129 session_id: self.session_id.clone(),
2130 timestamp: timestamp_ms(),
2131 capsule_name: self.capsule_name.clone(),
2132 capsule_version: self.capsule_version.clone(),
2133 model: self.model.clone(),
2134 max_turns,
2135 max_session_tokens: self.max_session_tokens,
2136 machine_tokens_per_day: self.machine_tokens_per_day,
2137 capabilities: self.capabilities.clone(),
2138 tools_declared,
2139 containment_declared: self.effective_grants.declared_containment,
2140 containment_achieved: self.effective_grants.achieved_containment,
2141 workdir_exec: self.effective_grants.workdir_exec,
2142 userns_grant: self.effective_grants.userns_grant,
2143 effective_grants: self.effective_grants.clone(),
2144 system_prompt_source: self.system_prompt_source,
2145 system_prompt_sha256: self.system_prompt_sha256.clone(),
2146 credential_source: self.credential_source,
2147 gateways: self.gateways.clone(),
2148 control: self.control.clone(),
2149 inference_choices: self.inference_choices.clone(),
2150 tool_refresh: self.tool_refresh,
2151 runtime_artifacts: self.runtime_artifacts.clone(),
2152 resumed_from: self.resumed_from.clone(),
2153 context_id: self.context_id.clone(),
2154 spawned_by: self.spawned_by.clone(),
2155 delegation_id: self.spawn_delegation_id.clone(),
2156 formation_id: self.formation_id.clone(),
2157 formation_member: self.formation_member.as_ref().map(|(name, _)| name.clone()),
2158 formation_callees: self
2159 .formation_member
2160 .as_ref()
2161 .map(|(_, callees)| callees.clone()),
2162 };
2163 self.write_event(&event).await?;
2164 self.session_started = true;
2165 Ok(())
2166 }
2167
2168 #[allow(clippy::too_many_arguments)]
2178 pub(crate) async fn write_inference(
2179 &mut self,
2180 turn: u32,
2181 input_tokens: Option<u64>,
2182 output_tokens: Option<u64>,
2183 decision: String,
2184 stop_reason: Option<&str>,
2185 tool_name: Option<String>,
2186 origin: Option<&InferenceOrigin>,
2187 usage: Option<&DriverUsage>,
2188 message_ids: Vec<String>,
2189 wire: Option<&WireCapture>,
2190 ) -> std::io::Result<()> {
2191 self.write_inference_event(InferenceLine {
2192 turn,
2193 input_tokens,
2194 output_tokens,
2195 decision,
2196 stop_reason,
2197 tool_name,
2198 origin,
2199 usage,
2200 message_ids,
2201 wire,
2202 choice: None,
2203 failure: None,
2204 counted: true,
2205 })
2206 .await
2207 }
2208
2209 #[allow(clippy::too_many_arguments)]
2213 pub(crate) async fn write_turn_inference(
2214 &mut self,
2215 turn: u32,
2216 input_tokens: Option<u64>,
2217 output_tokens: Option<u64>,
2218 decision: String,
2219 stop_reason: Option<&str>,
2220 tool_name: Option<String>,
2221 usage: Option<&DriverUsage>,
2222 message_ids: Vec<String>,
2223 wire: Option<&WireCapture>,
2224 choice: Option<&crate::driver_choice::DriverChoice>,
2225 ) -> std::io::Result<()> {
2226 self.write_inference_event(InferenceLine {
2227 turn,
2228 input_tokens,
2229 output_tokens,
2230 decision,
2231 stop_reason,
2232 tool_name,
2233 origin: None,
2234 usage,
2235 message_ids,
2236 wire,
2237 choice,
2238 failure: None,
2239 counted: true,
2240 })
2241 .await
2242 }
2243
2244 pub(crate) async fn write_failed_inference(
2252 &mut self,
2253 failed: FailedInference<'_>,
2254 ) -> std::io::Result<()> {
2255 let error =
2256 crate::agent::truncate_on_char_boundary(failed.error, MAX_TASK_FAILURE_REASON_BYTES)
2257 .to_string();
2258 let (input_tokens, output_tokens) = failed.spent.unwrap_or((None, None));
2259 self.write_inference_event(InferenceLine {
2260 turn: failed.turn,
2261 input_tokens,
2262 output_tokens,
2263 decision: "error".to_string(),
2264 stop_reason: Some("error"),
2265 tool_name: failed.tool_name,
2266 origin: failed.origin,
2267 usage: failed.usage,
2268 message_ids: failed.message_ids,
2269 wire: None,
2270 choice: failed.choice,
2271 failure: Some((failed.error_code, error, failed.provider_status)),
2272 counted: failed.spent.is_some(),
2273 })
2274 .await
2275 }
2276
2277 async fn write_inference_event(&mut self, line: InferenceLine<'_>) -> std::io::Result<()> {
2278 let InferenceLine {
2279 turn,
2280 input_tokens,
2281 output_tokens,
2282 decision,
2283 stop_reason,
2284 tool_name,
2285 origin,
2286 usage,
2287 message_ids,
2288 wire,
2289 choice,
2290 failure,
2291 counted,
2292 } = line;
2293 let (error_code, error, provider_status) = match failure {
2294 Some((code, error, status)) => (Some(code.to_string()), Some(error), status),
2295 None => (None, None, None),
2296 };
2297 let event_id = new_event_id();
2298 let parent_id = if origin.is_none() {
2302 let parent = self.task_parent();
2303 self.turn_event_id = Some(event_id.clone());
2304 parent
2305 } else {
2306 self.turn_parent()
2307 };
2308 let (system_sha, tools_sha, response_sha, message_shas) =
2309 match wire.filter(|_| self.capture.captures_hashes()) {
2310 Some(wire) => self.record_wire(wire).await?,
2311 None => (None, None, None, Vec::new()),
2312 };
2313 let event = InferenceEvent {
2314 event_type: "inference",
2315 event_id,
2316 parent_id,
2317 session_id: self.session_id.clone(),
2318 timestamp: timestamp_ms(),
2319 turn,
2320 task_id: self.active_task_id.clone(),
2321 input_tokens,
2322 output_tokens,
2323 decision,
2324 stop_reason: stop_reason.map(str::to_string),
2325 tool_name,
2326 input_tokens_actual: usage.and_then(|u| u.input_tokens),
2327 output_tokens_actual: usage.and_then(|u| u.output_tokens),
2328 cached_tokens: usage.and_then(|u| u.cached_tokens),
2329 cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
2330 thinking_tokens: usage.and_then(|u| u.thinking_tokens),
2331 origin: origin.map(|o| o.source.clone()),
2332 driver_choice: choice.map(|choice| choice.name.clone()),
2333 model: origin
2334 .map(|o| o.model.clone())
2335 .or_else(|| choice.map(|choice| choice.model.clone())),
2336 message_ids,
2337 system_sha,
2338 tools_sha,
2339 response_sha,
2340 message_shas,
2341 error_code,
2342 error,
2343 provider_status,
2344 };
2345 self.write_event(&event).await?;
2346 if origin.is_none() {
2347 self.task_agent_turns = self.task_agent_turns.saturating_add(1);
2348 }
2349 if !counted {
2350 return Ok(());
2351 }
2352 let (input_tokens, output_tokens) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
2356 self.total_turns = self.total_turns.saturating_add(1);
2357 self.total_input_tokens = self.total_input_tokens.saturating_add(input_tokens);
2358 self.total_output_tokens = self.total_output_tokens.saturating_add(output_tokens);
2359 self.task_turns = self.task_turns.saturating_add(1);
2360 if origin.is_some() {
2361 self.task_hook_inferences = self.task_hook_inferences.saturating_add(1);
2362 }
2363 self.task_input_tokens = self.task_input_tokens.saturating_add(input_tokens);
2364 self.task_output_tokens = self.task_output_tokens.saturating_add(output_tokens);
2365 Ok(())
2366 }
2367
2368 async fn record_wire(
2375 &mut self,
2376 wire: &WireCapture,
2377 ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
2378 let system_sha = murmur_artifact::sha256_hex(&wire.system);
2379 let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
2380 let response_sha = murmur_artifact::sha256_hex(&wire.response);
2381 let message_shas: Vec<String> = wire
2382 .messages
2383 .iter()
2384 .map(|message| murmur_artifact::sha256_hex(message))
2385 .collect();
2386
2387 if self.capture.captures_content() {
2388 self.blobs.put(&system_sha, &wire.system).await?;
2392 self.blobs.put(&tools_sha, &wire.tools).await?;
2393 self.blobs.put(&response_sha, &wire.response).await?;
2394 for (sha, body) in message_shas.iter().zip(&wire.messages) {
2395 self.blobs.put(sha, body).await?;
2396 }
2397 }
2398
2399 Ok((
2400 Some(system_sha),
2401 Some(tools_sha),
2402 Some(response_sha),
2403 message_shas,
2404 ))
2405 }
2406
2407 #[allow(clippy::too_many_arguments)]
2408 pub(crate) async fn write_tool_call(
2409 &mut self,
2410 turn: u32,
2411 tool_name: String,
2412 tool_call_id: Option<String>,
2413 mut input: Value,
2414 input_bytes: u64,
2415 output: &str,
2416 output_bytes: u64,
2417 duration_ms: u64,
2418 status: String,
2419 state_effect: Option<String>,
2420 resource_id: Option<String>,
2421 ) -> std::io::Result<()> {
2422 crate::peer_handoff::redact_handles_in_json(&mut input);
2427 let event = ToolCallEvent {
2428 event_type: "tool_call",
2429 event_id: new_event_id(),
2430 parent_id: self.turn_parent(),
2431 session_id: self.session_id.clone(),
2432 timestamp: timestamp_ms(),
2433 turn,
2434 task_id: self.active_task_id.clone(),
2435 tool_name,
2436 tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
2439 input,
2440 input_bytes,
2441 output: self
2442 .capture
2443 .captures_content()
2444 .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
2445 output_bytes,
2446 duration_ms,
2447 status,
2448 state_effect,
2449 resource_id,
2450 };
2451 self.write_event(&event).await?;
2452 self.total_tool_calls = self.total_tool_calls.saturating_add(1);
2453 self.task_tool_calls = self.task_tool_calls.saturating_add(1);
2454 Ok(())
2455 }
2456
2457 pub(crate) async fn write_skill_call(
2463 &mut self,
2464 turn: u32,
2465 skill_name: String,
2466 origin: &LockOrigin,
2467 output_bytes: u64,
2468 duration_ms: u64,
2469 status: String,
2470 ) -> std::io::Result<()> {
2471 let event = SkillCallEvent {
2472 event_type: "skill_call",
2473 event_id: new_event_id(),
2474 parent_id: self.turn_parent(),
2475 session_id: self.session_id.clone(),
2476 timestamp: timestamp_ms(),
2477 turn,
2478 task_id: self.active_task_id.clone(),
2479 skill_name,
2480 output_bytes,
2481 duration_ms,
2482 status,
2483 origin: origin.as_str(),
2484 trust: crate::origin::artifact_trust(origin).as_str(),
2485 };
2486 self.write_event(&event).await
2487 }
2489
2490 #[allow(clippy::too_many_arguments)]
2493 pub(crate) async fn write_shell(
2494 &mut self,
2495 turn: u32,
2496 binary: String,
2497 command: String,
2498 exit_code: i32,
2499 stdout_bytes: u64,
2500 stderr_bytes: u64,
2501 duration_ms: u64,
2502 resource_limit: Option<String>,
2503 ) -> std::io::Result<()> {
2504 let event = ShellEvent {
2505 event_type: "shell",
2506 event_id: new_event_id(),
2507 parent_id: self.turn_parent(),
2508 session_id: self.session_id.clone(),
2509 timestamp: timestamp_ms(),
2510 turn,
2511 task_id: self.active_task_id.clone(),
2512 binary,
2513 command,
2514 exit_code,
2515 stdout_bytes,
2516 stderr_bytes,
2517 duration_ms,
2518 resource_limit,
2519 };
2520 self.write_event(&event).await?;
2521 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
2522 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
2523 Ok(())
2524 }
2525
2526 pub(crate) async fn write_shell_detached(
2533 &mut self,
2534 turn: u32,
2535 work_id: &str,
2536 binary: &str,
2537 command: &str,
2538 grace_ms: u64,
2539 ) -> std::io::Result<()> {
2540 let event = ShellDetachedEvent {
2541 event_type: "shell_detached",
2542 event_id: new_event_id(),
2543 parent_id: self.turn_parent(),
2544 session_id: self.session_id.clone(),
2545 timestamp: timestamp_ms(),
2546 turn,
2547 task_id: self.active_task_id.clone(),
2548 work_id: work_id.to_string(),
2549 binary: binary.to_string(),
2550 command: command.to_string(),
2551 grace_ms,
2552 };
2553 self.write_event(&event).await?;
2554 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
2555 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
2556 Ok(())
2557 }
2558
2559 #[allow(clippy::too_many_arguments)]
2562 pub(crate) async fn write_shell_completed(
2563 &mut self,
2564 work_id: &str,
2565 binary: &str,
2566 command: &str,
2567 exit_code: i32,
2568 duration_ms: u64,
2569 output_path: &str,
2570 output_bytes: u64,
2571 resource_limit: Option<String>,
2572 status: &str,
2573 completion_task_id: &str,
2574 ) -> std::io::Result<()> {
2575 let event = ShellCompletedEvent {
2576 event_type: "shell_completed",
2577 event_id: new_event_id(),
2578 parent_id: self.session_parent(),
2579 session_id: self.session_id.clone(),
2580 timestamp: timestamp_ms(),
2581 work_id: work_id.to_string(),
2582 binary: binary.to_string(),
2583 command: command.to_string(),
2584 exit_code,
2585 duration_ms,
2586 output_path: output_path.to_string(),
2587 output_bytes,
2588 resource_limit,
2589 status: status.to_string(),
2590 completion_task_id: completion_task_id.to_string(),
2591 };
2592 self.write_event(&event).await
2593 }
2594
2595 pub(crate) async fn write_shell_abandoned(
2602 &mut self,
2603 work_id: &str,
2604 binary: &str,
2605 command: &str,
2606 running_ms: u64,
2607 recovered: Option<(i32, &str, u64)>,
2608 ) -> std::io::Result<()> {
2609 let event = ShellAbandonedEvent {
2610 event_type: "shell_abandoned",
2611 event_id: new_event_id(),
2612 parent_id: self.session_parent(),
2613 session_id: self.session_id.clone(),
2614 timestamp: timestamp_ms(),
2615 work_id: work_id.to_string(),
2616 binary: binary.to_string(),
2617 command: command.to_string(),
2618 running_ms,
2619 exit_code: recovered.map(|(exit_code, _, _)| exit_code),
2620 output_path: recovered.map(|(_, path, _)| path.to_string()),
2621 output_bytes: recovered.map(|(_, _, bytes)| bytes),
2622 };
2623 self.write_event(&event).await
2624 }
2625
2626 pub(crate) async fn write_shell_detach_unrecorded(
2628 &mut self,
2629 turn: u32,
2630 work_id: &str,
2631 binary: &str,
2632 reason: &str,
2633 ) -> std::io::Result<()> {
2634 let event = ShellDetachUnrecordedEvent {
2635 event_type: "shell_detach_unrecorded",
2636 event_id: new_event_id(),
2637 parent_id: self.turn_parent(),
2638 session_id: self.session_id.clone(),
2639 timestamp: timestamp_ms(),
2640 turn,
2641 task_id: self.active_task_id.clone(),
2642 work_id: work_id.to_string(),
2643 binary: binary.to_string(),
2644 reason: reason.to_string(),
2645 };
2646 self.write_event(&event).await
2647 }
2648
2649 pub(crate) async fn write_a2a_task_received(
2650 &mut self,
2651 task_id: &str,
2652 context_id: &str,
2653 message_id: &str,
2654 traceparent_from_caller: Option<&str>,
2655 caller_member: Option<&str>,
2656 ) -> std::io::Result<()> {
2657 let event = A2aTaskReceivedEvent {
2658 event_type: "a2a_task_received",
2659 event_id: new_event_id(),
2660 parent_id: self.session_parent(),
2661 session_id: self.session_id.clone(),
2662 timestamp: timestamp_ms(),
2663 task_id: task_id.to_string(),
2664 context_id: context_id.to_string(),
2665 message_id: message_id.to_string(),
2666 traceparent_from_caller: traceparent_from_caller.map(str::to_string),
2667 caller_member: caller_member.map(str::to_string),
2668 };
2669 self.write_event(&event).await
2670 }
2671
2672 pub(crate) async fn write_a2a_send(
2673 &mut self,
2674 peer_url: &str,
2675 message_id: &str,
2676 task_id: &str,
2677 context_id: &str,
2678 traceparent: Option<&str>,
2679 trust: TrustClass,
2680 ) -> std::io::Result<()> {
2681 let event = A2aSendEvent {
2682 event_type: "a2a_send",
2683 event_id: new_event_id(),
2684 parent_id: self.session_parent(),
2685 session_id: self.session_id.clone(),
2686 timestamp: timestamp_ms(),
2687 peer_url: peer_url.to_string(),
2688 message_id: message_id.to_string(),
2689 task_id: task_id.to_string(),
2690 context_id: context_id.to_string(),
2691 traceparent: traceparent.map(str::to_string),
2692 trust: trust.as_str().to_string(),
2693 };
2694 self.write_event(&event).await
2695 }
2696
2697 pub(crate) async fn write_artifact_pulled(
2702 &mut self,
2703 artifact: &InstalledArtifactSummary,
2704 ) -> std::io::Result<()> {
2705 let session = match &artifact.origin {
2706 LockOrigin::Runtime { session } => Some(session.clone()),
2707 LockOrigin::Operator => None,
2708 };
2709 let event = ArtifactPulledEvent {
2710 event_type: "artifact_pulled",
2711 event_id: new_event_id(),
2712 parent_id: self.session_parent(),
2713 session_id: self.session_id.clone(),
2714 timestamp: timestamp_ms(),
2715 name: artifact.name.clone(),
2716 version: artifact.version.clone(),
2717 runtime: artifact.runtime.as_str(),
2718 origin: artifact.origin.as_str(),
2719 session,
2720 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2721 };
2722 self.write_event(&event).await
2723 }
2724
2725 pub(crate) async fn write_task_start(
2726 &mut self,
2727 task_id: &str,
2728 context_id: &str,
2729 source: &str,
2730 provenance: TaskProvenance,
2731 message_parts_bytes: u64,
2732 ) -> std::io::Result<()> {
2733 self.task_turns = 0;
2735 self.task_input_tokens = 0;
2736 self.task_output_tokens = 0;
2737 self.task_tool_calls = 0;
2738 self.task_shell_calls = 0;
2739 self.task_agent_turns = 0;
2740 self.task_hook_inferences = 0;
2741 self.task_start_instant = Some(Instant::now());
2742 self.active_task_id = Some(task_id.to_string());
2743
2744 let event_id = new_event_id();
2745 self.task_event_id = Some(event_id.clone());
2746 self.turn_event_id = None;
2747
2748 let event = TaskStartEvent {
2749 event_type: "task_start",
2750 event_id: event_id.clone(),
2751 parent_id: self.session_parent(),
2752 session_id: self.session_id.clone(),
2753 timestamp: timestamp_ms(),
2754 task_id: task_id.to_string(),
2755 context_id: context_id.to_string(),
2756 source: source.to_string(),
2757 origin: provenance.origin().as_str().to_string(),
2758 trust: provenance.trust().as_str().to_string(),
2759 lane: TaskLane::for_origin(provenance.origin())
2762 .as_str()
2763 .to_string(),
2764 message_parts_bytes,
2765 };
2766 self.write_event(&event).await
2767 }
2768
2769 pub(crate) async fn write_task_end(
2770 &mut self,
2771 task_id: &str,
2772 exit_status: &str,
2773 reopen_count: u32,
2774 ) -> std::io::Result<()> {
2775 let duration_ms = self
2776 .task_start_instant
2777 .take()
2778 .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
2779 .unwrap_or(0);
2780
2781 let event = TaskEndEvent {
2782 event_type: "task_end",
2783 event_id: new_event_id(),
2784 parent_id: self.task_parent(),
2785 session_id: self.session_id.clone(),
2786 timestamp: timestamp_ms(),
2787 task_id: task_id.to_string(),
2788 exit_status: exit_status.to_string(),
2789 duration_ms,
2790 turns: self.task_turns,
2791 input_tokens: self.task_input_tokens,
2792 output_tokens: self.task_output_tokens,
2793 tool_calls: self.task_tool_calls,
2794 shell_calls: self.task_shell_calls,
2795 reopen_count,
2796 };
2797 self.active_task_id = None;
2798 self.task_event_id = None;
2799 self.turn_event_id = None;
2800 self.task_agent_turns = 0;
2801 self.write_event(&event).await
2802 }
2803
2804 pub(crate) async fn write_task_canceled(
2810 &mut self,
2811 task_id: &str,
2812 turn: Option<u32>,
2813 phase: &str,
2814 detached_work_ids: Vec<String>,
2815 delegation_ids: Vec<String>,
2816 ) -> std::io::Result<()> {
2817 let event = TaskCanceledEvent {
2818 event_type: "task_canceled",
2819 event_id: new_event_id(),
2820 parent_id: self.task_parent(),
2821 session_id: self.session_id.clone(),
2822 timestamp: timestamp_ms(),
2823 task_id: task_id.to_string(),
2824 turn,
2825 phase: phase.to_string(),
2826 detached_work_ids,
2827 delegation_ids,
2828 };
2829 self.write_event(&event).await
2830 }
2831
2832 pub(crate) async fn write_task_rejected(
2838 &mut self,
2839 task_id: &str,
2840 context_id: &str,
2841 source: &str,
2842 cause: &str,
2843 reason: &str,
2844 ) -> std::io::Result<()> {
2845 let event = TaskRejectedEvent {
2846 event_type: "task_rejected",
2847 event_id: new_event_id(),
2848 parent_id: self.task_parent(),
2849 session_id: self.session_id.clone(),
2850 timestamp: timestamp_ms(),
2851 task_id: task_id.to_string(),
2852 context_id: context_id.to_string(),
2853 source: source.to_string(),
2854 cause: cause.to_string(),
2855 reason: reason.to_string(),
2856 };
2857 self.write_event(&event).await
2858 }
2859
2860 pub(crate) async fn write_task_failed(
2866 &mut self,
2867 turn: Option<u32>,
2868 cause: &str,
2869 reason: &str,
2870 ) -> std::io::Result<()> {
2871 let reason = crate::agent::truncate_on_char_boundary(reason, MAX_TASK_FAILURE_REASON_BYTES)
2872 .to_string();
2873 let event = TaskFailedEvent {
2874 event_type: "task_failed",
2875 event_id: new_event_id(),
2876 parent_id: self.task_parent(),
2877 session_id: self.session_id.clone(),
2878 timestamp: timestamp_ms(),
2879 task_id: self.active_task_id.clone(),
2880 turn,
2881 cause: cause.to_string(),
2882 reason: reason.clone(),
2883 };
2884 self.task_failures_written += 1;
2885 self.last_task_failure = Some(reason);
2886 self.write_event(&event).await
2887 }
2888
2889 pub(crate) fn last_task_failure(&self) -> Option<&str> {
2891 self.last_task_failure.as_deref()
2892 }
2893
2894 pub(crate) fn task_failures_written(&self) -> u64 {
2897 self.task_failures_written
2898 }
2899
2900 pub(crate) async fn write_task_reopened(
2904 &mut self,
2905 task_id: &str,
2906 hook_name: &str,
2907 reason: &str,
2908 reopen_number: u32,
2909 attempt_context: &'static str,
2910 turns_remaining: u32,
2911 ) -> std::io::Result<()> {
2912 let event = TaskReopenedEvent {
2913 event_type: "task_reopened",
2914 event_id: new_event_id(),
2915 parent_id: self.task_parent(),
2916 session_id: self.session_id.clone(),
2917 timestamp: timestamp_ms(),
2918 task_id: task_id.to_string(),
2919 hook_name: hook_name.to_string(),
2920 reason: reason.to_string(),
2921 reopen_number,
2922 attempt_context,
2923 turns_remaining,
2924 };
2925 self.write_event(&event).await
2926 }
2927
2928 pub(crate) async fn write_task_continued(
2932 &mut self,
2933 task_id: &str,
2934 continuation_number: u32,
2935 member_calls: &[String],
2936 delegations: &[String],
2937 waited_ms: u64,
2938 turns_remaining: u32,
2939 ) -> std::io::Result<()> {
2940 let event = TaskContinuedEvent {
2941 event_type: "task_continued",
2942 event_id: new_event_id(),
2943 parent_id: self.task_parent(),
2944 session_id: self.session_id.clone(),
2945 timestamp: timestamp_ms(),
2946 task_id: task_id.to_string(),
2947 continuation_number,
2948 member_calls,
2949 delegations,
2950 waited_ms,
2951 turns_remaining,
2952 };
2953 self.write_event(&event).await
2954 }
2955
2956 pub(crate) fn task_turns(&self) -> u32 {
2966 self.task_turns
2967 }
2968
2969 pub(crate) fn task_hook_inferences(&self) -> u32 {
2971 self.task_hook_inferences
2972 }
2973
2974 pub(crate) fn task_agent_turns(&self) -> u32 {
2978 self.task_agent_turns
2979 }
2980
2981 pub(crate) async fn write_compaction(
2982 &mut self,
2983 turn: u32,
2984 tokens_before: u64,
2985 tokens_after: u64,
2986 ) -> std::io::Result<()> {
2987 let event = CompactionEvent {
2988 event_type: "compaction",
2989 event_id: new_event_id(),
2990 parent_id: self.turn_parent(),
2991 session_id: self.session_id.clone(),
2992 timestamp: timestamp_ms(),
2993 turn,
2994 task_id: self.active_task_id.clone(),
2995 tokens_before,
2996 tokens_after,
2997 };
2998 self.write_event(&event).await
2999 }
3000
3001 pub(crate) async fn write_compaction_declined(
3006 &mut self,
3007 turn: u32,
3008 tokens: u64,
3009 reason: &str,
3010 ) -> std::io::Result<()> {
3011 let event = CompactionDeclinedEvent {
3012 event_type: "compaction_declined",
3013 event_id: new_event_id(),
3014 parent_id: self.turn_parent(),
3015 session_id: self.session_id.clone(),
3016 timestamp: timestamp_ms(),
3017 turn,
3018 task_id: self.active_task_id.clone(),
3019 tokens,
3020 reason: reason.to_string(),
3021 };
3022 self.write_event(&event).await
3023 }
3024
3025 #[allow(clippy::too_many_arguments)]
3032 pub(crate) async fn write_context_seed(
3033 &mut self,
3034 hook_name: &str,
3035 tokens: u64,
3036 proposed_tokens: u64,
3037 budget_tokens: u64,
3038 outcome: &str,
3039 reason: Option<&str>,
3040 message_ids: Vec<String>,
3041 ) -> std::io::Result<()> {
3042 let event = ContextSeedEvent {
3043 event_type: "context_seed",
3044 event_id: new_event_id(),
3045 parent_id: self.task_parent(),
3046 session_id: self.session_id.clone(),
3047 timestamp: timestamp_ms(),
3048 task_id: self.active_task_id.clone(),
3049 hook_name: hook_name.to_string(),
3050 tokens,
3051 proposed_tokens,
3052 budget_tokens,
3053 outcome: outcome.to_string(),
3054 reason: reason.map(str::to_string),
3055 message_ids,
3056 };
3057 self.write_event(&event).await
3058 }
3059
3060 pub(crate) async fn write_retention(
3066 &mut self,
3067 store: &str,
3068 reason: &str,
3069 targets: Vec<String>,
3070 messages_dropped: Option<u64>,
3071 ) -> std::io::Result<()> {
3072 let event = RetentionEvent {
3073 event_type: "retention",
3074 event_id: new_event_id(),
3075 parent_id: self.session_parent(),
3076 session_id: self.session_id.clone(),
3077 timestamp: timestamp_ms(),
3078 store: store.to_string(),
3079 reason: reason.to_string(),
3080 removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
3081 targets,
3082 messages_dropped,
3083 };
3084 self.write_event(&event).await
3085 }
3086
3087 pub(crate) async fn write_call_denied(
3091 &mut self,
3092 turn: u32,
3093 event: &str,
3094 hook_name: &str,
3095 target: &str,
3096 reason: &str,
3097 ) -> std::io::Result<()> {
3098 let event = CallDeniedEvent {
3099 event_type: "call_denied",
3100 event_id: new_event_id(),
3101 parent_id: self.turn_parent(),
3102 session_id: self.session_id.clone(),
3103 timestamp: timestamp_ms(),
3104 turn,
3105 event: event.to_string(),
3106 hook_name: hook_name.to_string(),
3107 target: target.to_string(),
3108 reason: reason.to_string(),
3109 };
3110 self.write_event(&event).await
3111 }
3112
3113 pub(crate) async fn write_tool_input_refused(
3118 &mut self,
3119 turn: u32,
3120 tool_name: &str,
3121 tool_call_id: Option<&str>,
3122 missing: &[String],
3123 reason: &str,
3124 ) -> std::io::Result<()> {
3125 let event = ToolInputRefusedEvent {
3126 event_type: "tool_input_refused",
3127 event_id: new_event_id(),
3128 parent_id: self.turn_parent(),
3129 session_id: self.session_id.clone(),
3130 timestamp: timestamp_ms(),
3131 turn,
3132 tool_name: tool_name.to_string(),
3133 tool_call_id: tool_call_id.filter(|id| !id.is_empty()).map(str::to_string),
3134 missing: missing.to_vec(),
3135 reason: reason.to_string(),
3136 };
3137 self.write_event(&event).await
3138 }
3139
3140 pub(crate) async fn write_spend_ceiling_reached(
3143 &mut self,
3144 turn: u32,
3145 refusal: &crate::spend::SpendRefusal,
3146 origin: Option<&str>,
3147 ) -> std::io::Result<()> {
3148 let event = SpendCeilingReachedEvent {
3149 event_type: "spend_ceiling_reached",
3150 event_id: new_event_id(),
3151 parent_id: self.turn_parent(),
3152 session_id: self.session_id.clone(),
3153 timestamp: timestamp_ms(),
3154 turn,
3155 task_id: self.active_task_id.clone(),
3156 refusal,
3157 origin,
3158 };
3159 self.write_event(&event).await
3160 }
3161
3162 #[allow(clippy::too_many_arguments)]
3166 pub(crate) async fn write_protected_path_denied(
3167 &mut self,
3168 turn: u32,
3169 call: &str,
3170 target: &str,
3171 path: &str,
3172 rule: &str,
3173 signal: &str,
3174 reason: &str,
3175 ) -> std::io::Result<()> {
3176 let event = ProtectedPathDeniedEvent {
3177 event_type: "protected_path_denied",
3178 event_id: new_event_id(),
3179 parent_id: self.turn_parent(),
3180 session_id: self.session_id.clone(),
3181 timestamp: timestamp_ms(),
3182 turn,
3183 call: call.to_string(),
3184 target: target.to_string(),
3185 path: path.to_string(),
3186 rule: rule.to_string(),
3187 signal: signal.to_string(),
3188 reason: reason.to_string(),
3189 };
3190 self.write_event(&event).await
3191 }
3192
3193 pub(crate) async fn write_hook_dispatch_error(
3199 &mut self,
3200 hook_name: &str,
3201 event: &str,
3202 arm: &str,
3203 ) -> std::io::Result<()> {
3204 let event = HookDispatchErrorEvent {
3205 event_type: "hook_dispatch_error",
3206 event_id: new_event_id(),
3207 parent_id: self.session_parent(),
3208 session_id: self.session_id.clone(),
3209 timestamp: timestamp_ms(),
3210 hook_name: hook_name.to_string(),
3211 event: event.to_string(),
3212 arm: arm.to_string(),
3213 };
3214 self.write_event(&event).await
3215 }
3216
3217 pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
3227 let event = HarnessStartEvent {
3228 event_type: "harness_start",
3229 event_id: new_event_id(),
3230 parent_id: self.task_parent(),
3231 session_id: self.session_id.clone(),
3232 timestamp: timestamp_ms(),
3233 task_id: self.active_task_id.clone(),
3234 start,
3235 };
3236 self.write_event(&event).await
3237 }
3238
3239 pub(crate) async fn write_harness_warning(
3241 &mut self,
3242 code: &str,
3243 message: &str,
3244 ) -> std::io::Result<()> {
3245 let event = HarnessWarningEvent {
3246 event_type: "harness_warning",
3247 event_id: new_event_id(),
3248 parent_id: self.task_parent(),
3249 session_id: self.session_id.clone(),
3250 timestamp: timestamp_ms(),
3251 task_id: self.active_task_id.clone(),
3252 code: code.to_string(),
3253 message: message.to_string(),
3254 };
3255 self.write_event(&event).await
3256 }
3257
3258 pub(crate) async fn write_harness_session(
3260 &mut self,
3261 harness_session_id: &str,
3262 auth: &str,
3263 model: Option<&str>,
3264 ) -> std::io::Result<()> {
3265 let event = HarnessSessionEvent {
3266 event_type: "harness_session",
3267 event_id: new_event_id(),
3268 parent_id: self.task_parent(),
3269 session_id: self.session_id.clone(),
3270 timestamp: timestamp_ms(),
3271 task_id: self.active_task_id.clone(),
3272 harness_session_id: harness_session_id.to_string(),
3273 auth: auth.to_string(),
3274 model: model.map(str::to_string),
3275 };
3276 self.write_event(&event).await
3277 }
3278
3279 pub(crate) async fn write_harness_session_forgotten(
3281 &mut self,
3282 context_id: &str,
3283 harness_session_id: &str,
3284 requested_by: &str,
3285 ) -> std::io::Result<()> {
3286 let event = HarnessSessionForgottenEvent {
3287 event_type: "harness_session_forgotten",
3288 event_id: new_event_id(),
3289 parent_id: self.task_parent(),
3290 session_id: self.session_id.clone(),
3291 timestamp: timestamp_ms(),
3292 task_id: self.active_task_id.clone(),
3293 context_id: context_id.to_string(),
3294 harness_session_id: harness_session_id.to_string(),
3295 requested_by: requested_by.to_string(),
3296 };
3297 self.write_event(&event).await
3298 }
3299
3300 pub(crate) async fn write_harness_retry(
3302 &mut self,
3303 attempt: u32,
3304 reason: &str,
3305 ) -> std::io::Result<()> {
3306 let event = HarnessRetryEvent {
3307 event_type: "harness_retry",
3308 event_id: new_event_id(),
3309 parent_id: self.task_parent(),
3310 session_id: self.session_id.clone(),
3311 timestamp: timestamp_ms(),
3312 task_id: self.active_task_id.clone(),
3313 attempt,
3314 reason: reason.to_string(),
3315 };
3316 self.write_event(&event).await
3317 }
3318
3319 pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
3322 let event = HarnessNoteEvent {
3323 event_type: "harness_note",
3324 event_id: new_event_id(),
3325 parent_id: self.task_parent(),
3326 session_id: self.session_id.clone(),
3327 timestamp: timestamp_ms(),
3328 task_id: self.active_task_id.clone(),
3329 text: text.to_string(),
3330 };
3331 self.write_event(&event).await
3332 }
3333
3334 pub(crate) async fn write_harness_failed(
3337 &mut self,
3338 kind: &str,
3339 message: &str,
3340 source: &str,
3341 ) -> std::io::Result<()> {
3342 let event = HarnessFailedEvent {
3343 event_type: "harness_failed",
3344 event_id: new_event_id(),
3345 parent_id: self.task_parent(),
3346 session_id: self.session_id.clone(),
3347 timestamp: timestamp_ms(),
3348 task_id: self.active_task_id.clone(),
3349 kind: kind.to_string(),
3350 message: message.to_string(),
3351 source: source.to_string(),
3352 };
3353 self.write_event(&event).await
3354 }
3355
3356 pub(crate) async fn write_harness_interrupt(
3359 &mut self,
3360 method: &str,
3361 delivered: bool,
3362 grace_ms: u64,
3363 ) -> std::io::Result<()> {
3364 let event = HarnessInterruptEvent {
3365 event_type: "harness_interrupt",
3366 event_id: new_event_id(),
3367 parent_id: self.task_parent(),
3368 session_id: self.session_id.clone(),
3369 timestamp: timestamp_ms(),
3370 task_id: self.active_task_id.clone(),
3371 method: method.to_string(),
3372 delivered,
3373 grace_ms,
3374 };
3375 self.write_event(&event).await
3376 }
3377
3378 pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
3380 let event = HarnessExitEvent {
3381 event_type: "harness_exit",
3382 event_id: new_event_id(),
3383 parent_id: self.task_parent(),
3384 session_id: self.session_id.clone(),
3385 timestamp: timestamp_ms(),
3386 task_id: self.active_task_id.clone(),
3387 exit,
3388 };
3389 self.write_event(&event).await
3390 }
3391
3392 pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
3393 let duration_ms = self
3394 .session_start_time
3395 .elapsed()
3396 .as_millis()
3397 .try_into()
3398 .unwrap_or(u64::MAX);
3399 let event = SessionEndEvent {
3400 event_type: "session_end",
3401 event_id: new_event_id(),
3402 parent_id: self.session_parent(),
3403 session_id: self.session_id.clone(),
3404 timestamp: timestamp_ms(),
3405 total_turns: self.total_turns,
3406 total_input_tokens: self.total_input_tokens,
3407 total_output_tokens: self.total_output_tokens,
3408 total_tool_calls: self.total_tool_calls,
3409 total_shell_calls: self.total_shell_calls,
3410 duration_ms,
3411 exit_status: exit_status.to_string(),
3412 };
3413 self.write_event(&event).await?;
3414 self.session_ended = true;
3415 Ok(())
3416 }
3417
3418 pub(crate) async fn write_session_end_if_not_ended(
3421 &mut self,
3422 exit_status: &str,
3423 ) -> std::io::Result<()> {
3424 if self.session_started && !self.session_ended {
3425 self.write_session_end(exit_status).await?;
3426 }
3427 Ok(())
3428 }
3429
3430 pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
3431 self.writer.flush().await
3432 }
3433
3434 async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
3442 let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
3443 line.push('\n');
3444 self.writer.write_all(line.as_bytes()).await?;
3445 self.writer.flush().await
3446 }
3447}
3448
3449#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3451pub(crate) struct SessionGateway {
3452 pub(crate) artifact: String,
3454 pub(crate) host: String,
3456 pub(crate) credential_source: &'static str,
3458 pub(crate) metered: bool,
3461}
3462
3463#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3466pub(crate) struct RuntimeArtifact {
3467 name: String,
3468 version: String,
3469 origin: &'static str,
3471 session: String,
3473 trust: &'static str,
3475}
3476
3477#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3479pub(crate) struct SessionControl {
3480 pub(crate) settings: Vec<&'static str>,
3482 pub(crate) secrets: Vec<String>,
3484 #[serde(skip_serializing_if = "Vec::is_empty")]
3487 pub(crate) agent_settings: Vec<&'static str>,
3488}
3489
3490#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3493pub(crate) struct SessionInferenceChoice {
3494 pub(crate) name: String,
3495 pub(crate) driver: String,
3496 pub(crate) model: String,
3497 pub(crate) credential_source: &'static str,
3500 pub(crate) available: bool,
3503}
3504
3505pub(crate) struct ControlChange<'a> {
3509 pub(crate) event_id: String,
3510 pub(crate) principal: crate::control_plane::Principal,
3512 pub(crate) token_id: Option<&'a str>,
3515 pub(crate) kind: &'static str,
3517 pub(crate) name: &'a str,
3518 pub(crate) action: &'static str,
3520 pub(crate) previous: Option<Value>,
3522 pub(crate) value: Option<Value>,
3523 pub(crate) applies_from: Option<&'static str>,
3525 pub(crate) replaced: Option<bool>,
3527}
3528
3529#[derive(Serialize)]
3532struct ControlChangeEvent<'a> {
3533 event_type: &'static str,
3534 event_id: String,
3535 parent_id: Option<String>,
3536 session_id: String,
3537 timestamp: u64,
3538 principal: &'static str,
3539 #[serde(skip_serializing_if = "Option::is_none")]
3540 token_id: Option<&'a str>,
3541 kind: &'static str,
3542 name: &'a str,
3543 action: &'static str,
3544 #[serde(skip_serializing_if = "Option::is_none")]
3545 previous: Option<Value>,
3546 #[serde(skip_serializing_if = "Option::is_none")]
3547 value: Option<Value>,
3548 #[serde(skip_serializing_if = "Option::is_none")]
3549 applies_from: Option<&'static str>,
3550 #[serde(skip_serializing_if = "Option::is_none")]
3551 replaced: Option<bool>,
3552}
3553
3554#[derive(Serialize)]
3558struct ControlRefusedEvent<'a> {
3559 event_type: &'static str,
3560 event_id: String,
3561 parent_id: Option<String>,
3562 session_id: String,
3563 timestamp: u64,
3564 principal: &'static str,
3565 status: u16,
3566 reason: &'static str,
3567 #[serde(skip_serializing_if = "Option::is_none")]
3568 kind: Option<&'static str>,
3569 #[serde(skip_serializing_if = "Option::is_none")]
3570 name: Option<&'a str>,
3571 #[serde(skip_serializing_if = "Option::is_none")]
3572 token_id: Option<&'a str>,
3573}
3574
3575#[derive(Serialize)]
3577struct ControlAppliedEvent<'a> {
3578 event_type: &'static str,
3579 event_id: String,
3580 parent_id: Option<String>,
3581 session_id: String,
3582 timestamp: u64,
3583 turn: u32,
3584 task_id: Option<String>,
3585 name: &'a str,
3586 value: Value,
3587 change_id: &'a str,
3588}
3589
3590#[derive(Serialize)]
3592struct ToolsRefreshedEvent<'a> {
3593 event_type: &'static str,
3594 event_id: String,
3595 parent_id: Option<String>,
3596 session_id: String,
3597 timestamp: u64,
3598 turn: u32,
3599 task_id: Option<String>,
3600 trigger: &'a str,
3601 added: &'a [String],
3602 removed: &'a [String],
3603 tools: &'a [String],
3604}
3605
3606#[derive(Serialize)]
3609struct GatewayCredentialEvent<'a> {
3610 event_type: &'static str,
3611 event_id: String,
3612 parent_id: Option<String>,
3613 session_id: String,
3614 timestamp: u64,
3615 artifact: &'a str,
3616 source: &'static str,
3617 #[serde(skip_serializing_if = "Option::is_none")]
3618 credential: Option<&'a str>,
3619 change: &'static str,
3620 #[serde(skip_serializing_if = "Option::is_none")]
3621 trigger: Option<&'static str>,
3622 #[serde(skip_serializing_if = "Option::is_none")]
3623 status: Option<u16>,
3624 #[serde(skip_serializing_if = "Option::is_none")]
3625 retried: Option<bool>,
3626 #[serde(skip_serializing_if = "Option::is_none")]
3627 reason: Option<&'static str>,
3628}
3629
3630#[derive(Serialize)]
3634struct InferenceCredentialEvent<'a> {
3635 event_type: &'static str,
3636 event_id: String,
3637 parent_id: Option<String>,
3638 session_id: String,
3639 timestamp: u64,
3640 source: &'static str,
3641 #[serde(skip_serializing_if = "Option::is_none")]
3642 credential: Option<&'a str>,
3643 change: &'static str,
3644 #[serde(skip_serializing_if = "Option::is_none")]
3645 trigger: Option<&'static str>,
3646 #[serde(skip_serializing_if = "Option::is_none")]
3647 status: Option<u16>,
3648 #[serde(skip_serializing_if = "Option::is_none")]
3649 retried: Option<bool>,
3650 #[serde(skip_serializing_if = "Option::is_none")]
3651 reason: Option<&'static str>,
3652}
3653
3654#[allow(clippy::type_complexity)]
3657fn credential_change_fields(
3658 change: CredentialChange,
3659) -> (
3660 &'static str,
3661 Option<&'static str>,
3662 Option<u16>,
3663 Option<bool>,
3664 Option<&'static str>,
3665) {
3666 match change {
3667 CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
3668 CredentialChange::Rejected { status, retried } => {
3669 ("rejected", None, Some(status), Some(retried), None)
3670 }
3671 CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
3672 }
3673}
3674
3675pub struct ResourceTraceAppender {
3690 file: tokio::sync::Mutex<File>,
3692 session_id: String,
3693 session_event_id: String,
3698}
3699
3700impl ResourceTraceAppender {
3701 pub async fn open(
3702 workdir: &Path,
3703 session_id: String,
3704 session_event_id: String,
3705 ) -> std::io::Result<Self> {
3706 let file = OpenOptions::new()
3707 .create(true)
3708 .append(true)
3709 .open(workdir.join("trace.jsonl"))
3710 .await?;
3711 Ok(Self {
3712 file: tokio::sync::Mutex::new(file),
3713 session_id,
3714 session_event_id,
3715 })
3716 }
3717
3718 #[allow(clippy::too_many_arguments)]
3722 pub(crate) async fn write_resource_list(
3723 &self,
3724 root: &str,
3725 entry_count: usize,
3726 total_bytes: u64,
3727 generation: u64,
3728 containment_achieved: ContainmentClass,
3729 outcome: &str,
3730 reason: Option<String>,
3731 ) {
3732 let event = ResourceListEvent {
3733 event_type: "resource_list",
3734 event_id: new_event_id(),
3735 parent_id: Some(self.session_event_id.clone()),
3736 session_id: self.session_id.clone(),
3737 timestamp: timestamp_ms(),
3738 root: root.to_string(),
3739 entry_count,
3740 total_bytes,
3741 generation,
3742 containment_achieved,
3743 outcome: outcome.to_string(),
3744 reason,
3745 };
3746 self.append(&event).await;
3747 }
3748
3749 #[allow(clippy::too_many_arguments)]
3752 pub(crate) async fn write_resource_read(
3753 &self,
3754 path: &str,
3755 outcome: &str,
3756 bytes: Option<u64>,
3757 sha256: Option<String>,
3758 generation: u64,
3759 containment_achieved: ContainmentClass,
3760 reason: Option<String>,
3761 ) {
3762 let event = ResourceReadEvent {
3763 event_type: "resource_read",
3764 event_id: new_event_id(),
3765 parent_id: Some(self.session_event_id.clone()),
3766 session_id: self.session_id.clone(),
3767 timestamp: timestamp_ms(),
3768 path: path.to_string(),
3769 outcome: outcome.to_string(),
3770 bytes,
3771 sha256,
3772 generation,
3773 containment_achieved,
3774 reason,
3775 };
3776 self.append(&event).await;
3777 }
3778
3779 pub(crate) async fn write_formation_ended(&self, formation_id: &crate::FormationId) {
3782 let event = FormationEndedEvent {
3783 event_type: "formation_ended",
3784 event_id: new_event_id(),
3785 parent_id: Some(self.session_event_id.clone()),
3786 session_id: self.session_id.clone(),
3787 timestamp: timestamp_ms(),
3788 formation_id: formation_id.as_str().to_string(),
3789 };
3790 self.append(&event).await;
3791 }
3792
3793 pub(crate) async fn write_spawner_ended(
3797 &self,
3798 spawned_by: Option<&str>,
3799 delegation_id: Option<&str>,
3800 ) {
3801 let event = SpawnerEndedEvent {
3802 event_type: "spawner_ended",
3803 event_id: new_event_id(),
3804 parent_id: Some(self.session_event_id.clone()),
3805 session_id: self.session_id.clone(),
3806 timestamp: timestamp_ms(),
3807 spawned_by: spawned_by.map(str::to_string),
3808 delegation_id: delegation_id.map(str::to_string),
3809 };
3810 self.append(&event).await;
3811 }
3812
3813 #[allow(clippy::too_many_arguments)]
3816 pub(crate) async fn write_peer_handle_mint(
3817 &self,
3818 handle_id: Option<String>,
3819 path: &str,
3820 audience: &str,
3821 expires_at_ms: Option<u64>,
3822 outcome: &str,
3823 reason: Option<String>,
3824 ) {
3825 let event = PeerHandleMintEvent {
3826 event_type: "peer_handle_mint",
3827 event_id: new_event_id(),
3828 parent_id: Some(self.session_event_id.clone()),
3829 session_id: self.session_id.clone(),
3830 timestamp: timestamp_ms(),
3831 handle_id,
3832 path: path.to_string(),
3833 audience: audience.to_string(),
3834 expires_at_ms,
3835 outcome: outcome.to_string(),
3836 reason,
3837 };
3838 self.append(&event).await;
3839 }
3840
3841 #[allow(clippy::too_many_arguments)]
3844 pub(crate) async fn write_peer_handle_redeem(
3845 &self,
3846 handle_id: &str,
3847 path: Option<String>,
3848 generation: u64,
3849 audience_asserted: Option<String>,
3850 bytes: Option<u64>,
3851 sha256: Option<String>,
3852 outcome: &str,
3853 reason: Option<String>,
3854 ) {
3855 let event = PeerHandleRedeemEvent {
3856 event_type: "peer_handle_redeem",
3857 event_id: new_event_id(),
3858 parent_id: Some(self.session_event_id.clone()),
3859 session_id: self.session_id.clone(),
3860 timestamp: timestamp_ms(),
3861 handle_id: handle_id.to_string(),
3862 path,
3863 generation,
3864 audience_asserted,
3865 bytes,
3866 sha256,
3867 outcome: outcome.to_string(),
3868 reason,
3869 };
3870 self.append(&event).await;
3871 }
3872
3873 #[allow(clippy::too_many_arguments)]
3875 pub(crate) async fn write_peer_file_fetch(
3876 &self,
3877 peer: &str,
3878 handle_id: &str,
3879 stored_path: Option<String>,
3880 bytes: Option<u64>,
3881 sha256: Option<String>,
3882 outcome: &str,
3883 reason: Option<String>,
3884 ) {
3885 let event = PeerFileFetchEvent {
3886 event_type: "peer_file_fetch",
3887 event_id: new_event_id(),
3888 parent_id: Some(self.session_event_id.clone()),
3889 session_id: self.session_id.clone(),
3890 timestamp: timestamp_ms(),
3891 peer: peer.to_string(),
3892 handle_id: handle_id.to_string(),
3893 stored_path,
3894 bytes,
3895 sha256,
3896 outcome: outcome.to_string(),
3897 reason,
3898 };
3899 self.append(&event).await;
3900 }
3901
3902 pub(crate) async fn write_delegation_start(
3907 &self,
3908 delegation_id: &str,
3909 capsule: &str,
3910 version: &str,
3911 child_session_id: &str,
3912 child_workdir: &str,
3913 ) {
3914 let event = DelegationStartEvent::new(
3915 &self.session_id,
3916 &self.session_event_id,
3917 delegation_id,
3918 capsule,
3919 version,
3920 child_session_id,
3921 child_workdir,
3922 );
3923 self.append(&event).await;
3924 }
3925
3926 #[allow(clippy::too_many_arguments)]
3930 pub(crate) async fn write_delegation(
3931 &self,
3932 capsule: &str,
3933 version: &str,
3934 delegation_id: Option<String>,
3935 child_session_id: Option<String>,
3936 duration_ms: u64,
3937 outcome: &str,
3938 reason: Option<String>,
3939 ) {
3940 let event = DelegationEvent::new(
3941 &self.session_id,
3942 &self.session_event_id,
3943 capsule,
3944 version,
3945 delegation_id,
3946 child_session_id,
3947 duration_ms,
3948 outcome,
3949 reason,
3950 );
3951 self.append(&event).await;
3952 }
3953
3954 pub(crate) async fn write_member_call_start(
3956 &self,
3957 task_id: &str,
3958 call_id: &str,
3959 member: &str,
3960 member_task_id: &str,
3961 ) {
3962 let event = self.member_call_start(task_id, call_id, member, member_task_id);
3963 self.append(&event).await;
3964 }
3965
3966 pub(crate) async fn write_member_call_start_if(
3969 &self,
3970 task_id: &str,
3971 call_id: &str,
3972 member: &str,
3973 member_task_id: &str,
3974 admit: impl FnOnce() -> bool,
3975 ) -> bool {
3976 let event = self.member_call_start(task_id, call_id, member, member_task_id);
3977 self.append_if(&event, admit).await
3978 }
3979
3980 fn member_call_start(
3981 &self,
3982 task_id: &str,
3983 call_id: &str,
3984 member: &str,
3985 member_task_id: &str,
3986 ) -> MemberCallStartEvent {
3987 MemberCallStartEvent {
3988 event_type: "member_call_start",
3989 event_id: new_event_id(),
3990 parent_id: Some(self.session_event_id.clone()),
3991 session_id: self.session_id.clone(),
3992 timestamp: timestamp_ms(),
3993 task_id: task_id.to_string(),
3994 call_id: call_id.to_string(),
3995 member: member.to_string(),
3996 member_task_id: member_task_id.to_string(),
3997 }
3998 }
3999
4000 pub(crate) async fn write_member_call_busy(
4003 &self,
4004 task_id: &str,
4005 call_id: &str,
4006 member: &str,
4007 offer: u32,
4008 waited_ms: u64,
4009 message: &str,
4010 ) {
4011 let event = MemberCallBusyEvent {
4012 event_type: "member_call_busy",
4013 event_id: new_event_id(),
4014 parent_id: Some(self.session_event_id.clone()),
4015 session_id: self.session_id.clone(),
4016 timestamp: timestamp_ms(),
4017 task_id: task_id.to_string(),
4018 call_id: call_id.to_string(),
4019 member: member.to_string(),
4020 offer,
4021 waited_ms,
4022 message: message.to_string(),
4023 };
4024 self.append(&event).await;
4025 }
4026
4027 pub(crate) async fn write_member_call(
4029 &self,
4030 task_id: &str,
4031 outcome: &crate::member_call::MemberCallOutcome,
4032 delivered: bool,
4033 ) {
4034 let event = MemberCallEvent {
4035 event_type: "member_call",
4036 event_id: new_event_id(),
4037 parent_id: Some(self.session_event_id.clone()),
4038 session_id: self.session_id.clone(),
4039 timestamp: timestamp_ms(),
4040 task_id: task_id.to_string(),
4041 call_id: outcome.call_id.clone(),
4042 member: outcome.member.clone(),
4043 member_task_id: outcome.member_task_id.clone(),
4044 status: outcome.status.as_str().to_string(),
4045 duration_ms: outcome.duration_ms,
4046 output: outcome.output.clone(),
4047 truncated: outcome.truncated,
4048 delivered,
4049 no_answer_below: outcome
4050 .below
4051 .iter()
4052 .map(|(member, status)| NoAnswerBelow {
4053 member: member.clone(),
4054 status: status.as_str().to_string(),
4055 })
4056 .collect(),
4057 };
4058 self.append(&event).await;
4059 }
4060
4061 pub(crate) async fn write_inference_credential(
4064 &self,
4065 source: &'static str,
4066 credential: Option<&str>,
4067 change: CredentialChange,
4068 ) {
4069 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
4070 let event = InferenceCredentialEvent {
4071 event_type: "inference_credential",
4072 event_id: new_event_id(),
4073 parent_id: Some(self.session_event_id.clone()),
4074 session_id: self.session_id.clone(),
4075 timestamp: timestamp_ms(),
4076 source,
4077 credential,
4078 change: change_name,
4079 trigger,
4080 status,
4081 retried,
4082 reason,
4083 };
4084 self.append(&event).await;
4085 }
4086
4087 pub(crate) async fn write_gateway_credential(
4090 &self,
4091 artifact: &str,
4092 source: &'static str,
4093 credential: Option<&str>,
4094 change: CredentialChange,
4095 ) {
4096 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
4097 let event = GatewayCredentialEvent {
4098 event_type: "gateway_credential",
4099 event_id: new_event_id(),
4100 parent_id: Some(self.session_event_id.clone()),
4101 session_id: self.session_id.clone(),
4102 timestamp: timestamp_ms(),
4103 artifact,
4104 source,
4105 credential,
4106 change: change_name,
4107 trigger,
4108 status,
4109 retried,
4110 reason,
4111 };
4112 self.append(&event).await;
4113 }
4114
4115 pub(crate) async fn write_control_change(&self, change: ControlChange<'_>) {
4117 let event = ControlChangeEvent {
4118 event_type: "control_change",
4119 event_id: change.event_id,
4120 parent_id: Some(self.session_event_id.clone()),
4121 session_id: self.session_id.clone(),
4122 timestamp: timestamp_ms(),
4123 principal: change.principal.as_str(),
4124 token_id: change.token_id,
4125 kind: change.kind,
4126 name: change.name,
4127 action: change.action,
4128 previous: change.previous,
4129 value: change.value,
4130 applies_from: change.applies_from,
4131 replaced: change.replaced,
4132 };
4133 self.append(&event).await;
4134 }
4135
4136 pub(crate) async fn write_control_refused(
4139 &self,
4140 principal: crate::control_plane::Principal,
4141 status: u16,
4142 reason: &'static str,
4143 kind: Option<&'static str>,
4144 name: Option<&str>,
4145 token_id: Option<&str>,
4146 ) {
4147 let event = ControlRefusedEvent {
4148 event_type: "control_refused",
4149 event_id: new_event_id(),
4150 parent_id: Some(self.session_event_id.clone()),
4151 session_id: self.session_id.clone(),
4152 timestamp: timestamp_ms(),
4153 principal: principal.as_str(),
4154 status,
4155 reason,
4156 kind,
4157 name,
4158 token_id,
4159 };
4160 self.append(&event).await;
4161 }
4162
4163 async fn append(&self, event: &impl Serialize) {
4164 self.append_if(event, || true).await;
4165 }
4166
4167 async fn append_if(&self, event: &impl Serialize, admit: impl FnOnce() -> bool) -> bool {
4170 let Ok(mut line) = serde_json::to_string(event) else {
4171 return admit();
4172 };
4173 line.push('\n');
4174 let mut file = self.file.lock().await;
4175 if !admit() {
4176 return false;
4177 }
4178 if file.write_all(line.as_bytes()).await.is_ok() {
4179 let _ = file.flush().await;
4180 }
4181 true
4182 }
4183}
4184
4185pub(crate) struct PriorSessionTraceAppender {
4200 file: File,
4201 session_id: String,
4203 session_event_id: Option<String>,
4205 terminate_torn_tail: bool,
4209}
4210
4211impl PriorSessionTraceAppender {
4212 pub(crate) async fn open(
4215 trace_path: &Path,
4216 session_id: String,
4217 session_event_id: Option<String>,
4218 ) -> std::io::Result<Self> {
4219 let terminate_torn_tail = !ends_with_newline(trace_path)?;
4220 let file = OpenOptions::new().append(true).open(trace_path).await?;
4221 Ok(Self {
4222 file,
4223 session_id,
4224 session_event_id,
4225 terminate_torn_tail,
4226 })
4227 }
4228
4229 pub(crate) async fn write_shell_lost(
4235 &mut self,
4236 work: &crate::detached::LostWork,
4237 reconciled_by_session: &str,
4238 reconciled_task_id: &str,
4239 ) -> std::io::Result<()> {
4240 let event = ShellLostEvent {
4241 event_type: "shell_lost",
4242 event_id: new_event_id(),
4243 parent_id: self.session_event_id.clone(),
4244 session_id: self.session_id.clone(),
4245 timestamp: timestamp_ms(),
4246 work_id: work.work_id.clone(),
4247 binary: work.binary.clone(),
4248 command: work.command.clone(),
4249 detached_at_ms: work.detached_at_ms,
4250 reconciled_by_session: reconciled_by_session.to_string(),
4251 reconciled_task_id: reconciled_task_id.to_string(),
4252 };
4253 let mut line = if self.terminate_torn_tail {
4254 "\n".to_string()
4255 } else {
4256 String::new()
4257 };
4258 line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
4259 line.push('\n');
4260 self.file.write_all(line.as_bytes()).await?;
4261 self.file.flush().await?;
4262 self.terminate_torn_tail = false;
4263 Ok(())
4264 }
4265}
4266
4267fn ends_with_newline(path: &Path) -> std::io::Result<bool> {
4270 use std::io::{Read, Seek, SeekFrom};
4271
4272 let mut file = std::fs::File::open(path)?;
4273 let length = file.metadata()?.len();
4274 if length == 0 {
4275 return Ok(true);
4276 }
4277 file.seek(SeekFrom::End(-1))?;
4278 let mut last = [0u8; 1];
4279 file.read_exact(&mut last)?;
4280 Ok(last[0] == b'\n')
4281}
4282
4283pub(crate) fn timestamp_ms() -> u64 {
4284 SystemTime::now()
4285 .duration_since(UNIX_EPOCH)
4286 .unwrap_or_default()
4287 .as_millis() as u64
4288}
4289
4290#[derive(Serialize, Clone)]
4297pub(crate) struct PlanStepShape {
4298 pub(crate) step_id: String,
4299 pub(crate) kind: &'static str,
4302 pub(crate) depends_on: Vec<String>,
4303 pub(crate) has_condition: bool,
4305}
4306
4307#[derive(Serialize)]
4310struct PlanStartEvent {
4311 event_type: &'static str,
4312 event_id: String,
4313 parent_id: Option<String>,
4314 session_id: String,
4315 timestamp: u64,
4316 plan_id: String,
4317 step_count: usize,
4318 steps: Vec<PlanStepShape>,
4319}
4320
4321#[derive(Serialize)]
4327struct PlanStepStartEvent {
4328 event_type: &'static str,
4329 event_id: String,
4330 parent_id: Option<String>,
4331 session_id: String,
4332 timestamp: u64,
4333 plan_id: String,
4334 step_id: String,
4335 kind: &'static str,
4336 depends_on: Vec<String>,
4337}
4338
4339#[derive(Serialize)]
4343struct PlanStepEvent {
4344 event_type: &'static str,
4345 event_id: String,
4346 parent_id: Option<String>,
4347 session_id: String,
4348 timestamp: u64,
4349 plan_id: String,
4350 step_id: String,
4351 kind: &'static str,
4352 status: &'static str,
4353 attempts: u32,
4354 duration_ms: u64,
4355 #[serde(skip_serializing_if = "Option::is_none")]
4356 error: Option<String>,
4357 #[serde(skip_serializing_if = "Option::is_none")]
4360 input: Option<Value>,
4361 #[serde(skip_serializing_if = "Option::is_none")]
4365 state_effect: Option<String>,
4366 #[serde(skip_serializing_if = "Option::is_none")]
4368 resource_id: Option<String>,
4369}
4370
4371#[derive(Serialize)]
4373struct PlanEndEvent {
4374 event_type: &'static str,
4375 event_id: String,
4376 parent_id: Option<String>,
4377 session_id: String,
4378 timestamp: u64,
4379 plan_id: String,
4380 outcome: &'static str,
4381 #[serde(skip_serializing_if = "Option::is_none")]
4382 failed_step: Option<String>,
4383 steps_total: usize,
4384 steps_succeeded: usize,
4385 steps_failed: usize,
4386 steps_skipped: usize,
4387 duration_ms: u64,
4388 #[serde(skip_serializing_if = "Option::is_none")]
4391 reason: Option<String>,
4392}
4393
4394pub(crate) struct PlanStepRecord {
4396 pub(crate) plan_id: String,
4397 pub(crate) step_id: String,
4398 pub(crate) kind: &'static str,
4399 pub(crate) status: &'static str,
4402 pub(crate) attempts: u32,
4404 pub(crate) duration_ms: u64,
4405 pub(crate) error: Option<String>,
4406 pub(crate) input: Option<Value>,
4407 pub(crate) state_effect: Option<String>,
4408 pub(crate) resource_id: Option<String>,
4409}
4410
4411pub(crate) struct PlanEndRecord {
4413 pub(crate) plan_id: String,
4414 pub(crate) outcome: &'static str,
4415 pub(crate) failed_step: Option<String>,
4416 pub(crate) steps_total: usize,
4417 pub(crate) steps_succeeded: usize,
4418 pub(crate) steps_failed: usize,
4419 pub(crate) steps_skipped: usize,
4420 pub(crate) duration_ms: u64,
4421 pub(crate) reason: Option<String>,
4422}
4423
4424pub struct PlanTraceAppender {
4435 file: std::sync::Mutex<std::fs::File>,
4436 session_id: String,
4437 session_event_id: String,
4440}
4441
4442impl PlanTraceAppender {
4443 pub fn open(
4444 workdir: &Path,
4445 session_id: String,
4446 session_event_id: String,
4447 ) -> std::io::Result<Self> {
4448 let file = std::fs::OpenOptions::new()
4449 .create(true)
4450 .append(true)
4451 .open(workdir.join("trace.jsonl"))?;
4452 Ok(Self {
4453 file: std::sync::Mutex::new(file),
4454 session_id,
4455 session_event_id,
4456 })
4457 }
4458
4459 pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
4461 let event_id = new_event_id();
4462 let event = PlanStartEvent {
4463 event_type: "plan_start",
4464 event_id: event_id.clone(),
4465 parent_id: Some(self.session_event_id.clone()),
4466 session_id: self.session_id.clone(),
4467 timestamp: timestamp_ms(),
4468 plan_id: plan_id.to_string(),
4469 step_count: steps.len(),
4470 steps,
4471 };
4472 self.append(&event);
4473 event_id
4474 }
4475
4476 pub(crate) fn write_plan_step_start(
4477 &self,
4478 plan_node: &str,
4479 plan_id: &str,
4480 step_id: &str,
4481 kind: &'static str,
4482 depends_on: Vec<String>,
4483 ) {
4484 let event = PlanStepStartEvent {
4485 event_type: "plan_step_start",
4486 event_id: new_event_id(),
4487 parent_id: Some(plan_node.to_string()),
4488 session_id: self.session_id.clone(),
4489 timestamp: timestamp_ms(),
4490 plan_id: plan_id.to_string(),
4491 step_id: step_id.to_string(),
4492 kind,
4493 depends_on,
4494 };
4495 self.append(&event);
4496 }
4497
4498 pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
4499 let input = record.input.map(|mut value| {
4502 crate::peer_handoff::redact_handles_in_json(&mut value);
4503 value
4504 });
4505 let event = PlanStepEvent {
4506 event_type: "plan_step",
4507 event_id: new_event_id(),
4508 parent_id: Some(plan_node.to_string()),
4509 session_id: self.session_id.clone(),
4510 timestamp: timestamp_ms(),
4511 plan_id: record.plan_id,
4512 step_id: record.step_id,
4513 kind: record.kind,
4514 status: record.status,
4515 attempts: record.attempts,
4516 duration_ms: record.duration_ms,
4517 error: record.error,
4518 input,
4519 state_effect: record.state_effect,
4520 resource_id: record.resource_id,
4521 };
4522 self.append(&event);
4523 }
4524
4525 pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
4528 let event = PlanEndEvent {
4529 event_type: "plan_end",
4530 event_id: new_event_id(),
4531 parent_id: Some(
4532 plan_node
4533 .map(str::to_string)
4534 .unwrap_or_else(|| self.session_event_id.clone()),
4535 ),
4536 session_id: self.session_id.clone(),
4537 timestamp: timestamp_ms(),
4538 plan_id: record.plan_id,
4539 outcome: record.outcome,
4540 failed_step: record.failed_step,
4541 steps_total: record.steps_total,
4542 steps_succeeded: record.steps_succeeded,
4543 steps_failed: record.steps_failed,
4544 steps_skipped: record.steps_skipped,
4545 duration_ms: record.duration_ms,
4546 reason: record.reason,
4547 };
4548 self.append(&event);
4549 }
4550
4551 pub(crate) fn write_delegation_start(
4557 &self,
4558 delegation_id: &str,
4559 capsule: &str,
4560 version: &str,
4561 child_session_id: &str,
4562 child_workdir: &str,
4563 ) {
4564 let event = DelegationStartEvent::new(
4565 &self.session_id,
4566 &self.session_event_id,
4567 delegation_id,
4568 capsule,
4569 version,
4570 child_session_id,
4571 child_workdir,
4572 );
4573 self.append(&event);
4574 }
4575
4576 #[allow(clippy::too_many_arguments)]
4584 pub(crate) fn write_delegation(
4585 &self,
4586 capsule: &str,
4587 version: &str,
4588 delegation_id: Option<String>,
4589 child_session_id: Option<String>,
4590 duration_ms: u64,
4591 outcome: &str,
4592 reason: Option<String>,
4593 ) {
4594 let event = DelegationEvent::new(
4595 &self.session_id,
4596 &self.session_event_id,
4597 capsule,
4598 version,
4599 delegation_id,
4600 child_session_id,
4601 duration_ms,
4602 outcome,
4603 reason,
4604 );
4605 self.append(&event);
4606 }
4607
4608 fn append(&self, event: &impl Serialize) {
4612 use std::io::Write as _;
4613
4614 let Ok(mut line) = serde_json::to_string(event) else {
4615 return;
4616 };
4617 line.push('\n');
4618 let Ok(mut file) = self.file.lock() else {
4619 return;
4620 };
4621 if file.write_all(line.as_bytes()).is_ok() {
4622 let _ = file.flush();
4623 }
4624 }
4625}
4626
4627#[cfg(test)]
4630mod tests {
4631 use super::*;
4632 use crate::{
4633 containment::scope_report_for_tier, sandbox::EnforcementTier, sealed::UsernsGrant,
4634 trace_blobs::BLOB_DIR_NAME, types::CapabilityPolicy,
4635 };
4636 use murmur_artifact::{ArtifactRuntime, InterpreterRuntimeDir, InterpreterRuntimeGrant};
4637 use serde_json::Value;
4638
4639 use crate::origin::TaskOrigin;
4640
4641 fn event_provenance() -> TaskProvenance {
4643 TaskProvenance::derive(TaskOrigin::Event, None)
4644 }
4645
4646 fn report_for(
4650 declared: ContainmentClass,
4651 tier: EnforcementTier,
4652 workdir_exec: bool,
4653 ) -> ScopeReport {
4654 scope_report_for_tier(
4655 &CapabilityPolicy {
4656 workdir_exec_allowed: workdir_exec,
4657 ..CapabilityPolicy::default()
4658 },
4659 declared,
4660 tier,
4661 None,
4662 None,
4663 None,
4664 Vec::new(),
4665 Vec::new(),
4666 Vec::new(),
4667 crate::cgroup::IoMaxReport::default(),
4668 )
4669 }
4670
4671 fn demotion_info() -> crate::detached::DetachedDispatchInfo {
4672 crate::detached::DetachedDispatchInfo {
4673 work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
4674 binary: "/usr/bin/bash".to_string(),
4675 command: "make -j8".to_string(),
4676 grace_ms: 1_000,
4677 }
4678 }
4679
4680 #[tokio::test]
4683 async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
4684 let dir = tempfile::tempdir().unwrap();
4685 std::os::unix::fs::symlink("/dev/full", dir.path().join("trace.jsonl")).unwrap();
4688 let mut writer = make_writer(dir.path()).await;
4689 let info = demotion_info();
4690
4691 assert!(
4692 writer
4693 .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
4694 .await
4695 .is_err(),
4696 "the sink under this writer must be unwritable for the claim below to mean anything"
4697 );
4698 crate::agent::record_demotion(&mut writer, 1, &info).await;
4699 }
4700
4701 #[tokio::test]
4703 async fn a_recorded_demotion_writes_only_its_own_marker() {
4704 let dir = tempfile::tempdir().unwrap();
4705 let mut writer = make_writer(dir.path()).await;
4706 crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
4707
4708 let events = read_events(dir.path());
4709 assert_eq!(events.len(), 1);
4710 assert_eq!(events[0]["event_type"], "shell_detached");
4711 assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
4712 assert_eq!(events[0]["turn"], 3);
4713 }
4714
4715 #[tokio::test]
4716 async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
4717 let dir = tempfile::tempdir().unwrap();
4718 let mut writer = make_writer(dir.path()).await;
4719 writer
4720 .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
4721 .await
4722 .unwrap();
4723
4724 let events = read_events(dir.path());
4725 assert_eq!(events.len(), 1);
4726 assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
4727 assert_eq!(events[0]["turn"], 2);
4728 assert_eq!(events[0]["work_id"], "wrk_0a1b");
4729 assert_eq!(events[0]["binary"], "/usr/bin/bash");
4730 assert_eq!(events[0]["reason"], "No space left");
4731 }
4732
4733 #[tokio::test]
4737 async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
4738 let dir = tempfile::tempdir().unwrap();
4739 let mut writer = make_writer(dir.path()).await;
4740 writer
4741 .write_shell_abandoned(
4742 "wrk_0a1b2c3d4e5f6a7b",
4743 "/usr/bin/bash",
4744 "sleep 45; echo done",
4745 45_012,
4746 None,
4747 )
4748 .await
4749 .unwrap();
4750
4751 let events = read_events(dir.path());
4752 assert_eq!(events.len(), 1);
4753 let event = &events[0];
4754 assert_eq!(event["event_type"], "shell_abandoned");
4755 assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
4756 assert_eq!(event["binary"], "/usr/bin/bash");
4757 assert_eq!(event["command"], "sleep 45; echo done");
4758 assert_eq!(event["running_ms"], 45_012);
4759 for absent in ["exit_code", "output_path", "output_bytes"] {
4760 assert!(
4761 event.get(absent).is_none(),
4762 "{absent} must be omitted, not written as null: {event}"
4763 );
4764 }
4765 }
4766
4767 #[tokio::test]
4771 async fn an_abandoned_command_that_finished_carries_its_result() {
4772 let dir = tempfile::tempdir().unwrap();
4773 let mut writer = make_writer(dir.path()).await;
4774 writer
4775 .write_shell_abandoned(
4776 "wrk_0a1b2c3d4e5f6a7b",
4777 "/usr/bin/bash",
4778 "sleep 2; echo done",
4779 2_014,
4780 Some((0, "logs/wrk_0a1b2c3d4e5f6a7b.log", 91)),
4781 )
4782 .await
4783 .unwrap();
4784
4785 let event = &read_events(dir.path())[0];
4786 assert_eq!(event["event_type"], "shell_abandoned");
4787 assert_eq!(event["running_ms"], 2_014);
4788 assert_eq!(event["exit_code"], 0);
4789 assert_eq!(event["output_path"], "logs/wrk_0a1b2c3d4e5f6a7b.log");
4790 assert_eq!(event["output_bytes"], 91);
4791 }
4792
4793 async fn make_writer(dir: &std::path::Path) -> TraceWriter {
4794 make_writer_with_opts(dir, TraceCapture::Meta).await
4795 }
4796
4797 async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
4798 make_writer_with_prompt(dir, capture, None, false).await
4799 }
4800
4801 async fn make_writer_with_prompt(
4802 dir: &std::path::Path,
4803 capture: TraceCapture,
4804 system_prompt: Option<&str>,
4805 system_prompt_overridden: bool,
4806 ) -> TraceWriter {
4807 TraceWriter::open(
4808 dir,
4809 "test-session-id".to_string(),
4810 "test-capsule".to_string(),
4811 "1.0.0".to_string(),
4812 "claude-test".to_string(),
4813 vec!["shell".to_string()],
4814 report_for(
4815 ContainmentClass::Advisory,
4816 EnforcementTier::EnvironmentOnly,
4817 false,
4818 ),
4819 capture,
4820 system_prompt.map(str::to_string),
4821 system_prompt_overridden,
4822 None,
4823 None,
4824 None,
4825 None,
4826 )
4827 .await
4828 .unwrap()
4829 }
4830
4831 fn read_events(dir: &std::path::Path) -> Vec<Value> {
4832 let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
4833 content
4834 .lines()
4835 .filter(|l| !l.is_empty())
4836 .map(|l| serde_json::from_str(l).unwrap())
4837 .collect()
4838 }
4839
4840 #[tokio::test]
4841 async fn session_start_fields() {
4842 let dir = tempfile::tempdir().unwrap();
4843 let mut w = make_writer(dir.path()).await;
4844 w.write_session_start(10, vec!["bash".to_string()])
4845 .await
4846 .unwrap();
4847 w.flush().await.unwrap();
4848
4849 let events = read_events(dir.path());
4850 assert_eq!(events.len(), 1);
4851 let e = &events[0];
4852 assert_eq!(e["event_type"], "session_start");
4853 assert_eq!(e["session_id"], "test-session-id");
4854 assert_eq!(e["capsule_name"], "test-capsule");
4855 assert_eq!(e["capsule_version"], "1.0.0");
4856 assert_eq!(e["model"], "claude-test");
4857 assert_eq!(e["max_turns"], 10);
4858 assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
4859 assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
4860 assert_eq!(e["containment_declared"], "advisory");
4861 assert_eq!(e["containment_achieved"], "advisory");
4862 assert_eq!(e["workdir_exec"], false);
4863 assert!(e["timestamp"].as_u64().unwrap() > 0);
4864 }
4865
4866 #[tokio::test]
4869 async fn session_start_is_the_first_line_and_carries_a_members_formation() {
4870 let formation = FormationId::mint();
4871 for member in [Some(&formation), None] {
4872 let dir = tempfile::tempdir().unwrap();
4873 let mut w = make_writer(dir.path()).await;
4874 w.set_formation_id(member);
4875 w.write_session_start(10, Vec::new()).await.unwrap();
4876 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
4877 .await
4878 .unwrap();
4879 w.flush().await.unwrap();
4880
4881 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
4882 let first_line = content.lines().next().unwrap();
4883 let first: Value = serde_json::from_str(first_line).unwrap();
4884 assert_eq!(first["event_type"], "session_start");
4885 match member {
4886 Some(id) => {
4887 assert_eq!(first["formation_id"], id.as_str());
4888 assert!(
4889 first_line.contains(&format!("\"formation_id\":\"{id}\"")),
4890 "{first_line}"
4891 );
4892 }
4893 None => assert!(!first_line.contains("formation_id"), "{first_line}"),
4894 }
4895 assert_eq!(
4897 content.matches("formation_id").count(),
4898 usize::from(member.is_some())
4899 );
4900 }
4901 }
4902
4903 #[tokio::test]
4907 async fn a_members_trace_names_it_its_callees_and_its_callers() {
4908 let authority = crate::formation_credentials::FormationAuthority::for_test();
4909 let cases = [
4910 (Some(("coder", vec!["reviewer"])), Some("planner")),
4911 (Some(("reviewer", vec![])), None),
4912 (None, None),
4913 ];
4914 for (member, caller) in cases {
4915 let member = member.map(|(name, callees)| {
4916 crate::formation_credentials::FormationMember::from_bundle(
4917 authority.member_bundle(name, &callees),
4918 )
4919 });
4920 let dir = tempfile::tempdir().unwrap();
4921 let mut w = make_writer(dir.path()).await;
4922 w.set_formation_member(member.as_ref());
4923 w.write_session_start(10, Vec::new()).await.unwrap();
4924 w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, caller)
4925 .await
4926 .unwrap();
4927 w.flush().await.unwrap();
4928 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
4929 let lines: Vec<Value> = content
4930 .lines()
4931 .map(|line| serde_json::from_str(line).unwrap())
4932 .collect();
4933 let start = &lines[0];
4934 let received = lines
4935 .iter()
4936 .find(|line| line["event_type"] == "a2a_task_received")
4937 .unwrap();
4938 match &member {
4939 Some(member) => {
4940 assert_eq!(start["formation_member"], member.name());
4941 assert_eq!(
4942 start["formation_callees"],
4943 serde_json::json!(member.callees().collect::<Vec<_>>())
4944 );
4945 }
4946 None => {
4947 assert!(start.get("formation_member").is_none(), "{start}");
4948 assert!(start.get("formation_callees").is_none(), "{start}");
4949 }
4950 }
4951 match caller {
4952 Some(caller) => assert_eq!(received["caller_member"], caller),
4953 None => assert!(received.get("caller_member").is_none(), "{received}"),
4954 }
4955 assert!(!content.contains("mft1."), "{content}");
4956 }
4957 }
4958
4959 #[tokio::test]
4964 async fn session_start_records_system_prompt_source_and_hash() {
4965 let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
4966
4967 for (prompt, overridden, source, sha) in [
4968 (Some("Be terse."), false, "manifest", Some(&expected_sha)),
4969 (Some("Be terse."), true, "cli", Some(&expected_sha)),
4970 (None, false, "none", None),
4971 (None, true, "cli", None),
4974 ] {
4975 let dir = tempfile::tempdir().unwrap();
4976 let mut w =
4977 make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
4978 w.write_session_start(1, Vec::new()).await.unwrap();
4979 w.flush().await.unwrap();
4980
4981 let e = &read_events(dir.path())[0];
4982 assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
4983 match sha {
4984 Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
4985 None => assert!(
4986 e["system_prompt_sha256"].is_null(),
4987 "no prompt in effect must hash to null, got {}",
4988 e["system_prompt_sha256"]
4989 ),
4990 }
4991 }
4992 }
4993
4994 #[tokio::test]
4998 async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
4999 let sha = murmur_artifact::sha256_hex(b"Be terse.");
5000
5001 let dir = tempfile::tempdir().unwrap();
5002 let mut w =
5003 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
5004 w.write_session_start(1, Vec::new()).await.unwrap();
5005 w.flush().await.unwrap();
5006
5007 let e = &read_events(dir.path())[0];
5008 assert!(
5009 e.get("system_prompt").is_none(),
5010 "the verbatim prompt is not a session_start field, got {e}"
5011 );
5012 assert_eq!(e["system_prompt_sha256"], sha.as_str());
5013 assert!(
5014 !dir.path().join(BLOB_DIR_NAME).exists(),
5015 "meta stores no bodies"
5016 );
5017
5018 let opted_in = tempfile::tempdir().unwrap();
5019 let mut w = make_writer_with_prompt(
5020 opted_in.path(),
5021 TraceCapture::Content,
5022 Some("Be terse."),
5023 false,
5024 )
5025 .await;
5026 w.write_session_start(1, Vec::new()).await.unwrap();
5027 w.flush().await.unwrap();
5028
5029 let event = &read_events(opted_in.path())[0];
5030 assert!(event.get("system_prompt").is_none());
5031 assert_eq!(event["system_prompt_sha256"], sha.as_str());
5032 let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
5033 assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
5034 }
5035
5036 #[tokio::test]
5039 async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
5040 let dir = tempfile::tempdir().unwrap();
5041 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
5042 w.write_session_start(1, Vec::new()).await.unwrap();
5043 w.flush().await.unwrap();
5044
5045 let e = &read_events(dir.path())[0];
5046 assert!(e.get("system_prompt").is_none());
5047 assert!(e["system_prompt_sha256"].is_null());
5048 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
5049 }
5050
5051 #[tokio::test]
5053 async fn every_task_session_start_repeats_the_same_system_prompt_record() {
5054 let dir = tempfile::tempdir().unwrap();
5055 let mut w =
5056 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
5057 w.write_session_start(1, Vec::new()).await.unwrap();
5058 w.write_session_start(1, Vec::new()).await.unwrap();
5059 w.flush().await.unwrap();
5060
5061 let events = read_events(dir.path());
5062 assert_eq!(events.len(), 2);
5063 assert_eq!(
5064 events[0]["system_prompt_source"],
5065 events[1]["system_prompt_source"]
5066 );
5067 assert_eq!(
5068 events[0]["system_prompt_sha256"],
5069 events[1]["system_prompt_sha256"]
5070 );
5071 assert_eq!(events[1]["system_prompt_source"], "cli");
5072 }
5073
5074 #[tokio::test]
5079 async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
5080 let dir = tempfile::tempdir().unwrap();
5081 let mut w = TraceWriter::open(
5082 dir.path(),
5083 "test-session-id".to_string(),
5084 "test-capsule".to_string(),
5085 "1.0.0".to_string(),
5086 "claude-test".to_string(),
5087 Vec::new(),
5088 report_for(
5090 ContainmentClass::Advisory,
5091 EnforcementTier::KernelFull,
5092 true,
5093 ),
5094 TraceCapture::Meta,
5095 None,
5096 false,
5097 None,
5098 None,
5099 None,
5100 None,
5101 )
5102 .await
5103 .unwrap();
5104 w.write_session_start(1, Vec::new()).await.unwrap();
5105 w.flush().await.unwrap();
5106
5107 let events = read_events(dir.path());
5108 assert_eq!(events[0]["workdir_exec"], true);
5109 assert_eq!(events[0]["containment_achieved"], "advisory");
5110 }
5111
5112 #[tokio::test]
5115 async fn session_start_records_declared_and_achieved_containment_separately() {
5116 let dir = tempfile::tempdir().unwrap();
5117 let mut w = TraceWriter::open(
5118 dir.path(),
5119 "test-session-id".to_string(),
5120 "test-capsule".to_string(),
5121 "1.0.0".to_string(),
5122 "claude-test".to_string(),
5123 Vec::new(),
5124 report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
5125 TraceCapture::Meta,
5126 None,
5127 false,
5128 None,
5129 None,
5130 None,
5131 None,
5132 )
5133 .await
5134 .unwrap();
5135 w.write_session_start(1, Vec::new()).await.unwrap();
5136 w.flush().await.unwrap();
5137
5138 let events = read_events(dir.path());
5139 assert_eq!(events[0]["containment_declared"], "sealed");
5140 assert_eq!(events[0]["containment_achieved"], "scoped");
5141 }
5142
5143 #[tokio::test]
5151 async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
5152 async fn event_for(grant: UsernsGrant) -> Value {
5153 let report = scope_report_for_tier(
5154 &CapabilityPolicy::default(),
5155 ContainmentClass::Sealed,
5156 EnforcementTier::KernelSealed,
5157 None,
5158 Some(grant),
5159 None,
5160 Vec::new(),
5161 Vec::new(),
5162 Vec::new(),
5163 crate::cgroup::IoMaxReport::default(),
5164 );
5165 let dir = tempfile::tempdir().unwrap();
5166 let mut w = TraceWriter::open(
5167 dir.path(),
5168 "test-session-id".to_string(),
5169 "test-capsule".to_string(),
5170 "1.0.0".to_string(),
5171 "claude-test".to_string(),
5172 Vec::new(),
5173 report,
5174 TraceCapture::Meta,
5175 None,
5176 false,
5177 None,
5178 None,
5179 None,
5180 None,
5181 )
5182 .await
5183 .unwrap();
5184 w.write_session_start(1, Vec::new()).await.unwrap();
5185 w.flush().await.unwrap();
5186 let mut event = read_events(dir.path()).remove(0);
5187 event.as_object_mut().unwrap().remove("timestamp");
5190 event
5191 }
5192
5193 let through_profile = event_for(UsernsGrant::ProfileConfining).await;
5194 let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
5195
5196 assert_eq!(through_profile["containment_achieved"], "sealed");
5197 assert_eq!(host_wide["containment_achieved"], "sealed");
5198 assert_eq!(through_profile["userns_grant"], "profile_confining");
5199 assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
5200 assert_ne!(
5201 through_profile, host_wide,
5202 "two hosts granting the user namespace by different mechanisms must not write the \
5203 same session_start record"
5204 );
5205
5206 let unprobed = scope_report_for_tier(
5209 &CapabilityPolicy::default(),
5210 ContainmentClass::Advisory,
5211 EnforcementTier::EnvironmentOnly,
5212 None,
5213 None,
5214 None,
5215 Vec::new(),
5216 Vec::new(),
5217 Vec::new(),
5218 crate::cgroup::IoMaxReport::default(),
5219 );
5220 assert!(
5221 serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
5222 "off Linux the key is present and null, not absent"
5223 );
5224 }
5225
5226 #[tokio::test]
5234 async fn session_start_records_the_whole_scope_report_as_effective_grants() {
5235 let policy = CapabilityPolicy {
5236 network_allow: vec!["https://api.example.com".to_string()],
5237 unix_sockets_allowed: false,
5238 filesystem_scope: Some("workdir".to_string()),
5239 shell_allow: vec!["python3".to_string()],
5240 spawn_allow: vec!["helper".to_string()],
5241 env_allow: vec!["TZ".to_string()],
5242 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
5243 binary: "python3".to_string(),
5244 dirs: vec![InterpreterRuntimeDir {
5245 path: "/usr/lib/python3.11".to_string(),
5246 list_dir: true,
5247 }],
5248 }],
5249 ..CapabilityPolicy::default()
5250 };
5251 let expected = scope_report_for_tier(
5252 &policy,
5253 ContainmentClass::Scoped,
5254 EnforcementTier::KernelFull,
5255 None,
5256 None,
5257 None,
5258 vec![crate::containment::StateStoreReport {
5261 artifact: "notes-tool".to_string(),
5262 store: "shey".to_string(),
5263 host_path: "/home/dev/.murmur/state/shey".to_string(),
5264 }],
5265 vec!["config-echo".to_string()],
5267 vec![crate::containment::PreopenReport {
5268 artifact: "notes-tool".to_string(),
5269 role: "tool".to_string(),
5270 scope: None,
5271 surface: crate::containment::PreopenSurface::WholeWorkdir,
5272 }],
5273 crate::cgroup::IoMaxReport::default(),
5274 );
5275
5276 let dir = tempfile::tempdir().unwrap();
5277 let mut w = TraceWriter::open(
5278 dir.path(),
5279 "test-session-id".to_string(),
5280 "test-capsule".to_string(),
5281 "1.0.0".to_string(),
5282 "claude-test".to_string(),
5283 vec!["network".to_string(), "shell".to_string()],
5284 expected.clone(),
5285 TraceCapture::Meta,
5286 None,
5287 false,
5288 None,
5289 None,
5290 None,
5291 None,
5292 )
5293 .await
5294 .unwrap();
5295 w.write_session_start(1, Vec::new()).await.unwrap();
5296 w.flush().await.unwrap();
5297
5298 let events = read_events(dir.path());
5299 assert_eq!(
5300 events[0]["effective_grants"],
5301 serde_json::to_value(&expected).unwrap()
5302 );
5303
5304 assert_eq!(
5307 events[0]["capabilities"],
5308 serde_json::json!(["network", "shell"])
5309 );
5310 assert_eq!(
5311 events[0]["effective_grants"]["state_stores"],
5312 serde_json::json!([{
5313 "artifact": "notes-tool",
5314 "store": "shey",
5315 "host_path": "/home/dev/.murmur/state/shey",
5316 }])
5317 );
5318 assert_eq!(
5321 events[0]["effective_grants"]["configured_artifacts"],
5322 serde_json::json!(["config-echo"])
5323 );
5324 assert_eq!(
5328 events[0]["effective_grants"]["preopens"],
5329 serde_json::json!([{
5330 "artifact": "notes-tool",
5331 "role": "tool",
5332 "scope": null,
5333 "surface": "whole-workdir",
5334 }])
5335 );
5336 assert_eq!(events[0]["containment_declared"], "scoped");
5337 assert_eq!(events[0]["containment_achieved"], "scoped");
5338 assert_eq!(events[0]["workdir_exec"], false);
5339 assert_eq!(
5340 events[0]["effective_grants"]["network_allow"],
5341 serde_json::json!(["https://api.example.com"])
5342 );
5343 assert_eq!(
5344 events[0]["effective_grants"]["interpreter_runtime_grants"],
5345 serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
5346 );
5347 }
5348
5349 #[tokio::test]
5350 async fn inference_fields_and_snake_case() {
5351 let dir = tempfile::tempdir().unwrap();
5352 let mut w = make_writer(dir.path()).await;
5353 w.write_inference(
5354 0,
5355 Some(100),
5356 Some(50),
5357 "tool_call".to_string(),
5358 Some("tool_call"),
5359 Some("bash".to_string()),
5360 None,
5361 None,
5362 vec!["msg_0000000000000000000000000000000a".to_string()],
5363 None,
5364 )
5365 .await
5366 .unwrap();
5367 w.flush().await.unwrap();
5368
5369 let events = read_events(dir.path());
5370 let e = &events[0];
5371 assert_eq!(e["event_type"], "inference");
5372 assert_eq!(
5373 e["message_ids"],
5374 serde_json::json!(["msg_0000000000000000000000000000000a"]),
5375 "an agent-loop turn names the messages its request embedded"
5376 );
5377 assert_eq!(e["turn"], 0);
5378 assert_eq!(e["input_tokens"], 100);
5379 assert_eq!(e["output_tokens"], 50);
5380 assert_eq!(e["decision"], "tool_call");
5381 assert_eq!(e["tool_name"], "bash");
5382 assert!(e.get("inputTokens").is_none(), "must use snake_case");
5384 assert!(e.get("toolName").is_none(), "must use snake_case");
5385 for key in [
5387 "input_tokens_actual",
5388 "output_tokens_actual",
5389 "cached_tokens",
5390 "cache_write_tokens",
5391 ] {
5392 assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
5393 }
5394 }
5395
5396 #[tokio::test]
5399 async fn inference_writes_only_the_reported_usage_members() {
5400 let dir = tempfile::tempdir().unwrap();
5401 let mut w = make_writer(dir.path()).await;
5402 let usage = DriverUsage {
5403 input_tokens: Some(12043),
5404 output_tokens: None,
5405 cached_tokens: Some(0),
5406 cache_write_tokens: None,
5407 thinking_tokens: None,
5408 };
5409 w.write_inference(
5410 0,
5411 Some(100),
5412 Some(50),
5413 "end_turn".to_string(),
5414 Some("end_turn"),
5415 None,
5416 None,
5417 Some(&usage),
5418 Vec::new(),
5419 None,
5420 )
5421 .await
5422 .unwrap();
5423 w.flush().await.unwrap();
5424
5425 let events = read_events(dir.path());
5426 let e = &events[0];
5427 assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
5428 assert_eq!(e["input_tokens_actual"], 12043);
5429 assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
5430 assert!(e.get("output_tokens_actual").is_none());
5431 assert!(e.get("cache_write_tokens").is_none());
5432 }
5433
5434 #[tokio::test]
5435 async fn failed_inference_writes_its_keys_and_counts_only_what_it_spent() {
5436 let dir = tempfile::tempdir().unwrap();
5437 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5438 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
5439 .await
5440 .unwrap();
5441 let usage = DriverUsage {
5442 input_tokens: Some(7),
5443 ..DriverUsage::default()
5444 };
5445 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
5446 w.write_failed_inference(FailedInference {
5447 turn: 0,
5448 error_code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
5449 error: &long,
5450 provider_status: Some(401),
5451 usage: Some(&usage),
5452 origin: None,
5453 choice: None,
5454 message_ids: vec!["msg_1".to_string()],
5455 tool_name: None,
5456 spent: None,
5457 })
5458 .await
5459 .unwrap();
5460 assert_eq!(w.task_turns(), 0, "an uncounted record moves no counter");
5461 assert_eq!((w.total_turns, w.total_input_tokens), (0, 0));
5462 assert_eq!((w.task_input_tokens, w.task_output_tokens), (0, 0));
5463
5464 w.write_failed_inference(FailedInference {
5465 turn: 1,
5466 error_code: INFERENCE_ERROR_HARNESS_AUTH,
5467 error: "bad login",
5468 provider_status: None,
5469 usage: None,
5470 origin: None,
5471 choice: None,
5472 message_ids: Vec::new(),
5473 tool_name: None,
5474 spent: Some((Some(10), Some(5))),
5475 })
5476 .await
5477 .unwrap();
5478 assert_eq!(w.task_turns(), 1);
5479 assert_eq!(w.total_turns, 1);
5480 assert_eq!((w.total_input_tokens, w.total_output_tokens), (10, 5));
5481 assert_eq!((w.task_input_tokens, w.task_output_tokens), (10, 5));
5482 w.flush().await.unwrap();
5483
5484 let events = read_events(dir.path());
5485 let failed: Vec<&Value> = events
5486 .iter()
5487 .filter(|e| e["event_type"] == "inference")
5488 .collect();
5489 let keys = |e: &Value| {
5490 let mut keys: Vec<String> = e.as_object().unwrap().keys().cloned().collect();
5491 keys.sort();
5492 keys
5493 };
5494 assert_eq!(
5495 keys(failed[0]),
5496 [
5497 "decision",
5498 "error",
5499 "error_code",
5500 "event_id",
5501 "event_type",
5502 "input_tokens_actual",
5503 "message_ids",
5504 "parent_id",
5505 "provider_status",
5506 "session_id",
5507 "stop_reason",
5508 "task_id",
5509 "timestamp",
5510 "tool_name",
5511 "turn",
5512 ]
5513 );
5514 assert_eq!(failed[0]["decision"], "error");
5515 assert_eq!(failed[0]["stop_reason"], "error");
5516 assert_eq!(failed[0]["error_code"], "credential_rejected");
5517 assert_eq!(failed[0]["provider_status"], 401);
5518 assert_eq!(failed[0]["input_tokens_actual"], 7);
5519 let error = failed[0]["error"].as_str().unwrap();
5520 assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
5521 assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
5522
5523 assert_eq!(failed[1]["error_code"], "harness_auth");
5524 assert_eq!(failed[1]["error"], "bad login");
5525 assert_eq!(failed[1]["input_tokens"], 10);
5526 assert_eq!(failed[1]["output_tokens"], 5);
5527 assert!(failed[1].get("provider_status").is_none());
5528 assert!(
5529 !dir.path().join("blobs").exists(),
5530 "a failed record stores no blob, even under trace.capture: content"
5531 );
5532 let task_start = events
5534 .iter()
5535 .find(|e| e["event_type"] == "task_start")
5536 .unwrap();
5537 assert_eq!(failed[0]["parent_id"], task_start["event_id"]);
5538 }
5539
5540 #[tokio::test]
5541 async fn a_failed_hook_record_hangs_off_the_turn_and_keeps_hook_accounting() {
5542 let dir = tempfile::tempdir().unwrap();
5543 let mut w = make_writer(dir.path()).await;
5544 w.write_inference(
5545 0,
5546 Some(10),
5547 Some(5),
5548 "end_turn".to_string(),
5549 Some("end_turn"),
5550 None,
5551 None,
5552 None,
5553 Vec::new(),
5554 None,
5555 )
5556 .await
5557 .unwrap();
5558 let origin = InferenceOrigin {
5559 source: "hook:compactor".to_string(),
5560 model: "m".to_string(),
5561 };
5562 w.write_failed_inference(FailedInference {
5563 turn: 0,
5564 error_code: INFERENCE_ERROR_DRIVER_ERROR,
5565 error: "HTTP 500: boom",
5566 provider_status: Some(500),
5567 usage: None,
5568 origin: Some(&origin),
5569 choice: None,
5570 message_ids: Vec::new(),
5571 tool_name: None,
5572 spent: Some((Some(40), None)),
5573 })
5574 .await
5575 .unwrap();
5576 assert_eq!(w.total_turns, 2);
5577 assert_eq!((w.total_input_tokens, w.total_output_tokens), (50, 5));
5578 w.flush().await.unwrap();
5579
5580 let events = read_events(dir.path());
5581 let turn = events.iter().find(|e| e["decision"] == "end_turn").unwrap();
5582 let hook = events.iter().find(|e| e["decision"] == "error").unwrap();
5583 assert_eq!(hook["parent_id"], turn["event_id"]);
5584 assert_eq!(hook["origin"], "hook:compactor");
5585 assert_eq!(hook["model"], "m");
5586 assert_eq!(hook["input_tokens"], 40);
5587 assert!(hook.get("output_tokens").is_none());
5588 }
5589
5590 #[tokio::test]
5591 async fn tool_call_fields() {
5592 let dir = tempfile::tempdir().unwrap();
5593 let mut w = make_writer(dir.path()).await;
5594 w.write_tool_call(
5595 1,
5596 "bash".to_string(),
5597 None,
5598 serde_json::json!({"command": "echo hi"}),
5599 42,
5600 "hi\n",
5601 100,
5602 55,
5603 "ok".to_string(),
5604 None,
5605 None,
5606 )
5607 .await
5608 .unwrap();
5609 w.flush().await.unwrap();
5610
5611 let events = read_events(dir.path());
5612 let e = &events[0];
5613 assert_eq!(e["event_type"], "tool_call");
5614 assert_eq!(e["turn"], 1);
5615 assert_eq!(e["tool_name"], "bash");
5616 assert_eq!(e["input_bytes"], 42);
5617 assert_eq!(e["output_bytes"], 100);
5618 assert_eq!(e["duration_ms"], 55);
5619 assert_eq!(e["status"], "ok");
5620 assert!(
5621 e.get("state_effect").is_none(),
5622 "state_effect must be omitted when the tool declared nothing"
5623 );
5624 }
5625
5626 #[tokio::test]
5627 async fn tool_call_records_declared_state_effect() {
5628 let dir = tempfile::tempdir().unwrap();
5629 let mut w = make_writer(dir.path()).await;
5630 w.write_tool_call(
5631 0,
5632 "murmur-tool-editor".to_string(),
5633 None,
5634 serde_json::json!({"operation": "read_file", "path": "a.txt"}),
5635 10,
5636 "hi\n",
5637 3,
5638 5,
5639 "ok".to_string(),
5640 Some("read".to_string()),
5641 None,
5642 )
5643 .await
5644 .unwrap();
5645 w.flush().await.unwrap();
5646
5647 let events = read_events(dir.path());
5648 assert_eq!(
5649 events[0]["state_effect"], "read",
5650 "declared state_effect must be recorded verbatim on the tool_call event"
5651 );
5652 }
5653
5654 #[tokio::test]
5655 async fn tool_call_records_declared_resource_id() {
5656 let dir = tempfile::tempdir().unwrap();
5657 let mut w = make_writer(dir.path()).await;
5658 w.write_tool_call(
5659 0,
5660 "murmur-tool-code-graph".to_string(),
5661 None,
5662 serde_json::json!({"symbol": "Foo::bar"}),
5663 10,
5664 "hi\n",
5665 3,
5666 5,
5667 "ok".to_string(),
5668 Some("read".to_string()),
5669 Some("sym:Foo".to_string()),
5670 )
5671 .await
5672 .unwrap();
5673 w.flush().await.unwrap();
5674
5675 let events = read_events(dir.path());
5676 assert_eq!(
5677 events[0]["resource_id"], "sym:Foo",
5678 "declared resource_id must reach trace.jsonl verbatim, unparsed"
5679 );
5680 }
5681
5682 #[tokio::test]
5683 async fn tool_call_omits_undeclared_resource_id() {
5684 let dir = tempfile::tempdir().unwrap();
5685 let mut w = make_writer(dir.path()).await;
5686 w.write_tool_call(
5687 0,
5688 "bash".to_string(),
5689 None,
5690 serde_json::json!({"command": "echo hi"}),
5691 10,
5692 "hi\n",
5693 3,
5694 5,
5695 "ok".to_string(),
5696 None,
5697 None,
5698 )
5699 .await
5700 .unwrap();
5701 w.flush().await.unwrap();
5702
5703 let events = read_events(dir.path());
5704 assert!(
5705 events[0].get("resource_id").is_none(),
5706 "resource_id must be omitted entirely (not null) when the tool declared nothing"
5707 );
5708 }
5709
5710 #[tokio::test]
5711 async fn shell_fields() {
5712 let dir = tempfile::tempdir().unwrap();
5713 let mut w = make_writer(dir.path()).await;
5714 w.write_shell(
5715 0,
5716 "/usr/bin/echo".to_string(),
5717 "echo hi".to_string(),
5718 0,
5719 7,
5720 0,
5721 10,
5722 None,
5723 )
5724 .await
5725 .unwrap();
5726 w.flush().await.unwrap();
5727
5728 let events = read_events(dir.path());
5729 let e = &events[0];
5730 assert_eq!(e["event_type"], "shell");
5731 assert_eq!(e["binary"], "/usr/bin/echo");
5734 assert_eq!(e["command"], "echo hi");
5735 assert_eq!(e["exit_code"], 0);
5736 assert_eq!(e["stdout_bytes"], 7);
5737 assert_eq!(e["stderr_bytes"], 0);
5738 assert_eq!(e["duration_ms"], 10);
5739 }
5740
5741 #[tokio::test]
5742 async fn compaction_fields() {
5743 let dir = tempfile::tempdir().unwrap();
5744 let mut w = make_writer(dir.path()).await;
5745 w.write_compaction(3, 80000, 20000).await.unwrap();
5746 w.flush().await.unwrap();
5747
5748 let events = read_events(dir.path());
5749 let e = &events[0];
5750 assert_eq!(e["event_type"], "compaction");
5751 assert_eq!(e["turn"], 3);
5752 assert_eq!(e["tokens_before"], 80000);
5753 assert_eq!(e["tokens_after"], 20000);
5754 }
5755
5756 #[tokio::test]
5757 async fn hook_dispatch_error_fields() {
5758 let dir = tempfile::tempdir().unwrap();
5759 let mut w = make_writer(dir.path()).await;
5760 w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
5761 .await
5762 .unwrap();
5763 w.flush().await.unwrap();
5764
5765 let events = read_events(dir.path());
5766 let e = &events[0];
5767 assert_eq!(e["event_type"], "hook_dispatch_error");
5768 assert_eq!(e["session_id"], "test-session-id");
5769 assert_eq!(e["hook_name"], "my-hook");
5770 assert_eq!(e["event"], "on-tool-call");
5771 assert_eq!(e["arm"], "write-manifests");
5772 assert!(e["timestamp"].as_u64().unwrap() > 0);
5773 assert!(e.get("hookName").is_none(), "must use snake_case");
5775 }
5776
5777 #[tokio::test]
5778 async fn session_end_fields_and_snake_case() {
5779 let dir = tempfile::tempdir().unwrap();
5780 let mut w = make_writer(dir.path()).await;
5781 w.write_inference(
5782 0,
5783 Some(100),
5784 Some(50),
5785 "tool_call".to_string(),
5786 Some("tool_call"),
5787 None,
5788 None,
5789 None,
5790 Vec::new(),
5791 None,
5792 )
5793 .await
5794 .unwrap();
5795 w.write_tool_call(
5796 0,
5797 "bash".to_string(),
5798 None,
5799 serde_json::json!({"command": "ls"}),
5800 10,
5801 "file.txt\n",
5802 20,
5803 5,
5804 "ok".to_string(),
5805 None,
5806 None,
5807 )
5808 .await
5809 .unwrap();
5810 w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
5811 .await
5812 .unwrap();
5813 w.write_session_end("ok").await.unwrap();
5814 w.flush().await.unwrap();
5815
5816 let events = read_events(dir.path());
5817 let se = events.last().unwrap();
5818 assert_eq!(se["event_type"], "session_end");
5819 assert_eq!(se["total_turns"], 1);
5820 assert_eq!(se["total_input_tokens"], 100);
5821 assert_eq!(se["total_output_tokens"], 50);
5822 assert_eq!(se["total_tool_calls"], 1);
5823 assert_eq!(se["total_shell_calls"], 1);
5824 assert_eq!(se["exit_status"], "ok");
5825 assert!(se["duration_ms"].as_u64().is_some());
5826 assert!(se.get("exitStatus").is_none(), "must use snake_case");
5828 assert!(se.get("totalTurns").is_none(), "must use snake_case");
5829 }
5830
5831 #[tokio::test]
5832 async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
5833 let dir = tempfile::tempdir().unwrap();
5834 let mut w = make_writer(dir.path()).await;
5835 w.write_session_start(10, vec![]).await.unwrap();
5836 w.write_session_end("ok").await.unwrap();
5837 w.write_session_end_if_not_ended("failed").await.unwrap();
5838 w.flush().await.unwrap();
5839
5840 let events = read_events(dir.path());
5841 assert_eq!(
5842 events
5843 .iter()
5844 .filter(|e| e["event_type"] == "session_end")
5845 .count(),
5846 1,
5847 "session_end should appear exactly once"
5848 );
5849 }
5850
5851 #[tokio::test]
5852 async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
5853 let dir = tempfile::tempdir().unwrap();
5854 let mut w = make_writer(dir.path()).await;
5855 w.write_session_start(10, vec![]).await.unwrap();
5856 w.write_session_end_if_not_ended("failed").await.unwrap();
5858 w.flush().await.unwrap();
5859
5860 let events = read_events(dir.path());
5861 let se = events
5862 .iter()
5863 .find(|e| e["event_type"] == "session_end")
5864 .unwrap();
5865 assert_eq!(se["exit_status"], "failed");
5866 }
5867
5868 #[tokio::test]
5869 async fn no_session_end_written_when_session_never_started() {
5870 let dir = tempfile::tempdir().unwrap();
5871 let mut w = make_writer(dir.path()).await;
5872 w.write_session_end_if_not_ended("failed").await.unwrap();
5874 w.flush().await.unwrap();
5875
5876 let events = read_events(dir.path());
5877 assert!(
5878 events.iter().all(|e| e["event_type"] != "session_end"),
5879 "session_end must not appear when session_start was never written"
5880 );
5881 }
5882
5883 #[tokio::test]
5884 async fn session_id_on_every_event() {
5885 let dir = tempfile::tempdir().unwrap();
5886 let mut w = make_writer(dir.path()).await;
5887 w.write_session_start(10, vec!["bash".to_string()])
5888 .await
5889 .unwrap();
5890 w.write_inference(
5891 0,
5892 Some(10),
5893 Some(5),
5894 "end_turn".to_string(),
5895 Some("end_turn"),
5896 None,
5897 None,
5898 None,
5899 Vec::new(),
5900 None,
5901 )
5902 .await
5903 .unwrap();
5904 w.write_session_end("ok").await.unwrap();
5905 w.flush().await.unwrap();
5906
5907 let events = read_events(dir.path());
5908 for e in &events {
5909 assert_eq!(e["session_id"], "test-session-id");
5910 }
5911 }
5912
5913 #[tokio::test]
5914 async fn counts_match_per_event_sum() {
5915 let dir = tempfile::tempdir().unwrap();
5916 let mut w = make_writer(dir.path()).await;
5917 w.write_session_start(10, vec![]).await.unwrap();
5918 w.write_inference(
5919 0,
5920 Some(50),
5921 Some(25),
5922 "tool_call".to_string(),
5923 Some("tool_call"),
5924 Some("bash".to_string()),
5925 None,
5926 None,
5927 vec!["msg_0000000000000000000000000000000a".to_string()],
5928 None,
5929 )
5930 .await
5931 .unwrap();
5932 w.write_tool_call(
5933 0,
5934 "bash".to_string(),
5935 None,
5936 serde_json::json!({"command": "echo"}),
5937 5,
5938 "ok",
5939 10,
5940 3,
5941 "ok".to_string(),
5942 None,
5943 None,
5944 )
5945 .await
5946 .unwrap();
5947 w.write_shell(
5948 0,
5949 "/bin/echo".to_string(),
5950 "echo".to_string(),
5951 0,
5952 4,
5953 0,
5954 1,
5955 None,
5956 )
5957 .await
5958 .unwrap();
5959 w.write_inference(
5960 1,
5961 Some(60),
5962 Some(30),
5963 "end_turn".to_string(),
5964 Some("end_turn"),
5965 None,
5966 None,
5967 None,
5968 Vec::new(),
5969 None,
5970 )
5971 .await
5972 .unwrap();
5973 w.write_session_end("ok").await.unwrap();
5974 w.flush().await.unwrap();
5975
5976 let events = read_events(dir.path());
5977 let inference_count = events
5978 .iter()
5979 .filter(|e| e["event_type"] == "inference")
5980 .count();
5981 let tool_count = events
5982 .iter()
5983 .filter(|e| e["event_type"] == "tool_call")
5984 .count();
5985 let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
5986
5987 let se = events
5988 .iter()
5989 .find(|e| e["event_type"] == "session_end")
5990 .unwrap();
5991 assert_eq!(
5992 se["total_turns"].as_u64().unwrap() as usize,
5993 inference_count
5994 );
5995 assert_eq!(
5996 se["total_tool_calls"].as_u64().unwrap() as usize,
5997 tool_count
5998 );
5999 assert_eq!(
6000 se["total_shell_calls"].as_u64().unwrap() as usize,
6001 shell_count
6002 );
6003 }
6004
6005 #[tokio::test]
6006 async fn tool_call_input_always_present() {
6007 let dir = tempfile::tempdir().unwrap();
6008 let mut w = make_writer(dir.path()).await; w.write_tool_call(
6010 0,
6011 "bash".to_string(),
6012 None,
6013 serde_json::json!({"command": "ls -la"}),
6014 20,
6015 "total 8\n",
6016 80,
6017 12,
6018 "ok".to_string(),
6019 None,
6020 None,
6021 )
6022 .await
6023 .unwrap();
6024 w.flush().await.unwrap();
6025
6026 let events = read_events(dir.path());
6027 let e = &events[0];
6028 assert_eq!(e["event_type"], "tool_call");
6029 assert_eq!(e["input"]["command"], "ls -la");
6030 assert!(e.get("input").is_some(), "input must always be present");
6031 }
6032
6033 #[tokio::test]
6034 async fn tool_call_output_absent_by_default() {
6035 let dir = tempfile::tempdir().unwrap();
6036 let mut w = make_writer(dir.path()).await; w.write_tool_call(
6038 0,
6039 "bash".to_string(),
6040 None,
6041 serde_json::json!({"command": "ls"}),
6042 10,
6043 "file.txt\n",
6044 90,
6045 8,
6046 "ok".to_string(),
6047 None,
6048 None,
6049 )
6050 .await
6051 .unwrap();
6052 w.flush().await.unwrap();
6053
6054 let events = read_events(dir.path());
6055 let e = &events[0];
6056 assert!(
6057 e.get("output").is_none(),
6058 "output must be absent under a capture mode that stores no bodies"
6059 );
6060 assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
6061 }
6062
6063 #[tokio::test]
6064 async fn skill_call_writes_skill_call_event() {
6065 let dir = tempfile::tempdir().unwrap();
6066 let mut w = make_writer(dir.path()).await;
6067 w.write_skill_call(
6068 2,
6069 "my-skill".to_string(),
6070 &LockOrigin::Operator,
6071 1024,
6072 8,
6073 "ok".to_string(),
6074 )
6075 .await
6076 .unwrap();
6077 w.flush().await.unwrap();
6078
6079 let events = read_events(dir.path());
6080 let e = &events[0];
6081 assert_eq!(e["event_type"], "skill_call");
6082 assert_eq!(e["turn"], 2);
6083 assert_eq!(e["skill_name"], "my-skill");
6084 assert_eq!(e["output_bytes"], 1024);
6085 assert_eq!(e["duration_ms"], 8);
6086 assert_eq!(e["status"], "ok");
6087 }
6088
6089 #[tokio::test]
6090 async fn skill_call_does_not_increment_total_tool_calls() {
6091 let dir = tempfile::tempdir().unwrap();
6092 let mut w = make_writer(dir.path()).await;
6093 w.write_session_start(10, vec![]).await.unwrap();
6094 w.write_tool_call(
6095 0,
6096 "real-tool".to_string(),
6097 None,
6098 serde_json::json!({}),
6099 2,
6100 "data",
6101 4,
6102 5,
6103 "ok".to_string(),
6104 None,
6105 None,
6106 )
6107 .await
6108 .unwrap();
6109 w.write_skill_call(
6110 1,
6111 "my-skill".to_string(),
6112 &LockOrigin::Operator,
6113 512,
6114 3,
6115 "ok".to_string(),
6116 )
6117 .await
6118 .unwrap();
6119 w.write_session_end("ok").await.unwrap();
6120 w.flush().await.unwrap();
6121
6122 let events = read_events(dir.path());
6123 let se = events
6124 .iter()
6125 .find(|e| e["event_type"] == "session_end")
6126 .unwrap();
6127 assert_eq!(
6128 se["total_tool_calls"], 1,
6129 "skill_call must not inflate total_tool_calls"
6130 );
6131 }
6132
6133 #[tokio::test]
6134 async fn tool_call_output_present_when_opted_in() {
6135 let dir = tempfile::tempdir().unwrap();
6136 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6137 w.write_tool_call(
6138 0,
6139 "bash".to_string(),
6140 None,
6141 serde_json::json!({"command": "echo hello"}),
6142 22,
6143 "hello\n",
6144 60,
6145 5,
6146 "ok".to_string(),
6147 None,
6148 None,
6149 )
6150 .await
6151 .unwrap();
6152 w.flush().await.unwrap();
6153
6154 let events = read_events(dir.path());
6155 let e = &events[0];
6156 assert_eq!(
6157 e["output"].as_str().unwrap(),
6158 "hello\n",
6159 "output must be present under capture: content"
6160 );
6161 assert_eq!(e["output_bytes"], 60);
6162 }
6163
6164 #[tokio::test]
6165 async fn task_end_carries_reopen_count() {
6166 let dir = tempfile::tempdir().unwrap();
6167 let mut w = make_writer(dir.path()).await;
6168 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6169 .await
6170 .unwrap();
6171 w.write_task_end("tsk_1", "ok", 2).await.unwrap();
6172 w.flush().await.unwrap();
6173
6174 let events = read_events(dir.path());
6175 let end = events
6176 .iter()
6177 .find(|e| e["event_type"] == "task_end")
6178 .unwrap();
6179 assert_eq!(end["task_id"], "tsk_1");
6180 assert_eq!(end["exit_status"], "ok");
6181 assert_eq!(end["reopen_count"], 2);
6182 }
6183
6184 #[tokio::test]
6185 async fn task_reopened_names_hook_reason_and_ordinal() {
6186 let dir = tempfile::tempdir().unwrap();
6187 let mut w = make_writer(dir.path()).await;
6188 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6189 .await
6190 .unwrap();
6191 w.write_task_reopened(
6192 "tsk_1",
6193 "gatekeeper",
6194 "tests still fail",
6195 1,
6196 REOPEN_CONTEXT_CONTINUED,
6197 7,
6198 )
6199 .await
6200 .unwrap();
6201 w.flush().await.unwrap();
6202
6203 let events = read_events(dir.path());
6204 let re = events
6205 .iter()
6206 .find(|e| e["event_type"] == "task_reopened")
6207 .unwrap();
6208 assert_eq!(re["task_id"], "tsk_1");
6209 assert_eq!(re["hook_name"], "gatekeeper");
6210 assert_eq!(re["reason"], "tests still fail");
6211 assert_eq!(re["reopen_number"], 1);
6212 assert_eq!(re["attempt_context"], "continued");
6213 assert_eq!(re["turns_remaining"], 7);
6214 assert!(re["timestamp"].as_u64().unwrap() > 0);
6215 }
6216
6217 #[tokio::test]
6218 async fn task_continued_names_the_round_under_the_task_node() {
6219 let dir = tempfile::tempdir().unwrap();
6220 let mut w = make_writer(dir.path()).await;
6221 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6222 .await
6223 .unwrap();
6224 w.write_task_continued(
6225 "tsk_1",
6226 1,
6227 &["mcl_b".to_string(), "mcl_a".to_string()],
6228 &["dlg_1".to_string()],
6229 1234,
6230 8,
6231 )
6232 .await
6233 .unwrap();
6234 w.flush().await.unwrap();
6235
6236 let events = read_events(dir.path());
6237 let start = events
6238 .iter()
6239 .find(|e| e["event_type"] == "task_start")
6240 .unwrap();
6241 let continued = events
6242 .iter()
6243 .find(|e| e["event_type"] == "task_continued")
6244 .unwrap();
6245 assert_eq!(continued["parent_id"], start["event_id"]);
6246 assert_eq!(continued["task_id"], "tsk_1");
6247 assert_eq!(continued["continuation_number"], 1);
6248 assert_eq!(
6249 continued["member_calls"],
6250 serde_json::json!(["mcl_b", "mcl_a"])
6251 );
6252 assert_eq!(continued["delegations"], serde_json::json!(["dlg_1"]));
6253 assert_eq!(continued["waited_ms"], 1234);
6254 assert_eq!(continued["turns_remaining"], 8);
6255 }
6256
6257 #[tokio::test]
6261 async fn task_turns_accessor_tracks_and_resets() {
6262 let dir = tempfile::tempdir().unwrap();
6263 let mut w = make_writer(dir.path()).await;
6264 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6265 .await
6266 .unwrap();
6267 assert_eq!(w.task_turns(), 0);
6268 w.write_inference(
6269 0,
6270 Some(10),
6271 Some(5),
6272 "end_turn".to_string(),
6273 Some("end_turn"),
6274 None,
6275 None,
6276 None,
6277 Vec::new(),
6278 None,
6279 )
6280 .await
6281 .unwrap();
6282 w.write_inference(
6283 1,
6284 Some(10),
6285 Some(5),
6286 "end_turn".to_string(),
6287 Some("end_turn"),
6288 None,
6289 None,
6290 None,
6291 Vec::new(),
6292 None,
6293 )
6294 .await
6295 .unwrap();
6296 assert_eq!(w.task_turns(), 2);
6297 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3)
6299 .await
6300 .unwrap();
6301 assert_eq!(w.task_turns(), 0);
6302 }
6303
6304 #[tokio::test]
6307 async fn task_agent_turns_counts_agent_loop_inference_only() {
6308 let dir = tempfile::tempdir().unwrap();
6309 let mut w = make_writer(dir.path()).await;
6310 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6311 .await
6312 .unwrap();
6313 assert_eq!(w.task_agent_turns(), 0);
6314 let hook = InferenceOrigin {
6315 source: "hook:x".into(),
6316 model: "m".into(),
6317 };
6318 for (turn, origin) in [(0, None), (0, Some(&hook)), (1, None)] {
6319 w.write_inference(
6320 turn,
6321 Some(10),
6322 Some(5),
6323 "end_turn".to_string(),
6324 Some("end_turn"),
6325 None,
6326 origin,
6327 None,
6328 Vec::new(),
6329 None,
6330 )
6331 .await
6332 .unwrap();
6333 }
6334 assert_eq!(w.task_agent_turns(), 2);
6335 assert_eq!(w.task_turns(), 3);
6336 assert_eq!(w.task_hook_inferences(), 1);
6337 w.write_task_end("tsk_1", "completed", 0).await.unwrap();
6338 assert_eq!(
6339 w.task_agent_turns(),
6340 0,
6341 "a run outside a task frame starts at 0"
6342 );
6343 w.write_inference(
6344 0,
6345 Some(10),
6346 Some(5),
6347 "end_turn".to_string(),
6348 Some("end_turn"),
6349 None,
6350 None,
6351 None,
6352 Vec::new(),
6353 None,
6354 )
6355 .await
6356 .unwrap();
6357 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3)
6358 .await
6359 .unwrap();
6360 assert_eq!(w.task_agent_turns(), 0);
6361 }
6362
6363 #[tokio::test]
6369 async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
6370 let dir = tempfile::tempdir().unwrap();
6371 let mut w = make_writer(dir.path()).await;
6372 w.write_session_start(10, vec!["bash".to_string()])
6373 .await
6374 .unwrap();
6375 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6376 .await
6377 .unwrap();
6378 w.write_inference(
6379 0,
6380 Some(10),
6381 Some(5),
6382 "tool_call".to_string(),
6383 Some("tool_call"),
6384 None,
6385 None,
6386 None,
6387 Vec::new(),
6388 None,
6389 )
6390 .await
6391 .unwrap();
6392 w.write_tool_call(
6393 0,
6394 "bash".to_string(),
6395 Some("toolu_1".to_string()),
6396 serde_json::json!({"command": "echo hi"}),
6397 10,
6398 "hi\n",
6399 3,
6400 5,
6401 "ok".to_string(),
6402 None,
6403 None,
6404 )
6405 .await
6406 .unwrap();
6407 w.write_shell(
6408 0,
6409 "/bin/bash".to_string(),
6410 "echo hi".to_string(),
6411 0,
6412 3,
6413 0,
6414 2,
6415 None,
6416 )
6417 .await
6418 .unwrap();
6419 w.write_artifact_pulled(&runtime_summary("pulled-style", ArtifactRuntime::Skill))
6420 .await
6421 .unwrap();
6422 w.write_task_end("tsk_1", "ok", 0).await.unwrap();
6423 w.write_session_end("ok").await.unwrap();
6424 w.flush().await.unwrap();
6425
6426 let events = read_events(dir.path());
6427 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
6428 for (i, event) in events.iter().enumerate() {
6429 let id = event["event_id"].as_str().unwrap();
6430 assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
6431 assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
6432 assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
6433 assert!(
6434 event.get("parent_id").is_some(),
6435 "event {i} has no parent_id"
6436 );
6437 if let Some(parent) = event["parent_id"].as_str() {
6438 assert!(seen.contains(parent), "event {i} names dangling {parent}");
6439 }
6440 }
6441
6442 let by_type = |ty: &str| {
6443 events
6444 .iter()
6445 .find(|e| e["event_type"] == ty)
6446 .unwrap_or_else(|| panic!("no {ty}"))
6447 .clone()
6448 };
6449 let session = by_type("session_start");
6450 assert!(session["parent_id"].is_null());
6451 assert_eq!(session["event_id"], w.session_event_id());
6452
6453 let task = by_type("task_start");
6454 assert_eq!(task["parent_id"], session["event_id"]);
6455
6456 let turn = by_type("inference");
6457 assert_eq!(turn["parent_id"], task["event_id"]);
6458
6459 assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
6460 assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
6461 assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
6462 assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
6463 assert_eq!(by_type("artifact_pulled")["parent_id"], session["event_id"]);
6464 }
6465
6466 fn runtime_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
6467 InstalledArtifactSummary {
6468 name: name.to_string(),
6469 version: "1.0.0".to_string(),
6470 runtime,
6471 implementation: None,
6472 origin: LockOrigin::Runtime {
6473 session: "ses_puller".to_string(),
6474 },
6475 }
6476 }
6477
6478 fn operator_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
6479 InstalledArtifactSummary {
6480 origin: LockOrigin::Operator,
6481 ..runtime_summary(name, runtime)
6482 }
6483 }
6484
6485 #[tokio::test]
6488 async fn runtime_origin_session_start_lists_nothing_for_an_operator_session() {
6489 for set in [false, true] {
6490 let dir = tempfile::tempdir().unwrap();
6491 let mut w = make_writer(dir.path()).await;
6492 if set {
6493 w.set_runtime_artifacts(&[
6494 operator_summary("house-style", ArtifactRuntime::Skill),
6495 operator_summary("fetcher", ArtifactRuntime::Tool),
6496 ]);
6497 }
6498 w.write_session_start(10, Vec::new()).await.unwrap();
6499 w.flush().await.unwrap();
6500 let events = read_events(dir.path());
6501 assert_eq!(events[0]["runtime_artifacts"], serde_json::json!([]));
6502 }
6503 }
6504
6505 #[tokio::test]
6508 async fn runtime_origin_session_start_lists_runtime_artifacts_in_order() {
6509 let dir = tempfile::tempdir().unwrap();
6510 let mut w = make_writer(dir.path()).await;
6511 w.set_runtime_artifacts(&[
6512 runtime_summary("zeta-tool", ArtifactRuntime::Tool),
6513 operator_summary("house-style", ArtifactRuntime::Skill),
6514 InstalledArtifactSummary {
6515 origin: LockOrigin::Runtime {
6516 session: "ses_other".to_string(),
6517 },
6518 ..runtime_summary("alpha-style", ArtifactRuntime::Skill)
6519 },
6520 ]);
6521 w.write_session_start(10, Vec::new()).await.unwrap();
6522 w.flush().await.unwrap();
6523
6524 let events = read_events(dir.path());
6525 assert_eq!(
6526 events[0]["runtime_artifacts"],
6527 serde_json::json!([
6528 {"name": "zeta-tool", "version": "1.0.0", "origin": "runtime",
6529 "session": "ses_puller", "trust": "untrusted"},
6530 {"name": "alpha-style", "version": "1.0.0", "origin": "runtime",
6531 "session": "ses_other", "trust": "untrusted"},
6532 ])
6533 );
6534 }
6535
6536 #[tokio::test]
6537 async fn runtime_origin_skill_call_carries_origin_and_trust() {
6538 let dir = tempfile::tempdir().unwrap();
6539 let mut w = make_writer(dir.path()).await;
6540 for origin in [
6541 LockOrigin::Operator,
6542 LockOrigin::Runtime {
6543 session: "ses_puller".to_string(),
6544 },
6545 ] {
6546 w.write_skill_call(0, "a-skill".to_string(), &origin, 1, 1, "ok".to_string())
6547 .await
6548 .unwrap();
6549 }
6550 w.flush().await.unwrap();
6551
6552 let events = read_events(dir.path());
6553 assert_eq!(events[0]["origin"], "operator");
6554 assert_eq!(events[0]["trust"], "trusted");
6555 assert_eq!(events[1]["origin"], "runtime");
6556 assert_eq!(events[1]["trust"], "untrusted");
6557 }
6558
6559 #[tokio::test]
6561 async fn runtime_origin_artifact_pulled_writes_every_key() {
6562 let dir = tempfile::tempdir().unwrap();
6563 let mut w = make_writer(dir.path()).await;
6564 w.write_artifact_pulled(&runtime_summary("pulled-style", ArtifactRuntime::Skill))
6565 .await
6566 .unwrap();
6567 w.write_artifact_pulled(&operator_summary("fetcher", ArtifactRuntime::Tool))
6568 .await
6569 .unwrap();
6570 w.flush().await.unwrap();
6571
6572 let events = read_events(dir.path());
6573 let runtime = &events[0];
6574 assert_eq!(runtime["event_type"], "artifact_pulled");
6575 assert_eq!(runtime["name"], "pulled-style");
6576 assert_eq!(runtime["version"], "1.0.0");
6577 assert_eq!(runtime["runtime"], "skill");
6578 assert_eq!(runtime["origin"], "runtime");
6579 assert_eq!(runtime["session"], "ses_puller");
6580 assert_eq!(runtime["trust"], "untrusted");
6581 assert!(
6582 runtime["parent_id"].is_null(),
6583 "no session_start was written"
6584 );
6585
6586 let operator = &events[1];
6587 assert_eq!(operator["runtime"], "tool");
6588 assert_eq!(operator["origin"], "operator");
6589 assert!(operator.get("session").is_some_and(Value::is_null));
6590 assert_eq!(operator["trust"], "trusted");
6591 }
6592
6593 #[tokio::test]
6597 async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
6598 let dir = tempfile::tempdir().unwrap();
6599 let mut w = make_writer(dir.path()).await;
6600 w.write_session_start(10, Vec::new()).await.unwrap();
6601 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6602 .await
6603 .unwrap();
6604 w.write_inference(
6605 0,
6606 Some(10),
6607 Some(5),
6608 "tool_call".to_string(),
6609 Some("tool_call"),
6610 None,
6611 None,
6612 None,
6613 Vec::new(),
6614 None,
6615 )
6616 .await
6617 .unwrap();
6618 let origin = InferenceOrigin {
6619 source: "hook:gatekeeper".to_string(),
6620 model: "claude-test".to_string(),
6621 };
6622 w.write_inference(
6623 0,
6624 Some(1),
6625 Some(1),
6626 "end_turn".to_string(),
6627 None,
6629 None,
6630 Some(&origin),
6631 None,
6632 Vec::new(),
6633 None,
6634 )
6635 .await
6636 .unwrap();
6637 w.flush().await.unwrap();
6638
6639 let events = read_events(dir.path());
6640 let turn = &events[2];
6641 let hook = &events[3];
6642 assert!(turn["origin"].is_null());
6643 assert_eq!(hook["origin"], "hook:gatekeeper");
6644 assert_eq!(hook["parent_id"], turn["event_id"]);
6645 assert!(hook.get("message_ids").is_none(), "{hook}");
6648 }
6649
6650 #[tokio::test]
6654 async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
6655 let bare = tempfile::tempdir().unwrap();
6656 let mut w = make_writer(bare.path()).await;
6657 w.write_a2a_send(
6658 "http://peer",
6659 "msg_1",
6660 "tsk_1",
6661 "ctx_1",
6662 None,
6663 TrustClass::Untrusted,
6664 )
6665 .await
6666 .unwrap();
6667 w.flush().await.unwrap();
6668 assert!(read_events(bare.path())[0]["parent_id"].is_null());
6669
6670 let framed = tempfile::tempdir().unwrap();
6671 let mut w = make_writer(framed.path()).await;
6672 w.write_session_start(1, Vec::new()).await.unwrap();
6673 w.write_a2a_send(
6674 "http://peer",
6675 "msg_1",
6676 "tsk_1",
6677 "ctx_1",
6678 None,
6679 TrustClass::Untrusted,
6680 )
6681 .await
6682 .unwrap();
6683 w.flush().await.unwrap();
6684 let events = read_events(framed.path());
6685 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
6686 }
6687
6688 #[tokio::test]
6690 async fn task_rejected_names_the_task_source_cause_and_reason_under_the_session() {
6691 let dir = tempfile::tempdir().unwrap();
6692 let mut w = make_writer(dir.path()).await;
6693 w.write_session_start(10, Vec::new()).await.unwrap();
6694 w.write_task_start("tsk_ran", "ctx_ran", "a2a", event_provenance(), 3)
6695 .await
6696 .unwrap();
6697 w.write_task_end("tsk_ran", "ok", 0).await.unwrap();
6698 w.write_task_rejected(
6699 "tsk_1",
6700 "ctx_1",
6701 "detached_shell",
6702 TASK_REJECTED_SESSION_ENDED,
6703 "task rejected: the session ended before this task started",
6704 )
6705 .await
6706 .unwrap();
6707 w.flush().await.unwrap();
6708
6709 let events = read_events(dir.path());
6710 let session = events
6711 .iter()
6712 .find(|e| e["event_type"] == "session_start")
6713 .unwrap();
6714 let rejected: Vec<_> = events
6715 .iter()
6716 .filter(|e| e["event_type"] == "task_rejected")
6717 .collect();
6718 assert_eq!(rejected.len(), 1);
6719 let rejected = rejected[0];
6720 assert_eq!(rejected["parent_id"], session["event_id"], "{rejected}");
6721 assert_eq!(rejected["session_id"], session["session_id"]);
6722 assert_eq!(rejected["task_id"], "tsk_1");
6723 assert_eq!(rejected["context_id"], "ctx_1");
6724 assert_eq!(rejected["source"], "detached_shell");
6725 assert_eq!(rejected["cause"], "session_ended");
6726 assert_eq!(
6727 rejected["reason"],
6728 "task rejected: the session ended before this task started"
6729 );
6730 assert!(rejected["event_id"].is_string());
6731 assert!(rejected["timestamp"].is_u64());
6732 assert_eq!(rejected.as_object().unwrap().len(), 10, "{rejected}");
6733 }
6734
6735 #[tokio::test]
6738 async fn task_failed_names_the_task_turn_cause_and_capped_reason() {
6739 let dir = tempfile::tempdir().unwrap();
6740 let mut w = make_writer(dir.path()).await;
6741 w.write_session_start(10, Vec::new()).await.unwrap();
6742 assert_eq!(w.last_task_failure(), None);
6743 w.write_task_failed(None, TASK_FAILED_RUNTIME_ERROR, "before any task")
6744 .await
6745 .unwrap();
6746 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6747 .await
6748 .unwrap();
6749 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
6750 w.write_task_failed(Some(2), TASK_FAILED_DRIVER_ERROR, &long)
6751 .await
6752 .unwrap();
6753 w.write_task_end("tsk_1", "failed", 0).await.unwrap();
6754 w.flush().await.unwrap();
6755
6756 assert_eq!(w.task_failures_written(), 2);
6757 let events = read_events(dir.path());
6758 let failed: Vec<_> = events
6759 .iter()
6760 .filter(|e| e["event_type"] == "task_failed")
6761 .collect();
6762 assert_eq!(failed.len(), 2);
6763
6764 assert!(failed[0].get("task_id").is_none(), "{}", failed[0]);
6765 assert!(failed[0].get("turn").is_none(), "{}", failed[0]);
6766 assert_eq!(failed[0]["cause"], "runtime_error");
6767 assert_eq!(failed[0]["reason"], "before any task");
6768
6769 let start = events
6770 .iter()
6771 .find(|e| e["event_type"] == "task_start")
6772 .unwrap();
6773 assert_eq!(failed[1]["task_id"], "tsk_1");
6774 assert_eq!(failed[1]["turn"], 2);
6775 assert_eq!(failed[1]["cause"], "driver_error");
6776 assert_eq!(failed[1]["parent_id"], start["event_id"]);
6777 assert_eq!(failed[1]["session_id"], start["session_id"]);
6778 assert!(failed[1]["event_id"].is_string());
6779 assert!(failed[1]["timestamp"].is_u64());
6780 let reason = failed[1]["reason"].as_str().unwrap();
6781 assert!(reason.len() <= MAX_TASK_FAILURE_REASON_BYTES);
6782 assert!(reason.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
6783 assert_eq!(w.last_task_failure(), Some(reason));
6784 }
6785
6786 #[tokio::test]
6789 async fn turn_level_events_carry_the_active_task_id() {
6790 let dir = tempfile::tempdir().unwrap();
6791 let mut w = make_writer(dir.path()).await;
6792 w.write_session_start(10, Vec::new()).await.unwrap();
6793 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6794 .await
6795 .unwrap();
6796 w.write_inference(
6797 0,
6798 Some(10),
6799 Some(5),
6800 "tool_call".to_string(),
6801 Some("tool_call"),
6802 None,
6803 None,
6804 None,
6805 Vec::new(),
6806 None,
6807 )
6808 .await
6809 .unwrap();
6810 w.write_skill_call(
6811 0,
6812 "house-style".to_string(),
6813 &LockOrigin::Operator,
6814 12,
6815 1,
6816 "ok".to_string(),
6817 )
6818 .await
6819 .unwrap();
6820 w.write_compaction(0, 100, 40).await.unwrap();
6821 w.write_task_end("tsk_1", "ok", 0).await.unwrap();
6822 w.write_inference(
6823 1,
6824 Some(1),
6825 Some(1),
6826 "end_turn".to_string(),
6827 Some("end_turn"),
6828 None,
6829 None,
6830 None,
6831 Vec::new(),
6832 None,
6833 )
6834 .await
6835 .unwrap();
6836 w.flush().await.unwrap();
6837
6838 let events = read_events(dir.path());
6839 for event in events
6840 .iter()
6841 .filter(|e| {
6842 matches!(
6843 e["event_type"].as_str(),
6844 Some("inference" | "skill_call" | "compaction")
6845 )
6846 })
6847 .take(3)
6848 {
6849 assert_eq!(event["task_id"], "tsk_1", "{event}");
6850 }
6851 let after = events.last().unwrap();
6852 assert_eq!(after["event_type"], "inference");
6853 assert!(
6854 after["task_id"].is_null(),
6855 "an inference outside any task carries a null task_id, not the last task's"
6856 );
6857 assert_eq!(after["parent_id"], events[0]["event_id"]);
6859 }
6860
6861 #[tokio::test]
6864 async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
6865 for (given, expected) in [
6866 (Some("toolu_1".to_string()), Some("toolu_1")),
6867 (Some(String::new()), None),
6868 (None, None),
6869 ] {
6870 let dir = tempfile::tempdir().unwrap();
6871 let mut w = make_writer(dir.path()).await;
6872 w.write_tool_call(
6873 0,
6874 "bash".to_string(),
6875 given.clone(),
6876 serde_json::json!({}),
6877 2,
6878 "",
6879 0,
6880 1,
6881 "ok".to_string(),
6882 None,
6883 None,
6884 )
6885 .await
6886 .unwrap();
6887 w.flush().await.unwrap();
6888 let e = &read_events(dir.path())[0];
6889 match expected {
6890 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
6891 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
6892 }
6893 }
6894 }
6895
6896 #[tokio::test]
6899 async fn tool_input_refused_records_its_fields_in_every_capture_mode() {
6900 for capture in [
6901 TraceCapture::None,
6902 TraceCapture::Meta,
6903 TraceCapture::Content,
6904 ] {
6905 for (given, expected) in [
6906 (Some("toolu_1"), Some("toolu_1")),
6907 (Some(""), None),
6908 (None, None),
6909 ] {
6910 let dir = tempfile::tempdir().unwrap();
6911 let mut w = make_writer_with_opts(dir.path(), capture).await;
6912 w.write_tool_input_refused(
6913 2,
6914 "murmur-tool-editor",
6915 given,
6916 &["operation".to_string(), "dest_path".to_string()],
6917 "murmur-tool-editor: missing required fields",
6918 )
6919 .await
6920 .unwrap();
6921 w.flush().await.unwrap();
6922 let e = &read_events(dir.path())[0];
6923 assert_eq!(e["event_type"], "tool_input_refused", "{capture:?}");
6924 assert!(e["event_id"].is_string());
6925 assert!(e["session_id"].is_string());
6926 assert!(e["timestamp"].is_u64());
6927 assert_eq!(e["turn"], 2);
6928 assert_eq!(e["tool_name"], "murmur-tool-editor");
6929 match expected {
6930 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
6931 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
6932 }
6933 assert_eq!(e["missing"], serde_json::json!(["operation", "dest_path"]));
6934 assert_eq!(e["reason"], "murmur-tool-editor: missing required fields");
6935 }
6936 }
6937 }
6938
6939 #[tokio::test]
6943 async fn tools_refreshed_event_records_trigger_names_and_task_parent() {
6944 let dir = tempfile::tempdir().unwrap();
6945 let mut w = make_writer(dir.path()).await;
6946 w.set_tool_refresh(Some("immediate"));
6947 w.write_session_start(10, Vec::new()).await.unwrap();
6948 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
6949 .await
6950 .unwrap();
6951 w.write_inference(
6952 0,
6953 Some(10),
6954 Some(5),
6955 "tool_call".to_string(),
6956 Some("tool_call"),
6957 None,
6958 None,
6959 None,
6960 Vec::new(),
6961 None,
6962 )
6963 .await
6964 .unwrap();
6965 w.write_tools_refreshed(
6966 1,
6967 "immediate",
6968 &["aaa-late-skill".to_string()],
6969 &[],
6970 &[
6971 "aaa-late-skill".to_string(),
6972 "zzz-existing-tool".to_string(),
6973 ],
6974 )
6975 .await
6976 .unwrap();
6977 w.flush().await.unwrap();
6978
6979 let events = read_events(dir.path());
6980 assert_eq!(events[0]["tool_refresh"], "immediate");
6981 let task = events
6982 .iter()
6983 .find(|e| e["event_type"] == "task_start")
6984 .unwrap();
6985 let refreshed: Vec<&Value> = events
6986 .iter()
6987 .filter(|e| e["event_type"] == "tools_refreshed")
6988 .collect();
6989 assert_eq!(refreshed.len(), 1);
6990 let r = refreshed[0];
6991 assert!(r["event_id"].as_str().unwrap().starts_with("evt_"));
6992 assert_eq!(r["parent_id"], task["event_id"]);
6993 assert_eq!(r["session_id"], "test-session-id");
6994 assert!(r["timestamp"].as_u64().is_some());
6995 assert_eq!(r["turn"], 1);
6996 assert_eq!(r["task_id"], "tsk_1");
6997 assert_eq!(r["trigger"], "immediate");
6998 assert_eq!(r["added"], serde_json::json!(["aaa-late-skill"]));
6999 assert_eq!(r["removed"], serde_json::json!([]));
7000 assert_eq!(
7001 r["tools"],
7002 serde_json::json!(["aaa-late-skill", "zzz-existing-tool"])
7003 );
7004
7005 let process_dir = tempfile::tempdir().unwrap();
7006 let mut process = make_writer(process_dir.path()).await;
7007 process.set_tool_refresh(None);
7008 process.write_session_start(10, Vec::new()).await.unwrap();
7009 process.flush().await.unwrap();
7010 let start = &read_events(process_dir.path())[0];
7011 assert!(start.as_object().unwrap().contains_key("tool_refresh"));
7012 assert!(start["tool_refresh"].is_null());
7013 }
7014
7015 #[tokio::test]
7018 async fn compaction_declined_records_turn_task_tokens_and_reason() {
7019 let dir = tempfile::tempdir().unwrap();
7020 let mut w = make_writer(dir.path()).await;
7021 w.write_session_start(10, Vec::new()).await.unwrap();
7022 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3)
7023 .await
7024 .unwrap();
7025 w.write_inference(
7026 2,
7027 Some(10),
7028 Some(5),
7029 "tool_call".to_string(),
7030 Some("tool_call"),
7031 None,
7032 None,
7033 None,
7034 Vec::new(),
7035 None,
7036 )
7037 .await
7038 .unwrap();
7039 w.write_compaction_declined(2, 4321, COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL)
7040 .await
7041 .unwrap();
7042 w.flush().await.unwrap();
7043
7044 let events = read_events(dir.path());
7045 let declined: Vec<&Value> = events
7046 .iter()
7047 .filter(|e| e["event_type"] == "compaction_declined")
7048 .collect();
7049 assert_eq!(declined.len(), 1);
7050 let d = declined[0];
7051 assert_eq!(d["reason"], "unresolved_tool_call");
7052 assert_eq!(d["turn"], 2);
7053 assert_eq!(d["task_id"], "tsk_1");
7054 assert_eq!(d["tokens"], 4321);
7055 assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
7056 assert_eq!(
7057 d["parent_id"],
7058 events
7059 .iter()
7060 .find(|e| e["event_type"] == "inference")
7061 .unwrap()["event_id"]
7062 );
7063 }
7064
7065 #[tokio::test]
7068 async fn event_ids_sort_by_mint_time() {
7069 let dir = tempfile::tempdir().unwrap();
7070 let mut w = make_writer(dir.path()).await;
7071 for turn in 0..5 {
7072 w.write_inference(
7073 turn,
7074 Some(1),
7075 Some(1),
7076 "end_turn".to_string(),
7077 Some("end_turn"),
7078 None,
7079 None,
7080 None,
7081 Vec::new(),
7082 None,
7083 )
7084 .await
7085 .unwrap();
7086 }
7087 w.flush().await.unwrap();
7088
7089 let ids: Vec<String> = read_events(dir.path())
7090 .iter()
7091 .map(|e| e["event_id"].as_str().unwrap().to_string())
7092 .collect();
7093 let mut sorted = ids.clone();
7094 sorted.sort();
7095 assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
7096 }
7097
7098 fn wire_payload(system: &str, messages: usize) -> Value {
7102 let messages: Vec<Value> = (0..messages)
7103 .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
7104 .collect();
7105 serde_json::json!({
7106 "model": "claude-test",
7107 "max_tokens": 1024,
7108 "messages": messages,
7109 "tools": [{"name": "bash"}],
7110 "params": {},
7111 "system": system,
7112 })
7113 }
7114
7115 async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
7116 let wire = WireCapture::from_driver_payload(payload, response);
7117 w.write_inference(
7118 turn,
7119 Some(10),
7120 Some(5),
7121 "end_turn".to_string(),
7122 Some("end_turn"),
7123 None,
7124 None,
7125 None,
7126 vec!["msg_0000000000000000000000000000000a".to_string()],
7127 Some(&wire),
7128 )
7129 .await
7130 .unwrap();
7131 }
7132
7133 fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
7134 dir.join(BLOB_DIR_NAME).join(sha)
7135 }
7136
7137 #[tokio::test]
7140 async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
7141 let dir = tempfile::tempdir().unwrap();
7142 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7143 let payload = wire_payload("You are a capsule.", 3);
7144 write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
7145 w.flush().await.unwrap();
7146
7147 let e = &read_events(dir.path())[0];
7148 let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
7149 .iter()
7150 .map(|key| {
7151 e[*key]
7152 .as_str()
7153 .unwrap_or_else(|| panic!("{key} missing from {e}"))
7154 .to_string()
7155 })
7156 .collect();
7157 let shas = e["message_shas"].as_array().expect("message_shas");
7158 assert_eq!(shas.len(), 3);
7159 named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
7160
7161 for sha in &named {
7162 let path = blob_path(dir.path(), sha);
7163 let bytes =
7164 std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
7165 assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
7166 assert_eq!(sha.len(), 64, "{sha}");
7169 assert!(
7170 sha.chars()
7171 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
7172 "{sha}"
7173 );
7174 }
7175 }
7176
7177 #[tokio::test]
7180 async fn the_system_blob_holds_the_prompt_as_readable_text() {
7181 let dir = tempfile::tempdir().unwrap();
7182 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7183 let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
7184 write_turn(&mut w, 0, &payload, "{}").await;
7185 w.flush().await.unwrap();
7186
7187 let sha = read_events(dir.path())[0]["system_sha"]
7188 .as_str()
7189 .unwrap()
7190 .to_string();
7191 assert_eq!(
7192 std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
7193 "[Capsule] you are cap.\nBe terse."
7194 );
7195 }
7196
7197 #[tokio::test]
7200 async fn meta_writes_the_same_hashes_and_no_bodies() {
7201 let payload = wire_payload("You are a capsule.", 2);
7202 let response = r#"{"stop_reason":"end_turn"}"#;
7203
7204 let meta = tempfile::tempdir().unwrap();
7205 let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
7206 write_turn(&mut w, 0, &payload, response).await;
7207 w.flush().await.unwrap();
7208
7209 let content = tempfile::tempdir().unwrap();
7210 let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
7211 write_turn(&mut w, 0, &payload, response).await;
7212 w.flush().await.unwrap();
7213
7214 let meta_event = read_events(meta.path()).remove(0);
7215 let content_event = read_events(content.path()).remove(0);
7216 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
7217 assert_eq!(meta_event[key], content_event[key], "{key}");
7218 }
7219 assert!(
7220 !meta.path().join(BLOB_DIR_NAME).exists(),
7221 "meta must not create the blob directory"
7222 );
7223 assert!(content.path().join(BLOB_DIR_NAME).is_dir());
7224 }
7225
7226 #[tokio::test]
7228 async fn the_default_capture_mode_is_meta() {
7229 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
7230 let dir = tempfile::tempdir().unwrap();
7231 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
7232 write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
7233 w.flush().await.unwrap();
7234
7235 let e = &read_events(dir.path())[0];
7236 assert!(e.get("system_sha").is_some());
7237 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
7238 }
7239
7240 #[tokio::test]
7242 async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
7243 let payload = wire_payload("You are a capsule.", 2);
7244
7245 let off = tempfile::tempdir().unwrap();
7246 let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
7247 write_turn(&mut w, 3, &payload, "{}").await;
7248 w.flush().await.unwrap();
7249
7250 let e = read_events(off.path()).remove(0);
7251 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
7252 assert!(
7253 e.get(key).is_none(),
7254 "{key} must be absent under none, got {e}"
7255 );
7256 }
7257 assert!(!off.path().join(BLOB_DIR_NAME).exists());
7258 for key in [
7259 "event_type",
7260 "event_id",
7261 "parent_id",
7262 "session_id",
7263 "timestamp",
7264 "turn",
7265 "task_id",
7266 "input_tokens",
7267 "output_tokens",
7268 "decision",
7269 "tool_name",
7270 "message_ids",
7271 ] {
7272 assert!(
7273 e.get(key).is_some(),
7274 "{key} must survive under none, got {e}"
7275 );
7276 }
7277 assert_eq!(e["turn"], 3);
7278 assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
7279 }
7280
7281 #[tokio::test]
7284 async fn an_unchanged_system_prompt_is_stored_once() {
7285 let dir = tempfile::tempdir().unwrap();
7286 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7287 let payload = wire_payload("You are a capsule.", 1);
7288 write_turn(&mut w, 0, &payload, "{}").await;
7289
7290 let sha = {
7291 w.flush().await.unwrap();
7292 read_events(dir.path())[0]["system_sha"]
7293 .as_str()
7294 .unwrap()
7295 .to_string()
7296 };
7297 let path = blob_path(dir.path(), &sha);
7298 std::fs::write(&path, b"sentinel").unwrap();
7299
7300 for turn in 1..10 {
7301 write_turn(&mut w, turn, &payload, "{}").await;
7302 }
7303 w.flush().await.unwrap();
7304
7305 let events = read_events(dir.path());
7306 assert_eq!(events.len(), 10);
7307 for e in &events {
7308 assert_eq!(e["system_sha"], sha.as_str());
7309 }
7310 assert_eq!(
7311 std::fs::read(&path).unwrap(),
7312 b"sentinel",
7313 "an existing blob is never rewritten"
7314 );
7315 let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
7316 .unwrap()
7317 .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
7318 .count();
7319 assert_eq!(matching, 1, "exactly one file bears that name");
7320 }
7321
7322 #[tokio::test]
7327 async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
7328 let dir = tempfile::tempdir().unwrap();
7329 let mut w = make_writer(dir.path()).await;
7330 w.write_inference(
7331 0,
7332 Some(10),
7333 Some(5),
7334 "end_turn".to_string(),
7335 None,
7336 None,
7337 None,
7338 None,
7339 Vec::new(),
7340 None,
7341 )
7342 .await
7343 .unwrap();
7344 w.flush().await.unwrap();
7345
7346 let event = read_events(dir.path()).remove(0);
7347 assert!(
7348 event.get("stop_reason").is_none(),
7349 "stop_reason must be absent, got {event}"
7350 );
7351 }
7352
7353 #[tokio::test]
7356 async fn a_hook_origin_record_carries_no_hashes() {
7357 let dir = tempfile::tempdir().unwrap();
7358 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7359 let origin = InferenceOrigin {
7360 source: "hook:gatekeeper".to_string(),
7361 model: "claude-test".to_string(),
7362 };
7363 w.write_inference(
7364 0,
7365 Some(1),
7366 Some(1),
7367 "end_turn".to_string(),
7368 None,
7370 None,
7371 Some(&origin),
7372 None,
7373 Vec::new(),
7374 None,
7375 )
7376 .await
7377 .unwrap();
7378 w.flush().await.unwrap();
7379
7380 let e = read_events(dir.path()).remove(0);
7381 assert_eq!(e["origin"], "hook:gatekeeper");
7382 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
7383 assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
7384 }
7385 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
7386 }
7387
7388 #[tokio::test]
7391 async fn message_shas_locate_the_index_two_runs_diverge_at() {
7392 async fn shas_for(payload: &Value) -> Vec<String> {
7393 let dir = tempfile::tempdir().unwrap();
7394 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
7395 write_turn(&mut w, 0, payload, "{}").await;
7396 w.flush().await.unwrap();
7397 read_events(dir.path())[0]["message_shas"]
7398 .as_array()
7399 .unwrap()
7400 .iter()
7401 .map(|s| s.as_str().unwrap().to_string())
7402 .collect()
7403 }
7404
7405 let first = wire_payload("You are a capsule.", 4);
7406 let mut second = first.clone();
7407 second["messages"][2]["content"] = serde_json::json!("diverged");
7408
7409 let a = shas_for(&first).await;
7410 let b = shas_for(&second).await;
7411 assert_eq!(a.len(), 4);
7412 let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
7413 assert_eq!(divergence, Some(2));
7414 assert_eq!(a[3], b[3], "only the changed message changes its own sha");
7415 }
7416
7417 #[tokio::test]
7420 async fn identical_messages_share_one_sha_and_one_blob() {
7421 let dir = tempfile::tempdir().unwrap();
7422 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7423 let payload = serde_json::json!({
7424 "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
7425 "tools": [],
7426 "system": "s",
7427 });
7428 write_turn(&mut w, 0, &payload, "{}").await;
7429 w.flush().await.unwrap();
7430
7431 let shas = read_events(dir.path())[0]["message_shas"]
7432 .as_array()
7433 .unwrap()
7434 .iter()
7435 .map(|s| s.as_str().unwrap().to_string())
7436 .collect::<Vec<_>>();
7437 assert_eq!(shas[0], shas[1]);
7438 assert!(blob_path(dir.path(), &shas[0]).is_file());
7439 }
7440
7441 #[tokio::test]
7444 async fn a_message_blob_is_the_body_verbatim() {
7445 let dir = tempfile::tempdir().unwrap();
7446 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7447 let message = serde_json::json!({"role": "user", "content": "hello"});
7448 let payload = serde_json::json!({
7449 "messages": [message.clone()],
7450 "tools": [],
7451 "system": "s",
7452 });
7453 write_turn(&mut w, 0, &payload, "{}").await;
7454 w.flush().await.unwrap();
7455
7456 let sha = read_events(dir.path())[0]["message_shas"][0]
7457 .as_str()
7458 .unwrap()
7459 .to_string();
7460 assert_eq!(
7461 std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
7462 serde_json::to_vec(&message).unwrap()
7463 );
7464 }
7465
7466 #[tokio::test]
7474 async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
7475 let plan_dir = tempfile::tempdir().unwrap();
7476 let tool_dir = tempfile::tempdir().unwrap();
7477 let session = "ses_0000000000004000800000000000plan".to_string();
7478 let node = "evt_0000000000004000800000000000node".to_string();
7479
7480 let plan = PlanTraceAppender::open(plan_dir.path(), session.clone(), node.clone()).unwrap();
7481 let tool = ResourceTraceAppender::open(tool_dir.path(), session.clone(), node.clone())
7482 .await
7483 .unwrap();
7484
7485 plan.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c");
7486 tool.write_delegation_start("dlg_abc123", "worker", "0.1.0", "ses_child", ".murmur/c")
7487 .await;
7488 plan.write_delegation(
7489 "worker",
7490 "0.1.0",
7491 Some("dlg_abc123".to_string()),
7492 Some("ses_child".to_string()),
7493 42,
7494 "completed",
7495 None,
7496 );
7497 tool.write_delegation(
7498 "worker",
7499 "0.1.0",
7500 Some("dlg_abc123".to_string()),
7501 Some("ses_child".to_string()),
7502 42,
7503 "completed",
7504 None,
7505 )
7506 .await;
7507
7508 let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
7509 std::fs::read_to_string(dir.join("trace.jsonl"))
7510 .unwrap()
7511 .lines()
7512 .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
7513 .collect()
7514 };
7515 let from_plan = read(plan_dir.path());
7516 let from_tool = read(tool_dir.path());
7517 assert_eq!(from_plan.len(), 2);
7518 assert_eq!(from_tool.len(), 2);
7519
7520 for (planned, called, event_type) in [
7521 (&from_plan[0], &from_tool[0], "delegation_start"),
7522 (&from_plan[1], &from_tool[1], "delegation"),
7523 ] {
7524 assert_eq!(planned["event_type"], event_type);
7525 assert_eq!(called["event_type"], event_type);
7526 assert_eq!(
7527 planned.keys().collect::<Vec<_>>(),
7528 called.keys().collect::<Vec<_>>(),
7529 "{planned:?} vs {called:?}"
7530 );
7531 assert_eq!(planned["parent_id"], node.as_str());
7532 assert_eq!(called["parent_id"], node.as_str());
7533 for (key, value) in planned {
7534 if key == "event_id" || key == "timestamp" {
7535 continue;
7536 }
7537 assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
7538 }
7539 for key in ["plan_id", "step_id", "surface", "kind"] {
7540 assert!(!planned.contains_key(key), "{key} is on {planned:?}");
7541 assert!(!called.contains_key(key), "{key} is on {called:?}");
7542 }
7543 }
7544 }
7545
7546 #[tokio::test]
7550 async fn spawner_ended_names_the_lineage_or_omits_it() {
7551 let dir = tempfile::tempdir().unwrap();
7552 let session = "ses_0000000000004000800000000child".to_string();
7553 let node = "evt_0000000000004000800000000000node".to_string();
7554 let appender = ResourceTraceAppender::open(dir.path(), session.clone(), node.clone())
7555 .await
7556 .unwrap();
7557
7558 appender
7559 .write_spawner_ended(Some("ses_parent"), Some("dlg_abc123"))
7560 .await;
7561 appender.write_spawner_ended(None, None).await;
7562
7563 let lines: Vec<serde_json::Map<String, Value>> =
7564 std::fs::read_to_string(dir.path().join("trace.jsonl"))
7565 .unwrap()
7566 .lines()
7567 .map(|line| serde_json::from_str(line).unwrap())
7568 .collect();
7569 assert_eq!(lines.len(), 2);
7570 let named = &lines[0];
7571 assert_eq!(named["event_type"], "spawner_ended");
7572 assert_eq!(named["parent_id"], node.as_str());
7573 assert_eq!(named["session_id"], session.as_str());
7574 assert_eq!(named["spawned_by"], "ses_parent");
7575 assert_eq!(named["delegation_id"], "dlg_abc123");
7576 assert_eq!(
7577 named.keys().collect::<Vec<_>>(),
7578 [
7579 "delegation_id",
7580 "event_id",
7581 "event_type",
7582 "parent_id",
7583 "session_id",
7584 "spawned_by",
7585 "timestamp"
7586 ]
7587 );
7588 let bare = &lines[1];
7589 assert_eq!(bare["event_type"], "spawner_ended");
7590 assert!(!bare.contains_key("spawned_by"), "{bare:?}");
7591 assert!(!bare.contains_key("delegation_id"), "{bare:?}");
7592 }
7593}