Files
codegraph/src/ui-server/api/trail-store.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

333 lines
12 KiB
TypeScript

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