feat(ui): live refresh and drift banners — the viewer keeps up with the project (CG-53)

`GET /api/events` is a server-sent-event stream the viewer holds open for the
life of the page. Two signals, two things the browser could not know:

  changed  source files touched on disk, before any sync — the drift banner
  index    the graph moved, naming what the sync re-indexed — the live refresh

The server WATCHES and never syncs: the project tree through the engine's own
FileWatcher with a notify-only syncFn, the index through one non-recursive
fs.watch on the data directory settled at 400 ms. Both start with the first
subscriber and stop with the last, so a viewer nobody has open costs no watch
descriptors. Nothing polls, on either side.

Drift is now parity with codegraph_node (#1474) rather than an absence.
`/api/source?ondrift=current` serves a drifted file's CURRENT bytes flagged
`showing: 'current'`, and the three screens that can say so switch off
everything anchored to the old line numbering — gutter ports, call-site links,
call arcs, the callee rail's anchoring — while keeping the source. The banner is
paper-2 with a hairline rule, never amber: amber belongs to the untested badge.

Also fixes a stale read this exposed. A long-lived reader holds an LRU of nodes
by id that only its own writes invalidate, so `/api/node/<id>` kept answering
with a symbol another process's sync had deleted while `/api/search` beside it
said it was gone. GraphSession now drops the read caches when the database (or
its WAL) has been written, and the Symbol view follows a symbol whose id changed
because an edit above it moved its start line, carrying the trail across.

Measured on a live viewer: banner 360 ms after a save, toast 440 ms after
`codegraph sync` returns, 0 requests in 4 idle seconds, and the client gives up
reconnecting after ~90 s with "Not live" rather than hammering a dead port.
This commit is contained in:
Colby McHenry
2026-08-27 04:28:56 -05:00
parent bd99c5e99a
commit ecd6e1cd15
28 changed files with 2236 additions and 142 deletions
+37
View File
@@ -1154,6 +1154,43 @@ export class CodeGraph {
return this.queries.getLastIndexedAt();
}
/**
* How far the last sync got and how many files it left behind — the cheapest
* marker of "has this index moved". One query; safe to call on every
* filesystem event a live viewer sees.
*/
getIndexRevision(): { lastIndexedAt: number | null; fileCount: number } {
return this.queries.getIndexRevision();
}
/**
* Files re-indexed strictly after `since`, newest first — what a sync just
* picked up. `total` is the real count, `paths` is capped at `limit`.
*/
getFilesIndexedSince(since: number, limit: number): { paths: string[]; total: number } {
return this.queries.getFilesIndexedSince(since, limit);
}
/**
* Forget everything held in memory about rows another process may have
* changed.
*
* The query layer keeps an LRU of nodes by id, invalidated by writes made
* through THIS instance — which is exactly right for a process that owns the
* index, and wrong for one that is only reading a database somebody else is
* writing. A long-lived reader (the `codegraph ui` server, a daemon holding a
* graph open across an agent's edits) will otherwise answer `getNode(id)`
* with a row a sync deleted minutes ago, while every SQL-backed query beside
* it reports the truth — a disagreement that reads as a bug in whichever
* screen shows both.
*
* Cheap (clearing a bounded Map) and safe to call whenever the database file
* looks like it moved.
*/
dropReadCaches(): void {
this.queries.clearCache();
}
/**
* Completeness of the last full index run. `'complete'` is the only good
* state. `'indexing'` after the fact means a run was killed mid-index (OOM,