1use std::sync::Arc;
2use std::time::{Instant, SystemTime, UNIX_EPOCH};
3
4use murmur_artifact::{ContainmentClass, LockOrigin, TraceCapture};
5use serde::Serialize;
6use serde_json::Value;
7
8use crate::{
9 agent::{DriverUsage, OccupancyBasis},
10 containment::ScopeReport,
11 credential_gateway::GatewayOffsets,
12 formation::FormationId,
13 gateway_credential::CredentialChange,
14 inference_retry::{InferenceRetryRecord, RetryNote, RetryStop},
15 lanes::TaskLane,
16 origin::{TaskProvenance, TrustClass},
17 trace_blobs::BlobStore,
18 types::InstalledArtifactSummary,
19 workdir_fs::WorkdirRoot,
20};
21
22pub(crate) const TRACE_FILE_NAME: &str = "trace.jsonl";
24
25pub(crate) struct TraceWriter {
26 chain: Arc<crate::record_seal::InlineChain>,
28 records_root: WorkdirRoot,
30 sealed_head: Option<crate::record_seal::SealedHead>,
32 session_id: String,
33 capsule_name: String,
34 capsule_version: String,
35 model: String,
36 capabilities: Vec<String>,
37 effective_grants: ScopeReport,
43 capture: TraceCapture,
47 blobs: BlobStore,
50 system_prompt_source: &'static str,
55 system_prompt_sha256: Option<String>,
59 credential_source: &'static str,
62 gateways: Vec<SessionGateway>,
65 control: Option<SessionControl>,
68 inference_choices: Vec<SessionInferenceChoice>,
71 tool_refresh: Option<&'static str>,
74 runtime_artifacts: Vec<RuntimeArtifact>,
77 host_facts: Vec<HostFactsRecord>,
80 instruction_files: Option<Vec<crate::instruction_files::InstructionFileRecord>>,
84 resumed_from: Option<String>,
88 context_id: Option<String>,
91 spawned_by: Option<String>,
95 spawn_delegation_id: Option<String>,
96 formation_id: Option<FormationId>,
99 formation_member: Option<(String, Vec<String>)>,
102 session_start_time: Instant,
103 session_event_id: String,
108 session_started: bool,
109 session_ended: bool,
110 task_event_id: Option<String>,
113 turn_event_id: Option<String>,
117 total_turns: u32,
119 total_input_tokens: u64,
120 total_output_tokens: u64,
121 total_tool_calls: u32,
122 total_shell_calls: u32,
123 task_turns: u32,
125 task_input_tokens: u64,
126 task_output_tokens: u64,
127 task_tool_calls: u32,
128 task_shell_calls: u32,
129 task_agent_turns: u32,
134 task_hook_inferences: u32,
137 task_capped_turns: u32,
140 task_start_instant: Option<Instant>,
141 pub(crate) active_task_id: Option<String>,
142 task_failures_written: u64,
145 last_task_failure: Option<String>,
147 max_session_tokens: Option<u64>,
149 machine_tokens_per_day: Option<u64>,
150 session_status: Option<std::sync::Arc<crate::session_status::SessionStatus>>,
154 call_deadlines: Option<crate::limits::CallDeadlines>,
158 member_requests: Option<crate::member_request::MemberRequests>,
162}
163
164#[derive(Debug, Clone)]
169pub(crate) struct InferenceOrigin {
170 pub(crate) source: String,
172 pub(crate) model: String,
174}
175
176#[derive(Debug, Clone, PartialEq)]
178pub(crate) struct InferenceCallSettings {
179 pub(crate) max_output_tokens: u32,
182 pub(crate) input_budget: Option<u32>,
185 pub(crate) driver_config: Option<Value>,
188}
189
190pub(crate) fn recorded_driver_config(
198 env: &[(String, String)],
199 redact: impl Fn(&str) -> String,
200) -> Option<Value> {
201 fn redact_value(value: Value, redact: &impl Fn(&str) -> String) -> Value {
202 match value {
203 Value::String(text) => Value::String(redact(&text)),
204 Value::Number(number) => {
205 let text = number.to_string();
206 if redact(&text) == text {
207 Value::Number(number)
208 } else {
209 Value::String(crate::gateway_credential::REDACTED.to_string())
210 }
211 }
212 Value::Array(items) => Value::Array(
213 items
214 .into_iter()
215 .map(|item| redact_value(item, redact))
216 .collect(),
217 ),
218 Value::Object(entries) => Value::Object(
219 entries
220 .into_iter()
221 .map(|(key, item)| (redact(&key), redact_value(item, redact)))
222 .collect(),
223 ),
224 other @ (Value::Bool(_) | Value::Null) => other,
225 }
226 }
227 let (_, raw) = env
228 .iter()
229 .find(|(key, _)| key == "MURMUR_INFERENCE_DRIVER_CONFIG")?;
230 Some(match serde_json::from_str::<Value>(raw) {
231 Ok(value) => redact_value(value, &redact),
232 Err(_) => Value::String(redact(raw)),
233 })
234}
235
236pub(crate) const COMPACTION_DECLINED_NO_HOOK_REPLACEMENT: &str = "no_hook_replacement";
239
240pub(crate) const COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL: &str = "unresolved_tool_call";
243
244pub(crate) const SEED_OUTCOME_SEEDED: &str = "seeded";
246
247pub(crate) const SEED_OUTCOME_TRIMMED: &str = "trimmed";
250
251pub(crate) const SEED_OUTCOME_COMPACTED: &str = "compacted";
254
255pub(crate) const SEED_OUTCOME_REJECTED: &str = "rejected";
257
258pub(crate) const SEED_REJECTED_MESSAGE_OVER_BUDGET: &str = "message_over_budget";
261
262pub(crate) const SEED_REJECTED_OVERFLOW_OVER_LIMIT: &str = "overflow_over_limit";
265
266pub(crate) const SEED_REJECTED_NO_BUDGET: &str = "no_budget";
269
270pub(crate) const SEED_REJECTED_UNSUPPORTED_TRANSPORT: &str = "unsupported_transport";
273
274pub(crate) const RETENTION_STORE_SESSIONS: &str = "sessions";
276
277pub(crate) const RETENTION_STORE_RECORDS: &str = "records";
279
280pub(crate) const RETENTION_REASON_MAX_SESSIONS: &str = "max_sessions";
282
283pub(crate) const RETENTION_REASON_MAX_AGE: &str = "max_age";
286
287pub(crate) const RETENTION_REASON_MAX_MESSAGES: &str = "max_messages";
289
290pub(crate) fn new_event_id() -> String {
298 format!("evt_{}", uuid::Uuid::now_v7().simple())
299}
300
301#[derive(Serialize)]
315struct SessionStartEvent {
316 event_type: &'static str,
317 event_id: String,
318 parent_id: Option<String>,
319 session_id: String,
320 timestamp: u64,
321 capsule_name: String,
322 capsule_version: String,
323 model: String,
324 max_turns: Option<u32>,
326 model_limits: Option<crate::model_limits::ResolvedModelLimits>,
329 max_session_tokens: Option<u64>,
332 machine_tokens_per_day: Option<u64>,
335 deadline: Option<SessionStartDeadline>,
338 capabilities: Vec<String>,
339 tools_declared: Vec<String>,
340 containment_declared: ContainmentClass,
342 containment_achieved: ContainmentClass,
347 workdir_exec: bool,
355 userns_grant: Option<crate::sealed::UsernsGrant>,
367 effective_grants: ScopeReport,
376 system_prompt_source: &'static str,
384 system_prompt_sha256: Option<String>,
393 credential_source: &'static str,
396 gateways: Vec<SessionGateway>,
400 #[serde(skip_serializing_if = "Option::is_none")]
403 control: Option<SessionControl>,
404 #[serde(skip_serializing_if = "Vec::is_empty")]
408 inference_choices: Vec<SessionInferenceChoice>,
409 tool_refresh: Option<&'static str>,
412 runtime_artifacts: Vec<RuntimeArtifact>,
416 host_facts: Vec<HostFactsRecord>,
420 instruction_files: Option<Vec<crate::instruction_files::InstructionFileRecord>>,
426 resumed_from: Option<String>,
433 context_id: Option<String>,
439 #[serde(skip_serializing_if = "Option::is_none")]
443 spawned_by: Option<String>,
444 #[serde(skip_serializing_if = "Option::is_none")]
447 delegation_id: Option<String>,
448 #[serde(skip_serializing_if = "Option::is_none")]
452 formation_id: Option<FormationId>,
453 #[serde(skip_serializing_if = "Option::is_none")]
456 formation_member: Option<String>,
457 #[serde(skip_serializing_if = "Option::is_none")]
460 formation_callees: Option<Vec<String>>,
461}
462
463#[derive(Serialize)]
465struct SessionStartDeadline {
466 at: String,
468 secs: u64,
471 notice_secs: u64,
474 source: &'static str,
476}
477
478#[derive(Serialize)]
479struct InferenceEvent {
480 event_type: &'static str,
481 event_id: String,
482 parent_id: Option<String>,
483 session_id: String,
484 timestamp: u64,
485 turn: u32,
486 task_id: Option<String>,
487 #[serde(skip_serializing_if = "Option::is_none")]
500 input_tokens: Option<u64>,
501 #[serde(skip_serializing_if = "Option::is_none")]
505 output_tokens: Option<u64>,
506 decision: String,
507 #[serde(skip_serializing_if = "Option::is_none")]
514 stop_reason: Option<String>,
515 tool_name: Option<String>,
516 #[serde(skip_serializing_if = "Option::is_none")]
519 input_tokens_actual: Option<u64>,
520 #[serde(skip_serializing_if = "Option::is_none")]
521 output_tokens_actual: Option<u64>,
522 #[serde(skip_serializing_if = "Option::is_none")]
523 cached_tokens: Option<u64>,
524 #[serde(skip_serializing_if = "Option::is_none")]
525 cache_write_tokens: Option<u64>,
526 #[serde(skip_serializing_if = "Option::is_none")]
529 thinking_tokens: Option<u64>,
530 #[serde(skip_serializing_if = "Option::is_none")]
535 origin: Option<String>,
536 #[serde(skip_serializing_if = "Option::is_none")]
539 driver_choice: Option<String>,
540 #[serde(skip_serializing_if = "Option::is_none")]
545 model: Option<String>,
546 #[serde(skip_serializing_if = "Option::is_none")]
551 max_output_tokens: Option<u32>,
552 #[serde(skip_serializing_if = "Option::is_none")]
556 input_budget: Option<Option<u32>>,
557 #[serde(skip_serializing_if = "Option::is_none")]
562 driver_config: Option<Option<Value>>,
563 #[serde(skip_serializing_if = "Vec::is_empty")]
568 message_ids: Vec<String>,
569 #[serde(skip_serializing_if = "Option::is_none")]
577 system_sha: Option<String>,
578 #[serde(skip_serializing_if = "Option::is_none")]
581 tools_sha: Option<String>,
582 #[serde(skip_serializing_if = "Option::is_none")]
585 response_sha: Option<String>,
586 #[serde(skip_serializing_if = "Vec::is_empty")]
594 message_shas: Vec<String>,
595 #[serde(skip_serializing_if = "Option::is_none")]
599 error_code: Option<String>,
600 #[serde(skip_serializing_if = "Option::is_none")]
604 error: Option<String>,
605 #[serde(skip_serializing_if = "Option::is_none")]
609 provider_status: Option<u16>,
610 #[serde(skip_serializing_if = "Option::is_none")]
615 gateway_reached_ms: Option<Option<u64>>,
616 #[serde(skip_serializing_if = "Option::is_none")]
620 upstream_responded_ms: Option<Option<u64>>,
621 #[serde(skip_serializing_if = "Option::is_none")]
624 attempts: Option<u32>,
625 #[serde(skip_serializing_if = "Option::is_none")]
628 retry_stopped: Option<&'static str>,
629}
630
631pub(crate) struct WireCapture {
641 system: Vec<u8>,
644 tools: Vec<u8>,
646 messages: Vec<Vec<u8>>,
650 response: Vec<u8>,
652}
653
654impl WireCapture {
655 pub(crate) fn from_driver_payload(payload: &Value, response: &str) -> Self {
661 Self {
662 system: payload
663 .get("system")
664 .and_then(Value::as_str)
665 .unwrap_or_default()
666 .as_bytes()
667 .to_vec(),
668 tools: payload
669 .get("tools")
670 .map(|tools| serde_json::to_vec(tools).unwrap_or_default())
671 .unwrap_or_default(),
672 messages: payload
673 .get("messages")
674 .and_then(Value::as_array)
675 .map(|messages| {
676 messages
677 .iter()
678 .map(|message| serde_json::to_vec(message).unwrap_or_default())
679 .collect()
680 })
681 .unwrap_or_default(),
682 response: response.as_bytes().to_vec(),
683 }
684 }
685}
686
687#[derive(Serialize)]
688struct ToolCallEvent {
689 event_type: &'static str,
690 event_id: String,
691 parent_id: Option<String>,
692 session_id: String,
693 timestamp: u64,
694 turn: u32,
695 task_id: Option<String>,
696 tool_name: String,
697 tool_call_id: Option<String>,
701 input: Value,
702 input_bytes: u64,
703 #[serde(skip_serializing_if = "Option::is_none")]
704 output: Option<String>,
705 output_bytes: u64,
706 duration_ms: u64,
707 status: String,
708 #[serde(skip_serializing_if = "Option::is_none")]
714 error: Option<String>,
715 #[serde(skip_serializing_if = "Option::is_none")]
719 state_effect: Option<String>,
720 #[serde(skip_serializing_if = "Option::is_none")]
725 resource_id: Option<String>,
726 #[serde(skip_serializing_if = "Vec::is_empty")]
729 attachments: Vec<AttachmentRecord>,
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
738pub(crate) struct AttachmentRecord {
739 pub(crate) path: String,
740 pub(crate) media_type: String,
741 #[serde(skip_serializing_if = "Option::is_none")]
742 pub(crate) bytes: Option<u64>,
743 #[serde(skip_serializing_if = "Option::is_none")]
744 pub(crate) sha256: Option<String>,
745 #[serde(skip_serializing_if = "Option::is_none")]
746 pub(crate) refused: Option<&'static str>,
747}
748
749#[derive(Serialize)]
750struct SkillCallEvent {
751 event_type: &'static str,
752 event_id: String,
753 parent_id: Option<String>,
754 session_id: String,
755 timestamp: u64,
756 turn: u32,
757 task_id: Option<String>,
758 skill_name: String,
759 output_bytes: u64,
760 duration_ms: u64,
761 status: String,
762 origin: &'static str,
764 trust: &'static str,
767}
768
769#[derive(Serialize)]
770struct ShellEvent {
771 event_type: &'static str,
772 event_id: String,
773 parent_id: Option<String>,
774 session_id: String,
775 timestamp: u64,
776 turn: u32,
777 task_id: Option<String>,
778 binary: String,
782 command: String,
783 exit_code: i32,
784 stdout_bytes: u64,
785 stderr_bytes: u64,
786 duration_ms: u64,
787 #[serde(skip_serializing_if = "Option::is_none")]
793 resource_limit: Option<String>,
794}
795
796#[derive(Serialize)]
799struct ShellDetachedEvent {
800 event_type: &'static str,
801 event_id: String,
802 parent_id: Option<String>,
803 session_id: String,
804 timestamp: u64,
805 turn: u32,
806 task_id: Option<String>,
807 work_id: String,
808 binary: String,
809 command: String,
810 #[serde(skip_serializing_if = "Option::is_none")]
814 grace_ms: Option<u64>,
815}
816
817#[derive(Serialize)]
823struct ShellCompletedEvent {
824 event_type: &'static str,
825 event_id: String,
826 parent_id: Option<String>,
827 session_id: String,
828 timestamp: u64,
829 work_id: String,
830 binary: String,
831 command: String,
832 exit_code: i32,
833 duration_ms: u64,
834 exited_at_ms: u64,
837 output_path: String,
840 output_bytes: u64,
841 #[serde(skip_serializing_if = "Option::is_none")]
843 resource_limit: Option<String>,
844 status: String,
847 delivered_via: &'static str,
849 #[serde(skip_serializing_if = "Option::is_none")]
853 completion_task_id: Option<String>,
854}
855
856impl ShellCompletedEvent {
857 fn new(
858 session_id: String,
859 parent_id: Option<String>,
860 completion: &crate::detached::DetachedCompletion,
861 delivery: crate::detached::Delivery,
862 completion_task_id: Option<&str>,
863 ) -> Self {
864 Self {
865 event_type: "shell_completed",
866 event_id: new_event_id(),
867 parent_id,
868 session_id,
869 timestamp: timestamp_ms(),
870 work_id: completion.work_id.clone(),
871 binary: completion.binary.clone(),
872 command: completion.command.clone(),
873 exit_code: completion.exit_code,
874 duration_ms: completion.duration_ms,
875 exited_at_ms: completion.exited_at_ms,
876 output_path: completion.output_path.clone(),
877 output_bytes: completion.output_bytes,
878 resource_limit: completion.resource_limit.clone(),
879 status: completion.status().to_string(),
880 delivered_via: delivery.as_str(),
881 completion_task_id: completion_task_id.map(str::to_string),
882 }
883 }
884}
885
886#[derive(Serialize)]
888struct BackgroundCommandEvent<'a> {
889 event_type: &'static str,
890 event_id: String,
891 parent_id: Option<String>,
892 session_id: String,
893 timestamp: u64,
894 #[serde(skip_serializing_if = "Option::is_none")]
896 task_id: Option<String>,
897 work_id: &'a str,
898 operation: &'static str,
900 state: &'static str,
902 #[serde(skip_serializing_if = "Option::is_none")]
904 exit_code: Option<i32>,
905 #[serde(skip_serializing_if = "Option::is_none")]
907 waited_ms: Option<u64>,
908 output_bytes: u64,
910}
911
912#[derive(Serialize)]
914pub(crate) struct ProcessStartLine<'a> {
915 #[serde(skip_serializing_if = "Option::is_none")]
917 pub(crate) task_id: Option<String>,
918 pub(crate) tool: &'a str,
920 pub(crate) process_id: &'a str,
922 pub(crate) kind: &'static str,
924 pub(crate) name: Option<&'a str>,
926 pub(crate) binary: &'a str,
928 pub(crate) argv: &'a [String],
930 pub(crate) pid: i32,
932}
933
934#[derive(Serialize)]
935struct ProcessStartEvent<'a> {
936 event_type: &'static str,
937 event_id: String,
938 parent_id: Option<String>,
939 session_id: String,
940 timestamp: u64,
941 #[serde(flatten)]
942 line: ProcessStartLine<'a>,
943}
944
945#[derive(Serialize)]
947pub(crate) struct ProcessExitLine<'a> {
948 #[serde(skip_serializing_if = "Option::is_none")]
949 pub(crate) task_id: Option<String>,
950 pub(crate) tool: &'a str,
951 pub(crate) process_id: &'a str,
952 pub(crate) kind: &'static str,
953 pub(crate) name: Option<&'a str>,
954 pub(crate) binary: &'a str,
955 pub(crate) exit_code: i32,
957 pub(crate) reason: &'static str,
959 pub(crate) duration_ms: u64,
960}
961
962#[derive(Serialize)]
963struct ProcessExitEvent<'a> {
964 event_type: &'static str,
965 event_id: String,
966 parent_id: Option<String>,
967 session_id: String,
968 timestamp: u64,
969 #[serde(flatten)]
970 line: ProcessExitLine<'a>,
971}
972
973#[derive(Serialize)]
975pub(crate) struct ProcessRefusedLine<'a> {
976 #[serde(skip_serializing_if = "Option::is_none")]
977 pub(crate) task_id: Option<String>,
978 pub(crate) tool: &'a str,
979 pub(crate) kind: &'static str,
981 pub(crate) name: Option<&'a str>,
983 #[serde(skip_serializing_if = "Option::is_none")]
985 pub(crate) binary: Option<&'a str>,
986 #[serde(skip_serializing_if = "Option::is_none")]
988 pub(crate) port: Option<u16>,
989 pub(crate) rule: &'static str,
990 pub(crate) reason: &'a str,
991}
992
993#[derive(Serialize)]
994struct ProcessRefusedEvent<'a> {
995 event_type: &'static str,
996 event_id: String,
997 parent_id: Option<String>,
998 session_id: String,
999 timestamp: u64,
1000 #[serde(flatten)]
1001 line: ProcessRefusedLine<'a>,
1002}
1003
1004#[derive(Serialize)]
1013struct ShellAbandonedEvent {
1014 event_type: &'static str,
1015 event_id: String,
1016 parent_id: Option<String>,
1017 session_id: String,
1018 timestamp: u64,
1019 work_id: String,
1020 binary: String,
1021 command: String,
1022 running_ms: u64,
1025 #[serde(skip_serializing_if = "Option::is_none")]
1028 exit_code: Option<i32>,
1029 #[serde(skip_serializing_if = "Option::is_none")]
1033 output_path: Option<String>,
1034 #[serde(skip_serializing_if = "Option::is_none")]
1036 output_bytes: Option<u64>,
1037}
1038
1039#[derive(Serialize)]
1051struct ShellLostEvent {
1052 event_type: &'static str,
1053 event_id: String,
1054 #[serde(skip_serializing_if = "Option::is_none")]
1057 parent_id: Option<String>,
1058 session_id: String,
1060 timestamp: u64,
1061 work_id: String,
1062 binary: String,
1063 command: String,
1064 detached_at_ms: u64,
1066 reconciled_by_session: String,
1068 reconciled_task_id: String,
1071}
1072
1073#[derive(Serialize)]
1079struct ShellDetachUnrecordedEvent {
1080 event_type: &'static str,
1081 event_id: String,
1082 parent_id: Option<String>,
1083 session_id: String,
1084 timestamp: u64,
1085 turn: u32,
1086 task_id: Option<String>,
1087 work_id: String,
1088 binary: String,
1089 reason: String,
1091}
1092
1093#[derive(Serialize)]
1097struct SafetyCheckEvent {
1098 event_type: &'static str,
1099 event_id: String,
1100 parent_id: Option<String>,
1101 session_id: String,
1102 timestamp: u64,
1103 turn: u32,
1104 task_id: Option<String>,
1105 tool_call_id: String,
1106 tool: String,
1107 checks: serde_json::Value,
1109 outcome: &'static str,
1111}
1112
1113#[derive(Serialize)]
1114struct CompactionEvent {
1115 event_type: &'static str,
1116 event_id: String,
1117 parent_id: Option<String>,
1118 session_id: String,
1119 timestamp: u64,
1120 turn: u32,
1121 task_id: Option<String>,
1122 tokens_before: u64,
1125 tokens_before_source: OccupancyBasis,
1127 tokens_after: u64,
1130}
1131
1132#[derive(Serialize)]
1136struct CompactionDeclinedEvent {
1137 event_type: &'static str,
1138 event_id: String,
1139 parent_id: Option<String>,
1140 session_id: String,
1141 timestamp: u64,
1142 turn: u32,
1143 task_id: Option<String>,
1144 tokens: u64,
1147 tokens_source: OccupancyBasis,
1149 reason: String,
1152}
1153
1154#[derive(Serialize)]
1161struct ContextSeedEvent {
1162 event_type: &'static str,
1163 event_id: String,
1164 parent_id: Option<String>,
1165 session_id: String,
1166 timestamp: u64,
1167 task_id: Option<String>,
1168 hook_name: String,
1170 tokens: u64,
1173 proposed_tokens: u64,
1176 budget_tokens: u64,
1179 outcome: String,
1182 #[serde(skip_serializing_if = "Option::is_none")]
1185 reason: Option<String>,
1186 message_ids: Vec<String>,
1190}
1191
1192#[derive(Serialize)]
1200struct RetentionEvent {
1201 event_type: &'static str,
1202 event_id: String,
1203 parent_id: Option<String>,
1204 session_id: String,
1205 timestamp: u64,
1206 store: String,
1208 reason: String,
1211 removed: u32,
1215 targets: Vec<String>,
1217 #[serde(skip_serializing_if = "Option::is_none")]
1220 messages_dropped: Option<u64>,
1221}
1222
1223#[derive(Serialize)]
1224struct A2aTaskReceivedEvent {
1225 event_type: &'static str,
1226 event_id: String,
1227 parent_id: Option<String>,
1228 session_id: String,
1229 timestamp: u64,
1230 task_id: String,
1231 context_id: String,
1232 message_id: String,
1233 traceparent_from_caller: Option<String>,
1234 caller: &'static str,
1237 #[serde(skip_serializing_if = "Option::is_none")]
1239 caller_member: Option<String>,
1240 #[serde(skip_serializing_if = "Option::is_none")]
1243 caller_credential: Option<String>,
1244 #[serde(skip_serializing_if = "Option::is_none")]
1247 member_request_id: Option<String>,
1248 #[serde(skip_serializing_if = "Option::is_none")]
1251 message_sha256: Option<String>,
1252}
1253
1254pub(crate) const RECEIVED_FROM_OPERATOR: &str = "operator";
1256pub(crate) const RECEIVED_FROM_MEMBER: &str = "member";
1258pub(crate) const RECEIVED_FROM_CREDENTIAL: &str = "credential";
1260pub(crate) const RECEIVED_FROM_PUBLIC: &str = "public";
1262
1263#[derive(Serialize)]
1264struct A2aSendEvent {
1265 event_type: &'static str,
1266 event_id: String,
1267 parent_id: Option<String>,
1268 session_id: String,
1269 timestamp: u64,
1270 peer_url: String,
1271 message_id: String,
1272 task_id: String,
1273 context_id: String,
1274 traceparent: Option<String>,
1275 trust: String,
1278}
1279
1280#[derive(Serialize)]
1283struct ArtifactPulledEvent {
1284 event_type: &'static str,
1285 event_id: String,
1286 parent_id: Option<String>,
1287 session_id: String,
1288 timestamp: u64,
1289 name: String,
1290 version: String,
1291 runtime: &'static str,
1293 origin: &'static str,
1296 session: Option<String>,
1299 trust: &'static str,
1300 fetched_from: String,
1303 #[serde(skip_serializing_if = "Option::is_none")]
1306 owner_id: Option<u64>,
1307}
1308
1309#[derive(Serialize)]
1310struct SessionEndEvent {
1311 event_type: &'static str,
1312 event_id: String,
1313 parent_id: Option<String>,
1314 session_id: String,
1315 timestamp: u64,
1316 total_turns: u32,
1317 total_input_tokens: u64,
1318 total_output_tokens: u64,
1319 total_tool_calls: u32,
1320 total_shell_calls: u32,
1321 duration_ms: u64,
1322 exit_status: String,
1323}
1324
1325#[derive(Serialize)]
1326struct TaskStartEvent {
1327 event_type: &'static str,
1328 event_id: String,
1329 parent_id: Option<String>,
1330 session_id: String,
1331 timestamp: u64,
1332 task_id: String,
1333 context_id: String,
1334 source: String,
1337 origin: String,
1338 trust: String,
1339 lane: String,
1342 message_parts_bytes: u64,
1343 #[serde(skip_serializing_if = "Vec::is_empty")]
1346 part_kinds: Vec<&'static str>,
1347 #[serde(skip_serializing_if = "Vec::is_empty")]
1349 reference_task_ids: Vec<String>,
1350 #[serde(skip_serializing_if = "Vec::is_empty")]
1353 attachments: Vec<AttachmentRecord>,
1354}
1355
1356#[derive(Serialize)]
1357struct TaskEndEvent {
1358 event_type: &'static str,
1359 event_id: String,
1360 parent_id: Option<String>,
1361 session_id: String,
1362 timestamp: u64,
1363 task_id: String,
1364 exit_status: String,
1365 duration_ms: u64,
1366 turns: u32,
1367 input_tokens: u64,
1368 output_tokens: u64,
1369 tool_calls: u32,
1370 shell_calls: u32,
1371 reopen_count: u32,
1374 capped_turns: u32,
1377 #[serde(skip_serializing_if = "Vec::is_empty")]
1380 running_commands: Vec<String>,
1381 #[serde(skip_serializing_if = "Option::is_none")]
1383 keep_working: Option<KeepWorkingSummary>,
1384 #[serde(skip_serializing_if = "Option::is_none")]
1386 done_check: Option<DoneCheckSummary>,
1387}
1388
1389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1392pub(crate) struct DoneCheckSummary {
1393 pub(crate) runs: u32,
1395 pub(crate) reopens: u32,
1397 pub(crate) limit: u32,
1399 pub(crate) met: Option<bool>,
1402 pub(crate) stopped_by: Option<&'static str>,
1405}
1406
1407#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1410pub(crate) struct KeepWorkingSummary {
1411 pub(crate) used: u32,
1413 pub(crate) limit: u32,
1415 pub(crate) stopped_by: Option<&'static str>,
1419}
1420
1421#[derive(Serialize)]
1428struct TaskCanceledEvent {
1429 event_type: &'static str,
1430 event_id: String,
1431 parent_id: Option<String>,
1432 session_id: String,
1433 timestamp: u64,
1434 task_id: String,
1435 #[serde(skip_serializing_if = "Option::is_none")]
1437 turn: Option<u32>,
1438 phase: String,
1441 detached_work_ids: Vec<String>,
1444 delegation_ids: Vec<String>,
1446}
1447
1448pub(crate) const TASK_REJECTED_SESSION_ENDED: &str = "session_ended";
1451
1452pub(crate) const TASK_REJECTED_SESSION_STOPPED: &str = "session_stopped";
1454
1455#[derive(Serialize)]
1462struct TaskRejectedEvent {
1463 event_type: &'static str,
1464 event_id: String,
1465 parent_id: Option<String>,
1466 session_id: String,
1467 timestamp: u64,
1468 task_id: String,
1469 context_id: String,
1470 source: String,
1473 cause: String,
1475 reason: String,
1477}
1478
1479pub(crate) const TASK_FAILED_DRIVER_ERROR: &str = "driver_error";
1482
1483pub(crate) const TASK_FAILED_CREDENTIAL_REJECTED: &str = "credential_rejected";
1486
1487pub(crate) const TASK_FAILED_MALFORMED_RESPONSE: &str = "malformed_response";
1490
1491pub(crate) const TASK_FAILED_COMPACTION_HOOK: &str = "compaction_hook";
1493
1494pub(crate) const TASK_FAILED_INPUT_TIMEOUT: &str = "input_timeout";
1496
1497pub(crate) const TASK_FAILED_REOPEN_BUDGET_EXHAUSTED: &str = "reopen_budget_exhausted";
1500
1501pub(crate) const TASK_FAILED_RUNTIME_ERROR: &str = "runtime_error";
1504
1505pub(crate) const TASK_FAILED_OUTPUT_CAP: &str = "output_cap";
1508
1509pub(crate) const TASK_FAILED_WORKDIR_PATH_REFUSED: &str = "workdir_path_refused";
1512
1513pub(crate) const INFERENCE_ERROR_CREDENTIAL_REJECTED: &str = TASK_FAILED_CREDENTIAL_REJECTED;
1517
1518pub(crate) const INFERENCE_ERROR_DRIVER_ERROR: &str = TASK_FAILED_DRIVER_ERROR;
1522
1523pub(crate) const INFERENCE_ERROR_DRIVER_FAILED: &str = "driver_failed";
1526
1527pub(crate) const INFERENCE_ERROR_MALFORMED_RESPONSE: &str = TASK_FAILED_MALFORMED_RESPONSE;
1530
1531pub(crate) const INFERENCE_ERROR_HARNESS_AUTH: &str = "harness_auth";
1533
1534pub(crate) const INFERENCE_ERROR_HARNESS_QUOTA: &str = "harness_quota";
1536
1537pub(crate) const INFERENCE_ERROR_HARNESS_ERROR: &str = "harness_error";
1540
1541pub(crate) const INFERENCE_ERROR_HARNESS_OTHER: &str = "harness_other";
1543
1544pub(crate) const INFERENCE_ERROR_HARNESS_INACTIVE: &str = "harness_inactive";
1546
1547pub(crate) struct FailedInference<'a> {
1549 pub(crate) turn: u32,
1550 pub(crate) error_code: &'static str,
1552 pub(crate) error: &'a str,
1554 pub(crate) provider_status: Option<u16>,
1556 pub(crate) usage: Option<&'a DriverUsage>,
1558 pub(crate) origin: Option<&'a InferenceOrigin>,
1560 pub(crate) choice: Option<&'a crate::driver_choice::DriverChoice>,
1562 pub(crate) message_ids: Vec<String>,
1564 pub(crate) tool_name: Option<String>,
1565 pub(crate) spent: Option<(Option<u64>, Option<u64>)>,
1568 pub(crate) settings: Option<&'a InferenceCallSettings>,
1570 pub(crate) retry: RetryNote,
1572 pub(crate) gateway: Option<GatewayOffsets>,
1574}
1575
1576struct InferenceLine<'a> {
1579 turn: u32,
1580 input_tokens: Option<u64>,
1581 output_tokens: Option<u64>,
1582 decision: String,
1583 stop_reason: Option<&'a str>,
1584 tool_name: Option<String>,
1585 origin: Option<&'a InferenceOrigin>,
1586 usage: Option<&'a DriverUsage>,
1587 message_ids: Vec<String>,
1588 wire: Option<&'a WireCapture>,
1589 choice: Option<&'a crate::driver_choice::DriverChoice>,
1590 failure: Option<(&'static str, String, Option<u16>)>,
1592 counted: bool,
1594 settings: Option<&'a InferenceCallSettings>,
1596 retry: RetryNote,
1597 gateway: Option<GatewayOffsets>,
1600}
1601
1602pub(crate) const MAX_TASK_FAILURE_REASON_BYTES: usize = 2_000;
1605
1606#[derive(Serialize)]
1610struct WorkdirPathRefusedEvent<'a> {
1611 event_type: &'static str,
1612 event_id: String,
1613 parent_id: Option<String>,
1614 session_id: String,
1615 timestamp: u64,
1616 #[serde(skip_serializing_if = "Option::is_none")]
1617 task_id: Option<String>,
1618 path: &'a str,
1620 operation: crate::workdir_fs::WorkdirOperation,
1621 refused_at: &'a str,
1624 reason: crate::workdir_fs::RefusalReason,
1625}
1626
1627#[derive(Serialize)]
1635struct TaskFailedEvent {
1636 event_type: &'static str,
1637 event_id: String,
1638 parent_id: Option<String>,
1639 session_id: String,
1640 timestamp: u64,
1641 #[serde(skip_serializing_if = "Option::is_none")]
1643 task_id: Option<String>,
1644 #[serde(skip_serializing_if = "Option::is_none")]
1647 turn: Option<u32>,
1648 cause: String,
1649 reason: String,
1651}
1652
1653pub(crate) const REOPEN_CONTEXT_CONTINUED: &str = "continued";
1656
1657pub(crate) const REOPEN_CONTEXT_RESTARTED: &str = "restarted";
1660
1661#[derive(Serialize)]
1666struct TaskReopenedEvent {
1667 event_type: &'static str,
1668 event_id: String,
1669 parent_id: Option<String>,
1670 session_id: String,
1671 timestamp: u64,
1672 task_id: String,
1673 hook_name: String,
1675 reason: String,
1677 reopen_number: u32,
1679 attempt_context: &'static str,
1681 turns_remaining: Option<u32>,
1685}
1686
1687#[derive(Serialize)]
1692struct TaskContinuedEvent<'a> {
1693 event_type: &'static str,
1694 event_id: String,
1695 parent_id: Option<String>,
1696 session_id: String,
1697 timestamp: u64,
1698 task_id: String,
1699 continuation_number: u32,
1701 member_calls: &'a [String],
1703 delegations: &'a [String],
1706 waited_ms: u64,
1708 turns_remaining: Option<u32>,
1712}
1713
1714#[derive(Serialize)]
1717struct KeepWorkingEvent {
1718 event_type: &'static str,
1719 event_id: String,
1720 parent_id: Option<String>,
1721 session_id: String,
1722 timestamp: u64,
1723 task_id: String,
1724 number: u32,
1726 limit: u32,
1728 turns_remaining: Option<u32>,
1731 message_bytes: u64,
1733}
1734
1735#[derive(Serialize)]
1739struct DoneCheckEvent<'a> {
1740 event_type: &'static str,
1741 event_id: String,
1742 parent_id: Option<String>,
1743 session_id: String,
1744 timestamp: u64,
1745 #[serde(flatten)]
1746 run: &'a DoneCheckRun<'a>,
1747}
1748
1749#[derive(Debug, Serialize)]
1751pub(crate) struct DoneCheckRun<'a> {
1752 pub(crate) task_id: &'a str,
1753 pub(crate) number: u32,
1755 pub(crate) command: &'a str,
1757 pub(crate) outcome: &'static str,
1759 pub(crate) exit_code: Option<i32>,
1761 pub(crate) duration_ms: u64,
1762 pub(crate) stdout_bytes: u64,
1764 pub(crate) stderr_bytes: u64,
1765 pub(crate) truncated: bool,
1766 pub(crate) resource_limit: Option<&'a str>,
1768 pub(crate) reopened: bool,
1770 pub(crate) reopens_left: u32,
1772 pub(crate) turns_remaining: Option<u32>,
1774}
1775
1776#[derive(Serialize)]
1780struct CallDeadlineEvent {
1781 event_type: &'static str,
1782 event_id: String,
1783 parent_id: Option<String>,
1784 session_id: String,
1785 timestamp: u64,
1786 #[serde(skip_serializing_if = "Option::is_none")]
1787 task_id: Option<String>,
1788 component: String,
1790 role: &'static str,
1792 call: String,
1794 deadline_seconds: u64,
1795 elapsed_ms: u64,
1797}
1798
1799#[derive(Serialize)]
1803struct MemberRequestEvent {
1804 event_type: &'static str,
1805 event_id: String,
1806 parent_id: Option<String>,
1807 session_id: String,
1808 timestamp: u64,
1809 call_id: String,
1811 #[serde(skip_serializing_if = "Option::is_none")]
1813 task_id: Option<String>,
1814 #[serde(skip_serializing_if = "Option::is_none")]
1816 member: Option<String>,
1817 component: String,
1818 role: &'static str,
1820 via: &'static str,
1822 outcome: &'static str,
1824 #[serde(skip_serializing_if = "Option::is_none")]
1826 reason: Option<&'static str>,
1827 #[serde(skip_serializing_if = "Option::is_none")]
1828 http_status: Option<u16>,
1829 #[serde(skip_serializing_if = "Option::is_none")]
1830 member_task_id: Option<String>,
1831 duration_ms: u64,
1832}
1833
1834#[derive(Serialize)]
1837struct InferenceRetryEvent<'a> {
1838 event_type: &'static str,
1839 event_id: String,
1840 parent_id: Option<String>,
1841 session_id: String,
1842 timestamp: u64,
1843 turn: u32,
1844 task_id: Option<String>,
1845 attempt: u32,
1846 max_retries: u32,
1847 reason: &'static str,
1848 #[serde(skip_serializing_if = "Option::is_none")]
1849 provider_status: Option<u16>,
1850 error: &'a str,
1851 delay_ms: u64,
1852 #[serde(skip_serializing_if = "Option::is_none")]
1853 retry_after_ms: Option<u64>,
1854 charged_tokens: u64,
1855 #[serde(skip_serializing_if = "Option::is_none")]
1856 origin: Option<&'a str>,
1857}
1858
1859#[derive(Serialize)]
1862struct SpendCeilingReachedEvent<'a> {
1863 event_type: &'static str,
1864 event_id: String,
1865 parent_id: Option<String>,
1866 session_id: String,
1867 timestamp: u64,
1868 turn: u32,
1869 task_id: Option<String>,
1870 #[serde(flatten)]
1871 refusal: &'a crate::spend::SpendRefusal,
1872 #[serde(skip_serializing_if = "Option::is_none")]
1874 origin: Option<&'a str>,
1875}
1876
1877#[derive(Serialize)]
1882pub(crate) struct HarnessStart {
1883 pub driver: String,
1885 pub driver_version: String,
1886 pub harness: String,
1888 pub tier: String,
1892 pub binary: String,
1894 pub binary_source: String,
1896 pub harness_version: Option<String>,
1898 pub version_tested: bool,
1900 pub harness_session_id: String,
1902 pub session_mode: String,
1903 pub args_count: usize,
1906 pub env_names: Vec<String>,
1908 pub account_env_unset: Vec<String>,
1911 pub files: Vec<String>,
1913 pub bridge_tools: Vec<String>,
1915 pub stdin_bytes: usize,
1917 pub keep_stdin_open: bool,
1918 pub system_prompt_sha256: String,
1922 #[serde(skip)]
1925 pub system_prompt: String,
1926}
1927
1928#[derive(Serialize)]
1929struct HarnessStartEvent {
1930 event_type: &'static str,
1931 event_id: String,
1932 parent_id: Option<String>,
1933 session_id: String,
1934 timestamp: u64,
1935 task_id: Option<String>,
1936 #[serde(flatten)]
1937 start: HarnessStart,
1938}
1939
1940#[derive(Serialize)]
1943struct HarnessWarningEvent {
1944 event_type: &'static str,
1945 event_id: String,
1946 parent_id: Option<String>,
1947 session_id: String,
1948 timestamp: u64,
1949 task_id: Option<String>,
1950 code: String,
1951 message: String,
1952}
1953
1954#[derive(Serialize)]
1957struct HarnessSessionEvent {
1958 event_type: &'static str,
1959 event_id: String,
1960 parent_id: Option<String>,
1961 session_id: String,
1962 timestamp: u64,
1963 task_id: Option<String>,
1964 harness_session_id: String,
1965 auth: String,
1967 model: Option<String>,
1969}
1970
1971#[derive(Serialize)]
1975struct HarnessSessionForgottenEvent {
1976 event_type: &'static str,
1977 event_id: String,
1978 parent_id: Option<String>,
1979 session_id: String,
1980 timestamp: u64,
1981 task_id: Option<String>,
1982 context_id: String,
1984 harness_session_id: String,
1986 requested_by: String,
1988}
1989
1990#[derive(Serialize)]
1991struct HarnessRetryEvent {
1992 event_type: &'static str,
1993 event_id: String,
1994 parent_id: Option<String>,
1995 session_id: String,
1996 timestamp: u64,
1997 task_id: Option<String>,
1998 attempt: u32,
1999 reason: String,
2000}
2001
2002#[derive(Serialize)]
2005struct HarnessTextEvent {
2006 event_type: &'static str,
2007 event_id: String,
2008 parent_id: Option<String>,
2009 session_id: String,
2010 timestamp: u64,
2011 task_id: Option<String>,
2012 text: String,
2013}
2014
2015#[derive(Serialize)]
2018struct HarnessQuotaWindow<'a> {
2019 name: &'a str,
2020 #[serde(skip_serializing_if = "Option::is_none")]
2021 used: Option<f64>,
2022 #[serde(skip_serializing_if = "Option::is_none")]
2023 resets_at_ms: Option<u64>,
2024 #[serde(skip_serializing_if = "Option::is_none")]
2025 window_minutes: Option<u64>,
2026 #[serde(skip_serializing_if = "Option::is_none")]
2027 state: Option<&'static str>,
2028}
2029
2030#[derive(Serialize)]
2031struct HarnessQuotaEvent<'a> {
2032 event_type: &'static str,
2033 event_id: String,
2034 parent_id: Option<String>,
2035 session_id: String,
2036 timestamp: u64,
2037 task_id: Option<String>,
2038 windows: Vec<HarnessQuotaWindow<'a>>,
2039}
2040
2041#[derive(Serialize)]
2042struct HarnessFailedEvent {
2043 event_type: &'static str,
2044 event_id: String,
2045 parent_id: Option<String>,
2046 session_id: String,
2047 timestamp: u64,
2048 task_id: Option<String>,
2049 kind: String,
2051 message: String,
2052 source: String,
2054}
2055
2056#[derive(Serialize)]
2058struct HarnessInterruptEvent {
2059 event_type: &'static str,
2060 event_id: String,
2061 parent_id: Option<String>,
2062 session_id: String,
2063 timestamp: u64,
2064 task_id: Option<String>,
2065 method: String,
2067 delivered: bool,
2069 grace_ms: u64,
2072}
2073
2074#[derive(Serialize)]
2076pub(crate) struct HarnessExit {
2077 pub code: Option<i32>,
2079 pub signal: Option<i32>,
2081 pub cause: String,
2084 pub killed: bool,
2086 pub duration_ms: u64,
2088}
2089
2090#[derive(Serialize)]
2091struct HarnessExitEvent {
2092 event_type: &'static str,
2093 event_id: String,
2094 parent_id: Option<String>,
2095 session_id: String,
2096 timestamp: u64,
2097 task_id: Option<String>,
2098 #[serde(flatten)]
2099 exit: HarnessExit,
2100}
2101
2102#[derive(Serialize)]
2106struct CallDeniedEvent {
2107 event_type: &'static str,
2108 event_id: String,
2109 parent_id: Option<String>,
2110 session_id: String,
2111 timestamp: u64,
2112 turn: u32,
2113 event: String,
2116 hook_name: String,
2118 target: String,
2121 reason: String,
2123}
2124
2125#[derive(Serialize)]
2129struct ToolInputRefusedEvent {
2130 event_type: &'static str,
2131 event_id: String,
2132 parent_id: Option<String>,
2133 session_id: String,
2134 timestamp: u64,
2135 turn: u32,
2136 tool_name: String,
2137 tool_call_id: Option<String>,
2140 missing: Vec<String>,
2142 reason: String,
2144}
2145
2146#[derive(Serialize)]
2151struct ProtectedPathDeniedEvent {
2152 event_type: &'static str,
2153 event_id: String,
2154 parent_id: Option<String>,
2155 session_id: String,
2156 timestamp: u64,
2157 turn: u32,
2158 call: String,
2160 target: String,
2162 path: String,
2165 rule: String,
2167 signal: String,
2170 reason: String,
2172}
2173
2174#[derive(Serialize)]
2175struct HookDispatchErrorEvent {
2176 event_type: &'static str,
2177 event_id: String,
2178 parent_id: Option<String>,
2179 session_id: String,
2180 timestamp: u64,
2181 hook_name: String,
2182 event: String,
2183 arm: String,
2184}
2185
2186#[derive(Serialize)]
2190struct FormationEndedEvent {
2191 event_type: &'static str,
2192 event_id: String,
2193 parent_id: Option<String>,
2194 session_id: String,
2195 timestamp: u64,
2196 formation_id: String,
2197}
2198
2199#[derive(Serialize)]
2201struct SessionStatusEvent<'a> {
2202 event_type: &'static str,
2203 event_id: String,
2204 parent_id: Option<String>,
2205 session_id: String,
2206 timestamp: u64,
2207 #[serde(skip_serializing_if = "Option::is_none")]
2209 task_id: Option<String>,
2210 status: &'a crate::session_status::StatusReport,
2211}
2212
2213#[derive(Serialize)]
2215struct DeadlineNoticeEvent<'a> {
2216 event_type: &'static str,
2217 event_id: String,
2218 parent_id: Option<String>,
2219 session_id: String,
2220 timestamp: u64,
2221 #[serde(skip_serializing_if = "Option::is_none")]
2223 task_id: Option<String>,
2224 tool_name: &'a str,
2226 deadline_at: &'a str,
2228 left_secs: u64,
2230 notice_secs: u64,
2232}
2233
2234#[derive(Serialize)]
2237struct DeadlineReachedEvent {
2238 event_type: &'static str,
2239 event_id: String,
2240 parent_id: Option<String>,
2241 session_id: String,
2242 timestamp: u64,
2243 deadline_at: String,
2245 deadline_secs: u64,
2247 source: &'static str,
2249}
2250
2251#[derive(Serialize)]
2258struct SpawnerEndedEvent {
2259 event_type: &'static str,
2260 event_id: String,
2261 parent_id: Option<String>,
2262 session_id: String,
2263 timestamp: u64,
2264 #[serde(skip_serializing_if = "Option::is_none")]
2265 spawned_by: Option<String>,
2266 #[serde(skip_serializing_if = "Option::is_none")]
2267 delegation_id: Option<String>,
2268}
2269
2270#[derive(Serialize)]
2271struct ResourceListEvent {
2272 event_type: &'static str,
2273 event_id: String,
2274 parent_id: Option<String>,
2275 session_id: String,
2276 timestamp: u64,
2277 root: String,
2280 entry_count: usize,
2281 total_bytes: u64,
2282 generation: u64,
2284 containment_achieved: ContainmentClass,
2285 outcome: String,
2287 reason: Option<String>,
2289}
2290
2291#[derive(Serialize)]
2292struct ResourceReadEvent {
2293 event_type: &'static str,
2294 event_id: String,
2295 parent_id: Option<String>,
2296 session_id: String,
2297 timestamp: u64,
2298 path: String,
2301 outcome: String,
2302 bytes: Option<u64>,
2304 sha256: Option<String>,
2306 generation: u64,
2307 containment_achieved: ContainmentClass,
2308 reason: Option<String>,
2309}
2310
2311#[derive(Serialize)]
2313struct PeerHandleMintEvent {
2314 event_type: &'static str,
2315 event_id: String,
2316 parent_id: Option<String>,
2317 session_id: String,
2318 timestamp: u64,
2319 handle_id: Option<String>,
2322 path: String,
2325 audience: String,
2326 expires_at_ms: Option<u64>,
2328 outcome: String,
2329 reason: Option<String>,
2330}
2331
2332#[derive(Serialize)]
2335struct PeerHandleRedeemEvent {
2336 event_type: &'static str,
2337 event_id: String,
2338 parent_id: Option<String>,
2339 session_id: String,
2340 timestamp: u64,
2341 handle_id: String,
2342 path: Option<String>,
2345 generation: u64,
2348 audience_asserted: Option<String>,
2350 bytes: Option<u64>,
2352 sha256: Option<String>,
2354 outcome: String,
2355 reason: Option<String>,
2356}
2357
2358#[derive(Serialize)]
2360struct PeerFileFetchEvent {
2361 event_type: &'static str,
2362 event_id: String,
2363 parent_id: Option<String>,
2364 session_id: String,
2365 timestamp: u64,
2366 peer: String,
2367 handle_id: String,
2368 stored_path: Option<String>,
2371 bytes: Option<u64>,
2372 sha256: Option<String>,
2373 outcome: String,
2374 reason: Option<String>,
2375}
2376
2377#[derive(Serialize)]
2388struct DelegationStartEvent {
2389 event_type: &'static str,
2390 event_id: String,
2391 parent_id: Option<String>,
2392 session_id: String,
2393 timestamp: u64,
2394 delegation_id: String,
2397 capsule: String,
2398 version: String,
2399 child_session_id: String,
2401 child_workdir: String,
2403 child_records_dir: String,
2406 workdir_snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
2410}
2411
2412impl DelegationStartEvent {
2413 #[allow(clippy::too_many_arguments)]
2416 fn new(
2417 session_id: &str,
2418 session_event_id: &str,
2419 delegation_id: &str,
2420 capsule: &str,
2421 version: &str,
2422 child_session_id: &str,
2423 child_workdir: &str,
2424 child_records_dir: &str,
2425 workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
2426 ) -> Self {
2427 Self {
2428 event_type: "delegation_start",
2429 event_id: new_event_id(),
2430 parent_id: Some(session_event_id.to_string()),
2431 session_id: session_id.to_string(),
2432 timestamp: timestamp_ms(),
2433 delegation_id: delegation_id.to_string(),
2434 capsule: capsule.to_string(),
2435 version: version.to_string(),
2436 child_session_id: child_session_id.to_string(),
2437 child_workdir: child_workdir.to_string(),
2438 child_records_dir: child_records_dir.to_string(),
2439 workdir_snapshot: workdir_snapshot.cloned(),
2440 }
2441 }
2442}
2443
2444#[derive(Serialize)]
2450struct MemberCallStartEvent {
2451 event_type: &'static str,
2452 event_id: String,
2453 parent_id: Option<String>,
2454 session_id: String,
2455 timestamp: u64,
2456 task_id: String,
2458 call_id: String,
2460 member: String,
2462 member_task_id: String,
2464}
2465
2466#[derive(Serialize)]
2472struct MemberCallBusyEvent {
2473 event_type: &'static str,
2474 event_id: String,
2475 parent_id: Option<String>,
2476 session_id: String,
2477 timestamp: u64,
2478 task_id: String,
2480 call_id: String,
2482 member: String,
2484 offer: u32,
2486 waited_ms: u64,
2488 message: String,
2490}
2491
2492#[derive(Serialize)]
2498struct MemberCallEvent {
2499 event_type: &'static str,
2500 event_id: String,
2501 parent_id: Option<String>,
2502 session_id: String,
2503 timestamp: u64,
2504 task_id: String,
2505 call_id: String,
2506 member: String,
2507 #[serde(skip_serializing_if = "Option::is_none")]
2509 member_task_id: Option<String>,
2510 status: String,
2513 duration_ms: u64,
2514 output: String,
2516 truncated: bool,
2517 delivered: bool,
2520 #[serde(skip_serializing_if = "Vec::is_empty")]
2523 no_answer_below: Vec<NoAnswerBelow>,
2524}
2525
2526#[derive(Serialize)]
2528struct NoAnswerBelow {
2529 member: String,
2530 status: String,
2532}
2533
2534#[derive(Serialize)]
2545struct DelegationEvent {
2546 event_type: &'static str,
2547 event_id: String,
2548 parent_id: Option<String>,
2549 session_id: String,
2550 timestamp: u64,
2551 capsule: String,
2553 version: String,
2554 delegation_id: Option<String>,
2556 child_session_id: Option<String>,
2558 duration_ms: u64,
2561 outcome: String,
2571 reason: Option<String>,
2574}
2575
2576impl DelegationEvent {
2577 #[allow(clippy::too_many_arguments)]
2580 fn new(
2581 session_id: &str,
2582 session_event_id: &str,
2583 capsule: &str,
2584 version: &str,
2585 delegation_id: Option<String>,
2586 child_session_id: Option<String>,
2587 duration_ms: u64,
2588 outcome: &str,
2589 reason: Option<String>,
2590 ) -> Self {
2591 Self {
2592 event_type: "delegation",
2593 event_id: new_event_id(),
2594 parent_id: Some(session_event_id.to_string()),
2595 session_id: session_id.to_string(),
2596 timestamp: timestamp_ms(),
2597 capsule: capsule.to_string(),
2598 version: version.to_string(),
2599 delegation_id,
2600 child_session_id,
2601 duration_ms,
2602 outcome: outcome.to_string(),
2603 reason,
2604 }
2605 }
2606}
2607
2608impl TraceWriter {
2611 #[allow(clippy::too_many_arguments)]
2612 pub(crate) async fn open(
2613 records_root: &WorkdirRoot,
2614 session_id: String,
2615 capsule_name: String,
2616 capsule_version: String,
2617 model: String,
2618 capabilities: Vec<String>,
2619 effective_grants: ScopeReport,
2620 capture: TraceCapture,
2621 system_prompt: Option<String>,
2622 system_prompt_overridden: bool,
2623 resumed_from: Option<String>,
2624 context_id: Option<String>,
2625 spawned_by: Option<String>,
2626 spawn_delegation_id: Option<String>,
2627 ) -> std::io::Result<Self> {
2628 let chain = crate::record_seal::InlineChain::open(records_root, TRACE_FILE_NAME)?;
2629 let system_prompt_source = match (system_prompt_overridden, system_prompt.is_some()) {
2632 (true, _) => "cli",
2633 (false, true) => "manifest",
2634 (false, false) => "none",
2635 };
2636 let system_prompt_sha256 = system_prompt
2637 .as_ref()
2638 .map(|prompt| murmur_artifact::sha256_hex(prompt.as_bytes()));
2639 let blobs = BlobStore::new(records_root);
2640 if let (true, Some(prompt), Some(sha)) = (
2644 capture.captures_content(),
2645 system_prompt.as_ref(),
2646 system_prompt_sha256.as_deref(),
2647 ) {
2648 blobs.put(sha, prompt.as_bytes()).await?;
2649 }
2650 Ok(Self {
2651 chain,
2652 records_root: records_root.clone(),
2653 sealed_head: None,
2654 session_id,
2655 capsule_name,
2656 capsule_version,
2657 model,
2658 capabilities,
2659 effective_grants,
2660 capture,
2661 blobs,
2662 system_prompt_source,
2663 system_prompt_sha256,
2664 credential_source: "none",
2665 gateways: Vec::new(),
2666 control: None,
2667 inference_choices: Vec::new(),
2668 tool_refresh: None,
2669 runtime_artifacts: Vec::new(),
2670 host_facts: Vec::new(),
2671 instruction_files: None,
2672 resumed_from,
2673 context_id,
2674 spawned_by,
2675 spawn_delegation_id,
2676 formation_id: None,
2677 formation_member: None,
2678 session_start_time: Instant::now(),
2679 session_event_id: new_event_id(),
2680 session_started: false,
2681 session_ended: false,
2682 task_event_id: None,
2683 turn_event_id: None,
2684 total_turns: 0,
2685 total_input_tokens: 0,
2686 total_output_tokens: 0,
2687 total_tool_calls: 0,
2688 total_shell_calls: 0,
2689 task_turns: 0,
2690 task_input_tokens: 0,
2691 task_output_tokens: 0,
2692 task_tool_calls: 0,
2693 task_shell_calls: 0,
2694 task_agent_turns: 0,
2695 task_hook_inferences: 0,
2696 task_capped_turns: 0,
2697 task_start_instant: None,
2698 active_task_id: None,
2699 task_failures_written: 0,
2700 last_task_failure: None,
2701 max_session_tokens: None,
2702 machine_tokens_per_day: None,
2703 session_status: None,
2704 call_deadlines: None,
2705 member_requests: None,
2706 })
2707 }
2708
2709 pub(crate) fn set_call_deadlines(&mut self, deadlines: crate::limits::CallDeadlines) {
2712 self.call_deadlines = Some(deadlines);
2713 }
2714
2715 pub(crate) fn set_member_requests(&mut self, requests: crate::member_request::MemberRequests) {
2718 self.member_requests = Some(requests);
2719 }
2720
2721 pub(crate) fn set_session_status(
2724 &mut self,
2725 status: std::sync::Arc<crate::session_status::SessionStatus>,
2726 ) {
2727 self.session_status = Some(status);
2728 }
2729
2730 pub(crate) fn set_spend_ceilings(
2732 &mut self,
2733 max_session_tokens: Option<u64>,
2734 machine_tokens_per_day: Option<u64>,
2735 ) {
2736 self.max_session_tokens = max_session_tokens;
2737 self.machine_tokens_per_day = machine_tokens_per_day;
2738 }
2739
2740 pub(crate) fn session_event_id(&self) -> &str {
2744 &self.session_event_id
2745 }
2746
2747 pub(crate) fn captures_wire(&self) -> bool {
2751 self.capture.captures_hashes()
2752 }
2753
2754 fn session_parent(&self) -> Option<String> {
2758 self.session_started.then(|| self.session_event_id.clone())
2759 }
2760
2761 fn task_parent(&self) -> Option<String> {
2764 self.task_event_id.clone().or_else(|| self.session_parent())
2765 }
2766
2767 fn turn_parent(&self) -> Option<String> {
2771 self.turn_event_id.clone().or_else(|| self.task_parent())
2772 }
2773
2774 pub(crate) fn set_credential_source(&mut self, source: &'static str) {
2777 self.credential_source = source;
2778 }
2779
2780 pub(crate) fn set_gateways(&mut self, gateways: Vec<SessionGateway>) {
2782 self.gateways = gateways;
2783 }
2784
2785 pub(crate) fn set_control(&mut self, control: Option<SessionControl>) {
2787 self.control = control;
2788 }
2789
2790 pub(crate) fn set_inference_choices(&mut self, choices: Vec<SessionInferenceChoice>) {
2793 self.inference_choices = choices;
2794 }
2795
2796 pub(crate) fn set_runtime_artifacts(&mut self, installed: &[InstalledArtifactSummary]) {
2799 self.runtime_artifacts = installed
2800 .iter()
2801 .filter_map(|artifact| match &artifact.origin {
2802 LockOrigin::Runtime { session } => Some(RuntimeArtifact {
2803 name: artifact.name.clone(),
2804 version: artifact.version.clone(),
2805 origin: artifact.origin.as_str(),
2806 session: session.clone(),
2807 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
2808 }),
2809 LockOrigin::Operator => None,
2810 })
2811 .collect();
2812 }
2813
2814 pub(crate) fn set_host_facts(&mut self, uses: &[crate::host_facts::HostFactsUse]) {
2816 self.host_facts = uses.iter().map(HostFactsRecord::of).collect();
2817 }
2818
2819 pub(crate) async fn write_control_applied(
2826 &mut self,
2827 turn: u32,
2828 name: &str,
2829 value: Value,
2830 change_id: &str,
2831 ) -> std::io::Result<()> {
2832 let event = ControlAppliedEvent {
2833 event_type: "control_applied",
2834 event_id: new_event_id(),
2835 parent_id: self.task_parent(),
2836 session_id: self.session_id.clone(),
2837 timestamp: timestamp_ms(),
2838 turn,
2839 task_id: self.active_task_id.clone(),
2840 name,
2841 value,
2842 change_id,
2843 };
2844 self.write_event(&event).await
2845 }
2846
2847 pub(crate) fn set_formation_id(&mut self, formation_id: Option<&FormationId>) {
2849 self.formation_id = formation_id.cloned();
2850 }
2851
2852 pub(crate) fn set_formation_member(
2855 &mut self,
2856 member: Option<&crate::formation_credentials::FormationMember>,
2857 ) {
2858 self.formation_member = member.map(|member| {
2859 (
2860 member.name().to_string(),
2861 member.callees().map(str::to_string).collect(),
2862 )
2863 });
2864 }
2865
2866 pub(crate) fn set_instruction_files(
2869 &mut self,
2870 files: Option<&crate::instruction_files::InstructionFiles>,
2871 ) {
2872 self.instruction_files =
2873 files.map(crate::instruction_files::InstructionFiles::trace_records);
2874 }
2875
2876 pub(crate) fn set_tool_refresh(&mut self, tool_refresh: Option<&'static str>) {
2879 self.tool_refresh = tool_refresh;
2880 }
2881
2882 pub(crate) async fn write_tools_refreshed(
2890 &mut self,
2891 turn: u32,
2892 trigger: &str,
2893 added: &[String],
2894 removed: &[String],
2895 tools: &[String],
2896 ) -> std::io::Result<()> {
2897 let event = ToolsRefreshedEvent {
2898 event_type: "tools_refreshed",
2899 event_id: new_event_id(),
2900 parent_id: self.task_parent(),
2901 session_id: self.session_id.clone(),
2902 timestamp: timestamp_ms(),
2903 turn,
2904 task_id: self.active_task_id.clone(),
2905 trigger,
2906 added,
2907 removed,
2908 tools,
2909 };
2910 self.write_event(&event).await
2911 }
2912
2913 pub(crate) async fn write_session_start(
2914 &mut self,
2915 max_turns: Option<u32>,
2916 model_limits: Option<crate::model_limits::ResolvedModelLimits>,
2917 tools_declared: Vec<String>,
2918 ) -> std::io::Result<()> {
2919 let event = SessionStartEvent {
2920 event_type: "session_start",
2921 event_id: self.session_event_id.clone(),
2922 parent_id: None,
2923 session_id: self.session_id.clone(),
2924 timestamp: timestamp_ms(),
2925 capsule_name: self.capsule_name.clone(),
2926 capsule_version: self.capsule_version.clone(),
2927 model: self.model.clone(),
2928 max_turns,
2929 model_limits,
2930 max_session_tokens: self.max_session_tokens,
2931 machine_tokens_per_day: self.machine_tokens_per_day,
2932 deadline: self.session_status.as_ref().and_then(|status| {
2933 status.deadline().map(|deadline| SessionStartDeadline {
2934 at: deadline.at_rfc3339(),
2935 secs: deadline.secs(),
2936 notice_secs: status.notice_secs(),
2937 source: deadline.source().as_str(),
2938 })
2939 }),
2940 capabilities: self.capabilities.clone(),
2941 tools_declared,
2942 containment_declared: self.effective_grants.declared_containment,
2943 containment_achieved: self.effective_grants.achieved_containment,
2944 workdir_exec: self.effective_grants.workdir_exec,
2945 userns_grant: self.effective_grants.userns_grant,
2946 effective_grants: self.effective_grants.clone(),
2947 system_prompt_source: self.system_prompt_source,
2948 system_prompt_sha256: self.system_prompt_sha256.clone(),
2949 credential_source: self.credential_source,
2950 gateways: self.gateways.clone(),
2951 control: self.control.clone(),
2952 inference_choices: self.inference_choices.clone(),
2953 tool_refresh: self.tool_refresh,
2954 runtime_artifacts: self.runtime_artifacts.clone(),
2955 host_facts: self.host_facts.clone(),
2956 instruction_files: self.instruction_files.clone(),
2957 resumed_from: self.resumed_from.clone(),
2958 context_id: self.context_id.clone(),
2959 spawned_by: self.spawned_by.clone(),
2960 delegation_id: self.spawn_delegation_id.clone(),
2961 formation_id: self.formation_id.clone(),
2962 formation_member: self.formation_member.as_ref().map(|(name, _)| name.clone()),
2963 formation_callees: self
2964 .formation_member
2965 .as_ref()
2966 .map(|(_, callees)| callees.clone()),
2967 };
2968 self.write_event(&event).await?;
2969 self.session_started = true;
2970 self.write_latched().await
2972 }
2973
2974 #[allow(clippy::too_many_arguments)]
2987 pub(crate) async fn write_inference(
2988 &mut self,
2989 turn: u32,
2990 input_tokens: Option<u64>,
2991 output_tokens: Option<u64>,
2992 decision: String,
2993 stop_reason: Option<&str>,
2994 tool_name: Option<String>,
2995 origin: Option<&InferenceOrigin>,
2996 usage: Option<&DriverUsage>,
2997 message_ids: Vec<String>,
2998 wire: Option<&WireCapture>,
2999 settings: Option<&InferenceCallSettings>,
3000 ) -> std::io::Result<()> {
3001 self.write_inference_event(InferenceLine {
3002 turn,
3003 input_tokens,
3004 output_tokens,
3005 decision,
3006 stop_reason,
3007 tool_name,
3008 origin,
3009 usage,
3010 message_ids,
3011 wire,
3012 choice: None,
3013 failure: None,
3014 counted: true,
3015 settings,
3016 retry: RetryNote::default(),
3017 gateway: None,
3018 })
3019 .await
3020 }
3021
3022 #[allow(clippy::too_many_arguments)]
3025 pub(crate) async fn write_hook_inference(
3026 &mut self,
3027 turn: u32,
3028 input_tokens: Option<u64>,
3029 output_tokens: Option<u64>,
3030 decision: String,
3031 origin: &InferenceOrigin,
3032 usage: Option<&DriverUsage>,
3033 settings: Option<&InferenceCallSettings>,
3034 retry: RetryNote,
3035 gateway: Option<GatewayOffsets>,
3036 ) -> std::io::Result<()> {
3037 self.write_inference_event(InferenceLine {
3038 turn,
3039 input_tokens,
3040 output_tokens,
3041 decision,
3042 stop_reason: None,
3043 tool_name: None,
3044 origin: Some(origin),
3045 usage,
3046 message_ids: Vec::new(),
3047 wire: None,
3048 choice: None,
3049 failure: None,
3050 counted: true,
3051 settings,
3052 retry,
3053 gateway,
3054 })
3055 .await
3056 }
3057
3058 #[allow(clippy::too_many_arguments)]
3062 pub(crate) async fn write_turn_inference(
3063 &mut self,
3064 turn: u32,
3065 input_tokens: Option<u64>,
3066 output_tokens: Option<u64>,
3067 decision: String,
3068 stop_reason: Option<&str>,
3069 tool_name: Option<String>,
3070 usage: Option<&DriverUsage>,
3071 message_ids: Vec<String>,
3072 wire: Option<&WireCapture>,
3073 choice: Option<&crate::driver_choice::DriverChoice>,
3074 settings: Option<&InferenceCallSettings>,
3075 retry: RetryNote,
3076 gateway: Option<GatewayOffsets>,
3077 ) -> std::io::Result<()> {
3078 if stop_reason == Some("max_tokens") {
3079 self.task_capped_turns = self.task_capped_turns.saturating_add(1);
3080 }
3081 self.write_inference_event(InferenceLine {
3082 turn,
3083 input_tokens,
3084 output_tokens,
3085 decision,
3086 stop_reason,
3087 tool_name,
3088 origin: None,
3089 usage,
3090 message_ids,
3091 wire,
3092 choice,
3093 failure: None,
3094 counted: true,
3095 settings,
3096 retry,
3097 gateway,
3098 })
3099 .await
3100 }
3101
3102 pub(crate) async fn write_failed_inference(
3110 &mut self,
3111 failed: FailedInference<'_>,
3112 ) -> std::io::Result<()> {
3113 let error =
3114 crate::agent::truncate_on_char_boundary(failed.error, MAX_TASK_FAILURE_REASON_BYTES)
3115 .to_string();
3116 let (input_tokens, output_tokens) = failed.spent.unwrap_or((None, None));
3117 self.write_inference_event(InferenceLine {
3118 turn: failed.turn,
3119 input_tokens,
3120 output_tokens,
3121 decision: "error".to_string(),
3122 stop_reason: Some("error"),
3123 tool_name: failed.tool_name,
3124 origin: failed.origin,
3125 usage: failed.usage,
3126 message_ids: failed.message_ids,
3127 wire: None,
3128 choice: failed.choice,
3129 failure: Some((failed.error_code, error, failed.provider_status)),
3130 counted: failed.spent.is_some(),
3131 settings: failed.settings,
3132 retry: failed.retry,
3133 gateway: failed.gateway,
3134 })
3135 .await
3136 }
3137
3138 async fn write_inference_event(&mut self, line: InferenceLine<'_>) -> std::io::Result<()> {
3139 let InferenceLine {
3140 turn,
3141 input_tokens,
3142 output_tokens,
3143 decision,
3144 stop_reason,
3145 tool_name,
3146 origin,
3147 usage,
3148 message_ids,
3149 wire,
3150 choice,
3151 failure,
3152 counted,
3153 settings,
3154 retry,
3155 gateway,
3156 } = line;
3157 let (error_code, error, provider_status) = match failure {
3158 Some((code, error, status)) => (Some(code.to_string()), Some(error), status),
3159 None => (None, None, None),
3160 };
3161 self.write_latched().await?;
3163 let event_id = new_event_id();
3164 let parent_id = if origin.is_none() {
3168 let parent = self.task_parent();
3169 self.turn_event_id = Some(event_id.clone());
3170 parent
3171 } else {
3172 self.turn_parent()
3173 };
3174 let (system_sha, tools_sha, response_sha, message_shas) =
3175 match wire.filter(|_| self.capture.captures_hashes()) {
3176 Some(wire) => self.record_wire(wire).await?,
3177 None => (None, None, None, Vec::new()),
3178 };
3179 let event = InferenceEvent {
3180 event_type: "inference",
3181 event_id,
3182 parent_id,
3183 session_id: self.session_id.clone(),
3184 timestamp: timestamp_ms(),
3185 turn,
3186 task_id: self.active_task_id.clone(),
3187 input_tokens,
3188 output_tokens,
3189 decision,
3190 stop_reason: stop_reason.map(str::to_string),
3191 tool_name,
3192 input_tokens_actual: usage.and_then(|u| u.input_tokens),
3193 output_tokens_actual: usage.and_then(|u| u.output_tokens),
3194 cached_tokens: usage.and_then(|u| u.cached_tokens),
3195 cache_write_tokens: usage.and_then(|u| u.cache_write_tokens),
3196 thinking_tokens: usage.and_then(|u| u.thinking_tokens),
3197 origin: origin.map(|o| o.source.clone()),
3198 driver_choice: choice.map(|choice| choice.name.clone()),
3199 model: origin
3200 .map(|o| o.model.clone())
3201 .or_else(|| choice.map(|choice| choice.model.clone())),
3202 max_output_tokens: settings.map(|settings| settings.max_output_tokens),
3203 input_budget: settings.map(|settings| settings.input_budget),
3204 driver_config: settings.map(|settings| settings.driver_config.clone()),
3205 message_ids,
3206 system_sha,
3207 tools_sha,
3208 response_sha,
3209 message_shas,
3210 error_code,
3211 error,
3212 provider_status,
3213 gateway_reached_ms: gateway.map(|gateway| gateway.reached_ms),
3214 upstream_responded_ms: gateway.map(|gateway| gateway.responded_ms),
3215 attempts: retry.attempts,
3216 retry_stopped: retry.stopped.map(RetryStop::wire_name),
3217 };
3218 self.write_event(&event).await?;
3219 if origin.is_none() {
3220 self.task_agent_turns = self.task_agent_turns.saturating_add(1);
3221 }
3222 if counted {
3223 let (input, output) = (input_tokens.unwrap_or(0), output_tokens.unwrap_or(0));
3227 self.total_turns = self.total_turns.saturating_add(1);
3228 self.total_input_tokens = self.total_input_tokens.saturating_add(input);
3229 self.total_output_tokens = self.total_output_tokens.saturating_add(output);
3230 self.task_turns = self.task_turns.saturating_add(1);
3231 if origin.is_some() {
3232 self.task_hook_inferences = self.task_hook_inferences.saturating_add(1);
3233 }
3234 self.task_input_tokens = self.task_input_tokens.saturating_add(input);
3235 self.task_output_tokens = self.task_output_tokens.saturating_add(output);
3236 }
3237 if let Some(status) = &self.session_status {
3240 status.record_inference(self.task_turns, input_tokens, output_tokens, usage);
3241 }
3242 Ok(())
3243 }
3244
3245 async fn record_wire(
3252 &mut self,
3253 wire: &WireCapture,
3254 ) -> std::io::Result<(Option<String>, Option<String>, Option<String>, Vec<String>)> {
3255 let system_sha = murmur_artifact::sha256_hex(&wire.system);
3256 let tools_sha = murmur_artifact::sha256_hex(&wire.tools);
3257 let response_sha = murmur_artifact::sha256_hex(&wire.response);
3258 let message_shas: Vec<String> = wire
3259 .messages
3260 .iter()
3261 .map(|message| murmur_artifact::sha256_hex(message))
3262 .collect();
3263
3264 if self.capture.captures_content() {
3265 self.blobs.put(&system_sha, &wire.system).await?;
3269 self.blobs.put(&tools_sha, &wire.tools).await?;
3270 self.blobs.put(&response_sha, &wire.response).await?;
3271 for (sha, body) in message_shas.iter().zip(&wire.messages) {
3272 self.blobs.put(sha, body).await?;
3273 }
3274 }
3275
3276 Ok((
3277 Some(system_sha),
3278 Some(tools_sha),
3279 Some(response_sha),
3280 message_shas,
3281 ))
3282 }
3283
3284 #[allow(clippy::too_many_arguments)]
3285 pub(crate) async fn write_tool_call(
3286 &mut self,
3287 turn: u32,
3288 tool_name: String,
3289 tool_call_id: Option<String>,
3290 input: Value,
3291 input_bytes: u64,
3292 output: &str,
3293 output_bytes: u64,
3294 duration_ms: u64,
3295 status: String,
3296 state_effect: Option<String>,
3297 resource_id: Option<String>,
3298 error: Option<&str>,
3299 ) -> std::io::Result<()> {
3300 self.write_tool_call_with_attachments(
3301 turn,
3302 tool_name,
3303 tool_call_id,
3304 input,
3305 input_bytes,
3306 output,
3307 output_bytes,
3308 duration_ms,
3309 status,
3310 state_effect,
3311 resource_id,
3312 error,
3313 Vec::new(),
3314 )
3315 .await
3316 }
3317
3318 #[allow(clippy::too_many_arguments)]
3321 pub(crate) async fn write_tool_call_with_attachments(
3322 &mut self,
3323 turn: u32,
3324 tool_name: String,
3325 tool_call_id: Option<String>,
3326 mut input: Value,
3327 input_bytes: u64,
3328 output: &str,
3329 output_bytes: u64,
3330 duration_ms: u64,
3331 status: String,
3332 state_effect: Option<String>,
3333 resource_id: Option<String>,
3334 error: Option<&str>,
3335 attachments: Vec<AttachmentRecord>,
3336 ) -> std::io::Result<()> {
3337 crate::peer_handoff::redact_handles_in_json(&mut input);
3342 let event = ToolCallEvent {
3343 event_type: "tool_call",
3344 event_id: new_event_id(),
3345 parent_id: self.turn_parent(),
3346 session_id: self.session_id.clone(),
3347 timestamp: timestamp_ms(),
3348 turn,
3349 task_id: self.active_task_id.clone(),
3350 tool_name,
3351 tool_call_id: tool_call_id.filter(|id| !id.is_empty()),
3354 input,
3355 input_bytes,
3356 output: self
3357 .capture
3358 .captures_content()
3359 .then(|| crate::peer_handoff::redact_handle_tokens(output).into_owned()),
3360 output_bytes,
3361 duration_ms,
3362 status,
3363 error: error.map(|error| {
3364 let redacted = crate::peer_handoff::redact_handle_tokens(error);
3365 crate::agent::truncate_on_char_boundary(&redacted, MAX_TASK_FAILURE_REASON_BYTES)
3366 .to_string()
3367 }),
3368 state_effect,
3369 resource_id,
3370 attachments,
3371 };
3372 self.write_event(&event).await?;
3373 self.total_tool_calls = self.total_tool_calls.saturating_add(1);
3374 self.task_tool_calls = self.task_tool_calls.saturating_add(1);
3375 Ok(())
3376 }
3377
3378 pub(crate) async fn write_skill_call(
3384 &mut self,
3385 turn: u32,
3386 skill_name: String,
3387 origin: &LockOrigin,
3388 output_bytes: u64,
3389 duration_ms: u64,
3390 status: String,
3391 ) -> std::io::Result<()> {
3392 let event = SkillCallEvent {
3393 event_type: "skill_call",
3394 event_id: new_event_id(),
3395 parent_id: self.turn_parent(),
3396 session_id: self.session_id.clone(),
3397 timestamp: timestamp_ms(),
3398 turn,
3399 task_id: self.active_task_id.clone(),
3400 skill_name,
3401 output_bytes,
3402 duration_ms,
3403 status,
3404 origin: origin.as_str(),
3405 trust: crate::origin::artifact_trust(origin).as_str(),
3406 };
3407 self.write_event(&event).await
3408 }
3410
3411 #[allow(clippy::too_many_arguments)]
3414 pub(crate) async fn write_shell(
3415 &mut self,
3416 turn: u32,
3417 binary: String,
3418 command: String,
3419 exit_code: i32,
3420 stdout_bytes: u64,
3421 stderr_bytes: u64,
3422 duration_ms: u64,
3423 resource_limit: Option<String>,
3424 ) -> std::io::Result<()> {
3425 let event = ShellEvent {
3426 event_type: "shell",
3427 event_id: new_event_id(),
3428 parent_id: self.turn_parent(),
3429 session_id: self.session_id.clone(),
3430 timestamp: timestamp_ms(),
3431 turn,
3432 task_id: self.active_task_id.clone(),
3433 binary,
3434 command,
3435 exit_code,
3436 stdout_bytes,
3437 stderr_bytes,
3438 duration_ms,
3439 resource_limit,
3440 };
3441 self.write_event(&event).await?;
3442 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3443 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3444 Ok(())
3445 }
3446
3447 pub(crate) async fn write_shell_detached(
3454 &mut self,
3455 turn: u32,
3456 work_id: &str,
3457 binary: &str,
3458 command: &str,
3459 grace_ms: Option<u64>,
3460 ) -> std::io::Result<()> {
3461 let event = ShellDetachedEvent {
3462 event_type: "shell_detached",
3463 event_id: new_event_id(),
3464 parent_id: self.turn_parent(),
3465 session_id: self.session_id.clone(),
3466 timestamp: timestamp_ms(),
3467 turn,
3468 task_id: self.active_task_id.clone(),
3469 work_id: work_id.to_string(),
3470 binary: binary.to_string(),
3471 command: command.to_string(),
3472 grace_ms,
3473 };
3474 self.write_event(&event).await?;
3475 self.total_shell_calls = self.total_shell_calls.saturating_add(1);
3476 self.task_shell_calls = self.task_shell_calls.saturating_add(1);
3477 Ok(())
3478 }
3479
3480 pub(crate) async fn write_shell_completed(
3483 &mut self,
3484 completion: &crate::detached::DetachedCompletion,
3485 completion_task_id: &str,
3486 ) -> std::io::Result<()> {
3487 let event = ShellCompletedEvent::new(
3488 self.session_id.clone(),
3489 self.session_parent(),
3490 completion,
3491 crate::detached::Delivery::Task,
3492 Some(completion_task_id),
3493 );
3494 self.write_event(&event).await
3495 }
3496
3497 pub(crate) async fn write_shell_abandoned(
3504 &mut self,
3505 work_id: &str,
3506 binary: &str,
3507 command: &str,
3508 running_ms: u64,
3509 recovered: Option<(i32, &str, u64)>,
3510 ) -> std::io::Result<()> {
3511 let event = ShellAbandonedEvent {
3512 event_type: "shell_abandoned",
3513 event_id: new_event_id(),
3514 parent_id: self.session_parent(),
3515 session_id: self.session_id.clone(),
3516 timestamp: timestamp_ms(),
3517 work_id: work_id.to_string(),
3518 binary: binary.to_string(),
3519 command: command.to_string(),
3520 running_ms,
3521 exit_code: recovered.map(|(exit_code, _, _)| exit_code),
3522 output_path: recovered.map(|(_, path, _)| path.to_string()),
3523 output_bytes: recovered.map(|(_, _, bytes)| bytes),
3524 };
3525 self.write_event(&event).await
3526 }
3527
3528 pub(crate) async fn write_shell_detach_unrecorded(
3530 &mut self,
3531 turn: u32,
3532 work_id: &str,
3533 binary: &str,
3534 reason: &str,
3535 ) -> std::io::Result<()> {
3536 let event = ShellDetachUnrecordedEvent {
3537 event_type: "shell_detach_unrecorded",
3538 event_id: new_event_id(),
3539 parent_id: self.turn_parent(),
3540 session_id: self.session_id.clone(),
3541 timestamp: timestamp_ms(),
3542 turn,
3543 task_id: self.active_task_id.clone(),
3544 work_id: work_id.to_string(),
3545 binary: binary.to_string(),
3546 reason: reason.to_string(),
3547 };
3548 self.write_event(&event).await
3549 }
3550
3551 pub(crate) async fn write_safety_check(
3553 &mut self,
3554 turn: u32,
3555 tool_call_id: &str,
3556 tool: &str,
3557 checks: &serde_json::Value,
3558 outcome: &'static str,
3559 ) -> std::io::Result<()> {
3560 let event = SafetyCheckEvent {
3561 event_type: "safety_check",
3562 event_id: new_event_id(),
3563 parent_id: self.turn_parent(),
3564 session_id: self.session_id.clone(),
3565 timestamp: timestamp_ms(),
3566 turn,
3567 task_id: self.active_task_id.clone(),
3568 tool_call_id: tool_call_id.to_string(),
3569 tool: tool.to_string(),
3570 checks: checks.clone(),
3571 outcome,
3572 };
3573 self.write_event(&event).await
3574 }
3575
3576 #[allow(clippy::too_many_arguments)]
3581 pub(crate) async fn write_a2a_task_received(
3582 &mut self,
3583 task_id: &str,
3584 context_id: &str,
3585 message_id: &str,
3586 traceparent_from_caller: Option<&str>,
3587 caller: &crate::identity::TaskCaller,
3588 caller_member: Option<&str>,
3589 member_request_id: Option<&str>,
3590 message_text: &str,
3591 ) -> std::io::Result<()> {
3592 use crate::identity::TaskCaller;
3593 let (received_from, caller_credential) = match (caller_member, caller) {
3594 (Some(_), _) => (RECEIVED_FROM_MEMBER, None),
3595 (None, TaskCaller::Operator) => (RECEIVED_FROM_OPERATOR, None),
3596 (None, TaskCaller::Public) => (RECEIVED_FROM_PUBLIC, None),
3597 (None, TaskCaller::Credential(name)) => {
3598 (RECEIVED_FROM_CREDENTIAL, Some(name.to_string()))
3599 }
3600 };
3601 let message_sha256 = if self.capture.captures_hashes() {
3602 let sha = murmur_artifact::sha256_hex(message_text.as_bytes());
3603 if self.capture.captures_content() {
3604 self.blobs.put(&sha, message_text.as_bytes()).await?;
3605 }
3606 Some(sha)
3607 } else {
3608 None
3609 };
3610 let event = A2aTaskReceivedEvent {
3611 event_type: "a2a_task_received",
3612 event_id: new_event_id(),
3613 parent_id: self.session_parent(),
3614 session_id: self.session_id.clone(),
3615 timestamp: timestamp_ms(),
3616 task_id: task_id.to_string(),
3617 context_id: context_id.to_string(),
3618 message_id: message_id.to_string(),
3619 traceparent_from_caller: traceparent_from_caller.map(str::to_string),
3620 caller: received_from,
3621 caller_member: caller_member.map(str::to_string),
3622 caller_credential,
3623 member_request_id: member_request_id.map(str::to_string),
3624 message_sha256,
3625 };
3626 self.write_event(&event).await
3627 }
3628
3629 pub(crate) async fn write_a2a_send(
3630 &mut self,
3631 peer_url: &str,
3632 message_id: &str,
3633 task_id: &str,
3634 context_id: &str,
3635 traceparent: Option<&str>,
3636 trust: TrustClass,
3637 ) -> std::io::Result<()> {
3638 let event = A2aSendEvent {
3639 event_type: "a2a_send",
3640 event_id: new_event_id(),
3641 parent_id: self.session_parent(),
3642 session_id: self.session_id.clone(),
3643 timestamp: timestamp_ms(),
3644 peer_url: peer_url.to_string(),
3645 message_id: message_id.to_string(),
3646 task_id: task_id.to_string(),
3647 context_id: context_id.to_string(),
3648 traceparent: traceparent.map(str::to_string),
3649 trust: trust.as_str().to_string(),
3650 };
3651 self.write_event(&event).await
3652 }
3653
3654 pub(crate) async fn write_artifact_pulled(
3657 &mut self,
3658 pulled: &crate::runtime::PulledArtifact,
3659 ) -> std::io::Result<()> {
3660 let artifact = &pulled.summary;
3661 let session = match &artifact.origin {
3662 LockOrigin::Runtime { session } => Some(session.clone()),
3663 LockOrigin::Operator => None,
3664 };
3665 let event = ArtifactPulledEvent {
3666 event_type: "artifact_pulled",
3667 event_id: new_event_id(),
3668 parent_id: self.session_parent(),
3669 session_id: self.session_id.clone(),
3670 timestamp: timestamp_ms(),
3671 name: artifact.name.clone(),
3672 version: artifact.version.clone(),
3673 runtime: artifact.runtime.as_str(),
3674 origin: artifact.origin.as_str(),
3675 session,
3676 trust: crate::origin::artifact_trust(&artifact.origin).as_str(),
3677 fetched_from: pulled.fetched_from.clone(),
3678 owner_id: pulled.owner_id,
3679 };
3680 self.write_event(&event).await
3681 }
3682
3683 #[cfg(test)]
3686 #[allow(clippy::too_many_arguments)]
3687 pub(crate) async fn write_task_start(
3688 &mut self,
3689 task_id: &str,
3690 context_id: &str,
3691 source: &str,
3692 provenance: TaskProvenance,
3693 message_parts_bytes: u64,
3694 part_kinds: &[&'static str],
3695 reference_task_ids: &[String],
3696 ) -> std::io::Result<()> {
3697 self.write_task_start_with_attachments(
3698 task_id,
3699 context_id,
3700 source,
3701 provenance,
3702 message_parts_bytes,
3703 part_kinds,
3704 reference_task_ids,
3705 Vec::new(),
3706 )
3707 .await
3708 }
3709
3710 #[allow(clippy::too_many_arguments)]
3713 pub(crate) async fn write_task_start_with_attachments(
3714 &mut self,
3715 task_id: &str,
3716 context_id: &str,
3717 source: &str,
3718 provenance: TaskProvenance,
3719 message_parts_bytes: u64,
3720 part_kinds: &[&'static str],
3721 reference_task_ids: &[String],
3722 attachments: Vec<AttachmentRecord>,
3723 ) -> std::io::Result<()> {
3724 self.write_latched().await?;
3726 self.task_turns = 0;
3728 self.task_input_tokens = 0;
3729 self.task_output_tokens = 0;
3730 self.task_tool_calls = 0;
3731 self.task_shell_calls = 0;
3732 self.task_agent_turns = 0;
3733 self.task_hook_inferences = 0;
3734 self.task_capped_turns = 0;
3735 let started = Instant::now();
3736 self.task_start_instant = Some(started);
3737 self.active_task_id = Some(task_id.to_string());
3738 if let Some(status) = &self.session_status {
3739 status.task_started(task_id, started);
3740 }
3741
3742 let event_id = new_event_id();
3743 self.task_event_id = Some(event_id.clone());
3744 self.turn_event_id = None;
3745
3746 let event = TaskStartEvent {
3747 event_type: "task_start",
3748 event_id: event_id.clone(),
3749 parent_id: self.session_parent(),
3750 session_id: self.session_id.clone(),
3751 timestamp: timestamp_ms(),
3752 task_id: task_id.to_string(),
3753 context_id: context_id.to_string(),
3754 source: source.to_string(),
3755 origin: provenance.origin().as_str().to_string(),
3756 trust: provenance.trust().as_str().to_string(),
3757 lane: TaskLane::for_origin(provenance.origin())
3760 .as_str()
3761 .to_string(),
3762 message_parts_bytes,
3763 part_kinds: part_kinds.to_vec(),
3764 reference_task_ids: reference_task_ids.to_vec(),
3765 attachments,
3766 };
3767 self.write_event(&event).await
3768 }
3769
3770 pub(crate) async fn write_task_end(
3771 &mut self,
3772 task_id: &str,
3773 exit_status: &str,
3774 reopen_count: u32,
3775 running_commands: Vec<String>,
3776 keep_working: Option<KeepWorkingSummary>,
3777 done_check: Option<DoneCheckSummary>,
3778 ) -> std::io::Result<()> {
3779 self.write_latched().await?;
3781 let duration_ms = self
3782 .task_start_instant
3783 .take()
3784 .map(|t| t.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
3785 .unwrap_or(0);
3786
3787 let event = TaskEndEvent {
3788 event_type: "task_end",
3789 event_id: new_event_id(),
3790 parent_id: self.task_parent(),
3791 session_id: self.session_id.clone(),
3792 timestamp: timestamp_ms(),
3793 task_id: task_id.to_string(),
3794 exit_status: exit_status.to_string(),
3795 duration_ms,
3796 turns: self.task_turns,
3797 input_tokens: self.task_input_tokens,
3798 output_tokens: self.task_output_tokens,
3799 tool_calls: self.task_tool_calls,
3800 shell_calls: self.task_shell_calls,
3801 reopen_count,
3802 capped_turns: self.task_capped_turns,
3803 running_commands,
3804 keep_working,
3805 done_check,
3806 };
3807 self.active_task_id = None;
3808 self.task_event_id = None;
3809 self.turn_event_id = None;
3810 self.task_agent_turns = 0;
3811 if let Some(status) = &self.session_status {
3812 status.task_ended();
3813 }
3814 self.write_event(&event).await
3815 }
3816
3817 pub(crate) async fn write_task_canceled(
3823 &mut self,
3824 task_id: &str,
3825 turn: Option<u32>,
3826 phase: &str,
3827 detached_work_ids: Vec<String>,
3828 delegation_ids: Vec<String>,
3829 ) -> std::io::Result<()> {
3830 let event = TaskCanceledEvent {
3831 event_type: "task_canceled",
3832 event_id: new_event_id(),
3833 parent_id: self.task_parent(),
3834 session_id: self.session_id.clone(),
3835 timestamp: timestamp_ms(),
3836 task_id: task_id.to_string(),
3837 turn,
3838 phase: phase.to_string(),
3839 detached_work_ids,
3840 delegation_ids,
3841 };
3842 self.write_event(&event).await
3843 }
3844
3845 pub(crate) async fn write_task_rejected(
3851 &mut self,
3852 task_id: &str,
3853 context_id: &str,
3854 source: &str,
3855 cause: &str,
3856 reason: &str,
3857 ) -> std::io::Result<()> {
3858 let event = TaskRejectedEvent {
3859 event_type: "task_rejected",
3860 event_id: new_event_id(),
3861 parent_id: self.task_parent(),
3862 session_id: self.session_id.clone(),
3863 timestamp: timestamp_ms(),
3864 task_id: task_id.to_string(),
3865 context_id: context_id.to_string(),
3866 source: source.to_string(),
3867 cause: cause.to_string(),
3868 reason: reason.to_string(),
3869 };
3870 self.write_event(&event).await
3871 }
3872
3873 pub(crate) async fn write_task_failed(
3879 &mut self,
3880 turn: Option<u32>,
3881 cause: &str,
3882 reason: &str,
3883 ) -> std::io::Result<()> {
3884 let reason = crate::agent::truncate_on_char_boundary(reason, MAX_TASK_FAILURE_REASON_BYTES)
3885 .to_string();
3886 let event = TaskFailedEvent {
3887 event_type: "task_failed",
3888 event_id: new_event_id(),
3889 parent_id: self.task_parent(),
3890 session_id: self.session_id.clone(),
3891 timestamp: timestamp_ms(),
3892 task_id: self.active_task_id.clone(),
3893 turn,
3894 cause: cause.to_string(),
3895 reason: reason.clone(),
3896 };
3897 self.task_failures_written += 1;
3898 self.last_task_failure = Some(reason);
3899 self.write_event(&event).await
3900 }
3901
3902 pub(crate) async fn write_workdir_path_refused(
3904 &mut self,
3905 refusal: &crate::workdir_fs::WorkdirRefusal,
3906 ) -> std::io::Result<()> {
3907 let event = WorkdirPathRefusedEvent {
3908 event_type: "workdir_path_refused",
3909 event_id: new_event_id(),
3910 parent_id: self.task_parent(),
3911 session_id: self.session_id.clone(),
3912 timestamp: timestamp_ms(),
3913 task_id: self.active_task_id.clone(),
3914 path: &refusal.path,
3915 operation: refusal.operation,
3916 refused_at: &refusal.refused_at,
3917 reason: refusal.reason,
3918 };
3919 self.write_event(&event).await
3920 }
3921
3922 pub(crate) fn last_task_failure(&self) -> Option<&str> {
3924 self.last_task_failure.as_deref()
3925 }
3926
3927 pub(crate) fn task_failures_written(&self) -> u64 {
3930 self.task_failures_written
3931 }
3932
3933 pub(crate) async fn write_task_reopened(
3937 &mut self,
3938 task_id: &str,
3939 hook_name: &str,
3940 reason: &str,
3941 reopen_number: u32,
3942 attempt_context: &'static str,
3943 turns_remaining: Option<u32>,
3944 ) -> std::io::Result<()> {
3945 let event = TaskReopenedEvent {
3946 event_type: "task_reopened",
3947 event_id: new_event_id(),
3948 parent_id: self.task_parent(),
3949 session_id: self.session_id.clone(),
3950 timestamp: timestamp_ms(),
3951 task_id: task_id.to_string(),
3952 hook_name: hook_name.to_string(),
3953 reason: reason.to_string(),
3954 reopen_number,
3955 attempt_context,
3956 turns_remaining,
3957 };
3958 self.write_event(&event).await
3959 }
3960
3961 pub(crate) async fn write_task_continued(
3965 &mut self,
3966 task_id: &str,
3967 continuation_number: u32,
3968 member_calls: &[String],
3969 delegations: &[String],
3970 waited_ms: u64,
3971 turns_remaining: Option<u32>,
3972 ) -> std::io::Result<()> {
3973 let event = TaskContinuedEvent {
3974 event_type: "task_continued",
3975 event_id: new_event_id(),
3976 parent_id: self.task_parent(),
3977 session_id: self.session_id.clone(),
3978 timestamp: timestamp_ms(),
3979 task_id: task_id.to_string(),
3980 continuation_number,
3981 member_calls,
3982 delegations,
3983 waited_ms,
3984 turns_remaining,
3985 };
3986 self.write_event(&event).await
3987 }
3988
3989 pub(crate) async fn write_keep_working(
3992 &mut self,
3993 task_id: &str,
3994 number: u32,
3995 limit: u32,
3996 turns_remaining: Option<u32>,
3997 message_bytes: u64,
3998 ) -> std::io::Result<()> {
3999 let event = KeepWorkingEvent {
4000 event_type: "keep_working",
4001 event_id: new_event_id(),
4002 parent_id: self.task_parent(),
4003 session_id: self.session_id.clone(),
4004 timestamp: timestamp_ms(),
4005 task_id: task_id.to_string(),
4006 number,
4007 limit,
4008 turns_remaining,
4009 message_bytes,
4010 };
4011 if let Some(status) = &self.session_status {
4012 status.keep_working_sent(number);
4013 }
4014 self.write_event(&event).await
4015 }
4016
4017 pub(crate) async fn write_done_check(
4020 &mut self,
4021 run: &DoneCheckRun<'_>,
4022 reopens: u32,
4023 ) -> std::io::Result<()> {
4024 let event = DoneCheckEvent {
4025 event_type: "done_check",
4026 event_id: new_event_id(),
4027 parent_id: self.task_parent(),
4028 session_id: self.session_id.clone(),
4029 timestamp: timestamp_ms(),
4030 run,
4031 };
4032 if run.reopened {
4033 if let Some(status) = &self.session_status {
4034 status.done_check_reopened(reopens);
4035 }
4036 }
4037 self.write_event(&event).await
4038 }
4039
4040 pub(crate) fn task_turns(&self) -> u32 {
4050 self.task_turns
4051 }
4052
4053 pub(crate) fn task_hook_inferences(&self) -> u32 {
4055 self.task_hook_inferences
4056 }
4057
4058 pub(crate) fn task_agent_turns(&self) -> u32 {
4062 self.task_agent_turns
4063 }
4064
4065 pub(crate) async fn write_compaction(
4066 &mut self,
4067 turn: u32,
4068 tokens_before: u64,
4069 tokens_before_source: OccupancyBasis,
4070 tokens_after: u64,
4071 ) -> std::io::Result<()> {
4072 let event = CompactionEvent {
4073 event_type: "compaction",
4074 event_id: new_event_id(),
4075 parent_id: self.turn_parent(),
4076 session_id: self.session_id.clone(),
4077 timestamp: timestamp_ms(),
4078 turn,
4079 task_id: self.active_task_id.clone(),
4080 tokens_before,
4081 tokens_before_source,
4082 tokens_after,
4083 };
4084 self.write_event(&event).await
4085 }
4086
4087 pub(crate) async fn write_compaction_declined(
4093 &mut self,
4094 turn: u32,
4095 tokens: u64,
4096 tokens_source: OccupancyBasis,
4097 reason: &str,
4098 ) -> std::io::Result<()> {
4099 let event = CompactionDeclinedEvent {
4100 event_type: "compaction_declined",
4101 event_id: new_event_id(),
4102 parent_id: self.turn_parent(),
4103 session_id: self.session_id.clone(),
4104 timestamp: timestamp_ms(),
4105 turn,
4106 task_id: self.active_task_id.clone(),
4107 tokens,
4108 tokens_source,
4109 reason: reason.to_string(),
4110 };
4111 self.write_event(&event).await
4112 }
4113
4114 #[allow(clippy::too_many_arguments)]
4121 pub(crate) async fn write_context_seed(
4122 &mut self,
4123 hook_name: &str,
4124 tokens: u64,
4125 proposed_tokens: u64,
4126 budget_tokens: u64,
4127 outcome: &str,
4128 reason: Option<&str>,
4129 message_ids: Vec<String>,
4130 ) -> std::io::Result<()> {
4131 let event = ContextSeedEvent {
4132 event_type: "context_seed",
4133 event_id: new_event_id(),
4134 parent_id: self.task_parent(),
4135 session_id: self.session_id.clone(),
4136 timestamp: timestamp_ms(),
4137 task_id: self.active_task_id.clone(),
4138 hook_name: hook_name.to_string(),
4139 tokens,
4140 proposed_tokens,
4141 budget_tokens,
4142 outcome: outcome.to_string(),
4143 reason: reason.map(str::to_string),
4144 message_ids,
4145 };
4146 self.write_event(&event).await
4147 }
4148
4149 pub(crate) async fn write_retention(
4155 &mut self,
4156 store: &str,
4157 reason: &str,
4158 targets: Vec<String>,
4159 messages_dropped: Option<u64>,
4160 ) -> std::io::Result<()> {
4161 let event = RetentionEvent {
4162 event_type: "retention",
4163 event_id: new_event_id(),
4164 parent_id: self.session_parent(),
4165 session_id: self.session_id.clone(),
4166 timestamp: timestamp_ms(),
4167 store: store.to_string(),
4168 reason: reason.to_string(),
4169 removed: u32::try_from(targets.len()).unwrap_or(u32::MAX),
4170 targets,
4171 messages_dropped,
4172 };
4173 self.write_event(&event).await
4174 }
4175
4176 pub(crate) async fn write_call_denied(
4180 &mut self,
4181 turn: u32,
4182 event: &str,
4183 hook_name: &str,
4184 target: &str,
4185 reason: &str,
4186 ) -> std::io::Result<()> {
4187 let event = CallDeniedEvent {
4188 event_type: "call_denied",
4189 event_id: new_event_id(),
4190 parent_id: self.turn_parent(),
4191 session_id: self.session_id.clone(),
4192 timestamp: timestamp_ms(),
4193 turn,
4194 event: event.to_string(),
4195 hook_name: hook_name.to_string(),
4196 target: target.to_string(),
4197 reason: reason.to_string(),
4198 };
4199 self.write_event(&event).await
4200 }
4201
4202 pub(crate) async fn write_tool_input_refused(
4207 &mut self,
4208 turn: u32,
4209 tool_name: &str,
4210 tool_call_id: Option<&str>,
4211 missing: &[String],
4212 reason: &str,
4213 ) -> std::io::Result<()> {
4214 let event = ToolInputRefusedEvent {
4215 event_type: "tool_input_refused",
4216 event_id: new_event_id(),
4217 parent_id: self.turn_parent(),
4218 session_id: self.session_id.clone(),
4219 timestamp: timestamp_ms(),
4220 turn,
4221 tool_name: tool_name.to_string(),
4222 tool_call_id: tool_call_id.filter(|id| !id.is_empty()).map(str::to_string),
4223 missing: missing.to_vec(),
4224 reason: reason.to_string(),
4225 };
4226 self.write_event(&event).await
4227 }
4228
4229 pub(crate) async fn write_inference_retry(
4234 &mut self,
4235 turn: u32,
4236 retry: &InferenceRetryRecord,
4237 ) -> std::io::Result<()> {
4238 let event = InferenceRetryEvent {
4239 event_type: "inference_retry",
4240 event_id: new_event_id(),
4241 parent_id: self.turn_parent(),
4242 session_id: self.session_id.clone(),
4243 timestamp: timestamp_ms(),
4244 turn,
4245 task_id: self.active_task_id.clone(),
4246 attempt: retry.attempt,
4247 max_retries: retry.max_retries,
4248 reason: retry.reason.wire_name(),
4249 provider_status: retry.provider_status,
4250 error: crate::agent::truncate_on_char_boundary(
4251 &retry.error,
4252 MAX_TASK_FAILURE_REASON_BYTES,
4253 ),
4254 delay_ms: retry.delay_ms,
4255 retry_after_ms: retry.retry_after_ms,
4256 charged_tokens: retry.charged_tokens,
4257 origin: retry.origin.as_deref(),
4258 };
4259 self.write_event(&event).await
4260 }
4261
4262 pub(crate) async fn write_spend_ceiling_reached(
4265 &mut self,
4266 turn: u32,
4267 refusal: &crate::spend::SpendRefusal,
4268 origin: Option<&str>,
4269 ) -> std::io::Result<()> {
4270 let event = SpendCeilingReachedEvent {
4271 event_type: "spend_ceiling_reached",
4272 event_id: new_event_id(),
4273 parent_id: self.turn_parent(),
4274 session_id: self.session_id.clone(),
4275 timestamp: timestamp_ms(),
4276 turn,
4277 task_id: self.active_task_id.clone(),
4278 refusal,
4279 origin,
4280 };
4281 self.write_event(&event).await
4282 }
4283
4284 #[allow(clippy::too_many_arguments)]
4288 pub(crate) async fn write_protected_path_denied(
4289 &mut self,
4290 turn: u32,
4291 call: &str,
4292 target: &str,
4293 path: &str,
4294 rule: &str,
4295 signal: &str,
4296 reason: &str,
4297 ) -> std::io::Result<()> {
4298 let event = ProtectedPathDeniedEvent {
4299 event_type: "protected_path_denied",
4300 event_id: new_event_id(),
4301 parent_id: self.turn_parent(),
4302 session_id: self.session_id.clone(),
4303 timestamp: timestamp_ms(),
4304 turn,
4305 call: call.to_string(),
4306 target: target.to_string(),
4307 path: path.to_string(),
4308 rule: rule.to_string(),
4309 signal: signal.to_string(),
4310 reason: reason.to_string(),
4311 };
4312 self.write_event(&event).await
4313 }
4314
4315 pub(crate) async fn write_hook_dispatch_error(
4321 &mut self,
4322 hook_name: &str,
4323 event: &str,
4324 arm: &str,
4325 ) -> std::io::Result<()> {
4326 let event = HookDispatchErrorEvent {
4327 event_type: "hook_dispatch_error",
4328 event_id: new_event_id(),
4329 parent_id: self.session_parent(),
4330 session_id: self.session_id.clone(),
4331 timestamp: timestamp_ms(),
4332 hook_name: hook_name.to_string(),
4333 event: event.to_string(),
4334 arm: arm.to_string(),
4335 };
4336 self.write_event(&event).await
4337 }
4338
4339 pub(crate) async fn write_harness_start(&mut self, start: HarnessStart) -> std::io::Result<()> {
4349 if self.capture.captures_content() {
4350 self.blobs
4351 .put(&start.system_prompt_sha256, start.system_prompt.as_bytes())
4352 .await?;
4353 }
4354 let event = HarnessStartEvent {
4355 event_type: "harness_start",
4356 event_id: new_event_id(),
4357 parent_id: self.task_parent(),
4358 session_id: self.session_id.clone(),
4359 timestamp: timestamp_ms(),
4360 task_id: self.active_task_id.clone(),
4361 start,
4362 };
4363 self.write_event(&event).await
4364 }
4365
4366 pub(crate) async fn write_harness_warning(
4368 &mut self,
4369 code: &str,
4370 message: &str,
4371 ) -> std::io::Result<()> {
4372 let event = HarnessWarningEvent {
4373 event_type: "harness_warning",
4374 event_id: new_event_id(),
4375 parent_id: self.task_parent(),
4376 session_id: self.session_id.clone(),
4377 timestamp: timestamp_ms(),
4378 task_id: self.active_task_id.clone(),
4379 code: code.to_string(),
4380 message: message.to_string(),
4381 };
4382 self.write_event(&event).await
4383 }
4384
4385 pub(crate) async fn write_harness_session(
4387 &mut self,
4388 harness_session_id: &str,
4389 auth: &str,
4390 model: Option<&str>,
4391 ) -> std::io::Result<()> {
4392 let event = HarnessSessionEvent {
4393 event_type: "harness_session",
4394 event_id: new_event_id(),
4395 parent_id: self.task_parent(),
4396 session_id: self.session_id.clone(),
4397 timestamp: timestamp_ms(),
4398 task_id: self.active_task_id.clone(),
4399 harness_session_id: harness_session_id.to_string(),
4400 auth: auth.to_string(),
4401 model: model.map(str::to_string),
4402 };
4403 self.write_event(&event).await
4404 }
4405
4406 pub(crate) async fn write_harness_session_forgotten(
4408 &mut self,
4409 context_id: &str,
4410 harness_session_id: &str,
4411 requested_by: &str,
4412 ) -> std::io::Result<()> {
4413 let event = HarnessSessionForgottenEvent {
4414 event_type: "harness_session_forgotten",
4415 event_id: new_event_id(),
4416 parent_id: self.task_parent(),
4417 session_id: self.session_id.clone(),
4418 timestamp: timestamp_ms(),
4419 task_id: self.active_task_id.clone(),
4420 context_id: context_id.to_string(),
4421 harness_session_id: harness_session_id.to_string(),
4422 requested_by: requested_by.to_string(),
4423 };
4424 self.write_event(&event).await
4425 }
4426
4427 pub(crate) async fn write_harness_retry(
4429 &mut self,
4430 attempt: u32,
4431 reason: &str,
4432 ) -> std::io::Result<()> {
4433 let event = HarnessRetryEvent {
4434 event_type: "harness_retry",
4435 event_id: new_event_id(),
4436 parent_id: self.task_parent(),
4437 session_id: self.session_id.clone(),
4438 timestamp: timestamp_ms(),
4439 task_id: self.active_task_id.clone(),
4440 attempt,
4441 reason: reason.to_string(),
4442 };
4443 self.write_event(&event).await
4444 }
4445
4446 pub(crate) async fn write_harness_note(&mut self, text: &str) -> std::io::Result<()> {
4449 let event = HarnessTextEvent {
4450 event_type: "harness_note",
4451 event_id: new_event_id(),
4452 parent_id: self.task_parent(),
4453 session_id: self.session_id.clone(),
4454 timestamp: timestamp_ms(),
4455 task_id: self.active_task_id.clone(),
4456 text: text.to_string(),
4457 };
4458 self.write_event(&event).await
4459 }
4460
4461 pub(crate) async fn write_harness_quota(
4464 &mut self,
4465 windows: &[crate::process_driver::QuotaWindow],
4466 ) -> std::io::Result<()> {
4467 if let Some(status) = &self.session_status {
4468 status.record_quota(windows);
4469 }
4470 let event = HarnessQuotaEvent {
4471 event_type: "harness_quota",
4472 event_id: new_event_id(),
4473 parent_id: self.task_parent(),
4474 session_id: self.session_id.clone(),
4475 timestamp: timestamp_ms(),
4476 task_id: self.active_task_id.clone(),
4477 windows: windows
4478 .iter()
4479 .map(|window| HarnessQuotaWindow {
4480 name: &window.name,
4481 used: window.used,
4482 resets_at_ms: window.resets_at,
4483 window_minutes: window.window_minutes,
4484 state: window
4485 .state
4486 .map(crate::process_driver::QuotaState::wire_name),
4487 })
4488 .collect(),
4489 };
4490 self.write_event(&event).await
4491 }
4492
4493 pub(crate) async fn write_harness_suggestion(&mut self, text: &str) -> std::io::Result<()> {
4495 let event = HarnessTextEvent {
4496 event_type: "harness_suggestion",
4497 event_id: new_event_id(),
4498 parent_id: self.task_parent(),
4499 session_id: self.session_id.clone(),
4500 timestamp: timestamp_ms(),
4501 task_id: self.active_task_id.clone(),
4502 text: text.to_string(),
4503 };
4504 self.write_event(&event).await
4505 }
4506
4507 pub(crate) async fn write_harness_failed(
4510 &mut self,
4511 kind: &str,
4512 message: &str,
4513 source: &str,
4514 ) -> std::io::Result<()> {
4515 let event = HarnessFailedEvent {
4516 event_type: "harness_failed",
4517 event_id: new_event_id(),
4518 parent_id: self.task_parent(),
4519 session_id: self.session_id.clone(),
4520 timestamp: timestamp_ms(),
4521 task_id: self.active_task_id.clone(),
4522 kind: kind.to_string(),
4523 message: message.to_string(),
4524 source: source.to_string(),
4525 };
4526 self.write_event(&event).await
4527 }
4528
4529 pub(crate) async fn write_harness_interrupt(
4532 &mut self,
4533 method: &str,
4534 delivered: bool,
4535 grace_ms: u64,
4536 ) -> std::io::Result<()> {
4537 let event = HarnessInterruptEvent {
4538 event_type: "harness_interrupt",
4539 event_id: new_event_id(),
4540 parent_id: self.task_parent(),
4541 session_id: self.session_id.clone(),
4542 timestamp: timestamp_ms(),
4543 task_id: self.active_task_id.clone(),
4544 method: method.to_string(),
4545 delivered,
4546 grace_ms,
4547 };
4548 self.write_event(&event).await
4549 }
4550
4551 pub(crate) async fn write_harness_exit(&mut self, exit: HarnessExit) -> std::io::Result<()> {
4553 let event = HarnessExitEvent {
4554 event_type: "harness_exit",
4555 event_id: new_event_id(),
4556 parent_id: self.task_parent(),
4557 session_id: self.session_id.clone(),
4558 timestamp: timestamp_ms(),
4559 task_id: self.active_task_id.clone(),
4560 exit,
4561 };
4562 self.write_event(&event).await
4563 }
4564
4565 pub(crate) async fn write_session_end(&mut self, exit_status: &str) -> std::io::Result<()> {
4566 let duration_ms = self
4567 .session_start_time
4568 .elapsed()
4569 .as_millis()
4570 .try_into()
4571 .unwrap_or(u64::MAX);
4572 let event = SessionEndEvent {
4573 event_type: "session_end",
4574 event_id: new_event_id(),
4575 parent_id: self.session_parent(),
4576 session_id: self.session_id.clone(),
4577 timestamp: timestamp_ms(),
4578 total_turns: self.total_turns,
4579 total_input_tokens: self.total_input_tokens,
4580 total_output_tokens: self.total_output_tokens,
4581 total_tool_calls: self.total_tool_calls,
4582 total_shell_calls: self.total_shell_calls,
4583 duration_ms,
4584 exit_status: exit_status.to_string(),
4585 };
4586 self.write_event(&event).await?;
4587 self.session_ended = true;
4588 self.sealed_head =
4589 crate::record_seal::seal_session_end(&self.records_root, &self.session_id);
4590 Ok(())
4591 }
4592
4593 pub(crate) fn sealed_head(&self) -> Option<&crate::record_seal::SealedHead> {
4597 self.sealed_head.as_ref()
4598 }
4599
4600 pub(crate) fn current_head(&self) -> Option<crate::record_seal::SealedHead> {
4603 let head = self.chain.head().ok()?;
4604 Some(crate::record_seal::SealedHead {
4605 head: head.head,
4606 lines: head.lines,
4607 key_id: None,
4608 })
4609 }
4610
4611 pub(crate) fn records_root(&self) -> &WorkdirRoot {
4613 &self.records_root
4614 }
4615
4616 pub(crate) async fn write_session_end_if_not_ended(
4619 &mut self,
4620 exit_status: &str,
4621 ) -> std::io::Result<()> {
4622 if self.session_started && !self.session_ended {
4623 self.write_session_end(exit_status).await?;
4624 }
4625 Ok(())
4626 }
4627
4628 pub(crate) async fn flush(&mut self) -> std::io::Result<()> {
4630 Ok(())
4631 }
4632
4633 async fn write_event(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4638 self.write_latched().await?;
4639 self.write_line(event).await
4640 }
4641
4642 async fn write_latched(&mut self) -> std::io::Result<()> {
4645 self.write_call_deadlines().await?;
4646 self.write_member_requests().await
4647 }
4648
4649 async fn write_member_requests(&mut self) -> std::io::Result<()> {
4653 let Some(requests) = self
4654 .member_requests
4655 .as_ref()
4656 .filter(|_| self.session_started)
4657 else {
4658 return Ok(());
4659 };
4660 for request in requests.take() {
4661 let event = MemberRequestEvent {
4662 event_type: "member_request",
4663 event_id: new_event_id(),
4664 parent_id: self.session_parent(),
4665 session_id: self.session_id.clone(),
4666 timestamp: timestamp_ms(),
4667 reason: request.outcome.reason(),
4668 outcome: request.outcome.wire_name(),
4669 call_id: request.call_id,
4670 task_id: request.task_id,
4671 member: request.member,
4672 component: request.component,
4673 role: request.role.wire_name(),
4674 via: request.via.wire_name(),
4675 http_status: request.http_status,
4676 member_task_id: request.member_task_id,
4677 duration_ms: request.duration_ms,
4678 };
4679 self.write_line(&event).await?;
4680 }
4681 Ok(())
4682 }
4683
4684 async fn write_call_deadlines(&mut self) -> std::io::Result<()> {
4687 let Some(deadlines) = self
4688 .call_deadlines
4689 .as_ref()
4690 .filter(|_| self.session_started)
4691 else {
4692 return Ok(());
4693 };
4694 for cut in deadlines.take() {
4695 let event = CallDeadlineEvent {
4696 event_type: "call_deadline",
4697 event_id: new_event_id(),
4698 parent_id: self.turn_parent(),
4699 session_id: self.session_id.clone(),
4700 timestamp: timestamp_ms(),
4701 task_id: self.active_task_id.clone(),
4702 component: cut.component,
4703 role: cut.role.wire_name(),
4704 call: cut.call,
4705 deadline_seconds: cut.deadline_seconds,
4706 elapsed_ms: cut.elapsed_ms,
4707 };
4708 self.write_line(&event).await?;
4709 }
4710 Ok(())
4711 }
4712
4713 async fn write_line(&mut self, event: &impl Serialize) -> std::io::Result<()> {
4717 self.chain.append_event(event)
4718 }
4719}
4720
4721#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4723pub(crate) struct SessionGateway {
4724 pub(crate) artifact: String,
4726 pub(crate) host: String,
4728 pub(crate) credential_source: &'static str,
4730 pub(crate) metered: bool,
4733}
4734
4735#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4738pub(crate) struct RuntimeArtifact {
4739 name: String,
4740 version: String,
4741 origin: &'static str,
4743 session: String,
4745 trust: &'static str,
4747}
4748
4749#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4751pub(crate) struct HostFactsRecord {
4752 binary: String,
4754 source: &'static str,
4756 #[serde(skip_serializing_if = "Option::is_none")]
4759 reason: Option<&'static str>,
4760 #[serde(skip_serializing_if = "Option::is_none")]
4762 changed_path: Option<String>,
4763}
4764
4765impl HostFactsRecord {
4766 fn of(used: &crate::host_facts::HostFactsUse) -> Self {
4767 use crate::host_facts::HostFactsSource;
4768
4769 let binary = used.binary.display().to_string();
4770 match &used.source {
4771 HostFactsSource::Kept => Self {
4772 binary,
4773 source: "kept",
4774 reason: None,
4775 changed_path: None,
4776 },
4777 HostFactsSource::Derived {
4778 reason,
4779 changed_path,
4780 } => Self {
4781 binary,
4782 source: "derived",
4783 reason: Some(reason.wire_name()),
4784 changed_path: changed_path.as_ref().map(|path| path.display().to_string()),
4785 },
4786 }
4787 }
4788}
4789
4790#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4792pub(crate) struct SessionControl {
4793 pub(crate) settings: Vec<&'static str>,
4795 pub(crate) secrets: Vec<String>,
4797 #[serde(skip_serializing_if = "Vec::is_empty")]
4800 pub(crate) agent_settings: Vec<&'static str>,
4801}
4802
4803#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4806pub(crate) struct SessionInferenceChoice {
4807 pub(crate) name: String,
4808 pub(crate) driver: String,
4809 pub(crate) model: String,
4810 pub(crate) credential_source: &'static str,
4813 pub(crate) available: bool,
4816 pub(crate) model_limits: Option<crate::model_limits::ResolvedModelLimits>,
4818}
4819
4820pub(crate) struct ControlChange<'a> {
4824 pub(crate) event_id: String,
4825 pub(crate) principal: crate::control_plane::Principal,
4827 pub(crate) token_id: Option<&'a str>,
4830 pub(crate) kind: &'static str,
4832 pub(crate) name: &'a str,
4833 pub(crate) action: &'static str,
4835 pub(crate) previous: Option<Value>,
4837 pub(crate) value: Option<Value>,
4838 pub(crate) applies_from: Option<&'static str>,
4840 pub(crate) replaced: Option<bool>,
4842}
4843
4844#[derive(Serialize)]
4847struct ControlChangeEvent<'a> {
4848 event_type: &'static str,
4849 event_id: String,
4850 parent_id: Option<String>,
4851 session_id: String,
4852 timestamp: u64,
4853 principal: &'static str,
4854 #[serde(skip_serializing_if = "Option::is_none")]
4855 token_id: Option<&'a str>,
4856 kind: &'static str,
4857 name: &'a str,
4858 action: &'static str,
4859 #[serde(skip_serializing_if = "Option::is_none")]
4860 previous: Option<Value>,
4861 #[serde(skip_serializing_if = "Option::is_none")]
4862 value: Option<Value>,
4863 #[serde(skip_serializing_if = "Option::is_none")]
4864 applies_from: Option<&'static str>,
4865 #[serde(skip_serializing_if = "Option::is_none")]
4866 replaced: Option<bool>,
4867}
4868
4869#[derive(Serialize)]
4873struct ControlRefusedEvent<'a> {
4874 event_type: &'static str,
4875 event_id: String,
4876 parent_id: Option<String>,
4877 session_id: String,
4878 timestamp: u64,
4879 principal: &'static str,
4880 status: u16,
4881 reason: &'static str,
4882 #[serde(skip_serializing_if = "Option::is_none")]
4883 kind: Option<&'static str>,
4884 #[serde(skip_serializing_if = "Option::is_none")]
4885 name: Option<&'a str>,
4886 #[serde(skip_serializing_if = "Option::is_none")]
4887 token_id: Option<&'a str>,
4888}
4889
4890#[derive(Serialize)]
4892struct ControlAppliedEvent<'a> {
4893 event_type: &'static str,
4894 event_id: String,
4895 parent_id: Option<String>,
4896 session_id: String,
4897 timestamp: u64,
4898 turn: u32,
4899 task_id: Option<String>,
4900 name: &'a str,
4901 value: Value,
4902 change_id: &'a str,
4903}
4904
4905#[derive(Serialize)]
4907struct ToolsRefreshedEvent<'a> {
4908 event_type: &'static str,
4909 event_id: String,
4910 parent_id: Option<String>,
4911 session_id: String,
4912 timestamp: u64,
4913 turn: u32,
4914 task_id: Option<String>,
4915 trigger: &'a str,
4916 added: &'a [String],
4917 removed: &'a [String],
4918 tools: &'a [String],
4919}
4920
4921#[derive(Serialize)]
4924struct GatewayCredentialEvent<'a> {
4925 event_type: &'static str,
4926 event_id: String,
4927 parent_id: Option<String>,
4928 session_id: String,
4929 timestamp: u64,
4930 artifact: &'a str,
4931 source: &'static str,
4932 #[serde(skip_serializing_if = "Option::is_none")]
4933 credential: Option<&'a str>,
4934 change: &'static str,
4935 #[serde(skip_serializing_if = "Option::is_none")]
4936 trigger: Option<&'static str>,
4937 #[serde(skip_serializing_if = "Option::is_none")]
4938 status: Option<u16>,
4939 #[serde(skip_serializing_if = "Option::is_none")]
4940 retried: Option<bool>,
4941 #[serde(skip_serializing_if = "Option::is_none")]
4942 reason: Option<&'static str>,
4943}
4944
4945#[derive(Serialize)]
4949struct InferenceCredentialEvent<'a> {
4950 event_type: &'static str,
4951 event_id: String,
4952 parent_id: Option<String>,
4953 session_id: String,
4954 timestamp: u64,
4955 source: &'static str,
4956 #[serde(skip_serializing_if = "Option::is_none")]
4957 credential: Option<&'a str>,
4958 change: &'static str,
4959 #[serde(skip_serializing_if = "Option::is_none")]
4960 trigger: Option<&'static str>,
4961 #[serde(skip_serializing_if = "Option::is_none")]
4962 status: Option<u16>,
4963 #[serde(skip_serializing_if = "Option::is_none")]
4964 retried: Option<bool>,
4965 #[serde(skip_serializing_if = "Option::is_none")]
4966 reason: Option<&'static str>,
4967}
4968
4969#[allow(clippy::type_complexity)]
4972fn credential_change_fields(
4973 change: CredentialChange,
4974) -> (
4975 &'static str,
4976 Option<&'static str>,
4977 Option<u16>,
4978 Option<bool>,
4979 Option<&'static str>,
4980) {
4981 match change {
4982 CredentialChange::Rotated { trigger } => ("rotated", Some(trigger), None, None, None),
4983 CredentialChange::Rejected { status, retried } => {
4984 ("rejected", None, Some(status), Some(retried), None)
4985 }
4986 CredentialChange::Unreadable { reason } => ("unreadable", None, None, None, Some(reason)),
4987 }
4988}
4989
4990pub struct ResourceTraceAppender {
5005 chain: Arc<crate::record_seal::InlineChain>,
5007 admit: std::sync::Mutex<()>,
5009 session_id: String,
5010 session_event_id: String,
5015}
5016
5017impl ResourceTraceAppender {
5018 pub async fn open(
5019 records_root: &WorkdirRoot,
5020 session_id: String,
5021 session_event_id: String,
5022 ) -> std::io::Result<Self> {
5023 let chain = crate::record_seal::InlineChain::open(records_root, TRACE_FILE_NAME)?;
5024 Ok(Self {
5025 chain,
5026 admit: std::sync::Mutex::new(()),
5027 session_id,
5028 session_event_id,
5029 })
5030 }
5031
5032 #[allow(clippy::too_many_arguments)]
5036 pub(crate) async fn write_resource_list(
5037 &self,
5038 root: &str,
5039 entry_count: usize,
5040 total_bytes: u64,
5041 generation: u64,
5042 containment_achieved: ContainmentClass,
5043 outcome: &str,
5044 reason: Option<String>,
5045 ) {
5046 let event = ResourceListEvent {
5047 event_type: "resource_list",
5048 event_id: new_event_id(),
5049 parent_id: Some(self.session_event_id.clone()),
5050 session_id: self.session_id.clone(),
5051 timestamp: timestamp_ms(),
5052 root: root.to_string(),
5053 entry_count,
5054 total_bytes,
5055 generation,
5056 containment_achieved,
5057 outcome: outcome.to_string(),
5058 reason,
5059 };
5060 self.append(&event).await;
5061 }
5062
5063 #[allow(clippy::too_many_arguments)]
5066 pub(crate) async fn write_resource_read(
5067 &self,
5068 path: &str,
5069 outcome: &str,
5070 bytes: Option<u64>,
5071 sha256: Option<String>,
5072 generation: u64,
5073 containment_achieved: ContainmentClass,
5074 reason: Option<String>,
5075 ) {
5076 let event = ResourceReadEvent {
5077 event_type: "resource_read",
5078 event_id: new_event_id(),
5079 parent_id: Some(self.session_event_id.clone()),
5080 session_id: self.session_id.clone(),
5081 timestamp: timestamp_ms(),
5082 path: path.to_string(),
5083 outcome: outcome.to_string(),
5084 bytes,
5085 sha256,
5086 generation,
5087 containment_achieved,
5088 reason,
5089 };
5090 self.append(&event).await;
5091 }
5092
5093 pub(crate) async fn write_formation_ended(&self, formation_id: &crate::FormationId) {
5096 let event = FormationEndedEvent {
5097 event_type: "formation_ended",
5098 event_id: new_event_id(),
5099 parent_id: Some(self.session_event_id.clone()),
5100 session_id: self.session_id.clone(),
5101 timestamp: timestamp_ms(),
5102 formation_id: formation_id.as_str().to_string(),
5103 };
5104 self.append(&event).await;
5105 }
5106
5107 pub(crate) async fn write_session_status(
5110 &self,
5111 task_id: Option<String>,
5112 status: &crate::session_status::StatusReport,
5113 ) {
5114 let event = SessionStatusEvent {
5115 event_type: "session_status",
5116 event_id: new_event_id(),
5117 parent_id: Some(self.session_event_id.clone()),
5118 session_id: self.session_id.clone(),
5119 timestamp: timestamp_ms(),
5120 task_id,
5121 status,
5122 };
5123 self.append(&event).await;
5124 }
5125
5126 #[allow(clippy::too_many_arguments)]
5129 pub(crate) async fn write_background_command(
5130 &self,
5131 task_id: Option<String>,
5132 work_id: &str,
5133 operation: &'static str,
5134 state: &'static str,
5135 exit_code: Option<i32>,
5136 waited_ms: Option<u64>,
5137 output_bytes: u64,
5138 ) {
5139 let event = BackgroundCommandEvent {
5140 event_type: "background_command",
5141 event_id: new_event_id(),
5142 parent_id: Some(self.session_event_id.clone()),
5143 session_id: self.session_id.clone(),
5144 timestamp: timestamp_ms(),
5145 task_id,
5146 work_id,
5147 operation,
5148 state,
5149 exit_code,
5150 waited_ms,
5151 output_bytes,
5152 };
5153 self.append(&event).await;
5154 }
5155
5156 pub(crate) async fn write_process_start(&self, line: ProcessStartLine<'_>) {
5159 let event = ProcessStartEvent {
5160 event_type: "process_start",
5161 event_id: new_event_id(),
5162 parent_id: Some(self.session_event_id.clone()),
5163 session_id: self.session_id.clone(),
5164 timestamp: timestamp_ms(),
5165 line,
5166 };
5167 self.append(&event).await;
5168 }
5169
5170 pub(crate) async fn write_process_exit(&self, line: ProcessExitLine<'_>) {
5173 let event = ProcessExitEvent {
5174 event_type: "process_exit",
5175 event_id: new_event_id(),
5176 parent_id: Some(self.session_event_id.clone()),
5177 session_id: self.session_id.clone(),
5178 timestamp: timestamp_ms(),
5179 line,
5180 };
5181 self.append(&event).await;
5182 }
5183
5184 pub(crate) async fn write_process_refused(&self, line: ProcessRefusedLine<'_>) {
5187 let event = ProcessRefusedEvent {
5188 event_type: "process_refused",
5189 event_id: new_event_id(),
5190 parent_id: Some(self.session_event_id.clone()),
5191 session_id: self.session_id.clone(),
5192 timestamp: timestamp_ms(),
5193 line,
5194 };
5195 self.append(&event).await;
5196 }
5197
5198 pub(crate) async fn write_shell_completed(
5202 &self,
5203 completion: &crate::detached::DetachedCompletion,
5204 ) {
5205 let event = ShellCompletedEvent::new(
5206 self.session_id.clone(),
5207 Some(self.session_event_id.clone()),
5208 completion,
5209 crate::detached::Delivery::BackgroundCommand,
5210 None,
5211 );
5212 self.append(&event).await;
5213 }
5214
5215 pub(crate) async fn write_deadline_notice(
5217 &self,
5218 task_id: Option<String>,
5219 tool_name: &str,
5220 notice: &crate::session_status::DeadlineNotice,
5221 ) {
5222 let event = DeadlineNoticeEvent {
5223 event_type: "deadline_notice",
5224 event_id: new_event_id(),
5225 parent_id: Some(self.session_event_id.clone()),
5226 session_id: self.session_id.clone(),
5227 timestamp: timestamp_ms(),
5228 task_id,
5229 tool_name,
5230 deadline_at: ¬ice.at,
5231 left_secs: notice.left_secs,
5232 notice_secs: notice.notice_secs,
5233 };
5234 self.append(&event).await;
5235 }
5236
5237 pub(crate) async fn write_deadline_reached(
5240 &self,
5241 deadline: &crate::session_status::SessionDeadline,
5242 ) {
5243 let event = DeadlineReachedEvent {
5244 event_type: "deadline_reached",
5245 event_id: new_event_id(),
5246 parent_id: Some(self.session_event_id.clone()),
5247 session_id: self.session_id.clone(),
5248 timestamp: timestamp_ms(),
5249 deadline_at: deadline.at_rfc3339(),
5250 deadline_secs: deadline.secs(),
5251 source: deadline.source().as_str(),
5252 };
5253 self.append(&event).await;
5254 }
5255
5256 pub(crate) async fn write_spawner_ended(
5260 &self,
5261 spawned_by: Option<&str>,
5262 delegation_id: Option<&str>,
5263 ) {
5264 let event = SpawnerEndedEvent {
5265 event_type: "spawner_ended",
5266 event_id: new_event_id(),
5267 parent_id: Some(self.session_event_id.clone()),
5268 session_id: self.session_id.clone(),
5269 timestamp: timestamp_ms(),
5270 spawned_by: spawned_by.map(str::to_string),
5271 delegation_id: delegation_id.map(str::to_string),
5272 };
5273 self.append(&event).await;
5274 }
5275
5276 #[allow(clippy::too_many_arguments)]
5279 pub(crate) async fn write_peer_handle_mint(
5280 &self,
5281 handle_id: Option<String>,
5282 path: &str,
5283 audience: &str,
5284 expires_at_ms: Option<u64>,
5285 outcome: &str,
5286 reason: Option<String>,
5287 ) {
5288 let event = PeerHandleMintEvent {
5289 event_type: "peer_handle_mint",
5290 event_id: new_event_id(),
5291 parent_id: Some(self.session_event_id.clone()),
5292 session_id: self.session_id.clone(),
5293 timestamp: timestamp_ms(),
5294 handle_id,
5295 path: path.to_string(),
5296 audience: audience.to_string(),
5297 expires_at_ms,
5298 outcome: outcome.to_string(),
5299 reason,
5300 };
5301 self.append(&event).await;
5302 }
5303
5304 #[allow(clippy::too_many_arguments)]
5307 pub(crate) async fn write_peer_handle_redeem(
5308 &self,
5309 handle_id: &str,
5310 path: Option<String>,
5311 generation: u64,
5312 audience_asserted: Option<String>,
5313 bytes: Option<u64>,
5314 sha256: Option<String>,
5315 outcome: &str,
5316 reason: Option<String>,
5317 ) {
5318 let event = PeerHandleRedeemEvent {
5319 event_type: "peer_handle_redeem",
5320 event_id: new_event_id(),
5321 parent_id: Some(self.session_event_id.clone()),
5322 session_id: self.session_id.clone(),
5323 timestamp: timestamp_ms(),
5324 handle_id: handle_id.to_string(),
5325 path,
5326 generation,
5327 audience_asserted,
5328 bytes,
5329 sha256,
5330 outcome: outcome.to_string(),
5331 reason,
5332 };
5333 self.append(&event).await;
5334 }
5335
5336 #[allow(clippy::too_many_arguments)]
5338 pub(crate) async fn write_peer_file_fetch(
5339 &self,
5340 peer: &str,
5341 handle_id: &str,
5342 stored_path: Option<String>,
5343 bytes: Option<u64>,
5344 sha256: Option<String>,
5345 outcome: &str,
5346 reason: Option<String>,
5347 ) {
5348 let event = PeerFileFetchEvent {
5349 event_type: "peer_file_fetch",
5350 event_id: new_event_id(),
5351 parent_id: Some(self.session_event_id.clone()),
5352 session_id: self.session_id.clone(),
5353 timestamp: timestamp_ms(),
5354 peer: peer.to_string(),
5355 handle_id: handle_id.to_string(),
5356 stored_path,
5357 bytes,
5358 sha256,
5359 outcome: outcome.to_string(),
5360 reason,
5361 };
5362 self.append(&event).await;
5363 }
5364
5365 #[allow(clippy::too_many_arguments)]
5370 pub(crate) async fn write_delegation_start(
5371 &self,
5372 delegation_id: &str,
5373 capsule: &str,
5374 version: &str,
5375 child_session_id: &str,
5376 child_workdir: &str,
5377 child_records_dir: &str,
5378 workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
5379 ) {
5380 let event = DelegationStartEvent::new(
5381 &self.session_id,
5382 &self.session_event_id,
5383 delegation_id,
5384 capsule,
5385 version,
5386 child_session_id,
5387 child_workdir,
5388 child_records_dir,
5389 workdir_snapshot,
5390 );
5391 self.append(&event).await;
5392 }
5393
5394 #[allow(clippy::too_many_arguments)]
5398 pub(crate) async fn write_delegation(
5399 &self,
5400 capsule: &str,
5401 version: &str,
5402 delegation_id: Option<String>,
5403 child_session_id: Option<String>,
5404 duration_ms: u64,
5405 outcome: &str,
5406 reason: Option<String>,
5407 ) {
5408 let event = DelegationEvent::new(
5409 &self.session_id,
5410 &self.session_event_id,
5411 capsule,
5412 version,
5413 delegation_id,
5414 child_session_id,
5415 duration_ms,
5416 outcome,
5417 reason,
5418 );
5419 self.append(&event).await;
5420 }
5421
5422 pub(crate) async fn write_member_call_start(
5424 &self,
5425 task_id: &str,
5426 call_id: &str,
5427 member: &str,
5428 member_task_id: &str,
5429 ) {
5430 let event = self.member_call_start(task_id, call_id, member, member_task_id);
5431 self.append(&event).await;
5432 }
5433
5434 pub(crate) async fn write_member_call_start_if(
5437 &self,
5438 task_id: &str,
5439 call_id: &str,
5440 member: &str,
5441 member_task_id: &str,
5442 admit: impl FnOnce() -> bool,
5443 ) -> bool {
5444 let event = self.member_call_start(task_id, call_id, member, member_task_id);
5445 self.append_if(&event, admit).await
5446 }
5447
5448 fn member_call_start(
5449 &self,
5450 task_id: &str,
5451 call_id: &str,
5452 member: &str,
5453 member_task_id: &str,
5454 ) -> MemberCallStartEvent {
5455 MemberCallStartEvent {
5456 event_type: "member_call_start",
5457 event_id: new_event_id(),
5458 parent_id: Some(self.session_event_id.clone()),
5459 session_id: self.session_id.clone(),
5460 timestamp: timestamp_ms(),
5461 task_id: task_id.to_string(),
5462 call_id: call_id.to_string(),
5463 member: member.to_string(),
5464 member_task_id: member_task_id.to_string(),
5465 }
5466 }
5467
5468 pub(crate) async fn write_member_call_busy(
5471 &self,
5472 task_id: &str,
5473 call_id: &str,
5474 member: &str,
5475 offer: u32,
5476 waited_ms: u64,
5477 message: &str,
5478 ) {
5479 let event = MemberCallBusyEvent {
5480 event_type: "member_call_busy",
5481 event_id: new_event_id(),
5482 parent_id: Some(self.session_event_id.clone()),
5483 session_id: self.session_id.clone(),
5484 timestamp: timestamp_ms(),
5485 task_id: task_id.to_string(),
5486 call_id: call_id.to_string(),
5487 member: member.to_string(),
5488 offer,
5489 waited_ms,
5490 message: message.to_string(),
5491 };
5492 self.append(&event).await;
5493 }
5494
5495 pub(crate) async fn write_member_call(
5497 &self,
5498 task_id: &str,
5499 outcome: &crate::member_call::MemberCallOutcome,
5500 delivered: bool,
5501 ) {
5502 let event = MemberCallEvent {
5503 event_type: "member_call",
5504 event_id: new_event_id(),
5505 parent_id: Some(self.session_event_id.clone()),
5506 session_id: self.session_id.clone(),
5507 timestamp: timestamp_ms(),
5508 task_id: task_id.to_string(),
5509 call_id: outcome.call_id.clone(),
5510 member: outcome.member.clone(),
5511 member_task_id: outcome.member_task_id.clone(),
5512 status: outcome.status.as_str().to_string(),
5513 duration_ms: outcome.duration_ms,
5514 output: outcome.output.clone(),
5515 truncated: outcome.truncated,
5516 delivered,
5517 no_answer_below: outcome
5518 .below
5519 .iter()
5520 .map(|(member, status)| NoAnswerBelow {
5521 member: member.clone(),
5522 status: status.as_str().to_string(),
5523 })
5524 .collect(),
5525 };
5526 self.append(&event).await;
5527 }
5528
5529 pub(crate) async fn write_inference_credential(
5532 &self,
5533 source: &'static str,
5534 credential: Option<&str>,
5535 change: CredentialChange,
5536 ) {
5537 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5538 let event = InferenceCredentialEvent {
5539 event_type: "inference_credential",
5540 event_id: new_event_id(),
5541 parent_id: Some(self.session_event_id.clone()),
5542 session_id: self.session_id.clone(),
5543 timestamp: timestamp_ms(),
5544 source,
5545 credential,
5546 change: change_name,
5547 trigger,
5548 status,
5549 retried,
5550 reason,
5551 };
5552 self.append(&event).await;
5553 }
5554
5555 pub(crate) async fn write_gateway_credential(
5558 &self,
5559 artifact: &str,
5560 source: &'static str,
5561 credential: Option<&str>,
5562 change: CredentialChange,
5563 ) {
5564 let (change_name, trigger, status, retried, reason) = credential_change_fields(change);
5565 let event = GatewayCredentialEvent {
5566 event_type: "gateway_credential",
5567 event_id: new_event_id(),
5568 parent_id: Some(self.session_event_id.clone()),
5569 session_id: self.session_id.clone(),
5570 timestamp: timestamp_ms(),
5571 artifact,
5572 source,
5573 credential,
5574 change: change_name,
5575 trigger,
5576 status,
5577 retried,
5578 reason,
5579 };
5580 self.append(&event).await;
5581 }
5582
5583 pub(crate) async fn write_control_change(&self, change: ControlChange<'_>) {
5585 let event = ControlChangeEvent {
5586 event_type: "control_change",
5587 event_id: change.event_id,
5588 parent_id: Some(self.session_event_id.clone()),
5589 session_id: self.session_id.clone(),
5590 timestamp: timestamp_ms(),
5591 principal: change.principal.as_str(),
5592 token_id: change.token_id,
5593 kind: change.kind,
5594 name: change.name,
5595 action: change.action,
5596 previous: change.previous,
5597 value: change.value,
5598 applies_from: change.applies_from,
5599 replaced: change.replaced,
5600 };
5601 self.append(&event).await;
5602 }
5603
5604 pub(crate) async fn write_control_refused(
5607 &self,
5608 principal: crate::control_plane::Principal,
5609 status: u16,
5610 reason: &'static str,
5611 kind: Option<&'static str>,
5612 name: Option<&str>,
5613 token_id: Option<&str>,
5614 ) {
5615 let event = ControlRefusedEvent {
5616 event_type: "control_refused",
5617 event_id: new_event_id(),
5618 parent_id: Some(self.session_event_id.clone()),
5619 session_id: self.session_id.clone(),
5620 timestamp: timestamp_ms(),
5621 principal: principal.as_str(),
5622 status,
5623 reason,
5624 kind,
5625 name,
5626 token_id,
5627 };
5628 self.append(&event).await;
5629 }
5630
5631 async fn append(&self, event: &impl Serialize) {
5632 self.append_if(event, || true).await;
5633 }
5634
5635 #[cfg(test)]
5637 pub(crate) async fn append_for_test(&self, event: &impl Serialize) {
5638 self.append(event).await;
5639 }
5640
5641 async fn append_if(&self, event: &impl Serialize, admit: impl FnOnce() -> bool) -> bool {
5644 let Ok(line) = serde_json::to_string(event) else {
5645 return admit();
5646 };
5647 let _held = self.admit.lock().unwrap_or_else(|p| p.into_inner());
5648 if !admit() {
5649 return false;
5650 }
5651 let _ = self.chain.append_json(&line);
5652 true
5653 }
5654}
5655
5656pub(crate) struct PriorSessionTraceAppender {
5670 chain: Arc<crate::record_seal::InlineChain>,
5673 trace_dir: WorkdirRoot,
5676 trace: String,
5677 session_id: String,
5679 session_event_id: Option<String>,
5681}
5682
5683impl PriorSessionTraceAppender {
5684 pub(crate) async fn open(
5688 trace_dir: &WorkdirRoot,
5689 trace: &str,
5690 session_id: String,
5691 session_event_id: Option<String>,
5692 ) -> std::io::Result<Self> {
5693 if trace_dir.entry_kind(trace)?.is_none() {
5696 return Err(std::io::Error::from(std::io::ErrorKind::NotFound));
5697 }
5698 let chain = crate::record_seal::InlineChain::open(trace_dir, trace)?;
5699 Ok(Self {
5700 chain,
5701 trace_dir: trace_dir.clone(),
5702 trace: trace.to_string(),
5703 session_id,
5704 session_event_id,
5705 })
5706 }
5707
5708 pub(crate) async fn write_shell_lost(
5714 &mut self,
5715 work: &crate::detached::LostWork,
5716 reconciled_by_session: &str,
5717 reconciled_task_id: &str,
5718 ) -> std::io::Result<()> {
5719 let event = ShellLostEvent {
5720 event_type: "shell_lost",
5721 event_id: new_event_id(),
5722 parent_id: self.session_event_id.clone(),
5723 session_id: self.session_id.clone(),
5724 timestamp: timestamp_ms(),
5725 work_id: work.work_id.clone(),
5726 binary: work.binary.clone(),
5727 command: work.command.clone(),
5728 detached_at_ms: work.detached_at_ms,
5729 reconciled_by_session: reconciled_by_session.to_string(),
5730 reconciled_task_id: reconciled_task_id.to_string(),
5731 };
5732 self.chain.append_event(&event)?;
5734 Ok(())
5735 }
5736
5737 pub(crate) fn reseal(&self) {
5740 if let Err(reason) =
5741 crate::record_seal::seal_inline(&self.trace_dir, &self.trace, &self.session_id, None)
5742 {
5743 crate::runtime_err!(
5744 "[capsule-runtime] could not reseal {} ({reason}); it reads as unsealed",
5745 self.trace_dir.display_path(&self.trace)
5746 );
5747 }
5748 }
5749}
5750
5751pub(crate) fn timestamp_ms() -> u64 {
5752 SystemTime::now()
5753 .duration_since(UNIX_EPOCH)
5754 .unwrap_or_default()
5755 .as_millis() as u64
5756}
5757
5758#[derive(Serialize, Clone)]
5765pub(crate) struct PlanStepShape {
5766 pub(crate) step_id: String,
5767 pub(crate) kind: &'static str,
5770 pub(crate) depends_on: Vec<String>,
5771 pub(crate) has_condition: bool,
5773}
5774
5775#[derive(Serialize)]
5778struct PlanStartEvent {
5779 event_type: &'static str,
5780 event_id: String,
5781 parent_id: Option<String>,
5782 session_id: String,
5783 timestamp: u64,
5784 plan_id: String,
5785 step_count: usize,
5786 steps: Vec<PlanStepShape>,
5787}
5788
5789#[derive(Serialize)]
5795struct PlanStepStartEvent {
5796 event_type: &'static str,
5797 event_id: String,
5798 parent_id: Option<String>,
5799 session_id: String,
5800 timestamp: u64,
5801 plan_id: String,
5802 step_id: String,
5803 kind: &'static str,
5804 depends_on: Vec<String>,
5805}
5806
5807#[derive(Serialize)]
5811struct PlanStepEvent {
5812 event_type: &'static str,
5813 event_id: String,
5814 parent_id: Option<String>,
5815 session_id: String,
5816 timestamp: u64,
5817 plan_id: String,
5818 step_id: String,
5819 kind: &'static str,
5820 status: &'static str,
5821 attempts: u32,
5822 duration_ms: u64,
5823 #[serde(skip_serializing_if = "Option::is_none")]
5824 error: Option<String>,
5825 #[serde(skip_serializing_if = "Option::is_none")]
5828 input: Option<Value>,
5829 #[serde(skip_serializing_if = "Option::is_none")]
5833 state_effect: Option<String>,
5834 #[serde(skip_serializing_if = "Option::is_none")]
5836 resource_id: Option<String>,
5837}
5838
5839#[derive(Serialize)]
5841struct PlanEndEvent {
5842 event_type: &'static str,
5843 event_id: String,
5844 parent_id: Option<String>,
5845 session_id: String,
5846 timestamp: u64,
5847 plan_id: String,
5848 outcome: &'static str,
5849 #[serde(skip_serializing_if = "Option::is_none")]
5850 failed_step: Option<String>,
5851 steps_total: usize,
5852 steps_succeeded: usize,
5853 steps_failed: usize,
5854 steps_skipped: usize,
5855 duration_ms: u64,
5856 #[serde(skip_serializing_if = "Option::is_none")]
5859 reason: Option<String>,
5860}
5861
5862pub(crate) struct PlanStepRecord {
5864 pub(crate) plan_id: String,
5865 pub(crate) step_id: String,
5866 pub(crate) kind: &'static str,
5867 pub(crate) status: &'static str,
5870 pub(crate) attempts: u32,
5872 pub(crate) duration_ms: u64,
5873 pub(crate) error: Option<String>,
5874 pub(crate) input: Option<Value>,
5875 pub(crate) state_effect: Option<String>,
5876 pub(crate) resource_id: Option<String>,
5877}
5878
5879pub(crate) struct PlanEndRecord {
5881 pub(crate) plan_id: String,
5882 pub(crate) outcome: &'static str,
5883 pub(crate) failed_step: Option<String>,
5884 pub(crate) steps_total: usize,
5885 pub(crate) steps_succeeded: usize,
5886 pub(crate) steps_failed: usize,
5887 pub(crate) steps_skipped: usize,
5888 pub(crate) duration_ms: u64,
5889 pub(crate) reason: Option<String>,
5890}
5891
5892pub struct PlanTraceAppender {
5903 chain: Arc<crate::record_seal::InlineChain>,
5905 session_id: String,
5906 session_event_id: String,
5909}
5910
5911impl PlanTraceAppender {
5912 pub fn open(
5913 records_root: &WorkdirRoot,
5914 session_id: String,
5915 session_event_id: String,
5916 ) -> std::io::Result<Self> {
5917 let chain = crate::record_seal::InlineChain::open(records_root, TRACE_FILE_NAME)?;
5918 Ok(Self {
5919 chain,
5920 session_id,
5921 session_event_id,
5922 })
5923 }
5924
5925 pub(crate) fn write_plan_start(&self, plan_id: &str, steps: Vec<PlanStepShape>) -> String {
5927 let event_id = new_event_id();
5928 let event = PlanStartEvent {
5929 event_type: "plan_start",
5930 event_id: event_id.clone(),
5931 parent_id: Some(self.session_event_id.clone()),
5932 session_id: self.session_id.clone(),
5933 timestamp: timestamp_ms(),
5934 plan_id: plan_id.to_string(),
5935 step_count: steps.len(),
5936 steps,
5937 };
5938 self.append(&event);
5939 event_id
5940 }
5941
5942 pub(crate) fn write_plan_step_start(
5943 &self,
5944 plan_node: &str,
5945 plan_id: &str,
5946 step_id: &str,
5947 kind: &'static str,
5948 depends_on: Vec<String>,
5949 ) {
5950 let event = PlanStepStartEvent {
5951 event_type: "plan_step_start",
5952 event_id: new_event_id(),
5953 parent_id: Some(plan_node.to_string()),
5954 session_id: self.session_id.clone(),
5955 timestamp: timestamp_ms(),
5956 plan_id: plan_id.to_string(),
5957 step_id: step_id.to_string(),
5958 kind,
5959 depends_on,
5960 };
5961 self.append(&event);
5962 }
5963
5964 pub(crate) fn write_plan_step(&self, plan_node: &str, record: PlanStepRecord) {
5965 let input = record.input.map(|mut value| {
5968 crate::peer_handoff::redact_handles_in_json(&mut value);
5969 value
5970 });
5971 let event = PlanStepEvent {
5972 event_type: "plan_step",
5973 event_id: new_event_id(),
5974 parent_id: Some(plan_node.to_string()),
5975 session_id: self.session_id.clone(),
5976 timestamp: timestamp_ms(),
5977 plan_id: record.plan_id,
5978 step_id: record.step_id,
5979 kind: record.kind,
5980 status: record.status,
5981 attempts: record.attempts,
5982 duration_ms: record.duration_ms,
5983 error: record.error,
5984 input,
5985 state_effect: record.state_effect,
5986 resource_id: record.resource_id,
5987 };
5988 self.append(&event);
5989 }
5990
5991 pub(crate) fn write_plan_end(&self, plan_node: Option<&str>, record: PlanEndRecord) {
5994 let event = PlanEndEvent {
5995 event_type: "plan_end",
5996 event_id: new_event_id(),
5997 parent_id: Some(
5998 plan_node
5999 .map(str::to_string)
6000 .unwrap_or_else(|| self.session_event_id.clone()),
6001 ),
6002 session_id: self.session_id.clone(),
6003 timestamp: timestamp_ms(),
6004 plan_id: record.plan_id,
6005 outcome: record.outcome,
6006 failed_step: record.failed_step,
6007 steps_total: record.steps_total,
6008 steps_succeeded: record.steps_succeeded,
6009 steps_failed: record.steps_failed,
6010 steps_skipped: record.steps_skipped,
6011 duration_ms: record.duration_ms,
6012 reason: record.reason,
6013 };
6014 self.append(&event);
6015 }
6016
6017 #[allow(clippy::too_many_arguments)]
6023 pub(crate) fn write_delegation_start(
6024 &self,
6025 delegation_id: &str,
6026 capsule: &str,
6027 version: &str,
6028 child_session_id: &str,
6029 child_workdir: &str,
6030 child_records_dir: &str,
6031 workdir_snapshot: Option<&crate::workdir_snapshot::WorkdirSnapshot>,
6032 ) {
6033 let event = DelegationStartEvent::new(
6034 &self.session_id,
6035 &self.session_event_id,
6036 delegation_id,
6037 capsule,
6038 version,
6039 child_session_id,
6040 child_workdir,
6041 child_records_dir,
6042 workdir_snapshot,
6043 );
6044 self.append(&event);
6045 }
6046
6047 #[allow(clippy::too_many_arguments)]
6055 pub(crate) fn write_delegation(
6056 &self,
6057 capsule: &str,
6058 version: &str,
6059 delegation_id: Option<String>,
6060 child_session_id: Option<String>,
6061 duration_ms: u64,
6062 outcome: &str,
6063 reason: Option<String>,
6064 ) {
6065 let event = DelegationEvent::new(
6066 &self.session_id,
6067 &self.session_event_id,
6068 capsule,
6069 version,
6070 delegation_id,
6071 child_session_id,
6072 duration_ms,
6073 outcome,
6074 reason,
6075 );
6076 self.append(&event);
6077 }
6078
6079 fn append(&self, event: &impl Serialize) {
6083 let _ = self.chain.append_event(event);
6084 }
6085}
6086
6087#[cfg(test)]
6090mod tests {
6091 use super::*;
6092 use crate::{
6093 sandbox::EnforcementTier, sealed::UsernsGrant, trace_blobs::BLOB_DIR_NAME,
6094 types::CapabilityPolicy,
6095 };
6096 use murmur_artifact::{ArtifactRuntime, InterpreterRuntimeDir, InterpreterRuntimeGrant};
6097 use serde_json::Value;
6098 use std::path::Path;
6099
6100 use crate::origin::TaskOrigin;
6101
6102 fn event_provenance() -> TaskProvenance {
6104 TaskProvenance::derive(TaskOrigin::Event, None)
6105 }
6106
6107 fn report_for(
6111 declared: ContainmentClass,
6112 tier: EnforcementTier,
6113 workdir_exec: bool,
6114 ) -> ScopeReport {
6115 crate::containment::report_for(
6116 &CapabilityPolicy {
6117 workdir_exec_allowed: workdir_exec,
6118 ..CapabilityPolicy::default()
6119 },
6120 declared,
6121 tier,
6122 None,
6123 None,
6124 None,
6125 None,
6126 Vec::new(),
6127 Vec::new(),
6128 Vec::new(),
6129 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6130 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
6131 crate::cgroup::IoMaxReport::default(),
6132 )
6133 }
6134
6135 fn demotion_info() -> crate::detached::DetachedDispatchInfo {
6136 crate::detached::DetachedDispatchInfo {
6137 work_id: "wrk_0a1b2c3d4e5f6a7b".to_string(),
6138 binary: "/usr/bin/bash".to_string(),
6139 command: "make -j8".to_string(),
6140 grace_ms: Some(1_000),
6141 }
6142 }
6143
6144 #[tokio::test]
6147 async fn a_demotion_marker_that_cannot_be_written_does_not_fail_the_demotion() {
6148 let dir = tempfile::tempdir().unwrap();
6149 let mut writer = make_writer(dir.path()).await;
6153 let full = std::fs::OpenOptions::new()
6154 .write(true)
6155 .open("/dev/full")
6156 .unwrap();
6157 writer.chain = crate::record_seal::InlineChain::over_file_for_test(
6158 full,
6159 &WorkdirRoot::open(dir.path()).unwrap(),
6160 TRACE_FILE_NAME,
6161 );
6162 let info = demotion_info();
6163
6164 assert!(
6165 writer
6166 .write_shell_detached(1, &info.work_id, &info.binary, &info.command, info.grace_ms)
6167 .await
6168 .is_err(),
6169 "the sink under this writer must be unwritable for the claim below to mean anything"
6170 );
6171 crate::agent::record_demotion(&mut writer, 1, &info).await;
6172 }
6173
6174 #[tokio::test]
6176 async fn a_recorded_demotion_writes_only_its_own_marker() {
6177 let dir = tempfile::tempdir().unwrap();
6178 let mut writer = make_writer(dir.path()).await;
6179 crate::agent::record_demotion(&mut writer, 3, &demotion_info()).await;
6180
6181 let events = read_events(dir.path());
6182 assert_eq!(events.len(), 1);
6183 assert_eq!(events[0]["event_type"], "shell_detached");
6184 assert_eq!(events[0]["work_id"], "wrk_0a1b2c3d4e5f6a7b");
6185 assert_eq!(events[0]["turn"], 3);
6186 assert_eq!(events[0]["grace_ms"], 1_000);
6187 }
6188
6189 #[tokio::test]
6191 async fn a_demotion_with_no_grace_writes_no_grace_ms() {
6192 let dir = tempfile::tempdir().unwrap();
6193 let mut writer = make_writer(dir.path()).await;
6194 let info = crate::detached::DetachedDispatchInfo {
6195 grace_ms: None,
6196 ..demotion_info()
6197 };
6198 crate::agent::record_demotion(&mut writer, 3, &info).await;
6199
6200 let events = read_events(dir.path());
6201 assert_eq!(events.len(), 1);
6202 assert_eq!(events[0]["event_type"], "shell_detached");
6203 assert!(events[0].get("grace_ms").is_none(), "{}", events[0]);
6204 }
6205
6206 #[tokio::test]
6207 async fn a_safety_check_event_carries_the_checks_verbatim_and_its_outcome() {
6208 let dir = tempfile::tempdir().unwrap();
6209 let mut writer = make_writer(dir.path()).await;
6210 let checks = serde_json::json!([{"id": "sc_1", "code": "c", "message": "m", "x": 1}]);
6211 writer
6212 .write_safety_check(2, "call_1_0", "computer", &checks, "declined")
6213 .await
6214 .unwrap();
6215
6216 let events = read_events(dir.path());
6217 assert_eq!(events.len(), 1);
6218 assert_eq!(events[0]["event_type"], "safety_check");
6219 assert_eq!(events[0]["turn"], 2);
6220 assert_eq!(events[0]["tool_call_id"], "call_1_0");
6221 assert_eq!(events[0]["tool"], "computer");
6222 assert_eq!(events[0]["checks"], checks);
6223 assert_eq!(events[0]["outcome"], "declined");
6224 assert!(events[0]["event_id"].is_string() && events[0]["session_id"].is_string());
6225 }
6226
6227 #[tokio::test]
6228 async fn an_unrecorded_demotion_names_the_work_id_and_the_write_error() {
6229 let dir = tempfile::tempdir().unwrap();
6230 let mut writer = make_writer(dir.path()).await;
6231 writer
6232 .write_shell_detach_unrecorded(2, "wrk_0a1b", "/usr/bin/bash", "No space left")
6233 .await
6234 .unwrap();
6235
6236 let events = read_events(dir.path());
6237 assert_eq!(events.len(), 1);
6238 assert_eq!(events[0]["event_type"], "shell_detach_unrecorded");
6239 assert_eq!(events[0]["turn"], 2);
6240 assert_eq!(events[0]["work_id"], "wrk_0a1b");
6241 assert_eq!(events[0]["binary"], "/usr/bin/bash");
6242 assert_eq!(events[0]["reason"], "No space left");
6243 }
6244
6245 #[tokio::test]
6249 async fn an_abandoned_command_still_running_omits_the_fields_it_cannot_know() {
6250 let dir = tempfile::tempdir().unwrap();
6251 let mut writer = make_writer(dir.path()).await;
6252 writer
6253 .write_shell_abandoned(
6254 "wrk_0a1b2c3d4e5f6a7b",
6255 "/usr/bin/bash",
6256 "sleep 45; echo done",
6257 45_012,
6258 None,
6259 )
6260 .await
6261 .unwrap();
6262
6263 let events = read_events(dir.path());
6264 assert_eq!(events.len(), 1);
6265 let event = &events[0];
6266 assert_eq!(event["event_type"], "shell_abandoned");
6267 assert_eq!(event["work_id"], "wrk_0a1b2c3d4e5f6a7b");
6268 assert_eq!(event["binary"], "/usr/bin/bash");
6269 assert_eq!(event["command"], "sleep 45; echo done");
6270 assert_eq!(event["running_ms"], 45_012);
6271 for absent in ["exit_code", "output_path", "output_bytes"] {
6272 assert!(
6273 event.get(absent).is_none(),
6274 "{absent} must be omitted, not written as null: {event}"
6275 );
6276 }
6277 }
6278
6279 #[tokio::test]
6283 async fn an_abandoned_command_that_finished_carries_its_result() {
6284 let dir = tempfile::tempdir().unwrap();
6285 let mut writer = make_writer(dir.path()).await;
6286 writer
6287 .write_shell_abandoned(
6288 "wrk_0a1b2c3d4e5f6a7b",
6289 "/usr/bin/bash",
6290 "sleep 2; echo done",
6291 2_014,
6292 Some((
6293 0,
6294 "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log",
6295 91,
6296 )),
6297 )
6298 .await
6299 .unwrap();
6300
6301 let event = &read_events(dir.path())[0];
6302 assert_eq!(event["event_type"], "shell_abandoned");
6303 assert_eq!(event["running_ms"], 2_014);
6304 assert_eq!(event["exit_code"], 0);
6305 assert_eq!(
6306 event["output_path"],
6307 "/home/test/.murmur/records/work/ses_1/logs/wrk_0a1b2c3d4e5f6a7b.log"
6308 );
6309 assert_eq!(event["output_bytes"], 91);
6310 }
6311
6312 async fn make_writer(dir: &std::path::Path) -> TraceWriter {
6313 make_writer_with_opts(dir, TraceCapture::Meta).await
6314 }
6315
6316 async fn make_writer_with_opts(dir: &std::path::Path, capture: TraceCapture) -> TraceWriter {
6317 make_writer_with_prompt(dir, capture, None, false).await
6318 }
6319
6320 async fn make_writer_with_prompt(
6321 dir: &std::path::Path,
6322 capture: TraceCapture,
6323 system_prompt: Option<&str>,
6324 system_prompt_overridden: bool,
6325 ) -> TraceWriter {
6326 TraceWriter::open(
6327 &crate::workdir_fs::WorkdirRoot::open(dir).unwrap(),
6328 "test-session-id".to_string(),
6329 "test-capsule".to_string(),
6330 "1.0.0".to_string(),
6331 "claude-test".to_string(),
6332 vec!["shell".to_string()],
6333 report_for(
6334 ContainmentClass::Advisory,
6335 EnforcementTier::EnvironmentOnly,
6336 false,
6337 ),
6338 capture,
6339 system_prompt.map(str::to_string),
6340 system_prompt_overridden,
6341 None,
6342 None,
6343 None,
6344 None,
6345 )
6346 .await
6347 .unwrap()
6348 }
6349
6350 fn read_events(dir: &std::path::Path) -> Vec<Value> {
6351 let content = std::fs::read_to_string(dir.join("trace.jsonl")).unwrap();
6352 content
6353 .lines()
6354 .filter(|l| !l.is_empty())
6355 .map(|l| serde_json::from_str(l).unwrap())
6356 .collect()
6357 }
6358
6359 #[tokio::test]
6360 async fn session_start_fields() {
6361 let dir = tempfile::tempdir().unwrap();
6362 let mut w = make_writer(dir.path()).await;
6363 w.write_session_start(Some(10), None, vec!["bash".to_string()])
6364 .await
6365 .unwrap();
6366 w.flush().await.unwrap();
6367
6368 let events = read_events(dir.path());
6369 assert_eq!(events.len(), 1);
6370 let e = &events[0];
6371 assert_eq!(e["event_type"], "session_start");
6372 assert_eq!(e["session_id"], "test-session-id");
6373 assert_eq!(e["capsule_name"], "test-capsule");
6374 assert_eq!(e["capsule_version"], "1.0.0");
6375 assert_eq!(e["model"], "claude-test");
6376 assert_eq!(e["max_turns"], 10);
6377 assert_eq!(e["capabilities"], serde_json::json!(["shell"]));
6378 assert_eq!(e["tools_declared"], serde_json::json!(["bash"]));
6379 assert_eq!(e["containment_declared"], "advisory");
6380 assert_eq!(e["containment_achieved"], "advisory");
6381 assert_eq!(e["workdir_exec"], false);
6382 assert!(e["timestamp"].as_u64().unwrap() > 0);
6383 assert_eq!(e["model_limits"], serde_json::Value::Null);
6384 }
6385
6386 #[tokio::test]
6388 async fn session_start_writes_no_turn_cap_and_the_model_limits() {
6389 let dir = tempfile::tempdir().unwrap();
6390 let mut w = make_writer(dir.path()).await;
6391 let limits = crate::model_limits::ResolvedModelLimits {
6392 max_output_tokens: 65536,
6393 max_output_tokens_source: crate::model_limits::LimitSource::Driver,
6394 context_window: None,
6395 context_window_source: crate::model_limits::LimitSource::None,
6396 max_input_tokens: None,
6397 max_input_tokens_source: crate::model_limits::LimitSource::None,
6398 input_budget: None,
6399 input_budget_basis: crate::model_limits::BudgetBasis::None,
6400 };
6401 w.write_session_start(None, Some(limits), Vec::new())
6402 .await
6403 .unwrap();
6404 w.flush().await.unwrap();
6405
6406 let e = &read_events(dir.path())[0];
6407 assert!(e.get("max_turns").is_some_and(serde_json::Value::is_null));
6408 assert_eq!(
6409 e["model_limits"],
6410 serde_json::json!({
6411 "max_output_tokens": 65536,
6412 "max_output_tokens_source": "driver",
6413 "context_window": null,
6414 "context_window_source": "none",
6415 "max_input_tokens": null,
6416 "max_input_tokens_source": "none",
6417 "input_budget": null,
6418 "input_budget_basis": "none",
6419 })
6420 );
6421 }
6422
6423 #[tokio::test]
6426 async fn session_start_is_the_first_line_and_carries_a_members_formation() {
6427 let formation = FormationId::mint();
6428 for member in [Some(&formation), None] {
6429 let dir = tempfile::tempdir().unwrap();
6430 let mut w = make_writer(dir.path()).await;
6431 w.set_formation_id(member);
6432 w.write_session_start(Some(10), None, Vec::new())
6433 .await
6434 .unwrap();
6435 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
6436 .await
6437 .unwrap();
6438 w.flush().await.unwrap();
6439
6440 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6441 let first_line = content.lines().next().unwrap();
6442 let first: Value = serde_json::from_str(first_line).unwrap();
6443 assert_eq!(first["event_type"], "session_start");
6444 match member {
6445 Some(id) => {
6446 assert_eq!(first["formation_id"], id.as_str());
6447 assert!(
6448 first_line.contains(&format!("\"formation_id\":\"{id}\"")),
6449 "{first_line}"
6450 );
6451 }
6452 None => assert!(!first_line.contains("formation_id"), "{first_line}"),
6453 }
6454 assert_eq!(
6456 content.matches("formation_id").count(),
6457 usize::from(member.is_some())
6458 );
6459 }
6460 }
6461
6462 #[tokio::test]
6466 async fn a_members_trace_names_it_its_callees_and_its_callers() {
6467 let authority = crate::formation_credentials::FormationAuthority::for_test();
6468 let cases = [
6469 (Some(("coder", vec!["reviewer"])), Some("planner")),
6470 (Some(("reviewer", vec![])), None),
6471 (None, None),
6472 ];
6473 for (member, caller) in cases {
6474 let member = member.map(|(name, callees)| {
6475 crate::formation_credentials::FormationMember::from_bundle(
6476 authority.member_bundle(name, &callees),
6477 )
6478 });
6479 let dir = tempfile::tempdir().unwrap();
6480 let mut w = make_writer(dir.path()).await;
6481 w.set_formation_member(member.as_ref());
6482 w.write_session_start(Some(10), None, Vec::new())
6483 .await
6484 .unwrap();
6485 let door_caller = match caller {
6486 Some(caller) => crate::identity::TaskCaller::Credential(format!("member:{caller}")),
6487 None => crate::identity::TaskCaller::Operator,
6488 };
6489 w.write_a2a_task_received(
6490 "tsk_1",
6491 "ctx_1",
6492 "msg_1",
6493 None,
6494 &door_caller,
6495 caller,
6496 None,
6497 "hi",
6498 )
6499 .await
6500 .unwrap();
6501 w.flush().await.unwrap();
6502 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6503 let lines: Vec<Value> = content
6504 .lines()
6505 .map(|line| serde_json::from_str(line).unwrap())
6506 .collect();
6507 let start = &lines[0];
6508 let received = lines
6509 .iter()
6510 .find(|line| line["event_type"] == "a2a_task_received")
6511 .unwrap();
6512 match &member {
6513 Some(member) => {
6514 assert_eq!(start["formation_member"], member.name());
6515 assert_eq!(
6516 start["formation_callees"],
6517 serde_json::json!(member.callees().collect::<Vec<_>>())
6518 );
6519 }
6520 None => {
6521 assert!(start.get("formation_member").is_none(), "{start}");
6522 assert!(start.get("formation_callees").is_none(), "{start}");
6523 }
6524 }
6525 match caller {
6526 Some(caller) => {
6527 assert_eq!(received["caller"], "member");
6528 assert_eq!(received["caller_member"], caller);
6529 }
6530 None => {
6531 assert_eq!(received["caller"], "operator");
6532 assert!(received.get("caller_member").is_none(), "{received}");
6533 }
6534 }
6535 assert!(received.get("caller_credential").is_none(), "{received}");
6536 assert!(!content.contains("mft1."), "{content}");
6537 }
6538 }
6539
6540 #[tokio::test]
6544 async fn a_received_task_names_who_the_door_let_it_in_as() {
6545 use crate::identity::TaskCaller;
6546 let cases = [
6547 (TaskCaller::Operator, None, "operator", None),
6548 (TaskCaller::Public, None, "public", None),
6549 (
6550 TaskCaller::Credential("alice".to_string()),
6551 None,
6552 "credential",
6553 Some("alice"),
6554 ),
6555 (
6556 TaskCaller::Credential("member:planner".to_string()),
6557 Some("planner"),
6558 "member",
6559 None,
6560 ),
6561 ];
6562 for (door_caller, member, caller, credential) in cases {
6563 let dir = tempfile::tempdir().unwrap();
6564 let mut w = make_writer(dir.path()).await;
6565 w.write_session_start(Some(10), None, Vec::new())
6566 .await
6567 .unwrap();
6568 w.write_a2a_task_received(
6569 "tsk_1",
6570 "ctx_1",
6571 "msg_1",
6572 None,
6573 &door_caller,
6574 member,
6575 None,
6576 "hi",
6577 )
6578 .await
6579 .unwrap();
6580 w.flush().await.unwrap();
6581 let received = read_events(dir.path())
6582 .into_iter()
6583 .find(|line| line["event_type"] == "a2a_task_received")
6584 .unwrap();
6585 assert_eq!(received["caller"], caller, "{received}");
6586 match member {
6587 Some(member) => assert_eq!(received["caller_member"], member, "{received}"),
6588 None => assert!(received.get("caller_member").is_none(), "{received}"),
6589 }
6590 match credential {
6591 Some(name) => assert_eq!(received["caller_credential"], name, "{received}"),
6592 None => assert!(received.get("caller_credential").is_none(), "{received}"),
6593 }
6594 }
6595 }
6596
6597 #[tokio::test]
6600 async fn a_received_message_is_hashed_and_stored_by_capture_mode() {
6601 let text = "Name three colours, please.";
6602 let sha = murmur_artifact::sha256_hex(text.as_bytes());
6603 for capture in [
6604 TraceCapture::None,
6605 TraceCapture::Meta,
6606 TraceCapture::Content,
6607 ] {
6608 let dir = tempfile::tempdir().unwrap();
6609 let mut w = make_writer_with_opts(dir.path(), capture).await;
6610 w.write_a2a_task_received(
6611 "tsk_1",
6612 "ctx_1",
6613 "msg_1",
6614 None,
6615 &crate::identity::TaskCaller::Operator,
6616 None,
6617 None,
6618 text,
6619 )
6620 .await
6621 .unwrap();
6622 w.flush().await.unwrap();
6623 let content = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
6624 assert!(!content.contains(text), "{content}");
6625 let received = read_events(dir.path())
6626 .into_iter()
6627 .find(|line| line["event_type"] == "a2a_task_received")
6628 .unwrap();
6629 let blob = std::fs::read(blob_path(dir.path(), &sha)).ok();
6630 match capture {
6631 TraceCapture::None => {
6632 assert!(received.get("message_sha256").is_none(), "{received}");
6633 assert_eq!(blob, None);
6634 }
6635 TraceCapture::Meta => {
6636 assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6637 assert_eq!(blob, None);
6638 }
6639 TraceCapture::Content => {
6640 assert_eq!(received["message_sha256"], sha.as_str(), "{received}");
6641 assert_eq!(blob.as_deref(), Some(text.as_bytes()));
6642 }
6643 }
6644 }
6645 }
6646
6647 #[tokio::test]
6652 async fn session_start_records_system_prompt_source_and_hash() {
6653 let expected_sha = murmur_artifact::sha256_hex(b"Be terse.");
6654
6655 for (prompt, overridden, source, sha) in [
6656 (Some("Be terse."), false, "manifest", Some(&expected_sha)),
6657 (Some("Be terse."), true, "cli", Some(&expected_sha)),
6658 (None, false, "none", None),
6659 (None, true, "cli", None),
6662 ] {
6663 let dir = tempfile::tempdir().unwrap();
6664 let mut w =
6665 make_writer_with_prompt(dir.path(), TraceCapture::Meta, prompt, overridden).await;
6666 w.write_session_start(Some(1), None, Vec::new())
6667 .await
6668 .unwrap();
6669 w.flush().await.unwrap();
6670
6671 let e = &read_events(dir.path())[0];
6672 assert_eq!(e["system_prompt_source"], source, "prompt={prompt:?}");
6673 match sha {
6674 Some(sha) => assert_eq!(e["system_prompt_sha256"], sha.as_str()),
6675 None => assert!(
6676 e["system_prompt_sha256"].is_null(),
6677 "no prompt in effect must hash to null, got {}",
6678 e["system_prompt_sha256"]
6679 ),
6680 }
6681 }
6682 }
6683
6684 #[tokio::test]
6688 async fn session_start_records_the_prompt_by_hash_and_stores_it_only_under_content() {
6689 let sha = murmur_artifact::sha256_hex(b"Be terse.");
6690
6691 let dir = tempfile::tempdir().unwrap();
6692 let mut w =
6693 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), false).await;
6694 w.write_session_start(Some(1), None, Vec::new())
6695 .await
6696 .unwrap();
6697 w.flush().await.unwrap();
6698
6699 let e = &read_events(dir.path())[0];
6700 assert!(
6701 e.get("system_prompt").is_none(),
6702 "the verbatim prompt is not a session_start field, got {e}"
6703 );
6704 assert_eq!(e["system_prompt_sha256"], sha.as_str());
6705 assert!(
6706 !dir.path().join(BLOB_DIR_NAME).exists(),
6707 "meta stores no bodies"
6708 );
6709
6710 let opted_in = tempfile::tempdir().unwrap();
6711 let mut w = make_writer_with_prompt(
6712 opted_in.path(),
6713 TraceCapture::Content,
6714 Some("Be terse."),
6715 false,
6716 )
6717 .await;
6718 w.write_session_start(Some(1), None, Vec::new())
6719 .await
6720 .unwrap();
6721 w.flush().await.unwrap();
6722
6723 let event = &read_events(opted_in.path())[0];
6724 assert!(event.get("system_prompt").is_none());
6725 assert_eq!(event["system_prompt_sha256"], sha.as_str());
6726 let blob = opted_in.path().join(BLOB_DIR_NAME).join(&sha);
6727 assert_eq!(std::fs::read(&blob).unwrap(), b"Be terse.");
6728 }
6729
6730 #[tokio::test]
6733 async fn content_capture_stores_no_prompt_blob_when_there_is_no_prompt() {
6734 let dir = tempfile::tempdir().unwrap();
6735 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::Content, None, false).await;
6736 w.write_session_start(Some(1), None, Vec::new())
6737 .await
6738 .unwrap();
6739 w.flush().await.unwrap();
6740
6741 let e = &read_events(dir.path())[0];
6742 assert!(e.get("system_prompt").is_none());
6743 assert!(e["system_prompt_sha256"].is_null());
6744 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
6745 }
6746
6747 #[tokio::test]
6749 async fn every_task_session_start_repeats_the_same_system_prompt_record() {
6750 let dir = tempfile::tempdir().unwrap();
6751 let mut w =
6752 make_writer_with_prompt(dir.path(), TraceCapture::Meta, Some("Be terse."), true).await;
6753 w.write_session_start(Some(1), None, Vec::new())
6754 .await
6755 .unwrap();
6756 w.write_session_start(Some(1), None, Vec::new())
6757 .await
6758 .unwrap();
6759 w.flush().await.unwrap();
6760
6761 let events = read_events(dir.path());
6762 assert_eq!(events.len(), 2);
6763 assert_eq!(
6764 events[0]["system_prompt_source"],
6765 events[1]["system_prompt_source"]
6766 );
6767 assert_eq!(
6768 events[0]["system_prompt_sha256"],
6769 events[1]["system_prompt_sha256"]
6770 );
6771 assert_eq!(events[1]["system_prompt_source"], "cli");
6772 }
6773
6774 #[tokio::test]
6779 async fn session_start_records_workdir_exec_next_to_the_class_it_forced() {
6780 let dir = tempfile::tempdir().unwrap();
6781 let mut w = TraceWriter::open(
6782 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6783 "test-session-id".to_string(),
6784 "test-capsule".to_string(),
6785 "1.0.0".to_string(),
6786 "claude-test".to_string(),
6787 Vec::new(),
6788 report_for(
6790 ContainmentClass::Advisory,
6791 EnforcementTier::KernelFull,
6792 true,
6793 ),
6794 TraceCapture::Meta,
6795 None,
6796 false,
6797 None,
6798 None,
6799 None,
6800 None,
6801 )
6802 .await
6803 .unwrap();
6804 w.write_session_start(Some(1), None, Vec::new())
6805 .await
6806 .unwrap();
6807 w.flush().await.unwrap();
6808
6809 let events = read_events(dir.path());
6810 assert_eq!(events[0]["workdir_exec"], true);
6811 assert_eq!(events[0]["containment_achieved"], "advisory");
6812 }
6813
6814 #[tokio::test]
6817 async fn session_start_records_declared_and_achieved_containment_separately() {
6818 let dir = tempfile::tempdir().unwrap();
6819 let mut w = TraceWriter::open(
6820 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6821 "test-session-id".to_string(),
6822 "test-capsule".to_string(),
6823 "1.0.0".to_string(),
6824 "claude-test".to_string(),
6825 Vec::new(),
6826 report_for(ContainmentClass::Sealed, EnforcementTier::KernelFull, false),
6827 TraceCapture::Meta,
6828 None,
6829 false,
6830 None,
6831 None,
6832 None,
6833 None,
6834 )
6835 .await
6836 .unwrap();
6837 w.write_session_start(Some(1), None, Vec::new())
6838 .await
6839 .unwrap();
6840 w.flush().await.unwrap();
6841
6842 let events = read_events(dir.path());
6843 assert_eq!(events[0]["containment_declared"], "sealed");
6844 assert_eq!(events[0]["containment_achieved"], "scoped");
6845 }
6846
6847 #[tokio::test]
6855 async fn session_start_distinguishes_two_hosts_with_the_same_achieved_class() {
6856 async fn event_for(grant: UsernsGrant) -> Value {
6857 let report = crate::containment::report_for(
6858 &CapabilityPolicy::default(),
6859 ContainmentClass::Sealed,
6860 EnforcementTier::KernelSealed,
6861 None,
6862 Some(grant),
6863 None,
6864 None,
6865 Vec::new(),
6866 Vec::new(),
6867 Vec::new(),
6868 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6869 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6870 EnforcementTier::KernelSealed,
6871 ContainmentClass::Sealed,
6872 )),
6873 crate::cgroup::IoMaxReport::default(),
6874 );
6875 let dir = tempfile::tempdir().unwrap();
6876 let mut w = TraceWriter::open(
6877 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
6878 "test-session-id".to_string(),
6879 "test-capsule".to_string(),
6880 "1.0.0".to_string(),
6881 "claude-test".to_string(),
6882 Vec::new(),
6883 report,
6884 TraceCapture::Meta,
6885 None,
6886 false,
6887 None,
6888 None,
6889 None,
6890 None,
6891 )
6892 .await
6893 .unwrap();
6894 w.write_session_start(Some(1), None, Vec::new())
6895 .await
6896 .unwrap();
6897 w.flush().await.unwrap();
6898 let mut event = read_events(dir.path()).remove(0);
6899 event.as_object_mut().unwrap().remove("timestamp");
6902 event
6903 }
6904
6905 let through_profile = event_for(UsernsGrant::ProfileConfining).await;
6906 let host_wide = event_for(UsernsGrant::RestrictionDisabledHostWide).await;
6907
6908 assert_eq!(through_profile["containment_achieved"], "sealed");
6909 assert_eq!(host_wide["containment_achieved"], "sealed");
6910 assert_eq!(through_profile["userns_grant"], "profile_confining");
6911 assert_eq!(host_wide["userns_grant"], "restriction_disabled_host_wide");
6912 assert_ne!(
6913 through_profile, host_wide,
6914 "two hosts granting the user namespace by different mechanisms must not write the \
6915 same session_start record"
6916 );
6917
6918 let unprobed = crate::containment::report_for(
6921 &CapabilityPolicy::default(),
6922 ContainmentClass::Advisory,
6923 EnforcementTier::EnvironmentOnly,
6924 None,
6925 None,
6926 None,
6927 None,
6928 Vec::new(),
6929 Vec::new(),
6930 Vec::new(),
6931 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6932 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6933 EnforcementTier::EnvironmentOnly,
6934 ContainmentClass::Advisory,
6935 )),
6936 crate::cgroup::IoMaxReport::default(),
6937 );
6938 assert!(
6939 serde_json::to_value(&unprobed).unwrap()["userns_grant"].is_null(),
6940 "off Linux the key is present and null, not absent"
6941 );
6942 }
6943
6944 #[tokio::test]
6952 async fn session_start_records_the_whole_scope_report_as_effective_grants() {
6953 let policy = CapabilityPolicy {
6954 network_allow: vec!["https://api.example.com".to_string()],
6955 unix_sockets_allowed: false,
6956 filesystem_scope: Some("workdir".to_string()),
6957 shell_allow: vec!["python3".to_string()],
6958 spawn_allow: vec!["helper".to_string()],
6959 env_allow: vec!["TZ".to_string()],
6960 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6961 binary: "python3".to_string(),
6962 dirs: vec![InterpreterRuntimeDir {
6963 path: "/usr/lib/python3.11".to_string(),
6964 list_dir: true,
6965 }],
6966 }],
6967 ..CapabilityPolicy::default()
6968 };
6969 let expected = crate::containment::report_for(
6970 &policy,
6971 ContainmentClass::Scoped,
6972 EnforcementTier::KernelFull,
6973 None,
6974 None,
6975 Some(8),
6977 None,
6978 vec![crate::containment::StateStoreReport {
6981 artifact: "notes-tool".to_string(),
6982 store: "shey".to_string(),
6983 host_path: "/home/dev/.murmur/state/shey".to_string(),
6984 }],
6985 vec!["config-echo".to_string()],
6987 vec![crate::containment::PreopenReport {
6988 artifact: "notes-tool".to_string(),
6989 role: "tool".to_string(),
6990 scope: None,
6991 surface: crate::containment::PreopenSurface::WholeWorkdir,
6992 runtime_dir_reach: Some(crate::containment::RuntimeDirReach::MurmurRuntime),
6993 extra_roots: vec![crate::containment::ExtraRootReport {
6995 guest_path: "murmur-host/home/dev/.cargo".to_string(),
6996 path: "/home/dev/.cargo".to_string(),
6997 access: crate::host_paths::HostPathAccess::Read,
6998 source: crate::guest_roots::GuestRootSource::HostPaths,
6999 }],
7000 }],
7001 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
7002 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
7003 EnforcementTier::KernelFull,
7004 ContainmentClass::Scoped,
7005 )),
7006 crate::cgroup::IoMaxReport::default(),
7007 );
7008
7009 let dir = tempfile::tempdir().unwrap();
7010 let mut w = TraceWriter::open(
7011 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
7012 "test-session-id".to_string(),
7013 "test-capsule".to_string(),
7014 "1.0.0".to_string(),
7015 "claude-test".to_string(),
7016 vec!["network".to_string(), "shell".to_string()],
7017 expected.clone(),
7018 TraceCapture::Meta,
7019 None,
7020 false,
7021 None,
7022 None,
7023 None,
7024 None,
7025 )
7026 .await
7027 .unwrap();
7028 w.write_session_start(Some(1), None, Vec::new())
7029 .await
7030 .unwrap();
7031 w.flush().await.unwrap();
7032
7033 let events = read_events(dir.path());
7034 assert_eq!(
7035 events[0]["effective_grants"],
7036 serde_json::to_value(&expected).unwrap()
7037 );
7038
7039 assert_eq!(
7042 events[0]["capabilities"],
7043 serde_json::json!(["network", "shell"])
7044 );
7045 assert_eq!(
7046 events[0]["effective_grants"]["state_stores"],
7047 serde_json::json!([{
7048 "artifact": "notes-tool",
7049 "store": "shey",
7050 "host_path": "/home/dev/.murmur/state/shey",
7051 }])
7052 );
7053 assert_eq!(
7056 events[0]["effective_grants"]["configured_artifacts"],
7057 serde_json::json!(["config-echo"])
7058 );
7059 assert_eq!(
7063 events[0]["effective_grants"]["preopens"],
7064 serde_json::json!([{
7065 "artifact": "notes-tool",
7066 "role": "tool",
7067 "scope": null,
7068 "surface": "whole-workdir",
7069 "runtime_dir_reach": "murmur-runtime",
7070 "extra_roots": [{
7071 "guest_path": "murmur-host/home/dev/.cargo",
7072 "path": "/home/dev/.cargo",
7073 "access": "read",
7074 "source": "host_paths",
7075 }],
7076 }])
7077 );
7078 assert_eq!(events[0]["containment_declared"], "scoped");
7079 assert_eq!(events[0]["containment_achieved"], "scoped");
7080 assert_eq!(events[0]["workdir_exec"], false);
7081 assert_eq!(
7082 events[0]["effective_grants"]["network_allow"],
7083 serde_json::json!(["https://api.example.com"])
7084 );
7085 assert_eq!(
7086 events[0]["effective_grants"]["interpreter_runtime_grants"],
7087 serde_json::json!(["python3: /usr/lib/python3.11 (list_dir)"])
7088 );
7089 }
7090
7091 #[tokio::test]
7092 async fn inference_fields_and_snake_case() {
7093 let dir = tempfile::tempdir().unwrap();
7094 let mut w = make_writer(dir.path()).await;
7095 w.write_inference(
7096 0,
7097 Some(100),
7098 Some(50),
7099 "tool_call".to_string(),
7100 Some("tool_call"),
7101 Some("bash".to_string()),
7102 None,
7103 None,
7104 vec!["msg_0000000000000000000000000000000a".to_string()],
7105 None,
7106 None,
7107 )
7108 .await
7109 .unwrap();
7110 w.flush().await.unwrap();
7111
7112 let events = read_events(dir.path());
7113 let e = &events[0];
7114 assert_eq!(e["event_type"], "inference");
7115 assert_eq!(
7116 e["message_ids"],
7117 serde_json::json!(["msg_0000000000000000000000000000000a"]),
7118 "an agent-loop turn names the messages its request embedded"
7119 );
7120 assert_eq!(e["turn"], 0);
7121 assert_eq!(e["input_tokens"], 100);
7122 assert_eq!(e["output_tokens"], 50);
7123 assert_eq!(e["decision"], "tool_call");
7124 assert_eq!(e["tool_name"], "bash");
7125 assert!(e.get("inputTokens").is_none(), "must use snake_case");
7127 assert!(e.get("toolName").is_none(), "must use snake_case");
7128 for key in [
7130 "input_tokens_actual",
7131 "output_tokens_actual",
7132 "cached_tokens",
7133 "cache_write_tokens",
7134 ] {
7135 assert!(e.get(key).is_none(), "{key} must be omitted, not null or 0");
7136 }
7137 }
7138
7139 #[tokio::test]
7142 async fn inference_writes_only_the_reported_usage_members() {
7143 let dir = tempfile::tempdir().unwrap();
7144 let mut w = make_writer(dir.path()).await;
7145 let usage = DriverUsage {
7146 input_tokens: Some(12043),
7147 output_tokens: None,
7148 cached_tokens: Some(0),
7149 cache_write_tokens: None,
7150 thinking_tokens: None,
7151 };
7152 w.write_inference(
7153 0,
7154 Some(100),
7155 Some(50),
7156 "end_turn".to_string(),
7157 Some("end_turn"),
7158 None,
7159 None,
7160 Some(&usage),
7161 Vec::new(),
7162 None,
7163 None,
7164 )
7165 .await
7166 .unwrap();
7167 w.flush().await.unwrap();
7168
7169 let events = read_events(dir.path());
7170 let e = &events[0];
7171 assert_eq!(e["input_tokens"], 100, "the estimate is untouched");
7172 assert_eq!(e["input_tokens_actual"], 12043);
7173 assert_eq!(e["cached_tokens"], 0, "a reported zero is written");
7174 assert!(e.get("output_tokens_actual").is_none());
7175 assert!(e.get("cache_write_tokens").is_none());
7176 }
7177
7178 #[tokio::test]
7179 async fn failed_inference_writes_its_keys_and_counts_only_what_it_spent() {
7180 let dir = tempfile::tempdir().unwrap();
7181 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
7182 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7183 .await
7184 .unwrap();
7185 let usage = DriverUsage {
7186 input_tokens: Some(7),
7187 ..DriverUsage::default()
7188 };
7189 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
7190 w.write_failed_inference(FailedInference {
7191 turn: 0,
7192 error_code: INFERENCE_ERROR_CREDENTIAL_REJECTED,
7193 error: &long,
7194 provider_status: Some(401),
7195 usage: Some(&usage),
7196 origin: None,
7197 choice: None,
7198 message_ids: vec!["msg_1".to_string()],
7199 tool_name: None,
7200 spent: None,
7201 settings: None,
7202 retry: RetryNote::default(),
7203 gateway: None,
7204 })
7205 .await
7206 .unwrap();
7207 assert_eq!(w.task_turns(), 0, "an uncounted record moves no counter");
7208 assert_eq!((w.total_turns, w.total_input_tokens), (0, 0));
7209 assert_eq!((w.task_input_tokens, w.task_output_tokens), (0, 0));
7210
7211 w.write_failed_inference(FailedInference {
7212 turn: 1,
7213 error_code: INFERENCE_ERROR_HARNESS_AUTH,
7214 error: "bad login",
7215 provider_status: None,
7216 usage: None,
7217 origin: None,
7218 choice: None,
7219 message_ids: Vec::new(),
7220 tool_name: None,
7221 spent: Some((Some(10), Some(5))),
7222 settings: None,
7223 retry: RetryNote::default(),
7224 gateway: None,
7225 })
7226 .await
7227 .unwrap();
7228 assert_eq!(w.task_turns(), 1);
7229 assert_eq!(w.total_turns, 1);
7230 assert_eq!((w.total_input_tokens, w.total_output_tokens), (10, 5));
7231 assert_eq!((w.task_input_tokens, w.task_output_tokens), (10, 5));
7232 w.flush().await.unwrap();
7233
7234 let events = read_events(dir.path());
7235 let failed: Vec<&Value> = events
7236 .iter()
7237 .filter(|e| e["event_type"] == "inference")
7238 .collect();
7239 let keys = |e: &Value| {
7240 let mut keys: Vec<String> = e.as_object().unwrap().keys().cloned().collect();
7241 keys.sort();
7242 keys
7243 };
7244 assert_eq!(
7245 keys(failed[0]),
7246 [
7247 "decision",
7248 "error",
7249 "error_code",
7250 "event_id",
7251 "event_type",
7252 "input_tokens_actual",
7253 "message_ids",
7254 "parent_id",
7255 "prev_hash",
7256 "provider_status",
7257 "session_id",
7258 "stop_reason",
7259 "task_id",
7260 "timestamp",
7261 "tool_name",
7262 "turn",
7263 ]
7264 );
7265 assert_eq!(failed[0]["decision"], "error");
7266 assert_eq!(failed[0]["stop_reason"], "error");
7267 assert_eq!(failed[0]["error_code"], "credential_rejected");
7268 assert_eq!(failed[0]["provider_status"], 401);
7269 assert_eq!(failed[0]["input_tokens_actual"], 7);
7270 let error = failed[0]["error"].as_str().unwrap();
7271 assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
7272 assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
7273
7274 assert_eq!(failed[1]["error_code"], "harness_auth");
7275 assert_eq!(failed[1]["error"], "bad login");
7276 assert_eq!(failed[1]["input_tokens"], 10);
7277 assert_eq!(failed[1]["output_tokens"], 5);
7278 assert!(failed[1].get("provider_status").is_none());
7279 assert!(
7280 !dir.path().join("blobs").exists(),
7281 "a failed record stores no blob, even under trace.capture: content"
7282 );
7283 let task_start = events
7285 .iter()
7286 .find(|e| e["event_type"] == "task_start")
7287 .unwrap();
7288 assert_eq!(failed[0]["parent_id"], task_start["event_id"]);
7289 }
7290
7291 #[tokio::test]
7294 async fn gateway_timing_keys_follow_the_gateway() {
7295 let dir = tempfile::tempdir().unwrap();
7296 let mut w = make_writer(dir.path()).await;
7297 let failed = |turn, gateway| FailedInference {
7298 turn,
7299 error_code: INFERENCE_ERROR_DRIVER_FAILED,
7300 error: "stopped",
7301 provider_status: None,
7302 usage: None,
7303 origin: None,
7304 choice: None,
7305 message_ids: Vec::new(),
7306 tool_name: None,
7307 spent: None,
7308 settings: None,
7309 retry: RetryNote::default(),
7310 gateway,
7311 };
7312 w.write_failed_inference(failed(0, None)).await.unwrap();
7313 w.write_failed_inference(failed(1, Some(GatewayOffsets::default())))
7314 .await
7315 .unwrap();
7316 let answered = GatewayOffsets {
7317 reached_ms: Some(4),
7318 responded_ms: Some(90),
7319 };
7320 w.write_failed_inference(failed(2, Some(answered)))
7321 .await
7322 .unwrap();
7323 w.flush().await.unwrap();
7324
7325 let events = read_events(dir.path());
7326 assert!(events[0].get("gateway_reached_ms").is_none());
7327 assert!(events[0].get("upstream_responded_ms").is_none());
7328 assert_eq!(events[1]["gateway_reached_ms"], Value::Null);
7329 assert_eq!(events[1]["upstream_responded_ms"], Value::Null);
7330 assert!(events[1]
7331 .as_object()
7332 .unwrap()
7333 .contains_key("gateway_reached_ms"));
7334 assert!(events[1]
7335 .as_object()
7336 .unwrap()
7337 .contains_key("upstream_responded_ms"));
7338 assert_eq!(events[2]["gateway_reached_ms"], 4);
7339 assert_eq!(events[2]["upstream_responded_ms"], 90);
7340 }
7341
7342 #[tokio::test]
7346 async fn call_deadlines_are_drained_ahead_of_the_next_line() {
7347 let dir = tempfile::tempdir().unwrap();
7348 let mut w = make_writer(dir.path()).await;
7349 let deadlines = crate::limits::CallDeadlines::default();
7350 w.set_call_deadlines(deadlines.clone());
7351 let staged = |component: &str, call: &str| crate::limits::CallDeadline {
7352 component: component.to_string(),
7353 role: crate::limits::ComponentRole::Hook,
7354 call: call.to_string(),
7355 deadline_seconds: 2,
7356 elapsed_ms: 4_000,
7357 };
7358 deadlines.push_for_test(staged("stager", "on-stage"));
7359 w.write_session_start(None, None, Vec::new()).await.unwrap();
7360 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
7361 .await
7362 .unwrap();
7363 deadlines.push_for_test(crate::limits::CallDeadline {
7364 component: "stall-driver".to_string(),
7365 role: crate::limits::ComponentRole::Driver,
7366 call: "run".to_string(),
7367 deadline_seconds: 3,
7368 elapsed_ms: 5_001,
7369 });
7370 w.write_failed_inference(FailedInference {
7371 turn: 0,
7372 error_code: INFERENCE_ERROR_DRIVER_FAILED,
7373 error: "stopped",
7374 provider_status: None,
7375 usage: None,
7376 origin: None,
7377 choice: None,
7378 message_ids: Vec::new(),
7379 tool_name: None,
7380 spent: None,
7381 settings: None,
7382 retry: RetryNote::default(),
7383 gateway: Some(GatewayOffsets::default()),
7384 })
7385 .await
7386 .unwrap();
7387 w.write_session_end("ok").await.unwrap();
7388 w.flush().await.unwrap();
7389
7390 let events = read_events(dir.path());
7391 let types: Vec<&str> = events
7392 .iter()
7393 .map(|e| e["event_type"].as_str().unwrap())
7394 .collect();
7395 assert_eq!(
7396 types,
7397 [
7398 "session_start",
7399 "call_deadline",
7400 "task_start",
7401 "call_deadline",
7402 "inference",
7403 "session_end"
7404 ]
7405 );
7406 assert_eq!(events[1]["call"], "on-stage");
7407 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
7408 assert!(events[1].get("task_id").is_none(), "outside a task");
7409 let cut = &events[3];
7410 assert_eq!(cut["component"], "stall-driver");
7411 assert_eq!(cut["role"], "driver");
7412 assert_eq!(cut["call"], "run");
7413 assert_eq!(cut["deadline_seconds"], 3);
7414 assert_eq!(cut["elapsed_ms"], 5_001);
7415 assert_eq!(cut["task_id"], "tsk_1");
7416 assert_eq!(cut["parent_id"], events[2]["event_id"]);
7417 assert!(cut["event_id"].as_str().is_some());
7418 assert!(cut["timestamp"].as_u64().is_some());
7419 }
7420
7421 #[tokio::test]
7422 async fn a_failed_hook_record_hangs_off_the_turn_and_keeps_hook_accounting() {
7423 let dir = tempfile::tempdir().unwrap();
7424 let mut w = make_writer(dir.path()).await;
7425 w.write_inference(
7426 0,
7427 Some(10),
7428 Some(5),
7429 "end_turn".to_string(),
7430 Some("end_turn"),
7431 None,
7432 None,
7433 None,
7434 Vec::new(),
7435 None,
7436 None,
7437 )
7438 .await
7439 .unwrap();
7440 let origin = InferenceOrigin {
7441 source: "hook:compactor".to_string(),
7442 model: "m".to_string(),
7443 };
7444 w.write_failed_inference(FailedInference {
7445 turn: 0,
7446 error_code: INFERENCE_ERROR_DRIVER_ERROR,
7447 error: "HTTP 500: boom",
7448 provider_status: Some(500),
7449 usage: None,
7450 origin: Some(&origin),
7451 choice: None,
7452 message_ids: Vec::new(),
7453 tool_name: None,
7454 spent: Some((Some(40), None)),
7455 settings: None,
7456 retry: RetryNote::default(),
7457 gateway: None,
7458 })
7459 .await
7460 .unwrap();
7461 assert_eq!(w.total_turns, 2);
7462 assert_eq!((w.total_input_tokens, w.total_output_tokens), (50, 5));
7463 w.flush().await.unwrap();
7464
7465 let events = read_events(dir.path());
7466 let turn = events.iter().find(|e| e["decision"] == "end_turn").unwrap();
7467 let hook = events.iter().find(|e| e["decision"] == "error").unwrap();
7468 assert_eq!(hook["parent_id"], turn["event_id"]);
7469 assert_eq!(hook["origin"], "hook:compactor");
7470 assert_eq!(hook["model"], "m");
7471 assert_eq!(hook["input_tokens"], 40);
7472 assert!(hook.get("output_tokens").is_none());
7473 }
7474
7475 #[tokio::test]
7479 async fn inference_call_settings_are_written_on_every_kind_of_http_line() {
7480 let dir = tempfile::tempdir().unwrap();
7481 let mut w = make_writer(dir.path()).await;
7482 let turn = InferenceCallSettings {
7483 max_output_tokens: 4096,
7484 input_budget: Some(195_904),
7485 driver_config: Some(serde_json::json!({"reasoning_effort": "high"})),
7486 };
7487 let unconfigured = InferenceCallSettings {
7488 max_output_tokens: 1024,
7489 input_budget: None,
7490 driver_config: None,
7491 };
7492 let hook = InferenceCallSettings {
7493 max_output_tokens: 2000,
7494 input_budget: Some(198_000),
7495 driver_config: Some(serde_json::json!({"reasoning_effort": "low"})),
7496 };
7497 let origin = InferenceOrigin {
7498 source: "hook:compactor".to_string(),
7499 model: "m".to_string(),
7500 };
7501 w.write_turn_inference(
7502 0,
7503 Some(10),
7504 Some(5),
7505 "end_turn".to_string(),
7506 Some("end_turn"),
7507 None,
7508 None,
7509 Vec::new(),
7510 None,
7511 None,
7512 Some(&turn),
7513 RetryNote::default(),
7514 None,
7515 )
7516 .await
7517 .unwrap();
7518 w.write_turn_inference(
7519 1,
7520 Some(10),
7521 Some(5),
7522 "end_turn".to_string(),
7523 Some("end_turn"),
7524 None,
7525 None,
7526 Vec::new(),
7527 None,
7528 None,
7529 Some(&unconfigured),
7530 RetryNote::default(),
7531 None,
7532 )
7533 .await
7534 .unwrap();
7535 w.write_inference(
7536 1,
7537 Some(30),
7538 Some(8),
7539 "end_turn".to_string(),
7540 None,
7541 None,
7542 Some(&origin),
7543 None,
7544 Vec::new(),
7545 None,
7546 Some(&hook),
7547 )
7548 .await
7549 .unwrap();
7550 w.write_failed_inference(FailedInference {
7551 turn: 2,
7552 error_code: INFERENCE_ERROR_DRIVER_ERROR,
7553 error: "HTTP 500: boom",
7554 provider_status: Some(500),
7555 usage: None,
7556 origin: None,
7557 choice: None,
7558 message_ids: Vec::new(),
7559 tool_name: None,
7560 spent: None,
7561 settings: Some(&turn),
7562 retry: RetryNote::default(),
7563 gateway: None,
7564 })
7565 .await
7566 .unwrap();
7567 w.flush().await.unwrap();
7568
7569 let events = read_events(dir.path());
7570 let settings: Vec<(Value, Value, Value, Value)> = events
7571 .iter()
7572 .filter(|e| e["event_type"] == "inference")
7573 .map(|e| {
7574 (
7575 e["decision"].clone(),
7576 e["max_output_tokens"].clone(),
7577 e.get("input_budget")
7578 .cloned()
7579 .expect("input_budget is written, null or not"),
7580 e.get("driver_config")
7581 .cloned()
7582 .expect("driver_config is written, null or not"),
7583 )
7584 })
7585 .collect();
7586 assert_eq!(
7587 settings,
7588 [
7589 (
7590 serde_json::json!("end_turn"),
7591 serde_json::json!(4096),
7592 serde_json::json!(195_904),
7593 serde_json::json!({"reasoning_effort": "high"})
7594 ),
7595 (
7596 serde_json::json!("end_turn"),
7597 serde_json::json!(1024),
7598 Value::Null,
7599 Value::Null
7600 ),
7601 (
7602 serde_json::json!("end_turn"),
7603 serde_json::json!(2000),
7604 serde_json::json!(198_000),
7605 serde_json::json!({"reasoning_effort": "low"})
7606 ),
7607 (
7608 serde_json::json!("error"),
7609 serde_json::json!(4096),
7610 serde_json::json!(195_904),
7611 serde_json::json!({"reasoning_effort": "high"})
7612 ),
7613 ]
7614 );
7615 }
7616
7617 #[tokio::test]
7619 async fn an_inference_line_without_call_settings_writes_neither_key() {
7620 let dir = tempfile::tempdir().unwrap();
7621 let mut w = make_writer(dir.path()).await;
7622 w.write_inference(
7623 0,
7624 Some(10),
7625 Some(5),
7626 "end_turn".to_string(),
7627 None,
7628 None,
7629 None,
7630 None,
7631 Vec::new(),
7632 None,
7633 None,
7634 )
7635 .await
7636 .unwrap();
7637 w.write_failed_inference(FailedInference {
7638 turn: 1,
7639 error_code: INFERENCE_ERROR_HARNESS_AUTH,
7640 error: "bad login",
7641 provider_status: None,
7642 usage: None,
7643 origin: None,
7644 choice: None,
7645 message_ids: Vec::new(),
7646 tool_name: None,
7647 spent: None,
7648 settings: None,
7649 retry: RetryNote::default(),
7650 gateway: None,
7651 })
7652 .await
7653 .unwrap();
7654 w.flush().await.unwrap();
7655
7656 let events = read_events(dir.path());
7657 let lines: Vec<&Value> = events
7658 .iter()
7659 .filter(|e| e["event_type"] == "inference")
7660 .collect();
7661 assert_eq!(lines.len(), 2);
7662 for line in lines {
7663 assert!(line.get("max_output_tokens").is_none(), "{line}");
7664 assert!(line.get("input_budget").is_none(), "{line}");
7665 assert!(line.get("driver_config").is_none(), "{line}");
7666 }
7667 }
7668
7669 fn driver_config_env(value: &str) -> Vec<(String, String)> {
7670 vec![
7671 ("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string()),
7672 (
7673 "MURMUR_INFERENCE_DRIVER_CONFIG".to_string(),
7674 value.to_string(),
7675 ),
7676 ]
7677 }
7678
7679 fn redact_secret(text: &str) -> String {
7680 text.replace("sk-secret", crate::gateway_credential::REDACTED)
7681 }
7682
7683 #[test]
7684 fn recorded_driver_config_is_none_without_the_variable() {
7685 let env = vec![("MURMUR_INFERENCE_MODEL".to_string(), "gpt-5".to_string())];
7686 assert_eq!(recorded_driver_config(&env, redact_secret), None);
7687 }
7688
7689 #[test]
7690 fn recorded_driver_config_is_the_parsed_object() {
7691 assert_eq!(
7692 recorded_driver_config(
7693 &driver_config_env(r#"{"reasoning_effort":"low"}"#),
7694 redact_secret
7695 ),
7696 Some(serde_json::json!({"reasoning_effort": "low"}))
7697 );
7698 }
7699
7700 #[test]
7701 fn recorded_driver_config_redacts_keys_and_string_leaves_at_any_depth() {
7702 let env = driver_config_env(
7703 r#"{"note":"x-sk-secret-y","nested":{"list":["sk-secret"]},"sk-secret":1}"#,
7704 );
7705 assert_eq!(
7706 recorded_driver_config(&env, redact_secret),
7707 Some(serde_json::json!({
7708 "note": "x-[redacted]-y",
7709 "nested": {"list": ["[redacted]"]},
7710 "[redacted]": 1,
7711 }))
7712 );
7713 }
7714
7715 #[test]
7716 fn recorded_driver_config_replaces_a_number_the_redactor_changes() {
7717 let redact_pin = |text: &str| text.replace("424242", crate::gateway_credential::REDACTED);
7718 let env = driver_config_env(r#"{"pin":424242,"retries":3,"store":false,"seed":null}"#);
7719 assert_eq!(
7720 recorded_driver_config(&env, redact_pin),
7721 Some(serde_json::json!({
7722 "pin": "[redacted]",
7723 "retries": 3,
7724 "store": false,
7725 "seed": null,
7726 }))
7727 );
7728 }
7729
7730 #[test]
7731 fn recorded_driver_config_records_text_that_is_not_json_as_a_redacted_string() {
7732 assert_eq!(
7733 recorded_driver_config(&driver_config_env("effort=sk-secret"), redact_secret),
7734 Some(Value::String("effort=[redacted]".to_string()))
7735 );
7736 }
7737
7738 #[tokio::test]
7739 async fn tool_call_fields() {
7740 let dir = tempfile::tempdir().unwrap();
7741 let mut w = make_writer(dir.path()).await;
7742 w.write_tool_call(
7743 1,
7744 "bash".to_string(),
7745 None,
7746 serde_json::json!({"command": "echo hi"}),
7747 42,
7748 "hi\n",
7749 100,
7750 55,
7751 "ok".to_string(),
7752 None,
7753 None,
7754 None,
7755 )
7756 .await
7757 .unwrap();
7758 w.flush().await.unwrap();
7759
7760 let events = read_events(dir.path());
7761 let e = &events[0];
7762 assert_eq!(e["event_type"], "tool_call");
7763 assert_eq!(e["turn"], 1);
7764 assert_eq!(e["tool_name"], "bash");
7765 assert_eq!(e["input_bytes"], 42);
7766 assert_eq!(e["output_bytes"], 100);
7767 assert_eq!(e["duration_ms"], 55);
7768 assert_eq!(e["status"], "ok");
7769 assert!(
7770 e.get("state_effect").is_none(),
7771 "state_effect must be omitted when the tool declared nothing"
7772 );
7773 }
7774
7775 #[tokio::test]
7776 async fn tool_call_records_declared_state_effect() {
7777 let dir = tempfile::tempdir().unwrap();
7778 let mut w = make_writer(dir.path()).await;
7779 w.write_tool_call(
7780 0,
7781 "murmur-tool-editor".to_string(),
7782 None,
7783 serde_json::json!({"operation": "read_file", "path": "a.txt"}),
7784 10,
7785 "hi\n",
7786 3,
7787 5,
7788 "ok".to_string(),
7789 Some("read".to_string()),
7790 None,
7791 None,
7792 )
7793 .await
7794 .unwrap();
7795 w.flush().await.unwrap();
7796
7797 let events = read_events(dir.path());
7798 assert_eq!(
7799 events[0]["state_effect"], "read",
7800 "declared state_effect must be recorded verbatim on the tool_call event"
7801 );
7802 }
7803
7804 #[tokio::test]
7805 async fn tool_call_records_declared_resource_id() {
7806 let dir = tempfile::tempdir().unwrap();
7807 let mut w = make_writer(dir.path()).await;
7808 w.write_tool_call(
7809 0,
7810 "murmur-tool-code-graph".to_string(),
7811 None,
7812 serde_json::json!({"symbol": "Foo::bar"}),
7813 10,
7814 "hi\n",
7815 3,
7816 5,
7817 "ok".to_string(),
7818 Some("read".to_string()),
7819 Some("sym:Foo".to_string()),
7820 None,
7821 )
7822 .await
7823 .unwrap();
7824 w.flush().await.unwrap();
7825
7826 let events = read_events(dir.path());
7827 assert_eq!(
7828 events[0]["resource_id"], "sym:Foo",
7829 "declared resource_id must reach trace.jsonl verbatim, unparsed"
7830 );
7831 }
7832
7833 #[tokio::test]
7834 async fn tool_call_omits_undeclared_resource_id() {
7835 let dir = tempfile::tempdir().unwrap();
7836 let mut w = make_writer(dir.path()).await;
7837 w.write_tool_call(
7838 0,
7839 "bash".to_string(),
7840 None,
7841 serde_json::json!({"command": "echo hi"}),
7842 10,
7843 "hi\n",
7844 3,
7845 5,
7846 "ok".to_string(),
7847 None,
7848 None,
7849 None,
7850 )
7851 .await
7852 .unwrap();
7853 w.flush().await.unwrap();
7854
7855 let events = read_events(dir.path());
7856 assert!(
7857 events[0].get("resource_id").is_none(),
7858 "resource_id must be omitted entirely (not null) when the tool declared nothing"
7859 );
7860 }
7861
7862 #[tokio::test]
7863 async fn shell_fields() {
7864 let dir = tempfile::tempdir().unwrap();
7865 let mut w = make_writer(dir.path()).await;
7866 w.write_shell(
7867 0,
7868 "/usr/bin/echo".to_string(),
7869 "echo hi".to_string(),
7870 0,
7871 7,
7872 0,
7873 10,
7874 None,
7875 )
7876 .await
7877 .unwrap();
7878 w.flush().await.unwrap();
7879
7880 let events = read_events(dir.path());
7881 let e = &events[0];
7882 assert_eq!(e["event_type"], "shell");
7883 assert_eq!(e["binary"], "/usr/bin/echo");
7886 assert_eq!(e["command"], "echo hi");
7887 assert_eq!(e["exit_code"], 0);
7888 assert_eq!(e["stdout_bytes"], 7);
7889 assert_eq!(e["stderr_bytes"], 0);
7890 assert_eq!(e["duration_ms"], 10);
7891 }
7892
7893 #[tokio::test]
7894 async fn compaction_fields() {
7895 let dir = tempfile::tempdir().unwrap();
7896 let mut w = make_writer(dir.path()).await;
7897 w.write_compaction(3, 80000, OccupancyBasis::Actual, 20000)
7898 .await
7899 .unwrap();
7900 w.write_compaction(4, 30000, OccupancyBasis::Estimate, 10000)
7901 .await
7902 .unwrap();
7903 w.flush().await.unwrap();
7904
7905 let events = read_events(dir.path());
7906 let e = &events[0];
7907 assert_eq!(e["event_type"], "compaction");
7908 assert_eq!(e["turn"], 3);
7909 assert_eq!(e["tokens_before"], 80000);
7910 assert_eq!(e["tokens_before_source"], "actual");
7911 assert_eq!(e["tokens_after"], 20000);
7912 assert_eq!(events[1]["tokens_before_source"], "estimate");
7913 }
7914
7915 #[tokio::test]
7916 async fn hook_dispatch_error_fields() {
7917 let dir = tempfile::tempdir().unwrap();
7918 let mut w = make_writer(dir.path()).await;
7919 w.write_hook_dispatch_error("my-hook", "on-tool-call", "write-manifests")
7920 .await
7921 .unwrap();
7922 w.flush().await.unwrap();
7923
7924 let events = read_events(dir.path());
7925 let e = &events[0];
7926 assert_eq!(e["event_type"], "hook_dispatch_error");
7927 assert_eq!(e["session_id"], "test-session-id");
7928 assert_eq!(e["hook_name"], "my-hook");
7929 assert_eq!(e["event"], "on-tool-call");
7930 assert_eq!(e["arm"], "write-manifests");
7931 assert!(e["timestamp"].as_u64().unwrap() > 0);
7932 assert!(e.get("hookName").is_none(), "must use snake_case");
7934 }
7935
7936 #[tokio::test]
7937 async fn session_end_fields_and_snake_case() {
7938 let dir = tempfile::tempdir().unwrap();
7939 let mut w = make_writer(dir.path()).await;
7940 w.write_inference(
7941 0,
7942 Some(100),
7943 Some(50),
7944 "tool_call".to_string(),
7945 Some("tool_call"),
7946 None,
7947 None,
7948 None,
7949 Vec::new(),
7950 None,
7951 None,
7952 )
7953 .await
7954 .unwrap();
7955 w.write_tool_call(
7956 0,
7957 "bash".to_string(),
7958 None,
7959 serde_json::json!({"command": "ls"}),
7960 10,
7961 "file.txt\n",
7962 20,
7963 5,
7964 "ok".to_string(),
7965 None,
7966 None,
7967 None,
7968 )
7969 .await
7970 .unwrap();
7971 w.write_shell(0, "/bin/ls".to_string(), "ls".to_string(), 0, 3, 0, 2, None)
7972 .await
7973 .unwrap();
7974 w.write_session_end("ok").await.unwrap();
7975 w.flush().await.unwrap();
7976
7977 let events = read_events(dir.path());
7978 let se = events.last().unwrap();
7979 assert_eq!(se["event_type"], "session_end");
7980 assert_eq!(se["total_turns"], 1);
7981 assert_eq!(se["total_input_tokens"], 100);
7982 assert_eq!(se["total_output_tokens"], 50);
7983 assert_eq!(se["total_tool_calls"], 1);
7984 assert_eq!(se["total_shell_calls"], 1);
7985 assert_eq!(se["exit_status"], "ok");
7986 assert!(se["duration_ms"].as_u64().is_some());
7987 assert!(se.get("exitStatus").is_none(), "must use snake_case");
7989 assert!(se.get("totalTurns").is_none(), "must use snake_case");
7990 }
7991
7992 #[tokio::test]
7993 async fn write_session_end_if_not_ended_is_noop_when_already_ended() {
7994 let dir = tempfile::tempdir().unwrap();
7995 let mut w = make_writer(dir.path()).await;
7996 w.write_session_start(Some(10), None, vec![]).await.unwrap();
7997 w.write_session_end("ok").await.unwrap();
7998 w.write_session_end_if_not_ended("failed").await.unwrap();
7999 w.flush().await.unwrap();
8000
8001 let events = read_events(dir.path());
8002 assert_eq!(
8003 events
8004 .iter()
8005 .filter(|e| e["event_type"] == "session_end")
8006 .count(),
8007 1,
8008 "session_end should appear exactly once"
8009 );
8010 }
8011
8012 #[tokio::test]
8013 async fn write_session_end_if_not_ended_writes_when_started_not_ended() {
8014 let dir = tempfile::tempdir().unwrap();
8015 let mut w = make_writer(dir.path()).await;
8016 w.write_session_start(Some(10), None, vec![]).await.unwrap();
8017 w.write_session_end_if_not_ended("failed").await.unwrap();
8019 w.flush().await.unwrap();
8020
8021 let events = read_events(dir.path());
8022 let se = events
8023 .iter()
8024 .find(|e| e["event_type"] == "session_end")
8025 .unwrap();
8026 assert_eq!(se["exit_status"], "failed");
8027 }
8028
8029 #[tokio::test]
8030 async fn no_session_end_written_when_session_never_started() {
8031 let dir = tempfile::tempdir().unwrap();
8032 let mut w = make_writer(dir.path()).await;
8033 w.write_session_end_if_not_ended("failed").await.unwrap();
8035 w.flush().await.unwrap();
8036
8037 let events = read_events(dir.path());
8038 assert!(
8039 events.iter().all(|e| e["event_type"] != "session_end"),
8040 "session_end must not appear when session_start was never written"
8041 );
8042 }
8043
8044 #[tokio::test]
8045 async fn session_id_on_every_event() {
8046 let dir = tempfile::tempdir().unwrap();
8047 let mut w = make_writer(dir.path()).await;
8048 w.write_session_start(Some(10), None, vec!["bash".to_string()])
8049 .await
8050 .unwrap();
8051 w.write_inference(
8052 0,
8053 Some(10),
8054 Some(5),
8055 "end_turn".to_string(),
8056 Some("end_turn"),
8057 None,
8058 None,
8059 None,
8060 Vec::new(),
8061 None,
8062 None,
8063 )
8064 .await
8065 .unwrap();
8066 w.write_session_end("ok").await.unwrap();
8067 w.flush().await.unwrap();
8068
8069 let events = read_events(dir.path());
8070 for e in &events {
8071 assert_eq!(e["session_id"], "test-session-id");
8072 }
8073 }
8074
8075 #[tokio::test]
8076 async fn counts_match_per_event_sum() {
8077 let dir = tempfile::tempdir().unwrap();
8078 let mut w = make_writer(dir.path()).await;
8079 w.write_session_start(Some(10), None, vec![]).await.unwrap();
8080 w.write_inference(
8081 0,
8082 Some(50),
8083 Some(25),
8084 "tool_call".to_string(),
8085 Some("tool_call"),
8086 Some("bash".to_string()),
8087 None,
8088 None,
8089 vec!["msg_0000000000000000000000000000000a".to_string()],
8090 None,
8091 None,
8092 )
8093 .await
8094 .unwrap();
8095 w.write_tool_call(
8096 0,
8097 "bash".to_string(),
8098 None,
8099 serde_json::json!({"command": "echo"}),
8100 5,
8101 "ok",
8102 10,
8103 3,
8104 "ok".to_string(),
8105 None,
8106 None,
8107 None,
8108 )
8109 .await
8110 .unwrap();
8111 w.write_shell(
8112 0,
8113 "/bin/echo".to_string(),
8114 "echo".to_string(),
8115 0,
8116 4,
8117 0,
8118 1,
8119 None,
8120 )
8121 .await
8122 .unwrap();
8123 w.write_inference(
8124 1,
8125 Some(60),
8126 Some(30),
8127 "end_turn".to_string(),
8128 Some("end_turn"),
8129 None,
8130 None,
8131 None,
8132 Vec::new(),
8133 None,
8134 None,
8135 )
8136 .await
8137 .unwrap();
8138 w.write_session_end("ok").await.unwrap();
8139 w.flush().await.unwrap();
8140
8141 let events = read_events(dir.path());
8142 let inference_count = events
8143 .iter()
8144 .filter(|e| e["event_type"] == "inference")
8145 .count();
8146 let tool_count = events
8147 .iter()
8148 .filter(|e| e["event_type"] == "tool_call")
8149 .count();
8150 let shell_count = events.iter().filter(|e| e["event_type"] == "shell").count();
8151
8152 let se = events
8153 .iter()
8154 .find(|e| e["event_type"] == "session_end")
8155 .unwrap();
8156 assert_eq!(
8157 se["total_turns"].as_u64().unwrap() as usize,
8158 inference_count
8159 );
8160 assert_eq!(
8161 se["total_tool_calls"].as_u64().unwrap() as usize,
8162 tool_count
8163 );
8164 assert_eq!(
8165 se["total_shell_calls"].as_u64().unwrap() as usize,
8166 shell_count
8167 );
8168 }
8169
8170 #[tokio::test]
8171 async fn tool_call_input_always_present() {
8172 let dir = tempfile::tempdir().unwrap();
8173 let mut w = make_writer(dir.path()).await; w.write_tool_call(
8175 0,
8176 "bash".to_string(),
8177 None,
8178 serde_json::json!({"command": "ls -la"}),
8179 20,
8180 "total 8\n",
8181 80,
8182 12,
8183 "ok".to_string(),
8184 None,
8185 None,
8186 None,
8187 )
8188 .await
8189 .unwrap();
8190 w.flush().await.unwrap();
8191
8192 let events = read_events(dir.path());
8193 let e = &events[0];
8194 assert_eq!(e["event_type"], "tool_call");
8195 assert_eq!(e["input"]["command"], "ls -la");
8196 assert!(e.get("input").is_some(), "input must always be present");
8197 }
8198
8199 async fn tool_call_with_error(
8201 dir: &std::path::Path,
8202 capture: TraceCapture,
8203 error: Option<&str>,
8204 ) -> Value {
8205 let mut w = make_writer_with_opts(dir, capture).await;
8206 w.write_tool_call(
8207 0,
8208 "tool-fixture".to_string(),
8209 Some("toolu_1".to_string()),
8210 serde_json::json!({}),
8211 2,
8212 "shell execution failed early",
8213 28,
8214 3,
8215 "error".to_string(),
8216 None,
8217 None,
8218 error,
8219 )
8220 .await
8221 .unwrap();
8222 w.flush().await.unwrap();
8223 read_events(dir).remove(0)
8224 }
8225
8226 #[tokio::test]
8227 async fn tool_call_error_is_written_without_output_under_meta() {
8228 let dir = tempfile::tempdir().unwrap();
8229 let text = "sandbox: shell enforcement setup failed before exec: Landlock: \
8230 Too many open files (os error 24)";
8231 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(text)).await;
8232 assert_eq!(e["error"], text);
8233 assert!(e.get("output").is_none(), "meta stores no output: {e}");
8234 }
8235
8236 #[tokio::test]
8237 async fn tool_call_error_is_written_under_every_capture() {
8238 for capture in [
8239 TraceCapture::None,
8240 TraceCapture::Meta,
8241 TraceCapture::Content,
8242 ] {
8243 let dir = tempfile::tempdir().unwrap();
8244 let e = tool_call_with_error(dir.path(), capture, Some("spawn failed")).await;
8245 assert_eq!(e["error"], "spawn failed", "{capture:?}: {e}");
8246 }
8247 }
8248
8249 #[tokio::test]
8250 async fn tool_call_error_is_capped_on_a_char_boundary() {
8251 let dir = tempfile::tempdir().unwrap();
8252 let long = format!("a{}", "é".repeat(2_999));
8255 assert_eq!(long.chars().count(), 3_000);
8256 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&long)).await;
8257 let error = e["error"].as_str().unwrap();
8258 assert!(error.len() <= MAX_TASK_FAILURE_REASON_BYTES);
8259 assert!(error.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
8260 assert!(long.starts_with(error));
8261 }
8262
8263 #[tokio::test]
8264 async fn tool_call_error_redacts_a_peer_handle() {
8265 let dir = tempfile::tempdir().unwrap();
8266 let token = crate::peer_handoff::mint(
8267 &crate::peer_handoff::PeerMintKey::generate().unwrap(),
8268 &crate::peer_handoff::HandlePayload {
8269 v: 1,
8270 iss: "ses_1".to_string(),
8271 p: "report.md".to_string(),
8272 exp: timestamp_ms() + 3_600_000,
8273 n: "0123456789abcdef0123456789abcdef".to_string(),
8274 },
8275 "a@b",
8276 )
8277 .unwrap();
8278 let text = format!("fetch-peer-file {token} failed");
8279 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, Some(&text)).await;
8280 let id = crate::peer_handoff::handle_id(&token);
8281 assert_eq!(e["error"], format!("fetch-peer-file <handle:{id}> failed"));
8282 }
8283
8284 #[tokio::test]
8285 async fn tool_call_without_an_error_has_no_error_key() {
8286 let dir = tempfile::tempdir().unwrap();
8287 let e = tool_call_with_error(dir.path(), TraceCapture::Meta, None).await;
8288 assert!(e.get("error").is_none(), "{e}");
8289 }
8290
8291 #[tokio::test]
8292 async fn tool_call_output_absent_by_default() {
8293 let dir = tempfile::tempdir().unwrap();
8294 let mut w = make_writer(dir.path()).await; w.write_tool_call(
8296 0,
8297 "bash".to_string(),
8298 None,
8299 serde_json::json!({"command": "ls"}),
8300 10,
8301 "file.txt\n",
8302 90,
8303 8,
8304 "ok".to_string(),
8305 None,
8306 None,
8307 None,
8308 )
8309 .await
8310 .unwrap();
8311 w.flush().await.unwrap();
8312
8313 let events = read_events(dir.path());
8314 let e = &events[0];
8315 assert!(
8316 e.get("output").is_none(),
8317 "output must be absent under a capture mode that stores no bodies"
8318 );
8319 assert_eq!(e["output_bytes"], 90, "output_bytes must still be recorded");
8320 }
8321
8322 #[tokio::test]
8323 async fn skill_call_writes_skill_call_event() {
8324 let dir = tempfile::tempdir().unwrap();
8325 let mut w = make_writer(dir.path()).await;
8326 w.write_skill_call(
8327 2,
8328 "my-skill".to_string(),
8329 &LockOrigin::Operator,
8330 1024,
8331 8,
8332 "ok".to_string(),
8333 )
8334 .await
8335 .unwrap();
8336 w.flush().await.unwrap();
8337
8338 let events = read_events(dir.path());
8339 let e = &events[0];
8340 assert_eq!(e["event_type"], "skill_call");
8341 assert_eq!(e["turn"], 2);
8342 assert_eq!(e["skill_name"], "my-skill");
8343 assert_eq!(e["output_bytes"], 1024);
8344 assert_eq!(e["duration_ms"], 8);
8345 assert_eq!(e["status"], "ok");
8346 }
8347
8348 #[tokio::test]
8349 async fn skill_call_does_not_increment_total_tool_calls() {
8350 let dir = tempfile::tempdir().unwrap();
8351 let mut w = make_writer(dir.path()).await;
8352 w.write_session_start(Some(10), None, vec![]).await.unwrap();
8353 w.write_tool_call(
8354 0,
8355 "real-tool".to_string(),
8356 None,
8357 serde_json::json!({}),
8358 2,
8359 "data",
8360 4,
8361 5,
8362 "ok".to_string(),
8363 None,
8364 None,
8365 None,
8366 )
8367 .await
8368 .unwrap();
8369 w.write_skill_call(
8370 1,
8371 "my-skill".to_string(),
8372 &LockOrigin::Operator,
8373 512,
8374 3,
8375 "ok".to_string(),
8376 )
8377 .await
8378 .unwrap();
8379 w.write_session_end("ok").await.unwrap();
8380 w.flush().await.unwrap();
8381
8382 let events = read_events(dir.path());
8383 let se = events
8384 .iter()
8385 .find(|e| e["event_type"] == "session_end")
8386 .unwrap();
8387 assert_eq!(
8388 se["total_tool_calls"], 1,
8389 "skill_call must not inflate total_tool_calls"
8390 );
8391 }
8392
8393 #[tokio::test]
8394 async fn tool_call_output_present_when_opted_in() {
8395 let dir = tempfile::tempdir().unwrap();
8396 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
8397 w.write_tool_call(
8398 0,
8399 "bash".to_string(),
8400 None,
8401 serde_json::json!({"command": "echo hello"}),
8402 22,
8403 "hello\n",
8404 60,
8405 5,
8406 "ok".to_string(),
8407 None,
8408 None,
8409 None,
8410 )
8411 .await
8412 .unwrap();
8413 w.flush().await.unwrap();
8414
8415 let events = read_events(dir.path());
8416 let e = &events[0];
8417 assert_eq!(
8418 e["output"].as_str().unwrap(),
8419 "hello\n",
8420 "output must be present under capture: content"
8421 );
8422 assert_eq!(e["output_bytes"], 60);
8423 }
8424
8425 #[tokio::test]
8426 async fn task_end_carries_reopen_count() {
8427 let dir = tempfile::tempdir().unwrap();
8428 let mut w = make_writer(dir.path()).await;
8429 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8430 .await
8431 .unwrap();
8432 w.write_task_end("tsk_1", "ok", 2, Vec::new(), None, None)
8433 .await
8434 .unwrap();
8435 w.flush().await.unwrap();
8436
8437 let events = read_events(dir.path());
8438 let end = events
8439 .iter()
8440 .find(|e| e["event_type"] == "task_end")
8441 .unwrap();
8442 assert_eq!(end["task_id"], "tsk_1");
8443 assert_eq!(end["exit_status"], "ok");
8444 assert_eq!(end["reopen_count"], 2);
8445 assert_eq!(
8446 end["capped_turns"], 0,
8447 "always present, 0 when no turn was capped"
8448 );
8449 }
8450
8451 #[tokio::test]
8454 async fn task_end_counts_capped_turns_per_task() {
8455 let dir = tempfile::tempdir().unwrap();
8456 let mut w = make_writer(dir.path()).await;
8457 for task in ["tsk_1", "tsk_2"] {
8458 w.write_task_start(task, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8459 .await
8460 .unwrap();
8461 let stops: &[&str] = if task == "tsk_1" {
8462 &["max_tokens", "tool_call", "max_tokens", "end_turn"]
8463 } else {
8464 &["end_turn"]
8465 };
8466 for (turn, stop) in stops.iter().enumerate() {
8467 let decision = if *stop == "max_tokens" {
8468 "capped"
8469 } else {
8470 stop
8471 };
8472 w.write_turn_inference(
8473 u32::try_from(turn).unwrap(),
8474 Some(10),
8475 Some(5),
8476 decision.to_string(),
8477 Some(stop),
8478 None,
8479 None,
8480 Vec::new(),
8481 None,
8482 None,
8483 None,
8484 RetryNote::default(),
8485 None,
8486 )
8487 .await
8488 .unwrap();
8489 }
8490 w.write_task_end(task, "ok", 0, Vec::new(), None, None)
8491 .await
8492 .unwrap();
8493 }
8494 w.flush().await.unwrap();
8495
8496 let capped: Vec<_> = read_events(dir.path())
8497 .iter()
8498 .filter(|e| e["event_type"] == "task_end")
8499 .map(|e| e["capped_turns"].clone())
8500 .collect();
8501 assert_eq!(capped, vec![serde_json::json!(2), serde_json::json!(0)]);
8502 }
8503
8504 #[tokio::test]
8507 async fn task_end_carries_keep_working_only_when_set() {
8508 let dir = tempfile::tempdir().unwrap();
8509 let mut w = make_writer(dir.path()).await;
8510 for (task_id, keep_working) in [
8511 (
8512 "tsk_1",
8513 Some(KeepWorkingSummary {
8514 used: 2,
8515 limit: 2,
8516 stopped_by: Some("limit"),
8517 }),
8518 ),
8519 (
8520 "tsk_2",
8521 Some(KeepWorkingSummary {
8522 used: 1,
8523 limit: 5,
8524 stopped_by: None,
8525 }),
8526 ),
8527 ("tsk_3", None),
8528 ] {
8529 w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8530 .await
8531 .unwrap();
8532 w.write_task_end(task_id, "ok", 0, Vec::new(), keep_working, None)
8533 .await
8534 .unwrap();
8535 }
8536 w.flush().await.unwrap();
8537
8538 let events = read_events(dir.path());
8539 let ends: Vec<_> = events
8540 .iter()
8541 .filter(|e| e["event_type"] == "task_end")
8542 .collect();
8543 assert_eq!(
8544 ends[0]["keep_working"],
8545 serde_json::json!({"used": 2, "limit": 2, "stopped_by": "limit"})
8546 );
8547 assert_eq!(
8548 ends[1]["keep_working"],
8549 serde_json::json!({"used": 1, "limit": 5, "stopped_by": null})
8550 );
8551 assert!(ends[2].get("keep_working").is_none(), "{}", ends[2]);
8552 }
8553
8554 #[tokio::test]
8557 async fn keep_working_record_carries_its_fields_and_feeds_session_status() {
8558 let dir = tempfile::tempdir().unwrap();
8559 let mut w = make_writer(dir.path()).await;
8560 let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8561 std::time::Instant::now(),
8562 false,
8563 None,
8564 None,
8565 0,
8566 Some(3),
8567 None,
8568 ));
8569 w.set_session_status(std::sync::Arc::clone(&status));
8570 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8571 .await
8572 .unwrap();
8573 w.write_keep_working("tsk_1", 1, 3, Some(4), 66)
8574 .await
8575 .unwrap();
8576 w.write_keep_working("tsk_1", 2, 3, None, 66).await.unwrap();
8577 w.flush().await.unwrap();
8578
8579 let events = read_events(dir.path());
8580 let start = events
8581 .iter()
8582 .find(|e| e["event_type"] == "task_start")
8583 .unwrap();
8584 let records: Vec<_> = events
8585 .iter()
8586 .filter(|e| e["event_type"] == "keep_working")
8587 .collect();
8588 assert_eq!(records.len(), 2);
8589 for (record, (number, turns)) in records
8590 .iter()
8591 .zip([(1, serde_json::json!(4)), (2, serde_json::Value::Null)])
8592 {
8593 assert_eq!(record["task_id"], "tsk_1");
8594 assert_eq!(record["number"], number);
8595 assert_eq!(record["limit"], 3);
8596 assert_eq!(record["turns_remaining"], turns);
8597 assert_eq!(record["message_bytes"], 66);
8598 assert_eq!(record["parent_id"], start["event_id"]);
8599 assert!(record["event_id"].is_string());
8600 }
8601 let spend = crate::session_status::SpendReading {
8602 used: 0,
8603 ceiling: None,
8604 machine: None,
8605 };
8606 assert_eq!(
8607 serde_json::to_value(status.snapshot(spend)).unwrap()["keep_working"],
8608 serde_json::json!({"used": 2, "left": 1})
8609 );
8610 }
8611
8612 #[tokio::test]
8614 async fn task_end_carries_done_check_only_when_set() {
8615 let dir = tempfile::tempdir().unwrap();
8616 let mut w = make_writer(dir.path()).await;
8617 for (task_id, done_check) in [
8618 (
8619 "tsk_1",
8620 Some(DoneCheckSummary {
8621 runs: 3,
8622 reopens: 2,
8623 limit: 2,
8624 met: Some(false),
8625 stopped_by: Some("limit"),
8626 }),
8627 ),
8628 (
8629 "tsk_2",
8630 Some(DoneCheckSummary {
8631 runs: 0,
8632 reopens: 0,
8633 limit: 1,
8634 met: None,
8635 stopped_by: None,
8636 }),
8637 ),
8638 ("tsk_3", None),
8639 ] {
8640 w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8641 .await
8642 .unwrap();
8643 w.write_task_end(task_id, "ok", 0, Vec::new(), None, done_check)
8644 .await
8645 .unwrap();
8646 }
8647 w.flush().await.unwrap();
8648
8649 let events = read_events(dir.path());
8650 let ends: Vec<_> = events
8651 .iter()
8652 .filter(|e| e["event_type"] == "task_end")
8653 .collect();
8654 assert_eq!(
8655 ends[0]["done_check"],
8656 serde_json::json!({"runs": 3, "reopens": 2, "limit": 2, "met": false,
8657 "stopped_by": "limit"})
8658 );
8659 assert_eq!(
8660 ends[1]["done_check"],
8661 serde_json::json!({"runs": 0, "reopens": 0, "limit": 1, "met": null,
8662 "stopped_by": null})
8663 );
8664 assert!(ends[2].get("done_check").is_none(), "{}", ends[2]);
8665 }
8666
8667 #[tokio::test]
8670 async fn done_check_record_carries_its_fields_and_feeds_session_status() {
8671 let dir = tempfile::tempdir().unwrap();
8672 let mut w = make_writer(dir.path()).await;
8673 let status = std::sync::Arc::new(crate::session_status::SessionStatus::new(
8674 std::time::Instant::now(),
8675 false,
8676 None,
8677 None,
8678 0,
8679 None,
8680 Some(3),
8681 ));
8682 w.set_session_status(std::sync::Arc::clone(&status));
8683 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8684 .await
8685 .unwrap();
8686 let mut run = DoneCheckRun {
8687 task_id: "tsk_1",
8688 number: 1,
8689 command: "bash -c 'exit 1'",
8690 outcome: "failed",
8691 exit_code: Some(1),
8692 duration_ms: 12,
8693 stdout_bytes: 5,
8694 stderr_bytes: 0,
8695 truncated: false,
8696 resource_limit: None,
8697 reopened: true,
8698 reopens_left: 2,
8699 turns_remaining: Some(4),
8700 };
8701 w.write_done_check(&run, 1).await.unwrap();
8702 run.number = 2;
8703 run.outcome = "timed_out";
8704 run.exit_code = None;
8705 run.reopened = false;
8706 run.turns_remaining = None;
8707 w.write_done_check(&run, 1).await.unwrap();
8708 w.flush().await.unwrap();
8709
8710 let text = std::fs::read_to_string(dir.path().join("trace.jsonl")).unwrap();
8711 let line = text
8712 .lines()
8713 .find(|line| line.contains("\"done_check\""))
8714 .unwrap();
8715 let order: Vec<usize> = [
8716 "event_type",
8717 "event_id",
8718 "parent_id",
8719 "session_id",
8720 "timestamp",
8721 "task_id",
8722 "number",
8723 "command",
8724 "outcome",
8725 "exit_code",
8726 "duration_ms",
8727 "stdout_bytes",
8728 "stderr_bytes",
8729 "truncated",
8730 "resource_limit",
8731 "reopened",
8732 "reopens_left",
8733 "turns_remaining",
8734 ]
8735 .iter()
8736 .map(|key| line.find(&format!("\"{key}\":")).unwrap())
8737 .collect();
8738 assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{line}");
8739
8740 let events = read_events(dir.path());
8741 let start = events
8742 .iter()
8743 .find(|e| e["event_type"] == "task_start")
8744 .unwrap();
8745 let records: Vec<_> = events
8746 .iter()
8747 .filter(|e| e["event_type"] == "done_check")
8748 .collect();
8749 assert_eq!(records.len(), 2);
8750 assert_eq!(records[0]["parent_id"], start["event_id"]);
8751 assert_eq!(records[0]["command"], "bash -c 'exit 1'");
8752 assert_eq!(records[0]["exit_code"], 1);
8753 assert_eq!(records[0]["reopened"], true);
8754 assert_eq!(records[0]["turns_remaining"], 4);
8755 assert_eq!(records[1]["outcome"], "timed_out");
8756 assert_eq!(records[1]["exit_code"], serde_json::Value::Null);
8757 assert_eq!(records[1]["resource_limit"], serde_json::Value::Null);
8758 assert_eq!(records[1]["turns_remaining"], serde_json::Value::Null);
8759 let spend = crate::session_status::SpendReading {
8760 used: 0,
8761 ceiling: None,
8762 machine: None,
8763 };
8764 assert_eq!(
8765 serde_json::to_value(status.snapshot(spend)).unwrap()["done_check"],
8766 serde_json::json!({"used": 1, "left": 2})
8767 );
8768 }
8769
8770 #[tokio::test]
8773 async fn task_end_names_the_commands_still_running() {
8774 let dir = tempfile::tempdir().unwrap();
8775 let mut w = make_writer(dir.path()).await;
8776 for (task_id, running) in [
8777 ("tsk_1", vec!["wrk_0a".to_string(), "wrk_0b".to_string()]),
8778 ("tsk_2", Vec::new()),
8779 ] {
8780 w.write_task_start(task_id, "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8781 .await
8782 .unwrap();
8783 w.write_task_end(task_id, "ok", 0, running, None, None)
8784 .await
8785 .unwrap();
8786 }
8787 w.flush().await.unwrap();
8788
8789 let events = read_events(dir.path());
8790 let ends: Vec<_> = events
8791 .iter()
8792 .filter(|e| e["event_type"] == "task_end")
8793 .collect();
8794 assert_eq!(
8795 ends[0]["running_commands"],
8796 serde_json::json!(["wrk_0a", "wrk_0b"])
8797 );
8798 assert!(ends[1].get("running_commands").is_none(), "{}", ends[1]);
8799 }
8800
8801 #[tokio::test]
8805 async fn shell_completed_names_its_delivery_and_exit_time() {
8806 let dir = tempfile::tempdir().unwrap();
8807 let mut w = make_writer(dir.path()).await;
8808 let appender = ResourceTraceAppender::open(
8809 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
8810 "test-session-id".to_string(),
8811 w.session_event_id().to_string(),
8812 )
8813 .await
8814 .unwrap();
8815 let completion = crate::detached::DetachedCompletion {
8816 work_id: "wrk_0a".to_string(),
8817 binary: "/usr/bin/bash".to_string(),
8818 command: "sleep 3".to_string(),
8819 exit_code: 0,
8820 duration_ms: 3_000,
8821 exited_at_ms: 1_750,
8822 output_path: "/records/logs/wrk_0a.log".to_string(),
8823 output_bytes: 10,
8824 resource_limit: None,
8825 output_holders: crate::shell::OutputHolders::Closed,
8826 context_id: "ctx_1".to_string(),
8827 provenance: crate::origin::TaskProvenance::derive(
8828 crate::origin::TaskOrigin::Completion,
8829 None,
8830 ),
8831 error: None,
8832 };
8833 w.write_session_start(None, None, Vec::new()).await.unwrap();
8834 w.flush().await.unwrap();
8835 appender.write_shell_completed(&completion).await;
8836 w.write_shell_completed(&completion, "tsk_2").await.unwrap();
8837 w.flush().await.unwrap();
8838
8839 let events = read_events(dir.path());
8840 let completed: Vec<_> = events
8841 .iter()
8842 .filter(|e| e["event_type"] == "shell_completed")
8843 .collect();
8844 assert_eq!(completed.len(), 2);
8845 assert_eq!(completed[0]["delivered_via"], "background_command");
8846 assert!(
8847 completed[0].get("completion_task_id").is_none(),
8848 "{}",
8849 completed[0]
8850 );
8851 assert_eq!(completed[1]["delivered_via"], "task");
8852 assert_eq!(completed[1]["completion_task_id"], "tsk_2");
8853 for record in &completed {
8854 assert_eq!(record["exited_at_ms"], 1_750);
8855 assert_eq!(record["work_id"], "wrk_0a");
8856 assert_eq!(record["status"], "ok");
8857 assert_eq!(record["duration_ms"], 3_000);
8858 assert_eq!(record["session_id"], "test-session-id");
8859 assert_eq!(record["parent_id"].as_str(), Some(w.session_event_id()));
8860 }
8861 }
8862
8863 #[tokio::test]
8864 async fn task_reopened_names_hook_reason_and_ordinal() {
8865 let dir = tempfile::tempdir().unwrap();
8866 let mut w = make_writer(dir.path()).await;
8867 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8868 .await
8869 .unwrap();
8870 w.write_task_reopened(
8871 "tsk_1",
8872 "gatekeeper",
8873 "tests still fail",
8874 1,
8875 REOPEN_CONTEXT_CONTINUED,
8876 Some(7),
8877 )
8878 .await
8879 .unwrap();
8880 w.flush().await.unwrap();
8881
8882 let events = read_events(dir.path());
8883 let re = events
8884 .iter()
8885 .find(|e| e["event_type"] == "task_reopened")
8886 .unwrap();
8887 assert_eq!(re["task_id"], "tsk_1");
8888 assert_eq!(re["hook_name"], "gatekeeper");
8889 assert_eq!(re["reason"], "tests still fail");
8890 assert_eq!(re["reopen_number"], 1);
8891 assert_eq!(re["attempt_context"], "continued");
8892 assert_eq!(re["turns_remaining"], 7);
8893
8894 w.write_task_reopened(
8895 "tsk_1",
8896 "gatekeeper",
8897 "again",
8898 2,
8899 REOPEN_CONTEXT_CONTINUED,
8900 None,
8901 )
8902 .await
8903 .unwrap();
8904 w.flush().await.unwrap();
8905 let uncapped = read_events(dir.path())
8906 .into_iter()
8907 .filter(|e| e["event_type"] == "task_reopened")
8908 .nth(1)
8909 .unwrap();
8910 assert!(uncapped
8911 .get("turns_remaining")
8912 .is_some_and(serde_json::Value::is_null));
8913 assert!(re["timestamp"].as_u64().unwrap() > 0);
8914 }
8915
8916 #[tokio::test]
8917 async fn task_continued_names_the_round_under_the_task_node() {
8918 let dir = tempfile::tempdir().unwrap();
8919 let mut w = make_writer(dir.path()).await;
8920 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8921 .await
8922 .unwrap();
8923 w.write_task_continued(
8924 "tsk_1",
8925 1,
8926 &["mcl_b".to_string(), "mcl_a".to_string()],
8927 &["dlg_1".to_string()],
8928 1234,
8929 Some(8),
8930 )
8931 .await
8932 .unwrap();
8933 w.flush().await.unwrap();
8934
8935 let events = read_events(dir.path());
8936 let start = events
8937 .iter()
8938 .find(|e| e["event_type"] == "task_start")
8939 .unwrap();
8940 let continued = events
8941 .iter()
8942 .find(|e| e["event_type"] == "task_continued")
8943 .unwrap();
8944 assert_eq!(continued["parent_id"], start["event_id"]);
8945 assert_eq!(continued["task_id"], "tsk_1");
8946 assert_eq!(continued["continuation_number"], 1);
8947 assert_eq!(
8948 continued["member_calls"],
8949 serde_json::json!(["mcl_b", "mcl_a"])
8950 );
8951 assert_eq!(continued["delegations"], serde_json::json!(["dlg_1"]));
8952 assert_eq!(continued["waited_ms"], 1234);
8953 assert_eq!(continued["turns_remaining"], 8);
8954 }
8955
8956 #[tokio::test]
8960 async fn task_turns_accessor_tracks_and_resets() {
8961 let dir = tempfile::tempdir().unwrap();
8962 let mut w = make_writer(dir.path()).await;
8963 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
8964 .await
8965 .unwrap();
8966 assert_eq!(w.task_turns(), 0);
8967 w.write_inference(
8968 0,
8969 Some(10),
8970 Some(5),
8971 "end_turn".to_string(),
8972 Some("end_turn"),
8973 None,
8974 None,
8975 None,
8976 Vec::new(),
8977 None,
8978 None,
8979 )
8980 .await
8981 .unwrap();
8982 w.write_inference(
8983 1,
8984 Some(10),
8985 Some(5),
8986 "end_turn".to_string(),
8987 Some("end_turn"),
8988 None,
8989 None,
8990 None,
8991 Vec::new(),
8992 None,
8993 None,
8994 )
8995 .await
8996 .unwrap();
8997 assert_eq!(w.task_turns(), 2);
8998 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
9000 .await
9001 .unwrap();
9002 assert_eq!(w.task_turns(), 0);
9003 }
9004
9005 #[tokio::test]
9008 async fn task_agent_turns_counts_agent_loop_inference_only() {
9009 let dir = tempfile::tempdir().unwrap();
9010 let mut w = make_writer(dir.path()).await;
9011 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9012 .await
9013 .unwrap();
9014 assert_eq!(w.task_agent_turns(), 0);
9015 let hook = InferenceOrigin {
9016 source: "hook:x".into(),
9017 model: "m".into(),
9018 };
9019 for (turn, origin) in [(0, None), (0, Some(&hook)), (1, None)] {
9020 w.write_inference(
9021 turn,
9022 Some(10),
9023 Some(5),
9024 "end_turn".to_string(),
9025 Some("end_turn"),
9026 None,
9027 origin,
9028 None,
9029 Vec::new(),
9030 None,
9031 None,
9032 )
9033 .await
9034 .unwrap();
9035 }
9036 assert_eq!(w.task_agent_turns(), 2);
9037 assert_eq!(w.task_turns(), 3);
9038 assert_eq!(w.task_hook_inferences(), 1);
9039 w.write_task_end("tsk_1", "completed", 0, Vec::new(), None, None)
9040 .await
9041 .unwrap();
9042 assert_eq!(
9043 w.task_agent_turns(),
9044 0,
9045 "a run outside a task frame starts at 0"
9046 );
9047 w.write_inference(
9048 0,
9049 Some(10),
9050 Some(5),
9051 "end_turn".to_string(),
9052 Some("end_turn"),
9053 None,
9054 None,
9055 None,
9056 Vec::new(),
9057 None,
9058 None,
9059 )
9060 .await
9061 .unwrap();
9062 w.write_task_start("tsk_2", "ctx_2", "a2a", event_provenance(), 3, &[], &[])
9063 .await
9064 .unwrap();
9065 assert_eq!(w.task_agent_turns(), 0);
9066 }
9067
9068 #[tokio::test]
9074 async fn every_event_carries_a_unique_id_and_parents_to_an_earlier_event() {
9075 let dir = tempfile::tempdir().unwrap();
9076 let mut w = make_writer(dir.path()).await;
9077 w.write_session_start(Some(10), None, vec!["bash".to_string()])
9078 .await
9079 .unwrap();
9080 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9081 .await
9082 .unwrap();
9083 w.write_inference(
9084 0,
9085 Some(10),
9086 Some(5),
9087 "tool_call".to_string(),
9088 Some("tool_call"),
9089 None,
9090 None,
9091 None,
9092 Vec::new(),
9093 None,
9094 None,
9095 )
9096 .await
9097 .unwrap();
9098 w.write_tool_call(
9099 0,
9100 "bash".to_string(),
9101 Some("toolu_1".to_string()),
9102 serde_json::json!({"command": "echo hi"}),
9103 10,
9104 "hi\n",
9105 3,
9106 5,
9107 "ok".to_string(),
9108 None,
9109 None,
9110 None,
9111 )
9112 .await
9113 .unwrap();
9114 w.write_shell(
9115 0,
9116 "/bin/bash".to_string(),
9117 "echo hi".to_string(),
9118 0,
9119 3,
9120 0,
9121 2,
9122 None,
9123 )
9124 .await
9125 .unwrap();
9126 w.write_artifact_pulled(&pulled(
9127 runtime_summary("pulled-style", ArtifactRuntime::Skill),
9128 "global store",
9129 None,
9130 ))
9131 .await
9132 .unwrap();
9133 w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
9134 .await
9135 .unwrap();
9136 w.write_session_end("ok").await.unwrap();
9137 w.flush().await.unwrap();
9138
9139 let events = read_events(dir.path());
9140 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9141 for (i, event) in events.iter().enumerate() {
9142 let id = event["event_id"].as_str().unwrap();
9143 assert!(id.starts_with("evt_") && id.len() == 36, "event {i}: {id}");
9144 assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()), "event {i}");
9145 assert!(seen.insert(id.to_string()), "event {i} reuses {id}");
9146 assert!(
9147 event.get("parent_id").is_some(),
9148 "event {i} has no parent_id"
9149 );
9150 if let Some(parent) = event["parent_id"].as_str() {
9151 assert!(seen.contains(parent), "event {i} names dangling {parent}");
9152 }
9153 }
9154
9155 let by_type = |ty: &str| {
9156 events
9157 .iter()
9158 .find(|e| e["event_type"] == ty)
9159 .unwrap_or_else(|| panic!("no {ty}"))
9160 .clone()
9161 };
9162 let session = by_type("session_start");
9163 assert!(session["parent_id"].is_null());
9164 assert_eq!(session["event_id"], w.session_event_id());
9165
9166 let task = by_type("task_start");
9167 assert_eq!(task["parent_id"], session["event_id"]);
9168
9169 let turn = by_type("inference");
9170 assert_eq!(turn["parent_id"], task["event_id"]);
9171
9172 assert_eq!(by_type("tool_call")["parent_id"], turn["event_id"]);
9173 assert_eq!(by_type("shell")["parent_id"], turn["event_id"]);
9174 assert_eq!(by_type("task_end")["parent_id"], task["event_id"]);
9175 assert_eq!(by_type("session_end")["parent_id"], session["event_id"]);
9176 assert_eq!(by_type("artifact_pulled")["parent_id"], session["event_id"]);
9177 }
9178
9179 fn runtime_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
9180 InstalledArtifactSummary {
9181 name: name.to_string(),
9182 version: "1.0.0".to_string(),
9183 runtime,
9184 origin: LockOrigin::Runtime {
9185 session: "ses_puller".to_string(),
9186 },
9187 }
9188 }
9189
9190 fn operator_summary(name: &str, runtime: ArtifactRuntime) -> InstalledArtifactSummary {
9191 InstalledArtifactSummary {
9192 origin: LockOrigin::Operator,
9193 ..runtime_summary(name, runtime)
9194 }
9195 }
9196
9197 fn pulled(
9199 summary: InstalledArtifactSummary,
9200 fetched_from: &str,
9201 owner_id: Option<u64>,
9202 ) -> crate::runtime::PulledArtifact {
9203 crate::runtime::PulledArtifact {
9204 summary,
9205 fetched_from: fetched_from.to_string(),
9206 owner_id,
9207 notes: Vec::new(),
9208 }
9209 }
9210
9211 #[tokio::test]
9214 async fn session_start_instruction_files_is_null_without_the_key() {
9215 let work = tempfile::tempdir().unwrap();
9216 std::fs::write(work.path().join("AGENTS.md"), "x").unwrap();
9217 let read = crate::instruction_files::InstructionFiles::read(
9218 work.path(),
9219 &["AGENTS.md".to_string(), "CLAUDE.md".to_string()],
9220 );
9221 let empty = crate::instruction_files::InstructionFiles::read(work.path(), &[]);
9222 for (files, expected) in [
9223 (None, serde_json::Value::Null),
9224 (Some(&empty), serde_json::json!([])),
9225 (
9226 Some(&read),
9227 serde_json::json!([
9228 {
9229 "path": "AGENTS.md",
9230 "status": "read",
9231 "resolved": "AGENTS.md",
9232 "bytes": 1,
9233 "included_bytes": 1,
9234 "truncated": false,
9235 "sha256": murmur_artifact::sha256_hex(b"x"),
9236 },
9237 {"path": "CLAUDE.md", "status": "missing"},
9238 ]),
9239 ),
9240 ] {
9241 let dir = tempfile::tempdir().unwrap();
9242 let mut w = make_writer(dir.path()).await;
9243 w.set_instruction_files(files);
9244 w.write_session_start(Some(10), None, Vec::new())
9245 .await
9246 .unwrap();
9247 w.flush().await.unwrap();
9248 let events = read_events(dir.path());
9249 assert!(events[0].get("instruction_files").is_some());
9250 assert_eq!(events[0]["instruction_files"], expected);
9251 }
9252 }
9253
9254 fn harness_start_with_prompt(prompt: &str) -> HarnessStart {
9255 HarnessStart {
9256 driver: "local/fixture-process-driver".to_string(),
9257 driver_version: "0.1.0".to_string(),
9258 harness: "fixture".to_string(),
9259 tier: "landlock+seccomp".to_string(),
9260 binary: "/bin/true".to_string(),
9261 binary_source: "describe".to_string(),
9262 harness_version: None,
9263 version_tested: false,
9264 harness_session_id: "h".to_string(),
9265 session_mode: "new".to_string(),
9266 args_count: 0,
9267 env_names: Vec::new(),
9268 account_env_unset: Vec::new(),
9269 files: Vec::new(),
9270 bridge_tools: Vec::new(),
9271 stdin_bytes: 0,
9272 keep_stdin_open: false,
9273 system_prompt_sha256: murmur_artifact::sha256_hex(prompt.as_bytes()),
9274 system_prompt: prompt.to_string(),
9275 }
9276 }
9277
9278 #[tokio::test]
9281 async fn harness_start_records_the_system_prompt_hash_and_blob() {
9282 let prompt = "facts\n\nMANIFEST\n\n[Instruction file: AGENTS.md]\nx";
9283 let sha = murmur_artifact::sha256_hex(prompt.as_bytes());
9284 for capture in [TraceCapture::Meta, TraceCapture::Content] {
9285 let dir = tempfile::tempdir().unwrap();
9286 let mut w = make_writer_with_opts(dir.path(), capture).await;
9287 w.write_harness_start(harness_start_with_prompt(prompt))
9288 .await
9289 .unwrap();
9290 w.flush().await.unwrap();
9291 let event = &read_events(dir.path())[0];
9292 assert_eq!(event["event_type"], "harness_start");
9293 assert_eq!(event["system_prompt_sha256"], sha.as_str());
9294 assert!(event.get("system_prompt").is_none(), "{event}");
9295 let blob = dir.path().join(BLOB_DIR_NAME).join(&sha);
9296 if capture == TraceCapture::Content {
9297 assert_eq!(std::fs::read(&blob).unwrap(), prompt.as_bytes());
9298 } else {
9299 assert!(!blob.exists());
9300 }
9301 }
9302 }
9303
9304 #[tokio::test]
9307 async fn runtime_origin_session_start_lists_nothing_for_an_operator_session() {
9308 for set in [false, true] {
9309 let dir = tempfile::tempdir().unwrap();
9310 let mut w = make_writer(dir.path()).await;
9311 if set {
9312 w.set_runtime_artifacts(&[
9313 operator_summary("house-style", ArtifactRuntime::Skill),
9314 operator_summary("fetcher", ArtifactRuntime::Tool),
9315 ]);
9316 }
9317 w.write_session_start(Some(10), None, Vec::new())
9318 .await
9319 .unwrap();
9320 w.flush().await.unwrap();
9321 let events = read_events(dir.path());
9322 assert_eq!(events[0]["runtime_artifacts"], serde_json::json!([]));
9323 }
9324 }
9325
9326 #[tokio::test]
9329 async fn runtime_origin_session_start_lists_runtime_artifacts_in_order() {
9330 let dir = tempfile::tempdir().unwrap();
9331 let mut w = make_writer(dir.path()).await;
9332 w.set_runtime_artifacts(&[
9333 runtime_summary("zeta-tool", ArtifactRuntime::Tool),
9334 operator_summary("house-style", ArtifactRuntime::Skill),
9335 InstalledArtifactSummary {
9336 origin: LockOrigin::Runtime {
9337 session: "ses_other".to_string(),
9338 },
9339 ..runtime_summary("alpha-style", ArtifactRuntime::Skill)
9340 },
9341 ]);
9342 w.write_session_start(Some(10), None, Vec::new())
9343 .await
9344 .unwrap();
9345 w.flush().await.unwrap();
9346
9347 let events = read_events(dir.path());
9348 assert_eq!(
9349 events[0]["runtime_artifacts"],
9350 serde_json::json!([
9351 {"name": "zeta-tool", "version": "1.0.0", "origin": "runtime",
9352 "session": "ses_puller", "trust": "untrusted"},
9353 {"name": "alpha-style", "version": "1.0.0", "origin": "runtime",
9354 "session": "ses_other", "trust": "untrusted"},
9355 ])
9356 );
9357 }
9358
9359 #[tokio::test]
9360 async fn runtime_origin_skill_call_carries_origin_and_trust() {
9361 let dir = tempfile::tempdir().unwrap();
9362 let mut w = make_writer(dir.path()).await;
9363 for origin in [
9364 LockOrigin::Operator,
9365 LockOrigin::Runtime {
9366 session: "ses_puller".to_string(),
9367 },
9368 ] {
9369 w.write_skill_call(0, "a-skill".to_string(), &origin, 1, 1, "ok".to_string())
9370 .await
9371 .unwrap();
9372 }
9373 w.flush().await.unwrap();
9374
9375 let events = read_events(dir.path());
9376 assert_eq!(events[0]["origin"], "operator");
9377 assert_eq!(events[0]["trust"], "trusted");
9378 assert_eq!(events[1]["origin"], "runtime");
9379 assert_eq!(events[1]["trust"], "untrusted");
9380 }
9381
9382 #[tokio::test]
9385 async fn runtime_origin_artifact_pulled_writes_every_key() {
9386 let dir = tempfile::tempdir().unwrap();
9387 let mut w = make_writer(dir.path()).await;
9388 w.write_artifact_pulled(&pulled(
9389 runtime_summary("pulled-style", ArtifactRuntime::Skill),
9390 "github:ana/artifacts",
9391 Some(4242),
9392 ))
9393 .await
9394 .unwrap();
9395 w.write_artifact_pulled(&pulled(
9396 operator_summary("fetcher", ArtifactRuntime::Tool),
9397 "project store",
9398 None,
9399 ))
9400 .await
9401 .unwrap();
9402 w.flush().await.unwrap();
9403
9404 let events = read_events(dir.path());
9405 let runtime = &events[0];
9406 assert_eq!(runtime["event_type"], "artifact_pulled");
9407 assert_eq!(runtime["name"], "pulled-style");
9408 assert_eq!(runtime["version"], "1.0.0");
9409 assert_eq!(runtime["runtime"], "skill");
9410 assert_eq!(runtime["origin"], "runtime");
9411 assert_eq!(runtime["session"], "ses_puller");
9412 assert_eq!(runtime["trust"], "untrusted");
9413 assert_eq!(runtime["fetched_from"], "github:ana/artifacts");
9414 assert_eq!(runtime["owner_id"], 4242);
9415 assert!(
9416 runtime["parent_id"].is_null(),
9417 "no session_start was written"
9418 );
9419
9420 let operator = &events[1];
9421 assert_eq!(operator["runtime"], "tool");
9422 assert_eq!(operator["origin"], "operator");
9423 assert!(operator.get("session").is_some_and(Value::is_null));
9424 assert_eq!(operator["trust"], "trusted");
9425 assert_eq!(operator["fetched_from"], "project store");
9426 assert!(operator.get("owner_id").is_none());
9427 }
9428
9429 #[tokio::test]
9433 async fn hook_origin_inference_hangs_off_the_turn_rather_than_becoming_one() {
9434 let dir = tempfile::tempdir().unwrap();
9435 let mut w = make_writer(dir.path()).await;
9436 w.write_session_start(Some(10), None, Vec::new())
9437 .await
9438 .unwrap();
9439 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9440 .await
9441 .unwrap();
9442 w.write_inference(
9443 0,
9444 Some(10),
9445 Some(5),
9446 "tool_call".to_string(),
9447 Some("tool_call"),
9448 None,
9449 None,
9450 None,
9451 Vec::new(),
9452 None,
9453 None,
9454 )
9455 .await
9456 .unwrap();
9457 let origin = InferenceOrigin {
9458 source: "hook:gatekeeper".to_string(),
9459 model: "claude-test".to_string(),
9460 };
9461 w.write_inference(
9462 0,
9463 Some(1),
9464 Some(1),
9465 "end_turn".to_string(),
9466 None,
9468 None,
9469 Some(&origin),
9470 None,
9471 Vec::new(),
9472 None,
9473 None,
9474 )
9475 .await
9476 .unwrap();
9477 w.flush().await.unwrap();
9478
9479 let events = read_events(dir.path());
9480 let turn = &events[2];
9481 let hook = &events[3];
9482 assert!(turn["origin"].is_null());
9483 assert_eq!(hook["origin"], "hook:gatekeeper");
9484 assert_eq!(hook["parent_id"], turn["event_id"]);
9485 assert!(hook.get("message_ids").is_none(), "{hook}");
9488 }
9489
9490 #[tokio::test]
9492 async fn a2a_send_parents_to_null_without_a_session_frame_and_to_it_with_one() {
9493 let bare = tempfile::tempdir().unwrap();
9494 let mut w = make_writer(bare.path()).await;
9495 w.write_a2a_send(
9496 "http://peer",
9497 "msg_1",
9498 "tsk_1",
9499 "ctx_1",
9500 None,
9501 TrustClass::Untrusted,
9502 )
9503 .await
9504 .unwrap();
9505 w.flush().await.unwrap();
9506 assert!(read_events(bare.path())[0]["parent_id"].is_null());
9507
9508 let framed = tempfile::tempdir().unwrap();
9509 let mut w = make_writer(framed.path()).await;
9510 w.write_session_start(Some(1), None, Vec::new())
9511 .await
9512 .unwrap();
9513 w.write_a2a_send(
9514 "http://peer",
9515 "msg_1",
9516 "tsk_1",
9517 "ctx_1",
9518 None,
9519 TrustClass::Untrusted,
9520 )
9521 .await
9522 .unwrap();
9523 w.flush().await.unwrap();
9524 let events = read_events(framed.path());
9525 assert_eq!(events[1]["parent_id"], events[0]["event_id"]);
9526 }
9527
9528 #[tokio::test]
9530 async fn task_rejected_names_the_task_source_cause_and_reason_under_the_session() {
9531 let dir = tempfile::tempdir().unwrap();
9532 let mut w = make_writer(dir.path()).await;
9533 w.write_session_start(Some(10), None, Vec::new())
9534 .await
9535 .unwrap();
9536 w.write_task_start("tsk_ran", "ctx_ran", "a2a", event_provenance(), 3, &[], &[])
9537 .await
9538 .unwrap();
9539 w.write_task_end("tsk_ran", "ok", 0, Vec::new(), None, None)
9540 .await
9541 .unwrap();
9542 w.write_task_rejected(
9543 "tsk_1",
9544 "ctx_1",
9545 "detached_shell",
9546 TASK_REJECTED_SESSION_ENDED,
9547 "task rejected: the session ended before this task started",
9548 )
9549 .await
9550 .unwrap();
9551 w.flush().await.unwrap();
9552
9553 let events = read_events(dir.path());
9554 let session = events
9555 .iter()
9556 .find(|e| e["event_type"] == "session_start")
9557 .unwrap();
9558 let rejected: Vec<_> = events
9559 .iter()
9560 .filter(|e| e["event_type"] == "task_rejected")
9561 .collect();
9562 assert_eq!(rejected.len(), 1);
9563 let rejected = rejected[0];
9564 assert_eq!(rejected["parent_id"], session["event_id"], "{rejected}");
9565 assert_eq!(rejected["session_id"], session["session_id"]);
9566 assert_eq!(rejected["task_id"], "tsk_1");
9567 assert_eq!(rejected["context_id"], "ctx_1");
9568 assert_eq!(rejected["source"], "detached_shell");
9569 assert_eq!(rejected["cause"], "session_ended");
9570 assert_eq!(
9571 rejected["reason"],
9572 "task rejected: the session ended before this task started"
9573 );
9574 assert!(rejected["event_id"].is_string());
9575 assert!(rejected["timestamp"].is_u64());
9576 assert_eq!(rejected.as_object().unwrap().len(), 11, "{rejected}");
9577 }
9578
9579 #[tokio::test]
9582 async fn task_failed_names_the_task_turn_cause_and_capped_reason() {
9583 let dir = tempfile::tempdir().unwrap();
9584 let mut w = make_writer(dir.path()).await;
9585 w.write_session_start(Some(10), None, Vec::new())
9586 .await
9587 .unwrap();
9588 assert_eq!(w.last_task_failure(), None);
9589 w.write_task_failed(None, TASK_FAILED_RUNTIME_ERROR, "before any task")
9590 .await
9591 .unwrap();
9592 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9593 .await
9594 .unwrap();
9595 let long = "é".repeat(MAX_TASK_FAILURE_REASON_BYTES);
9596 w.write_task_failed(Some(2), TASK_FAILED_DRIVER_ERROR, &long)
9597 .await
9598 .unwrap();
9599 w.write_task_end("tsk_1", "failed", 0, Vec::new(), None, None)
9600 .await
9601 .unwrap();
9602 w.flush().await.unwrap();
9603
9604 assert_eq!(w.task_failures_written(), 2);
9605 let events = read_events(dir.path());
9606 let failed: Vec<_> = events
9607 .iter()
9608 .filter(|e| e["event_type"] == "task_failed")
9609 .collect();
9610 assert_eq!(failed.len(), 2);
9611
9612 assert!(failed[0].get("task_id").is_none(), "{}", failed[0]);
9613 assert!(failed[0].get("turn").is_none(), "{}", failed[0]);
9614 assert_eq!(failed[0]["cause"], "runtime_error");
9615 assert_eq!(failed[0]["reason"], "before any task");
9616
9617 let start = events
9618 .iter()
9619 .find(|e| e["event_type"] == "task_start")
9620 .unwrap();
9621 assert_eq!(failed[1]["task_id"], "tsk_1");
9622 assert_eq!(failed[1]["turn"], 2);
9623 assert_eq!(failed[1]["cause"], "driver_error");
9624 assert_eq!(failed[1]["parent_id"], start["event_id"]);
9625 assert_eq!(failed[1]["session_id"], start["session_id"]);
9626 assert!(failed[1]["event_id"].is_string());
9627 assert!(failed[1]["timestamp"].is_u64());
9628 let reason = failed[1]["reason"].as_str().unwrap();
9629 assert!(reason.len() <= MAX_TASK_FAILURE_REASON_BYTES);
9630 assert!(reason.len() > MAX_TASK_FAILURE_REASON_BYTES - 2);
9631 assert_eq!(w.last_task_failure(), Some(reason));
9632 }
9633
9634 #[tokio::test]
9637 async fn turn_level_events_carry_the_active_task_id() {
9638 let dir = tempfile::tempdir().unwrap();
9639 let mut w = make_writer(dir.path()).await;
9640 w.write_session_start(Some(10), None, Vec::new())
9641 .await
9642 .unwrap();
9643 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9644 .await
9645 .unwrap();
9646 w.write_inference(
9647 0,
9648 Some(10),
9649 Some(5),
9650 "tool_call".to_string(),
9651 Some("tool_call"),
9652 None,
9653 None,
9654 None,
9655 Vec::new(),
9656 None,
9657 None,
9658 )
9659 .await
9660 .unwrap();
9661 w.write_skill_call(
9662 0,
9663 "house-style".to_string(),
9664 &LockOrigin::Operator,
9665 12,
9666 1,
9667 "ok".to_string(),
9668 )
9669 .await
9670 .unwrap();
9671 w.write_compaction(0, 100, OccupancyBasis::Estimate, 40)
9672 .await
9673 .unwrap();
9674 w.write_task_end("tsk_1", "ok", 0, Vec::new(), None, None)
9675 .await
9676 .unwrap();
9677 w.write_inference(
9678 1,
9679 Some(1),
9680 Some(1),
9681 "end_turn".to_string(),
9682 Some("end_turn"),
9683 None,
9684 None,
9685 None,
9686 Vec::new(),
9687 None,
9688 None,
9689 )
9690 .await
9691 .unwrap();
9692 w.flush().await.unwrap();
9693
9694 let events = read_events(dir.path());
9695 for event in events
9696 .iter()
9697 .filter(|e| {
9698 matches!(
9699 e["event_type"].as_str(),
9700 Some("inference" | "skill_call" | "compaction")
9701 )
9702 })
9703 .take(3)
9704 {
9705 assert_eq!(event["task_id"], "tsk_1", "{event}");
9706 }
9707 let after = events.last().unwrap();
9708 assert_eq!(after["event_type"], "inference");
9709 assert!(
9710 after["task_id"].is_null(),
9711 "an inference outside any task carries a null task_id, not the last task's"
9712 );
9713 assert_eq!(after["parent_id"], events[0]["event_id"]);
9715 }
9716
9717 #[tokio::test]
9720 async fn tool_call_records_the_provider_id_verbatim_and_empty_as_null() {
9721 for (given, expected) in [
9722 (Some("toolu_1".to_string()), Some("toolu_1")),
9723 (Some(String::new()), None),
9724 (None, None),
9725 ] {
9726 let dir = tempfile::tempdir().unwrap();
9727 let mut w = make_writer(dir.path()).await;
9728 w.write_tool_call(
9729 0,
9730 "bash".to_string(),
9731 given.clone(),
9732 serde_json::json!({}),
9733 2,
9734 "",
9735 0,
9736 1,
9737 "ok".to_string(),
9738 None,
9739 None,
9740 None,
9741 )
9742 .await
9743 .unwrap();
9744 w.flush().await.unwrap();
9745 let e = &read_events(dir.path())[0];
9746 match expected {
9747 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9748 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9749 }
9750 }
9751 }
9752
9753 #[tokio::test]
9756 async fn tool_input_refused_records_its_fields_in_every_capture_mode() {
9757 for capture in [
9758 TraceCapture::None,
9759 TraceCapture::Meta,
9760 TraceCapture::Content,
9761 ] {
9762 for (given, expected) in [
9763 (Some("toolu_1"), Some("toolu_1")),
9764 (Some(""), None),
9765 (None, None),
9766 ] {
9767 let dir = tempfile::tempdir().unwrap();
9768 let mut w = make_writer_with_opts(dir.path(), capture).await;
9769 w.write_tool_input_refused(
9770 2,
9771 "murmur-tool-editor",
9772 given,
9773 &["operation".to_string(), "dest_path".to_string()],
9774 "murmur-tool-editor: missing required fields",
9775 )
9776 .await
9777 .unwrap();
9778 w.flush().await.unwrap();
9779 let e = &read_events(dir.path())[0];
9780 assert_eq!(e["event_type"], "tool_input_refused", "{capture:?}");
9781 assert!(e["event_id"].is_string());
9782 assert!(e["session_id"].is_string());
9783 assert!(e["timestamp"].is_u64());
9784 assert_eq!(e["turn"], 2);
9785 assert_eq!(e["tool_name"], "murmur-tool-editor");
9786 match expected {
9787 Some(id) => assert_eq!(e["tool_call_id"], id, "given={given:?}"),
9788 None => assert!(e["tool_call_id"].is_null(), "given={given:?}"),
9789 }
9790 assert_eq!(e["missing"], serde_json::json!(["operation", "dest_path"]));
9791 assert_eq!(e["reason"], "murmur-tool-editor: missing required fields");
9792 }
9793 }
9794 }
9795
9796 #[tokio::test]
9800 async fn tools_refreshed_event_records_trigger_names_and_task_parent() {
9801 let dir = tempfile::tempdir().unwrap();
9802 let mut w = make_writer(dir.path()).await;
9803 w.set_tool_refresh(Some("immediate"));
9804 w.write_session_start(Some(10), None, Vec::new())
9805 .await
9806 .unwrap();
9807 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9808 .await
9809 .unwrap();
9810 w.write_inference(
9811 0,
9812 Some(10),
9813 Some(5),
9814 "tool_call".to_string(),
9815 Some("tool_call"),
9816 None,
9817 None,
9818 None,
9819 Vec::new(),
9820 None,
9821 None,
9822 )
9823 .await
9824 .unwrap();
9825 w.write_tools_refreshed(
9826 1,
9827 "immediate",
9828 &["aaa-late-skill".to_string()],
9829 &[],
9830 &[
9831 "aaa-late-skill".to_string(),
9832 "zzz-existing-tool".to_string(),
9833 ],
9834 )
9835 .await
9836 .unwrap();
9837 w.flush().await.unwrap();
9838
9839 let events = read_events(dir.path());
9840 assert_eq!(events[0]["tool_refresh"], "immediate");
9841 let task = events
9842 .iter()
9843 .find(|e| e["event_type"] == "task_start")
9844 .unwrap();
9845 let refreshed: Vec<&Value> = events
9846 .iter()
9847 .filter(|e| e["event_type"] == "tools_refreshed")
9848 .collect();
9849 assert_eq!(refreshed.len(), 1);
9850 let r = refreshed[0];
9851 assert!(r["event_id"].as_str().unwrap().starts_with("evt_"));
9852 assert_eq!(r["parent_id"], task["event_id"]);
9853 assert_eq!(r["session_id"], "test-session-id");
9854 assert!(r["timestamp"].as_u64().is_some());
9855 assert_eq!(r["turn"], 1);
9856 assert_eq!(r["task_id"], "tsk_1");
9857 assert_eq!(r["trigger"], "immediate");
9858 assert_eq!(r["added"], serde_json::json!(["aaa-late-skill"]));
9859 assert_eq!(r["removed"], serde_json::json!([]));
9860 assert_eq!(
9861 r["tools"],
9862 serde_json::json!(["aaa-late-skill", "zzz-existing-tool"])
9863 );
9864
9865 let process_dir = tempfile::tempdir().unwrap();
9866 let mut process = make_writer(process_dir.path()).await;
9867 process.set_tool_refresh(None);
9868 process
9869 .write_session_start(Some(10), None, Vec::new())
9870 .await
9871 .unwrap();
9872 process.flush().await.unwrap();
9873 let start = &read_events(process_dir.path())[0];
9874 assert!(start.as_object().unwrap().contains_key("tool_refresh"));
9875 assert!(start["tool_refresh"].is_null());
9876 }
9877
9878 #[tokio::test]
9881 async fn compaction_declined_records_turn_task_tokens_and_reason() {
9882 let dir = tempfile::tempdir().unwrap();
9883 let mut w = make_writer(dir.path()).await;
9884 w.write_session_start(Some(10), None, Vec::new())
9885 .await
9886 .unwrap();
9887 w.write_task_start("tsk_1", "ctx_1", "a2a", event_provenance(), 3, &[], &[])
9888 .await
9889 .unwrap();
9890 w.write_inference(
9891 2,
9892 Some(10),
9893 Some(5),
9894 "tool_call".to_string(),
9895 Some("tool_call"),
9896 None,
9897 None,
9898 None,
9899 Vec::new(),
9900 None,
9901 None,
9902 )
9903 .await
9904 .unwrap();
9905 w.write_compaction_declined(
9906 2,
9907 4321,
9908 OccupancyBasis::Actual,
9909 COMPACTION_DECLINED_UNRESOLVED_TOOL_CALL,
9910 )
9911 .await
9912 .unwrap();
9913 w.flush().await.unwrap();
9914
9915 let events = read_events(dir.path());
9916 let declined: Vec<&Value> = events
9917 .iter()
9918 .filter(|e| e["event_type"] == "compaction_declined")
9919 .collect();
9920 assert_eq!(declined.len(), 1);
9921 let d = declined[0];
9922 assert_eq!(d["reason"], "unresolved_tool_call");
9923 assert_eq!(d["turn"], 2);
9924 assert_eq!(d["task_id"], "tsk_1");
9925 assert_eq!(d["tokens"], 4321);
9926 assert_eq!(d["tokens_source"], "actual");
9927 assert!(d["event_id"].as_str().unwrap().starts_with("evt_"));
9928 assert_eq!(
9929 d["parent_id"],
9930 events
9931 .iter()
9932 .find(|e| e["event_type"] == "inference")
9933 .unwrap()["event_id"]
9934 );
9935 }
9936
9937 #[tokio::test]
9940 async fn event_ids_sort_by_mint_time() {
9941 let dir = tempfile::tempdir().unwrap();
9942 let mut w = make_writer(dir.path()).await;
9943 for turn in 0..5 {
9944 w.write_inference(
9945 turn,
9946 Some(1),
9947 Some(1),
9948 "end_turn".to_string(),
9949 Some("end_turn"),
9950 None,
9951 None,
9952 None,
9953 Vec::new(),
9954 None,
9955 None,
9956 )
9957 .await
9958 .unwrap();
9959 }
9960 w.flush().await.unwrap();
9961
9962 let ids: Vec<String> = read_events(dir.path())
9963 .iter()
9964 .map(|e| e["event_id"].as_str().unwrap().to_string())
9965 .collect();
9966 let mut sorted = ids.clone();
9967 sorted.sort();
9968 assert_eq!(ids, sorted, "evt_ ids must sort into mint order");
9969 }
9970
9971 fn wire_payload(system: &str, messages: usize) -> Value {
9975 let messages: Vec<Value> = (0..messages)
9976 .map(|i| serde_json::json!({"role": "user", "content": format!("m{i}")}))
9977 .collect();
9978 serde_json::json!({
9979 "model": "claude-test",
9980 "max_tokens": 1024,
9981 "messages": messages,
9982 "tools": [{"name": "bash"}],
9983 "params": {},
9984 "system": system,
9985 })
9986 }
9987
9988 async fn write_turn(w: &mut TraceWriter, turn: u32, payload: &Value, response: &str) {
9989 let wire = WireCapture::from_driver_payload(payload, response);
9990 w.write_inference(
9991 turn,
9992 Some(10),
9993 Some(5),
9994 "end_turn".to_string(),
9995 Some("end_turn"),
9996 None,
9997 None,
9998 None,
9999 vec!["msg_0000000000000000000000000000000a".to_string()],
10000 Some(&wire),
10001 None,
10002 )
10003 .await
10004 .unwrap();
10005 }
10006
10007 fn blob_path(dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
10008 dir.join(BLOB_DIR_NAME).join(sha)
10009 }
10010
10011 #[tokio::test]
10014 async fn content_capture_names_blobs_that_exist_and_rehash_to_their_names() {
10015 let dir = tempfile::tempdir().unwrap();
10016 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10017 let payload = wire_payload("You are a capsule.", 3);
10018 write_turn(&mut w, 0, &payload, r#"{"stop_reason":"end_turn"}"#).await;
10019 w.flush().await.unwrap();
10020
10021 let e = &read_events(dir.path())[0];
10022 let mut named: Vec<String> = ["system_sha", "tools_sha", "response_sha"]
10023 .iter()
10024 .map(|key| {
10025 e[*key]
10026 .as_str()
10027 .unwrap_or_else(|| panic!("{key} missing from {e}"))
10028 .to_string()
10029 })
10030 .collect();
10031 let shas = e["message_shas"].as_array().expect("message_shas");
10032 assert_eq!(shas.len(), 3);
10033 named.extend(shas.iter().map(|s| s.as_str().unwrap().to_string()));
10034
10035 for sha in &named {
10036 let path = blob_path(dir.path(), sha);
10037 let bytes =
10038 std::fs::read(&path).unwrap_or_else(|err| panic!("blob {sha} must exist: {err}"));
10039 assert_eq!(murmur_artifact::sha256_hex(&bytes), *sha);
10040 assert_eq!(sha.len(), 64, "{sha}");
10043 assert!(
10044 sha.chars()
10045 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
10046 "{sha}"
10047 );
10048 }
10049 }
10050
10051 #[tokio::test]
10054 async fn the_system_blob_holds_the_prompt_as_readable_text() {
10055 let dir = tempfile::tempdir().unwrap();
10056 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10057 let payload = wire_payload("[Capsule] you are cap.\nBe terse.", 1);
10058 write_turn(&mut w, 0, &payload, "{}").await;
10059 w.flush().await.unwrap();
10060
10061 let sha = read_events(dir.path())[0]["system_sha"]
10062 .as_str()
10063 .unwrap()
10064 .to_string();
10065 assert_eq!(
10066 std::fs::read_to_string(blob_path(dir.path(), &sha)).unwrap(),
10067 "[Capsule] you are cap.\nBe terse."
10068 );
10069 }
10070
10071 #[tokio::test]
10074 async fn meta_writes_the_same_hashes_and_no_bodies() {
10075 let payload = wire_payload("You are a capsule.", 2);
10076 let response = r#"{"stop_reason":"end_turn"}"#;
10077
10078 let meta = tempfile::tempdir().unwrap();
10079 let mut w = make_writer_with_opts(meta.path(), TraceCapture::Meta).await;
10080 write_turn(&mut w, 0, &payload, response).await;
10081 w.flush().await.unwrap();
10082
10083 let content = tempfile::tempdir().unwrap();
10084 let mut w = make_writer_with_opts(content.path(), TraceCapture::Content).await;
10085 write_turn(&mut w, 0, &payload, response).await;
10086 w.flush().await.unwrap();
10087
10088 let meta_event = read_events(meta.path()).remove(0);
10089 let content_event = read_events(content.path()).remove(0);
10090 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
10091 assert_eq!(meta_event[key], content_event[key], "{key}");
10092 }
10093 assert!(
10094 !meta.path().join(BLOB_DIR_NAME).exists(),
10095 "meta must not create the blob directory"
10096 );
10097 assert!(content.path().join(BLOB_DIR_NAME).is_dir());
10098 }
10099
10100 #[tokio::test]
10102 async fn the_default_capture_mode_is_meta() {
10103 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
10104 let dir = tempfile::tempdir().unwrap();
10105 let mut w = make_writer_with_prompt(dir.path(), TraceCapture::default(), None, false).await;
10106 write_turn(&mut w, 0, &wire_payload("s", 1), "{}").await;
10107 w.flush().await.unwrap();
10108
10109 let e = &read_events(dir.path())[0];
10110 assert!(e.get("system_sha").is_some());
10111 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
10112 }
10113
10114 #[tokio::test]
10116 async fn none_omits_every_hash_and_leaves_the_rest_of_the_event_intact() {
10117 let payload = wire_payload("You are a capsule.", 2);
10118
10119 let off = tempfile::tempdir().unwrap();
10120 let mut w = make_writer_with_opts(off.path(), TraceCapture::None).await;
10121 write_turn(&mut w, 3, &payload, "{}").await;
10122 w.flush().await.unwrap();
10123
10124 let e = read_events(off.path()).remove(0);
10125 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
10126 assert!(
10127 e.get(key).is_none(),
10128 "{key} must be absent under none, got {e}"
10129 );
10130 }
10131 assert!(!off.path().join(BLOB_DIR_NAME).exists());
10132 for key in [
10133 "event_type",
10134 "event_id",
10135 "parent_id",
10136 "session_id",
10137 "timestamp",
10138 "turn",
10139 "task_id",
10140 "input_tokens",
10141 "output_tokens",
10142 "decision",
10143 "tool_name",
10144 "message_ids",
10145 ] {
10146 assert!(
10147 e.get(key).is_some(),
10148 "{key} must survive under none, got {e}"
10149 );
10150 }
10151 assert_eq!(e["turn"], 3);
10152 assert_eq!(e["message_ids"].as_array().unwrap().len(), 1);
10153 }
10154
10155 #[tokio::test]
10158 async fn an_unchanged_system_prompt_is_stored_once() {
10159 let dir = tempfile::tempdir().unwrap();
10160 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10161 let payload = wire_payload("You are a capsule.", 1);
10162 write_turn(&mut w, 0, &payload, "{}").await;
10163
10164 let sha = {
10165 w.flush().await.unwrap();
10166 read_events(dir.path())[0]["system_sha"]
10167 .as_str()
10168 .unwrap()
10169 .to_string()
10170 };
10171 let path = blob_path(dir.path(), &sha);
10172 std::fs::write(&path, b"sentinel").unwrap();
10173
10174 for turn in 1..10 {
10175 write_turn(&mut w, turn, &payload, "{}").await;
10176 }
10177 w.flush().await.unwrap();
10178
10179 let events = read_events(dir.path());
10180 assert_eq!(events.len(), 10);
10181 for e in &events {
10182 assert_eq!(e["system_sha"], sha.as_str());
10183 }
10184 assert_eq!(
10185 std::fs::read(&path).unwrap(),
10186 b"sentinel",
10187 "an existing blob is never rewritten"
10188 );
10189 let matching = std::fs::read_dir(dir.path().join(BLOB_DIR_NAME))
10190 .unwrap()
10191 .filter(|entry| entry.as_ref().unwrap().file_name() == sha.as_str())
10192 .count();
10193 assert_eq!(matching, 1, "exactly one file bears that name");
10194 }
10195
10196 #[tokio::test]
10201 async fn stop_reason_is_absent_when_no_driver_response_was_parsed() {
10202 let dir = tempfile::tempdir().unwrap();
10203 let mut w = make_writer(dir.path()).await;
10204 w.write_inference(
10205 0,
10206 Some(10),
10207 Some(5),
10208 "end_turn".to_string(),
10209 None,
10210 None,
10211 None,
10212 None,
10213 Vec::new(),
10214 None,
10215 None,
10216 )
10217 .await
10218 .unwrap();
10219 w.flush().await.unwrap();
10220
10221 let event = read_events(dir.path()).remove(0);
10222 assert!(
10223 event.get("stop_reason").is_none(),
10224 "stop_reason must be absent, got {event}"
10225 );
10226 }
10227
10228 #[tokio::test]
10231 async fn a_hook_origin_record_carries_no_hashes() {
10232 let dir = tempfile::tempdir().unwrap();
10233 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10234 let origin = InferenceOrigin {
10235 source: "hook:gatekeeper".to_string(),
10236 model: "claude-test".to_string(),
10237 };
10238 w.write_inference(
10239 0,
10240 Some(1),
10241 Some(1),
10242 "end_turn".to_string(),
10243 None,
10245 None,
10246 Some(&origin),
10247 None,
10248 Vec::new(),
10249 None,
10250 None,
10251 )
10252 .await
10253 .unwrap();
10254 w.flush().await.unwrap();
10255
10256 let e = read_events(dir.path()).remove(0);
10257 assert_eq!(e["origin"], "hook:gatekeeper");
10258 for key in ["system_sha", "tools_sha", "response_sha", "message_shas"] {
10259 assert!(e.get(key).is_none(), "{key} must be absent, got {e}");
10260 }
10261 assert!(!dir.path().join(BLOB_DIR_NAME).exists());
10262 }
10263
10264 #[tokio::test]
10267 async fn message_shas_locate_the_index_two_runs_diverge_at() {
10268 async fn shas_for(payload: &Value) -> Vec<String> {
10269 let dir = tempfile::tempdir().unwrap();
10270 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Meta).await;
10271 write_turn(&mut w, 0, payload, "{}").await;
10272 w.flush().await.unwrap();
10273 read_events(dir.path())[0]["message_shas"]
10274 .as_array()
10275 .unwrap()
10276 .iter()
10277 .map(|s| s.as_str().unwrap().to_string())
10278 .collect()
10279 }
10280
10281 let first = wire_payload("You are a capsule.", 4);
10282 let mut second = first.clone();
10283 second["messages"][2]["content"] = serde_json::json!("diverged");
10284
10285 let a = shas_for(&first).await;
10286 let b = shas_for(&second).await;
10287 assert_eq!(a.len(), 4);
10288 let divergence = a.iter().zip(&b).position(|(x, y)| x != y);
10289 assert_eq!(divergence, Some(2));
10290 assert_eq!(a[3], b[3], "only the changed message changes its own sha");
10291 }
10292
10293 #[tokio::test]
10296 async fn identical_messages_share_one_sha_and_one_blob() {
10297 let dir = tempfile::tempdir().unwrap();
10298 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10299 let payload = serde_json::json!({
10300 "messages": [{"role": "user", "content": "same"}, {"role": "user", "content": "same"}],
10301 "tools": [],
10302 "system": "s",
10303 });
10304 write_turn(&mut w, 0, &payload, "{}").await;
10305 w.flush().await.unwrap();
10306
10307 let shas = read_events(dir.path())[0]["message_shas"]
10308 .as_array()
10309 .unwrap()
10310 .iter()
10311 .map(|s| s.as_str().unwrap().to_string())
10312 .collect::<Vec<_>>();
10313 assert_eq!(shas[0], shas[1]);
10314 assert!(blob_path(dir.path(), &shas[0]).is_file());
10315 }
10316
10317 #[tokio::test]
10320 async fn a_message_blob_is_the_body_verbatim() {
10321 let dir = tempfile::tempdir().unwrap();
10322 let mut w = make_writer_with_opts(dir.path(), TraceCapture::Content).await;
10323 let message = serde_json::json!({"role": "user", "content": "hello"});
10324 let payload = serde_json::json!({
10325 "messages": [message.clone()],
10326 "tools": [],
10327 "system": "s",
10328 });
10329 write_turn(&mut w, 0, &payload, "{}").await;
10330 w.flush().await.unwrap();
10331
10332 let sha = read_events(dir.path())[0]["message_shas"][0]
10333 .as_str()
10334 .unwrap()
10335 .to_string();
10336 assert_eq!(
10337 std::fs::read(blob_path(dir.path(), &sha)).unwrap(),
10338 serde_json::to_vec(&message).unwrap()
10339 );
10340 }
10341
10342 #[tokio::test]
10350 async fn a_plan_written_delegation_record_matches_a_delegate_task_one() {
10351 let plan_dir = tempfile::tempdir().unwrap();
10352 let tool_dir = tempfile::tempdir().unwrap();
10353 let session = "ses_0000000000004000800000000000plan".to_string();
10354 let node = "evt_0000000000004000800000000000node".to_string();
10355
10356 let plan = PlanTraceAppender::open(
10357 &crate::workdir_fs::WorkdirRoot::open(plan_dir.path()).unwrap(),
10358 session.clone(),
10359 node.clone(),
10360 )
10361 .unwrap();
10362 let tool = ResourceTraceAppender::open(
10363 &crate::workdir_fs::WorkdirRoot::open(tool_dir.path()).unwrap(),
10364 session.clone(),
10365 node.clone(),
10366 )
10367 .await
10368 .unwrap();
10369
10370 let snapshot = crate::workdir_snapshot::WorkdirSnapshot {
10371 source: "/home/you/project".to_string(),
10372 files: 2,
10373 dirs: 0,
10374 symlinks: 0,
10375 repointed_symlinks: 0,
10376 bytes: 12,
10377 skipped_count: 0,
10378 skipped: Vec::new(),
10379 };
10380 plan.write_delegation_start(
10381 "dlg_abc123",
10382 "worker",
10383 "0.1.0",
10384 "ses_child",
10385 ".murmur/c",
10386 "/r/ses_child",
10387 Some(&snapshot),
10388 );
10389 tool.write_delegation_start(
10390 "dlg_abc123",
10391 "worker",
10392 "0.1.0",
10393 "ses_child",
10394 ".murmur/c",
10395 "/r/ses_child",
10396 Some(&snapshot),
10397 )
10398 .await;
10399 plan.write_delegation(
10400 "worker",
10401 "0.1.0",
10402 Some("dlg_abc123".to_string()),
10403 Some("ses_child".to_string()),
10404 42,
10405 "completed",
10406 None,
10407 );
10408 tool.write_delegation(
10409 "worker",
10410 "0.1.0",
10411 Some("dlg_abc123".to_string()),
10412 Some("ses_child".to_string()),
10413 42,
10414 "completed",
10415 None,
10416 )
10417 .await;
10418
10419 let read = |dir: &Path| -> Vec<serde_json::Map<String, Value>> {
10420 std::fs::read_to_string(dir.join("trace.jsonl"))
10421 .unwrap()
10422 .lines()
10423 .map(|line| serde_json::from_str::<serde_json::Map<_, _>>(line).unwrap())
10424 .collect()
10425 };
10426 let from_plan = read(plan_dir.path());
10427 let from_tool = read(tool_dir.path());
10428 assert_eq!(from_plan.len(), 2);
10429 assert_eq!(from_tool.len(), 2);
10430 assert_eq!(
10431 from_plan[0]["workdir_snapshot"],
10432 serde_json::to_value(&snapshot).unwrap()
10433 );
10434
10435 for (planned, called, event_type) in [
10436 (&from_plan[0], &from_tool[0], "delegation_start"),
10437 (&from_plan[1], &from_tool[1], "delegation"),
10438 ] {
10439 assert_eq!(planned["event_type"], event_type);
10440 assert_eq!(called["event_type"], event_type);
10441 assert_eq!(
10442 planned.keys().collect::<Vec<_>>(),
10443 called.keys().collect::<Vec<_>>(),
10444 "{planned:?} vs {called:?}"
10445 );
10446 assert_eq!(planned["parent_id"], node.as_str());
10447 assert_eq!(called["parent_id"], node.as_str());
10448 for (key, value) in planned {
10449 if key == "event_id" || key == "timestamp" || key == "prev_hash" {
10451 continue;
10452 }
10453 assert_eq!(value, &called[key], "{key}: {planned:?} vs {called:?}");
10454 }
10455 for key in ["plan_id", "step_id", "surface", "kind"] {
10456 assert!(!planned.contains_key(key), "{key} is on {planned:?}");
10457 assert!(!called.contains_key(key), "{key} is on {called:?}");
10458 }
10459 }
10460 }
10461
10462 #[tokio::test]
10466 async fn spawner_ended_names_the_lineage_or_omits_it() {
10467 let dir = tempfile::tempdir().unwrap();
10468 let session = "ses_0000000000004000800000000child".to_string();
10469 let node = "evt_0000000000004000800000000000node".to_string();
10470 let appender = ResourceTraceAppender::open(
10471 &crate::workdir_fs::WorkdirRoot::open(dir.path()).unwrap(),
10472 session.clone(),
10473 node.clone(),
10474 )
10475 .await
10476 .unwrap();
10477
10478 appender
10479 .write_spawner_ended(Some("ses_parent"), Some("dlg_abc123"))
10480 .await;
10481 appender.write_spawner_ended(None, None).await;
10482
10483 let lines: Vec<serde_json::Map<String, Value>> =
10484 std::fs::read_to_string(dir.path().join("trace.jsonl"))
10485 .unwrap()
10486 .lines()
10487 .map(|line| serde_json::from_str(line).unwrap())
10488 .collect();
10489 assert_eq!(lines.len(), 2);
10490 let named = &lines[0];
10491 assert_eq!(named["event_type"], "spawner_ended");
10492 assert_eq!(named["parent_id"], node.as_str());
10493 assert_eq!(named["session_id"], session.as_str());
10494 assert_eq!(named["spawned_by"], "ses_parent");
10495 assert_eq!(named["delegation_id"], "dlg_abc123");
10496 assert_eq!(
10497 named.keys().collect::<Vec<_>>(),
10498 [
10499 "delegation_id",
10500 "event_id",
10501 "event_type",
10502 "parent_id",
10503 "prev_hash",
10504 "session_id",
10505 "spawned_by",
10506 "timestamp"
10507 ]
10508 );
10509 let bare = &lines[1];
10510 assert_eq!(bare["event_type"], "spawner_ended");
10511 assert!(!bare.contains_key("spawned_by"), "{bare:?}");
10512 assert!(!bare.contains_key("delegation_id"), "{bare:?}");
10513 }
10514}