Skip to main content

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}