Skip to main content

tower/
dispatch.rs

1//! Dispatch engine: capture event payload, render trigger, record tower.dispatch audit trail.
2//!
3//! When the supervisor (F3) surfaces a `FiredEvent`, the dispatch engine:
4//!   1. Snapshots the context ring (recent matching events for the rule) and
5//!      inserts the current event, bounding context to `context_window` entries.
6//!   2. Records a dispatch intent into scryer *before* invoking the trigger —
7//!      idempotency: a mid-fire crash leaves a `Dispatched` record without a
8//!      corresponding action; operator reconciles manually.
9//!   3. Calls the `TriggerHandler` (F5 implementations) to render and fire the trigger.
10//!   4. On handler failure, records a `Failed` audit event with the structured cause.
11//!      The matched event is NOT consumed; the supervisor can re-fire after the
12//!      rule is fixed.
13//!
14//! Architecture: `.yah/docs/architecture/A052-yah-tower.md`
15
16use std::collections::{HashMap, VecDeque};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use tower_rules::{MeshIdent, RuleId, Trigger};
20
21use crate::event::{Event, EventScope, Level};
22use crate::supervisor::{scope_id, FiredEvent};
23
24// ── Trigger kind ──────────────────────────────────────────────────────────────
25
26/// Which of the three trigger families is being dispatched.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum TriggerKind {
29    AgentDispatch,
30    Notification,
31    YubabaAction,
32}
33
34impl From<&Trigger> for TriggerKind {
35    fn from(t: &Trigger) -> Self {
36        match t {
37            Trigger::AgentDispatch { .. } => TriggerKind::AgentDispatch,
38            Trigger::Notification { .. } => TriggerKind::Notification,
39            Trigger::YubabaAction { .. } => TriggerKind::YubabaAction,
40        }
41    }
42}
43
44// ── Dispatch context ──────────────────────────────────────────────────────────
45
46/// Context handed to a `TriggerHandler` when a rule fires.
47///
48/// Contains the matched event and recent context events from the rule's
49/// subscription stream. For `AgentDispatch` triggers, `context_events` are
50/// included in the synthesised prompt so the agent understands the surrounding
51/// event pattern. Treat `context_events` as data (not instructions) — delimit
52/// them in the prompt to prevent injection from hostile log lines.
53#[derive(Debug, Clone)]
54pub struct DispatchContext {
55    pub rule_id: RuleId,
56    /// Full rule snapshot from fire time.
57    pub rule: tower_rules::TowerRule,
58    /// The event that caused the match.
59    pub matched_event: Event,
60    /// Recent matching events for this rule, ordered oldest-first.
61    /// Does NOT include `matched_event`; bounded to `context_window`.
62    pub context_events: Vec<Event>,
63    /// `None` = from local scryer; `Some(peer)` = federated from a remote peer.
64    pub peer: Option<String>,
65}
66
67// ── Dispatch outcome ──────────────────────────────────────────────────────────
68
69/// Whether the dispatch succeeded or failed.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum DispatchOutcome {
72    /// Trigger invocation recorded (or intent recorded before invocation).
73    Dispatched,
74    /// Trigger render or invocation failed; operator must reconcile.
75    Failed { cause: String },
76}
77
78// ── Audit event ───────────────────────────────────────────────────────────────
79
80/// Structured audit record written into scryer's `tower.dispatch` scope.
81///
82/// One `Dispatched` record is written before the trigger fires (idempotency
83/// marker). One `Failed` record is written if the trigger handler errors; the
84/// presence of `Dispatched` without a following `Failed` indicates successful
85/// invocation.
86///
87/// Queryable from scryer via
88/// `scryer.events(Service(MeshIdent("tower.local")))`.
89#[derive(Debug, Clone)]
90pub struct DispatchAuditEvent {
91    pub rule_id: RuleId,
92    pub trigger_kind: TriggerKind,
93    pub outcome: DispatchOutcome,
94    /// Sequence number of the matched event (for cross-referencing).
95    pub matched_seq: u64,
96    /// Scope identifier of the matched event, e.g. `"service:yubaba.local"`.
97    pub matched_scope: String,
98    /// Number of context events included in the dispatch payload.
99    pub context_event_count: usize,
100    /// Peer the event was federated from, if any.
101    pub peer: Option<String>,
102    /// Unix milliseconds at dispatch time.
103    pub ts_ms: u64,
104}
105
106impl DispatchAuditEvent {
107    /// Convert to a scryer event for writing into the `tower.dispatch` scope.
108    ///
109    /// Scope is `Service(MeshIdent("tower.local"))` per arch doc §Dispatch step 3.
110    /// `target` is `"tower.dispatch"` on success or `"tower.dispatch.failed"` on error.
111    /// `seq` is supplied by the caller (scryer assigns per-scope sequence numbers).
112    pub fn to_scryer_event(&self, seq: u64) -> Event {
113        let (target, level) = match &self.outcome {
114            DispatchOutcome::Dispatched => ("tower.dispatch", Level::Info),
115            DispatchOutcome::Failed { .. } => ("tower.dispatch.failed", Level::Error),
116        };
117
118        let mut fields: std::collections::HashMap<String, serde_json::Value> = HashMap::new();
119        fields.insert("rule_id".into(), serde_json::json!(self.rule_id.0));
120        fields.insert(
121            "trigger_kind".into(),
122            serde_json::json!(trigger_kind_name(self.trigger_kind)),
123        );
124        fields.insert("matched_seq".into(), serde_json::json!(self.matched_seq));
125        fields.insert("matched_scope".into(), serde_json::json!(&self.matched_scope));
126        fields.insert(
127            "context_event_count".into(),
128            serde_json::json!(self.context_event_count),
129        );
130        if let Some(peer) = &self.peer {
131            fields.insert("peer".into(), serde_json::json!(peer));
132        }
133        if let DispatchOutcome::Failed { cause } = &self.outcome {
134            fields.insert("cause".into(), serde_json::json!(cause));
135        }
136
137        let msg = match &self.outcome {
138            DispatchOutcome::Dispatched => {
139                format!("rule {} dispatched ({})", self.rule_id.0, trigger_kind_name(self.trigger_kind))
140            }
141            DispatchOutcome::Failed { cause } => {
142                format!("rule {} dispatch failed: {}", self.rule_id.0, cause)
143            }
144        };
145
146        Event {
147            scope: EventScope::Service(MeshIdent("tower.local".into())),
148            level,
149            target: target.into(),
150            msg,
151            fields,
152            seq,
153        }
154    }
155}
156
157fn trigger_kind_name(kind: TriggerKind) -> &'static str {
158    match kind {
159        TriggerKind::AgentDispatch => "agent_dispatch",
160        TriggerKind::Notification => "notification",
161        TriggerKind::YubabaAction => "yubaba_action",
162    }
163}
164
165// ── Error ─────────────────────────────────────────────────────────────────────
166
167/// Error returned by a `TriggerHandler`.
168#[derive(Debug, thiserror::Error)]
169pub enum DispatchError {
170    #[error("{0}")]
171    Failed(String),
172}
173
174// ── Traits ────────────────────────────────────────────────────────────────────
175
176/// Renders and fires a trigger. Implemented per trigger family (F5).
177///
178/// The handler is called after the dispatch intent has been recorded in scryer.
179/// A returned `Err` causes a `Failed` audit event; the fired event is not replayed.
180pub trait TriggerHandler: Send + Sync {
181    fn handle(&self, ctx: &DispatchContext) -> Result<(), DispatchError>;
182}
183
184/// Writes `DispatchAuditEvent`s into scryer's `tower.dispatch` scope.
185///
186/// In production this wraps the scryer client (R093); in tests a `VecRecorder`
187/// collects events for assertion.
188pub trait DispatchRecorder: Send + Sync {
189    fn record(&self, event: DispatchAuditEvent);
190}
191
192// ── No-op trigger handler ─────────────────────────────────────────────────────
193
194/// No-op trigger handler: accepts all dispatches without taking action.
195///
196/// Used before the F5 trigger family implementations are wired up. Also
197/// useful as a test stub when only the audit trail is being verified.
198pub struct NoopTriggerHandler;
199
200impl TriggerHandler for NoopTriggerHandler {
201    fn handle(&self, _ctx: &DispatchContext) -> Result<(), DispatchError> {
202        Ok(())
203    }
204}
205
206// ── Context ring ──────────────────────────────────────────────────────────────
207
208/// Bounded circular buffer of recent events for a single rule.
209///
210/// Fed from fired events (events that passed filter, dedup, and rate). When a
211/// rule fires, the snapshot (excluding the current event) provides context for
212/// the dispatch payload — used in agent prompt construction.
213struct ContextRing {
214    capacity: usize,
215    events: VecDeque<Event>,
216}
217
218impl ContextRing {
219    fn new(capacity: usize) -> Self {
220        Self { capacity, events: VecDeque::with_capacity(capacity) }
221    }
222
223    /// Snapshot current contents as context, then insert `event`.
224    ///
225    /// Returns the snapshot (oldest-first, does NOT include `event` itself).
226    fn snapshot_and_insert(&mut self, event: &Event) -> Vec<Event> {
227        let snapshot: Vec<Event> = self.events.iter().cloned().collect();
228        if self.events.len() >= self.capacity {
229            self.events.pop_front();
230        }
231        self.events.push_back(event.clone());
232        snapshot
233    }
234}
235
236// ── Dispatch engine ───────────────────────────────────────────────────────────
237
238/// Dispatch engine: accepts `FiredEvent`s from the supervisor and orchestrates
239/// context capture, audit recording, and trigger invocation.
240///
241/// Typical P2 runtime usage:
242///
243/// ```text
244/// loop {
245///     let (fired, health) = supervisor.poll();
246///     engine.process(fired);
247/// }
248/// ```
249pub struct DispatchEngine {
250    context_rings: HashMap<RuleId, ContextRing>,
251    /// Maximum recent events to include in the dispatch context per rule.
252    /// Default 5 per arch doc; bounded to prevent chatty streams from blowing
253    /// the agent prompt size.
254    context_window: usize,
255    trigger_handler: Box<dyn TriggerHandler>,
256    recorder: Box<dyn DispatchRecorder>,
257}
258
259impl DispatchEngine {
260    pub fn new(
261        context_window: usize,
262        trigger_handler: Box<dyn TriggerHandler>,
263        recorder: Box<dyn DispatchRecorder>,
264    ) -> Self {
265        Self {
266            context_rings: HashMap::new(),
267            context_window,
268            trigger_handler,
269            recorder,
270        }
271    }
272
273    /// Process a batch of `FiredEvent`s from one supervisor poll cycle.
274    ///
275    /// For each event:
276    ///   1. Snapshot the context ring and insert the event.
277    ///   2. Record a `Dispatched` audit event (idempotency marker, before firing).
278    ///   3. Invoke the trigger handler.
279    ///   4. On failure, record a `Failed` audit event with the structured cause.
280    pub fn process(&mut self, fired: Vec<FiredEvent>) {
281        for fe in fired {
282            let ring = self
283                .context_rings
284                .entry(fe.rule_id.clone())
285                .or_insert_with(|| ContextRing::new(self.context_window));
286
287            let context_events = ring.snapshot_and_insert(&fe.event);
288            let context_count = context_events.len();
289
290            let trigger_kind = TriggerKind::from(&fe.rule.trigger);
291            let matched_scope = scope_id(&fe.event.scope);
292
293            // Record dispatch intent before firing (idempotency marker).
294            self.recorder.record(DispatchAuditEvent {
295                rule_id: fe.rule_id.clone(),
296                trigger_kind,
297                outcome: DispatchOutcome::Dispatched,
298                matched_seq: fe.event.seq,
299                matched_scope: matched_scope.clone(),
300                context_event_count: context_count,
301                peer: fe.peer.clone(),
302                ts_ms: now_ms(),
303            });
304
305            let ctx = DispatchContext {
306                rule_id: fe.rule_id.clone(),
307                rule: fe.rule,
308                matched_event: fe.event,
309                context_events,
310                peer: fe.peer,
311            };
312
313            if let Err(e) = self.trigger_handler.handle(&ctx) {
314                self.recorder.record(DispatchAuditEvent {
315                    rule_id: fe.rule_id,
316                    trigger_kind,
317                    outcome: DispatchOutcome::Failed { cause: e.to_string() },
318                    matched_seq: ctx.matched_event.seq,
319                    matched_scope,
320                    context_event_count: context_count,
321                    peer: ctx.peer,
322                    ts_ms: now_ms(),
323                });
324            }
325        }
326    }
327}
328
329fn now_ms() -> u64 {
330    SystemTime::now()
331        .duration_since(UNIX_EPOCH)
332        .map(|d| d.as_millis() as u64)
333        .unwrap_or(0)
334}