Files
codegraph/src/ui-server/api/trails.ts
T
Colby McHenryandClaude Opus 5 47576b392e 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>
2026-08-27 08:31:48 -05:00

478 lines
16 KiB
TypeScript

/**
* `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;
}
}