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
+26 -3
View File
@@ -145,13 +145,20 @@ export interface WireSource {
file: string;
language: string;
drift: boolean;
/**
* Which numbering `lines` belong to. `'indexed'` — the file matches the
* index. `'current'` — it drifted and we asked for the bytes anyway
* (`ondrift: 'current'`), so nothing the graph holds about this file lines up
* with them. `'none'` — it drifted and no slice came back.
*/
showing: 'indexed' | 'current' | 'none';
contentHash: string;
indexedAt: number;
generated: boolean;
totalLines: number | null;
from?: number;
to?: number;
/** Absent when `drift` — a mis-sliced body is worse than no body. */
/** Absent when the file drifted and `ondrift` was left at its default. */
lines?: string[];
truncated?: boolean;
reason?: string;
@@ -590,13 +597,29 @@ export function fetchFileCode(
return getJson<WireFileCodePayload>(`api/filecode/${encoded}`, signal);
}
/**
* A slice of an indexed file.
*
* `ondrift` decides what happens when the file has changed since it was
* indexed. The default omits the slice — an indexed range over rewritten bytes
* can show a different symbol's code under the right name. `'current'` asks for
* the file's current lines instead, which is only correct for a caller that is
* also going to SAY so: the response comes back `showing: 'current'`, and every
* line-anchored thing the graph knows (ports, arcs, call sites, rail rows) has
* to be switched off over it.
*/
export function fetchSource(
file: string,
from: number,
to: number,
signal?: AbortSignal
signal?: AbortSignal,
ondrift?: 'current'
): Promise<WireSource> {
const params = new URLSearchParams({ file, from: String(from), to: String(to) });
const params = new URLSearchParams({ file, from: String(from) });
// `to` is 1-based on the wire and absent means "to the end of the file" —
// sending 0 for that would be out of range, not a synonym.
if (to > 0) params.set('to', String(to));
if (ondrift) params.set('ondrift', ondrift);
return getJson<WireSource>(`api/source?${params}`, signal);
}
+325
View File
@@ -0,0 +1,325 @@
/**
* The live channel — the viewer's end of `/api/events` (CG-53).
*
* The server watches two things and says so; this module turns that into two
* counters every screen can read:
*
* `live.indexTick` — the graph moved. Every screen is one round-trip stale.
* `live.diskTick` — source files changed on disk. Only the screens showing
* one of them care, and what they care about is drift.
*
* A counter rather than a callback list because Svelte's effects already do the
* subscribing: a view that reads `live.indexTick` inside an `$effect` re-runs
* when it moves, and one that does not read it is not subscribed. `liveRefresh`
* below wraps the three lines of bookkeeping that turns "the counter moved"
* into "call this once".
*
* ## Nothing polls, and nothing loops
*
* `EventSource` is the transport, but its own reconnect is not: left alone it
* retries forever at a fixed interval, so a viewer left open against a stopped
* `codegraph ui` becomes a request every three seconds until the tab is closed.
* So each `error` closes the stream and schedules ONE reconnect on a backoff
* that ends: after {@link MAX_ATTEMPTS} consecutive failures the connection
* gives up and says so, and only a deliberate signal — the tab coming back to
* the foreground, or the window regaining focus — starts it again.
*
* The same rule covers the server's own bad day: a `degraded` event means live
* watching has stopped for good on that side. The client records it and shows
* it. It must never respond by asking again on a timer — a degraded watcher is
* exactly the case where a poll would run forever.
*/
import { untrack } from 'svelte';
/* ----------------------------------------------------------- wire shapes -- */
export interface LiveIndexRevision {
lastIndexedAt: number | null;
files: number;
}
export interface LiveHello {
type: 'hello';
index: LiveIndexRevision | null;
watching: { source: boolean; index: boolean };
degraded: string | null;
heartbeatMs: number;
at: number;
}
export interface LiveChanged {
type: 'changed';
files: string[];
total: number;
truncated: boolean;
/** The change could not be described file by file — assume any file is affected. */
scan: boolean;
at: number;
}
export interface LiveIndexEvent {
type: 'index';
index: LiveIndexRevision;
files: string[];
total: number;
truncated: boolean;
at: number;
}
/* --------------------------------------------------------------- backoff -- */
/** Reconnect delays, in order. The last one repeats until the attempts run out. */
export const BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 15_000, 30_000];
/** Consecutive failures before the connection stops trying on its own. */
export const MAX_ATTEMPTS = 8;
/* ----------------------------------------------------------------- state -- */
let connected = $state(false);
/** Gave up reconnecting. Only a foreground/focus signal restarts it. */
let stopped = $state(false);
let degraded = $state<string | null>(null);
let watching = $state<{ source: boolean; index: boolean } | null>(null);
let indexTick = $state(0);
let diskTick = $state(0);
let lastIndex = $state<LiveIndexEvent | null>(null);
let lastChanged = $state<LiveChanged | null>(null);
let source: EventSource | null = null;
let retry: ReturnType<typeof setTimeout> | null = null;
let attempts = 0;
let started = false;
/**
* Ticks that arrived while the tab was in the background.
*
* A hidden tab still gets every event — the stream does not care — but making
* it refetch is work nobody is looking at. The counters move when it comes
* back, and because they are counters, ten syncs in the background still cost
* exactly one refresh.
*/
let deferredIndex = false;
let deferredDisk = false;
function hidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
function bumpIndex(event: LiveIndexEvent): void {
lastIndex = event;
if (hidden()) {
deferredIndex = true;
return;
}
indexTick += 1;
}
function bumpDisk(event: LiveChanged): void {
lastChanged = event;
if (hidden()) {
deferredDisk = true;
return;
}
diskTick += 1;
}
function flushDeferred(): void {
if (deferredIndex) {
deferredIndex = false;
indexTick += 1;
}
if (deferredDisk) {
deferredDisk = false;
diskTick += 1;
}
}
/* ------------------------------------------------------------ connection -- */
function open(): void {
if (source || typeof EventSource === 'undefined') return;
if (retry !== null) {
clearTimeout(retry);
retry = null;
}
stopped = false;
const es = new EventSource('api/events');
source = es;
es.addEventListener('open', () => {
connected = true;
});
es.addEventListener('hello', (event) => {
const hello = parse<LiveHello>(event);
if (!hello) return;
// A hello is the only proof the stream is really working: `open` fires on
// the response headers, and a server that answered and then died would
// otherwise reset the backoff it should have been paying.
attempts = 0;
connected = true;
watching = hello.watching;
degraded = hello.degraded;
});
es.addEventListener('changed', (event) => {
const changed = parse<LiveChanged>(event);
if (changed) bumpDisk(changed);
});
es.addEventListener('index', (event) => {
const moved = parse<LiveIndexEvent>(event);
if (moved) bumpIndex(moved);
});
es.addEventListener('degraded', (event) => {
const note = parse<{ reason: string }>(event);
if (note) degraded = note.reason;
});
es.addEventListener('error', () => {
connected = false;
es.close();
if (source === es) source = null;
attempts += 1;
if (attempts >= MAX_ATTEMPTS) {
// Out of attempts. Nothing on a timer from here — the tab coming back to
// the foreground is the only thing that tries again.
stopped = true;
return;
}
const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000;
retry = setTimeout(open, delay);
});
}
function parse<T>(event: Event): T | null {
const data = (event as MessageEvent<string>).data;
if (typeof data !== 'string') return null;
try {
return JSON.parse(data) as T;
} catch {
return null;
}
}
/** Connect, once, for the life of the page. */
function start(): void {
if (started || typeof window === 'undefined') return;
started = true;
document.addEventListener('visibilitychange', () => {
if (hidden()) return;
flushDeferred();
// Back in the foreground is the deliberate signal a stopped connection
// waits for. A tab that has been asleep for an hour reconnects when it is
// looked at, and not before.
if (stopped) {
attempts = 0;
open();
}
});
window.addEventListener('focus', () => {
if (!stopped) return;
attempts = 0;
open();
});
window.addEventListener('pagehide', () => {
source?.close();
source = null;
});
open();
}
/* ----------------------------------------------------------------- store -- */
export const live = {
get connected(): boolean {
return connected;
},
/** True once the client has stopped trying to reconnect on its own. */
get stopped(): boolean {
return stopped;
},
/** Why the SERVER stopped watching, when it has. Never a reason to poll. */
get degraded(): string | null {
return degraded;
},
get watching(): { source: boolean; index: boolean } | null {
return watching;
},
get indexTick(): number {
return indexTick;
},
get diskTick(): number {
return diskTick;
},
get lastIndex(): LiveIndexEvent | null {
return lastIndex;
},
get lastChanged(): LiveChanged | null {
return lastChanged;
},
start,
};
/**
* Whether the latest on-disk change is one a screen showing `file` should react
* to.
*
* `scan: true` means the watcher could not name the files (a directory removal,
* or a burst past its ceiling), so the honest answer is yes.
*/
export function touchesFile(file: string | null): boolean {
const changed = lastChanged;
if (!changed) return false;
if (changed.scan || changed.truncated) return true;
if (file === null) return false;
return changed.files.includes(file);
}
/**
* Call `refresh` when what a screen is showing has gone stale.
*
* Two different staleness signals, deliberately not merged:
*
* - **the index moved** — every screen refetches. Not "the file I am showing
* changed": a rail is the answer to a question about the whole graph, and a
* symbol gains a caller when some *other* file is edited. Filtering by the
* focused file here would leave the rails quietly wrong, which is the failure
* this whole task exists to remove. One request per sync is the cost, and a
* sync is not a thing that happens in a loop.
* - **the file changed on disk** — only the screen showing that file, and only
* so its drift banner appears without waiting for the sync.
*
* Must be called during component initialisation (it creates an `$effect`).
*/
export function liveRefresh(
file: () => string | null,
refresh: (reason: 'index' | 'disk') => void
): void {
let seenIndex = indexTick;
let seenDisk = diskTick;
$effect(() => {
const index = live.indexTick;
const disk = live.diskTick;
const path = file();
untrack(() => {
if (index !== seenIndex) {
seenIndex = index;
// An index event supersedes any disk event before it: the sync that
// just landed is what those edits became.
seenDisk = disk;
refresh('index');
return;
}
if (disk !== seenDisk) {
seenDisk = disk;
if (touchesFile(path)) refresh('disk');
}
});
});
}
+9
View File
@@ -46,4 +46,13 @@ export const project = {
return `${n(stats.graph.nodes)} symbols · ${n(stats.graph.edges)} edges · ${n(stats.graph.files)} files indexed`;
},
ensure: load,
/**
* Re-read `/api/stats` because the index moved (the live channel's `index`
* event). Distinct from `ensure`, which memoises the first request forever —
* memoising this one would mean the top bar's counts never move again.
*/
reload(): Promise<void> {
inflight = null;
return load();
},
};
+36
View File
@@ -0,0 +1,36 @@
/**
* The one transient message the viewer has: "Index updated · reloaded".
*
* A note, not a dialog — nothing was asked of the reader and nothing is waiting
* on them. It replaces itself rather than stacking, because the only thing it
* ever reports is the most recent state of one fact.
*/
/** How long a note stays up (design spec). */
export const TOAST_MS = 2_600;
let message = $state<string | null>(null);
let timer: ReturnType<typeof setTimeout> | null = null;
function show(text: string): void {
if (timer !== null) clearTimeout(timer);
message = text;
timer = setTimeout(() => {
message = null;
timer = null;
}, TOAST_MS);
}
function clear(): void {
if (timer !== null) clearTimeout(timer);
timer = null;
message = null;
}
export const toast = {
get message(): string | null {
return message;
},
show,
clear,
};
+21
View File
@@ -73,6 +73,27 @@ export const trail = {
];
},
/**
* The same symbol, under a new id.
*
* A node's id contains its start LINE (`generateNodeId`), so any edit above a
* symbol gives it a different id at the next sync — while it is the same
* symbol, in the same place in the reader's path. Swapping it in place keeps
* the trail a path; pushing the new id would draw a hop that describes no
* call, and dropping the trail would lose the walk that got here.
*/
rename(oldId: string, next: { id: string; name?: string | null; kind?: string | null }): void {
const at = hops.findIndex((h) => h.id === oldId);
if (at < 0) return;
remember(next.id, next);
const hop = hops[at] as TrailHop;
hops = [
...hops.slice(0, at),
{ ...hop, id: next.id, name: next.name ?? hop.name, kind: next.kind ?? hop.kind },
...hops.slice(at + 1),
];
},
/** Drop every hop after `index`, making it the current one. */
truncateTo(index: number): void {
if (index < 0 || index >= hops.length) return;