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");