feat(ui): read-only JSON API over the index for the viewer (CG-42)
Six endpoints under `/api/`, one per screen, each answering in a single
round-trip in the spirit of `codegraph_explore` — the viewer should never
have to ask a follow-up question to finish drawing a pane:
/api/stats index state, graph counts, frameworks
/api/search?q= ranked, kind-grouped symbol search
/api/node/<id> rails, members, tests, blast radius
/api/source?file=&from=&to= verbatim source + a drift verdict
/api/file/<path> outline and import rails
/api/routes URL -> handler, when there is one
It is a reader of the existing schema: no extraction or resolution changes.
It mounts on the `api` seam `startUiServer` already exposed, so it sits
behind the CG-41 loopback boundary — Host allowlist, no CORS headers,
GET/HEAD only — and every read out of the repository goes through
`resolveProjectFile`, ahead of the index lookup so a traversal is refused
as a traversal rather than reported as "not indexed".
Three properties the endpoints are built around:
- No N+1. The engine's busiest symbol has 545 incoming edges; resolving
those one `getNode` at a time is 545 queries. Every edge list is
resolved with one batched lookup, which needed four additive read-only
query methods (`getNodesByIds`/`getFanIn`/`getFanOut` on `CodeGraph`,
plus batched outgoing/incoming edge fetches and unresolved-reference
reads). `/api/node` on `LRUCache.get` answers in ~10 ms.
- Capped lists, honest totals. 545 callers cannot all be rows, so caller
groups cap at 300 — but `total` is always the real number, and the
ordering puts the useful end first (same file, then production code,
then tests). Every count in the payload is the length of a list the
same payload returns, so a badge and its rail cannot disagree.
- Nothing overclaims. Source that drifted on disk since the last index
sync is omitted rather than sliced at line ranges that may now point at
a different symbol; calls that leave the index are counted instead of
silently shortening the callee rail; imports that never resolved are
named; and a test-coverage claim reports whether its search actually
finished. `/api/routes` says a project simply is not routed, and
refuses a `limit` below three because the engine's manifest would
answer that question wrongly.
Tests: 45 against a real indexed fixture over a real loopback server,
covering every endpoint's shape, the drift verdict in all three places it
surfaces, search ranking and the filter grammar, the refusals, and the
capping/latency behaviour at 500 callers. The issue's own acceptance case
— `lru-cache.ts` `get` under 100 ms — runs against this repo's index when
one is present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
41a90c6ba4
commit
951ba3678a
+13
-1
@@ -1919,7 +1919,14 @@ ${BROWSER_ENV}=none to never open one.
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { startUiServer, openBrowser, ViewerMissingError } = await import('../ui-server');
|
||||
const { startUiServer, openBrowser, createGraphApi, ViewerMissingError } = await import(
|
||||
'../ui-server'
|
||||
);
|
||||
|
||||
// The read-only JSON API the viewer reads its screens from. It opens the
|
||||
// index lazily on the first request, so a slow first paint is the only cost
|
||||
// of mounting it here rather than after the browser connects.
|
||||
const api = createGraphApi({ projectRoot: projectPath });
|
||||
|
||||
let handle: UiServerHandle;
|
||||
try {
|
||||
@@ -1927,8 +1934,10 @@ ${BROWSER_ENV}=none to never open one.
|
||||
projectRoot: projectPath,
|
||||
port: requestedPort,
|
||||
portFallback: requestedPort === undefined,
|
||||
api: api.handler,
|
||||
});
|
||||
} catch (err) {
|
||||
api.close();
|
||||
// Both failure modes here (viewer assets missing, no port available) carry
|
||||
// their own remediation — print it plainly, never a stack trace.
|
||||
error(err instanceof ViewerMissingError || err instanceof Error ? err.message : String(err));
|
||||
@@ -1954,6 +1963,9 @@ ${BROWSER_ENV}=none to never open one.
|
||||
// The http server keeps the event loop alive on its own; these just make
|
||||
// Ctrl-C hang up live sockets instead of waiting on browser keep-alives.
|
||||
const shutdown = (): void => {
|
||||
// Release the SQLite handle before the socket: the process should never
|
||||
// exit with a live connection to the user's index.
|
||||
api.close();
|
||||
void handle.close().then(() => process.exit(0));
|
||||
};
|
||||
process.once('SIGINT', shutdown);
|
||||
|
||||
Reference in New Issue
Block a user