agentstatedeveloper_mcp/events.rs
1//! Live activity stream for asd-serve — backs `GET /api/v1/events` (SSE).
2//!
3//! asd-serve is a READ-ONLY server over the shared SQLite/ASG db; writes
4//! come from OTHER processes (CLI, MCP server, git hooks). There is no
5//! in-process write path to hook, so change detection is polling-based.
6//!
7//! ## Change-signal choice
8//!
9//! Three candidate signals were considered:
10//!
11//! 1. **db file mtime** — cheap, but second-granularity, noisy under WAL
12//! checkpoints, and nonexistent for in-memory engines (every test).
13//! 2. **max(created_at) over ledger entries** — requires walking the whole
14//! `/asd/v1/ledger` subtree every tick, and misses non-ledger writes
15//! (effects, index runs).
16//! 3. **the ASG ref head** (`repo.head(ref_name)`) — every ASD write of
17//! any kind (ledger/thinking, effect decls, index commits) is an ASG
18//! commit that moves the ref, and `SqliteStorage::get_ref` is a
19//! single-row indexed query with no in-process caching, so commits
20//! landed by other processes are visible on the next poll.
21//!
22//! We use (3): it is the cheapest signal that is also *complete* (fires
23//! exactly when a commit lands, for every write kind) and behaves
24//! identically for file-backed and in-memory engines. The audit log is a
25//! separate JSONL file (not in the db), so it gets its own cheap gate:
26//! file size via `fs::metadata`, with the parsed event count as cursor.
27//!
28//! ## Architecture
29//!
30//! One poller task per server process feeds a `tokio::sync::broadcast`
31//! channel; each SSE subscriber holds a `Receiver`. The poller is spawned
32//! **lazily on the first subscriber** (not at router build) so that
33//! deployments that never open the events stream — and the dozens of
34//! `build_router` calls in unit tests — pay zero background cost. Once
35//! spawned, the loop wakes every [`POLL_INTERVAL`] but skips ALL db/fs
36//! work while `receiver_count() == 0`, and re-snapshots its baseline when
37//! a subscriber reconnects — stream semantics are "changes while you are
38//! connected", not a replay of history missed while nobody watched.
39//!
40//! The task holds only a `Weak` reference to the engine, so dropping the
41//! router (test teardown, shutdown) lets the poller exit on its next tick.
42
43use std::path::{Path, PathBuf};
44use std::sync::{Arc, Weak};
45use std::time::Duration;
46
47use agentstatedeveloper_core::{
48 AsgEffectStore, AsgIndexStore, EffectStore, Engine, LedgerEntry, paths, read_jsonl,
49};
50use agentstategraph_core::{Commit, ObjectId};
51use serde_json::json;
52use tokio::sync::{Mutex, OnceCell, broadcast};
53
54/// How often the poller checks for changes. Also the worst-case latency
55/// between a write landing in the db and the SSE event being emitted.
56pub const POLL_INTERVAL: Duration = Duration::from_secs(2);
57
58/// How many commits `repo.log` walks back per tick when the head moved.
59/// A burst larger than this (e.g. a full reindex between two polls) is
60/// coalesced into a single `resync` event instead of a flood.
61const LOG_WINDOW: usize = 512;
62
63/// Broadcast buffer per subscriber. Slow consumers that fall more than
64/// this many events behind see a `Lagged` gap (skipped, not fatal).
65const CHANNEL_CAPACITY: usize = 256;
66
67/// Verification writes reuse the same `put_effects` path (and commit
68/// description) as declarations. A decl whose `verification.at` is within
69/// this window of the commit timestamp is classified `effect_verified`.
70const VERIFY_WINDOW_SECS: i64 = 10;
71
72/// Shared hub: lazily spawns the poller and hands out broadcast receivers.
73pub struct EventHub {
74 tx: broadcast::Sender<String>,
75 started: OnceCell<()>,
76 engine: Weak<Mutex<Engine>>,
77 audit_log_path: Option<PathBuf>,
78}
79
80impl EventHub {
81 /// Build a hub bound (weakly) to the engine. Cheap — no task is
82 /// spawned until the first [`subscribe`](Self::subscribe).
83 pub fn new(engine: &Arc<Mutex<Engine>>, audit_log_path: Option<PathBuf>) -> Arc<Self> {
84 let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
85 Arc::new(Self {
86 tx,
87 started: OnceCell::new(),
88 engine: Arc::downgrade(engine),
89 audit_log_path,
90 })
91 }
92
93 /// Register a subscriber. On the first call, snapshots the baseline
94 /// (current ref head + audit cursor) BEFORE spawning the poller, so an
95 /// event written immediately after this returns can never race past
96 /// the baseline and be missed.
97 pub async fn subscribe(&self) -> broadcast::Receiver<String> {
98 self.started
99 .get_or_init(|| async {
100 let baseline =
101 snapshot_baseline(&self.engine, self.audit_log_path.as_deref()).await;
102 tokio::spawn(poll_loop(
103 self.engine.clone(),
104 self.tx.clone(),
105 self.audit_log_path.clone(),
106 baseline,
107 ));
108 })
109 .await;
110 self.tx.subscribe()
111 }
112}
113
114/// Poller cursor: last seen ref head + audit-log position.
115struct Baseline {
116 head: Option<ObjectId>,
117 audit_size: u64,
118 audit_count: usize,
119}
120
121async fn snapshot_baseline(engine: &Weak<Mutex<Engine>>, audit: Option<&Path>) -> Baseline {
122 let head = match engine.upgrade() {
123 Some(arc) => {
124 let g = arc.lock().await;
125 g.repo.head(&g.ref_name).ok()
126 }
127 None => None,
128 };
129 let (audit_size, audit_count) = audit.map(audit_cursor).unwrap_or((0, 0));
130 Baseline {
131 head,
132 audit_size,
133 audit_count,
134 }
135}
136
137/// (file size, parsed event count) for the audit JSONL. Missing file → (0, 0).
138fn audit_cursor(path: &Path) -> (u64, usize) {
139 let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
140 let count = read_jsonl(path).map(|v| v.len()).unwrap_or(0);
141 (size, count)
142}
143
144async fn poll_loop(
145 engine: Weak<Mutex<Engine>>,
146 tx: broadcast::Sender<String>,
147 audit_path: Option<PathBuf>,
148 mut baseline: Baseline,
149) {
150 // The spawn was triggered by a live subscriber, so start "hot".
151 let mut had_subscribers = true;
152 loop {
153 tokio::time::sleep(POLL_INTERVAL).await;
154 // Engine gone → server shut down (or test router dropped): exit.
155 let Some(engine_arc) = engine.upgrade() else {
156 return;
157 };
158 if tx.receiver_count() == 0 {
159 // Idle: one timer wakeup per tick, zero db/fs access.
160 had_subscribers = false;
161 continue;
162 }
163 if !had_subscribers {
164 // 0 → >0 transition: re-snapshot instead of replaying the
165 // backlog accumulated while nobody was connected.
166 baseline = snapshot_baseline(&engine, audit_path.as_deref()).await;
167 had_subscribers = true;
168 continue;
169 }
170
171 let mut events: Vec<serde_json::Value> = Vec::new();
172 {
173 let g = engine_arc.lock().await;
174 if let Ok(head) = g.repo.head(&g.ref_name) {
175 if baseline.head.as_ref() != Some(&head) {
176 events.extend(commit_events(&g, baseline.head.as_ref()));
177 baseline.head = Some(head);
178 }
179 }
180 }
181
182 if let Some(path) = audit_path.as_deref() {
183 let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
184 if size != baseline.audit_size {
185 if let Ok(all) = read_jsonl(path) {
186 for e in all.iter().skip(baseline.audit_count) {
187 events.push(json!({
188 "at": e.timestamp,
189 "kind": "audit",
190 "qname": serde_json::Value::Null,
191 "symbol_id": serde_json::Value::Null,
192 "entry_id": e.subject_id,
193 "summary": match &e.reason {
194 Some(r) => format!("{} → {} ({})", e.event_type, e.outcome, r),
195 None => format!("{} → {}", e.event_type, e.outcome),
196 },
197 }));
198 }
199 baseline.audit_count = all.len();
200 }
201 baseline.audit_size = size;
202 }
203 }
204
205 for e in events {
206 if let Ok(s) = serde_json::to_string(&e) {
207 // Send fails only when every receiver is gone — harmless.
208 let _ = tx.send(s);
209 }
210 }
211 }
212}
213
214/// Diff the commit chain since `last_head` and classify each new commit
215/// into a timeline-shaped event. Emitted oldest-first.
216///
217/// Field names ({at, kind, qname, symbol_id, entry_id, summary}) match
218/// `/api/v1/timeline` so the Lens "now" feed can merge the two sources.
219/// `kind` is a `LedgerKind` snake_case name for ledger/thinking entries,
220/// or one of the stream-only kinds: `effect_declared`, `effect_verified`,
221/// `index_run`, `audit`, `commit`, `resync`.
222fn commit_events(engine: &Engine, last_head: Option<&ObjectId>) -> Vec<serde_json::Value> {
223 let log = match engine.repo.log(&engine.ref_name, LOG_WINDOW) {
224 Ok(l) => l,
225 Err(_) => return Vec::new(),
226 };
227 // `log` is newest-first. Take commits until we hit the old head.
228 let cut = last_head.and_then(|h| log.iter().position(|c| &c.id == h));
229 let new_commits: Vec<&Commit> = match cut {
230 Some(i) => log[..i].iter().collect(),
231 None => {
232 // Old head not in the window: either > LOG_WINDOW commits
233 // landed since the last tick (full reindex) or history was
234 // rewritten. Coalesce into one honest event and resync.
235 return vec![json!({
236 "at": chrono::Utc::now(),
237 "kind": "resync",
238 "qname": serde_json::Value::Null,
239 "symbol_id": serde_json::Value::Null,
240 "entry_id": serde_json::Value::Null,
241 "summary": format!(
242 "more than {} commits since last poll — event log truncated, state resynced",
243 LOG_WINDOW
244 ),
245 })];
246 }
247 };
248 if new_commits.is_empty() {
249 return Vec::new();
250 }
251
252 // qname resolution map, built at most once per changed batch.
253 let mut id_map: Option<std::collections::HashMap<String, agentstatedeveloper_core::Symbol>> =
254 None;
255 let mut qname_of = |engine: &Engine, symbol_id: &str| -> serde_json::Value {
256 let map =
257 id_map.get_or_insert_with(|| AsgIndexStore::from_engine(engine).build_id_map(engine));
258 map.get(symbol_id)
259 .map(|s| serde_json::Value::String(s.qname.clone()))
260 .unwrap_or(serde_json::Value::Null)
261 };
262
263 let mut events: Vec<serde_json::Value> = Vec::new();
264 // Index-family commits (symbol/qname/edges/hydrate) are bulk noise —
265 // coalesce them into a single index_run event per batch.
266 let mut index_commits: usize = 0;
267 let mut index_last: Option<(chrono::DateTime<chrono::Utc>, String)> = None;
268
269 for commit in new_commits.iter().rev() {
270 let desc = commit.intent.description.as_str();
271 if let Some(entry_id) = desc.strip_prefix("ledger-idx ") {
272 // The reverse-index commit carries the entry_id; resolve it to
273 // the full entry for a high-fidelity event. (The sibling
274 // "ledger <kind> for <sym>" commit is skipped below.)
275 if let Some(entry) = load_ledger_entry(engine, entry_id) {
276 let qname = qname_of(engine, &entry.symbol_id);
277 events.push(json!({
278 "at": entry.created_at,
279 "kind": serde_json::to_value(entry.kind).unwrap_or(serde_json::Value::Null),
280 "qname": qname,
281 "symbol_id": entry.symbol_id,
282 "entry_id": entry.entry_id,
283 "summary": entry.summary,
284 }));
285 }
286 } else if desc.starts_with("ledger ") {
287 // Covered by the paired ledger-idx commit above.
288 } else if let Some(symbol_id) = desc.strip_prefix("declare effects for ") {
289 let qname = qname_of(engine, symbol_id);
290 let decl = AsgEffectStore::from_engine(engine)
291 .get_effects(&engine.ref_name, symbol_id)
292 .ok()
293 .flatten();
294 // Verification reuses put_effects (same commit description);
295 // a verification stamp written in the same breath as this
296 // commit means "verified", otherwise it's a (re)declaration.
297 let verified = decl
298 .as_ref()
299 .and_then(|d| d.verification.as_ref())
300 .filter(|v| (commit.timestamp - v.at).num_seconds().abs() <= VERIFY_WINDOW_SECS);
301 let (kind, summary) = match (verified, &decl) {
302 (Some(v), _) => (
303 "effect_verified",
304 format!(
305 "effects verified: {}",
306 serde_json::to_value(v.status)
307 .ok()
308 .and_then(|x| x.as_str().map(String::from))
309 .unwrap_or_else(|| "unknown".into())
310 ),
311 ),
312 (None, Some(d)) => {
313 let cats: Vec<&str> = d.declared.iter().map(|e| e.effect.as_str()).collect();
314 (
315 "effect_declared",
316 if cats.is_empty() {
317 "declares no effects".to_string()
318 } else {
319 format!("declares {}", cats.join(", "))
320 },
321 )
322 }
323 (None, None) => ("effect_declared", desc.to_string()),
324 };
325 events.push(json!({
326 "at": commit.timestamp,
327 "kind": kind,
328 "qname": qname,
329 "symbol_id": symbol_id,
330 "entry_id": serde_json::Value::Null,
331 "summary": summary,
332 }));
333 } else if desc.starts_with("index symbol ")
334 || desc.starts_with("qname index ")
335 || desc.starts_with("asd index: ")
336 || desc.starts_with("asd hydrate: ")
337 {
338 index_commits += 1;
339 // Prefer the pipeline's own summary commits ("asd index: N call
340 // edges") over per-symbol ones when labelling the run.
341 let informative = desc.starts_with("asd ");
342 if informative || index_last.is_none() {
343 index_last = Some((commit.timestamp, desc.to_string()));
344 }
345 } else {
346 // Anything else (scratch writes, rebind repairs, approvals from
347 // the pro crate, …): surface generically rather than dropping.
348 events.push(json!({
349 "at": commit.timestamp,
350 "kind": "commit",
351 "qname": serde_json::Value::Null,
352 "symbol_id": serde_json::Value::Null,
353 "entry_id": serde_json::Value::Null,
354 "summary": desc,
355 }));
356 }
357 }
358
359 if let Some((at, last_desc)) = index_last {
360 events.push(json!({
361 "at": at,
362 "kind": "index_run",
363 "qname": serde_json::Value::Null,
364 "symbol_id": serde_json::Value::Null,
365 "entry_id": serde_json::Value::Null,
366 "summary": format!("{} ({} commits)", last_desc, index_commits),
367 }));
368 }
369
370 events
371}
372
373/// entry_id → LedgerEntry via the `/asd/v1/ledger-idx/` reverse index.
374fn load_ledger_entry(engine: &Engine, entry_id: &str) -> Option<LedgerEntry> {
375 let idx = engine
376 .repo
377 .get_json(&engine.ref_name, &paths::ledger_entry_index_path(entry_id))
378 .ok()?;
379 let symbol_id = idx.as_str()?.to_string();
380 let val = engine
381 .repo
382 .get_json(
383 &engine.ref_name,
384 &paths::ledger_entry_path(&symbol_id, entry_id),
385 )
386 .ok()?;
387 serde_json::from_value(val).ok()
388}