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}