Files
codegraph/src/ui-server/api/session.ts
T
Colby McHenry ecd6e1cd15 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.
2026-08-27 04:28:56 -05:00

191 lines
6.7 KiB
TypeScript

/**
* The one open handle on the project's index.
*
* `CodeGraph.openSync` costs tens of milliseconds and runs pending migrations,
* so it happens once for the life of the server rather than once per request.
* That leaves two things this module has to get right:
*
* - **A missing index is guidance, not a stack trace.** `codegraph ui` refuses
* to start without one, but a user can delete `.codegraph/` while the viewer
* is open, so every endpoint has to be able to say so in the same words the
* CLI does.
* - **A re-index must not be served from a phantom database.** `codegraph init`
* on an already-indexed project *replaces the database file* (see
* `CodeGraph.recreate`). On POSIX our handle would keep reading the unlinked
* inode and happily serve a graph that no longer exists on disk. So the file
* identity is re-checked on acquisition — one `stat` — and a swapped file
* reopens the connection.
* - **A sync by ANOTHER process must not be served from memory.** The same
* `stat` also notices the database growing, and when it has, the read caches
* go (`dropReadCaches`). The query layer holds an LRU of nodes by id which
* only a write through *this* instance invalidates, so without it
* `/api/node/<id>` keeps answering with a symbol an agent's sync deleted
* while `/api/search` — which never caches — correctly says it is gone. That
* is not a stale screen, it is two screens contradicting each other; and
* because a node's id contains its start line, ANY edit above a symbol
* renames it, so this is the common case rather than the corner one.
*/
import * as fs from 'fs';
import { CodeGraph } from '../../index';
import { getDatabasePath } from '../../db';
import { isInitialized } from '../../directory';
import { ApiError } from './respond';
/**
* Identity of the database file, so a swap underneath us is detectable — plus
* the marks that say it was WRITTEN to without being replaced.
*
* The WAL is measured as well as the database: in WAL mode a commit lands in
* `codegraph.db-wal` and may not touch `codegraph.db` until a checkpoint, so a
* whole sync can go by with the main file's size and mtime unchanged.
*/
interface FileIdentity {
ino: number;
birthtimeMs: number;
size: number;
mtimeMs: number;
walSize: number;
walMtimeMs: number;
}
function identify(dbPath: string): FileIdentity | null {
try {
const st = fs.statSync(dbPath);
let walSize = 0;
let walMtimeMs = 0;
try {
const wal = fs.statSync(`${dbPath}-wal`);
walSize = wal.size;
walMtimeMs = wal.mtimeMs;
} catch {
// No WAL sidecar: either not in WAL mode, or fully checkpointed. Both are
// "nothing pending", which is what zeroes mean here.
}
return {
ino: st.ino,
birthtimeMs: st.birthtimeMs,
size: st.size,
mtimeMs: st.mtimeMs,
walSize,
walMtimeMs,
};
} catch {
return null;
}
}
function sameFile(a: FileIdentity | null, b: FileIdentity | null): boolean {
if (a === null || b === null) return false;
// `ino` is 0 on a few Windows filesystems; birthtime alone still catches a
// recreate there, and a false "changed" only costs one reopen.
return a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
}
/** Same file, but written to since we last looked. */
function sameContent(a: FileIdentity | null, b: FileIdentity | null): boolean {
if (a === null || b === null) return false;
return (
a.size === b.size &&
a.mtimeMs === b.mtimeMs &&
a.walSize === b.walSize &&
a.walMtimeMs === b.walMtimeMs
);
}
/**
* Guidance shown when there is no index to read. Deliberately the same three
* facts the CLI prints: the viewer never creates an index, `codegraph init`
* does, and you can point the viewer somewhere already indexed.
*/
function noIndexError(projectRoot: string): ApiError {
return new ApiError(
'no-index',
`No CodeGraph index found for ${projectRoot}.`,
'The viewer reads an index that already exists — it never creates one. ' +
'Run "codegraph init" in that project, or start the viewer against a project ' +
'that has one: codegraph ui /path/to/indexed/project'
);
}
/**
* Holds the project's `CodeGraph` open for the life of the server.
*
* Not thread-safe and does not need to be: `node:http` dispatches on one
* thread, and every read below is synchronous.
*/
export class GraphSession {
readonly projectRoot: string;
private readonly dbPath: string;
private cg: CodeGraph | null = null;
private identity: FileIdentity | null = null;
constructor(projectRoot: string) {
this.projectRoot = projectRoot;
this.dbPath = getDatabasePath(projectRoot);
}
/**
* The open graph, opening (or reopening) it if needed.
*
* @throws {ApiError} `no-index` when the project has no index,
* `index-unusable` when it has one that will not open.
*/
acquire(): CodeGraph {
const current = identify(this.dbPath);
if (this.cg !== null) {
if (sameFile(this.identity, current)) {
// Same file, but somebody wrote to it. SQLite itself is fine — a WAL
// reader sees the new commits — but our in-memory node cache is not,
// so it goes. One `stat` already paid for; clearing a bounded Map is
// the whole cost.
if (!sameContent(this.identity, current)) {
this.identity = current;
this.cg.dropReadCaches();
}
return this.cg;
}
// The database was replaced (a re-index) or removed. Drop the stale
// handle; falling through re-opens against whatever is there now.
this.closeQuietly();
}
if (!isInitialized(this.projectRoot)) throw noIndexError(this.projectRoot);
try {
this.cg = CodeGraph.openSync(this.projectRoot);
} catch (err) {
this.cg = null;
this.identity = null;
throw new ApiError(
'index-unusable',
`The CodeGraph index for ${this.projectRoot} could not be opened: ` +
(err instanceof Error ? err.message : String(err)),
'If another CodeGraph process is rebuilding it, wait for that to finish. ' +
'If the index is damaged, rebuild it with "codegraph init".'
);
}
this.identity = current ?? identify(this.dbPath);
return this.cg;
}
/** Release the handle. Idempotent — the CLI calls it on Ctrl-C. */
close(): void {
this.closeQuietly();
}
private closeQuietly(): void {
const cg = this.cg;
this.cg = null;
this.identity = null;
if (!cg) return;
try {
cg.close();
} catch {
// A close that fails has nothing left to release — the process is either
// exiting or the file is already gone. Never let it fail a request.
}
}
}