1use std::time::{Instant, SystemTime, UNIX_EPOCH};
2
3use murmur_artifact::{ContainmentClass, LockOrigin, TraceCapture};
4use serde::Serialize;
5use serde_json::Value;
6use tokio::{
7 fs::File,
8 io::{AsyncWriteExt, BufWriter},
9};
10
11use crate::{
12 agent::DriverUsage,
13 containment::ScopeReport,
14 formation::FormationId,
15 gateway_credential::CredentialChange,
16 lanes::TaskLane,
17 origin::{TaskProvenance, TrustClass},
18 trace_blobs::BlobStore,
19 types::InstalledArtifactSummary,
20 workdir_fs::{WorkdirRoot, DEFAULT_FILE_MODE},
21};
22
23pub(crate) const TRACE_FILE_NAME: &str = "trace.jsonl";
25
26pub(crate) struct TraceWriter {
27 writer: BufWriter<File>,
28 session_id: String,
29 capsule_name: String,
30 capsule_version: String,
31 model: String,
32 capabilities: Vec<String>,
33 effective_grants: ScopeReport,
39 capture: TraceCapture,
43 blobs: BlobStore,
46 system_prompt_source: &'static str,
51 system_prompt_sha256: Option<String>,
55 credential_source: &'static str,
58 gateways: Vec<SessionGateway>,
61 control: Option<SessionControl>,
64 inference_choices: Vec<SessionInferenceChoice>,
67 tool_refresh: Option<&'static str>,
70 runtime_artifacts: Vec<RuntimeArtifact>,
73 resumed_from: Option<String>,
77 context_id: Option<String>,
80 spawned_by: Option<String>,
84 spawn_delegation_id: Option<String>,
85 formation_id: Option<FormationId>,
88 formation_member: Option<(String, Vec<String>)>,
91 session_start_time: Instant,
92 session_event_id: String,
97 session_started: bool,
98 session_ended: bool,
99 task_event_id: Option<String>,
102 turn_event_id: Option<String>,
106 total_turns: u32,
108 total_input_tokens: u64,
109 total_output_tokens: u64,
110 total_tool_calls: u32,
111 total_shell_calls: u32,
112 task_turns: u32,
114 task_input_tokens: u64,
115 task_output_tokens: u64,
116 task_tool_calls: u32,
117 task_shell_calls: u32,
118 task_agent_turns: u32,
123 task_hook_inferences: u32,
126 task_start_instant: Option<Instant>,
127 pub(crate) active_task_id: Option<String>,
128 task_failures_written: u64,
131 last_task_failure: Option<String>,
133 max_session_tokens: Option<u64>,
135 machine_tokens_per_day: Option<u64>,
136}
137
138#[derive(Debug, Clone)]
143pub(crate) struct InferenceOrigin {
144 pub(crate) source: String,
146 pub(crate) model: String,
148}
149
150pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
153
154pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
157
158pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
160
161pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
164
165pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
168
169pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
171
172pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
175
176pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
179
180pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
183
184pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
187
188pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
190
191pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
193
194pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
196
197pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
200
201pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
203
204pub(crate) fn new_event_id() -> String {
212 format!("evt_{}", uuid::Uuid::now_v7().simple())
213}
214
215#[derive(Serialize)]
229struct SessionStartEvent {
230 event_type: &'static str,
231 event_id: String,
232 parent_id: Option<String>,
233 session_id: String,
234 timestamp: u64,
235 capsule_name: String,
236 capsule_version: String,
237 model: String,
238 max_turns: u32,
239 max_session_tokens: Option<u64>,
242 machine_tokens_per_day: Option<u64>,
245 capabilities: Vec<String>,
246 tools_declared: Vec<String>,
247 containment_declared: ContainmentClass,
249 containment_achieved: ContainmentClass,
253 workdir_exec: bool,
261 userns_grant: Option<crate::sealed::UsernsGrant>,
273 effective_grants: ScopeReport,
282 system_prompt_source: &'static str,
290 system_prompt_sha256: Option<String>,
299 credential_source: &'static str,
302 gateways: Vec<SessionGateway>,
306 #[serde(skip_serializing_if = "Option::is_none")]
309 control: Option<SessionControl>,
310 #[serde(skip_serializing_if = "Vec::is_empty")]
314 inference_choices: Vec<SessionInferenceChoice>,
315 tool_refresh: Option<&'static str>,
318 runtime_artifacts: Vec<RuntimeArtifact>,
322 resumed_from: Option<String>,
329 context_id: Option<String>,
335 #[serde(skip_serializing_if = "Option::is_none")]
339 spawned_by: Option<String>,
340 #[serde(skip_serializing_if = "Option::is_none")]
343 delegation_id: Option<String>,
344 #[serde(skip_serializing_if = "Option::is_none")]
348 formation_id: Option<FormationId>,
349 #[serde(skip_serializing_if = "Option::is_none")]
352 formation_member: Option<String>,
353 #[serde(skip_serializing_if = "Option::is_none")]
356 formation_callees: Option<Vec<String>>,
357}
358
359#[derive(Serialize)]
360struct InferenceEvent {
361 event_type: &'static str,
362 event_id: String,
363 parent_id: Option<String>,
364 session_id: String,
365 timestamp: u64,
366 turn: u32,
367 task_id: Option<String>,
368 #[serde(skip_serializing_if = "Option::is_none")]
381 input_tokens: Option<u64>,
382 #[serde(skip_serializing_if = "Option::is_none")]
386 output_tokens: Option<u64>,
387 decision: String,
388 #[serde(skip_serializing_if = "Option::is_none")]
395 stop_reason: Option<String>,
396 tool_name: Option<String>,
397 #[serde(skip_serializing_if = "Option::is_none")]
400 input_tokens_actual: Option<u64>,
401 #[serde(skip_serializing_if = "Option::is_none")]
402 output_tokens_actual: Option<u64>,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 cached_tokens: Option<u64>,
405 #[serde(skip_serializing_if = "Option::is_none")]
406 cache_write_tokens: Option<u64>,
407 #[serde(skip_serializing_if = "Option::is_none")]
410 thinking_tokens: Option<u64>,
411 #[serde(skip_serializing_if = "Option::is_none")]
416 origin: Option<String>,
417 #[serde(skip_serializing_if = "Option::is_none")]
420 driver_choice: Option<String>,
421 #[serde(skip_serializing_if = "Option::is_none")]
426 model: Option<String>,
427 #[serde(skip_serializing_if = "Vec::is_empty")]
432 message_ids: Vec<String>,
433 #[serde(skip_serializing_if = "Option::is_none")]
441 system_sha: Option<String>,
442 #[serde(skip_serializing_if = "Option::is_none")]
445 tools_sha: Option<String>,
446 #[serde(skip_serializing_if = "Option::is_none")]
449 response_sha: Option<String>,
450 #[serde(skip_serializing_if = "Vec::is_empty")]
458 message_shas: Vec<String>,
459 #[serde(skip_serializing_if = "Option::is_none")]
463 error_code: Option<String>,
464 #[serde(skip_serializing_if = "Option::is_none")]
468 error: Option<String>,
469 #[serde(skip_serializing_if = "Option::is_none")]
473 provider_status: Option<u16>,
474}
475
476pub(crate) struct WireCapture {
486 system: Vec<u8>,
489 tools: Vec<u8>,
491 messages: Vec<Vec<u8>>,
495 response: Vec<u8>,
497}
498
499impl WireCapture {
500 pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
506 Self {
507 system: payload
508 .get("system")
509 .and_then(Value::as_str)
510 .unwrap_or_default()
511 .as_bytes()
512 .to_vec(),
513 tools: payload
514 .get("tools")
515 .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
516 .unwrap_or_default(),
517 messages: payload
518 .get("messages")
519 .and_then(Value::as_array)
520 .map(|messages| {
521 messages
522 .iter()
523 .map(|message| serde_json::to_vec(message).unwrap_or_default())
524 .collect()
525 })
526 .unwrap_or_default(),
527 response: response.as_bytes().to_vec(),
528 }
529 }
530}
531
532#[derive(Serialize)]
533struct ToolCallEvent {
534 event_type: &'static str,
535 event_id: String,
536 parent_id: Option<String>,
537 session_id: String,
538 timestamp: u64,
539 turn: u32,
540 task_id: Option<String>,
541 tool_name: String,
542 tool_call_id: Option<String>,
546 input: Value,
547 input_bytes: u64,
548 #[serde(skip_serializing_if = "Option::is_none")]
549 output: Option<String>,
550 output_bytes: u64,
551 duration_ms: u64,
552 status: String,
553 #[serde(skip_serializing_if = "Option::is_none")]
557 state_effect: Option<String>,
558 #[serde(skip_serializing_if = "Option::is_none")]
563 resource_id: Option<String>,
564}
565
566#[derive(Serialize)]
567struct SkillCallEvent {
568 event_type: &'static str,
569 event_id: String,
570 parent_id: Option<String>,
571 session_id: String,
572 timestamp: u64,
573 turn: u32,
574 task_id: Option<String>,
575 skill_name: String,
576 output_bytes: u64,
577 duration_ms: u64,
578 status: String,
579 origin: &'static str,
581 trust: &'static str,
584}
585
586#[derive(Serialize)]
587struct ShellEvent {
588 event_type: &'static str,
589 event_id: String,
590 parent_id: Option<String>,
591 session_id: String,
592 timestamp: u64,
593 turn: u32,
594 task_id: Option<String>,
595 binary: String,
599 command: String,
600 exit_code: i32,
601 stdout_bytes: u64,
602 stderr_bytes: u64,
603 duration_ms: u64,
604 #[serde(skip_serializing_if = "Option::is_none")]
610 resource_limit: Option<String>,
611}
612
613#[derive(Serialize)]
616struct ShellDetachedEvent {
617 event_type: &'static str,
618 event_id: String,
619 parent_id: Option<String>,
620 session_id: String,
621 timestamp: u64,
622 turn: u32,
623 task_id: Option<String>,
624 work_id: String,
625 binary: String,
626 command: String,
627 grace_ms: u64,
629}
630
631#[derive(Serialize)]
634struct ShellCompletedEvent {
635 event_type: &'static str,
636 event_id: String,
637 parent_id: Option<String>,
638 session_id: String,
639 timestamp: u64,
640 work_id: String,
641 binary: String,
642 command: String,
643 exit_code: i32,
644 duration_ms: u64,
645 output_path: String,
647 output_bytes: u64,
648 #[serde(skip_serializing_if = "Option::is_none")]
650 resource_limit: Option<String>,
651 status: String,
654 completion_task_id: String,
657}
658
659#[derive(Serialize)]
668struct ShellAbandonedEvent {
669 event_type: &'static str,
670 event_id: String,
671 parent_id: Option<String>,
672 session_id: String,
673 timestamp: u64,
674 work_id: String,
675 binary: String,
676 command: String,
677 running_ms: u64,
680 #[serde(skip_serializing_if = "Option::is_none")]
683 exit_code: Option<i32>,
684 #[serde(skip_serializing_if = "Option::is_none")]
688 output_path: Option<String>,
689 #[serde(skip_serializing_if = "Option::is_none")]
691 output_bytes: Option<u64>,
692}
693
694#[derive(Serialize)]
706struct ShellLostEvent {
707 event_type: &'static str,
708 event_id: String,
709 #[serde(skip_serializing_if = "Option::is_none")]
712 parent_id: Option<String>,
713 session_id: String,
715 timestamp: u64,
716 work_id: String,
717 binary: String,
718 command: String,
719 detached_at_ms: u64,
721 reconciled_by_session: String,
723 reconciled_task_id: String,
726}
727
728#[derive(Serialize)]
734struct ShellDetachUnrecordedEvent {
735 event_type: &'static str,
736 event_id: String,
737 parent_id: Option<String>,
738 session_id: String,
739 timestamp: u64,
740 turn: u32,
741 task_id: Option<String>,
742 work_id: String,
743 binary: String,
744 reason: String,
746}
747
748#[derive(Serialize)]
749struct CompactionEvent {
750 event_type: &'static str,
751 event_id: String,
752 parent_id: Option<String>,
753 session_id: String,
754 timestamp: u64,
755 turn: u32,
756 task_id: Option<String>,
757 tokens_before: u64,
758 tokens_after: u64,
759}
760
761#[derive(Serialize)]
765struct CompactionDeclinedEvent {
766 event_type: &'static str,
767 event_id: String,
768 parent_id: Option<String>,
769 session_id: String,
770 timestamp: u64,
771 turn: u32,
772 task_id: Option<String>,
773 tokens: u64,
776 reason: String,
779}
780
781#[derive(Serialize)]
788struct ContextSeedEvent {
789 event_type: &'static str,
790 event_id: String,
791 parent_id: Option<String>,
792 session_id: String,
793 timestamp: u64,
794 task_id: Option<String>,
795 hook_name: String,
797 tokens: u64,
800 proposed_tokens: u64,
803 budget_tokens: u64,
806 outcome: String,
809 #[serde(skip_serializing_if = "Option::is_none")]
812 reason: Option<String>,
813 message_ids: Vec<String>,
817}
818
819#[derive(Serialize)]
827struct RetentionEvent {
828 event_type: &'static str,
829 event_id: String,
830 parent_id: Option<String>,
831 session_id: String,
832 timestamp: u64,
833 store: String,
835 reason: String,
838 removed: u32,
842 targets: Vec<String>,
844 #[serde(skip_serializing_if = "Option::is_none")]
847 messages_dropped: Option<u64>,
848}
849
850#[derive(Serialize)]
851struct A2aTaskReceivedEvent {
852 event_type: &'static str,
853 event_id: String,
854 parent_id: Option<String>,
855 session_id: String,
856 timestamp: u64,
857 task_id: String,
858 context_id: String,
859 message_id: String,
860 traceparent_from_caller: Option<String>,
861 #[serde(skip_serializing_if = "Option::is_none")]
864 caller_member: Option<String>,
865}
866
867#[derive(Serialize)]
868struct A2aSendEvent {
869 event_type: &'static str,
870 event_id: String,
871 parent_id: Option<String>,
872 session_id: String,
873 timestamp: u64,
874 peer_url: String,
875 message_id: String,
876 task_id: String,
877 context_id: String,
878 traceparent: Option<String>,
879 trust: String,
882}
883
884#[derive(Serialize)]
887struct ArtifactPulledEvent {
888 event_type: &'static str,
889 event_id: String,
890 parent_id: Option<String>,
891 session_id: String,
892 timestamp: u64,
893 name: String,
894 version: String,
895 runtime: &'static str,
897 origin: &'static str,
900 session: Option<String>,
903 trust: &'static str,
904 fetched_from: String,
907 #[serde(skip_serializing_if = "Option::is_none")]
910 owner_id: Option<u64>,
911}
912
913#[derive(Serialize)]
914struct SessionEndEvent {
915 event_type: &'static str,
916 event_id: String,
917 parent_id: Option<String>,
918 session_id: String,
919 timestamp: u64,
920 total_turns: u32,
921 total_input_tokens: u64,
922 total_output_tokens: u64,
923 total_tool_calls: u32,
924 total_shell_calls: u32,
925 duration_ms: u64,
926 exit_status: String,
927}
928
929#[derive(Serialize)]
930struct TaskStartEvent {
931 event_type: &'static str,
932 event_id: String,
933 parent_id: Option<String>,
934 session_id: String,
935 timestamp: u64,
936 task_id: String,
937 context_id: String,
938 source: String,
941 origin: String,
942 trust: String,
943 lane: String,
946 message_parts_bytes: u64,
947 #[serde(skip_serializing_if = "Vec::is_empty")]
950 part_kinds: Vec<&'static str>,
951 #[serde(skip_serializing_if = "Vec::is_empty")]
953 reference_task_ids: Vec<String>,
954}
955
956#[derive(Serialize)]
957struct TaskEndEvent {
958 event_type: &'static str,
959 event_id: String,
960 parent_id: Option<String>,
961 session_id: String,
962 timestamp: u64,
963 task_id: String,
964 exit_status: String,
965 duration_ms: u64,
966 turns: u32,
967 input_tokens: u64,
968 output_tokens: u64,
969 tool_calls: u32,
970 shell_calls: u32,
971 reopen_count: u32,
974}
975
976#[derive(Serialize)]
983struct TaskCanceledEvent {
984 event_type: &'static str,
985 event_id: String,
986 parent_id: Option<String>,
987 session_id: String,
988 timestamp: u64,
989 task_id: String,
990 #[serde(skip_serializing_if = "Option::is_none")]
992 turn: Option<u32>,
993 phase: String,
996 detached_work_ids: Vec<String>,
999 delegation_ids: Vec<String>,
1001}
1002
1003pub(crate) const TASK_REJECTED_SESSION_ENDED: &str = "session_ended";
1006
1007pub(crate) const TASK_REJECTED_SESSION_STOPPED: &str = "session_stopped";
1009
1010#[derive(Serialize)]
1017struct TaskRejectedEvent {
1018 event_type: &'static str,
1019 event_id: String,
1020 parent_id: Option<String>,
1021 session_id: String,
1022 timestamp: u64,
1023 task_id: String,
1024 context_id: String,
1025 source: String,
1028 cause: String,
1030 reason: String,
1032}
1033
1034pub(crate) const TASK_FAILED_DRIVER_ERROR: &str = "driver_error";
1037
1038pub(crate) const TASK_FAILED_CREDENTIAL_REJECTED: &str = "credential_rejected";
1041
1042pub(crate) const TASK_FAILED_MALFORMED_RESPONSE: &str = "malformed_response";
1045
1046pub(crate) const TASK_FAILED_COMPACTION_HOOK: &str = "compaction_hook";
1048
1049pub(crate) const TASK_FAILED_INPUT_TIMEOUT: &str = "input_timeout";
1051
1052pub(crate) const TASK_FAILED_REOPEN_BUDGET_EXHAUSTED: &str = "reopen_budget_exhausted";
1055
1056pub(crate) const TASK_FAILED_RUNTIME_ERROR: &str = "runtime_error";
1059
1060pub(crate) const TASK_FAILED_WORKDIR_PATH_REFUSED: &str = "workdir_path_refused";
1063
1064pub(crate) const INFERENCE_ERROR_CREDENTIAL_REJECTED: &str = TASK_FAILED_CREDENTIAL_REJECTED;
1068
1069pub(crate) const INFERENCE_ERROR_DRIVER_ERROR: &str = TASK_FAILED_DRIVER_ERROR;
1073
1074pub(crate) const INFERENCE_ERROR_DRIVER_FAILED: &str = "driver_failed";
1077
1078pub(crate) const INFERENCE_ERROR_MALFORMED_RESPONSE: &str = TASK_FAILED_MALFORMED_RESPONSE;
1081
1082pub(crate) const INFERENCE_ERROR_HARNESS_AUTH: &str = "harness_auth";
1084
1085pub(crate) const INFERENCE_ERROR_HARNESS_QUOTA: &str = "harness_quota";
1087
1088pub(crate) const INFERENCE_ERROR_HARNESS_ERROR: &str = "harness_error";
1091
1092pub(crate) const INFERENCE_ERROR_HARNESS_OTHER: &str = "harness_other";
1094
1095pub(crate) const INFERENCE_ERROR_HARNESS_INACTIVE: &str = "harness_inactive";
1097
1098pub(crate) struct FailedInference<'a> {
1100 pub(crate) turn: u32,
1101 pub(crate) error_code: &'static str,
1103 pub(crate) error: &'a str,
1105 pub(crate) provider_status: Option<u16>,
1107 pub(crate) usage: Option<&'a DriverUsage>,
1109 pub(crate) origin: Option<&'a InferenceOrigin>,
1111 pub(crate) choice: Option<&'a crate::driver_choice::DriverChoice>,
1113 pub(crate) message_ids: Vec<String>,
1115 pub(crate) tool_name: Option<String>,
1116 pub(crate) spent: Option<(Option<u64>, Option<u64>)>,
1119}
1120
1121struct InferenceLine<'a> {
1124 turn: u32,
1125 input_tokens: Option<u64>,
1126 output_tokens: Option<u64>,
1127 decision: String,
1128 stop_reason: Option<&'a str>,
1129 tool_name: Option<String>,
1130 origin: Option<&'a InferenceOrigin>,
1131 usage: Option<&'a DriverUsage>,
1132 message_ids: Vec<String>,
1133 wire: Option<&'a WireCapture>,
1134 choice: Option<&'a crate::driver_choice::DriverChoice>,
1135 failure: Option<(&'static str, String, Option<u16>)>,
1137 counted: bool,
1139}
1140
1141pub(crate) const MAX_TASK_FAILURE_REASON_BYTES: usize = 2_000;
1144
1145#[derive(Serialize)]
1149struct WorkdirPathRefusedEvent<'a> {
1150 event_type: &'static str,
1151 event_id: String,
1152 parent_id: Option<String>,
1153 session_id: String,
1154 timestamp: u64,
1155 #[serde(skip_serializing_if = "Option::is_none")]
1156 task_id: Option<String>,
1157 path: &'a str,
1159 operation: crate::workdir_fs::WorkdirOperation,
1160 refused_at: &'a str,
1163 reason: crate::workdir_fs::RefusalReason,
1164}
1165
1166#[derive(Serialize)]
1174struct TaskFailedEvent {
1175 event_type: &'static str,
1176 event_id: String,
1177 parent_id: Option<String>,
1178 session_id: String,
1179 timestamp: u64,
1180 #[serde(skip_serializing_if = "Option::is_none")]
1182 task_id: Option<String>,
1183 #[serde(skip_serializing_if = "Option::is_none")]
1186 turn: Option<u32>,
1187 cause: String,
1188 reason: String,
1190}
1191
1192pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
1195
1196pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
1199
1200#[derive(Serialize)]
1205struct TaskReopenedEvent {
1206 event_type: &'static str,
1207 event_id: String,
1208 parent_id: Option<String>,
1209 session_id: String,
1210 timestamp: u64,
1211 task_id: String,
1212 hook_name: String,
1214 reason: String,
1216 reopen_number: u32,
1218 attempt_context: &'static str,
1220 turns_remaining: u32,
1223}
1224
1225#[derive(Serialize)]
1230struct TaskContinuedEvent<'a> {
1231 event_type: &'static str,
1232 event_id: String,
1233 parent_id: Option<String>,
1234 session_id: String,
1235 timestamp: u64,
1236 task_id: String,
1237 continuation_number: u32,
1239 member_calls: &'a [String],
1241 delegations: &'a [String],
1244 waited_ms: u64,
1246 turns_remaining: u32,
1249}
1250
1251#[derive(Serialize)]
1254struct SpendCeilingReachedEvent<'a> {
1255 event_type: &'static str,
1256 event_id: String,
1257 parent_id: Option<String>,
1258 session_id: String,
1259 timestamp: u64,
1260 turn: u32,
1261 task_id: Option<String>,
1262 #[serde(flatten)]
1263 refusal: &'a crate::spend::SpendRefusal,
1264 #[serde(skip_serializing_if = "Option::is_none")]
1266 origin: Option<&'a str>,
1267}
1268
1269#[derive(Serialize)]
1274pub(crate) struct HarnessStart {
1275 pub driver: String,
1277 pub driver_version: String,
1278 pub harness: String,
1280 pub binary: String,
1282 pub binary_source: String,
1284 pub harness_version: Option<String>,
1286 pub version_tested: bool,
1288 pub harness_session_id: String,
1290 pub session_mode: String,
1291 pub args_count: usize,
1294 pub env_names: Vec<String>,
1296 pub account_env_unset: Vec<String>,
1299 pub files: Vec<String>,
1301 pub bridge_tools: Vec<String>,
1303 pub stdin_bytes: usize,
1305 pub keep_stdin_open: bool,
1306}
1307
1308#[derive(Serialize)]
1309struct HarnessStartEvent {
1310 event_type: &'static str,
1311 event_id: String,
1312 parent_id: Option<String>,
1313 session_id: String,
1314 timestamp: u64,
1315 task_id: Option<String>,
1316 #[serde(flatten)]
1317 start: HarnessStart,
1318}
1319
1320#[derive(Serialize)]
1323struct HarnessWarningEvent {
1324 event_type: &'static str,
1325 event_id: String,
1326 parent_id: Option<String>,
1327 session_id: String,
1328 timestamp: u64,
1329 task_id: Option<String>,
1330 code: String,
1331 message: String,
1332}
1333
1334#[derive(Serialize)]
1337struct HarnessSessionEvent {
1338 event_type: &'static str,
1339 event_id: String,
1340 parent_id: Option<String>,
1341 session_id: String,
1342 timestamp: u64,
1343 task_id: Option<String>,
1344 harness_session_id: String,
1345 auth: String,
1347 model: Option<String>,
1349}
1350
1351#[derive(Serialize)]
1355struct HarnessSessionForgottenEvent {
1356 event_type: &'static str,
1357 event_id: String,
1358 parent_id: Option<String>,
1359 session_id: String,
1360 timestamp: u64,
1361 task_id: Option<String>,
1362 context_id: String,
1364 harness_session_id: String,
1366 requested_by: String,
1368}
1369
1370#[derive(Serialize)]
1371struct HarnessRetryEvent {
1372 event_type: &'static str,
1373 event_id: String,
1374 parent_id: Option<String>,
1375 session_id: String,
1376 timestamp: u64,
1377 task_id: Option<String>,
1378 attempt: u32,
1379 reason: String,
1380}
1381
1382#[derive(Serialize)]
1383struct HarnessNoteEvent {
1384 event_type: &'static str,
1385 event_id: String,
1386 parent_id: Option<String>,
1387 session_id: String,
1388 timestamp: u64,
1389 task_id: Option<String>,
1390 text: String,
1391}
1392
1393#[derive(Serialize)]
1394struct HarnessFailedEvent {
1395 event_type: &'static str,
1396 event_id: String,
1397 parent_id: Option<String>,
1398 session_id: String,
1399 timestamp: u64,
1400 task_id: Option<String>,
1401 kind: String,
1403 message: String,
1404 source: String,
1406}
1407
1408#[derive(Serialize)]
1410struct HarnessInterruptEvent {
1411 event_type: &'static str,
1412 event_id: String,
1413 parent_id: Option<String>,
1414 session_id: String,
1415 timestamp: u64,
1416 task_id: Option<String>,
1417 method: String,
1419 delivered: bool,
1421 grace_ms: u64,
1424}
1425
1426#[derive(Serialize)]
1428pub(crate) struct HarnessExit {
1429 pub code: Option<i32>,
1431 pub signal: Option<i32>,
1433 pub cause: String,
1436 pub killed: bool,
1438 pub duration_ms: u64,
1440}
1441
1442#[derive(Serialize)]
1443struct HarnessExitEvent {
1444 event_type: &'static str,
1445 event_id: String,
1446 parent_id: Option<String>,
1447 session_id: String,
1448 timestamp: u64,
1449 task_id: Option<String>,
1450 #[serde(flatten)]
1451 exit: HarnessExit,
1452}
1453
1454#[derive(Serialize)]
1458struct CallDeniedEvent {
1459 event_type: &'static str,
1460 event_id: String,
1461 parent_id: Option<String>,
1462 session_id: String,
1463 timestamp: u64,
1464 turn: u32,
1465 event: String,
1468 hook_name: String,
1470 target: String,
1473 reason: String,
1475}
1476
1477#[derive(Serialize)]
1481struct ToolInputRefusedEvent {
1482 event_type: &'static str,
1483 event_id: String,
1484 parent_id: Option<String>,
1485 session_id: String,
1486 timestamp: u64,
1487 turn: u32,
1488 tool_name: String,
1489 tool_call_id: Option<String>,
1492 missing: Vec<String>,
1494 reason: String,
1496}
1497
1498#[derive(Serialize)]
1503struct ProtectedPathDeniedEvent {
1504 event_type: &'static str,
1505 event_id: String,
1506 parent_id: Option<String>,
1507 session_id: String,
1508 timestamp: u64,
1509 turn: u32,
1510 call: String,
1512 target: String,
1514 path: String,
1517 rule: String,
1519 signal: String,
1522 reason: String,
1524}
1525
1526#[derive(Serialize)]
1527struct HookDispatchErrorEvent {
1528 event_type: &'static str,
1529 event_id: String,
1530 parent_id: Option<String>,
1531 session_id: String,
1532 timestamp: u64,
1533 hook_name: String,
1534 event: String,
1535 arm: String,
1536}
1537
1538#[derive(Serialize)]
1542struct FormationEndedEvent {
1543 event_type: &'static str,
1544 event_id: String,
1545 parent_id: Option<String>,
1546 session_id: String,
1547 timestamp: u64,
1548 formation_id: String,
1549}
1550
1551#[derive(Serialize)]
1558struct SpawnerEndedEvent {
1559 event_type: &'static str,
1560 event_id: String,
1561 parent_id: Option<String>,
1562 session_id: String,
1563 timestamp: u64,
1564 #[serde(skip_serializing_if = "Option::is_none")]
1565 spawned_by: Option<String>,
1566 #[serde(skip_serializing_if = "Option::is_none")]
1567 delegation_id: Option<String>,
1568}
1569
1570#[derive(Serialize)]
1571struct ResourceListEvent {
1572 event_type: &'static str,
1573 event_id: String,
1574 parent_id: Option<String>,
1575 session_id: String,
1576 timestamp: u64,
1577 root: String,
1580 entry_count: usize,
1581 total_bytes: u64,
1582 generation: u64,
1584 containment_achieved: ContainmentClass,
1585 outcome: String,
1587 reason: Option<String>,
1589}
1590
1591#[derive(Serialize)]
1592struct ResourceReadEvent {
1593 event_type: &'static str,
1594 event_id: String,
1595 parent_id: Option<String>,
1596 session_id: String,
1597 timestamp: u64,
1598 path: String,
1601 outcome: String,
1602 bytes: Option<u64>,
1604 sha256: Option<String>,
1606 generation: u64,
1607 containment_achieved: ContainmentClass,
1608 reason: Option<String>,
1609}
1610
1611#[derive(Serialize)]
1613struct PeerHandleMintEvent {
1614 event_type: &'static str,
1615 event_id: String,
1616 parent_id: Option<String>,
1617 session_id: String,
1618 timestamp: u64,
1619 handle_id: Option<String>,
1622 path: String,
1625 audience: String,
1626 expires_at_ms: Option<u64>,
1628 outcome: String,
1629 reason: Option<String>,
1630}
1631
1632#[derive(Serialize)]
1635struct PeerHandleRedeemEvent {
1636 event_type: &'static str,
1637 event_id: String,
1638 parent_id: Option<String>,
1639 session_id: String,
1640 timestamp: u64,
1641 handle_id: String,
1642 path: Option<String>,
1645 generation: u64,
1648 audience_asserted: Option<String>,
1650 bytes: Option<u64>,
1652 sha256: Option<String>,
1654 outcome: String,
1655 reason: Option<String>,
1656}
1657
1658#[derive(Serialize)]
1660struct PeerFileFetchEvent {
1661 event_type: &'static str,
1662 event_id: String,
1663 parent_id: Option<String>,
1664 session_id: String,
1665 timestamp: u64,
1666 peer: String,
1667 handle_id: String,
1668 stored_path: Option<String>,
1671 bytes: Option<u64>,
1672 sha256: Option<String>,
1673 outcome: String,
1674 reason: Option<String>,
1675}
1676
1677#[derive(Serialize)]
1688struct DelegationStartEvent {
1689 event_type: &'static str,
1690 event_id: String,
1691 parent_id: Option<String>,
1692 session_id: String,
1693 timestamp: u64,
1694 delegation_id: String,
1697 capsule: String,
1698 version: String,
1699 child_session_id: String,
1701 child_workdir: String,
1703 child_records_dir: String,
1706}
1707
1708impl DelegationStartEvent {
1709 #[allow(clippy::too_many_arguments)]
1712 fn new(
1713 session_id: &str,
1714 session_event_id: &str,
1715 delegation_id: &str,
1716 capsule: &str,
1717 version: &str,
1718 child_session_id: &str,
1719 child_workdir: &str,
1720 child_records_dir: &str,
1721 ) -> Self {
1722 Self {
1723 event_type: "delegation_start",
1724 event_id: new_event_id(),
1725 parent_id: Some(session_event_id.to_string()),
1726 session_id: session_id.to_string(),
1727 timestamp: timestamp_ms(),
1728 delegation_id: delegation_id.to_string(),
1729 capsule: capsule.to_string(),
1730 version: version.to_string(),
1731 child_session_id: child_session_id.to_string(),
1732 child_workdir: child_workdir.to_string(),
1733 child_records_dir: child_records_dir.to_string(),
1734 }
1735 }
1736}
1737
1738#[derive(Serialize)]
1744struct MemberCallStartEvent {
1745 event_type: &'static str,
1746 event_id: String,
1747 parent_id: Option<String>,
1748 session_id: String,
1749 timestamp: u64,
1750 task_id: String,
1752 call_id: String,
1754 member: String,
1756 member_task_id: String,
1758}
1759
1760#[derive(Serialize)]
1766struct MemberCallBusyEvent {
1767 event_type: &'static str,
1768 event_id: String,
1769 parent_id: Option<String>,
1770 session_id: String,
1771 timestamp: u64,
1772 task_id: String,
1774 call_id: String,
1776 member: String,
1778 offer: u32,
1780 waited_ms: u64,
1782 message: String,
1784}
1785
1786#[derive(Serialize)]
1792struct MemberCallEvent {
1793 event_type: &'static str,
1794 event_id: String,
1795 parent_id: Option<String>,
1796 session_id: String,
1797 timestamp: u64,
1798 task_id: String,
1799 call_id: String,
1800 member: String,
1801 #[serde(skip_serializing_if = "Option::is_none")]
1803 member_task_id: Option<String>,
1804 status: String,
1807 duration_ms: u64,
1808 output: String,
1810 truncated: bool,
1811 delivered: bool,
1814 #[serde(skip_serializing_if = "Vec::is_empty")]
1817 no_answer_below: Vec<NoAnswerBelow>,
1818}
1819
1820#[derive(Serialize)]
1822struct NoAnswerBelow {
1823 member: String,
1824 status: String,
1826}
1827
1828#[derive(Serialize)]
1839struct DelegationEvent {
1840 event_type: &'static str,
1841 event_id: String,
1842 parent_id: Option<String>,
1843 session_id: String,
1844 timestamp: u64,
1845 capsule: String,
1847 version: String,
1848 delegation_id: Option<String>,
1850 child_session_id: Option<String>,
1852 duration_ms: u64,
1855 outcome: String,
1865 reason: Option<String>,
1868}
1869
1870impl DelegationEvent {
1871 #[allow(clippy::too_many_arguments)]
1874 fn new(
1875 session_id: &str,
1876 session_event_id: &str,
1877 capsule: &str,
1878 version: &str,
1879 delegation_id: Option<String>,
1880 child_session_id: Option<String>,
1881 duration_ms: u64,
1882 outcome: &str,
1883 reason: Option<String>,
1884 ) -> Self {
1885 Self {
1886 event_type: "delegation",
1887 event_id: new_event_id(),
1888 parent_id: Some(session_event_id.to_string()),
1889 session_id: session_id.to_string(),
1890 timestamp: timestamp_ms(),
1891 capsule: capsule.to_string(),
1892 version: version.to_string(),
1893 delegation_id,
1894 child_session_id,
1895 duration_ms,
1896 outcome: outcome.to_string(),
1897 reason,
1898 }
1899 }
1900}
1901
1902impl TraceWriter {
1905 #[allow(clippy::too_many_arguments)]
1906 pub(crate) async fn open(
1907 records_root: &WorkdirRoot,
1908 session_id: String,
1909 capsule_name: String,
1910 capsule_version: String,
1911 model: String,
1912 capabilities: Vec<String>,
1913 effective_grants: ScopeReport,
1914 capture: TraceCapture,
1915 system_prompt: Option<String>,
1916 system_prompt_overridden: bool,
1917 resumed_from: Option<String>,
1918 context_id: Option<String>,
1919 spawned_by: Option<String>,
1920 spawn_delegation_id: Option<String>,
1921 ) -> std::io::Result<Self> {
1922 let file = File::from_std(records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?);
1923 let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
1926 (true, _) => "cli",
1927 (false, true) => "manifest",
1928 (false, false) => "none",
1929 };
1930 let system_prompt_sha256 = system_prompt
1931 .as_ref()
1932 .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
1933 let blobs = BlobStore::new(records_root);
1934 if let (true, Some(prompt), Some(sha)) = (
1938 capture.captures_content(),
1939 system_prompt.as_ref(),
1940 system_prompt_sha256.as_deref(),
1941 ) {
1942 blobs.put(sha, prompt.as_bytes()).await?;
1943 }
1944 Ok(Self {
1945 writer: BufWriter::new(file),
1946 session_id,
1947 capsule_name,
1948 capsule_version,
1949 model,
1950 capabilities,
1951 effective_grants,
1952 capture,
1953 blobs,
1954 system_prompt_source,
1955 system_prompt_sha256,
1956 credential_source: "none",
1957 gateways: Vec::new(),
1958 control: None,
1959 inference_choices: Vec::new(),
1960 tool_refresh: None,
1961 runtime_artifacts: Vec::new(),
1962 resumed_from,
1963 context_id,
1964 spawned_by,
1965 spawn_delegation_id,
1966 formation_id: None,
1967 formation_member: None,
1968 session_start_time: Instant::now(),
1969 session_event_id: new_event_id(),
1970 session_started: false,
1971 session_ended: false,
1972 task_event_id: None,
1973 turn_event_id: None,
1974 total_turns: 0,
1975 total_input_tokens: 0,
1976 total_output_tokens: 0,
1977 total_tool_calls: 0,
1978 total_shell_calls: 0,
1979 task_turns: 0,
1980 task_input_tokens: 0,
1981 task_output_tokens: 0,
1982 task_tool_calls: 0,
1983 task_shell_calls: 0,
1984 task_agent_turns: 0,
1985 task_hook_inferences: 0,
1986 task_start_instant: None,
1987 active_task_id: None,
1988 task_failures_written: 0,
1989 last_task_failure: None,
1990 max_session_tokens: None,
1991 machine_tokens_per_day: None,
1992 })
1993 }
1994
1995 pub(crate) fn set_spend_ceilings(
1997 &mut self,
1998 max_session_tokens: Option<u64>,
1999 machine_tokens_per_day: Option<u64>,
2000 ) {
2001 self.max_session_tokens = max_session_tokens;
2002 self.machine_tokens_per_day = machine_tokens_per_day;
2003 }
2004
2005 pub(crate) fn session_event_id(&self) -> &str {
2009 &self.session_event_id
2010 }
2011
2012 pub(crate) fn captures_wire(&self) -> bool {
2016 self.capture.captures_hashes()
2017 }
2018
2019 fn session_parent(&self) -> Option<String> {
2024 self.session_started.then(|| self.session_event_id.clone())
2025 }
2026
2027 fn task_parent(&self) -> Option<String> {
2030 self.task_event_id.clone().or_else(|| self.session_parent())
2031 }
2032
2033 fn turn_parent(&self) -> Option<String> {
2037 self.turn_event_id.clone().or_else(|| self.task_parent())
2038 }
2039
2040 pub(crate) fn set_credential_source(&mut self, source: &'static str) {
2043 self.credential_source = source;
2044 }
2045
2046 pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
2048 self.gateways = gateways;
2049 }
2050
2051 pub(crate) fn set_control(&mut self, control: Option<SessionControl>) {
2053 self.control = control;
2054 }
2055
2056 pub(crate) fn set_inference_choices(&mut self, choices: Vec<SessionInferenceChoice>) {
2059 self.inference_choices = choices;
2060 }
2061
2062 pub(crate) fn set_runtime_artifacts(&mut self, installed: &[InstalledArtifactSummary]) {
2065 self.runtime_artifacts = installed
2066 .iter()
2067 .filter_map(|artifact| match &artifact.origin {
2068 LockOrigin::Runtime { session } => Some(RuntimeArtifact {
2069 name: artifact.name.clone(),
2070 version: artifact.version.clone(),
2071 origin: artifact.origin.as_str(),
2072 session: session.clone(),
2073 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2074 }),
2075 LockOrigin::Operator => None,
2076 })
2077 .collect();
2078 }
2079
2080 pub(crate) async fn write_control_applied(
2087 &mut self,
2088 turn: u32,
2089 name: &str,
2090 value: Value,
2091 change_id: &str,
2092 ) -> std::io::Result<()> {
2093 let event = ControlAppliedEvent {
2094 event_type: "control_applied",
2095 event_id: new_event_id(),
2096 parent_id: self.task_parent(),
2097 session_id: self.session_id.clone(),
2098 timestamp: timestamp_ms(),
2099 turn,
2100 task_id: self.active_task_id.clone(),
2101 name,
2102 value,
2103 change_id,
2104 };
2105 self.write_event(&event).await
2106 }
2107
2108 pub(crate) fn set_formation_id(&mut self, formation_id: Option<&FormationId>) {
2110 self.formation_id = formation_id.cloned();
2111 }
2112
2113 pub(crate) fn set_formation_member(
2116 &mut self,
2117 member: Option<&crate::formation_credentials::FormationMember>,
2118 ) {
2119 self.formation_member = member.map(|member| {
2120 (
2121 member.name().to_string(),
2122 member.callees().map(str::to_string).collect(),
2123 )
2124 });
2125 }
2126
2127 pub(crate) fn set_tool_refresh(&mut self, tool_refresh: Option<&'static str>) {
2130 self.tool_refresh = tool_refresh;
2131 }
2132
2133 pub(crate) async fn write_tools_refreshed(
2141 &mut self,
2142 turn: u32,
2143 trigger: &str,
2144 added: &[String],
2145 removed: &[String],
2146 tools: &[String],
2147 ) -> std::io::Result<()> {
2148 let event = ToolsRefreshedEvent {
2149 event_type: "tools_refreshed",
2150 event_id: new_event_id(),
2151 parent_id: self.task_parent(),
2152 session_id: self.session_id.clone(),
2153 timestamp: timestamp_ms(),
2154 turn,
2155 task_id: self.active_task_id.clone(),
2156 trigger,
2157 added,
2158 removed,
2159 tools,
2160 };
2161 self.write_event(&event).await
2162 }
2163
2164 pub(crate) async fn write_session_start(
2165 &mut self,
2166 max_turns: u32,
2167 tools_declared: Vec<String>,
2168 ) -> std::io::Result<()> {
2169 let event = SessionStartEvent {
2170 event_type: "session_start",
2171 event_id: self.session_event_id.clone(),
2172 parent_id: None,
2173 session_id: self.session_id.clone(),
2174 timestamp: timestamp_ms(),
2175 capsule_name: self.capsule_name.clone(),
2176 capsule_version: self.capsule_version.clone(),
2177 model: self.model.clone(),
2178 max_turns,
2179 max_session_tokens: self.max_session_tokens,
2180 machine_tokens_per_day: self.machine_tokens_per_day,
2181 capabilities: self.capabilities.clone(),
2182 tools_declared,
2183 containment_declared: self.effective_grants.declared_containment,
2184 containment_achieved: self.effective_grants.achieved_containment,
2185 workdir_exec: self.effective_grants.workdir_exec,
2186 userns_grant: self.effective_grants.userns_grant,
2187 effective_grants: self.effective_grants.clone(),
2188 system_prompt_source: self.system_prompt_source,
2189 system_prompt_sha256: self.system_prompt_sha256.clone(),
2190 credential_source: self.credential_source,
2191 gateways: self.gateways.clone(),
2192 control: self.control.clone(),
2193 inference_choices: self.inference_choices.clone(),
2194 tool_refresh: self.tool_refresh,
2195 runtime_artifacts: self.runtime_artifacts.clone(),
2196 resumed_from: self.resumed_from.clone(),
2197 context_id: self.context_id.clone(),
2198 spawned_by: self.spawned_by.clone(),
2199 delegation_id: self.spawn_delegation_id.clone(),
2200 formation_id: self.formation_id.clone(),
2201 formation_member: self.formation_member.as_ref().map(|(name, _)| name.clone()),
2202 formation_callees: self
2203 .formation_member
2204 .as_ref()
2205 .map(|(_, callees)| callees.clone()),
2206 };
2207 self.write_event(&event).await?;
2208 self.session_started = true;
2209 Ok(())
2210 }
2211
2212 #[allow(clippy::too_many_arguments)]
2222 pub(crate) async fn write_inference(
2223 &mut self,
2224 turn: u32,
2225 input_tokens: Option<u64>,
2226 output_tokens: Option<u64>,
2227 decision: String,
2228 stop_reason: Option<&str>,
2229 tool_name: Option<String>,
2230 origin: Option<&InferenceOrigin>,
2231 usage: Option<&DriverUsage>,
2232 message_ids: Vec<String>,
2233 wire: Option<&WireCapture>,
2234 ) -> std::io::Result<()> {
2235 self.write_inference_event(InferenceLine {
2236 turn,
2237 input_tokens,
2238 output_tokens,
2239 decision,
2240 stop_reason,
2241 tool_name,
2242 origin,
2243 usage,
2244 message_ids,
2245 wire,
2246 choice: None,
2247 failure: None,
2248 counted: true,
2249 })
2250 .await
2251 }
2252
2253 #[allow(clippy::too_many_arguments)]
2257 pub(crate) async fn write_turn_inference(
2258 &mut self,
2259 turn: u32,
2260 input_tokens: Option<u64>,
2261 output_tokens: Option<u64>,
2262 decision: String,
2263 stop_reason: Option<&str>,
2264 tool_name: Option<String>,
2265 usage: Option<&DriverUsage>,
2266 message_ids: Vec<String>,
2267 wire: Option<&WireCapture>,
2268 choice: Option<&crate::driver_choice::DriverChoice>,
2269 ) -> std::io::Result<()> {
2270 self.write_inference_event(InferenceLine {
2271 turn,
2272 input_tokens,
2273 output_tokens,
2274 decision,
2275 stop_reason,
2276 tool_name,
2277 origin: None,
2278 usage,
2279 message_ids,
2280 wire,
2281 choice,
2282 failure: None,
2283 counted: true,
2284 })
2285 .await
2286 }
2287
2288 pub(crate) async fn write_failed_inference(
2296 &mut self,
2297 failed: FailedInference<'_>,
2298 ) -> std::io::Result<()> {
2299 let error =
2300 crate::agent::truncate_on_char_boundary(failed.error, MAX_TASK_FAILURE_REASON_BYTES)
2301 .to_string();
2302 let (input_tokens, output_tokens) = failed.spent.unwrap_or((None, None));
2303 self.write_inference_event(InferenceLine {
2304 turn: failed.turn,
2305 input_tokens,
2306 output_tokens,
2307 decision: "error".to_string(),
2308 stop_reason: Some("error"),
2309 tool_name: failed.tool_name,
2310 origin: failed.origin,
2311 usage: failed.usage,
2312 message_ids: failed.message_ids,
2313 wire: None,
2314 choice: failed.choice,
2315 failure: Some((failed.error_code, error, failed.provider_status)),
2316 counted: failed.spent.is_some(),
2317 })
2318 .await
2319 }
2320
2321 async fn write_inference_event(&mut self, line: InferenceLine<'_>) -> std::io::Result<()> {
2322 let InferenceLine {
2323 turn,
2324 input_tokens,
2325 output_tokens,
2326 decision,
2327 stop_reason,
2328 tool_name,
2329 origin,
2330 usage,
2331 message_ids,
2332 wire,
2333 choice,
2334 failure,
2335 counted,
2336 } = line;
2337 let (error_code, error, provider_status) = match failure {
2338 Some((code, error, status)) => (Some(code.to_string()), Some(error), status),
2339 None => (None, None, None),
2340 };
2341 let event_id = new_event_id();
2342 let parent_id = if origin.is_none() {
2346 let parent = self.task_parent();
2347 self.turn_event_id = Some(event_id.clone());
2348 parent
2349 } else {
2350 self.turn_parent()
2351 };
2352 let (system_sha, tools_sha, response_sha, message_shas) =
2353 match wire.filter(|_| self.capture.captures_hashes()) {
2354 Some(wire) => self.record_wire(wire).await?,
2355 None => (None, None, None, Vec::new()),
2356 };
2357 let event = InferenceEvent {
2358 event_type: "inference",
2359 event_id,
2360 parent_id,
2361 session_id: self.session_id.clone(),
2362 timestamp: timestamp_ms(),
2363 turn,
2364 task_id: self.active_task_id.clone(),
2365 input_tokens,
2366 output_tokens,
2367 decision,
2368 stop_reason: stop_reason.map(str::to_string),
2369 tool_name,
2370 input_tokens_actual: usage.and_then(|u| u.input_tokens),
2371 output_tokens_actual: usage.and_then(|u| u.output_tokens),
2372 cached_tokens: usage.and_then(|u| u.cached_tokens),
2373 cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
2374 thinking_tokens: usage.and_then(|u| u.thinking_tokens),
2375 origin: origin.map(|o| o.source.clone()),
2376 driver_choice: choice.map(|choice| choice.name.clone()),
2377 model: origin
2378 .map(|o| o.model.clone())
2379 .or_else(|| choice.map(|choice| choice.model.clone())),
2380 message_ids,
2381 system_sha,
2382 tools_sha,
2383 response_sha,
2384 message_shas,
2385 error_code,
2386 error,
2387 provider_status,
2388 };
2389 self.write_event(&event).await?;
2390 if origin.is_none() {
2391 self.task_agent_turns = self.task_agent_turns.saturating_add(1);
2392 }
2393 if !counted {
2394 return Ok(());
2395 }
2396 let (input_tokens, output_tokens) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
2400 self.total_turns = self.total_turns.saturating_add(1);
2401 self.total_input_tokens = self.total_input_tokens.saturating_add(input_tokens);
2402 self.total_output_tokens = self.total_output_tokens.saturating_add(output_tokens);
2403 self.task_turns = self.task_turns.saturating_add(1);
2404 if origin.is_some() {
2405 self.task_hook_inferences = self.task_hook_inferences.saturating_add(1);
2406 }
2407 self.task_input_tokens = self.task_input_tokens.saturating_add(input_tokens);
2408 self.task_output_tokens = self.task_output_tokens.saturating_add(output_tokens);
2409 Ok(())
2410 }
2411
2412 async fn record_wire(
2419 &mut self,
2420 wire: &WireCapture,
2421 ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
2422 let system_sha = murmur_artifact::sha256_hex(&wire.system);
2423 let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
2424 let response_sha = murmur_artifact::sha256_hex(&wire.response);
2425 let message_shas: Vec<String> = wire
2426 .messages
2427 .iter()
2428 .map(|message| murmur_artifact::sha256_hex(message))
2429 .collect();
2430
2431 if self.capture.captures_content() {
2432 self.blobs.put(&system_sha, &wire.system).await?;
2436 self.blobs.put(&tools_sha, &wire.tools).await?;
2437 self.blobs.put(&response_sha, &wire.response).await?;
2438 for (sha, body) in message_shas.iter().zip(&wire.messages) {
2439 self.blobs.put(sha, body).await?;
2440 }
2441 }
2442
2443 Ok((
2444 Some(system_sha),
2445 Some(tools_sha),
2446 Some(response_sha),
2447 message_shas,
2448 ))
2449 }
2450
2451 #[allow(clippy::too_many_arguments)]
2452 pub(crate) async fn write_tool_call(
2453 &mut self,
2454 turn: u32,
2455 tool_name: String,
2456 tool_call_id: Option<String>,
2457 mut input: Value,
2458 input_bytes: u64,
2459 output: &str,
2460 output_bytes: u64,
2461 duration_ms: u64,
2462 status: String,
2463 state_effect: Option<String>,
2464 resource_id: Option<String>,
2465 ) -> std::io::Result<()> {
2466 crate::peer_handoff::redact_handles_in_json(&mut input);
2471 let event = ToolCallEvent {
2472 event_type: "tool_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 tool_name,
2480 tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
2483 input,
2484 input_bytes,
2485 output: self
2486 .capture
2487 .captures_content()
2488 .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
2489 output_bytes,
2490 duration_ms,
2491 status,
2492 state_effect,
2493 resource_id,
2494 };
2495 self.write_event(&event).await?;
2496 self.total_tool_calls = self.total_tool_calls.saturating_add(1);
2497 self.task_tool_calls = self.task_tool_calls.saturating_add(1);
2498 Ok(())
2499 }
2500
2501 pub(crate) async fn write_skill_call(
2507 &mut self,
2508 turn: u32,
2509 skill_name: String,
2510 origin: &LockOrigin,
2511 output_bytes: u64,
2512 duration_ms: u64,
2513 status: String,
2514 ) -> std::io::Result<()> {
2515 let event = SkillCallEvent {
2516 event_type: "skill_call",
2517 event_id: new_event_id(),
2518 parent_id: self.turn_parent(),
2519 session_id: self.session_id.clone(),
2520 timestamp: timestamp_ms(),
2521 turn,
2522 task_id: self.active_task_id.clone(),
2523 skill_name,
2524 output_bytes,
2525 duration_ms,
2526 status,
2527 origin: origin.as_str(),
2528 trust: crate::origin::artifact_trust(origin).as_str(),
2529 };
2530 self.write_event(&event).await
2531 }
2533
2534 #[allow(clippy::too_many_arguments)]
2537 pub(crate) async fn write_shell(
2538 &mut self,
2539 turn: u32,
2540 binary: String,
2541 command: String,
2542 exit_code: i32,
2543 stdout_bytes: u64,
2544 stderr_bytes: u64,
2545 duration_ms: u64,
2546 resource_limit: Option<String>,
2547 ) -> std::io::Result<()> {
2548 let event = ShellEvent {
2549 event_type: "shell",
2550 event_id: new_event_id(),
2551 parent_id: self.turn_parent(),
2552 session_id: self.session_id.clone(),
2553 timestamp: timestamp_ms(),
2554 turn,
2555 task_id: self.active_task_id.clone(),
2556 binary,
2557 command,
2558 exit_code,
2559 stdout_bytes,
2560 stderr_bytes,
2561 duration_ms,
2562 resource_limit,
2563 };
2564 self.write_event(&event).await?;
2565 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
2566 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
2567 Ok(())
2568 }
2569
2570 pub(crate) async fn write_shell_detached(
2577 &mut self,
2578 turn: u32,
2579 work_id: &str,
2580 binary: &str,
2581 command: &str,
2582 grace_ms: u64,
2583 ) -> std::io::Result<()> {
2584 let event = ShellDetachedEvent {
2585 event_type: "shell_detached",
2586 event_id: new_event_id(),
2587 parent_id: self.turn_parent(),
2588 session_id: self.session_id.clone(),
2589 timestamp: timestamp_ms(),
2590 turn,
2591 task_id: self.active_task_id.clone(),
2592 work_id: work_id.to_string(),
2593 binary: binary.to_string(),
2594 command: command.to_string(),
2595 grace_ms,
2596 };
2597 self.write_event(&event).await?;
2598 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
2599 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
2600 Ok(())
2601 }
2602
2603 #[allow(clippy::too_many_arguments)]
2606 pub(crate) async fn write_shell_completed(
2607 &mut self,
2608 work_id: &str,
2609 binary: &str,
2610 command: &str,
2611 exit_code: i32,
2612 duration_ms: u64,
2613 output_path: &str,
2614 output_bytes: u64,
2615 resource_limit: Option<String>,
2616 status: &str,
2617 completion_task_id: &str,
2618 ) -> std::io::Result<()> {
2619 let event = ShellCompletedEvent {
2620 event_type: "shell_completed",
2621 event_id: new_event_id(),
2622 parent_id: self.session_parent(),
2623 session_id: self.session_id.clone(),
2624 timestamp: timestamp_ms(),
2625 work_id: work_id.to_string(),
2626 binary: binary.to_string(),
2627 command: command.to_string(),
2628 exit_code,
2629 duration_ms,
2630 output_path: output_path.to_string(),
2631 output_bytes,
2632 resource_limit,
2633 status: status.to_string(),
2634 completion_task_id: completion_task_id.to_string(),
2635 };
2636 self.write_event(&event).await
2637 }
2638
2639 pub(crate) async fn write_shell_abandoned(
2646 &mut self,
2647 work_id: &str,
2648 binary: &str,
2649 command: &str,
2650 running_ms: u64,
2651 recovered: Option<(i32, &str, u64)>,
2652 ) -> std::io::Result<()> {
2653 let event = ShellAbandonedEvent {
2654 event_type: "shell_abandoned",
2655 event_id: new_event_id(),
2656 parent_id: self.session_parent(),
2657 session_id: self.session_id.clone(),
2658 timestamp: timestamp_ms(),
2659 work_id: work_id.to_string(),
2660 binary: binary.to_string(),
2661 command: command.to_string(),
2662 running_ms,
2663 exit_code: recovered.map(|(exit_code, _, _)| exit_code),
2664 output_path: recovered.map(|(_, path, _)| path.to_string()),
2665 output_bytes: recovered.map(|(_, _, bytes)| bytes),
2666 };
2667 self.write_event(&event).await
2668 }
2669
2670 pub(crate) async fn write_shell_detach_unrecorded(
2672 &mut self,
2673 turn: u32,
2674 work_id: &str,
2675 binary: &str,
2676 reason: &str,
2677 ) -> std::io::Result<()> {
2678 let event = ShellDetachUnrecordedEvent {
2679 event_type: "shell_detach_unrecorded",
2680 event_id: new_event_id(),
2681 parent_id: self.turn_parent(),
2682 session_id: self.session_id.clone(),
2683 timestamp: timestamp_ms(),
2684 turn,
2685 task_id: self.active_task_id.clone(),
2686 work_id: work_id.to_string(),
2687 binary: binary.to_string(),
2688 reason: reason.to_string(),
2689 };
2690 self.write_event(&event).await
2691 }
2692
2693 pub(crate) async fn write_a2a_task_received(
2694 &mut self,
2695 task_id: &str,
2696 context_id: &str,
2697 message_id: &str,
2698 traceparent_from_caller: Option<&str>,
2699 caller_member: Option<&str>,
2700 ) -> std::io::Result<()> {
2701 let event = A2aTaskReceivedEvent {
2702 event_type: "a2a_task_received",
2703 event_id: new_event_id(),
2704 parent_id: self.session_parent(),
2705 session_id: self.session_id.clone(),
2706 timestamp: timestamp_ms(),
2707 task_id: task_id.to_string(),
2708 context_id: context_id.to_string(),
2709 message_id: message_id.to_string(),
2710 traceparent_from_caller: traceparent_from_caller.map(str::to_string),
2711 caller_member: caller_member.map(str::to_string),
2712 };
2713 self.write_event(&event).await
2714 }
2715
2716 pub(crate) async fn write_a2a_send(
2717 &mut self,
2718 peer_url: &str,
2719 message_id: &str,
2720 task_id: &str,
2721 context_id: &str,
2722 traceparent: Option<&str>,
2723 trust: TrustClass,
2724 ) -> std::io::Result<()> {
2725 let event = A2aSendEvent {
2726 event_type: "a2a_send",
2727 event_id: new_event_id(),
2728 parent_id: self.session_parent(),
2729 session_id: self.session_id.clone(),
2730 timestamp: timestamp_ms(),
2731 peer_url: peer_url.to_string(),
2732 message_id: message_id.to_string(),
2733 task_id: task_id.to_string(),
2734 context_id: context_id.to_string(),
2735 traceparent: traceparent.map(str::to_string),
2736 trust: trust.as_str().to_string(),
2737 };
2738 self.write_event(&event).await
2739 }
2740
2741 pub(crate) async fn write_artifact_pulled(
2747 &mut self,
2748 pulled: &crate::runtime::PulledArtifact,
2749 ) -> std::io::Result<()> {
2750 let artifact = &pulled.summary;
2751 let session = match &artifact.origin {
2752 LockOrigin::Runtime { session } => Some(session.clone()),
2753 LockOrigin::Operator => None,
2754 };
2755 let event = ArtifactPulledEvent {
2756 event_type: "artifact_pulled",
2757 event_id: new_event_id(),
2758 parent_id: self.session_parent(),
2759 session_id: self.session_id.clone(),
2760 timestamp: timestamp_ms(),
2761 name: artifact.name.clone(),
2762 version: artifact.version.clone(),
2763 runtime: artifact.runtime.as_str(),
2764 origin: artifact.origin.as_str(),
2765 session,
2766 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2767 fetched_from: pulled.fetched_from.clone(),
2768 owner_id: pulled.owner_id,
2769 };
2770 self.write_event(&event).await
2771 }
2772
2773 #[allow(clippy::too_many_arguments)]
2776 pub(crate) async fn write_task_start(
2777 &mut self,
2778 task_id: &str,
2779 context_id: &str,
2780 source: &str,
2781 provenance: TaskProvenance,
2782 message_parts_bytes: u64,
2783 part_kinds: &[&'static str],
2784 reference_task_ids: &[String],
2785 ) -> std::io::Result<()> {
2786 self.task_turns = 0;
2788 self.task_input_tokens = 0;
2789 self.task_output_tokens = 0;
2790 self.task_tool_calls = 0;
2791 self.task_shell_calls = 0;
2792 self.task_agent_turns = 0;
2793 self.task_hook_inferences = 0;
2794 self.task_start_instant = Some(Instant::now());
2795 self.active_task_id = Some(task_id.to_string());
2796
2797 let event_id = new_event_id();
2798 self.task_event_id = Some(event_id.clone());
2799 self.turn_event_id = None;
2800
2801 let event = TaskStartEvent {
2802 event_type: "task_start",
2803 event_id: event_id.clone(),
2804 parent_id: self.session_parent(),
2805 session_id: self.session_id.clone(),
2806 timestamp: timestamp_ms(),
2807 task_id: task_id.to_string(),
2808 context_id: context_id.to_string(),
2809 source: source.to_string(),
2810 origin: provenance.origin().as_str().to_string(),
2811 trust: provenance.trust().as_str().to_string(),
2812 lane: TaskLane::for_origin(provenance.origin())
2815 .as_str()
2816 .to_string(),
2817 message_parts_bytes,
2818 part_kinds: part_kinds.to_vec(),
2819 reference_task_ids: reference_task_ids.to_vec(),
2820 };
2821 self.write_event(&event).await
2822 }
2823
2824 pub(crate) async fn write_task_end(
2825 &mut self,
2826 task_id: &str,
2827 exit_status: &str,
2828 reopen_count: u32,
2829 ) -> std::io::Result<()> {
2830 let duration_ms = self
2831 .task_start_instant
2832 .take()
2833 .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
2834 .unwrap_or(0);
2835
2836 let event = TaskEndEvent {
2837 event_type: "task_end",
2838 event_id: new_event_id(),
2839 parent_id: self.task_parent(),
2840 session_id: self.session_id.clone(),
2841 timestamp: timestamp_ms(),
2842 task_id: task_id.to_string(),
2843 exit_status: exit_status.to_string(),
2844 duration_ms,
2845 turns: self.task_turns,
2846 input_tokens: self.task_input_tokens,
2847 output_tokens: self.task_output_tokens,
2848 tool_calls: self.task_tool_calls,
2849 shell_calls: self.task_shell_calls,
2850 reopen_count,
2851 };
2852 self.active_task_id = None;
2853 self.task_event_id = None;
2854 self.turn_event_id = None;
2855 self.task_agent_turns = 0;
2856 self.write_event(&event).await
2857 }
2858
2859 pub(crate) async fn write_task_canceled(
2865 &mut self,
2866 task_id: &str,
2867 turn: Option<u32>,
2868 phase: &str,
2869 detached_work_ids: Vec<String>,
2870 delegation_ids: Vec<String>,
2871 ) -> std::io::Result<()> {
2872 let event = TaskCanceledEvent {
2873 event_type: "task_canceled",
2874 event_id: new_event_id(),
2875 parent_id: self.task_parent(),
2876 session_id: self.session_id.clone(),
2877 timestamp: timestamp_ms(),
2878 task_id: task_id.to_string(),
2879 turn,
2880 phase: phase.to_string(),
2881 detached_work_ids,
2882 delegation_ids,
2883 };
2884 self.write_event(&event).await
2885 }
2886
2887 pub(crate) async fn write_task_rejected(
2893 &mut self,
2894 task_id: &str,
2895 context_id: &str,
2896 source: &str,
2897 cause: &str,
2898 reason: &str,
2899 ) -> std::io::Result<()> {
2900 let event = TaskRejectedEvent {
2901 event_type: "task_rejected",
2902 event_id: new_event_id(),
2903 parent_id: self.task_parent(),
2904 session_id: self.session_id.clone(),
2905 timestamp: timestamp_ms(),
2906 task_id: task_id.to_string(),
2907 context_id: context_id.to_string(),
2908 source: source.to_string(),
2909 cause: cause.to_string(),
2910 reason: reason.to_string(),
2911 };
2912 self.write_event(&event).await
2913 }
2914
2915 pub(crate) async fn write_task_failed(
2921 &mut self,
2922 turn: Option<u32>,
2923 cause: &str,
2924 reason: &str,
2925 ) -> std::io::Result<()> {
2926 let reason = crate::agent::truncate_on_char_boundary(reason, MAX_TASK_FAILURE_REASON_BYTES)
2927 .to_string();
2928 let event = TaskFailedEvent {
2929 event_type: "task_failed",
2930 event_id: new_event_id(),
2931 parent_id: self.task_parent(),
2932 session_id: self.session_id.clone(),
2933 timestamp: timestamp_ms(),
2934 task_id: self.active_task_id.clone(),
2935 turn,
2936 cause: cause.to_string(),
2937 reason: reason.clone(),
2938 };
2939 self.task_failures_written += 1;
2940 self.last_task_failure = Some(reason);
2941 self.write_event(&event).await
2942 }
2943
2944 pub(crate) async fn write_workdir_path_refused(
2946 &mut self,
2947 refusal: &crate::workdir_fs::WorkdirRefusal,
2948 ) -> std::io::Result<()> {
2949 let event = WorkdirPathRefusedEvent {
2950 event_type: "workdir_path_refused",
2951 event_id: new_event_id(),
2952 parent_id: self.task_parent(),
2953 session_id: self.session_id.clone(),
2954 timestamp: timestamp_ms(),
2955 task_id: self.active_task_id.clone(),
2956 path: &refusal.path,
2957 operation: refusal.operation,
2958 refused_at: &refusal.refused_at,
2959 reason: refusal.reason,
2960 };
2961 self.write_event(&event).await
2962 }
2963
2964 pub(crate) fn last_task_failure(&self) -> Option<&str> {
2966 self.last_task_failure.as_deref()
2967 }
2968
2969 pub(crate) fn task_failures_written(&self) -> u64 {
2972 self.task_failures_written
2973 }
2974
2975 pub(crate) async fn write_task_reopened(
2979 &mut self,
2980 task_id: &str,
2981 hook_name: &str,
2982 reason: &str,
2983 reopen_number: u32,
2984 attempt_context: &'static str,
2985 turns_remaining: u32,
2986 ) -> std::io::Result<()> {
2987 let event = TaskReopenedEvent {
2988 event_type: "task_reopened",
2989 event_id: new_event_id(),
2990 parent_id: self.task_parent(),
2991 session_id: self.session_id.clone(),
2992 timestamp: timestamp_ms(),
2993 task_id: task_id.to_string(),
2994 hook_name: hook_name.to_string(),
2995 reason: reason.to_string(),
2996 reopen_number,
2997 attempt_context,
2998 turns_remaining,
2999 };
3000 self.write_event(&event).await
3001 }
3002
3003 pub(crate) async fn write_task_continued(
3007 &mut self,
3008 task_id: &str,
3009 continuation_number: u32,
3010 member_calls: &[String],
3011 delegations: &[String],
3012 waited_ms: u64,
3013 turns_remaining: u32,
3014 ) -> std::io::Result<()> {
3015 let event = TaskContinuedEvent {
3016 event_type: "task_continued",
3017 event_id: new_event_id(),
3018 parent_id: self.task_parent(),
3019 session_id: self.session_id.clone(),
3020 timestamp: timestamp_ms(),
3021 task_id: task_id.to_string(),
3022 continuation_number,
3023 member_calls,
3024 delegations,
3025 waited_ms,
3026 turns_remaining,
3027 };
3028 self.write_event(&event).await
3029 }
3030
3031 pub(crate) fn task_turns(&self) -> u32 {
3041 self.task_turns
3042 }
3043
3044 pub(crate) fn task_hook_inferences(&self) -> u32 {
3046 self.task_hook_inferences
3047 }
3048
3049 pub(crate) fn task_agent_turns(&self) -> u32 {
3053 self.task_agent_turns
3054 }
3055
3056 pub(crate) async fn write_compaction(
3057 &mut self,
3058 turn: u32,
3059 tokens_before: u64,
3060 tokens_after: u64,
3061 ) -> std::io::Result<()> {
3062 let event = CompactionEvent {
3063 event_type: "compaction",
3064 event_id: new_event_id(),
3065 parent_id: self.turn_parent(),
3066 session_id: self.session_id.clone(),
3067 timestamp: timestamp_ms(),
3068 turn,
3069 task_id: self.active_task_id.clone(),
3070 tokens_before,
3071 tokens_after,
3072 };
3073 self.write_event(&event).await
3074 }
3075
3076 pub(crate) async fn write_compaction_declined(
3081 &mut self,
3082 turn: u32,
3083 tokens: u64,
3084 reason: &str,
3085 ) -> std::io::Result<()> {
3086 let event = CompactionDeclinedEvent {
3087 event_type: "compaction_declined",
3088 event_id: new_event_id(),
3089 parent_id: self.turn_parent(),
3090 session_id: self.session_id.clone(),
3091 timestamp: timestamp_ms(),
3092 turn,
3093 task_id: self.active_task_id.clone(),
3094 tokens,
3095 reason: reason.to_string(),
3096 };
3097 self.write_event(&event).await
3098 }
3099
3100 #[allow(clippy::too_many_arguments)]
3107 pub(crate) async fn write_context_seed(
3108 &mut self,
3109 hook_name: &str,
3110 tokens: u64,
3111 proposed_tokens: u64,
3112 budget_tokens: u64,
3113 outcome: &str,
3114 reason: Option<&str>,
3115 message_ids: Vec<String>,
3116 ) -> std::io::Result<()> {
3117 let event = ContextSeedEvent {
3118 event_type: "context_seed",
3119 event_id: new_event_id(),
3120 parent_id: self.task_parent(),
3121 session_id: self.session_id.clone(),
3122 timestamp: timestamp_ms(),
3123 task_id: self.active_task_id.clone(),
3124 hook_name: hook_name.to_string(),
3125 tokens,
3126 proposed_tokens,
3127 budget_tokens,
3128 outcome: outcome.to_string(),
3129 reason: reason.map(str::to_string),
3130 message_ids,
3131 };
3132 self.write_event(&event).await
3133 }
3134
3135 pub(crate) async fn write_retention(
3141 &mut self,
3142 store: &str,
3143 reason: &str,
3144 targets: Vec<String>,
3145 messages_dropped: Option<u64>,
3146 ) -> std::io::Result<()> {
3147 let event = RetentionEvent {
3148 event_type: "retention",
3149 event_id: new_event_id(),
3150 parent_id: self.session_parent(),
3151 session_id: self.session_id.clone(),
3152 timestamp: timestamp_ms(),
3153 store: store.to_string(),
3154 reason: reason.to_string(),
3155 removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
3156 targets,
3157 messages_dropped,
3158 };
3159 self.write_event(&event).await
3160 }
3161
3162 pub(crate) async fn write_call_denied(
3166 &mut self,
3167 turn: u32,
3168 event: &str,
3169 hook_name: &str,
3170 target: &str,
3171 reason: &str,
3172 ) -> std::io::Result<()> {
3173 let event = CallDeniedEvent {
3174 event_type: "call_denied",
3175 event_id: new_event_id(),
3176 parent_id: self.turn_parent(),
3177 session_id: self.session_id.clone(),
3178 timestamp: timestamp_ms(),
3179 turn,
3180 event: event.to_string(),
3181 hook_name: hook_name.to_string(),
3182 target: target.to_string(),
3183 reason: reason.to_string(),
3184 };
3185 self.write_event(&event).await
3186 }
3187
3188 pub(crate) async fn write_tool_input_refused(
3193 &mut self,
3194 turn: u32,
3195 tool_name: &str,
3196 tool_call_id: Option<&str>,
3197 missing: &[String],
3198 reason: &str,
3199 ) -> std::io::Result<()> {
3200 let event = ToolInputRefusedEvent {
3201 event_type: "tool_input_refused",
3202 event_id: new_event_id(),
3203 parent_id: self.turn_parent(),
3204 session_id: self.session_id.clone(),
3205 timestamp: timestamp_ms(),
3206 turn,
3207 tool_name: tool_name.to_string(),
3208 tool_call_id: tool_call_id.filter(|id| !id.is_empty()).map(str::to_string),
3209 missing: missing.to_vec(),
3210 reason: reason.to_string(),
3211 };
3212 self.write_event(&event).await
3213 }
3214
3215 pub(crate) async fn write_spend_ceiling_reached(
3218 &mut self,
3219 turn: u32,
3220 refusal: &crate::spend::SpendRefusal,
3221 origin: Option<&str>,
3222 ) -> std::io::Result<()> {
3223 let event = SpendCeilingReachedEvent {
3224 event_type: "spend_ceiling_reached",
3225 event_id: new_event_id(),
3226 parent_id: self.turn_parent(),
3227 session_id: self.session_id.clone(),
3228 timestamp: timestamp_ms(),
3229 turn,
3230 task_id: self.active_task_id.clone(),
3231 refusal,
3232 origin,
3233 };
3234 self.write_event(&event).await
3235 }
3236
3237 #[allow(clippy::too_many_arguments)]
3241 pub(crate) async fn write_protected_path_denied(
3242 &mut self,
3243 turn: u32,
3244 call: &str,
3245 target: &str,
3246 path: &str,
3247 rule: &str,
3248 signal: &str,
3249 reason: &str,
3250 ) -> std::io::Result<()> {
3251 let event = ProtectedPathDeniedEvent {
3252 event_type: "protected_path_denied",
3253 event_id: new_event_id(),
3254 parent_id: self.turn_parent(),
3255 session_id: self.session_id.clone(),
3256 timestamp: timestamp_ms(),
3257 turn,
3258 call: call.to_string(),
3259 target: target.to_string(),
3260 path: path.to_string(),
3261 rule: rule.to_string(),
3262 signal: signal.to_string(),
3263 reason: reason.to_string(),
3264 };
3265 self.write_event(&event).await
3266 }
3267
3268 pub(crate) async fn write_hook_dispatch_error(
3274 &mut self,
3275 hook_name: &str,
3276 event: &str,
3277 arm: &str,
3278 ) -> std::io::Result<()> {
3279 let event = HookDispatchErrorEvent {
3280 event_type: "hook_dispatch_error",
3281 event_id: new_event_id(),
3282 parent_id: self.session_parent(),
3283 session_id: self.session_id.clone(),
3284 timestamp: timestamp_ms(),
3285 hook_name: hook_name.to_string(),
3286 event: event.to_string(),
3287 arm: arm.to_string(),
3288 };
3289 self.write_event(&event).await
3290 }
3291
3292 pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
3302 let event = HarnessStartEvent {
3303 event_type: "harness_start",
3304 event_id: new_event_id(),
3305 parent_id: self.task_parent(),
3306 session_id: self.session_id.clone(),
3307 timestamp: timestamp_ms(),
3308 task_id: self.active_task_id.clone(),
3309 start,
3310 };
3311 self.write_event(&event).await
3312 }
3313
3314 pub(crate) async fn write_harness_warning(
3316 &mut self,
3317 code: &str,
3318 message: &str,
3319 ) -> std::io::Result<()> {
3320 let event = HarnessWarningEvent {
3321 event_type: "harness_warning",
3322 event_id: new_event_id(),
3323 parent_id: self.task_parent(),
3324 session_id: self.session_id.clone(),
3325 timestamp: timestamp_ms(),
3326 task_id: self.active_task_id.clone(),
3327 code: code.to_string(),
3328 message: message.to_string(),
3329 };
3330 self.write_event(&event).await
3331 }
3332
3333 pub(crate) async fn write_harness_session(
3335 &mut self,
3336 harness_session_id: &str,
3337 auth: &str,
3338 model: Option<&str>,
3339 ) -> std::io::Result<()> {
3340 let event = HarnessSessionEvent {
3341 event_type: "harness_session",
3342 event_id: new_event_id(),
3343 parent_id: self.task_parent(),
3344 session_id: self.session_id.clone(),
3345 timestamp: timestamp_ms(),
3346 task_id: self.active_task_id.clone(),
3347 harness_session_id: harness_session_id.to_string(),
3348 auth: auth.to_string(),
3349 model: model.map(str::to_string),
3350 };
3351 self.write_event(&event).await
3352 }
3353
3354 pub(crate) async fn write_harness_session_forgotten(
3356 &mut self,
3357 context_id: &str,
3358 harness_session_id: &str,
3359 requested_by: &str,
3360 ) -> std::io::Result<()> {
3361 let event = HarnessSessionForgottenEvent {
3362 event_type: "harness_session_forgotten",
3363 event_id: new_event_id(),
3364 parent_id: self.task_parent(),
3365 session_id: self.session_id.clone(),
3366 timestamp: timestamp_ms(),
3367 task_id: self.active_task_id.clone(),
3368 context_id: context_id.to_string(),
3369 harness_session_id: harness_session_id.to_string(),
3370 requested_by: requested_by.to_string(),
3371 };
3372 self.write_event(&event).await
3373 }
3374
3375 pub(crate) async fn write_harness_retry(
3377 &mut self,
3378 attempt: u32,
3379 reason: &str,
3380 ) -> std::io::Result<()> {
3381 let event = HarnessRetryEvent {
3382 event_type: "harness_retry",
3383 event_id: new_event_id(),
3384 parent_id: self.task_parent(),
3385 session_id: self.session_id.clone(),
3386 timestamp: timestamp_ms(),
3387 task_id: self.active_task_id.clone(),
3388 attempt,
3389 reason: reason.to_string(),
3390 };
3391 self.write_event(&event).await
3392 }
3393
3394 pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
3397 let event = HarnessNoteEvent {
3398 event_type: "harness_note",
3399 event_id: new_event_id(),
3400 parent_id: self.task_parent(),
3401 session_id: self.session_id.clone(),
3402 timestamp: timestamp_ms(),
3403 task_id: self.active_task_id.clone(),
3404 text: text.to_string(),
3405 };
3406 self.write_event(&event).await
3407 }
3408
3409 pub(crate) async fn write_harness_failed(
3412 &mut self,
3413 kind: &str,
3414 message: &str,
3415 source: &str,
3416 ) -> std::io::Result<()> {
3417 let event = HarnessFailedEvent {
3418 event_type: "harness_failed",
3419 event_id: new_event_id(),
3420 parent_id: self.task_parent(),
3421 session_id: self.session_id.clone(),
3422 timestamp: timestamp_ms(),
3423 task_id: self.active_task_id.clone(),
3424 kind: kind.to_string(),
3425 message: message.to_string(),
3426 source: source.to_string(),
3427 };
3428 self.write_event(&event).await
3429 }
3430
3431 pub(crate) async fn write_harness_interrupt(
3434 &mut self,
3435 method: &str,
3436 delivered: bool,
3437 grace_ms: u64,
3438 ) -> std::io::Result<()> {
3439 let event = HarnessInterruptEvent {
3440 event_type: "harness_interrupt",
3441 event_id: new_event_id(),
3442 parent_id: self.task_parent(),
3443 session_id: self.session_id.clone(),
3444 timestamp: timestamp_ms(),
3445 task_id: self.active_task_id.clone(),
3446 method: method.to_string(),
3447 delivered,
3448 grace_ms,
3449 };
3450 self.write_event(&event).await
3451 }
3452
3453 pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
3455 let event = HarnessExitEvent {
3456 event_type: "harness_exit",
3457 event_id: new_event_id(),
3458 parent_id: self.task_parent(),
3459 session_id: self.session_id.clone(),
3460 timestamp: timestamp_ms(),
3461 task_id: self.active_task_id.clone(),
3462 exit,
3463 };
3464 self.write_event(&event).await
3465 }
3466
3467 pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
3468 let duration_ms = self
3469 .session_start_time
3470 .elapsed()
3471 .as_millis()
3472 .try_into()
3473 .unwrap_or(u64::MAX);
3474 let event = SessionEndEvent {
3475 event_type: "session_end",
3476 event_id: new_event_id(),
3477 parent_id: self.session_parent(),
3478 session_id: self.session_id.clone(),
3479 timestamp: timestamp_ms(),
3480 total_turns: self.total_turns,
3481 total_input_tokens: self.total_input_tokens,
3482 total_output_tokens: self.total_output_tokens,
3483 total_tool_calls: self.total_tool_calls,
3484 total_shell_calls: self.total_shell_calls,
3485 duration_ms,
3486 exit_status: exit_status.to_string(),
3487 };
3488 self.write_event(&event).await?;
3489 self.session_ended = true;
3490 Ok(())
3491 }
3492
3493 pub(crate) async fn write_session_end_if_not_ended(
3496 &mut self,
3497 exit_status: &str,
3498 ) -> std::io::Result<()> {
3499 if self.session_started && !self.session_ended {
3500 self.write_session_end(exit_status).await?;
3501 }
3502 Ok(())
3503 }
3504
3505 pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
3506 self.writer.flush().await
3507 }
3508
3509 async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
3517 let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
3518 line.push('\n');
3519 self.writer.write_all(line.as_bytes()).await?;
3520 self.writer.flush().await
3521 }
3522}
3523
3524#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3526pub(crate) struct SessionGateway {
3527 pub(crate) artifact: String,
3529 pub(crate) host: String,
3531 pub(crate) credential_source: &'static str,
3533 pub(crate) metered: bool,
3536}
3537
3538#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3541pub(crate) struct RuntimeArtifact {
3542 name: String,
3543 version: String,
3544 origin: &'static str,
3546 session: String,
3548 trust: &'static str,
3550}
3551
3552#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3554pub(crate) struct SessionControl {
3555 pub(crate) settings: Vec<&'static str>,
3557 pub(crate) secrets: Vec<String>,
3559 #[serde(skip_serializing_if = "Vec::is_empty")]
3562 pub(crate) agent_settings: Vec<&'static str>,
3563}
3564
3565#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3568pub(crate) struct SessionInferenceChoice {
3569 pub(crate) name: String,
3570 pub(crate) driver: String,
3571 pub(crate) model: String,
3572 pub(crate) credential_source: &'static str,
3575 pub(crate) available: bool,
3578}
3579
3580pub(crate) struct ControlChange<'a> {
3584 pub(crate) event_id: String,
3585 pub(crate) principal: crate::control_plane::Principal,
3587 pub(crate) token_id: Option<&'a str>,
3590 pub(crate) kind: &'static str,
3592 pub(crate) name: &'a str,
3593 pub(crate) action: &'static str,
3595 pub(crate) previous: Option<Value>,
3597 pub(crate) value: Option<Value>,
3598 pub(crate) applies_from: Option<&'static str>,
3600 pub(crate) replaced: Option<bool>,
3602}
3603
3604#[derive(Serialize)]
3607struct ControlChangeEvent<'a> {
3608 event_type: &'static str,
3609 event_id: String,
3610 parent_id: Option<String>,
3611 session_id: String,
3612 timestamp: u64,
3613 principal: &'static str,
3614 #[serde(skip_serializing_if = "Option::is_none")]
3615 token_id: Option<&'a str>,
3616 kind: &'static str,
3617 name: &'a str,
3618 action: &'static str,
3619 #[serde(skip_serializing_if = "Option::is_none")]
3620 previous: Option<Value>,
3621 #[serde(skip_serializing_if = "Option::is_none")]
3622 value: Option<Value>,
3623 #[serde(skip_serializing_if = "Option::is_none")]
3624 applies_from: Option<&'static str>,
3625 #[serde(skip_serializing_if = "Option::is_none")]
3626 replaced: Option<bool>,
3627}
3628
3629#[derive(Serialize)]
3633struct ControlRefusedEvent<'a> {
3634 event_type: &'static str,
3635 event_id: String,
3636 parent_id: Option<String>,
3637 session_id: String,
3638 timestamp: u64,
3639 principal: &'static str,
3640 status: u16,
3641 reason: &'static str,
3642 #[serde(skip_serializing_if = "Option::is_none")]
3643 kind: Option<&'static str>,
3644 #[serde(skip_serializing_if = "Option::is_none")]
3645 name: Option<&'a str>,
3646 #[serde(skip_serializing_if = "Option::is_none")]
3647 token_id: Option<&'a str>,
3648}
3649
3650#[derive(Serialize)]
3652struct ControlAppliedEvent<'a> {
3653 event_type: &'static str,
3654 event_id: String,
3655 parent_id: Option<String>,
3656 session_id: String,
3657 timestamp: u64,
3658 turn: u32,
3659 task_id: Option<String>,
3660 name: &'a str,
3661 value: Value,
3662 change_id: &'a str,
3663}
3664
3665#[derive(Serialize)]
3667struct ToolsRefreshedEvent<'a> {
3668 event_type: &'static str,
3669 event_id: String,
3670 parent_id: Option<String>,
3671 session_id: String,
3672 timestamp: u64,
3673 turn: u32,
3674 task_id: Option<String>,
3675 trigger: &'a str,
3676 added: &'a [String],
3677 removed: &'a [String],
3678 tools: &'a [String],
3679}
3680
3681#[derive(Serialize)]
3684struct GatewayCredentialEvent<'a> {
3685 event_type: &'static str,
3686 event_id: String,
3687 parent_id: Option<String>,
3688 session_id: String,
3689 timestamp: u64,
3690 artifact: &'a str,
3691 source: &'static str,
3692 #[serde(skip_serializing_if = "Option::is_none")]
3693 credential: Option<&'a str>,
3694 change: &'static str,
3695 #[serde(skip_serializing_if = "Option::is_none")]
3696 trigger: Option<&'static str>,
3697 #[serde(skip_serializing_if = "Option::is_none")]
3698 status: Option<u16>,
3699 #[serde(skip_serializing_if = "Option::is_none")]
3700 retried: Option<bool>,
3701 #[serde(skip_serializing_if = "Option::is_none")]
3702 reason: Option<&'static str>,
3703}
3704
3705#[derive(Serialize)]
3709struct InferenceCredentialEvent<'a> {
3710 event_type: &'static str,
3711 event_id: String,
3712 parent_id: Option<String>,
3713 session_id: String,
3714 timestamp: u64,
3715 source: &'static str,
3716 #[serde(skip_serializing_if = "Option::is_none")]
3717 credential: Option<&'a str>,
3718 change: &'static str,
3719 #[serde(skip_serializing_if = "Option::is_none")]
3720 trigger: Option<&'static str>,
3721 #[serde(skip_serializing_if = "Option::is_none")]
3722 status: Option<u16>,
3723 #[serde(skip_serializing_if = "Option::is_none")]
3724 retried: Option<bool>,
3725 #[serde(skip_serializing_if = "Option::is_none")]
3726 reason: Option<&'static str>,
3727}
3728
3729#[allow(clippy::type_complexity)]
3732fn credential_change_fields(
3733 change: CredentialChange,
3734) -> (
3735 &'static str,
3736 Option<&'static str>,
3737 Option<u16>,
3738 Option<bool>,
3739 Option<&'static str>,
3740) {
3741 match change {
3742 CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
3743 CredentialChange::Rejected { status, retried } => {
3744 ("rejected", None, Some(status), Some(retried), None)
3745 }
3746 CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
3747 }
3748}
3749
3750pub struct ResourceTraceAppender {
3765 file: tokio::sync::Mutex<File>,
3767 session_id: String,
3768 session_event_id: String,
3773}
3774
3775impl ResourceTraceAppender {
3776 pub async fn open(
3777 records_root: &WorkdirRoot,
3778 session_id: String,
3779 session_event_id: String,
3780 ) -> std::io::Result<Self> {
3781 let file = File::from_std(records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?);
3782 Ok(Self {
3783 file: tokio::sync::Mutex::new(file),
3784 session_id,
3785 session_event_id,
3786 })
3787 }
3788
3789 #[allow(clippy::too_many_arguments)]
3793 pub(crate) async fn write_resource_list(
3794 &self,
3795 root: &str,
3796 entry_count: usize,
3797 total_bytes: u64,
3798 generation: u64,
3799 containment_achieved: ContainmentClass,
3800 outcome: &str,
3801 reason: Option<String>,
3802 ) {
3803 let event = ResourceListEvent {
3804 event_type: "resource_list",
3805 event_id: new_event_id(),
3806 parent_id: Some(self.session_event_id.clone()),
3807 session_id: self.session_id.clone(),
3808 timestamp: timestamp_ms(),
3809 root: root.to_string(),
3810 entry_count,
3811 total_bytes,
3812 generation,
3813 containment_achieved,
3814 outcome: outcome.to_string(),
3815 reason,
3816 };
3817 self.append(&event).await;
3818 }
3819
3820 #[allow(clippy::too_many_arguments)]
3823 pub(crate) async fn write_resource_read(
3824 &self,
3825 path: &str,
3826 outcome: &str,
3827 bytes: Option<u64>,
3828 sha256: Option<String>,
3829 generation: u64,
3830 containment_achieved: ContainmentClass,
3831 reason: Option<String>,
3832 ) {
3833 let event = ResourceReadEvent {
3834 event_type: "resource_read",
3835 event_id: new_event_id(),
3836 parent_id: Some(self.session_event_id.clone()),
3837 session_id: self.session_id.clone(),
3838 timestamp: timestamp_ms(),
3839 path: path.to_string(),
3840 outcome: outcome.to_string(),
3841 bytes,
3842 sha256,
3843 generation,
3844 containment_achieved,
3845 reason,
3846 };
3847 self.append(&event).await;
3848 }
3849
3850 pub(crate) async fn write_formation_ended(&self, formation_id: &crate::FormationId) {
3853 let event = FormationEndedEvent {
3854 event_type: "formation_ended",
3855 event_id: new_event_id(),
3856 parent_id: Some(self.session_event_id.clone()),
3857 session_id: self.session_id.clone(),
3858 timestamp: timestamp_ms(),
3859 formation_id: formation_id.as_str().to_string(),
3860 };
3861 self.append(&event).await;
3862 }
3863
3864 pub(crate) async fn write_spawner_ended(
3868 &self,
3869 spawned_by: Option<&str>,
3870 delegation_id: Option<&str>,
3871 ) {
3872 let event = SpawnerEndedEvent {
3873 event_type: "spawner_ended",
3874 event_id: new_event_id(),
3875 parent_id: Some(self.session_event_id.clone()),
3876 session_id: self.session_id.clone(),
3877 timestamp: timestamp_ms(),
3878 spawned_by: spawned_by.map(str::to_string),
3879 delegation_id: delegation_id.map(str::to_string),
3880 };
3881 self.append(&event).await;
3882 }
3883
3884 #[allow(clippy::too_many_arguments)]
3887 pub(crate) async fn write_peer_handle_mint(
3888 &self,
3889 handle_id: Option<String>,
3890 path: &str,
3891 audience: &str,
3892 expires_at_ms: Option<u64>,
3893 outcome: &str,
3894 reason: Option<String>,
3895 ) {
3896 let event = PeerHandleMintEvent {
3897 event_type: "peer_handle_mint",
3898 event_id: new_event_id(),
3899 parent_id: Some(self.session_event_id.clone()),
3900 session_id: self.session_id.clone(),
3901 timestamp: timestamp_ms(),
3902 handle_id,
3903 path: path.to_string(),
3904 audience: audience.to_string(),
3905 expires_at_ms,
3906 outcome: outcome.to_string(),
3907 reason,
3908 };
3909 self.append(&event).await;
3910 }
3911
3912 #[allow(clippy::too_many_arguments)]
3915 pub(crate) async fn write_peer_handle_redeem(
3916 &self,
3917 handle_id: &str,
3918 path: Option<String>,
3919 generation: u64,
3920 audience_asserted: Option<String>,
3921 bytes: Option<u64>,
3922 sha256: Option<String>,
3923 outcome: &str,
3924 reason: Option<String>,
3925 ) {
3926 let event = PeerHandleRedeemEvent {
3927 event_type: "peer_handle_redeem",
3928 event_id: new_event_id(),
3929 parent_id: Some(self.session_event_id.clone()),
3930 session_id: self.session_id.clone(),
3931 timestamp: timestamp_ms(),
3932 handle_id: handle_id.to_string(),
3933 path,
3934 generation,
3935 audience_asserted,
3936 bytes,
3937 sha256,
3938 outcome: outcome.to_string(),
3939 reason,
3940 };
3941 self.append(&event).await;
3942 }
3943
3944 #[allow(clippy::too_many_arguments)]
3946 pub(crate) async fn write_peer_file_fetch(
3947 &self,
3948 peer: &str,
3949 handle_id: &str,
3950 stored_path: Option<String>,
3951 bytes: Option<u64>,
3952 sha256: Option<String>,
3953 outcome: &str,
3954 reason: Option<String>,
3955 ) {
3956 let event = PeerFileFetchEvent {
3957 event_type: "peer_file_fetch",
3958 event_id: new_event_id(),
3959 parent_id: Some(self.session_event_id.clone()),
3960 session_id: self.session_id.clone(),
3961 timestamp: timestamp_ms(),
3962 peer: peer.to_string(),
3963 handle_id: handle_id.to_string(),
3964 stored_path,
3965 bytes,
3966 sha256,
3967 outcome: outcome.to_string(),
3968 reason,
3969 };
3970 self.append(&event).await;
3971 }
3972
3973 pub(crate) async fn write_delegation_start(
3978 &self,
3979 delegation_id: &str,
3980 capsule: &str,
3981 version: &str,
3982 child_session_id: &str,
3983 child_workdir: &str,
3984 child_records_dir: &str,
3985 ) {
3986 let event = DelegationStartEvent::new(
3987 &self.session_id,
3988 &self.session_event_id,
3989 delegation_id,
3990 capsule,
3991 version,
3992 child_session_id,
3993 child_workdir,
3994 child_records_dir,
3995 );
3996 self.append(&event).await;
3997 }
3998
3999 #[allow(clippy::too_many_arguments)]
4003 pub(crate) async fn write_delegation(
4004 &self,
4005 capsule: &str,
4006 version: &str,
4007 delegation_id: Option<String>,
4008 child_session_id: Option<String>,
4009 duration_ms: u64,
4010 outcome: &str,
4011 reason: Option<String>,
4012 ) {
4013 let event = DelegationEvent::new(
4014 &self.session_id,
4015 &self.session_event_id,
4016 capsule,
4017 version,
4018 delegation_id,
4019 child_session_id,
4020 duration_ms,
4021 outcome,
4022 reason,
4023 );
4024 self.append(&event).await;
4025 }
4026
4027 pub(crate) async fn write_member_call_start(
4029 &self,
4030 task_id: &str,
4031 call_id: &str,
4032 member: &str,
4033 member_task_id: &str,
4034 ) {
4035 let event = self.member_call_start(task_id, call_id, member, member_task_id);
4036 self.append(&event).await;
4037 }
4038
4039 pub(crate) async fn write_member_call_start_if(
4042 &self,
4043 task_id: &str,
4044 call_id: &str,
4045 member: &str,
4046 member_task_id: &str,
4047 admit: impl FnOnce() -> bool,
4048 ) -> bool {
4049 let event = self.member_call_start(task_id, call_id, member, member_task_id);
4050 self.append_if(&event, admit).await
4051 }
4052
4053 fn member_call_start(
4054 &self,
4055 task_id: &str,
4056 call_id: &str,
4057 member: &str,
4058 member_task_id: &str,
4059 ) -> MemberCallStartEvent {
4060 MemberCallStartEvent {
4061 event_type: "member_call_start",
4062 event_id: new_event_id(),
4063 parent_id: Some(self.session_event_id.clone()),
4064 session_id: self.session_id.clone(),
4065 timestamp: timestamp_ms(),
4066 task_id: task_id.to_string(),
4067 call_id: call_id.to_string(),
4068 member: member.to_string(),
4069 member_task_id: member_task_id.to_string(),
4070 }
4071 }
4072
4073 pub(crate) async fn write_member_call_busy(
4076 &self,
4077 task_id: &str,
4078 call_id: &str,
4079 member: &str,
4080 offer: u32,
4081 waited_ms: u64,
4082 message: &str,
4083 ) {
4084 let event = MemberCallBusyEvent {
4085 event_type: "member_call_busy",
4086 event_id: new_event_id(),
4087 parent_id: Some(self.session_event_id.clone()),
4088 session_id: self.session_id.clone(),
4089 timestamp: timestamp_ms(),
4090 task_id: task_id.to_string(),
4091 call_id: call_id.to_string(),
4092 member: member.to_string(),
4093 offer,
4094 waited_ms,
4095 message: message.to_string(),
4096 };
4097 self.append(&event).await;
4098 }
4099
4100 pub(crate) async fn write_member_call(
4102 &self,
4103 task_id: &str,
4104 outcome: &crate::member_call::MemberCallOutcome,
4105 delivered: bool,
4106 ) {
4107 let event = MemberCallEvent {
4108 event_type: "member_call",
4109 event_id: new_event_id(),
4110 parent_id: Some(self.session_event_id.clone()),
4111 session_id: self.session_id.clone(),
4112 timestamp: timestamp_ms(),
4113 task_id: task_id.to_string(),
4114 call_id: outcome.call_id.clone(),
4115 member: outcome.member.clone(),
4116 member_task_id: outcome.member_task_id.clone(),
4117 status: outcome.status.as_str().to_string(),
4118 duration_ms: outcome.duration_ms,
4119 output: outcome.output.clone(),
4120 truncated: outcome.truncated,
4121 delivered,
4122 no_answer_below: outcome
4123 .below
4124 .iter()
4125 .map(|(member, status)| NoAnswerBelow {
4126 member: member.clone(),
4127 status: status.as_str().to_string(),
4128 })
4129 .collect(),
4130 };
4131 self.append(&event).await;
4132 }
4133
4134 pub(crate) async fn write_inference_credential(
4137 &self,
4138 source: &'static str,
4139 credential: Option<&str>,
4140 change: CredentialChange,
4141 ) {
4142 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
4143 let event = InferenceCredentialEvent {
4144 event_type: "inference_credential",
4145 event_id: new_event_id(),
4146 parent_id: Some(self.session_event_id.clone()),
4147 session_id: self.session_id.clone(),
4148 timestamp: timestamp_ms(),
4149 source,
4150 credential,
4151 change: change_name,
4152 trigger,
4153 status,
4154 retried,
4155 reason,
4156 };
4157 self.append(&event).await;
4158 }
4159
4160 pub(crate) async fn write_gateway_credential(
4163 &self,
4164 artifact: &str,
4165 source: &'static str,
4166 credential: Option<&str>,
4167 change: CredentialChange,
4168 ) {
4169 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
4170 let event = GatewayCredentialEvent {
4171 event_type: "gateway_credential",
4172 event_id: new_event_id(),
4173 parent_id: Some(self.session_event_id.clone()),
4174 session_id: self.session_id.clone(),
4175 timestamp: timestamp_ms(),
4176 artifact,
4177 source,
4178 credential,
4179 change: change_name,
4180 trigger,
4181 status,
4182 retried,
4183 reason,
4184 };
4185 self.append(&event).await;
4186 }
4187
4188 pub(crate) async fn write_control_change(&self, change: ControlChange<'_>) {
4190 let event = ControlChangeEvent {
4191 event_type: "control_change",
4192 event_id: change.event_id,
4193 parent_id: Some(self.session_event_id.clone()),
4194 session_id: self.session_id.clone(),
4195 timestamp: timestamp_ms(),
4196 principal: change.principal.as_str(),
4197 token_id: change.token_id,
4198 kind: change.kind,
4199 name: change.name,
4200 action: change.action,
4201 previous: change.previous,
4202 value: change.value,
4203 applies_from: change.applies_from,
4204 replaced: change.replaced,
4205 };
4206 self.append(&event).await;
4207 }
4208
4209 pub(crate) async fn write_control_refused(
4212 &self,
4213 principal: crate::control_plane::Principal,
4214 status: u16,
4215 reason: &'static str,
4216 kind: Option<&'static str>,
4217 name: Option<&str>,
4218 token_id: Option<&str>,
4219 ) {
4220 let event = ControlRefusedEvent {
4221 event_type: "control_refused",
4222 event_id: new_event_id(),
4223 parent_id: Some(self.session_event_id.clone()),
4224 session_id: self.session_id.clone(),
4225 timestamp: timestamp_ms(),
4226 principal: principal.as_str(),
4227 status,
4228 reason,
4229 kind,
4230 name,
4231 token_id,
4232 };
4233 self.append(&event).await;
4234 }
4235
4236 async fn append(&self, event: &impl Serialize) {
4237 self.append_if(event, || true).await;
4238 }
4239
4240 async fn append_if(&self, event: &impl Serialize, admit: impl FnOnce() -> bool) -> bool {
4243 let Ok(mut line) = serde_json::to_string(event) else {
4244 return admit();
4245 };
4246 line.push('\n');
4247 let mut file = self.file.lock().await;
4248 if !admit() {
4249 return false;
4250 }
4251 if file.write_all(line.as_bytes()).await.is_ok() {
4252 let _ = file.flush().await;
4253 }
4254 true
4255 }
4256}
4257
4258pub(crate) struct PriorSessionTraceAppender {
4273 file: File,
4274 session_id: String,
4276 session_event_id: Option<String>,
4278 terminate_torn_tail: bool,
4282}
4283
4284impl PriorSessionTraceAppender {
4285 pub(crate) async fn open(
4290 records_group: &WorkdirRoot,
4291 trace: &str,
4292 session_id: String,
4293 session_event_id: Option<String>,
4294 ) -> std::io::Result<Self> {
4295 let contents = records_group
4296 .read_file(trace, u64::MAX)?
4297 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))?;
4298 let terminate_torn_tail = contents.last().is_some_and(|last| *last != b'\n');
4300 let file = File::from_std(records_group.open_append(trace, DEFAULT_FILE_MODE)?);
4301 Ok(Self {
4302 file,
4303 session_id,
4304 session_event_id,
4305 terminate_torn_tail,
4306 })
4307 }
4308
4309 pub(crate) async fn write_shell_lost(
4315 &mut self,
4316 work: &crate::detached::LostWork,
4317 reconciled_by_session: &str,
4318 reconciled_task_id: &str,
4319 ) -> std::io::Result<()> {
4320 let event = ShellLostEvent {
4321 event_type: "shell_lost",
4322 event_id: new_event_id(),
4323 parent_id: self.session_event_id.clone(),
4324 session_id: self.session_id.clone(),
4325 timestamp: timestamp_ms(),
4326 work_id: work.work_id.clone(),
4327 binary: work.binary.clone(),
4328 command: work.command.clone(),
4329 detached_at_ms: work.detached_at_ms,
4330 reconciled_by_session: reconciled_by_session.to_string(),
4331 reconciled_task_id: reconciled_task_id.to_string(),
4332 };
4333 let mut line = if self.terminate_torn_tail {
4334 "\n".to_string()
4335 } else {
4336 String::new()
4337 };
4338 line.push_str(&serde_json::to_string(&event).map_err(std::io::Error::other)?);
4339 line.push('\n');
4340 self.file.write_all(line.as_bytes()).await?;
4341 self.file.flush().await?;
4342 self.terminate_torn_tail = false;
4343 Ok(())
4344 }
4345}
4346
4347pub(crate) fn timestamp_ms() -> u64 {
4348 SystemTime::now()
4349 .duration_since(UNIX_EPOCH)
4350 .unwrap_or_default()
4351 .as_millis() as u64
4352}
4353
4354#[derive(Serialize, Clone)]
4361pub(crate) struct PlanStepShape {
4362 pub(crate) step_id: String,
4363 pub(crate) kind: &'static str,
4366 pub(crate) depends_on: Vec<String>,
4367 pub(crate) has_condition: bool,
4369}
4370
4371#[derive(Serialize)]
4374struct PlanStartEvent {
4375 event_type: &'static str,
4376 event_id: String,
4377 parent_id: Option<String>,
4378 session_id: String,
4379 timestamp: u64,
4380 plan_id: String,
4381 step_count: usize,
4382 steps: Vec<PlanStepShape>,
4383}
4384
4385#[derive(Serialize)]
4391struct PlanStepStartEvent {
4392 event_type: &'static str,
4393 event_id: String,
4394 parent_id: Option<String>,
4395 session_id: String,
4396 timestamp: u64,
4397 plan_id: String,
4398 step_id: String,
4399 kind: &'static str,
4400 depends_on: Vec<String>,
4401}
4402
4403#[derive(Serialize)]
4407struct PlanStepEvent {
4408 event_type: &'static str,
4409 event_id: String,
4410 parent_id: Option<String>,
4411 session_id: String,
4412 timestamp: u64,
4413 plan_id: String,
4414 step_id: String,
4415 kind: &'static str,
4416 status: &'static str,
4417 attempts: u32,
4418 duration_ms: u64,
4419 #[serde(skip_serializing_if = "Option::is_none")]
4420 error: Option<String>,
4421 #[serde(skip_serializing_if = "Option::is_none")]
4424 input: Option<Value>,
4425 #[serde(skip_serializing_if = "Option::is_none")]
4429 state_effect: Option<String>,
4430 #[serde(skip_serializing_if = "Option::is_none")]
4432 resource_id: Option<String>,
4433}
4434
4435#[derive(Serialize)]
4437struct PlanEndEvent {
4438 event_type: &'static str,
4439 event_id: String,
4440 parent_id: Option<String>,
4441 session_id: String,
4442 timestamp: u64,
4443 plan_id: String,
4444 outcome: &'static str,
4445 #[serde(skip_serializing_if = "Option::is_none")]
4446 failed_step: Option<String>,
4447 steps_total: usize,
4448 steps_succeeded: usize,
4449 steps_failed: usize,
4450 steps_skipped: usize,
4451 duration_ms: u64,
4452 #[serde(skip_serializing_if = "Option::is_none")]
4455 reason: Option<String>,
4456}
4457
4458pub(crate) struct PlanStepRecord {
4460 pub(crate) plan_id: String,
4461 pub(crate) step_id: String,
4462 pub(crate) kind: &'static str,
4463 pub(crate) status: &'static str,
4466 pub(crate) attempts: u32,
4468 pub(crate) duration_ms: u64,
4469 pub(crate) error: Option<String>,
4470 pub(crate) input: Option<Value>,
4471 pub(crate) state_effect: Option<String>,
4472 pub(crate) resource_id: Option<String>,
4473}
4474
4475pub(crate) struct PlanEndRecord {
4477 pub(crate) plan_id: String,
4478 pub(crate) outcome: &'static str,
4479 pub(crate) failed_step: Option<String>,
4480 pub(crate) steps_total: usize,
4481 pub(crate) steps_succeeded: usize,
4482 pub(crate) steps_failed: usize,
4483 pub(crate) steps_skipped: usize,
4484 pub(crate) duration_ms: u64,
4485 pub(crate) reason: Option<String>,
4486}
4487
4488pub struct PlanTraceAppender {
4499 file: std::sync::Mutex<std::fs::File>,
4500 session_id: String,
4501 session_event_id: String,
4504}
4505
4506impl PlanTraceAppender {
4507 pub fn open(
4508 records_root: &WorkdirRoot,
4509 session_id: String,
4510 session_event_id: String,
4511 ) -> std::io::Result<Self> {
4512 let file = records_root.open_append(TRACE_FILE_NAME, DEFAULT_FILE_MODE)?;
4513 Ok(Self {
4514 file: std::sync::Mutex::new(file),
4515 session_id,
4516 session_event_id,
4517 })
4518 }
4519
4520 pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
4522 let event_id = new_event_id();
4523 let event = PlanStartEvent {
4524 event_type: "plan_start",
4525 event_id: event_id.clone(),
4526 parent_id: Some(self.session_event_id.clone()),
4527 session_id: self.session_id.clone(),
4528 timestamp: timestamp_ms(),
4529 plan_id: plan_id.to_string(),
4530 step_count: steps.len(),
4531 steps,
4532 };
4533 self.append(&event);
4534 event_id
4535 }
4536
4537 pub(crate) fn write_plan_step_start(
4538 &self,
4539 plan_node: &str,
4540 plan_id: &str,
4541 step_id: &str,
4542 kind: &'static str,
4543 depends_on: Vec<String>,
4544 ) {
4545 let event = PlanStepStartEvent {
4546 event_type: "plan_step_start",
4547 event_id: new_event_id(),
4548 parent_id: Some(plan_node.to_string()),
4549 session_id: self.session_id.clone(),
4550 timestamp: timestamp_ms(),
4551 plan_id: plan_id.to_string(),
4552 step_id: step_id.to_string(),
4553 kind,
4554 depends_on,
4555 };
4556 self.append(&event);
4557 }
4558
4559 pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
4560 let input = record.input.map(|mut value| {
4563 crate::peer_handoff::redact_handles_in_json(&mut value);
4564 value
4565 });
4566 let event = PlanStepEvent {
4567 event_type: "plan_step",
4568 event_id: new_event_id(),
4569 parent_id: Some(plan_node.to_string()),
4570 session_id: self.session_id.clone(),
4571 timestamp: timestamp_ms(),
4572 plan_id: record.plan_id,
4573 step_id: record.step_id,
4574 kind: record.kind,
4575 status: record.status,
4576 attempts: record.attempts,
4577 duration_ms: record.duration_ms,
4578 error: record.error,
4579 input,
4580 state_effect: record.state_effect,
4581 resource_id: record.resource_id,
4582 };
4583 self.append(&event);
4584 }
4585
4586 pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
4589 let event = PlanEndEvent {
4590 event_type: "plan_end",
4591 event_id: new_event_id(),
4592 parent_id: Some(
4593 plan_node
4594 .map(str::to_string)
4595 .unwrap_or_else(|| self.session_event_id.clone()),
4596 ),
4597 session_id: self.session_id.clone(),
4598 timestamp: timestamp_ms(),
4599 plan_id: record.plan_id,
4600 outcome: record.outcome,
4601 failed_step: record.failed_step,
4602 steps_total: record.steps_total,
4603 steps_succeeded: record.steps_succeeded,
4604 steps_failed: record.steps_failed,
4605 steps_skipped: record.steps_skipped,
4606 duration_ms: record.duration_ms,
4607 reason: record.reason,
4608 };
4609 self.append(&event);
4610 }
4611
4612 pub(crate) fn write_delegation_start(
4618 &self,
4619 delegation_id: &str,
4620 capsule: &str,
4621 version: &str,
4622 child_session_id: &str,
4623 child_workdir: &str,
4624 child_records_dir: &str,
4625 ) {
4626 let event = DelegationStartEvent::new(
4627 &self.session_id,
4628 &self.session_event_id,
4629 delegation_id,
4630 capsule,
4631 version,
4632 child_session_id,
4633 child_workdir,
4634 child_records_dir,
4635 );
4636 self.append(&event);
4637 }
4638
4639 #[allow(clippy::too_many_arguments)]
4647 pub(crate) fn write_delegation(
4648 &self,
4649 capsule: &str,
4650 version: &str,
4651 delegation_id: Option<String>,
4652 child_session_id: Option<String>,
4653 duration_ms: u64,
4654 outcome: &str,
4655 reason: Option<String>,
4656 ) {
4657 let event = DelegationEvent::new(
4658 &self.session_id,
4659 &self.session_event_id,
4660 capsule,
4661 version,
4662 delegation_id,
4663 child_session_id,
4664 duration_ms,
4665 outcome,
4666 reason,
4667 );
4668 self.append(&event);
4669 }
4670
4671 fn append(&self, event: &impl Serialize) {
4675 use std::io::Write as _;
4676
4677 let Ok(mut line) = serde_json::to_string(event) else {
4678 return;
4679 };
4680 line.push('\n');
4681 let Ok(mut file) = self.file.lock() else {
4682 return;
4683 };
4684 if file.write_all(line.as_bytes()).is_ok() {
4685 let _ = file.flush();
4686 }
4687 }
4688}
4689
4690#[cfg(test)]
4693mod tests {
4694 use super::*;
4695 use crate::{
4696 containment::scope_report_for_tier, sandbox::EnforcementTier, sealed::UsernsGrant,
4697 trace_blobs::BLOB_DIR_NAME, types::CapabilityPolicy,
4698 };
4699 use murmur_artifact::{ArtifactRuntime, InterpreterRuntimeDir, InterpreterRuntimeGrant};
4700 use serde_json::Value;
4701 use std::path::Path;
4702
4703 use crate::origin::TaskOrigin;
4704
4705 fn event_provenance() -> TaskProvenance {
4707 TaskProvenance::derive(TaskOrigin::Event, None)
4708 }
4709
4710 fn report_for(
4714 declared: ContainmentClass,
4715 tier: EnforcementTier,
4716 workdir_exec: bool,
4717 ) -> ScopeReport {
4718 scope_report_for_tier(
4719 &CapabilityPolicy {
4720 workdir_exec_allowed: workdir_exec,
4721 ..CapabilityPolicy::default()
4722 },
4723 declared,
4724 tier,
4725 None,
4726 None,
4727 None,
4728 None,
4729 Vec::new(),
4730 Vec::new(),
4731 Vec::new(),
4732 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4733 crate::cgroup::IoMaxReport::default(),
4734 )
4735 }
4736
4737 fn demotion_info() -> crate::detached::DetachedDispatchInfo {
4738 crate::detached::DetachedDispatchInfo {
4739 work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
4740 binary: "/usr/bin/bash".to_string(),
4741 command: "make -j8".to_string(),
4742 grace_ms: 1_000,
4743 }
4744 }
4745
4746 #[tokio::test]
4749 async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
4750 let dir = tempfile::tempdir().unwrap();
4751 let mut writer = make_writer(dir.path()).await;
4755 let full = std::fs::OpenOptions::new()
4756 .write(true)
4757 .open("/dev/full")
4758 .unwrap();
4759 writer.writer = BufWriter::new(File::from_std(full));
4760 let info = demotion_info();
4761
4762 assert!(
4763 writer
4764 .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
4765 .await
4766 .is_err(),
4767 "the sink under this writer must be unwritable for the claim below to mean anything"
4768 );
4769 crate::agent::record_demotion(&mut writer, 1, &info).await;
4770 }
4771
4772 #[tokio::test]
4774 async fn a_recorded_demotion_writes_only_its_own_marker() {
4775 let dir = tempfile::tempdir().unwrap();
4776 let mut writer = make_writer(dir.path()).await;
4777 crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
4778
4779 let events = read_events(dir.path());
4780 assert_eq!(events.len(), 1);
4781 assert_eq!(events[0]["event_type"], "shell_detached");
4782 assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
4783 assert_eq!(events[0]["turn"], 3);
4784 }
4785
4786 #[tokio::test]
4787 async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
4788 let dir = tempfile::tempdir().unwrap();
4789 let mut writer = make_writer(dir.path()).await;
4790 writer
4791 .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
4792 .await
4793 .unwrap();
4794
4795 let events = read_events(dir.path());
4796 assert_eq!(events.len(), 1);
4797 assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
4798 assert_eq!(events[0]["turn"], 2);
4799 assert_eq!(events[0]["work_id"], "wrk_0a1b");
4800 assert_eq!(events[0]["binary"], "/usr/bin/bash");
4801 assert_eq!(events[0]["reason"], "No space left");
4802 }
4803
4804 #[tokio::test]
4808 async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
4809 let dir = tempfile::tempdir().unwrap();
4810 let mut writer = make_writer(dir.path()).await;
4811 writer
4812 .write_shell_abandoned(
4813 "wrk_0a1b2c3d4e5f6a7b",
4814 "/usr/bin/bash",
4815 "sleep 45; echo done",
4816 45_012,
4817 None,
4818 )
4819 .await
4820 .unwrap();
4821
4822 let events = read_events(dir.path());
4823 assert_eq!(events.len(), 1);
4824 let event = &events[0];
4825 assert_eq!(event["event_type"], "shell_abandoned");
4826 assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
4827 assert_eq!(event["binary"], "/usr/bin/bash");
4828 assert_eq!(event["command"], "sleep 45; echo done");
4829 assert_eq!(event["running_ms"], 45_012);
4830 for absent in ["exit_code", "output_path", "output_bytes"] {
4831 assert!(
4832 event.get(absent).is_none(),
4833 "{absent} must be omitted, not written as null: {event}"
4834 );
4835 }
4836 }
4837
4838 #[tokio::test]
4842 async fn an_abandoned_command_that_finished_carries_its_result() {
4843 let dir = tempfile::tempdir().unwrap();
4844 let mut writer = make_writer(dir.path()).await;
4845 writer
4846 .write_shell_abandoned(
4847 "wrk_0a1b2c3d4e5f6a7b",
4848 "/usr/bin/bash",
4849 "sleep 2; echo done",
4850 2_014,
4851 Some((0, "logs/wrk_0a1b2c3d4e5f6a7b.log", 91)),
4852 )
4853 .await
4854 .unwrap();
4855
4856 let event = &read_events(dir.path())[0];
4857 assert_eq!(event["event_type"], "shell_abandoned");
4858 assert_eq!(event["running_ms"], 2_014);
4859 assert_eq!(event["exit_code"], 0);
4860 assert_eq!(event["output_path"], "logs/wrk_0a1b2c3d4e5f6a7b.log");
4861 assert_eq!(event["output_bytes"], 91);
4862 }
4863
4864 async fn make_writer(dir: &std::path::Path) -> TraceWriter {
4865 make_writer_with_opts(dir, TraceCapture::Meta).await
4866 }
4867
4868 async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
4869 make_writer_with_prompt(dir, capture, None, false).await
4870 }
4871
4872 async fn make_writer_with_prompt(
4873 dir: &std::path::Path,
4874 capture: TraceCapture,
4875 system_prompt: Option<&str>,
4876 system_prompt_overridden: bool,
4877 ) -> TraceWriter {
4878 TraceWriter::open(
4879 &crate::workdir_fs::WorkdirRoot::open(dir).unwrap(),
4880 "test-session-id".to_string(),
4881 "test-capsule".to_string(),
4882 "1.0.0".to_string(),
4883 "claude-test".to_string(),
4884 vec!["shell".to_string()],
4885 report_for(
4886 ContainmentClass::Advisory,
4887 EnforcementTier::EnvironmentOnly,
4888 false,
4889 ),
4890 capture,
4891 system_prompt.map(str::to_string),
4892 system_prompt_overridden,
4893 None,
4894 None,
4895 None,
4896 None,
4897 )
4898 .await
4899 .unwrap()
4900 }
4901
4902 fn read_events(dir: &std::path::Path) -> Vec<Value> {
4903 let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
4904 content
4905 .lines()
4906 .filter(|l| !l.is_empty())
4907 .map(|l| serde_json::from_str(l).unwrap())
4908 .collect()
4909 }
4910
4911 #[tokio::test]
4912 async fn session_start_fields() {
4913 let dir = tempfile::tempdir().unwrap();
4914 let mut w = make_writer(dir.path()).await;
4915 w.write_session_start(10, vec!["bash".to_string()])
4916 .await
4917 .unwrap();
4918 w.flush().await.unwrap();
4919
4920 let events = read_events(dir.path());
4921 assert_eq!(events.len(), 1);
4922 let e = &events[0];
4923 assert_eq!(e["event_type"], "session_start");
4924 assert_eq!(e["session_id"], "test-session-id");
4925 assert_eq!(e["capsule_name"], "test-capsule");
4926 assert_eq!(e["capsule_version"], "1.0.0");
4927 assert_eq!(e["model"], "claude-test");
4928 assert_eq!(e["max_turns"], 10);
4929 assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
4930 assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
4931 assert_eq!(e["containment_declared"], "advisory");
4932 assert_eq!(e["containment_achieved"], "advisory");
4933 assert_eq!(e["workdir_exec"], false);
4934 assert!(e["timestamp"].as_u64().unwrap() > 0);
4935 }
4936
4937 #[tokio::test]
4940 async fn session_start_is_the_first_line_and_carries_a_members_formation() {
4941 let formation = FormationId::mint();
4942 for member in [Some(&formation), None] {
4943 let dir = tempfile::tempdir().unwrap();
4944 let mut w = make_writer(dir.path()).await;
4945 w.set_formation_id(member);
4946 w.write_session_start(10, Vec::new()).await.unwrap();
4947 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
4948 .await
4949 .unwrap();
4950 w.flush().await.unwrap();
4951
4952 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
4953 let first_line = content.lines().next().unwrap();
4954 let first: Value = serde_json::from_str(first_line).unwrap();
4955 assert_eq!(first["event_type"], "session_start");
4956 match member {
4957 Some(id) => {
4958 assert_eq!(first["formation_id"], id.as_str());
4959 assert!(
4960 first_line.contains(&format!("\"formation_id\":\"{id}\"")),
4961 "{first_line}"
4962 );
4963 }
4964 None => assert!(!first_line.contains("formation_id"), "{first_line}"),
4965 }
4966 assert_eq!(
4968 content.matches("formation_id").count(),
4969 usize::from(member.is_some())
4970 );
4971 }
4972 }
4973
4974 #[tokio::test]
4978 async fn a_members_trace_names_it_its_callees_and_its_callers() {
4979 let authority = crate::formation_credentials::FormationAuthority::for_test();
4980 let cases = [
4981 (Some(("coder", vec!["reviewer"])), Some("planner")),
4982 (Some(("reviewer", vec![])), None),
4983 (None, None),
4984 ];
4985 for (member, caller) in cases {
4986 let member = member.map(|(name, callees)| {
4987 crate::formation_credentials::FormationMember::from_bundle(
4988 authority.member_bundle(name, &callees),
4989 )
4990 });
4991 let dir = tempfile::tempdir().unwrap();
4992 let mut w = make_writer(dir.path()).await;
4993 w.set_formation_member(member.as_ref());
4994 w.write_session_start(10, Vec::new()).await.unwrap();
4995 w.write_a2a_task_received("tsk_1", "ctx_1", "msg_1", None, caller)
4996 .await
4997 .unwrap();
4998 w.flush().await.unwrap();
4999 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
5000 let lines: Vec<Value> = content
5001 .lines()
5002 .map(|line| serde_json::from_str(line).unwrap())
5003 .collect();
5004 let start = &lines[0];
5005 let received = lines
5006 .iter()
5007 .find(|line| line["event_type"] == "a2a_task_received")
5008 .unwrap();
5009 match &member {
5010 Some(member) => {
5011 assert_eq!(start["formation_member"], member.name());
5012 assert_eq!(
5013 start["formation_callees"],
5014 serde_json::json!(member.callees().collect::<Vec<_>>())
5015 );
5016 }
5017 None => {
5018 assert!(start.get("formation_member").is_none(), "{start}");
5019 assert!(start.get("formation_callees").is_none(), "{start}");
5020 }
5021 }
5022 match caller {
5023 Some(caller) => assert_eq!(received["caller_member"], caller),
5024 None => assert!(received.get("caller_member").is_none(), "{received}"),
5025 }
5026 assert!(!content.contains("mft1."), "{content}");
5027 }
5028 }
5029
5030 #[tokio::test]
5035 async fn session_start_records_system_prompt_source_and_hash() {
5036 let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
5037
5038 for (prompt, overridden, source, sha) in [
5039 (Some("Be terse."), false, "manifest", Some(&expected_sha)),
5040 (Some("Be terse."), true, "cli", Some(&expected_sha)),
5041 (None, false, "none", None),
5042 (None, true, "cli", None),
5045 ] {
5046 let dir = tempfile::tempdir().unwrap();
5047 let mut w =
5048 make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
5049 w.write_session_start(1, Vec::new()).await.unwrap();
5050 w.flush().await.unwrap();
5051
5052 let e = &read_events(dir.path())[0];
5053 assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
5054 match sha {
5055 Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
5056 None => assert!(
5057 e["system_prompt_sha256"].is_null(),
5058 "no prompt in effect must hash to null, got {}",
5059 e["system_prompt_sha256"]
5060 ),
5061 }
5062 }
5063 }
5064
5065 #[tokio::test]
5069 async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
5070 let sha = murmur_artifact::sha256_hex(b"Be terse.");
5071
5072 let dir = tempfile::tempdir().unwrap();
5073 let mut w =
5074 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
5075 w.write_session_start(1, Vec::new()).await.unwrap();
5076 w.flush().await.unwrap();
5077
5078 let e = &read_events(dir.path())[0];
5079 assert!(
5080 e.get("system_prompt").is_none(),
5081 "the verbatim prompt is not a session_start field, got {e}"
5082 );
5083 assert_eq!(e["system_prompt_sha256"], sha.as_str());
5084 assert!(
5085 !dir.path().join(BLOB_DIR_NAME).exists(),
5086 "meta stores no bodies"
5087 );
5088
5089 let opted_in = tempfile::tempdir().unwrap();
5090 let mut w = make_writer_with_prompt(
5091 opted_in.path(),
5092 TraceCapture::Content,
5093 Some("Be terse."),
5094 false,
5095 )
5096 .await;
5097 w.write_session_start(1, Vec::new()).await.unwrap();
5098 w.flush().await.unwrap();
5099
5100 let event = &read_events(opted_in.path())[0];
5101 assert!(event.get("system_prompt").is_none());
5102 assert_eq!(event["system_prompt_sha256"], sha.as_str());
5103 let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
5104 assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
5105 }
5106
5107 #[tokio::test]
5110 async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
5111 let dir = tempfile::tempdir().unwrap();
5112 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
5113 w.write_session_start(1, Vec::new()).await.unwrap();
5114 w.flush().await.unwrap();
5115
5116 let e = &read_events(dir.path())[0];
5117 assert!(e.get("system_prompt").is_none());
5118 assert!(e["system_prompt_sha256"].is_null());
5119 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
5120 }
5121
5122 #[tokio::test]
5124 async fn every_task_session_start_repeats_the_same_system_prompt_record() {
5125 let dir = tempfile::tempdir().unwrap();
5126 let mut w =
5127 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
5128 w.write_session_start(1, Vec::new()).await.unwrap();
5129 w.write_session_start(1, Vec::new()).await.unwrap();
5130 w.flush().await.unwrap();
5131
5132 let events = read_events(dir.path());
5133 assert_eq!(events.len(), 2);
5134 assert_eq!(
5135 events[0]["system_prompt_source"],
5136 events[1]["system_prompt_source"]
5137 );
5138 assert_eq!(
5139 events[0]["system_prompt_sha256"],
5140 events[1]["system_prompt_sha256"]
5141 );
5142 assert_eq!(events[1]["system_prompt_source"], "cli");
5143 }
5144
5145 #[tokio::test]
5150 async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
5151 let dir = tempfile::tempdir().unwrap();
5152 let mut w = TraceWriter::open(
5153 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
5154 "test-session-id".to_string(),
5155 "test-capsule".to_string(),
5156 "1.0.0".to_string(),
5157 "claude-test".to_string(),
5158 Vec::new(),
5159 report_for(
5161 ContainmentClass::Advisory,
5162 EnforcementTier::KernelFull,
5163 true,
5164 ),
5165 TraceCapture::Meta,
5166 None,
5167 false,
5168 None,
5169 None,
5170 None,
5171 None,
5172 )
5173 .await
5174 .unwrap();
5175 w.write_session_start(1, Vec::new()).await.unwrap();
5176 w.flush().await.unwrap();
5177
5178 let events = read_events(dir.path());
5179 assert_eq!(events[0]["workdir_exec"], true);
5180 assert_eq!(events[0]["containment_achieved"], "advisory");
5181 }
5182
5183 #[tokio::test]
5186 async fn session_start_records_declared_and_achieved_containment_separately() {
5187 let dir = tempfile::tempdir().unwrap();
5188 let mut w = TraceWriter::open(
5189 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
5190 "test-session-id".to_string(),
5191 "test-capsule".to_string(),
5192 "1.0.0".to_string(),
5193 "claude-test".to_string(),
5194 Vec::new(),
5195 report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
5196 TraceCapture::Meta,
5197 None,
5198 false,
5199 None,
5200 None,
5201 None,
5202 None,
5203 )
5204 .await
5205 .unwrap();
5206 w.write_session_start(1, Vec::new()).await.unwrap();
5207 w.flush().await.unwrap();
5208
5209 let events = read_events(dir.path());
5210 assert_eq!(events[0]["containment_declared"], "sealed");
5211 assert_eq!(events[0]["containment_achieved"], "scoped");
5212 }
5213
5214 #[tokio::test]
5222 async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
5223 async fn event_for(grant: UsernsGrant) -> Value {
5224 let report = scope_report_for_tier(
5225 &CapabilityPolicy::default(),
5226 ContainmentClass::Sealed,
5227 EnforcementTier::KernelSealed,
5228 None,
5229 Some(grant),
5230 None,
5231 None,
5232 Vec::new(),
5233 Vec::new(),
5234 Vec::new(),
5235 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5236 crate::cgroup::IoMaxReport::default(),
5237 );
5238 let dir = tempfile::tempdir().unwrap();
5239 let mut w = TraceWriter::open(
5240 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
5241 "test-session-id".to_string(),
5242 "test-capsule".to_string(),
5243 "1.0.0".to_string(),
5244 "claude-test".to_string(),
5245 Vec::new(),
5246 report,
5247 TraceCapture::Meta,
5248 None,
5249 false,
5250 None,
5251 None,
5252 None,
5253 None,
5254 )
5255 .await
5256 .unwrap();
5257 w.write_session_start(1, Vec::new()).await.unwrap();
5258 w.flush().await.unwrap();
5259 let mut event = read_events(dir.path()).remove(0);
5260 event.as_object_mut().unwrap().remove("timestamp");
5263 event
5264 }
5265
5266 let through_profile = event_for(UsernsGrant::ProfileConfining).await;
5267 let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
5268
5269 assert_eq!(through_profile["containment_achieved"], "sealed");
5270 assert_eq!(host_wide["containment_achieved"], "sealed");
5271 assert_eq!(through_profile["userns_grant"], "profile_confining");
5272 assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
5273 assert_ne!(
5274 through_profile, host_wide,
5275 "two hosts granting the user namespace by different mechanisms must not write the \
5276 same session_start record"
5277 );
5278
5279 let unprobed = scope_report_for_tier(
5282 &CapabilityPolicy::default(),
5283 ContainmentClass::Advisory,
5284 EnforcementTier::EnvironmentOnly,
5285 None,
5286 None,
5287 None,
5288 None,
5289 Vec::new(),
5290 Vec::new(),
5291 Vec::new(),
5292 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5293 crate::cgroup::IoMaxReport::default(),
5294 );
5295 assert!(
5296 serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
5297 "off Linux the key is present and null, not absent"
5298 );
5299 }
5300
5301 #[tokio::test]
5309 async fn session_start_records_the_whole_scope_report_as_effective_grants() {
5310 let policy = CapabilityPolicy {
5311 network_allow: vec!["https://api.example.com".to_string()],
5312 unix_sockets_allowed: false,
5313 filesystem_scope: Some("workdir".to_string()),
5314 shell_allow: vec!["python3".to_string()],
5315 spawn_allow: vec!["helper".to_string()],
5316 env_allow: vec!["TZ".to_string()],
5317 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
5318 binary: "python3".to_string(),
5319 dirs: vec![InterpreterRuntimeDir {
5320 path: "/usr/lib/python3.11".to_string(),
5321 list_dir: true,
5322 }],
5323 }],
5324 ..CapabilityPolicy::default()
5325 };
5326 let expected = scope_report_for_tier(
5327 &policy,
5328 ContainmentClass::Scoped,
5329 EnforcementTier::KernelFull,
5330 None,
5331 None,
5332 Some(8),
5334 None,
5335 vec![crate::containment::StateStoreReport {
5338 artifact: "notes-tool".to_string(),
5339 store: "shey".to_string(),
5340 host_path: "/home/dev/.murmur/state/shey".to_string(),
5341 }],
5342 vec!["config-echo".to_string()],
5344 vec![crate::containment::PreopenReport {
5345 artifact: "notes-tool".to_string(),
5346 role: "tool".to_string(),
5347 scope: None,
5348 surface: crate::containment::PreopenSurface::WholeWorkdir,
5349 runtime_dir_reach: Some(crate::containment::RuntimeDirReach::MurmurRuntime),
5350 }],
5351 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5352 crate::cgroup::IoMaxReport::default(),
5353 );
5354
5355 let dir = tempfile::tempdir().unwrap();
5356 let mut w = TraceWriter::open(
5357 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
5358 "test-session-id".to_string(),
5359 "test-capsule".to_string(),
5360 "1.0.0".to_string(),
5361 "claude-test".to_string(),
5362 vec!["network".to_string(), "shell".to_string()],
5363 expected.clone(),
5364 TraceCapture::Meta,
5365 None,
5366 false,
5367 None,
5368 None,
5369 None,
5370 None,
5371 )
5372 .await
5373 .unwrap();
5374 w.write_session_start(1, Vec::new()).await.unwrap();
5375 w.flush().await.unwrap();
5376
5377 let events = read_events(dir.path());
5378 assert_eq!(
5379 events[0]["effective_grants"],
5380 serde_json::to_value(&expected).unwrap()
5381 );
5382
5383 assert_eq!(
5386 events[0]["capabilities"],
5387 serde_json::json!(["network", "shell"])
5388 );
5389 assert_eq!(
5390 events[0]["effective_grants"]["state_stores"],
5391 serde_json::json!([{
5392 "artifact": "notes-tool",
5393 "store": "shey",
5394 "host_path": "/home/dev/.murmur/state/shey",
5395 }])
5396 );
5397 assert_eq!(
5400 events[0]["effective_grants"]["configured_artifacts"],
5401 serde_json::json!(["config-echo"])
5402 );
5403 assert_eq!(
5407 events[0]["effective_grants"]["preopens"],
5408 serde_json::json!([{
5409 "artifact": "notes-tool",
5410 "role": "tool",
5411 "scope": null,
5412 "surface": "whole-workdir",
5413 "runtime_dir_reach": "murmur-runtime",
5414 }])
5415 );
5416 assert_eq!(events[0]["containment_declared"], "scoped");
5417 assert_eq!(events[0]["containment_achieved"], "scoped");
5418 assert_eq!(events[0]["workdir_exec"], false);
5419 assert_eq!(
5420 events[0]["effective_grants"]["network_allow"],
5421 serde_json::json!(["https://api.example.com"])
5422 );
5423 assert_eq!(
5424 events[0]["effective_grants"]["interpreter_runtime_grants"],
5425 serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
5426 );
5427 }
5428
5429 #[tokio::test]
5430 async fn inference_fields_and_snake_case() {
5431 let dir = tempfile::tempdir().unwrap();
5432 let mut w = make_writer(dir.path()).await;
5433 w.write_inference(
5434 0,
5435 Some(100),
5436 Some(50),
5437 "tool_call".to_string(),
5438 Some("tool_call"),
5439 Some("bash".to_string()),
5440 None,
5441 None,
5442 vec!["msg_0000000000000000000000000000000a".to_string()],
5443 None,
5444 )
5445 .await
5446 .unwrap();
5447 w.flush().await.unwrap();
5448
5449 let events = read_events(dir.path());
5450 let e = &events[0];
5451 assert_eq!(e["event_type"], "inference");
5452 assert_eq!(
5453 e["message_ids"],
5454 serde_json::json!(["msg_0000000000000000000000000000000a"]),
5455 "an agent-loop turn names the messages its request embedded"
5456 );
5457 assert_eq!(e["turn"], 0);
5458 assert_eq!(e["input_tokens"], 100);
5459 assert_eq!(e["output_tokens"], 50);
5460 assert_eq!(e["decision"], "tool_call");
5461 assert_eq!(e["tool_name"], "bash");
5462 assert!(e.get("inputTokens").is_none(), "must use snake_case");
5464 assert!(e.get("toolName").is_none(), "must use snake_case");
5465 for key in [
5467 "input_tokens_actual",
5468 "output_tokens_actual",
5469 "cached_tokens",
5470 "cache_write_tokens",
5471 ] {
5472 assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
5473 }
5474 }
5475
5476 #[tokio::test]
5479 async fn inference_writes_only_the_reported_usage_members() {
5480 let dir = tempfile::tempdir().unwrap();
5481 let mut w = make_writer(dir.path()).await;
5482 let usage = DriverUsage {
5483 input_tokens: Some(12043),
5484 output_tokens: None,
5485 cached_tokens: Some(0),
5486 cache_write_tokens: None,
5487 thinking_tokens: None,
5488 };
5489 w.write_inference(
5490 0,
5491 Some(100),
5492 Some(50),
5493 "end_turn".to_string(),
5494 Some("end_turn"),
5495 None,
5496 None,
5497 Some(&usage),
5498 Vec::new(),
5499 None,
5500 )
5501 .await
5502 .unwrap();
5503 w.flush().await.unwrap();
5504
5505 let events = read_events(dir.path());
5506 let e = &events[0];
5507 assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
5508 assert_eq!(e["input_tokens_actual"], 12043);
5509 assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
5510 assert!(e.get("output_tokens_actual").is_none());
5511 assert!(e.get("cache_write_tokens").is_none());
5512 }
5513
5514 #[tokio::test]
5515 async fn failed_inference_writes_its_keys_and_counts_only_what_it_spent() {
5516 let dir = tempfile::tempdir().unwrap();
5517 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
5518 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
5519 .await
5520 .unwrap();
5521 let usage = DriverUsage {
5522 input_tokens: Some(7),
5523 ..DriverUsage::default()
5524 };
5525 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
5526 w.write_failed_inference(FailedInference {
5527 turn: 0,
5528 error_code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
5529 error: &long,
5530 provider_status: Some(401),
5531 usage: Some(&usage),
5532 origin: None,
5533 choice: None,
5534 message_ids: vec!["msg_1".to_string()],
5535 tool_name: None,
5536 spent: None,
5537 })
5538 .await
5539 .unwrap();
5540 assert_eq!(w.task_turns(), 0, "an uncounted record moves no counter");
5541 assert_eq!((w.total_turns, w.total_input_tokens), (0, 0));
5542 assert_eq!((w.task_input_tokens, w.task_output_tokens), (0, 0));
5543
5544 w.write_failed_inference(FailedInference {
5545 turn: 1,
5546 error_code: INFERENCE_ERROR_HARNESS_AUTH,
5547 error: "bad login",
5548 provider_status: None,
5549 usage: None,
5550 origin: None,
5551 choice: None,
5552 message_ids: Vec::new(),
5553 tool_name: None,
5554 spent: Some((Some(10), Some(5))),
5555 })
5556 .await
5557 .unwrap();
5558 assert_eq!(w.task_turns(), 1);
5559 assert_eq!(w.total_turns, 1);
5560 assert_eq!((w.total_input_tokens, w.total_output_tokens), (10, 5));
5561 assert_eq!((w.task_input_tokens, w.task_output_tokens), (10, 5));
5562 w.flush().await.unwrap();
5563
5564 let events = read_events(dir.path());
5565 let failed: Vec<&Value> = events
5566 .iter()
5567 .filter(|e| e["event_type"] == "inference")
5568 .collect();
5569 let keys = |e: &Value| {
5570 let mut keys: Vec<String> = e.as_object().unwrap().keys().cloned().collect();
5571 keys.sort();
5572 keys
5573 };
5574 assert_eq!(
5575 keys(failed[0]),
5576 [
5577 "decision",
5578 "error",
5579 "error_code",
5580 "event_id",
5581 "event_type",
5582 "input_tokens_actual",
5583 "message_ids",
5584 "parent_id",
5585 "provider_status",
5586 "session_id",
5587 "stop_reason",
5588 "task_id",
5589 "timestamp",
5590 "tool_name",
5591 "turn",
5592 ]
5593 );
5594 assert_eq!(failed[0]["decision"], "error");
5595 assert_eq!(failed[0]["stop_reason"], "error");
5596 assert_eq!(failed[0]["error_code"], "credential_rejected");
5597 assert_eq!(failed[0]["provider_status"], 401);
5598 assert_eq!(failed[0]["input_tokens_actual"], 7);
5599 let error = failed[0]["error"].as_str().unwrap();
5600 assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
5601 assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
5602
5603 assert_eq!(failed[1]["error_code"], "harness_auth");
5604 assert_eq!(failed[1]["error"], "bad login");
5605 assert_eq!(failed[1]["input_tokens"], 10);
5606 assert_eq!(failed[1]["output_tokens"], 5);
5607 assert!(failed[1].get("provider_status").is_none());
5608 assert!(
5609 !dir.path().join("blobs").exists(),
5610 "a failed record stores no blob, even under trace.capture: content"
5611 );
5612 let task_start = events
5614 .iter()
5615 .find(|e| e["event_type"] == "task_start")
5616 .unwrap();
5617 assert_eq!(failed[0]["parent_id"], task_start["event_id"]);
5618 }
5619
5620 #[tokio::test]
5621 async fn a_failed_hook_record_hangs_off_the_turn_and_keeps_hook_accounting() {
5622 let dir = tempfile::tempdir().unwrap();
5623 let mut w = make_writer(dir.path()).await;
5624 w.write_inference(
5625 0,
5626 Some(10),
5627 Some(5),
5628 "end_turn".to_string(),
5629 Some("end_turn"),
5630 None,
5631 None,
5632 None,
5633 Vec::new(),
5634 None,
5635 )
5636 .await
5637 .unwrap();
5638 let origin = InferenceOrigin {
5639 source: "hook:compactor".to_string(),
5640 model: "m".to_string(),
5641 };
5642 w.write_failed_inference(FailedInference {
5643 turn: 0,
5644 error_code: INFERENCE_ERROR_DRIVER_ERROR,
5645 error: "HTTP 500: boom",
5646 provider_status: Some(500),
5647 usage: None,
5648 origin: Some(&origin),
5649 choice: None,
5650 message_ids: Vec::new(),
5651 tool_name: None,
5652 spent: Some((Some(40), None)),
5653 })
5654 .await
5655 .unwrap();
5656 assert_eq!(w.total_turns, 2);
5657 assert_eq!((w.total_input_tokens, w.total_output_tokens), (50, 5));
5658 w.flush().await.unwrap();
5659
5660 let events = read_events(dir.path());
5661 let turn = events.iter().find(|e| e["decision"] == "end_turn").unwrap();
5662 let hook = events.iter().find(|e| e["decision"] == "error").unwrap();
5663 assert_eq!(hook["parent_id"], turn["event_id"]);
5664 assert_eq!(hook["origin"], "hook:compactor");
5665 assert_eq!(hook["model"], "m");
5666 assert_eq!(hook["input_tokens"], 40);
5667 assert!(hook.get("output_tokens").is_none());
5668 }
5669
5670 #[tokio::test]
5671 async fn tool_call_fields() {
5672 let dir = tempfile::tempdir().unwrap();
5673 let mut w = make_writer(dir.path()).await;
5674 w.write_tool_call(
5675 1,
5676 "bash".to_string(),
5677 None,
5678 serde_json::json!({"command": "echo hi"}),
5679 42,
5680 "hi\n",
5681 100,
5682 55,
5683 "ok".to_string(),
5684 None,
5685 None,
5686 )
5687 .await
5688 .unwrap();
5689 w.flush().await.unwrap();
5690
5691 let events = read_events(dir.path());
5692 let e = &events[0];
5693 assert_eq!(e["event_type"], "tool_call");
5694 assert_eq!(e["turn"], 1);
5695 assert_eq!(e["tool_name"], "bash");
5696 assert_eq!(e["input_bytes"], 42);
5697 assert_eq!(e["output_bytes"], 100);
5698 assert_eq!(e["duration_ms"], 55);
5699 assert_eq!(e["status"], "ok");
5700 assert!(
5701 e.get("state_effect").is_none(),
5702 "state_effect must be omitted when the tool declared nothing"
5703 );
5704 }
5705
5706 #[tokio::test]
5707 async fn tool_call_records_declared_state_effect() {
5708 let dir = tempfile::tempdir().unwrap();
5709 let mut w = make_writer(dir.path()).await;
5710 w.write_tool_call(
5711 0,
5712 "murmur-tool-editor".to_string(),
5713 None,
5714 serde_json::json!({"operation": "read_file", "path": "a.txt"}),
5715 10,
5716 "hi\n",
5717 3,
5718 5,
5719 "ok".to_string(),
5720 Some("read".to_string()),
5721 None,
5722 )
5723 .await
5724 .unwrap();
5725 w.flush().await.unwrap();
5726
5727 let events = read_events(dir.path());
5728 assert_eq!(
5729 events[0]["state_effect"], "read",
5730 "declared state_effect must be recorded verbatim on the tool_call event"
5731 );
5732 }
5733
5734 #[tokio::test]
5735 async fn tool_call_records_declared_resource_id() {
5736 let dir = tempfile::tempdir().unwrap();
5737 let mut w = make_writer(dir.path()).await;
5738 w.write_tool_call(
5739 0,
5740 "murmur-tool-code-graph".to_string(),
5741 None,
5742 serde_json::json!({"symbol": "Foo::bar"}),
5743 10,
5744 "hi\n",
5745 3,
5746 5,
5747 "ok".to_string(),
5748 Some("read".to_string()),
5749 Some("sym:Foo".to_string()),
5750 )
5751 .await
5752 .unwrap();
5753 w.flush().await.unwrap();
5754
5755 let events = read_events(dir.path());
5756 assert_eq!(
5757 events[0]["resource_id"], "sym:Foo",
5758 "declared resource_id must reach trace.jsonl verbatim, unparsed"
5759 );
5760 }
5761
5762 #[tokio::test]
5763 async fn tool_call_omits_undeclared_resource_id() {
5764 let dir = tempfile::tempdir().unwrap();
5765 let mut w = make_writer(dir.path()).await;
5766 w.write_tool_call(
5767 0,
5768 "bash".to_string(),
5769 None,
5770 serde_json::json!({"command": "echo hi"}),
5771 10,
5772 "hi\n",
5773 3,
5774 5,
5775 "ok".to_string(),
5776 None,
5777 None,
5778 )
5779 .await
5780 .unwrap();
5781 w.flush().await.unwrap();
5782
5783 let events = read_events(dir.path());
5784 assert!(
5785 events[0].get("resource_id").is_none(),
5786 "resource_id must be omitted entirely (not null) when the tool declared nothing"
5787 );
5788 }
5789
5790 #[tokio::test]
5791 async fn shell_fields() {
5792 let dir = tempfile::tempdir().unwrap();
5793 let mut w = make_writer(dir.path()).await;
5794 w.write_shell(
5795 0,
5796 "/usr/bin/echo".to_string(),
5797 "echo hi".to_string(),
5798 0,
5799 7,
5800 0,
5801 10,
5802 None,
5803 )
5804 .await
5805 .unwrap();
5806 w.flush().await.unwrap();
5807
5808 let events = read_events(dir.path());
5809 let e = &events[0];
5810 assert_eq!(e["event_type"], "shell");
5811 assert_eq!(e["binary"], "/usr/bin/echo");
5814 assert_eq!(e["command"], "echo hi");
5815 assert_eq!(e["exit_code"], 0);
5816 assert_eq!(e["stdout_bytes"], 7);
5817 assert_eq!(e["stderr_bytes"], 0);
5818 assert_eq!(e["duration_ms"], 10);
5819 }
5820
5821 #[tokio::test]
5822 async fn compaction_fields() {
5823 let dir = tempfile::tempdir().unwrap();
5824 let mut w = make_writer(dir.path()).await;
5825 w.write_compaction(3, 80000, 20000).await.unwrap();
5826 w.flush().await.unwrap();
5827
5828 let events = read_events(dir.path());
5829 let e = &events[0];
5830 assert_eq!(e["event_type"], "compaction");
5831 assert_eq!(e["turn"], 3);
5832 assert_eq!(e["tokens_before"], 80000);
5833 assert_eq!(e["tokens_after"], 20000);
5834 }
5835
5836 #[tokio::test]
5837 async fn hook_dispatch_error_fields() {
5838 let dir = tempfile::tempdir().unwrap();
5839 let mut w = make_writer(dir.path()).await;
5840 w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
5841 .await
5842 .unwrap();
5843 w.flush().await.unwrap();
5844
5845 let events = read_events(dir.path());
5846 let e = &events[0];
5847 assert_eq!(e["event_type"], "hook_dispatch_error");
5848 assert_eq!(e["session_id"], "test-session-id");
5849 assert_eq!(e["hook_name"], "my-hook");
5850 assert_eq!(e["event"], "on-tool-call");
5851 assert_eq!(e["arm"], "write-manifests");
5852 assert!(e["timestamp"].as_u64().unwrap() > 0);
5853 assert!(e.get("hookName").is_none(), "must use snake_case");
5855 }
5856
5857 #[tokio::test]
5858 async fn session_end_fields_and_snake_case() {
5859 let dir = tempfile::tempdir().unwrap();
5860 let mut w = make_writer(dir.path()).await;
5861 w.write_inference(
5862 0,
5863 Some(100),
5864 Some(50),
5865 "tool_call".to_string(),
5866 Some("tool_call"),
5867 None,
5868 None,
5869 None,
5870 Vec::new(),
5871 None,
5872 )
5873 .await
5874 .unwrap();
5875 w.write_tool_call(
5876 0,
5877 "bash".to_string(),
5878 None,
5879 serde_json::json!({"command": "ls"}),
5880 10,
5881 "file.txt\n",
5882 20,
5883 5,
5884 "ok".to_string(),
5885 None,
5886 None,
5887 )
5888 .await
5889 .unwrap();
5890 w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
5891 .await
5892 .unwrap();
5893 w.write_session_end("ok").await.unwrap();
5894 w.flush().await.unwrap();
5895
5896 let events = read_events(dir.path());
5897 let se = events.last().unwrap();
5898 assert_eq!(se["event_type"], "session_end");
5899 assert_eq!(se["total_turns"], 1);
5900 assert_eq!(se["total_input_tokens"], 100);
5901 assert_eq!(se["total_output_tokens"], 50);
5902 assert_eq!(se["total_tool_calls"], 1);
5903 assert_eq!(se["total_shell_calls"], 1);
5904 assert_eq!(se["exit_status"], "ok");
5905 assert!(se["duration_ms"].as_u64().is_some());
5906 assert!(se.get("exitStatus").is_none(), "must use snake_case");
5908 assert!(se.get("totalTurns").is_none(), "must use snake_case");
5909 }
5910
5911 #[tokio::test]
5912 async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
5913 let dir = tempfile::tempdir().unwrap();
5914 let mut w = make_writer(dir.path()).await;
5915 w.write_session_start(10, vec![]).await.unwrap();
5916 w.write_session_end("ok").await.unwrap();
5917 w.write_session_end_if_not_ended("failed").await.unwrap();
5918 w.flush().await.unwrap();
5919
5920 let events = read_events(dir.path());
5921 assert_eq!(
5922 events
5923 .iter()
5924 .filter(|e| e["event_type"] == "session_end")
5925 .count(),
5926 1,
5927 "session_end should appear exactly once"
5928 );
5929 }
5930
5931 #[tokio::test]
5932 async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
5933 let dir = tempfile::tempdir().unwrap();
5934 let mut w = make_writer(dir.path()).await;
5935 w.write_session_start(10, vec![]).await.unwrap();
5936 w.write_session_end_if_not_ended("failed").await.unwrap();
5938 w.flush().await.unwrap();
5939
5940 let events = read_events(dir.path());
5941 let se = events
5942 .iter()
5943 .find(|e| e["event_type"] == "session_end")
5944 .unwrap();
5945 assert_eq!(se["exit_status"], "failed");
5946 }
5947
5948 #[tokio::test]
5949 async fn no_session_end_written_when_session_never_started() {
5950 let dir = tempfile::tempdir().unwrap();
5951 let mut w = make_writer(dir.path()).await;
5952 w.write_session_end_if_not_ended("failed").await.unwrap();
5954 w.flush().await.unwrap();
5955
5956 let events = read_events(dir.path());
5957 assert!(
5958 events.iter().all(|e| e["event_type"] != "session_end"),
5959 "session_end must not appear when session_start was never written"
5960 );
5961 }
5962
5963 #[tokio::test]
5964 async fn session_id_on_every_event() {
5965 let dir = tempfile::tempdir().unwrap();
5966 let mut w = make_writer(dir.path()).await;
5967 w.write_session_start(10, vec!["bash".to_string()])
5968 .await
5969 .unwrap();
5970 w.write_inference(
5971 0,
5972 Some(10),
5973 Some(5),
5974 "end_turn".to_string(),
5975 Some("end_turn"),
5976 None,
5977 None,
5978 None,
5979 Vec::new(),
5980 None,
5981 )
5982 .await
5983 .unwrap();
5984 w.write_session_end("ok").await.unwrap();
5985 w.flush().await.unwrap();
5986
5987 let events = read_events(dir.path());
5988 for e in &events {
5989 assert_eq!(e["session_id"], "test-session-id");
5990 }
5991 }
5992
5993 #[tokio::test]
5994 async fn counts_match_per_event_sum() {
5995 let dir = tempfile::tempdir().unwrap();
5996 let mut w = make_writer(dir.path()).await;
5997 w.write_session_start(10, vec![]).await.unwrap();
5998 w.write_inference(
5999 0,
6000 Some(50),
6001 Some(25),
6002 "tool_call".to_string(),
6003 Some("tool_call"),
6004 Some("bash".to_string()),
6005 None,
6006 None,
6007 vec!["msg_0000000000000000000000000000000a".to_string()],
6008 None,
6009 )
6010 .await
6011 .unwrap();
6012 w.write_tool_call(
6013 0,
6014 "bash".to_string(),
6015 None,
6016 serde_json::json!({"command": "echo"}),
6017 5,
6018 "ok",
6019 10,
6020 3,
6021 "ok".to_string(),
6022 None,
6023 None,
6024 )
6025 .await
6026 .unwrap();
6027 w.write_shell(
6028 0,
6029 "/bin/echo".to_string(),
6030 "echo".to_string(),
6031 0,
6032 4,
6033 0,
6034 1,
6035 None,
6036 )
6037 .await
6038 .unwrap();
6039 w.write_inference(
6040 1,
6041 Some(60),
6042 Some(30),
6043 "end_turn".to_string(),
6044 Some("end_turn"),
6045 None,
6046 None,
6047 None,
6048 Vec::new(),
6049 None,
6050 )
6051 .await
6052 .unwrap();
6053 w.write_session_end("ok").await.unwrap();
6054 w.flush().await.unwrap();
6055
6056 let events = read_events(dir.path());
6057 let inference_count = events
6058 .iter()
6059 .filter(|e| e["event_type"] == "inference")
6060 .count();
6061 let tool_count = events
6062 .iter()
6063 .filter(|e| e["event_type"] == "tool_call")
6064 .count();
6065 let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
6066
6067 let se = events
6068 .iter()
6069 .find(|e| e["event_type"] == "session_end")
6070 .unwrap();
6071 assert_eq!(
6072 se["total_turns"].as_u64().unwrap() as usize,
6073 inference_count
6074 );
6075 assert_eq!(
6076 se["total_tool_calls"].as_u64().unwrap() as usize,
6077 tool_count
6078 );
6079 assert_eq!(
6080 se["total_shell_calls"].as_u64().unwrap() as usize,
6081 shell_count
6082 );
6083 }
6084
6085 #[tokio::test]
6086 async fn tool_call_input_always_present() {
6087 let dir = tempfile::tempdir().unwrap();
6088 let mut w = make_writer(dir.path()).await; w.write_tool_call(
6090 0,
6091 "bash".to_string(),
6092 None,
6093 serde_json::json!({"command": "ls -la"}),
6094 20,
6095 "total 8\n",
6096 80,
6097 12,
6098 "ok".to_string(),
6099 None,
6100 None,
6101 )
6102 .await
6103 .unwrap();
6104 w.flush().await.unwrap();
6105
6106 let events = read_events(dir.path());
6107 let e = &events[0];
6108 assert_eq!(e["event_type"], "tool_call");
6109 assert_eq!(e["input"]["command"], "ls -la");
6110 assert!(e.get("input").is_some(), "input must always be present");
6111 }
6112
6113 #[tokio::test]
6114 async fn tool_call_output_absent_by_default() {
6115 let dir = tempfile::tempdir().unwrap();
6116 let mut w = make_writer(dir.path()).await; w.write_tool_call(
6118 0,
6119 "bash".to_string(),
6120 None,
6121 serde_json::json!({"command": "ls"}),
6122 10,
6123 "file.txt\n",
6124 90,
6125 8,
6126 "ok".to_string(),
6127 None,
6128 None,
6129 )
6130 .await
6131 .unwrap();
6132 w.flush().await.unwrap();
6133
6134 let events = read_events(dir.path());
6135 let e = &events[0];
6136 assert!(
6137 e.get("output").is_none(),
6138 "output must be absent under a capture mode that stores no bodies"
6139 );
6140 assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
6141 }
6142
6143 #[tokio::test]
6144 async fn skill_call_writes_skill_call_event() {
6145 let dir = tempfile::tempdir().unwrap();
6146 let mut w = make_writer(dir.path()).await;
6147 w.write_skill_call(
6148 2,
6149 "my-skill".to_string(),
6150 &LockOrigin::Operator,
6151 1024,
6152 8,
6153 "ok".to_string(),
6154 )
6155 .await
6156 .unwrap();
6157 w.flush().await.unwrap();
6158
6159 let events = read_events(dir.path());
6160 let e = &events[0];
6161 assert_eq!(e["event_type"], "skill_call");
6162 assert_eq!(e["turn"], 2);
6163 assert_eq!(e["skill_name"], "my-skill");
6164 assert_eq!(e["output_bytes"], 1024);
6165 assert_eq!(e["duration_ms"], 8);
6166 assert_eq!(e["status"], "ok");
6167 }
6168
6169 #[tokio::test]
6170 async fn skill_call_does_not_increment_total_tool_calls() {
6171 let dir = tempfile::tempdir().unwrap();
6172 let mut w = make_writer(dir.path()).await;
6173 w.write_session_start(10, vec![]).await.unwrap();
6174 w.write_tool_call(
6175 0,
6176 "real-tool".to_string(),
6177 None,
6178 serde_json::json!({}),
6179 2,
6180 "data",
6181 4,
6182 5,
6183 "ok".to_string(),
6184 None,
6185 None,
6186 )
6187 .await
6188 .unwrap();
6189 w.write_skill_call(
6190 1,
6191 "my-skill".to_string(),
6192 &LockOrigin::Operator,
6193 512,
6194 3,
6195 "ok".to_string(),
6196 )
6197 .await
6198 .unwrap();
6199 w.write_session_end("ok").await.unwrap();
6200 w.flush().await.unwrap();
6201
6202 let events = read_events(dir.path());
6203 let se = events
6204 .iter()
6205 .find(|e| e["event_type"] == "session_end")
6206 .unwrap();
6207 assert_eq!(
6208 se["total_tool_calls"], 1,
6209 "skill_call must not inflate total_tool_calls"
6210 );
6211 }
6212
6213 #[tokio::test]
6214 async fn tool_call_output_present_when_opted_in() {
6215 let dir = tempfile::tempdir().unwrap();
6216 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
6217 w.write_tool_call(
6218 0,
6219 "bash".to_string(),
6220 None,
6221 serde_json::json!({"command": "echo hello"}),
6222 22,
6223 "hello\n",
6224 60,
6225 5,
6226 "ok".to_string(),
6227 None,
6228 None,
6229 )
6230 .await
6231 .unwrap();
6232 w.flush().await.unwrap();
6233
6234 let events = read_events(dir.path());
6235 let e = &events[0];
6236 assert_eq!(
6237 e["output"].as_str().unwrap(),
6238 "hello\n",
6239 "output must be present under capture: content"
6240 );
6241 assert_eq!(e["output_bytes"], 60);
6242 }
6243
6244 #[tokio::test]
6245 async fn task_end_carries_reopen_count() {
6246 let dir = tempfile::tempdir().unwrap();
6247 let mut w = make_writer(dir.path()).await;
6248 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6249 .await
6250 .unwrap();
6251 w.write_task_end("tsk_1", "ok", 2).await.unwrap();
6252 w.flush().await.unwrap();
6253
6254 let events = read_events(dir.path());
6255 let end = events
6256 .iter()
6257 .find(|e| e["event_type"] == "task_end")
6258 .unwrap();
6259 assert_eq!(end["task_id"], "tsk_1");
6260 assert_eq!(end["exit_status"], "ok");
6261 assert_eq!(end["reopen_count"], 2);
6262 }
6263
6264 #[tokio::test]
6265 async fn task_reopened_names_hook_reason_and_ordinal() {
6266 let dir = tempfile::tempdir().unwrap();
6267 let mut w = make_writer(dir.path()).await;
6268 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6269 .await
6270 .unwrap();
6271 w.write_task_reopened(
6272 "tsk_1",
6273 "gatekeeper",
6274 "tests still fail",
6275 1,
6276 REOPEN_CONTEXT_CONTINUED,
6277 7,
6278 )
6279 .await
6280 .unwrap();
6281 w.flush().await.unwrap();
6282
6283 let events = read_events(dir.path());
6284 let re = events
6285 .iter()
6286 .find(|e| e["event_type"] == "task_reopened")
6287 .unwrap();
6288 assert_eq!(re["task_id"], "tsk_1");
6289 assert_eq!(re["hook_name"], "gatekeeper");
6290 assert_eq!(re["reason"], "tests still fail");
6291 assert_eq!(re["reopen_number"], 1);
6292 assert_eq!(re["attempt_context"], "continued");
6293 assert_eq!(re["turns_remaining"], 7);
6294 assert!(re["timestamp"].as_u64().unwrap() > 0);
6295 }
6296
6297 #[tokio::test]
6298 async fn task_continued_names_the_round_under_the_task_node() {
6299 let dir = tempfile::tempdir().unwrap();
6300 let mut w = make_writer(dir.path()).await;
6301 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6302 .await
6303 .unwrap();
6304 w.write_task_continued(
6305 "tsk_1",
6306 1,
6307 &["mcl_b".to_string(), "mcl_a".to_string()],
6308 &["dlg_1".to_string()],
6309 1234,
6310 8,
6311 )
6312 .await
6313 .unwrap();
6314 w.flush().await.unwrap();
6315
6316 let events = read_events(dir.path());
6317 let start = events
6318 .iter()
6319 .find(|e| e["event_type"] == "task_start")
6320 .unwrap();
6321 let continued = events
6322 .iter()
6323 .find(|e| e["event_type"] == "task_continued")
6324 .unwrap();
6325 assert_eq!(continued["parent_id"], start["event_id"]);
6326 assert_eq!(continued["task_id"], "tsk_1");
6327 assert_eq!(continued["continuation_number"], 1);
6328 assert_eq!(
6329 continued["member_calls"],
6330 serde_json::json!(["mcl_b", "mcl_a"])
6331 );
6332 assert_eq!(continued["delegations"], serde_json::json!(["dlg_1"]));
6333 assert_eq!(continued["waited_ms"], 1234);
6334 assert_eq!(continued["turns_remaining"], 8);
6335 }
6336
6337 #[tokio::test]
6341 async fn task_turns_accessor_tracks_and_resets() {
6342 let dir = tempfile::tempdir().unwrap();
6343 let mut w = make_writer(dir.path()).await;
6344 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6345 .await
6346 .unwrap();
6347 assert_eq!(w.task_turns(), 0);
6348 w.write_inference(
6349 0,
6350 Some(10),
6351 Some(5),
6352 "end_turn".to_string(),
6353 Some("end_turn"),
6354 None,
6355 None,
6356 None,
6357 Vec::new(),
6358 None,
6359 )
6360 .await
6361 .unwrap();
6362 w.write_inference(
6363 1,
6364 Some(10),
6365 Some(5),
6366 "end_turn".to_string(),
6367 Some("end_turn"),
6368 None,
6369 None,
6370 None,
6371 Vec::new(),
6372 None,
6373 )
6374 .await
6375 .unwrap();
6376 assert_eq!(w.task_turns(), 2);
6377 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
6379 .await
6380 .unwrap();
6381 assert_eq!(w.task_turns(), 0);
6382 }
6383
6384 #[tokio::test]
6387 async fn task_agent_turns_counts_agent_loop_inference_only() {
6388 let dir = tempfile::tempdir().unwrap();
6389 let mut w = make_writer(dir.path()).await;
6390 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6391 .await
6392 .unwrap();
6393 assert_eq!(w.task_agent_turns(), 0);
6394 let hook = InferenceOrigin {
6395 source: "hook:x".into(),
6396 model: "m".into(),
6397 };
6398 for (turn, origin) in [(0, None), (0, Some(&hook)), (1, None)] {
6399 w.write_inference(
6400 turn,
6401 Some(10),
6402 Some(5),
6403 "end_turn".to_string(),
6404 Some("end_turn"),
6405 None,
6406 origin,
6407 None,
6408 Vec::new(),
6409 None,
6410 )
6411 .await
6412 .unwrap();
6413 }
6414 assert_eq!(w.task_agent_turns(), 2);
6415 assert_eq!(w.task_turns(), 3);
6416 assert_eq!(w.task_hook_inferences(), 1);
6417 w.write_task_end("tsk_1", "completed", 0).await.unwrap();
6418 assert_eq!(
6419 w.task_agent_turns(),
6420 0,
6421 "a run outside a task frame starts at 0"
6422 );
6423 w.write_inference(
6424 0,
6425 Some(10),
6426 Some(5),
6427 "end_turn".to_string(),
6428 Some("end_turn"),
6429 None,
6430 None,
6431 None,
6432 Vec::new(),
6433 None,
6434 )
6435 .await
6436 .unwrap();
6437 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
6438 .await
6439 .unwrap();
6440 assert_eq!(w.task_agent_turns(), 0);
6441 }
6442
6443 #[tokio::test]
6449 async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
6450 let dir = tempfile::tempdir().unwrap();
6451 let mut w = make_writer(dir.path()).await;
6452 w.write_session_start(10, vec!["bash".to_string()])
6453 .await
6454 .unwrap();
6455 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6456 .await
6457 .unwrap();
6458 w.write_inference(
6459 0,
6460 Some(10),
6461 Some(5),
6462 "tool_call".to_string(),
6463 Some("tool_call"),
6464 None,
6465 None,
6466 None,
6467 Vec::new(),
6468 None,
6469 )
6470 .await
6471 .unwrap();
6472 w.write_tool_call(
6473 0,
6474 "bash".to_string(),
6475 Some("toolu_1".to_string()),
6476 serde_json::json!({"command": "echo hi"}),
6477 10,
6478 "hi\n",
6479 3,
6480 5,
6481 "ok".to_string(),
6482 None,
6483 None,
6484 )
6485 .await
6486 .unwrap();
6487 w.write_shell(
6488 0,
6489 "/bin/bash".to_string(),
6490 "echo hi".to_string(),
6491 0,
6492 3,
6493 0,
6494 2,
6495 None,
6496 )
6497 .await
6498 .unwrap();
6499 w.write_artifact_pulled(&pulled(
6500 runtime_summary("pulled-style", ArtifactRuntime::Skill),
6501 "global store",
6502 None,
6503 ))
6504 .await
6505 .unwrap();
6506 w.write_task_end("tsk_1", "ok", 0).await.unwrap();
6507 w.write_session_end("ok").await.unwrap();
6508 w.flush().await.unwrap();
6509
6510 let events = read_events(dir.path());
6511 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
6512 for (i, event) in events.iter().enumerate() {
6513 let id = event["event_id"].as_str().unwrap();
6514 assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
6515 assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
6516 assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
6517 assert!(
6518 event.get("parent_id").is_some(),
6519 "event {i} has no parent_id"
6520 );
6521 if let Some(parent) = event["parent_id"].as_str() {
6522 assert!(seen.contains(parent), "event {i} names dangling {parent}");
6523 }
6524 }
6525
6526 let by_type = |ty: &str| {
6527 events
6528 .iter()
6529 .find(|e| e["event_type"] == ty)
6530 .unwrap_or_else(|| panic!("no {ty}"))
6531 .clone()
6532 };
6533 let session = by_type("session_start");
6534 assert!(session["parent_id"].is_null());
6535 assert_eq!(session["event_id"], w.session_event_id());
6536
6537 let task = by_type("task_start");
6538 assert_eq!(task["parent_id"], session["event_id"]);
6539
6540 let turn = by_type("inference");
6541 assert_eq!(turn["parent_id"], task["event_id"]);
6542
6543 assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
6544 assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
6545 assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
6546 assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
6547 assert_eq!(by_type("artifact_pulled")["parent_id"], session["event_id"]);
6548 }
6549
6550 fn runtime_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
6551 InstalledArtifactSummary {
6552 name: name.to_string(),
6553 version: "1.0.0".to_string(),
6554 runtime,
6555 implementation: None,
6556 origin: LockOrigin::Runtime {
6557 session: "ses_puller".to_string(),
6558 },
6559 }
6560 }
6561
6562 fn operator_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
6563 InstalledArtifactSummary {
6564 origin: LockOrigin::Operator,
6565 ..runtime_summary(name, runtime)
6566 }
6567 }
6568
6569 fn pulled(
6571 summary: InstalledArtifactSummary,
6572 fetched_from: &str,
6573 owner_id: Option<u64>,
6574 ) -> crate::runtime::PulledArtifact {
6575 crate::runtime::PulledArtifact {
6576 summary,
6577 fetched_from: fetched_from.to_string(),
6578 owner_id,
6579 notes: Vec::new(),
6580 }
6581 }
6582
6583 #[tokio::test]
6586 async fn runtime_origin_session_start_lists_nothing_for_an_operator_session() {
6587 for set in [false, true] {
6588 let dir = tempfile::tempdir().unwrap();
6589 let mut w = make_writer(dir.path()).await;
6590 if set {
6591 w.set_runtime_artifacts(&[
6592 operator_summary("house-style", ArtifactRuntime::Skill),
6593 operator_summary("fetcher", ArtifactRuntime::Tool),
6594 ]);
6595 }
6596 w.write_session_start(10, Vec::new()).await.unwrap();
6597 w.flush().await.unwrap();
6598 let events = read_events(dir.path());
6599 assert_eq!(events[0]["runtime_artifacts"], serde_json::json!([]));
6600 }
6601 }
6602
6603 #[tokio::test]
6606 async fn runtime_origin_session_start_lists_runtime_artifacts_in_order() {
6607 let dir = tempfile::tempdir().unwrap();
6608 let mut w = make_writer(dir.path()).await;
6609 w.set_runtime_artifacts(&[
6610 runtime_summary("zeta-tool", ArtifactRuntime::Tool),
6611 operator_summary("house-style", ArtifactRuntime::Skill),
6612 InstalledArtifactSummary {
6613 origin: LockOrigin::Runtime {
6614 session: "ses_other".to_string(),
6615 },
6616 ..runtime_summary("alpha-style", ArtifactRuntime::Skill)
6617 },
6618 ]);
6619 w.write_session_start(10, Vec::new()).await.unwrap();
6620 w.flush().await.unwrap();
6621
6622 let events = read_events(dir.path());
6623 assert_eq!(
6624 events[0]["runtime_artifacts"],
6625 serde_json::json!([
6626 {"name": "zeta-tool", "version": "1.0.0", "origin": "runtime",
6627 "session": "ses_puller", "trust": "untrusted"},
6628 {"name": "alpha-style", "version": "1.0.0", "origin": "runtime",
6629 "session": "ses_other", "trust": "untrusted"},
6630 ])
6631 );
6632 }
6633
6634 #[tokio::test]
6635 async fn runtime_origin_skill_call_carries_origin_and_trust() {
6636 let dir = tempfile::tempdir().unwrap();
6637 let mut w = make_writer(dir.path()).await;
6638 for origin in [
6639 LockOrigin::Operator,
6640 LockOrigin::Runtime {
6641 session: "ses_puller".to_string(),
6642 },
6643 ] {
6644 w.write_skill_call(0, "a-skill".to_string(), &origin, 1, 1, "ok".to_string())
6645 .await
6646 .unwrap();
6647 }
6648 w.flush().await.unwrap();
6649
6650 let events = read_events(dir.path());
6651 assert_eq!(events[0]["origin"], "operator");
6652 assert_eq!(events[0]["trust"], "trusted");
6653 assert_eq!(events[1]["origin"], "runtime");
6654 assert_eq!(events[1]["trust"], "untrusted");
6655 }
6656
6657 #[tokio::test]
6660 async fn runtime_origin_artifact_pulled_writes_every_key() {
6661 let dir = tempfile::tempdir().unwrap();
6662 let mut w = make_writer(dir.path()).await;
6663 w.write_artifact_pulled(&pulled(
6664 runtime_summary("pulled-style", ArtifactRuntime::Skill),
6665 "github:ana/artifacts",
6666 Some(4242),
6667 ))
6668 .await
6669 .unwrap();
6670 w.write_artifact_pulled(&pulled(
6671 operator_summary("fetcher", ArtifactRuntime::Tool),
6672 "project store",
6673 None,
6674 ))
6675 .await
6676 .unwrap();
6677 w.flush().await.unwrap();
6678
6679 let events = read_events(dir.path());
6680 let runtime = &events[0];
6681 assert_eq!(runtime["event_type"], "artifact_pulled");
6682 assert_eq!(runtime["name"], "pulled-style");
6683 assert_eq!(runtime["version"], "1.0.0");
6684 assert_eq!(runtime["runtime"], "skill");
6685 assert_eq!(runtime["origin"], "runtime");
6686 assert_eq!(runtime["session"], "ses_puller");
6687 assert_eq!(runtime["trust"], "untrusted");
6688 assert_eq!(runtime["fetched_from"], "github:ana/artifacts");
6689 assert_eq!(runtime["owner_id"], 4242);
6690 assert!(
6691 runtime["parent_id"].is_null(),
6692 "no session_start was written"
6693 );
6694
6695 let operator = &events[1];
6696 assert_eq!(operator["runtime"], "tool");
6697 assert_eq!(operator["origin"], "operator");
6698 assert!(operator.get("session").is_some_and(Value::is_null));
6699 assert_eq!(operator["trust"], "trusted");
6700 assert_eq!(operator["fetched_from"], "project store");
6701 assert!(operator.get("owner_id").is_none());
6702 }
6703
6704 #[tokio::test]
6708 async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
6709 let dir = tempfile::tempdir().unwrap();
6710 let mut w = make_writer(dir.path()).await;
6711 w.write_session_start(10, Vec::new()).await.unwrap();
6712 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6713 .await
6714 .unwrap();
6715 w.write_inference(
6716 0,
6717 Some(10),
6718 Some(5),
6719 "tool_call".to_string(),
6720 Some("tool_call"),
6721 None,
6722 None,
6723 None,
6724 Vec::new(),
6725 None,
6726 )
6727 .await
6728 .unwrap();
6729 let origin = InferenceOrigin {
6730 source: "hook:gatekeeper".to_string(),
6731 model: "claude-test".to_string(),
6732 };
6733 w.write_inference(
6734 0,
6735 Some(1),
6736 Some(1),
6737 "end_turn".to_string(),
6738 None,
6740 None,
6741 Some(&origin),
6742 None,
6743 Vec::new(),
6744 None,
6745 )
6746 .await
6747 .unwrap();
6748 w.flush().await.unwrap();
6749
6750 let events = read_events(dir.path());
6751 let turn = &events[2];
6752 let hook = &events[3];
6753 assert!(turn["origin"].is_null());
6754 assert_eq!(hook["origin"], "hook:gatekeeper");
6755 assert_eq!(hook["parent_id"], turn["event_id"]);
6756 assert!(hook.get("message_ids").is_none(), "{hook}");
6759 }
6760
6761 #[tokio::test]
6765 async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
6766 let bare = tempfile::tempdir().unwrap();
6767 let mut w = make_writer(bare.path()).await;
6768 w.write_a2a_send(
6769 "http://peer",
6770 "msg_1",
6771 "tsk_1",
6772 "ctx_1",
6773 None,
6774 TrustClass::Untrusted,
6775 )
6776 .await
6777 .unwrap();
6778 w.flush().await.unwrap();
6779 assert!(read_events(bare.path())[0]["parent_id"].is_null());
6780
6781 let framed = tempfile::tempdir().unwrap();
6782 let mut w = make_writer(framed.path()).await;
6783 w.write_session_start(1, Vec::new()).await.unwrap();
6784 w.write_a2a_send(
6785 "http://peer",
6786 "msg_1",
6787 "tsk_1",
6788 "ctx_1",
6789 None,
6790 TrustClass::Untrusted,
6791 )
6792 .await
6793 .unwrap();
6794 w.flush().await.unwrap();
6795 let events = read_events(framed.path());
6796 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
6797 }
6798
6799 #[tokio::test]
6801 async fn task_rejected_names_the_task_source_cause_and_reason_under_the_session() {
6802 let dir = tempfile::tempdir().unwrap();
6803 let mut w = make_writer(dir.path()).await;
6804 w.write_session_start(10, Vec::new()).await.unwrap();
6805 w.write_task_start("tsk_ran", "ctx_ran", "a2a", event_provenance(), 3, &[], &[])
6806 .await
6807 .unwrap();
6808 w.write_task_end("tsk_ran", "ok", 0).await.unwrap();
6809 w.write_task_rejected(
6810 "tsk_1",
6811 "ctx_1",
6812 "detached_shell",
6813 TASK_REJECTED_SESSION_ENDED,
6814 "task rejected: the session ended before this task started",
6815 )
6816 .await
6817 .unwrap();
6818 w.flush().await.unwrap();
6819
6820 let events = read_events(dir.path());
6821 let session = events
6822 .iter()
6823 .find(|e| e["event_type"] == "session_start")
6824 .unwrap();
6825 let rejected: Vec<_> = events
6826 .iter()
6827 .filter(|e| e["event_type"] == "task_rejected")
6828 .collect();
6829 assert_eq!(rejected.len(), 1);
6830 let rejected = rejected[0];
6831 assert_eq!(rejected["parent_id"], session["event_id"], "{rejected}");
6832 assert_eq!(rejected["session_id"], session["session_id"]);
6833 assert_eq!(rejected["task_id"], "tsk_1");
6834 assert_eq!(rejected["context_id"], "ctx_1");
6835 assert_eq!(rejected["source"], "detached_shell");
6836 assert_eq!(rejected["cause"], "session_ended");
6837 assert_eq!(
6838 rejected["reason"],
6839 "task rejected: the session ended before this task started"
6840 );
6841 assert!(rejected["event_id"].is_string());
6842 assert!(rejected["timestamp"].is_u64());
6843 assert_eq!(rejected.as_object().unwrap().len(), 10, "{rejected}");
6844 }
6845
6846 #[tokio::test]
6849 async fn task_failed_names_the_task_turn_cause_and_capped_reason() {
6850 let dir = tempfile::tempdir().unwrap();
6851 let mut w = make_writer(dir.path()).await;
6852 w.write_session_start(10, Vec::new()).await.unwrap();
6853 assert_eq!(w.last_task_failure(), None);
6854 w.write_task_failed(None, TASK_FAILED_RUNTIME_ERROR, "before any task")
6855 .await
6856 .unwrap();
6857 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6858 .await
6859 .unwrap();
6860 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
6861 w.write_task_failed(Some(2), TASK_FAILED_DRIVER_ERROR, &long)
6862 .await
6863 .unwrap();
6864 w.write_task_end("tsk_1", "failed", 0).await.unwrap();
6865 w.flush().await.unwrap();
6866
6867 assert_eq!(w.task_failures_written(), 2);
6868 let events = read_events(dir.path());
6869 let failed: Vec<_> = events
6870 .iter()
6871 .filter(|e| e["event_type"] == "task_failed")
6872 .collect();
6873 assert_eq!(failed.len(), 2);
6874
6875 assert!(failed[0].get("task_id").is_none(), "{}", failed[0]);
6876 assert!(failed[0].get("turn").is_none(), "{}", failed[0]);
6877 assert_eq!(failed[0]["cause"], "runtime_error");
6878 assert_eq!(failed[0]["reason"], "before any task");
6879
6880 let start = events
6881 .iter()
6882 .find(|e| e["event_type"] == "task_start")
6883 .unwrap();
6884 assert_eq!(failed[1]["task_id"], "tsk_1");
6885 assert_eq!(failed[1]["turn"], 2);
6886 assert_eq!(failed[1]["cause"], "driver_error");
6887 assert_eq!(failed[1]["parent_id"], start["event_id"]);
6888 assert_eq!(failed[1]["session_id"], start["session_id"]);
6889 assert!(failed[1]["event_id"].is_string());
6890 assert!(failed[1]["timestamp"].is_u64());
6891 let reason = failed[1]["reason"].as_str().unwrap();
6892 assert!(reason.len() <= MAX_TASK_FAILURE_REASON_BYTES);
6893 assert!(reason.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
6894 assert_eq!(w.last_task_failure(), Some(reason));
6895 }
6896
6897 #[tokio::test]
6900 async fn turn_level_events_carry_the_active_task_id() {
6901 let dir = tempfile::tempdir().unwrap();
6902 let mut w = make_writer(dir.path()).await;
6903 w.write_session_start(10, Vec::new()).await.unwrap();
6904 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6905 .await
6906 .unwrap();
6907 w.write_inference(
6908 0,
6909 Some(10),
6910 Some(5),
6911 "tool_call".to_string(),
6912 Some("tool_call"),
6913 None,
6914 None,
6915 None,
6916 Vec::new(),
6917 None,
6918 )
6919 .await
6920 .unwrap();
6921 w.write_skill_call(
6922 0,
6923 "house-style".to_string(),
6924 &LockOrigin::Operator,
6925 12,
6926 1,
6927 "ok".to_string(),
6928 )
6929 .await
6930 .unwrap();
6931 w.write_compaction(0, 100, 40).await.unwrap();
6932 w.write_task_end("tsk_1", "ok", 0).await.unwrap();
6933 w.write_inference(
6934 1,
6935 Some(1),
6936 Some(1),
6937 "end_turn".to_string(),
6938 Some("end_turn"),
6939 None,
6940 None,
6941 None,
6942 Vec::new(),
6943 None,
6944 )
6945 .await
6946 .unwrap();
6947 w.flush().await.unwrap();
6948
6949 let events = read_events(dir.path());
6950 for event in events
6951 .iter()
6952 .filter(|e| {
6953 matches!(
6954 e["event_type"].as_str(),
6955 Some("inference" | "skill_call" | "compaction")
6956 )
6957 })
6958 .take(3)
6959 {
6960 assert_eq!(event["task_id"], "tsk_1", "{event}");
6961 }
6962 let after = events.last().unwrap();
6963 assert_eq!(after["event_type"], "inference");
6964 assert!(
6965 after["task_id"].is_null(),
6966 "an inference outside any task carries a null task_id, not the last task's"
6967 );
6968 assert_eq!(after["parent_id"], events[0]["event_id"]);
6970 }
6971
6972 #[tokio::test]
6975 async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
6976 for (given, expected) in [
6977 (Some("toolu_1".to_string()), Some("toolu_1")),
6978 (Some(String::new()), None),
6979 (None, None),
6980 ] {
6981 let dir = tempfile::tempdir().unwrap();
6982 let mut w = make_writer(dir.path()).await;
6983 w.write_tool_call(
6984 0,
6985 "bash".to_string(),
6986 given.clone(),
6987 serde_json::json!({}),
6988 2,
6989 "",
6990 0,
6991 1,
6992 "ok".to_string(),
6993 None,
6994 None,
6995 )
6996 .await
6997 .unwrap();
6998 w.flush().await.unwrap();
6999 let e = &read_events(dir.path())[0];
7000 match expected {
7001 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
7002 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
7003 }
7004 }
7005 }
7006
7007 #[tokio::test]
7010 async fn tool_input_refused_records_its_fields_in_every_capture_mode() {
7011 for capture in [
7012 TraceCapture::None,
7013 TraceCapture::Meta,
7014 TraceCapture::Content,
7015 ] {
7016 for (given, expected) in [
7017 (Some("toolu_1"), Some("toolu_1")),
7018 (Some(""), None),
7019 (None, None),
7020 ] {
7021 let dir = tempfile::tempdir().unwrap();
7022 let mut w = make_writer_with_opts(dir.path(), capture).await;
7023 w.write_tool_input_refused(
7024 2,
7025 "murmur-tool-editor",
7026 given,
7027 &["operation".to_string(), "dest_path".to_string()],
7028 "murmur-tool-editor: missing required fields",
7029 )
7030 .await
7031 .unwrap();
7032 w.flush().await.unwrap();
7033 let e = &read_events(dir.path())[0];
7034 assert_eq!(e["event_type"], "tool_input_refused", "{capture:?}");
7035 assert!(e["event_id"].is_string());
7036 assert!(e["session_id"].is_string());
7037 assert!(e["timestamp"].is_u64());
7038 assert_eq!(e["turn"], 2);
7039 assert_eq!(e["tool_name"], "murmur-tool-editor");
7040 match expected {
7041 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
7042 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
7043 }
7044 assert_eq!(e["missing"], serde_json::json!(["operation", "dest_path"]));
7045 assert_eq!(e["reason"], "murmur-tool-editor: missing required fields");
7046 }
7047 }
7048 }
7049
7050 #[tokio::test]
7054 async fn tools_refreshed_event_records_trigger_names_and_task_parent() {
7055 let dir = tempfile::tempdir().unwrap();
7056 let mut w = make_writer(dir.path()).await;
7057 w.set_tool_refresh(Some("immediate"));
7058 w.write_session_start(10, Vec::new()).await.unwrap();
7059 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7060 .await
7061 .unwrap();
7062 w.write_inference(
7063 0,
7064 Some(10),
7065 Some(5),
7066 "tool_call".to_string(),
7067 Some("tool_call"),
7068 None,
7069 None,
7070 None,
7071 Vec::new(),
7072 None,
7073 )
7074 .await
7075 .unwrap();
7076 w.write_tools_refreshed(
7077 1,
7078 "immediate",
7079 &["aaa-late-skill".to_string()],
7080 &[],
7081 &[
7082 "aaa-late-skill".to_string(),
7083 "zzz-existing-tool".to_string(),
7084 ],
7085 )
7086 .await
7087 .unwrap();
7088 w.flush().await.unwrap();
7089
7090 let events = read_events(dir.path());
7091 assert_eq!(events[0]["tool_refresh"], "immediate");
7092 let task = events
7093 .iter()
7094 .find(|e| e["event_type"] == "task_start")
7095 .unwrap();
7096 let refreshed: Vec<&Value> = events
7097 .iter()
7098 .filter(|e| e["event_type"] == "tools_refreshed")
7099 .collect();
7100 assert_eq!(refreshed.len(), 1);
7101 let r = refreshed[0];
7102 assert!(r["event_id"].as_str().unwrap().starts_with("evt_"));
7103 assert_eq!(r["parent_id"], task["event_id"]);
7104 assert_eq!(r["session_id"], "test-session-id");
7105 assert!(r["timestamp"].as_u64().is_some());
7106 assert_eq!(r["turn"], 1);
7107 assert_eq!(r["task_id"], "tsk_1");
7108 assert_eq!(r["trigger"], "immediate");
7109 assert_eq!(r["added"], serde_json::json!(["aaa-late-skill"]));
7110 assert_eq!(r["removed"], serde_json::json!([]));
7111 assert_eq!(
7112 r["tools"],
7113 serde_json::json!(["aaa-late-skill", "zzz-existing-tool"])
7114 );
7115
7116 let process_dir = tempfile::tempdir().unwrap();
7117 let mut process = make_writer(process_dir.path()).await;
7118 process.set_tool_refresh(None);
7119 process.write_session_start(10, Vec::new()).await.unwrap();
7120 process.flush().await.unwrap();
7121 let start = &read_events(process_dir.path())[0];
7122 assert!(start.as_object().unwrap().contains_key("tool_refresh"));
7123 assert!(start["tool_refresh"].is_null());
7124 }
7125
7126 #[tokio::test]
7129 async fn compaction_declined_records_turn_task_tokens_and_reason() {
7130 let dir = tempfile::tempdir().unwrap();
7131 let mut w = make_writer(dir.path()).await;
7132 w.write_session_start(10, Vec::new()).await.unwrap();
7133 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7134 .await
7135 .unwrap();
7136 w.write_inference(
7137 2,
7138 Some(10),
7139 Some(5),
7140 "tool_call".to_string(),
7141 Some("tool_call"),
7142 None,
7143 None,
7144 None,
7145 Vec::new(),
7146 None,
7147 )
7148 .await
7149 .unwrap();
7150 w.write_compaction_declined(2, 4321, COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL)
7151 .await
7152 .unwrap();
7153 w.flush().await.unwrap();
7154
7155 let events = read_events(dir.path());
7156 let declined: Vec<&Value> = events
7157 .iter()
7158 .filter(|e| e["event_type"] == "compaction_declined")
7159 .collect();
7160 assert_eq!(declined.len(), 1);
7161 let d = declined[0];
7162 assert_eq!(d["reason"], "unresolved_tool_call");
7163 assert_eq!(d["turn"], 2);
7164 assert_eq!(d["task_id"], "tsk_1");
7165 assert_eq!(d["tokens"], 4321);
7166 assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
7167 assert_eq!(
7168 d["parent_id"],
7169 events
7170 .iter()
7171 .find(|e| e["event_type"] == "inference")
7172 .unwrap()["event_id"]
7173 );
7174 }
7175
7176 #[tokio::test]
7179 async fn event_ids_sort_by_mint_time() {
7180 let dir = tempfile::tempdir().unwrap();
7181 let mut w = make_writer(dir.path()).await;
7182 for turn in 0..5 {
7183 w.write_inference(
7184 turn,
7185 Some(1),
7186 Some(1),
7187 "end_turn".to_string(),
7188 Some("end_turn"),
7189 None,
7190 None,
7191 None,
7192 Vec::new(),
7193 None,
7194 )
7195 .await
7196 .unwrap();
7197 }
7198 w.flush().await.unwrap();
7199
7200 let ids: Vec<String> = read_events(dir.path())
7201 .iter()
7202 .map(|e| e["event_id"].as_str().unwrap().to_string())
7203 .collect();
7204 let mut sorted = ids.clone();
7205 sorted.sort();
7206 assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
7207 }
7208
7209 fn wire_payload(system: &str, messages: usize) -> Value {
7213 let messages: Vec<Value> = (0..messages)
7214 .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
7215 .collect();
7216 serde_json::json!({
7217 "model": "claude-test",
7218 "max_tokens": 1024,
7219 "messages": messages,
7220 "tools": [{"name": "bash"}],
7221 "params": {},
7222 "system": system,
7223 })
7224 }
7225
7226 async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
7227 let wire = WireCapture::from_driver_payload(payload, response);
7228 w.write_inference(
7229 turn,
7230 Some(10),
7231 Some(5),
7232 "end_turn".to_string(),
7233 Some("end_turn"),
7234 None,
7235 None,
7236 None,
7237 vec!["msg_0000000000000000000000000000000a".to_string()],
7238 Some(&wire),
7239 )
7240 .await
7241 .unwrap();
7242 }
7243
7244 fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
7245 dir.join(BLOB_DIR_NAME).join(sha)
7246 }
7247
7248 #[tokio::test]
7251 async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
7252 let dir = tempfile::tempdir().unwrap();
7253 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7254 let payload = wire_payload("You are a capsule.", 3);
7255 write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
7256 w.flush().await.unwrap();
7257
7258 let e = &read_events(dir.path())[0];
7259 let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
7260 .iter()
7261 .map(|key| {
7262 e[*key]
7263 .as_str()
7264 .unwrap_or_else(|| panic!("{key} missing from {e}"))
7265 .to_string()
7266 })
7267 .collect();
7268 let shas = e["message_shas"].as_array().expect("message_shas");
7269 assert_eq!(shas.len(), 3);
7270 named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
7271
7272 for sha in &named {
7273 let path = blob_path(dir.path(), sha);
7274 let bytes =
7275 std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
7276 assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
7277 assert_eq!(sha.len(), 64, "{sha}");
7280 assert!(
7281 sha.chars()
7282 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
7283 "{sha}"
7284 );
7285 }
7286 }
7287
7288 #[tokio::test]
7291 async fn the_system_blob_holds_the_prompt_as_readable_text() {
7292 let dir = tempfile::tempdir().unwrap();
7293 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7294 let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
7295 write_turn(&mut w, 0, &payload, "{}").await;
7296 w.flush().await.unwrap();
7297
7298 let sha = read_events(dir.path())[0]["system_sha"]
7299 .as_str()
7300 .unwrap()
7301 .to_string();
7302 assert_eq!(
7303 std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
7304 "[Capsule] you are cap.\nBe terse."
7305 );
7306 }
7307
7308 #[tokio::test]
7311 async fn meta_writes_the_same_hashes_and_no_bodies() {
7312 let payload = wire_payload("You are a capsule.", 2);
7313 let response = r#"{"stop_reason":"end_turn"}"#;
7314
7315 let meta = tempfile::tempdir().unwrap();
7316 let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
7317 write_turn(&mut w, 0, &payload, response).await;
7318 w.flush().await.unwrap();
7319
7320 let content = tempfile::tempdir().unwrap();
7321 let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
7322 write_turn(&mut w, 0, &payload, response).await;
7323 w.flush().await.unwrap();
7324
7325 let meta_event = read_events(meta.path()).remove(0);
7326 let content_event = read_events(content.path()).remove(0);
7327 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
7328 assert_eq!(meta_event[key], content_event[key], "{key}");
7329 }
7330 assert!(
7331 !meta.path().join(BLOB_DIR_NAME).exists(),
7332 "meta must not create the blob directory"
7333 );
7334 assert!(content.path().join(BLOB_DIR_NAME).is_dir());
7335 }
7336
7337 #[tokio::test]
7339 async fn the_default_capture_mode_is_meta() {
7340 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
7341 let dir = tempfile::tempdir().unwrap();
7342 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
7343 write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
7344 w.flush().await.unwrap();
7345
7346 let e = &read_events(dir.path())[0];
7347 assert!(e.get("system_sha").is_some());
7348 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
7349 }
7350
7351 #[tokio::test]
7353 async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
7354 let payload = wire_payload("You are a capsule.", 2);
7355
7356 let off = tempfile::tempdir().unwrap();
7357 let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
7358 write_turn(&mut w, 3, &payload, "{}").await;
7359 w.flush().await.unwrap();
7360
7361 let e = read_events(off.path()).remove(0);
7362 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
7363 assert!(
7364 e.get(key).is_none(),
7365 "{key} must be absent under none, got {e}"
7366 );
7367 }
7368 assert!(!off.path().join(BLOB_DIR_NAME).exists());
7369 for key in [
7370 "event_type",
7371 "event_id",
7372 "parent_id",
7373 "session_id",
7374 "timestamp",
7375 "turn",
7376 "task_id",
7377 "input_tokens",
7378 "output_tokens",
7379 "decision",
7380 "tool_name",
7381 "message_ids",
7382 ] {
7383 assert!(
7384 e.get(key).is_some(),
7385 "{key} must survive under none, got {e}"
7386 );
7387 }
7388 assert_eq!(e["turn"], 3);
7389 assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
7390 }
7391
7392 #[tokio::test]
7395 async fn an_unchanged_system_prompt_is_stored_once() {
7396 let dir = tempfile::tempdir().unwrap();
7397 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7398 let payload = wire_payload("You are a capsule.", 1);
7399 write_turn(&mut w, 0, &payload, "{}").await;
7400
7401 let sha = {
7402 w.flush().await.unwrap();
7403 read_events(dir.path())[0]["system_sha"]
7404 .as_str()
7405 .unwrap()
7406 .to_string()
7407 };
7408 let path = blob_path(dir.path(), &sha);
7409 std::fs::write(&path, b"sentinel").unwrap();
7410
7411 for turn in 1..10 {
7412 write_turn(&mut w, turn, &payload, "{}").await;
7413 }
7414 w.flush().await.unwrap();
7415
7416 let events = read_events(dir.path());
7417 assert_eq!(events.len(), 10);
7418 for e in &events {
7419 assert_eq!(e["system_sha"], sha.as_str());
7420 }
7421 assert_eq!(
7422 std::fs::read(&path).unwrap(),
7423 b"sentinel",
7424 "an existing blob is never rewritten"
7425 );
7426 let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
7427 .unwrap()
7428 .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
7429 .count();
7430 assert_eq!(matching, 1, "exactly one file bears that name");
7431 }
7432
7433 #[tokio::test]
7438 async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
7439 let dir = tempfile::tempdir().unwrap();
7440 let mut w = make_writer(dir.path()).await;
7441 w.write_inference(
7442 0,
7443 Some(10),
7444 Some(5),
7445 "end_turn".to_string(),
7446 None,
7447 None,
7448 None,
7449 None,
7450 Vec::new(),
7451 None,
7452 )
7453 .await
7454 .unwrap();
7455 w.flush().await.unwrap();
7456
7457 let event = read_events(dir.path()).remove(0);
7458 assert!(
7459 event.get("stop_reason").is_none(),
7460 "stop_reason must be absent, got {event}"
7461 );
7462 }
7463
7464 #[tokio::test]
7467 async fn a_hook_origin_record_carries_no_hashes() {
7468 let dir = tempfile::tempdir().unwrap();
7469 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7470 let origin = InferenceOrigin {
7471 source: "hook:gatekeeper".to_string(),
7472 model: "claude-test".to_string(),
7473 };
7474 w.write_inference(
7475 0,
7476 Some(1),
7477 Some(1),
7478 "end_turn".to_string(),
7479 None,
7481 None,
7482 Some(&origin),
7483 None,
7484 Vec::new(),
7485 None,
7486 )
7487 .await
7488 .unwrap();
7489 w.flush().await.unwrap();
7490
7491 let e = read_events(dir.path()).remove(0);
7492 assert_eq!(e["origin"], "hook:gatekeeper");
7493 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
7494 assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
7495 }
7496 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
7497 }
7498
7499 #[tokio::test]
7502 async fn message_shas_locate_the_index_two_runs_diverge_at() {
7503 async fn shas_for(payload: &Value) -> Vec<String> {
7504 let dir = tempfile::tempdir().unwrap();
7505 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
7506 write_turn(&mut w, 0, payload, "{}").await;
7507 w.flush().await.unwrap();
7508 read_events(dir.path())[0]["message_shas"]
7509 .as_array()
7510 .unwrap()
7511 .iter()
7512 .map(|s| s.as_str().unwrap().to_string())
7513 .collect()
7514 }
7515
7516 let first = wire_payload("You are a capsule.", 4);
7517 let mut second = first.clone();
7518 second["messages"][2]["content"] = serde_json::json!("diverged");
7519
7520 let a = shas_for(&first).await;
7521 let b = shas_for(&second).await;
7522 assert_eq!(a.len(), 4);
7523 let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
7524 assert_eq!(divergence, Some(2));
7525 assert_eq!(a[3], b[3], "only the changed message changes its own sha");
7526 }
7527
7528 #[tokio::test]
7531 async fn identical_messages_share_one_sha_and_one_blob() {
7532 let dir = tempfile::tempdir().unwrap();
7533 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7534 let payload = serde_json::json!({
7535 "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
7536 "tools": [],
7537 "system": "s",
7538 });
7539 write_turn(&mut w, 0, &payload, "{}").await;
7540 w.flush().await.unwrap();
7541
7542 let shas = read_events(dir.path())[0]["message_shas"]
7543 .as_array()
7544 .unwrap()
7545 .iter()
7546 .map(|s| s.as_str().unwrap().to_string())
7547 .collect::<Vec<_>>();
7548 assert_eq!(shas[0], shas[1]);
7549 assert!(blob_path(dir.path(), &shas[0]).is_file());
7550 }
7551
7552 #[tokio::test]
7555 async fn a_message_blob_is_the_body_verbatim() {
7556 let dir = tempfile::tempdir().unwrap();
7557 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7558 let message = serde_json::json!({"role": "user", "content": "hello"});
7559 let payload = serde_json::json!({
7560 "messages": [message.clone()],
7561 "tools": [],
7562 "system": "s",
7563 });
7564 write_turn(&mut w, 0, &payload, "{}").await;
7565 w.flush().await.unwrap();
7566
7567 let sha = read_events(dir.path())[0]["message_shas"][0]
7568 .as_str()
7569 .unwrap()
7570 .to_string();
7571 assert_eq!(
7572 std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
7573 serde_json::to_vec(&message).unwrap()
7574 );
7575 }
7576
7577 #[tokio::test]
7585 async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
7586 let plan_dir = tempfile::tempdir().unwrap();
7587 let tool_dir = tempfile::tempdir().unwrap();
7588 let session = "ses_0000000000004000800000000000plan".to_string();
7589 let node = "evt_0000000000004000800000000000node".to_string();
7590
7591 let plan = PlanTraceAppender::open(
7592 &crate::workdir_fs::WorkdirRoot::open(plan_dir.path()).unwrap(),
7593 session.clone(),
7594 node.clone(),
7595 )
7596 .unwrap();
7597 let tool = ResourceTraceAppender::open(
7598 &crate::workdir_fs::WorkdirRoot::open(tool_dir.path()).unwrap(),
7599 session.clone(),
7600 node.clone(),
7601 )
7602 .await
7603 .unwrap();
7604
7605 plan.write_delegation_start(
7606 "dlg_abc123",
7607 "worker",
7608 "0.1.0",
7609 "ses_child",
7610 ".murmur/c",
7611 "/r/ses_child",
7612 );
7613 tool.write_delegation_start(
7614 "dlg_abc123",
7615 "worker",
7616 "0.1.0",
7617 "ses_child",
7618 ".murmur/c",
7619 "/r/ses_child",
7620 )
7621 .await;
7622 plan.write_delegation(
7623 "worker",
7624 "0.1.0",
7625 Some("dlg_abc123".to_string()),
7626 Some("ses_child".to_string()),
7627 42,
7628 "completed",
7629 None,
7630 );
7631 tool.write_delegation(
7632 "worker",
7633 "0.1.0",
7634 Some("dlg_abc123".to_string()),
7635 Some("ses_child".to_string()),
7636 42,
7637 "completed",
7638 None,
7639 )
7640 .await;
7641
7642 let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
7643 std::fs::read_to_string(dir.join("trace.jsonl"))
7644 .unwrap()
7645 .lines()
7646 .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
7647 .collect()
7648 };
7649 let from_plan = read(plan_dir.path());
7650 let from_tool = read(tool_dir.path());
7651 assert_eq!(from_plan.len(), 2);
7652 assert_eq!(from_tool.len(), 2);
7653
7654 for (planned, called, event_type) in [
7655 (&from_plan[0], &from_tool[0], "delegation_start"),
7656 (&from_plan[1], &from_tool[1], "delegation"),
7657 ] {
7658 assert_eq!(planned["event_type"], event_type);
7659 assert_eq!(called["event_type"], event_type);
7660 assert_eq!(
7661 planned.keys().collect::<Vec<_>>(),
7662 called.keys().collect::<Vec<_>>(),
7663 "{planned:?} vs {called:?}"
7664 );
7665 assert_eq!(planned["parent_id"], node.as_str());
7666 assert_eq!(called["parent_id"], node.as_str());
7667 for (key, value) in planned {
7668 if key == "event_id" || key == "timestamp" {
7669 continue;
7670 }
7671 assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
7672 }
7673 for key in ["plan_id", "step_id", "surface", "kind"] {
7674 assert!(!planned.contains_key(key), "{key} is on {planned:?}");
7675 assert!(!called.contains_key(key), "{key} is on {called:?}");
7676 }
7677 }
7678 }
7679
7680 #[tokio::test]
7684 async fn spawner_ended_names_the_lineage_or_omits_it() {
7685 let dir = tempfile::tempdir().unwrap();
7686 let session = "ses_0000000000004000800000000child".to_string();
7687 let node = "evt_0000000000004000800000000000node".to_string();
7688 let appender = ResourceTraceAppender::open(
7689 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
7690 session.clone(),
7691 node.clone(),
7692 )
7693 .await
7694 .unwrap();
7695
7696 appender
7697 .write_spawner_ended(Some("ses_parent"), Some("dlg_abc123"))
7698 .await;
7699 appender.write_spawner_ended(None, None).await;
7700
7701 let lines: Vec<serde_json::Map<String, Value>> =
7702 std::fs::read_to_string(dir.path().join("trace.jsonl"))
7703 .unwrap()
7704 .lines()
7705 .map(|line| serde_json::from_str(line).unwrap())
7706 .collect();
7707 assert_eq!(lines.len(), 2);
7708 let named = &lines[0];
7709 assert_eq!(named["event_type"], "spawner_ended");
7710 assert_eq!(named["parent_id"], node.as_str());
7711 assert_eq!(named["session_id"], session.as_str());
7712 assert_eq!(named["spawned_by"], "ses_parent");
7713 assert_eq!(named["delegation_id"], "dlg_abc123");
7714 assert_eq!(
7715 named.keys().collect::<Vec<_>>(),
7716 [
7717 "delegation_id",
7718 "event_id",
7719 "event_type",
7720 "parent_id",
7721 "session_id",
7722 "spawned_by",
7723 "timestamp"
7724 ]
7725 );
7726 let bare = &lines[1];
7727 assert_eq!(bare["event_type"], "spawner_ended");
7728 assert!(!bare.contains_key("spawned_by"), "{bare:?}");
7729 assert!(!bare.contains_key("delegation_id"), "{bare:?}");
7730 }
7731}