1use super::{
6 Hook, HookAction, HookBinding, HookEvent, HookExecutor, HookHandler, HookOutcome, HookResponse,
7 HookResult,
8};
9use async_trait::async_trait;
10use std::collections::{HashMap, HashSet};
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::{Arc, OnceLock, RwLock};
14use tokio::sync::mpsc;
15
16use crate::error::{read_or_recover, write_or_recover};
17
18pub(crate) type HookTaskFuture = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
19
20pub(crate) trait HookTaskDispatcher: Send + Sync {
21 fn dispatch(&self, name: &'static str, task: HookTaskFuture) -> Result<(), String>;
22}
23
24#[derive(Debug, thiserror::Error)]
25#[error("projected Hook name '{name}' conflicts with the compatibility registry")]
26pub(crate) struct HookEngineSnapshotError {
27 name: String,
28}
29
30impl HookEngineSnapshotError {
31 pub(crate) fn name(&self) -> &str {
32 &self.name
33 }
34}
35
36pub struct HookEngine {
38 hooks: Arc<RwLock<HashMap<String, Arc<Hook>>>>,
40
41 handlers: Arc<RwLock<HashMap<String, Arc<dyn HookHandler>>>>,
43
44 event_tx: Option<mpsc::Sender<HookEvent>>,
46
47 task_dispatcher: OnceLock<Arc<dyn HookTaskDispatcher>>,
49}
50
51impl std::fmt::Debug for HookEngine {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.debug_struct("HookEngine")
54 .field("hooks_count", &read_or_recover(&self.hooks).len())
55 .field("handlers_count", &read_or_recover(&self.handlers).len())
56 .field("has_event_channel", &self.event_tx.is_some())
57 .field("has_task_dispatcher", &self.task_dispatcher.get().is_some())
58 .finish()
59 }
60}
61
62impl Default for HookEngine {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl HookEngine {
69 pub fn new() -> Self {
71 Self {
72 hooks: Arc::new(RwLock::new(HashMap::new())),
73 handlers: Arc::new(RwLock::new(HashMap::new())),
74 event_tx: None,
75 task_dispatcher: OnceLock::new(),
76 }
77 }
78
79 pub fn with_event_channel(mut self, tx: mpsc::Sender<HookEvent>) -> Self {
81 self.event_tx = Some(tx);
82 self
83 }
84
85 pub fn register(&self, hook: Hook) {
87 let mut hooks = write_or_recover(&self.hooks);
88 hooks.insert(hook.id.clone(), Arc::new(hook));
89 }
90
91 pub fn unregister(&self, hook_id: &str) -> Option<Hook> {
93 let mut hooks = write_or_recover(&self.hooks);
94 hooks.remove(hook_id).map(|hook| (*hook).clone())
95 }
96
97 pub fn register_handler(&self, hook_id: &str, handler: Arc<dyn HookHandler>) {
99 drop(self.replace_handler(hook_id, handler));
100 }
101
102 pub fn unregister_handler(&self, hook_id: &str) {
104 drop(self.take_handler(hook_id));
105 }
106
107 pub(crate) fn replace_handler(
108 &self,
109 hook_id: &str,
110 handler: Arc<dyn HookHandler>,
111 ) -> Option<Arc<dyn HookHandler>> {
112 write_or_recover(&self.handlers).insert(hook_id.to_string(), handler)
113 }
114
115 pub(crate) fn take_handler(&self, hook_id: &str) -> Option<Arc<dyn HookHandler>> {
116 write_or_recover(&self.handlers).remove(hook_id)
117 }
118
119 pub(crate) fn register_registration(
121 &self,
122 hook: Hook,
123 handler: Option<Arc<dyn HookHandler>>,
124 ) -> (Option<Arc<Hook>>, Option<Arc<dyn HookHandler>>) {
125 let hook_id = hook.id.clone();
126 let mut hooks = write_or_recover(&self.hooks);
127 let mut handlers = write_or_recover(&self.handlers);
128 let retired_hook = hooks.insert(hook_id.clone(), Arc::new(hook));
129 let retired_handler = match handler {
130 Some(handler) => handlers.insert(hook_id, handler),
131 None => handlers.remove(&hook_id),
132 };
133 (retired_hook, retired_handler)
134 }
135
136 pub(crate) fn unregister_registration(
138 &self,
139 hook_id: &str,
140 ) -> (Option<Arc<Hook>>, Option<Arc<dyn HookHandler>>) {
141 let mut hooks = write_or_recover(&self.hooks);
142 let mut handlers = write_or_recover(&self.handlers);
143 let retired_handler = handlers.remove(hook_id);
144 let retired_hook = hooks.remove(hook_id);
145 (retired_hook, retired_handler)
146 }
147
148 pub(crate) fn snapshot_with_external_hooks(
154 &self,
155 external: impl IntoIterator<Item = Arc<HookBinding>>,
156 include_compatibility: bool,
157 ) -> Result<Self, HookEngineSnapshotError> {
158 let compatibility_hooks = read_or_recover(&self.hooks);
160 let compatibility_handlers = read_or_recover(&self.handlers);
161 let compatibility_names = compatibility_hooks
162 .keys()
163 .chain(compatibility_handlers.keys())
164 .cloned()
165 .collect::<HashSet<_>>();
166 let mut hooks = if include_compatibility {
167 compatibility_hooks.clone()
168 } else {
169 HashMap::new()
170 };
171 let mut handlers = if include_compatibility {
172 compatibility_handlers.clone()
173 } else {
174 HashMap::new()
175 };
176 let mut projected_names = HashSet::new();
177
178 for binding in external {
179 let name = binding.hook().id.clone();
180 if compatibility_names.contains(&name) || !projected_names.insert(name.clone()) {
181 return Err(HookEngineSnapshotError { name });
182 }
183 hooks.insert(name.clone(), Arc::clone(binding.hook_arc()));
184 handlers.insert(name, Arc::clone(binding.handler_arc()));
185 }
186
187 Ok(Self {
188 hooks: Arc::new(RwLock::new(hooks)),
189 handlers: Arc::new(RwLock::new(handlers)),
190 event_tx: include_compatibility
191 .then(|| self.event_tx.clone())
192 .flatten(),
193 task_dispatcher: OnceLock::new(),
194 })
195 }
196
197 pub(crate) fn attach_task_dispatcher(
198 &self,
199 dispatcher: Arc<dyn HookTaskDispatcher>,
200 ) -> Result<(), Arc<dyn HookTaskDispatcher>> {
201 self.task_dispatcher.set(dispatcher)
202 }
203
204 pub fn matching_hooks(&self, event: &HookEvent) -> Vec<Hook> {
206 self.matching_hook_arcs(event)
207 .into_iter()
208 .map(|hook| (*hook).clone())
209 .collect()
210 }
211
212 fn matching_hook_arcs(&self, event: &HookEvent) -> Vec<Arc<Hook>> {
213 let hooks = read_or_recover(&self.hooks);
214 let mut matching: Vec<Arc<Hook>> = hooks
215 .values()
216 .filter(|h| h.matches(event))
217 .cloned()
218 .collect();
219
220 matching.sort_by(|left, right| {
222 left.config
223 .priority
224 .cmp(&right.config.priority)
225 .then_with(|| left.id.cmp(&right.id))
226 });
227 matching
228 }
229
230 pub async fn fire(&self, event: &HookEvent) -> HookResult {
232 self.fire_outcome(event).await.into()
233 }
234
235 pub async fn fire_outcome(&self, event: &HookEvent) -> HookOutcome {
237 self.fire_outcome_with_policy(event, true).await
238 }
239
240 pub(crate) async fn fire_outcome_inline_observers(&self, event: &HookEvent) -> HookOutcome {
245 self.fire_outcome_with_policy(event, false).await
246 }
247
248 async fn fire_outcome_with_policy(
249 &self,
250 event: &HookEvent,
251 detach_observational_handlers: bool,
252 ) -> HookOutcome {
253 if let Some(ref tx) = self.event_tx {
255 let _ = tx.send(event.clone()).await;
256 }
257
258 let matching_hooks = self.matching_hook_arcs(event);
260
261 if matching_hooks.is_empty() {
262 return HookOutcome::Continue(None);
263 }
264
265 let mut last_modified: Option<serde_json::Value> = None;
267 for hook in matching_hooks {
268 let result = self
269 .execute_hook(&hook, event, detach_observational_handlers)
270 .await;
271
272 match result {
273 HookOutcome::Continue(modified) => {
274 if modified.is_some() {
276 last_modified = modified;
277 }
278 }
279 block @ HookOutcome::Block { .. } => return block,
280 retry @ HookOutcome::Retry { .. } => return retry,
281 HookOutcome::Skip => return HookOutcome::Continue(None),
282 escalate @ HookOutcome::Escalate { .. } => return escalate,
283 }
284 }
285
286 HookOutcome::Continue(last_modified)
287 }
288
289 async fn execute_hook(
291 &self,
292 hook: &Hook,
293 event: &HookEvent,
294 detach_observational_handlers: bool,
295 ) -> HookOutcome {
296 let is_gate = Self::is_gating_event(event);
297
298 let handler = {
300 let handlers = read_or_recover(&self.handlers);
301 handlers.get(&hook.id).cloned()
302 };
303
304 match handler {
305 Some(h) => {
306 if hook.config.async_execution && !is_gate && detach_observational_handlers {
311 let hook_id = hook.id.clone();
312 let event = event.clone();
313 let event_type = event.event_type();
314 let task: HookTaskFuture = Box::pin(async move {
315 let response = tokio::task::spawn_blocking(move || {
316 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
317 h.try_handle(&event)
318 }))
319 })
320 .await;
321 match response {
322 Ok(Ok(Ok(_))) => {}
323 Ok(Ok(Err(error))) => tracing::warn!(
324 hook_id = %hook_id,
325 event_type = %event_type,
326 failure = %error,
327 "Asynchronous observational hook handler failed"
328 ),
329 Ok(Err(_)) => tracing::warn!(
330 hook_id = %hook_id,
331 event_type = %event_type,
332 "Asynchronous observational hook handler panicked"
333 ),
334 Err(error) => tracing::warn!(
335 hook_id = %hook_id,
336 event_type = %event_type,
337 failure = %error,
338 "Asynchronous observational hook task failed"
339 ),
340 }
341 });
342 if let Some(dispatcher) = self.task_dispatcher.get() {
343 if let Err(error) = dispatcher.dispatch("hook.observer", task) {
344 tracing::warn!(
345 hook_id = %hook.id,
346 event_type = %event_type,
347 failure = %error,
348 "Asynchronous observational hook could not be supervised"
349 );
350 }
351 } else {
352 tokio::spawn(task);
353 }
354 return HookOutcome::Continue(None);
355 }
356
357 let timeout = std::time::Duration::from_millis(hook.config.timeout_ms);
358 let event_for_handler = event.clone();
359 let mut task =
360 tokio::task::spawn_blocking(move || h.try_handle(&event_for_handler));
361
362 match tokio::time::timeout(timeout, &mut task).await {
363 Ok(Ok(Ok(response))) => self.response_to_outcome(response),
364 Ok(Ok(Err(error))) => self.handler_failure(hook, event, error),
365 Ok(Err(error)) => self.handler_failure(
366 hook,
367 event,
368 format!("handler terminated unexpectedly: {error}"),
369 ),
370 Err(_) => {
371 task.abort();
376 if !detach_observational_handlers {
377 if let Err(error) = task.await {
382 if !error.is_cancelled() {
383 tracing::warn!(
384 hook_id = %hook.id,
385 event_type = %event.event_type(),
386 failure = %error,
387 "Timed-out observational Hook handler failed while settling"
388 );
389 }
390 }
391 } else if let Some(dispatcher) = self.task_dispatcher.get() {
392 let hook_id = hook.id.clone();
393 let event_type = event.event_type();
394 let settle: HookTaskFuture = Box::pin(async move {
395 if let Err(error) = task.await {
396 if !error.is_cancelled() {
397 tracing::warn!(
398 hook_id = %hook_id,
399 event_type = %event_type,
400 failure = %error,
401 "Timed-out Hook handler failed while settling"
402 );
403 }
404 }
405 });
406 if let Err(error) = dispatcher.dispatch("hook.timeout-settle", settle) {
407 tracing::warn!(
408 hook_id = %hook.id,
409 event_type = %event.event_type(),
410 failure = %error,
411 "Timed-out Hook handler could not be supervised"
412 );
413 }
414 }
415 self.handler_failure(
416 hook,
417 event,
418 format!("handler timed out after {} ms", hook.config.timeout_ms),
419 )
420 }
421 }
422 }
423 None => HookOutcome::Continue(None),
426 }
427 }
428
429 fn is_gating_event(event: &HookEvent) -> bool {
435 matches!(
436 event,
437 HookEvent::PreToolUse(_)
438 | HookEvent::PermissionRequest(_)
439 | HookEvent::PreCompact(_)
440 | HookEvent::PrePrompt(_)
441 | HookEvent::PrePlanning(_)
442 )
443 }
444
445 fn handler_failure(&self, hook: &Hook, event: &HookEvent, failure: String) -> HookOutcome {
447 tracing::warn!(
448 hook_id = %hook.id,
449 event_type = %event.event_type(),
450 failure = %failure,
451 gating = Self::is_gating_event(event),
452 "Hook handler failed"
453 );
454
455 if Self::is_gating_event(event) {
456 HookOutcome::Block {
457 reason: format!("Required hook '{}' failed: {}", hook.id, failure),
458 }
459 } else {
460 HookOutcome::Continue(None)
461 }
462 }
463
464 fn response_to_outcome(&self, response: HookResponse) -> HookOutcome {
466 match response.action {
467 HookAction::Continue => HookOutcome::Continue(response.modified),
468 HookAction::Block => HookOutcome::Block {
469 reason: response.reason.unwrap_or_else(|| "Blocked".to_string()),
470 },
471 HookAction::Retry => HookOutcome::Retry {
472 reason: response
473 .reason
474 .unwrap_or_else(|| "Hook requested a retry".to_string()),
475 retry_after_ms: response.retry_delay_ms.unwrap_or(1000),
476 },
477 HookAction::Skip => HookOutcome::Skip,
478 }
479 }
480
481 pub fn hook_count(&self) -> usize {
483 read_or_recover(&self.hooks).len()
484 }
485
486 pub fn get_hook(&self, id: &str) -> Option<Hook> {
488 read_or_recover(&self.hooks)
489 .get(id)
490 .map(|hook| (**hook).clone())
491 }
492
493 pub fn all_hooks(&self) -> Vec<Hook> {
495 read_or_recover(&self.hooks)
496 .values()
497 .map(|hook| (**hook).clone())
498 .collect()
499 }
500}
501
502#[async_trait]
504impl HookExecutor for HookEngine {
505 async fn fire(&self, event: &HookEvent) -> HookResult {
506 HookEngine::fire(self, event).await
507 }
508
509 async fn fire_outcome(&self, event: &HookEvent) -> HookOutcome {
510 HookEngine::fire_outcome(self, event).await
511 }
512}
513
514#[cfg(test)]
515#[path = "engine/tests.rs"]
516mod tests;