Skip to main content

orcs_hook/
lib.rs

1//! Hook system for ORCS CLI.
2//!
3//! This crate provides the hook abstraction layer for the ORCS
4//! (Orchestrated Runtime for Collaborative Systems) architecture.
5//!
6//! # Crate Architecture
7//!
8//! This crate sits between the **Plugin SDK** and **Runtime** layers:
9//!
10//! ```text
11//! ┌─────────────────────────────────────────────────────────────┐
12//! │                    Plugin SDK Layer                          │
13//! │  (External, SemVer stable, safe to depend on)               │
14//! ├─────────────────────────────────────────────────────────────┤
15//! │  orcs-types     : ID types, Principal, ErrorCode            │
16//! │  orcs-event     : Signal, Request, Event                    │
17//! │  orcs-component : Component trait (WIT target)              │
18//! └─────────────────────────────────────────────────────────────┘
19//!           ↕ depends on SDK, depended on by Runtime
20//! ┌─────────────────────────────────────────────────────────────┐
21//! │                      Hook Layer                  ◄── HERE   │
22//! ├─────────────────────────────────────────────────────────────┤
23//! │  orcs-hook : Hook trait, Registry, FQL, Config              │
24//! └─────────────────────────────────────────────────────────────┘
25//!           ↕
26//! ┌─────────────────────────────────────────────────────────────┐
27//! │                    Runtime Layer                             │
28//! ├─────────────────────────────────────────────────────────────┤
29//! │  orcs-runtime : Session, EventBus, ChannelRunner            │
30//! └─────────────────────────────────────────────────────────────┘
31//! ```
32//!
33//! # Overview
34//!
35//! Hooks allow cross-cutting concerns (logging, auditing, capability
36//! injection, payload transformation, metrics, etc.) to be injected
37//! at lifecycle points throughout the ORCS runtime via a single,
38//! unified configuration interface.
39//!
40//! # Core Concepts
41//!
42//! ## Hook Points
43//!
44//! [`HookPoint`] enumerates 26 lifecycle points across 8 categories:
45//! Component, Request, Signal, Child, Channel, Tool, Auth, and EventBus.
46//!
47//! ## FQL (Fully Qualified Locator)
48//!
49//! [`FqlPattern`] provides pattern matching for component addressing:
50//!
51//! ```text
52//! <scope>::<target>[/<child_path>][#<instance>]
53//! ```
54//!
55//! Examples: `"builtin::llm"`, `"*::*"`, `"builtin::llm/agent-1"`.
56//!
57//! ## Hook Trait
58//!
59//! The [`Hook`] trait defines a single hook handler:
60//!
61//! ```ignore
62//! pub trait Hook: Send + Sync {
63//!     fn id(&self) -> &str;
64//!     fn fql_pattern(&self) -> &FqlPattern;
65//!     fn hook_point(&self) -> HookPoint;
66//!     fn priority(&self) -> i32 { 100 }
67//!     fn execute(&self, ctx: HookContext) -> HookAction;
68//! }
69//! ```
70//!
71//! ## Hook Actions
72//!
73//! [`HookAction`] determines what happens after a hook executes:
74//!
75//! - `Continue(ctx)` — pass (modified) context downstream
76//! - `Skip(value)` — skip the operation (pre-hooks only)
77//! - `Abort { reason }` — abort with error (pre-hooks only)
78//! - `Replace(value)` — replace result payload (post-hooks only)
79//!
80//! ## Registry
81//!
82//! [`HookRegistry`] is the central dispatch engine. It manages
83//! hook registration, priority ordering, FQL filtering, and
84//! chain execution semantics.
85//!
86//! ## Configuration
87//!
88//! [`HooksConfig`] and [`HookDef`] provide TOML-serializable
89//! declarative hook definitions for `OrcsConfig` integration.
90//!
91//! # Concurrency
92//!
93//! The registry is designed to be wrapped in
94//! `Arc<std::sync::RwLock<HookRegistry>>` following the same pattern
95//! as `SharedChannelHandles` in the runtime.
96//!
97//! # Example
98//!
99//! ```
100//! use orcs_hook::{
101//!     HookRegistry, HookPoint, HookContext, HookAction, FqlPattern, Hook,
102//! };
103//! use orcs_types::{ComponentId, ChannelId, Principal};
104//! use serde_json::json;
105//!
106//! // Create a registry
107//! let mut registry = HookRegistry::new();
108//!
109//! // Build a context
110//! let ctx = HookContext::new(
111//!     HookPoint::RequestPreDispatch,
112//!     ComponentId::builtin("llm"),
113//!     ChannelId::new(),
114//!     Principal::System,
115//!     0,
116//!     json!({"operation": "chat"}),
117//! );
118//!
119//! // Dispatch (no hooks registered → Continue with unchanged context)
120//! let action = registry.dispatch(
121//!     HookPoint::RequestPreDispatch,
122//!     &ComponentId::builtin("llm"),
123//!     None,
124//!     ctx,
125//! );
126//! assert!(action.is_continue());
127//! ```
128
129mod action;
130mod config;
131mod context;
132mod error;
133mod fql;
134pub mod hook;
135mod point;
136mod registry;
137
138// Re-export core types
139pub use action::HookAction;
140pub use config::{HookDef, HookDefValidationError, HooksConfig};
141pub use context::{HookContext, DEFAULT_MAX_DEPTH};
142pub use error::HookError;
143pub use fql::{FqlPattern, PatternSegment};
144pub use hook::Hook;
145pub use point::HookPoint;
146pub use registry::HookRegistry;
147
148use std::sync::{Arc, RwLock};
149
150/// Thread-safe shared reference to a [`HookRegistry`].
151///
152/// Follows the same pattern as `SharedChannelHandles` in the runtime:
153/// - `dispatch()` takes `&self` → read lock
154/// - `register()` / `unregister()` take `&mut self` → write lock
155///
156/// `std::sync::RwLock` (not tokio) because the lock is never held across
157/// `.await` points.
158pub type SharedHookRegistry = Arc<RwLock<HookRegistry>>;
159
160/// Creates a new empty [`SharedHookRegistry`].
161#[must_use]
162pub fn shared_hook_registry() -> SharedHookRegistry {
163    Arc::new(RwLock::new(HookRegistry::new()))
164}
165
166// Re-export testing utilities
167#[cfg(any(test, feature = "test-utils"))]
168pub mod testing {
169    //! Test utilities for the hook system.
170    //!
171    //! Provides [`MockHook`] for use in tests.
172    pub use crate::hook::testing::MockHook;
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use orcs_types::{ChannelId, ComponentId, Principal};
179    use serde_json::json;
180
181    #[test]
182    fn shared_registry_creation() {
183        let reg = shared_hook_registry();
184        let guard = reg
185            .read()
186            .expect("shared registry read lock should not be poisoned");
187        assert!(guard.is_empty());
188    }
189
190    #[test]
191    fn shared_registry_register_and_dispatch() {
192        let reg = shared_hook_registry();
193
194        // Write lock: register a hook
195        {
196            let mut guard = reg
197                .write()
198                .expect("shared registry write lock should not be poisoned");
199            let hook =
200                testing::MockHook::pass_through("test", "*::*", HookPoint::RequestPreDispatch);
201            guard.register(Box::new(hook));
202            assert_eq!(guard.len(), 1);
203        }
204
205        // Read lock: dispatch
206        {
207            let guard = reg
208                .read()
209                .expect("shared registry read lock should not be poisoned for dispatch");
210            let ctx = HookContext::new(
211                HookPoint::RequestPreDispatch,
212                ComponentId::builtin("llm"),
213                ChannelId::new(),
214                Principal::System,
215                0,
216                json!({"op": "test"}),
217            );
218            let action = guard.dispatch(
219                HookPoint::RequestPreDispatch,
220                &ComponentId::builtin("llm"),
221                None,
222                ctx,
223            );
224            assert!(action.is_continue());
225        }
226    }
227
228    #[test]
229    fn shared_registry_clone_shares_state() {
230        let reg = shared_hook_registry();
231        let reg2 = Arc::clone(&reg);
232
233        {
234            let mut guard = reg
235                .write()
236                .expect("shared registry write lock should not be poisoned for clone test");
237            guard.register(Box::new(testing::MockHook::pass_through(
238                "shared",
239                "*::*",
240                HookPoint::RequestPreDispatch,
241            )));
242        }
243
244        let guard = reg2
245            .read()
246            .expect("cloned registry read lock should reflect shared state");
247        assert_eq!(guard.len(), 1);
248    }
249}