Skip to main content

theway_daemon/trigger_engine/
notification_hook.rs

1//! RFC 1 (issue #20) `NotificationHook` trait + status surface (moved out of theway-core
2//! with the rest of the trigger engine).
3//!
4//! A `NotificationHook` is the transport-agnostic plug for external sources (MCP server
5//! pushes, local cron, file-watch, etc.). Adapters own the transport, normalize the
6//! inbound stream into [`Trigger`](super::types::Trigger) envelopes, and push them into a
7//! shared `TriggerSink`. The `TriggerExecutor` (host) consumes whatever the hooks produce.
8
9use std::sync::Arc;
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use tokio::sync::mpsc;
14
15use super::types::Trigger;
16
17/// Sink that hooks push triggers into. The runtime owns the receiver and the dedup /
18/// permission / agent-loop pipeline. Cloning the sender is cheap; multiple hooks share the
19/// same sink and the runtime fair-schedules between them.
20///
21/// `mpsc::UnboundedSender` is intentional for v1 — bounded back-pressure is a follow-up
22/// (and will be enforced at the hook level via per-source `queued_count` watermarks rather
23/// than upstream channel capacity).
24pub type TriggerSink = mpsc::UnboundedSender<Trigger>;
25
26/// Long-running source adapter trait. One instance per configured source.
27///
28/// Implementations live in `crates/harness` (or downstream crates) — the runtime
29/// crate must stay transport-agnostic. The runtime invokes `run` once per hook on a
30/// dedicated task; the task is expected to live until the supervisor cancels it (Tokio
31/// cancellation token), at which point `run` should return promptly.
32#[async_trait::async_trait]
33pub trait NotificationHook: Send + Sync {
34    /// Stable label used in `NotificationHookStatus`, `/triggers hooks` UI rows, and
35    /// per-source counters. Should be short and human-readable (e.g. `"mcp:filesystem"`,
36    /// `"cron"`).
37    fn label(&self) -> &str;
38
39    /// Drive the source. Push triggers into `sink` as they arrive. Return `Ok(())` on
40    /// clean shutdown or `Err` on protocol / auth failure; the supervisor records the
41    /// failure on the hook status and may restart per its backoff policy.
42    async fn run(&self, sink: TriggerSink) -> Result<(), HookError>;
43
44    /// Snapshot for status views (`/triggers hooks`, `theway status`). Called frequently; the
45    /// implementation should keep this cheap (atomic loads or `parking_lot::Mutex`).
46    fn status(&self) -> NotificationHookStatus;
47}
48
49/// Hooks can also be stored / shared as boxed trait objects. Most callers will use this
50/// alias instead of writing the trait-object syntax everywhere.
51pub type DynNotificationHook = Arc<dyn NotificationHook>;
52
53/// Assembly target for notification hooks. The only production impl is the
54/// per-session [`TriggerExecutor`](super::execution::TriggerExecutor); the
55/// one-shot-registration unit tests inject a recording fake.
56///
57/// `pub(crate)` so the settings `Configure` path (issue #73) and the
58/// `/reload` reconnect path can register freshly connected MCP hooks onto
59/// the live session's executor. Lives here (not in `orchestration`) so the
60/// path-included slash-command integration tests can reach it without
61/// pulling the whole orchestration layer.
62pub(crate) trait NotificationHookSink {
63    fn register(&self, hook: DynNotificationHook);
64}
65
66impl NotificationHookSink for std::sync::Arc<super::execution::TriggerExecutor> {
67    fn register(&self, hook: DynNotificationHook) {
68        self.register_notification_hook(hook);
69    }
70}
71
72/// Failure modes reported by a hook to the runtime supervisor. The supervisor decides
73/// whether to restart, escalate to `requires_attention`, or surface as a user error.
74#[derive(Clone, Debug, thiserror::Error)]
75pub enum HookError {
76    /// Source-specific authentication failed (token expired, scope mismatch, etc.). The
77    /// supervisor marks the hook as `AuthFailed` and does not auto-restart.
78    #[error("auth failed: {reason}")]
79    AuthFailed { reason: String },
80
81    /// Source negotiated an incompatible protocol version. Distinct from `AuthFailed`
82    /// because UX should suggest "upgrade client/source" not "re-login".
83    #[error("protocol mismatch: {reason}")]
84    ProtocolMismatch { reason: String },
85
86    /// Transport closed cleanly or due to a recoverable network error. Supervisor restarts
87    /// with exponential backoff.
88    #[error("disconnected: {reason}")]
89    Disconnected { reason: String },
90
91    /// The source produced a frame that did not match the declared schema. Supervisor
92    /// records and may restart; if it persists the hook is moved to `AuthFailed`-equivalent
93    /// `requires_attention`.
94    #[error("schema invalid: {reason}")]
95    SchemaInvalid { reason: String },
96
97    /// Sink was dropped — the runtime is shutting down. Hook should exit promptly.
98    #[error("sink closed")]
99    SinkClosed,
100
101    /// Catch-all for unexpected errors so adapters do not need a custom error enum just to
102    /// surface odd one-off failures.
103    #[error("hook error: {0}")]
104    Other(String),
105}
106
107/// Snapshot of a hook's current state. The runtime aggregates these into
108/// `harness.trigger_status()` and exposes them via `/triggers hooks`.
109///
110/// Field names match RFC 1 §2.5 verbatim so the UI / acceptance tests share one
111/// vocabulary with the spec.
112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub struct NotificationHookStatus {
114    pub state: HookState,
115    /// Wall-clock time the most recent trigger was pushed into the sink, if any.
116    pub last_event_at: Option<DateTime<Utc>>,
117    /// Wall-clock time the most recent ack was received, if the adapter protocol has
118    /// explicit acknowledgements. MCP push / cron / file-watch leave this `None`.
119    pub last_ack_at: Option<DateTime<Utc>>,
120    /// Most recent transport-level error, if any. Cleared on next successful transition
121    /// back to `Connected`.
122    pub last_error: Option<String>,
123    /// Adapter-side queued depth. The runtime's bounded back-pressure is a follow-up; for
124    /// v1 hooks expose their own queue depth so `/triggers hooks` can show it.
125    pub queued_count: u64,
126    /// Count of events the adapter intentionally dropped (e.g. unsigned custom MCP
127    /// notification without `_meta.theway_dedup_key`).
128    pub dropped_count: u64,
129    /// Count of events the adapter dedup-suppressed before pushing into the sink. Distinct
130    /// from runtime-side dedup, which is separate and counted in `TriggerRecord`.
131    pub deduped_count: u64,
132    /// User-readable subscription labels (e.g. `"GitHub: repo c4pt0r/theway"`,
133    /// `"Slock: #dev"`). Stable across reconnects.
134    pub subscription_labels: Vec<String>,
135    /// When `Some`, UI highlights this hook and surfaces the message. The supervisor only
136    /// sets this when the cause is one the user can act on (panic, protocol violation,
137    /// auth failure, sustained reconnect backoff > 60s — exact thresholds in §2.5).
138    pub requires_attention: Option<String>,
139}
140
141impl NotificationHookStatus {
142    /// Construct a fresh status for a hook that has not yet started. Used by hooks during
143    /// their constructor before the first `run` invocation.
144    pub fn pending() -> Self {
145        Self {
146            state: HookState::Disconnected {
147                reason: "not yet started".into(),
148            },
149            last_event_at: None,
150            last_ack_at: None,
151            last_error: None,
152            queued_count: 0,
153            dropped_count: 0,
154            deduped_count: 0,
155            subscription_labels: Vec::new(),
156            requires_attention: None,
157        }
158    }
159}
160
161/// Per-hook lifecycle state. The runtime supervisor reads this for `/triggers hooks`; the
162/// hook itself updates it as transport events arrive. RFC 1 §2.5 + Provider/Auth refinement
163/// (RFC 0 §3.3): `AuthFailed` is reserved for credential failures, `Disconnected` covers
164/// protocol mismatches, and `Disabled` is only entered when explicitly disabled by the
165/// user / supervisor.
166#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(tag = "kind", rename_all = "snake_case")]
168pub enum HookState {
169    Connected,
170    Reconnecting,
171    Disconnected {
172        reason: String,
173    },
174    /// User or supervisor explicitly disabled this hook. Distinct from `Disconnected`:
175    /// `Disabled` is intentional, `Disconnected` is transient.
176    Disabled,
177    /// Credential failure. Use `Disconnected { reason: "protocol_mismatch" }` for protocol
178    /// version mismatches; do not collapse them into `AuthFailed`.
179    AuthFailed {
180        reason: String,
181    },
182}
183
184#[cfg(test)]
185// Test files live in `tests/trigger_engine/notification_hook/` (mirror of src), pulled in by
186// path so they keep unit-test semantics (private access). See docs/rust-test-files.md.
187tests_bridge_macro::tests_bridge!("trigger_engine/notification_hook");