feat(ui): saved trails — a walk you named, kept, and still true after a re-index (CG-60)

Save trail on the trail bar writes the walk to .codegraph/ui/trails/ as one
JSON file, listed on the empty screen and on Entry points above the derived
suggestions, reopened at the symbol you left with the whole path restored.

A hop is stored by qualified name, kind and file — never by node id, which
contains a start line and so changes the first time anybody edits above the
symbol. Every hop is re-resolved against the current index on the way out and
each row says what became of it: still here, moved to another file, now
ambiguous, or gone. A hole is never stitched over: the row opens the longest
run of CONSECUTIVE resolved hops and says which ones those are, because the
trail is a path and a skipped hop would draw a call that does not exist.

This is the first write the viewer makes, and the boundary moved with it:
POST/DELETE answer under /api/ only, must carry X-CodeGraph-UI and
application/json (neither of which a cross-origin form can produce without a
preflight this server answers none of), and --read-only refuses both while
still listing what is there. The blanket "read-only" claim is retired from the
banner, the README, the CLI help and the docs site in favour of the narrower
true one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-27 08:31:48 -05:00
co-authored by Claude Opus 5
parent 55a33055ee
commit 47576b392e
32 changed files with 3162 additions and 78 deletions
+124 -12
View File
@@ -1,12 +1,17 @@
/**
* The read-only JSON API the viewer reads its screens from.
*
* Twelve endpoints, one per screen, each answering in a single round-trip — the
* same principle as `codegraph_explore`: return enough that the caller does not
* have to ask a follow-up question — plus one that does not answer at all and
* stays open instead (`/api/events`), so a screen learns that its answer went
* stale rather than waiting to be asked again. Everything here is a *reader* of
* the existing schema; nothing indexes, resolves, or writes.
* Thirteen endpoints, one per screen, each answering in a single round-trip —
* the same principle as `codegraph_explore`: return enough that the caller does
* not have to ask a follow-up question — plus one that does not answer at all
* and stays open instead (`/api/events`), so a screen learns that its answer
* went stale rather than waiting to be asked again.
*
* All but one are *readers* of the existing schema; nothing here indexes or
* resolves. The exception is `/api/trails`, which saves the reader's own named
* walks as JSON under `.codegraph/ui/trails/` — the only write the viewer makes,
* to the only directory it may write to, and refused outright under
* `--read-only`. See `./trail-store.ts`.
*
* ```
* GET /api/stats what this index is and how much to trust it
@@ -22,20 +27,25 @@
* GET /api/deadcode symbols nothing reaches, and what was excluded
* GET /api/flow?from=&to= the flow strip: one card per hop
* GET /api/events the live channel (SSE): drift and refresh
* GET /api/trails saved trails, re-resolved against the index
* POST /api/trails save one (refused under --read-only)
* DELETE /api/trails/<id> remove one (refused under --read-only)
* ```
*
* It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
* the loopback boundary in `security.ts`: the `Host` allowlist, the absence of
* CORS headers and the GET/HEAD restriction are already enforced by the time a
* handler here runs. The one obligation that remains ours is the read
* chokepoint — `resolveProjectFile` for anything that touches the repository —
* and it lives in `source.ts`, the only module here that opens a file.
* CORS headers and the method restriction are already enforced by the time a
* handler here runs — including the extra shape a write has to have. The one
* obligation that remains ours is the path chokepoint, `resolveProjectFile` for
* anything that touches the repository. Two modules here reach the filesystem
* and no others: `source.ts` reads the project's code, and `trail-store.ts`
* reads and writes `.codegraph/ui/trails/`.
*/
import type { UiApiHandler, UiRequestContext } from '../index';
import { PathRefusalError } from '../security';
import { GraphSession } from './session';
import { ApiError, badRequest, fail, notFound, ok } from './respond';
import { ApiError, badRequest, fail, notFound, ok, readJsonBody } from './respond';
import { buildStats } from './stats';
import { buildSearch } from './search';
import { buildNode } from './node';
@@ -48,6 +58,7 @@ import { buildNodeRefs } from './nodes';
import { buildMap } from './map';
import { buildDeadCode } from './deadcode';
import { buildFlow } from './flow';
import { buildTrails, removeTrail, saveTrail, type TrailsOptions } from './trails';
import { EventHub } from './events';
export { GraphSession } from './session';
@@ -99,6 +110,28 @@ export type {
WireDeadCodeRow,
} from './deadcode';
export { MAX_DEAD_CODE_MEMBERS, MAX_DEAD_CODE_ROWS } from './deadcode';
export type {
WireTrail,
WireTrailHop,
WireTrailHopStatus,
WireTrails,
SaveTrailRequest,
TrailsOptions,
} from './trails';
export { buildTrails, encodeResolvedRun, resolveHop, resolveTrail } from './trails';
export type { StoredHop, StoredTrail } from './trail-store';
export {
MAX_TRAILS,
MAX_TRAIL_HOPS,
MAX_TRAIL_NAME,
MAX_TRAIL_NOTE,
TRAILS_RELATIVE_DIR,
TRAIL_FORMAT_VERSION,
isTrailId,
listStoredTrails,
parseTrail,
slugify,
} from './trail-store';
/**
* A mounted API, plus the handle it holds open.
@@ -114,12 +147,29 @@ export interface GraphApi {
export interface GraphApiOptions {
/** Absolute path of the indexed project to read. */
projectRoot: string;
/**
* Refuse every write, so the viewer is a pure reader again.
*
* The one thing it would otherwise write is a saved trail into
* `.codegraph/ui/trails/`. Turning this on is for a checkout that must not
* change (a review sandbox, a read-only mount, a shared machine); the viewer
* still lists trails that are already there, and says why Save is gone.
*/
readOnly?: boolean;
/** The sentence shown in place of Save. Defaults to a generic one. */
readOnlyReason?: string;
}
/** What `GET /api` answers: the endpoint list, for anyone poking at it by hand. */
const API_INDEX = {
name: 'codegraph ui',
readOnly: true,
/**
* Every endpoint but `/api/trails` is a pure read. Kept as a field rather
* than dropped, because it was `true` and something may be reading it; it is
* now the honest, narrower claim.
*/
readOnly: false,
writes: ['POST /api/trails', 'DELETE /api/trails/<id>'],
endpoints: [
{ path: '/api/stats', description: 'Index state, graph counts, detected frameworks.' },
{ path: '/api/search', description: 'Ranked symbol search.', params: ['q', 'limit'] },
@@ -167,6 +217,12 @@ const API_INDEX = {
description: 'Where to start reading: routes, files that run something, and hubs.',
params: ['limit'],
},
{
path: '/api/trails',
description:
'Saved trails, each hop re-resolved against the current index. POST saves one, ' +
'DELETE /api/trails/<id> removes it. The only endpoint that writes.',
},
],
};
@@ -175,12 +231,25 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
// Watches nothing until a browser subscribes, and stops again when the last
// one goes away — mounting the API costs no watch descriptors.
const events = new EventHub(options.projectRoot, session);
const trails: TrailsOptions = {
readOnly: options.readOnly === true,
readOnlyReason:
options.readOnly === true
? options.readOnlyReason ?? 'This viewer is running read-only, so trails cannot be saved.'
: null,
};
// Async because `/api/source` highlights: everything else answers straight
// out of SQLite and resolves on the same tick.
const handler: UiApiHandler = async (req, res, ctx) => {
const route = normalize(ctx.pathname);
try {
// Writes first: they are the only requests that carry a body, and
// routing them beside the readers would put a `case` that mutates in a
// switch every other arm of which is a query.
if (ctx.method === 'POST' || ctx.method === 'DELETE') {
return await dispatchWrite(route, req, res, ctx, session, trails);
}
switch (route) {
case '/api':
return ok(res, API_INDEX, ctx.method);
@@ -196,6 +265,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
case '/api/entrypoints':
return ok(res, buildEntryPoints(session.acquire(), ctx.query), ctx.method);
case '/api/trails':
return ok(res, buildTrails(session.acquire(), ctx.projectRoot, trails), ctx.method);
case '/api/nodes':
return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
case '/api/source':
@@ -231,6 +302,47 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
};
}
/**
* The write half: `/api/trails` and nothing else.
*
* Kept to one function so the answer to "what can this server change?" is one
* place a reviewer can read in full. Anything else that arrives with a write
* method is a 405 naming the endpoint that does accept one — by the time this
* runs, `isWriteRequest` has already established the request could not have
* been forged from another origin, so an unhelpfully vague refusal here would
* only confuse the person poking at their own API.
*/
async function dispatchWrite(
route: string,
req: Parameters<UiApiHandler>[0],
res: Parameters<UiApiHandler>[1],
ctx: UiRequestContext,
session: GraphSession,
trails: TrailsOptions
): Promise<boolean> {
if (ctx.method === 'POST' && route === '/api/trails') {
const body = await readJsonBody(req);
return ok(res, saveTrail(session.acquire(), ctx.projectRoot, body, trails), ctx.method);
}
if (ctx.method === 'DELETE') {
const id = suffixAfter(route, '/api/trails/');
if (id !== null && id !== '') {
return ok(res, removeTrail(session.acquire(), ctx.projectRoot, id, trails), ctx.method);
}
if (route === '/api/trails') {
throw badRequest('Deleting a trail needs its id: DELETE /api/trails/<id>.');
}
}
res.setHeader('Allow', 'GET, HEAD');
throw new ApiError(
'bad-request',
`${ctx.method} ${route} is not something this server changes.`,
'The only endpoint that writes is /api/trails (POST to save, DELETE /api/trails/<id> to remove).'
);
}
/**
* The two endpoints that carry their argument in the path.
*
+49 -1
View File
@@ -8,7 +8,7 @@
* the CLI and the MCP tools do. What it never does is leak a stack trace.
*/
import type { ServerResponse } from 'http';
import type { IncomingMessage, ServerResponse } from 'http';
import { sendJson } from '../static';
/**
@@ -88,6 +88,54 @@ export function fail(res: ServerResponse, err: unknown, method: string): true {
return true;
}
// =============================================================================
// Request bodies
// =============================================================================
/**
* Bytes a request body may carry.
*
* The only body this server reads is a saved trail: a name and up to 64 node
* ids. 64 KB is generous for that and small enough that a runaway client cannot
* make the process hold a megabyte per socket.
*/
export const MAX_BODY_BYTES = 64 * 1024;
/**
* Read a request body as JSON.
*
* Counts BYTES, not characters, and stops at the cap by destroying the socket
* rather than draining a body nobody is going to parse — a `Content-Length`
* header is a claim, and the only limit that holds is the one applied to what
* actually arrives.
*
* @throws {ApiError} `bad-request` for a body that is too large or is not JSON.
*/
export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let size = 0;
try {
for await (const chunk of req) {
const buf = chunk as Buffer;
size += buf.length;
if (size > MAX_BODY_BYTES) {
req.destroy();
throw badRequest(`That request body is too large (max ${MAX_BODY_BYTES} bytes).`);
}
chunks.push(buf);
}
} catch (err) {
if (err instanceof ApiError) throw err;
throw badRequest('That request body could not be read.');
}
if (size === 0) throw badRequest('That request needs a JSON body.');
try {
return JSON.parse(Buffer.concat(chunks).toString('utf-8')) as unknown;
} catch {
throw badRequest('That request body is not valid JSON.');
}
}
// =============================================================================
// Query parameters
// =============================================================================
+332
View File
@@ -0,0 +1,332 @@
/**
* Where saved trails live on disk — the only thing `codegraph ui` ever writes.
*
* Every other module under `api/` is a reader. This one holds the single write
* path in the whole viewer, and it is scoped as narrowly as a write can be: one
* directory, `<CODEGRAPH_DIR>/ui/trails/`, inside the project the server was
* started on, one JSON file per trail. It never touches source, never touches
* the index, and never writes anywhere a `codegraph init` would not already
* have created. `.codegraph/.gitignore` ignores everything but itself, so a
* saved trail is local by default; exporting one to commit is a copy the reader
* makes deliberately.
*
* Two rules hold it inside the boundary described in `../security.ts`:
*
* - **The directory is resolved through `resolveProjectFile`**, exactly like a
* source read, so a trail id that tried to be a path is refused by the same
* chokepoint that refuses `?file=../../.ssh/id_rsa`. It is belt and braces on
* top of {@link isTrailId}, which already refuses anything but a slug.
* - **A write is atomic.** Temp file in the same directory, then rename. A
* half-written trail read back by the list would look like a corrupt one, and
* the list would then have to decide whether to hide it — which is a decision
* nobody should have to make about a file they saved a second ago.
*
* The format is deliberately plain: a reader can open one in an editor, and a
* hop is described by what it IS (a qualified name in a file) rather than by the
* node id it happened to have. Node ids contain a start line, so any edit above
* a symbol renames it — a trail keyed on ids would not survive its own project.
*/
import * as fs from 'fs';
import * as path from 'path';
import { CODEGRAPH_DIR } from '../../directory';
import { resolveProjectFile } from '../security';
import { ApiError, badRequest } from './respond';
/** Where trails live, relative to the project root. Forward slashes always. */
export const TRAILS_RELATIVE_DIR = `${CODEGRAPH_DIR}/ui/trails`;
/** The only `version` this build writes, and the only one it reads. */
export const TRAIL_FORMAT_VERSION = 1;
/** Trail files read from the directory before the list stops looking. */
export const MAX_TRAILS = 200;
/** Hops one trail may carry. Past this it is a history, not a tour. */
export const MAX_TRAIL_HOPS = 64;
/** Characters in a trail's name. */
export const MAX_TRAIL_NAME = 120;
/** Characters in a trail's note. */
export const MAX_TRAIL_NOTE = 600;
/** Bytes a single trail file may be before it is skipped as not-ours. */
export const MAX_TRAIL_FILE_BYTES = 64 * 1024;
/** Characters in a generated slug, before any de-duplicating suffix. */
const MAX_SLUG = 60;
/** How a reader got from the previous hop to this one. Mirrors the viewer's `HopDirection`. */
export type StoredHopDirection = 'start' | 'down' | 'up';
/**
* One hop, described by what it is rather than by the id it had.
*
* `id` is kept as a HINT — when the file has not changed it resolves in one
* lookup — but `qualifiedName` + `kind` + `file` is what the trail is actually
* keyed on, and what lets it survive a re-index.
*/
export interface StoredHop {
dir: StoredHopDirection;
name: string;
qualifiedName: string;
kind: string;
/** Project-relative, forward slashes. */
file: string;
line: number;
/** The node id at save time. A fast path, never the identity. */
id: string;
}
export interface StoredTrail {
version: number;
/** Slug, and the file's basename. */
id: string;
name: string;
note: string;
/** Whoever saved it — git's `user.name`, or the OS user. */
author: string;
createdAt: string;
updatedAt: string;
hops: StoredHop[];
}
/* ------------------------------------------------------------------ paths -- */
/**
* Whether a string is a trail id we would have written.
*
* Lowercase slug characters only: no dot, no separator, no leading dash. This
* is what makes `<id>.json` a filename rather than a path expression, and it
* runs before the id is ever joined to anything.
*/
export function isTrailId(value: string): boolean {
return /^[a-z0-9][a-z0-9-]{0,79}$/.test(value);
}
/**
* `Read a file with these lines` -> `read-a-file-with-these-lines`.
*
* Names that carry no ASCII letters or digits at all (a trail named entirely in
* Chinese, or in emoji) slug to nothing; they get `trail`, and the collision
* handling in {@link saveTrail} keeps them distinct from each other.
*/
export function slugify(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, MAX_SLUG)
.replace(/-+$/g, '');
return slug === '' ? 'trail' : slug;
}
/** The absolute trails directory, having been through the read chokepoint. */
export function trailsDirectory(projectRoot: string): string {
return resolveProjectFile(projectRoot, TRAILS_RELATIVE_DIR);
}
/**
* The absolute path of one trail file.
*
* @throws {ApiError} `bad-request` when the id is not a slug we would have
* written — checked before the join, so nothing path-shaped is ever built.
*/
export function trailPath(projectRoot: string, id: string): string {
if (!isTrailId(id)) {
throw badRequest(
`"${id}" is not a saved trail id.`,
'Trail ids are the lowercase slug in the file name, e.g. "how-a-request-is-served".'
);
}
return resolveProjectFile(projectRoot, `${TRAILS_RELATIVE_DIR}/${id}.json`);
}
/* ------------------------------------------------------------------- read -- */
/**
* Parse a file into a trail, or `null` if it is not one.
*
* Everything is re-validated rather than trusted: the directory is a place a
* user may hand-edit a file, or drop one somebody else exported, and a trail
* that half-parsed would draw a row with holes in it. A file that fails is
* skipped and counted, never repaired in place.
*/
export function parseTrail(id: string, text: string): StoredTrail | null {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return null;
}
if (typeof raw !== 'object' || raw === null) return null;
const value = raw as Record<string, unknown>;
if (typeof value.name !== 'string' || value.name.trim() === '') return null;
if (!Array.isArray(value.hops) || value.hops.length === 0) return null;
const hops: StoredHop[] = [];
for (const entry of value.hops.slice(0, MAX_TRAIL_HOPS)) {
if (typeof entry !== 'object' || entry === null) return null;
const hop = entry as Record<string, unknown>;
const qualifiedName = typeof hop.qualifiedName === 'string' ? hop.qualifiedName : '';
const name = typeof hop.name === 'string' ? hop.name : '';
if (qualifiedName === '' && name === '') return null;
hops.push({
dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
name: name || qualifiedName,
qualifiedName: qualifiedName || name,
kind: typeof hop.kind === 'string' ? hop.kind : '',
file: typeof hop.file === 'string' ? hop.file : '',
line: typeof hop.line === 'number' && hop.line > 0 ? Math.floor(hop.line) : 0,
id: typeof hop.id === 'string' ? hop.id : '',
});
}
const created = typeof value.createdAt === 'string' ? value.createdAt : '';
return {
version: typeof value.version === 'number' ? value.version : TRAIL_FORMAT_VERSION,
// The FILE's name wins over any `id` inside it: the basename is what the
// delete route addresses, so a hand-copied file is addressable under the
// name it actually has rather than the one it remembers having.
id,
name: value.name.slice(0, MAX_TRAIL_NAME),
note: typeof value.note === 'string' ? value.note.slice(0, MAX_TRAIL_NOTE) : '',
author: typeof value.author === 'string' ? value.author.slice(0, 120) : '',
createdAt: created,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : created,
hops,
};
}
export interface StoredTrailList {
trails: StoredTrail[];
/** Files in the directory that were not readable trails. */
skipped: number;
}
/**
* Every trail in the project, newest save first.
*
* A missing directory is the ordinary state of a project nobody has saved a
* trail in — an empty list, never an error.
*/
export function listStoredTrails(projectRoot: string): StoredTrailList {
const dir = trailsDirectory(projectRoot);
let names: string[];
try {
names = fs.readdirSync(dir);
} catch {
return { trails: [], skipped: 0 };
}
const trails: StoredTrail[] = [];
let skipped = 0;
for (const name of names.sort()) {
if (!name.endsWith('.json')) continue;
if (trails.length >= MAX_TRAILS) break;
const id = name.slice(0, -'.json'.length);
if (!isTrailId(id)) {
skipped += 1;
continue;
}
const trail = readTrailFile(path.join(dir, name), id);
if (trail) trails.push(trail);
else skipped += 1;
}
// Newest save first: a tour written a minute ago is the one being iterated on.
trails.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : a.name.localeCompare(b.name)));
return { trails, skipped };
}
function readTrailFile(absolute: string, id: string): StoredTrail | null {
try {
const stat = fs.statSync(absolute);
// A file too big to be a trail is skipped rather than read: this directory
// is inside the project, and something else may one day put a log in it.
if (!stat.isFile() || stat.size > MAX_TRAIL_FILE_BYTES) return null;
return parseTrail(id, fs.readFileSync(absolute, 'utf-8'));
} catch {
return null;
}
}
/** One trail by id, or `null` when there is no such file. */
export function readStoredTrail(projectRoot: string, id: string): StoredTrail | null {
return readTrailFile(trailPath(projectRoot, id), id);
}
/* ------------------------------------------------------------------ write -- */
/**
* Write a trail, atomically.
*
* Temp file beside the target then `rename`, so a reader either sees the
* previous trail or the new one and never a partial file. The temp name carries
* the pid: two `codegraph ui` processes on one project is unusual but not
* forbidden, and two writers sharing a temp name would corrupt each other's.
*/
export function writeStoredTrail(projectRoot: string, trail: StoredTrail): void {
const dir = trailsDirectory(projectRoot);
try {
fs.mkdirSync(dir, { recursive: true });
} catch (err) {
throw writeFailure(err);
}
const target = trailPath(projectRoot, trail.id);
const temp = `${target}.${process.pid}.tmp`;
try {
fs.writeFileSync(temp, `${JSON.stringify(trail, null, 2)}\n`, 'utf-8');
fs.renameSync(temp, target);
} catch (err) {
try {
fs.unlinkSync(temp);
} catch {
// Nothing to clean up, or nothing we can do about it. The write already
// failed; the caller is about to be told so.
}
throw writeFailure(err);
}
}
/** Remove a trail. Returns false when there was nothing there. */
export function deleteStoredTrail(projectRoot: string, id: string): boolean {
try {
fs.unlinkSync(trailPath(projectRoot, id));
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw writeFailure(err);
}
}
/**
* An id nothing in `taken` is using, preferring the plain slug.
*
* A save under a name that is already there REPLACES it — that is what a reader
* pressing Save with the same name means — so the caller passes the ids of
* trails carrying a *different* name, and this only steps aside for those.
*/
export function uniqueTrailId(base: string, taken: ReadonlySet<string>): string {
if (!taken.has(base)) return base;
for (let n = 2; n < 1000; n += 1) {
const candidate = `${base}-${n}`;
if (!taken.has(candidate)) return candidate;
}
// 999 trails sharing one slug is not a state worth a clever answer.
throw new ApiError('bad-request', `Too many saved trails are already named like "${base}".`);
}
function writeFailure(err: unknown): ApiError {
const code = (err as NodeJS.ErrnoException).code;
const detail = err instanceof Error ? err.message : String(err);
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
return new ApiError(
'refused',
`Saved trails could not be written: ${detail}`,
`The viewer writes only to ${TRAILS_RELATIVE_DIR} inside this project. Check that it is writable.`
);
}
return new ApiError('internal', `Saved trails could not be written: ${detail}`);
}
+477
View File
@@ -0,0 +1,477 @@
/**
* `GET/POST/DELETE /api/trails` — saved trails, the reader's own tours through
* the graph (design spec §3.12).
*
* A trail is the path of symbols someone walked to explain something: "how a
* request is served", "everything the token expiry touches". The viewer already
* carries one in the URL; this is the same walk given a name and kept, so the
* next person — or the same person next week — starts at the explanation rather
* than at the search box.
*
* ## The one thing this feature has to get right
*
* **A trail must survive a re-index.** A node's id contains its start line, so
* inserting an import at the top of a file renames every symbol below it. A
* trail keyed on ids would break the first time anybody edited the code it
* describes — which is exactly when it matters. So a hop is stored as what it
* *is* — qualified name, kind, file — with the id kept only as a fast path, and
* every hop is re-resolved against the current index on the way out:
*
* - the recorded id still names the same symbol → `ok`
* - the qualified name resolves somewhere else → `moved`, and the row says
* where from
* - the name is now carried by several symbols and none is in the recorded
* file → `ambiguous`, best guess offered and labelled as one
* - nothing answers to it → `missing`, and the row says "moved or renamed"
*
* Nothing is silently dropped and nothing is silently guessed: a trail that has
* decayed says so on its own row, which is the point at which its author can
* fix it.
*
* ## What it opens
*
* A trail with a hole in it cannot be handed to the viewer whole — the `t`
* param is a PATH, and stitching hop 2 to hop 4 would draw an adjacency that
* does not exist. So the payload carries the longest run of consecutive
* resolved hops, and the row says when that is less than the whole trail.
*
* Storage — the only write `codegraph ui` makes — is `./trail-store.ts`.
*/
import { execFileSync } from 'child_process';
import * as os from 'os';
import type { CodeGraph } from '../../index';
import type { Node } from '../../types';
import { ApiError, badRequest, notFound } from './respond';
import {
MAX_TRAIL_HOPS,
MAX_TRAIL_NAME,
MAX_TRAIL_NOTE,
MAX_TRAILS,
TRAILS_RELATIVE_DIR,
TRAIL_FORMAT_VERSION,
deleteStoredTrail,
listStoredTrails,
slugify,
uniqueTrailId,
writeStoredTrail,
type StoredHop,
type StoredHopDirection,
type StoredTrail,
} from './trail-store';
import { toNodeRef } from './wire';
/* ------------------------------------------------------------------ wire -- */
/** How a saved hop fared against the current index. */
export type WireTrailHopStatus = 'ok' | 'moved' | 'ambiguous' | 'missing';
export interface WireTrailHop {
dir: StoredHopDirection;
/** The name as it was when the trail was saved. */
name: string;
qualifiedName: string;
kind: string;
/** Where the symbol was when the trail was saved. */
savedFile: string;
savedLine: number;
status: WireTrailHopStatus;
/** The symbol's id NOW. Null when nothing answers to it any more. */
id: string | null;
file: string | null;
line: number | null;
/** Finished screen wording for a status that is not `ok`; null when it is. */
note: string | null;
}
export interface WireTrail {
id: string;
name: string;
note: string;
author: string;
createdAt: string;
updatedAt: string;
hops: WireTrailHop[];
/** Hops that still resolve to a symbol in this index. */
resolved: number;
/** Every hop resolved, and none of them moved. */
intact: boolean;
/**
* The longest run of CONSECUTIVE resolved hops, encoded as the `t` param.
* Null when nothing in the trail resolves. Never stitched across a hole: the
* trail is a path, and a fabricated adjacency is worse than a short one.
*/
encoded: string | null;
/** 1-based index of the first hop `encoded` carries. */
openFrom: number;
/** How many hops `encoded` carries. */
openCount: number;
/** The symbol the trail opens at — the last hop of that run. */
openId: string | null;
}
export interface WireTrails {
trails: WireTrail[];
/** Writes are off. The viewer hides Save and Delete, and says why. */
readOnly: boolean;
readOnlyReason: string | null;
/** Project-relative directory the files live in. The screen names it. */
directory: string;
/** Files in that directory that were not readable trails. */
skipped: number;
/** The list stopped at {@link MAX_TRAILS}. */
bounded: boolean;
/** The id just written, on the answer to a POST. */
saved?: string;
/** That POST replaced a trail of the same name. */
replaced?: boolean;
/** The id just removed, on the answer to a DELETE. */
deleted?: string;
}
/* -------------------------------------------------------------- resolution -- */
/**
* Re-resolve one saved hop against the index as it is now.
*
* Order matters: the recorded id first, because in the common case (nothing
* above the symbol changed) it is one lookup and exactly right. It is still
* verified against the qualified name — an id is a hash of position as well as
* identity, and a recycled one pointing at a different symbol would put a
* stranger in the middle of somebody's explanation.
*/
export function resolveHop(cg: CodeGraph, hop: StoredHop): WireTrailHop {
const base = {
dir: hop.dir,
name: hop.name,
qualifiedName: hop.qualifiedName,
kind: hop.kind,
savedFile: hop.file,
savedLine: hop.line,
};
const byId = hop.id ? cg.getNode(hop.id) : null;
if (byId && matches(byId, hop)) {
return { ...base, status: 'ok', id: byId.id, file: byId.filePath, line: byId.startLine, note: null };
}
const candidates = cg
.getNodesByQualifiedName(hop.qualifiedName)
.filter((node) => hop.kind === '' || node.kind === hop.kind);
if (candidates.length === 0) {
return {
...base,
status: 'missing',
id: null,
file: null,
line: null,
note: `no longer in the index — moved or renamed since this trail was saved`,
};
}
const sameFile = candidates.filter((node) => node.filePath === hop.file);
if (sameFile.length === 1) {
const node = sameFile[0] as Node;
return { ...base, status: 'ok', id: node.id, file: node.filePath, line: node.startLine, note: null };
}
if (candidates.length === 1) {
const node = candidates[0] as Node;
return {
...base,
status: 'moved',
id: node.id,
file: node.filePath,
line: node.startLine,
note: `moved from ${hop.file || 'an unrecorded file'} to ${node.filePath}`,
};
}
// Several symbols carry this name and none of them is where it used to be.
// The best guess is offered — a row nobody can open is not more honest, it
// is just less useful — but it is labelled as a guess.
const pick = (sameFile[0] ?? candidates[0]) as Node;
return {
...base,
status: 'ambiguous',
id: pick.id,
file: pick.filePath,
line: pick.startLine,
note: `${candidates.length} symbols now carry this name — showing the one in ${pick.filePath}`,
};
}
function matches(node: Node, hop: StoredHop): boolean {
if (hop.kind !== '' && node.kind !== hop.kind) return false;
return node.qualifiedName === hop.qualifiedName || node.name === hop.name;
}
/** The `t` param's own encoding — kept identical to `ui/src/lib/trail-codec.ts`. */
const DIR_CHAR: Record<StoredHopDirection, string> = { start: 's', down: 'd', up: 'u' };
/**
* Turn resolved hops into something the viewer can open.
*
* The longest CONSECUTIVE run, not every resolved hop: skipping a missing hop
* would encode a step from A to C that no edge supports, and the Flow strip
* reads a trail as exactly that sequence of edges. The first hop of the run is
* always written as `start`, because a run beginning mid-trail arrived from
* nothing the viewer can draw.
*/
export function encodeResolvedRun(hops: readonly WireTrailHop[]): {
encoded: string | null;
openFrom: number;
openCount: number;
openId: string | null;
} {
let bestStart = -1;
let bestLength = 0;
let start = -1;
for (let i = 0; i <= hops.length; i += 1) {
const resolved = i < hops.length && (hops[i] as WireTrailHop).id !== null;
if (resolved) {
if (start < 0) start = i;
continue;
}
if (start >= 0 && i - start > bestLength) {
bestStart = start;
bestLength = i - start;
}
start = -1;
}
if (bestLength === 0) return { encoded: null, openFrom: 0, openCount: 0, openId: null };
const run = hops.slice(bestStart, bestStart + bestLength);
const encoded = run
.map((hop, index) => `${index === 0 ? 's' : DIR_CHAR[hop.dir]}${encodeURIComponent(hop.id as string)}`)
.join(',');
return {
encoded,
openFrom: bestStart + 1,
openCount: bestLength,
openId: (run[run.length - 1] as WireTrailHop).id,
};
}
export function resolveTrail(cg: CodeGraph, stored: StoredTrail): WireTrail {
const hops = stored.hops.map((hop) => resolveHop(cg, hop));
const run = encodeResolvedRun(hops);
return {
id: stored.id,
name: stored.name,
note: stored.note,
author: stored.author,
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
hops,
resolved: hops.filter((hop) => hop.id !== null).length,
intact: hops.every((hop) => hop.status === 'ok'),
...run,
};
}
/* ------------------------------------------------------------------ read -- */
export interface TrailsOptions {
/** Writes refused, and the sentence saying why. */
readOnly: boolean;
readOnlyReason: string | null;
}
export function buildTrails(
cg: CodeGraph,
projectRoot: string,
options: TrailsOptions
): WireTrails {
const { trails, skipped } = listStoredTrails(projectRoot);
return {
trails: trails.map((stored) => resolveTrail(cg, stored)),
readOnly: options.readOnly,
readOnlyReason: options.readOnlyReason,
directory: TRAILS_RELATIVE_DIR,
skipped,
bounded: trails.length >= MAX_TRAILS,
};
}
/* ----------------------------------------------------------------- write -- */
/** What a POST body has to be. Everything else about a hop comes from the graph. */
export interface SaveTrailRequest {
name: string;
note?: string;
hops: Array<{ dir?: string; id: string }>;
}
/**
* Save a trail.
*
* The client sends ids and directions and nothing else: the name, kind, file
* and line of every hop are read out of the index here. A client that supplied
* its own metadata could save a trail describing symbols that are not in the
* graph, and the whole value of the feature is that a trail is a claim the
* index can re-check.
*
* A save under a name that already exists REPLACES that trail, keeping its
* `createdAt`. That is what pressing Save with the same name means, and the
* answer says `replaced` so the screen can too.
*/
export function saveTrail(
cg: CodeGraph,
projectRoot: string,
body: unknown,
options: TrailsOptions
): WireTrails {
if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
const request = parseSaveRequest(body);
const hops: StoredHop[] = [];
request.hops.forEach((hop, index) => {
const node = cg.getNode(hop.id);
if (!node) {
throw badRequest(
`Hop ${index + 1} is not in the index: ${hop.id}`,
'Trails are saved from symbols the index holds. Reload the page and walk the trail again.'
);
}
const ref = toNodeRef(node);
hops.push({
dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
name: ref.name,
qualifiedName: ref.qualifiedName,
kind: ref.kind,
file: ref.file,
line: ref.line,
id: ref.id,
});
});
// The first hop is where the walk began, whatever the client called it.
if (hops[0]) hops[0].dir = 'start';
const existing = listStoredTrails(projectRoot).trails;
const sameName = existing.find((trail) => trail.name === request.name);
const takenByOthers = new Set(
existing.filter((trail) => trail.name !== request.name).map((trail) => trail.id)
);
const id = sameName ? sameName.id : uniqueTrailId(slugify(request.name), takenByOthers);
const now = new Date().toISOString();
writeStoredTrail(projectRoot, {
version: TRAIL_FORMAT_VERSION,
id,
name: request.name,
note: request.note,
author: trailAuthor(projectRoot),
createdAt: sameName?.createdAt || now,
updatedAt: now,
hops,
});
return { ...buildTrails(cg, projectRoot, options), saved: id, replaced: sameName !== undefined };
}
export function removeTrail(
cg: CodeGraph,
projectRoot: string,
id: string,
options: TrailsOptions
): WireTrails {
if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
if (!deleteStoredTrail(projectRoot, id)) {
throw notFound(`There is no saved trail called "${id}".`);
}
return { ...buildTrails(cg, projectRoot, options), deleted: id };
}
function readOnlyRefusal(reason: string | null): ApiError {
return new ApiError(
'refused',
reason ?? 'This viewer is running read-only, so trails cannot be saved.',
`Restart without --read-only to let the viewer write trails into ${TRAILS_RELATIVE_DIR}.`
);
}
function parseSaveRequest(body: unknown): { name: string; note: string; hops: SaveTrailRequest['hops'] } {
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
throw badRequest('A trail is saved from a JSON object: { name, hops }.');
}
const value = body as Record<string, unknown>;
const name = typeof value.name === 'string' ? value.name.trim().replace(/\s+/g, ' ') : '';
if (name === '') throw badRequest('A saved trail needs a name.');
if (name.length > MAX_TRAIL_NAME) {
throw badRequest(`That name is too long (max ${MAX_TRAIL_NAME} characters).`);
}
const note = typeof value.note === 'string' ? value.note.trim() : '';
if (note.length > MAX_TRAIL_NOTE) {
throw badRequest(`That note is too long (max ${MAX_TRAIL_NOTE} characters).`);
}
if (!Array.isArray(value.hops) || value.hops.length === 0) {
throw badRequest('A saved trail needs at least one hop.');
}
if (value.hops.length > MAX_TRAIL_HOPS) {
throw badRequest(`A saved trail can hold at most ${MAX_TRAIL_HOPS} hops.`);
}
const hops: SaveTrailRequest['hops'] = [];
for (const entry of value.hops) {
if (typeof entry !== 'object' || entry === null) throw badRequest('Each hop is { dir, id }.');
const hop = entry as Record<string, unknown>;
if (typeof hop.id !== 'string' || hop.id === '') throw badRequest('Each hop needs an id.');
hops.push({ id: hop.id, ...(typeof hop.dir === 'string' ? { dir: hop.dir } : {}) });
}
return { name, note, hops };
}
/* ---------------------------------------------------------------- author -- */
/**
* Who to record as the author.
*
* Git's `user.name` first, because a trail is a thing one person wrote for
* others to read and that is the name they already sign work with in this
* project; the OS user is the fallback. Read ONCE per process — `git config` is
* a subprocess, and a save should not pay for it twice — and never sent
* anywhere: it goes into a file inside the user's own `.codegraph/`.
*/
let cachedAuthor: string | null = null;
export function trailAuthor(projectRoot: string): string {
if (cachedAuthor !== null) return cachedAuthor;
cachedAuthor = gitUserName(projectRoot) ?? osUserName() ?? '';
return cachedAuthor;
}
/** Test seam: forget the cached author. */
export function resetTrailAuthor(): void {
cachedAuthor = null;
}
function gitUserName(projectRoot: string): string | null {
try {
const out = execFileSync('git', ['config', 'user.name'], {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 2_000,
stdio: ['ignore', 'pipe', 'ignore'],
});
const name = out.trim();
return name === '' ? null : name.slice(0, 120);
} catch {
// No git, no config, not a repository — all ordinary. Fall through.
return null;
}
}
function osUserName(): string | null {
try {
const name = os.userInfo().username.trim();
return name === '' ? null : name.slice(0, 120);
} catch {
return null;
}
}