|
| 1 | +use std::fs; |
| 2 | +use std::path::{Path, PathBuf}; |
| 3 | +use std::time::{SystemTime, UNIX_EPOCH}; |
| 4 | + |
| 5 | +use runtime::task_registry::{TaskRegistry, TaskStatus}; |
| 6 | +use runtime::{ |
| 7 | + validate_packet, ConfigLoader, HookRunner, LaneEvent, LaneEventBlocker, LaneFailureClass, |
| 8 | + RuntimeHookConfig, TaskPacket, WorkerEventKind, WorkerFailureKind, WorkerRegistry, |
| 9 | + WorkerStatus, |
| 10 | +}; |
| 11 | +use serde_json::json; |
| 12 | + |
| 13 | +struct TestDir { |
| 14 | + path: PathBuf, |
| 15 | +} |
| 16 | + |
| 17 | +impl TestDir { |
| 18 | + fn new(prefix: &str) -> Self { |
| 19 | + let unique = SystemTime::now() |
| 20 | + .duration_since(UNIX_EPOCH) |
| 21 | + .expect("time should be after epoch") |
| 22 | + .as_nanos(); |
| 23 | + let path = std::env::temp_dir().join(format!("{prefix}-{unique}")); |
| 24 | + fs::create_dir_all(&path).expect("temp dir should be created"); |
| 25 | + Self { path } |
| 26 | + } |
| 27 | + |
| 28 | + fn path(&self) -> &Path { |
| 29 | + &self.path |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +impl Drop for TestDir { |
| 34 | + fn drop(&mut self) { |
| 35 | + if self.path.exists() { |
| 36 | + let _ = fs::remove_dir_all(&self.path); |
| 37 | + } |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +#[test] |
| 42 | +fn worker_boot_state_progresses_from_spawning_to_ready_snapshot() { |
| 43 | + let registry = WorkerRegistry::new(); |
| 44 | + let worker = registry.create("/tmp/runtime-integration-worker", &[], true); |
| 45 | + |
| 46 | + assert_eq!(worker.status, WorkerStatus::Spawning); |
| 47 | + assert_eq!(worker.events.len(), 1); |
| 48 | + assert_eq!(worker.events[0].kind, WorkerEventKind::Spawning); |
| 49 | + |
| 50 | + let ready = registry |
| 51 | + .observe(&worker.worker_id, "Ready for your input\n>") |
| 52 | + .expect("ready observe should succeed"); |
| 53 | + |
| 54 | + assert_eq!(ready.status, WorkerStatus::ReadyForPrompt); |
| 55 | + assert!(ready.last_error.is_none()); |
| 56 | + assert_eq!( |
| 57 | + ready.events.last().map(|event| event.kind), |
| 58 | + Some(WorkerEventKind::ReadyForPrompt) |
| 59 | + ); |
| 60 | + |
| 61 | + let snapshot = registry |
| 62 | + .await_ready(&worker.worker_id) |
| 63 | + .expect("ready snapshot should succeed"); |
| 64 | + assert_eq!(snapshot.worker_id, worker.worker_id); |
| 65 | + assert!(snapshot.ready); |
| 66 | + assert!(!snapshot.blocked); |
| 67 | + assert!(!snapshot.replay_prompt_ready); |
| 68 | + assert!(snapshot.last_error.is_none()); |
| 69 | +} |
| 70 | + |
| 71 | +#[test] |
| 72 | +fn lane_event_emission_serializes_worker_prompt_delivery_failure() { |
| 73 | + let registry = WorkerRegistry::new(); |
| 74 | + let worker = registry.create("/tmp/runtime-integration-lane", &[], true); |
| 75 | + registry |
| 76 | + .observe(&worker.worker_id, "Ready for input\n>") |
| 77 | + .expect("ready observe should succeed"); |
| 78 | + registry |
| 79 | + .send_prompt(&worker.worker_id, Some("Run lane event emission test")) |
| 80 | + .expect("prompt send should succeed"); |
| 81 | + |
| 82 | + let failed = registry |
| 83 | + .observe( |
| 84 | + &worker.worker_id, |
| 85 | + "% Run lane event emission test\nzsh: command not found: Run", |
| 86 | + ) |
| 87 | + .expect("misdelivery observe should succeed"); |
| 88 | + |
| 89 | + let error = failed |
| 90 | + .last_error |
| 91 | + .clone() |
| 92 | + .expect("prompt delivery failure should be recorded"); |
| 93 | + assert_eq!(error.kind, WorkerFailureKind::PromptDelivery); |
| 94 | + |
| 95 | + let blocker = LaneEventBlocker { |
| 96 | + failure_class: LaneFailureClass::PromptDelivery, |
| 97 | + detail: error.message, |
| 98 | + }; |
| 99 | + let lane_event = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker).with_data(json!({ |
| 100 | + "worker_id": failed.worker_id, |
| 101 | + "worker_status": failed.status, |
| 102 | + "worker_event_kinds": failed |
| 103 | + .events |
| 104 | + .iter() |
| 105 | + .map(|event| format!("{:?}", event.kind)) |
| 106 | + .collect::<Vec<_>>() |
| 107 | + })); |
| 108 | + |
| 109 | + let emitted = serde_json::to_value(&lane_event).expect("lane event should serialize"); |
| 110 | + assert_eq!(emitted["event"], json!("lane.blocked")); |
| 111 | + assert_eq!(emitted["status"], json!("blocked")); |
| 112 | + assert_eq!(emitted["failureClass"], json!("prompt_delivery")); |
| 113 | + assert!(emitted["detail"] |
| 114 | + .as_str() |
| 115 | + .expect("detail should be a string") |
| 116 | + .contains("worker prompt landed in shell")); |
| 117 | + assert_eq!(emitted["data"]["worker_status"], json!("ready_for_prompt")); |
| 118 | + assert!(emitted["data"]["worker_event_kinds"] |
| 119 | + .as_array() |
| 120 | + .expect("worker event kinds should be an array") |
| 121 | + .iter() |
| 122 | + .any(|value| value == "PromptMisdelivery")); |
| 123 | +} |
| 124 | + |
| 125 | +#[test] |
| 126 | +fn hook_merge_runs_loaded_config_hooks_and_overlay_once_each() { |
| 127 | + let temp = TestDir::new("runtime-hooks-integration"); |
| 128 | + let cwd = temp.path().join("project"); |
| 129 | + let home = temp.path().join("home").join(".claw"); |
| 130 | + fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); |
| 131 | + fs::create_dir_all(&home).expect("home config dir should exist"); |
| 132 | + |
| 133 | + fs::write( |
| 134 | + home.join("settings.json"), |
| 135 | + r#"{ |
| 136 | + "hooks": { |
| 137 | + "PreToolUse": ["printf 'config pre'"] |
| 138 | + } |
| 139 | + }"#, |
| 140 | + ) |
| 141 | + .expect("home settings should be written"); |
| 142 | + fs::write( |
| 143 | + cwd.join(".claw").join("settings.local.json"), |
| 144 | + r#"{ |
| 145 | + "hooks": { |
| 146 | + "PostToolUse": ["printf 'config post'"] |
| 147 | + } |
| 148 | + }"#, |
| 149 | + ) |
| 150 | + .expect("project settings should be written"); |
| 151 | + |
| 152 | + let loaded = ConfigLoader::new(&cwd, &home) |
| 153 | + .load() |
| 154 | + .expect("config should load"); |
| 155 | + let overlay = RuntimeHookConfig::new( |
| 156 | + vec![ |
| 157 | + "printf 'config pre'".to_string(), |
| 158 | + "printf 'overlay pre'".to_string(), |
| 159 | + ], |
| 160 | + vec![], |
| 161 | + vec![], |
| 162 | + ); |
| 163 | + |
| 164 | + let runner = HookRunner::new(loaded.hooks().merged(&overlay)); |
| 165 | + let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#); |
| 166 | + |
| 167 | + assert_eq!( |
| 168 | + result.messages(), |
| 169 | + &["config pre".to_string(), "overlay pre".to_string()] |
| 170 | + ); |
| 171 | + assert!(!result.is_failed()); |
| 172 | + assert!(!result.is_denied()); |
| 173 | +} |
| 174 | + |
| 175 | +#[test] |
| 176 | +fn task_packet_roundtrip_validates_and_creates_registry_task() { |
| 177 | + let packet = TaskPacket { |
| 178 | + objective: "Ship runtime integration coverage".to_string(), |
| 179 | + scope: "runtime/tests".to_string(), |
| 180 | + repo: "claw-code-parity".to_string(), |
| 181 | + branch_policy: "origin/main only".to_string(), |
| 182 | + acceptance_tests: vec!["cargo test --workspace".to_string()], |
| 183 | + commit_policy: "single verified commit".to_string(), |
| 184 | + reporting_contract: "print verification summary and sha".to_string(), |
| 185 | + escalation_policy: "escalate only on destructive ambiguity".to_string(), |
| 186 | + }; |
| 187 | + |
| 188 | + let serialized = serde_json::to_string(&packet).expect("packet should serialize"); |
| 189 | + let roundtrip: TaskPacket = |
| 190 | + serde_json::from_str(&serialized).expect("packet should deserialize"); |
| 191 | + let validated = validate_packet(roundtrip.clone()).expect("packet should validate"); |
| 192 | + |
| 193 | + let registry = TaskRegistry::new(); |
| 194 | + let task = registry |
| 195 | + .create_from_packet(validated.into_inner()) |
| 196 | + .expect("task should be created from packet"); |
| 197 | + registry |
| 198 | + .set_status(&task.task_id, TaskStatus::Running) |
| 199 | + .expect("status should update"); |
| 200 | + |
| 201 | + let stored = registry.get(&task.task_id).expect("task should be stored"); |
| 202 | + assert_eq!(stored.prompt, packet.objective); |
| 203 | + assert_eq!(stored.description.as_deref(), Some("runtime/tests")); |
| 204 | + assert_eq!(stored.task_packet, Some(packet)); |
| 205 | + assert_eq!(stored.status, TaskStatus::Running); |
| 206 | +} |
| 207 | + |
| 208 | +#[test] |
| 209 | +fn config_validation_rejects_invalid_hook_entries_before_merge() { |
| 210 | + let temp = TestDir::new("runtime-config-validation"); |
| 211 | + let cwd = temp.path().join("project"); |
| 212 | + let home = temp.path().join("home").join(".claw"); |
| 213 | + let project_settings = cwd.join(".claw").join("settings.json"); |
| 214 | + fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); |
| 215 | + fs::create_dir_all(&home).expect("home config dir should exist"); |
| 216 | + |
| 217 | + fs::write( |
| 218 | + home.join("settings.json"), |
| 219 | + r#"{"hooks":{"PreToolUse":["printf 'base'"]}}"#, |
| 220 | + ) |
| 221 | + .expect("home settings should be written"); |
| 222 | + fs::write( |
| 223 | + &project_settings, |
| 224 | + r#"{"hooks":{"PreToolUse":["printf 'project'",42]}}"#, |
| 225 | + ) |
| 226 | + .expect("project settings should be written"); |
| 227 | + |
| 228 | + let error = ConfigLoader::new(&cwd, &home) |
| 229 | + .load() |
| 230 | + .expect_err("invalid hooks should fail validation"); |
| 231 | + let rendered = error.to_string(); |
| 232 | + |
| 233 | + assert!(rendered.contains(&format!( |
| 234 | + "{}: hooks: field PreToolUse must contain only strings", |
| 235 | + project_settings.display() |
| 236 | + ))); |
| 237 | + assert!(!rendered.contains("merged settings.hooks")); |
| 238 | +} |
0 commit comments