跳转至

graph-stream

The graph's push channel — an SSE with two modes (bare change signals, or hash-chained incremental patches) fed by domain-scoped freshness sources, self-healed by an accountable patrol.

raw source

The dashboard kept its status/grouping fresh by re-fetching the whole graph on a 4s timer, while the live terminal rode a WebSocket. So a session's status change felt laggy (up to the poll interval) even though the backend already knew it — two different freshness models on one screen. Give the graph the terminal's model: the backend pushes and the dashboard follows, so status flips as fast as the characters do. Then the probe campaign measured the push itself: a one-field lifecycle write cost a flat 150ms wait plus a full ~250ms graph rebuild before its 1KB patch went out. The signal must carry its DOMAIN, so a session change pays a sessions-only splice — and every leaf that can be watched IS watched, so the patrol stops being load-bearing.

expanded spec

graph-stream is the graph's live-delivery channel: GET /api/graph/stream, a server-sent-events stream a dashboard opens once, server→client only. It speaks two protocols on one route. Plain mode (no query) is the legacy contract, kept verbatim for old clients: a bare graph-changed signal, the client refetches /api/graph on its ETag/304 path. Delta mode (?mode=delta) inverts who fetches: the server sends a full snapshot on every (re)connect (graph-full {to, graph}), then per change either the hash-chained patch (graph-delta {from, to, set, del}) or a fresh full when the patch wouldn't win — the algebra, and the proof that this renders exactly what refetching would, is graph-delta's contract.

Every change signal carries its domain. fireChanged(scope) — 'sessions' or 'full' — feeds graph-cache's scoped invalidation, so a session-only change is answered by the sessions SPLICE (fresh listSessions, every other unit reused) instead of a full assembly: measured, the store-write→push path dropped from ~420ms median to ~56ms on the dogfood corpus. The sources and their scopes: (1) recursive fs.watch on the per-user session store (runtime) → 'sessions'. (2) the git dir's refs (loose refs recursively, packed-refs/HEAD) → 'full' — a commit legitimately reshapes nodes, drift, overlays and eval anchors at once, which is why refs stay full-scope rather than pretending to a narrower domain. (3) TWO subscriber-gated pollers for what never touches a file (state): a ~100ms HOT tier (hotSignature — pure-syscall death detection over launch-registered pids) and a ~1s WARM tier (warmSignature — one merged tmux call for window/title state plus the rendezvous tri-state), both → 'sessions'. (4) the .git/worktrees REGISTRY watcher — git's own birth-ledger for every worktree, hand-made or dispatched — which attaches a .spec subtree watch to each live worktree and detaches on removal; a draft spec edit fires 'full' (overlays live on node units). And (0) the exported explicit nudge (notifyBoardChanged) for a server-side mutation that must show regardless of watcher health — /rename passes 'sessions', and the issue/remark write routes pass 'full' atomically with their store persist (remark-substrate write-visibility: the writer's own post-write refetch must never race an asynchronous fs event into the stale cache; the issue store dir is deliberately not a watched leaf — one mechanism per surface). All funnel into one debounced fire; the debounce is 25ms, sized to the MEASURED fs-event burst width (0–5ms for real declares/renames, single-digit ms for ref moves) — the in-flight build's dirty-rerun loop is the coalescer for anything wider, so the old flat 150ms was pure added latency.

The patrol is a self-heal authority, not a crutch — and it is accountable. The delta-gated ~15s cold tick now invalidateBoard('full')s before it rebuilds (it once read the still-valid cache back — a no-op patrol, found by measurement: an uncommitted worktree edit stayed invisible through five ticks while a control write propagated in 231ms). A patrol rebuild whose diff is non-empty when NO leaf watcher signalled logs a loud PATROL-REPAIR naming the changed units: a repair means some leaf is blind, and the target state is repairs/hour = 0. SPEXCODE_DISABLE_WATCHERS (csv: store, refs, worktrees) deliberately blinds a leaf so tests can prove the patrol catches and reports what it misses; SPEXCODE_BOARD_DEBUG=1 logs every broadcast's changed units, trigger tags and build cost.

Rebuilds are gated on someone listening. With no delta subscriber the pipeline never builds — plain subscribers get the zero-cost notify, a closed dashboard costs nothing (both pollers stop with their last subscriber). With delta subscribers the debounced fire rebuilds ONCE through graph-cache's single-flight getBoard() (the SSE rebuild and a concurrent /api/graph poll share one assembly), broadcasts the patch, and notifies plain streams only when the content tag actually moved. Every source is best-effort and never throws: a source that can't start leaves that leaf to the patrol — which now actually covers it, and says so.

Reconnect is free, and the ping is a contract. A backend hot-reload drops the stream; EventSource auto-reconnects and the fresh graph-full re-anchors the patch chain with no client-side repair logic. The keep-alive ping (which also keeps idle proxies from timing the stream out) is promised every 10s and is the client's heartbeat: dashboard-shell holds the server to it — silence past 2.5 windows means a DEAD stream to replace (the half-open deaths that fire no error event), so an undetectably dead connection degrades to the poll for at most one watchdog window instead of forever. An old backend without this route, a proxy that strips SSE, or a server that ignores ?mode=delta still degrade to the plain protocol or the poll — never to a frozen view. The full snapshot itself (first paint, resync) stays graph-lean's cut (issue #26), composing with — not replaced by — the delta path.