Skip to main content

harness_hooks/
lib.rs

1//! `HookBus` — dispatch the 27 lifecycle events to registered [`Hook`]s.
2//!
3//! Per DESIGN.md §10, a hook is a *cheap* synchronous policy that decides what
4//! happens around tool calls, model calls, compaction, sensor runs, and so on.
5//! Long async work belongs in a sensor or tool, not a hook.
6
7#[cfg(feature = "otel")]
8pub mod otel;
9
10#[cfg(feature = "otel")]
11pub use otel::OtelHook;
12
13use harness_core::{Event, Hook, HookOutcome, World, iter_macro_hooks};
14use std::sync::Arc;
15
16/// Ordered list of hooks; fires events through every match.
17#[derive(Default)]
18pub struct HookBus {
19    hooks: Vec<Arc<dyn Hook>>,
20}
21
22impl HookBus {
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Pull in every `#[hook]`-registered hook.
28    pub fn with_macro_hooks(mut self) -> Self {
29        for h in iter_macro_hooks() {
30            self.hooks.push(h);
31        }
32        self
33    }
34
35    /// Consume + return (used by `AgentLoop::with_macro_hooks`).
36    pub fn with_macro_hooks_take(self) -> Self {
37        self.with_macro_hooks()
38    }
39
40    pub fn register(&mut self, h: Arc<dyn Hook>) {
41        self.hooks.push(h);
42    }
43
44    pub fn len(&self) -> usize {
45        self.hooks.len()
46    }
47    pub fn is_empty(&self) -> bool {
48        self.hooks.is_empty()
49    }
50
51    /// Fire `ev` through all matching hooks in registration order.
52    ///
53    /// Aggregation:
54    /// - First `Deny` short-circuits, return the `Deny`.
55    /// - All `Inject` payloads concatenate; if any present and no Deny, return `Inject(joined)`.
56    /// - Otherwise `Allow`.
57    pub fn fire(&self, ev: &Event<'_>, world: &mut World) -> HookOutcome {
58        let mut injects = Vec::<String>::new();
59        for h in self.hooks.iter().filter(|h| h.matches(ev)) {
60            match h.fire(ev, world) {
61                HookOutcome::Deny { reason } => {
62                    tracing::warn!(hook = h.name(), event = ev.name(), %reason, "hook denied");
63                    return HookOutcome::Deny { reason };
64                }
65                HookOutcome::Inject(s) => injects.push(s),
66                HookOutcome::Mutate(_) => {
67                    // Mutation semantics are event-specific and not yet honoured by the runtime.
68                    tracing::debug!(
69                        hook = h.name(),
70                        event = ev.name(),
71                        "hook mutation ignored (not yet wired)"
72                    );
73                }
74                HookOutcome::Allow => {}
75                // HookOutcome is `#[non_exhaustive]`; treat any future variant as Allow.
76                _ => {
77                    tracing::warn!(
78                        hook = h.name(),
79                        "unrecognised HookOutcome variant — treating as Allow"
80                    );
81                }
82            }
83        }
84        if injects.is_empty() {
85            HookOutcome::Allow
86        } else {
87            HookOutcome::Inject(injects.join("\n"))
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use harness_core::{Event, World};
96
97    struct AlwaysDeny;
98    impl Hook for AlwaysDeny {
99        fn name(&self) -> &str {
100            "always-deny"
101        }
102        fn matches(&self, _: &Event<'_>) -> bool {
103            true
104        }
105        fn fire(&self, _: &Event<'_>, _: &mut World) -> HookOutcome {
106            HookOutcome::Deny {
107                reason: "nope".into(),
108            }
109        }
110    }
111
112    struct Counter(std::sync::atomic::AtomicU32);
113    impl Hook for Counter {
114        fn name(&self) -> &str {
115            "counter"
116        }
117        fn matches(&self, _: &Event<'_>) -> bool {
118            true
119        }
120        fn fire(&self, _: &Event<'_>, _: &mut World) -> HookOutcome {
121            self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
122            HookOutcome::Allow
123        }
124    }
125
126    fn mock_world() -> World {
127        use harness_core::{Clock, KvStore, ProcessOutput, ProcessRunner, RepoView};
128        use std::path::Path;
129
130        struct NoopClock;
131        impl Clock for NoopClock {
132            fn now_ms(&self) -> i64 {
133                0
134            }
135        }
136        struct NoopRunner;
137        #[async_trait::async_trait]
138        impl ProcessRunner for NoopRunner {
139            async fn exec(
140                &self,
141                _: &str,
142                _: &[&str],
143                _: Option<&Path>,
144            ) -> std::io::Result<ProcessOutput> {
145                Ok(ProcessOutput {
146                    status: 0,
147                    stdout: String::new(),
148                    stderr: String::new(),
149                })
150            }
151        }
152        struct NoopKv;
153        #[async_trait::async_trait]
154        impl KvStore for NoopKv {
155            async fn get(&self, _: &str) -> Option<Vec<u8>> {
156                None
157            }
158            async fn set(&self, _: &str, _: Vec<u8>) {}
159            async fn delete(&self, _: &str) {}
160        }
161
162        World {
163            repo: RepoView { root: ".".into() },
164            runner: Arc::new(NoopRunner),
165            clock: Arc::new(NoopClock),
166            kv: Arc::new(NoopKv),
167            profile: harness_core::UserProfile::default(),
168        }
169    }
170
171    #[test]
172    fn deny_short_circuits() {
173        let counter = Arc::new(Counter(0.into()));
174        let mut bus = HookBus::new();
175        bus.register(Arc::new(AlwaysDeny));
176        bus.register(counter.clone());
177        let mut world = mock_world();
178        let outcome = bus.fire(&Event::Stop, &mut world);
179        assert!(matches!(outcome, HookOutcome::Deny { .. }));
180        assert_eq!(counter.0.load(std::sync::atomic::Ordering::SeqCst), 0);
181    }
182
183    #[test]
184    fn all_match_fire_in_order() {
185        let counter = Arc::new(Counter(0.into()));
186        let mut bus = HookBus::new();
187        bus.register(counter.clone());
188        bus.register(counter.clone());
189        bus.register(counter.clone());
190        let mut world = mock_world();
191        let outcome = bus.fire(&Event::Stop, &mut world);
192        assert!(matches!(outcome, HookOutcome::Allow));
193        assert_eq!(counter.0.load(std::sync::atomic::Ordering::SeqCst), 3);
194    }
195}