From 47576b392ed31912cfbec5742e094daf8031ed32 Mon Sep 17 00:00:00 2001
From: Colby McHenry
Date: Thu, 27 Aug 2026 08:31:48 -0500
Subject: [PATCH] =?UTF-8?q?feat(ui):=20saved=20trails=20=E2=80=94=20a=20wa?=
=?UTF-8?q?lk=20you=20named,=20kept,=20and=20still=20true=20after=20a=20re?=
=?UTF-8?q?-index=20(CG-60)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
CHANGELOG.md | 8 +-
README.md | 12 +-
__tests__/ui-package.test.ts | 40 +-
__tests__/ui-server-api.test.ts | 5 +-
__tests__/ui-server.test.ts | 36 +-
__tests__/ui-trails-model.test.ts | 179 ++++++++
__tests__/ui-trails.test.ts | 562 ++++++++++++++++++++++++
docs/design/codegraph-ui-design-spec.md | 41 ++
scripts/check-ui-package.mjs | 1 +
site/src/content/docs/guides/viewer.md | 23 +-
site/src/content/docs/reference/cli.md | 3 +-
src/bin/codegraph.ts | 46 +-
src/index.ts | 15 +
src/ui-server/api/index.ts | 136 +++++-
src/ui-server/api/respond.ts | 50 ++-
src/ui-server/api/trail-store.ts | 332 ++++++++++++++
src/ui-server/api/trails.ts | 477 ++++++++++++++++++++
src/ui-server/index.ts | 56 ++-
src/ui-server/security.ts | 68 ++-
ui/README.md | 26 +-
ui/src/App.svelte | 5 +
ui/src/app.css | 8 +-
ui/src/components/SavedTrails.svelte | 305 +++++++++++++
ui/src/components/TrailBar.svelte | 180 +++++++-
ui/src/index.ts | 20 +-
ui/src/lib/adapter.ts | 91 +++-
ui/src/lib/api.ts | 51 ++-
ui/src/lib/trails-model.ts | 202 +++++++++
ui/src/lib/trails.svelte.ts | 164 +++++++
ui/src/lib/wire.ts | 72 +++
ui/src/views/EntryView.svelte | 13 +
ui/src/views/HomeView.svelte | 13 +
32 files changed, 3162 insertions(+), 78 deletions(-)
create mode 100644 __tests__/ui-trails-model.test.ts
create mode 100644 __tests__/ui-trails.test.ts
create mode 100644 src/ui-server/api/trail-store.ts
create mode 100644 src/ui-server/api/trails.ts
create mode 100644 ui/src/components/SavedTrails.svelte
create mode 100644 ui/src/lib/trails-model.ts
create mode 100644 ui/src/lib/trails.svelte.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cce5174..78d87e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,7 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Run `codegraph ui` in an indexed project, or `codegraph ui /path/to/project` for one indexed elsewhere (`codegraph web` is an alias). It takes port 4747, or the next free one; `--port ` pins a specific port and `--no-open` just prints the URL for a headless box or an SSH session. Set `CODEGRAPH_BROWSER=` to choose the browser, or `CODEGRAPH_BROWSER=none` to never open one.
- The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It is read-only: it opens an index that already exists, never creates one, and never writes to your project or your graph. It sends nothing anywhere.
+ The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It opens an index that already exists, never creates one, and never changes your graph or a line of your code — the one thing it writes is a trail you asked it to save (see below), and `--read-only` turns even that off. It sends nothing anywhere.
- **A map of the whole project, in `codegraph ui`.** The Map tab draws your repository at module granularity — one box per directory — with dependencies pointing down, so the top of the picture is what runs first and the bottom is what everything else stands on. Nothing is placed by hand and nothing floats: a module sits one layer above whatever it depends on, line weight is how many calls, imports and type references cross the link, and the same project always draws the same picture. Hover a link for what crosses it, including the busiest symbol pairs behind the weight; click a module to isolate its links, list its dependencies and dependents with counts, and jump straight into one of its files.
@@ -80,6 +80,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
On the **Map**, a module nothing depends on now says so in its own count line instead of counting itself — usually your entry points, sometimes something you forgot to delete. Tool-generated files and modules are dimmed wherever they appear: on the map, in its file list, in search results and on the file screen.
+- **Keep a walk you want to come back to: saved trails in `codegraph ui`.** Follow a path through the code, press **Save trail** on the trail bar, give it a name, and it's kept. Saved trails are listed on the empty screen and on the Entry points tab, above the suggestions — a walk somebody named beats any ranking — and opening one puts you back at the symbol you left with the whole path in the trail bar. Explaining "how a request is served" to a new teammate is now a link and a name rather than a paragraph.
+
+ A saved trail survives your project changing. Each step is remembered by what it is — its qualified name, its kind, the file it was in — rather than by where it sat, so editing the file above a function doesn't lose it. When something does move, the trail says so on its own row: which step moved to another file, which one was renamed or deleted, and which part of the walk still opens. It never quietly stitches over a missing step, because a trail is a path and a step that skips one would show a call that doesn't exist.
+
+ Trails are plain JSON, one file per trail, under `.codegraph/ui/trails/` — already ignored by git, so they stay yours by default. **Export** hands you the file if you'd rather commit one for the team. This is the only thing the viewer writes: it still never indexes, never changes your graph, and never touches a line of your code. Start it with `codegraph ui --read-only` and it won't write even that — saved trails can still be opened, just not saved or deleted.
+
### Fixes
- Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.
diff --git a/README.md b/README.md
index b5f62b0..88f094e 100644
--- a/README.md
+++ b/README.md
@@ -351,6 +351,7 @@ What you get on that screen:
- **And when the path runs out, it says where.** A flow that doesn't get there ends in "Where the graph stops": the kind of dispatch that ended it (a computed member call, a `getattr`, a reflective invoke, a message bus), its line, the key when the source spells one out, and a shortlist of what could be on the other side — plus the name-only matches CodeGraph refused to follow, with their confidence. Nothing is guessed, and a flow that does connect never shows it.
- **The map**: the whole project at module granularity, laid out from the graph with dependencies pointing down — never drawn by hand, and the same picture every time. Cycles are listed rather than straightened away.
- **Take the picture with you.** A flow strip or a map can be copied as an image straight into a pull-request comment, or saved as an SVG for a README — always in the light theme, whichever one you are reading in, with a caption saying what the picture is. The SVG is real text, so it stays sharp at any size and the names in it are selectable.
+- **Keep a walk.** Press **Save trail** on the trail bar, name it, and the path is kept — listed on the empty screen and on Entry points, above the suggestions, and reopened at the symbol you left with the whole walk restored. Steps are remembered by what they are, not where they sat, so a saved trail survives editing the code it describes; when something does move it says which step moved, which was renamed away, and how much of the walk still opens. Trails are plain JSON under `.codegraph/ui/trails/` (git already ignores it), and **Export** hands you the file if you would rather commit one.
- **It keeps up.** Save a file and a banner appears within about a third of a second saying the index hasn't caught up yet — and the screen switches to the file's current source rather than a body sliced at lines it no longer has. When something re-indexes, whatever is on screen refetches itself and says "Index updated · reloaded". A symbol that moved because you added a line above it is followed, not lost. Nothing polls: the viewer watches, and if it loses touch with the server it retries a few times and then says so instead of hammering it.
Options: `--port ` to pin a port (without it the viewer takes 4747, or the next free one),
@@ -359,10 +360,11 @@ Options: `--port ` to pin a port (without it the viewer takes 4747, or the ne
`codegraph web` is an alias for the same command.
**Privacy:** the viewer listens on `127.0.0.1` only, so nothing on your network can reach it,
-and requests claiming to come from any other host are refused. It is read-only — it opens an
-index that already exists, never writes to your project or your graph, and never creates an
-index. **It sends nothing anywhere**: no code, no paths, no analytics. There is no account and
-no cloud in this feature at all.
+and requests claiming to come from any other host are refused. It opens an index that already
+exists, never creates one, and never changes your graph or a line of your code. The one thing
+it writes is a trail you asked it to save, into `.codegraph/ui/trails/`; `codegraph ui
+--read-only` refuses even that. **It sends nothing anywhere**: no code, no paths, no analytics.
+There is no account and no cloud in this feature at all.
The viewer reads an index that already exists — it never creates one — so `codegraph init` has
to have run first. `codegraph ui /path/to/project` points it at a project you indexed elsewhere.
@@ -573,7 +575,7 @@ codegraph uninit [path] # Remove CodeGraph from a project (--force to
codegraph index [path] # Full index (--force to re-index, --quiet for less output)
codegraph sync [path] # Incremental update
codegraph status [path] # Show statistics
-codegraph ui [path] # Open the browser viewer for an indexed project (alias: web; --port, --no-open)
+codegraph ui [path] # Open the browser viewer for an indexed project (alias: web; --port, --no-open, --read-only)
codegraph unlock [path] # Remove a stale lock file that's blocking indexing
codegraph query # Search symbols (--kind, --limit, --json)
codegraph explore # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)
diff --git a/__tests__/ui-package.test.ts b/__tests__/ui-package.test.ts
index abd90c5..05fadaa 100644
--- a/__tests__/ui-package.test.ts
+++ b/__tests__/ui-package.test.ts
@@ -28,6 +28,7 @@ import {
FlowStrip,
SearchPalette,
SymbolView,
+ SavedTrails,
TrailBar,
TypeHierarchy,
createHttpAdapter,
@@ -406,8 +407,22 @@ function mockAdapter(): { adapter: GraphAdapter; calls: string[] } {
corroborated: true,
timing: { elapsedMs: 1 },
}),
- // Deliberately no `events`: a host without a live channel is the normal
- // case, and nothing may poll in its absence.
+ trails: () =>
+ seen('trails', {
+ trails: [],
+ // A host with nowhere to keep trails still ANSWERS the question — it
+ // says it is read-only rather than omitting the method, so the screens
+ // show the section explained instead of showing a Save that does
+ // nothing.
+ readOnly: true,
+ readOnlyReason: 'This host does not store trails.',
+ directory: '.codegraph/ui/trails',
+ skipped: 0,
+ bounded: false,
+ }),
+ // Deliberately no `events`, `saveTrail` or `deleteTrail`: a host without a
+ // live channel and without anywhere to write is the normal case, and
+ // nothing may poll or offer to save in their absence.
};
return { adapter, calls };
}
@@ -601,6 +616,27 @@ describe('@colbymchenry/codegraph-ui — a host renders the package', () => {
expect(host.querySelector('input[role="combobox"]')).not.toBeNull();
});
+ it('offers no Save when the adapter cannot write, and says why in the list', async () => {
+ const { adapter } = mockAdapter();
+ setGraphAdapter(adapter);
+
+ trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' });
+ await render(TrailBar, {});
+ // The one screen affordance that must never appear against a read-only
+ // host: an adapter with no `saveTrail` has no button, not a button that
+ // fails.
+ expect(host.textContent ?? '').not.toContain('Save trail');
+
+ void unmount(mounted as Record);
+ mounted = null;
+ host.innerHTML = '';
+
+ await render(SavedTrails, { hideWhenEmpty: false });
+ const text = host.textContent ?? '';
+ expect(text).toContain('Saved trails');
+ expect(text).toContain('This host does not store trails.');
+ });
+
it('CodegraphUi installs the adapter before its children ask for data', async () => {
const { adapter, calls } = mockAdapter();
// NOT installed by hand — the provider is the only thing that installs it.
diff --git a/__tests__/ui-server-api.test.ts b/__tests__/ui-server-api.test.ts
index 732c9d8..5128872 100644
--- a/__tests__/ui-server-api.test.ts
+++ b/__tests__/ui-server-api.test.ts
@@ -258,7 +258,10 @@ afterAll(async () => {
describe('GET /api', () => {
it('lists the endpoints it answers', async () => {
const body = await getJson('/api');
- expect(body.readOnly).toBe(true);
+ // Not a blanket claim any more (CG-60): saved trails are the one thing
+ // this server writes, and it names it rather than implying there is none.
+ expect(body.readOnly).toBe(false);
+ expect(body.writes).toEqual(['POST /api/trails', 'DELETE /api/trails/']);
const paths = body.endpoints.map((e: any) => e.path);
expect(paths).toEqual(
expect.arrayContaining([
diff --git a/__tests__/ui-server.test.ts b/__tests__/ui-server.test.ts
index 8f07592..df25858 100644
--- a/__tests__/ui-server.test.ts
+++ b/__tests__/ui-server.test.ts
@@ -266,14 +266,44 @@ describe('codegraph ui server', () => {
});
});
- describe('read-only', () => {
- it('refuses every method that is not GET or HEAD', async () => {
- for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
+ describe('methods', () => {
+ it('refuses every method it has never answered', async () => {
+ for (const method of ['PUT', 'PATCH', 'OPTIONS', 'TRACE']) {
const res = await request(server.port, '/', { method });
expect(res.status, method).toBe(405);
+ expect(res.headers['allow']).toBe('GET, HEAD, POST, DELETE');
+ }
+ });
+
+ /**
+ * The static side stayed a pure reader when `/api/trails` gained a write
+ * (CG-60). A POST at an asset path is 405 with `Allow: GET, HEAD` — the
+ * narrower answer, since nothing under the viewer bundle will ever take
+ * one.
+ */
+ it('refuses a write outside /api/, whatever it carries', async () => {
+ for (const method of ['POST', 'DELETE']) {
+ const res = await request(server.port, '/', {
+ method,
+ headers: { 'X-CodeGraph-UI': '1' },
+ });
+ expect(res.status, method).toBe(405);
expect(res.headers['allow']).toBe('GET, HEAD');
}
});
+
+ /**
+ * Under `/api/` a write is answered as JSON even when refused — the viewer
+ * parses these, and a text/plain body surfaces as a parse error rather than
+ * the refusal it is. No API is mounted on this server, so the refusal is
+ * the boundary's own and not an endpoint's.
+ */
+ it('refuses an unmarked write under /api/ as JSON', async () => {
+ const res = await request(server.port, '/api/trails', { method: 'POST' });
+ expect(res.status).toBe(403);
+ expect(res.headers['content-type']).toContain('application/json');
+ expect(JSON.parse(res.body).code).toBe('refused');
+ });
});
describe('paths outside the asset root', () => {
diff --git a/__tests__/ui-trails-model.test.ts b/__tests__/ui-trails-model.test.ts
new file mode 100644
index 0000000..7517a52
--- /dev/null
+++ b/__tests__/ui-trails-model.test.ts
@@ -0,0 +1,179 @@
+/**
+ * What a saved trail's row says, without a browser (CG-60).
+ *
+ * The endpoint's own behaviour is pinned in `ui-trails.test.ts` against a real
+ * index; this is the wording layer, and the rule it exists to protect is that
+ * **a trail that has decayed never reads as intact**. A saved trail is somebody's
+ * explanation of a codebase that has since moved underneath it, and a row that
+ * prints "6 hops" while two of them are gone is a lie by omission at exactly the
+ * moment the trail needs fixing.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ hopStatusWord,
+ isOpenable,
+ replacedTrail,
+ trailDecay,
+ trailExport,
+ trailMeta,
+ trailNameProblem,
+ trailOpens,
+ trailTitle,
+} from '../ui/src/lib/trails-model';
+import type { WireTrail, WireTrailHop, WireTrailHopStatus } from '../ui/src/lib/wire';
+
+function hop(
+ name: string,
+ status: WireTrailHopStatus = 'ok',
+ dir: WireTrailHop['dir'] = 'down'
+): WireTrailHop {
+ const alive = status !== 'missing';
+ return {
+ dir,
+ name,
+ qualifiedName: name,
+ kind: 'function',
+ savedFile: 'src/a.ts',
+ savedLine: 10,
+ status,
+ id: alive ? `function:${name}` : null,
+ file: alive ? 'src/a.ts' : null,
+ line: alive ? 10 : null,
+ note: status === 'ok' ? null : `${name} ${status}`,
+ };
+}
+
+function trail(hops: WireTrailHop[], over: Partial = {}): WireTrail {
+ const resolved = hops.filter((h) => h.id !== null);
+ return {
+ id: 'a-walk',
+ name: 'A walk',
+ note: '',
+ author: 'Ada',
+ createdAt: '2026-08-01T00:00:00.000Z',
+ updatedAt: '2026-08-02T00:00:00.000Z',
+ hops,
+ resolved: resolved.length,
+ intact: hops.every((h) => h.status === 'ok'),
+ encoded: resolved.length > 0 ? resolved.map((h) => `d${h.id}`).join(',') : null,
+ openFrom: 1,
+ openCount: resolved.length,
+ openId: resolved.length > 0 ? (resolved[resolved.length - 1] as WireTrailHop).id : null,
+ ...over,
+ };
+}
+
+describe('trailMeta', () => {
+ it('reports the SAVED length, whatever became of the hops', () => {
+ const decayed = trail([hop('a', 'ok', 'start'), hop('b', 'missing'), hop('c')]);
+ expect(trailMeta(decayed)).toBe('3 hops · Ada');
+ });
+
+ it('drops the author when there is not one', () => {
+ expect(trailMeta(trail([hop('a', 'ok', 'start')], { author: '' }))).toBe('1 hop');
+ });
+});
+
+describe('trailDecay', () => {
+ it('is null for a trail nothing has happened to', () => {
+ expect(trailDecay(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull();
+ });
+
+ it('warns about hops that are gone, naming them', () => {
+ const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('gone', 'missing')]));
+ expect(decay?.tone).toBe('warn');
+ expect(decay?.text).toContain('1 hop moved or renamed');
+ expect(decay?.text).toContain('gone');
+ });
+
+ it('caps how many it names', () => {
+ const hops = ['a', 'b', 'c', 'd', 'e'].map((n) => hop(n, 'missing'));
+ const decay = trailDecay(trail(hops));
+ expect(decay?.text).toContain('and 2 more');
+ });
+
+ it('notes a move without warning about it — a moved hop still opens', () => {
+ const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'moved')]));
+ expect(decay?.tone).toBe('note');
+ expect(decay?.text).toContain('moved to another file');
+ });
+
+ it('puts a missing hop ahead of a merely moved one', () => {
+ const decay = trailDecay(trail([hop('m', 'moved'), hop('g', 'missing')]));
+ expect(decay?.text).toContain('moved or renamed');
+ });
+
+ it('warns about an ambiguous hop — the trail may no longer mean what it said', () => {
+ const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'ambiguous')]));
+ expect(decay?.tone).toBe('warn');
+ expect(decay?.text).toContain('more than one symbol');
+ });
+});
+
+describe('trailOpens', () => {
+ it('says nothing when the whole trail opens', () => {
+ expect(trailOpens(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull();
+ });
+
+ it('names the range when only part of it does', () => {
+ const partial = trail([hop('a'), hop('b'), hop('c')], {
+ openFrom: 2,
+ openCount: 2,
+ });
+ expect(trailOpens(partial)).toBe('Opens hops 2–3 of 3.');
+ });
+
+ it('says so plainly when nothing resolves', () => {
+ const dead = trail([hop('a', 'missing')], { encoded: null, openCount: 0, openId: null });
+ expect(trailOpens(dead)).toContain('None of this trail resolves');
+ expect(isOpenable(dead)).toBe(false);
+ });
+});
+
+describe('trailTitle', () => {
+ it('draws the whole walk with its arrows, and when it was saved', () => {
+ const walked = trail([hop('a', 'ok', 'start'), hop('b', 'ok', 'down'), hop('c', 'ok', 'up')]);
+ expect(trailTitle(walked)).toBe('a → b ← c — saved 2026-08-02');
+ });
+});
+
+describe('saving', () => {
+ it('refuses an empty or over-long name before the round-trip', () => {
+ expect(trailNameProblem(' ', 120)).toContain('name');
+ expect(trailNameProblem('x'.repeat(121), 120)).toContain('too long');
+ expect(trailNameProblem('ok', 120)).toBeNull();
+ });
+
+ it('spots the trail a name would replace, whitespace and all', () => {
+ const list = [trail([hop('a', 'ok', 'start')], { name: 'A walk' })];
+ expect(replacedTrail(' A walk ', list)?.name).toBe('A walk');
+ expect(replacedTrail('Another walk', list)).toBeNull();
+ });
+});
+
+describe('trailExport', () => {
+ it('exports the SAVED identity of each hop, not today’s resolution', () => {
+ const moved = trail([hop('a', 'ok', 'start'), hop('b', 'moved')]);
+ const raw = JSON.parse(trailExport(moved));
+ expect(raw.version).toBe(1);
+ // `savedFile`, so dropping the file into another checkout re-runs the same
+ // resolution rather than baking this index's answer in.
+ expect(raw.hops[1].file).toBe('src/a.ts');
+ expect(raw.hops[1].qualifiedName).toBe('b');
+ expect(raw.hops.map((h: { dir: string }) => h.dir)).toEqual(['start', 'down']);
+ });
+
+ it('survives a hop with no id at all', () => {
+ const raw = JSON.parse(trailExport(trail([hop('gone', 'missing')])));
+ expect(raw.hops[0].id).toBe('');
+ });
+});
+
+describe('hopStatusWord', () => {
+ it('has a word for every status', () => {
+ for (const status of ['ok', 'moved', 'ambiguous', 'missing'] as const) {
+ expect(hopStatusWord(status)).toBeTruthy();
+ }
+ });
+});
diff --git a/__tests__/ui-trails.test.ts b/__tests__/ui-trails.test.ts
new file mode 100644
index 0000000..a669cb1
--- /dev/null
+++ b/__tests__/ui-trails.test.ts
@@ -0,0 +1,562 @@
+/**
+ * Saved trails (CG-60) — the viewer's only write.
+ *
+ * Two things are worth a real end-to-end fixture rather than a unit test, and
+ * they are the two the feature exists for:
+ *
+ * 1. **A trail survives a re-index.** The suite indexes a project, saves a
+ * trail, then EDITS the files so every node id changes (a symbol shifts down
+ * a file, another moves to a different file, a third is deleted), re-indexes,
+ * and asserts the trail still opens and says what became of each hop. That
+ * cannot be faked: node ids contain a start line, so the ids really do all
+ * change.
+ * 2. **The write boundary.** `POST` without the marker header, from a foreign
+ * `Origin`, or against a `--read-only` server has to be refused — by a real
+ * loopback server, because the refusals live in the request handler and not
+ * in the endpoint.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+import {
+ encodeResolvedRun,
+ isTrailId,
+ parseTrail,
+ slugify,
+ TRAILS_RELATIVE_DIR,
+ type WireTrailHop,
+} from '../src/ui-server/api';
+
+interface Res {
+ status: number;
+ headers: http.IncomingHttpHeaders;
+ body: any;
+}
+
+let tempDir: string;
+let projectRoot: string;
+let viewerDir: string;
+let api: GraphApi;
+let server: UiServerHandle;
+let readOnlyApi: GraphApi;
+let readOnlyServer: UiServerHandle;
+
+interface CallOptions {
+ method?: string;
+ body?: unknown;
+ /** Send the write marker header. On by default for a write. */
+ marker?: boolean;
+ contentType?: string | null;
+ origin?: string;
+}
+
+/**
+ * One request against a live server.
+ *
+ * `http.request` rather than `fetch` so `Host` is ours to set — undici treats
+ * it as a forbidden header, and the `Host` allowlist is half of what is being
+ * tested here.
+ */
+function callOn(port: number, requestPath: string, opts: CallOptions = {}): Promise {
+ const method = opts.method ?? 'GET';
+ const isWrite = method === 'POST' || method === 'DELETE';
+ const payload = opts.body === undefined ? null : Buffer.from(JSON.stringify(opts.body), 'utf-8');
+ const headers: Record = { Host: `127.0.0.1:${port}` };
+ if (isWrite && (opts.marker ?? true)) headers['X-CodeGraph-UI'] = '1';
+ if (opts.origin) headers['Origin'] = opts.origin;
+ if (payload) {
+ const type = opts.contentType === undefined ? 'application/json' : opts.contentType;
+ if (type !== null) headers['Content-Type'] = type;
+ headers['Content-Length'] = String(payload.length);
+ }
+
+ return new Promise((resolve, reject) => {
+ const req = http.request(
+ { host: '127.0.0.1', port, path: requestPath, method, headers, setHost: false },
+ (res) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (c: Buffer) => chunks.push(c));
+ res.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf-8');
+ let parsed: unknown = text;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ /* a text/plain refusal is a legitimate answer on the static side */
+ }
+ resolve({ status: res.statusCode ?? 0, headers: res.headers, body: parsed });
+ });
+ }
+ );
+ req.on('error', reject);
+ if (payload) req.write(payload);
+ req.end();
+ });
+}
+
+function call(requestPath: string, opts: CallOptions = {}): Promise {
+ return callOn(server.port, requestPath, opts);
+}
+
+/** The id of a fixture symbol, looked up through the API itself. */
+async function idOf(name: string): Promise {
+ const res = await call(`/api/search?q=${encodeURIComponent(name)}`);
+ const hit = res.body.results.items.find((r: any) => r.name === name);
+ expect(hit, `no symbol named ${name}`).toBeTruthy();
+ return hit.id as string;
+}
+
+function trailsDir(): string {
+ return path.join(projectRoot, TRAILS_RELATIVE_DIR);
+}
+
+/** Re-index in place, the way a `codegraph sync` would after an edit. */
+async function reindex(): Promise {
+ const cg = CodeGraph.openSync(projectRoot);
+ await cg.sync();
+ cg.resolveReferences();
+ cg.close();
+}
+
+const SRC = () => path.join(projectRoot, 'src');
+
+beforeAll(async () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-trails-'));
+ projectRoot = path.join(tempDir, 'project');
+ fs.mkdirSync(SRC(), { recursive: true });
+
+ fs.writeFileSync(
+ path.join(SRC(), 'handler.ts'),
+ `import { load } from './service';
+
+export function handleRequest(key: string): string {
+ return load(key);
+}
+`
+ );
+ fs.writeFileSync(
+ path.join(SRC(), 'service.ts'),
+ `import { read } from './cache';
+
+export function load(key: string): string {
+ return read(key);
+}
+
+export function retired(): string {
+ return 'nothing calls me after the edit';
+}
+`
+ );
+ fs.writeFileSync(
+ path.join(SRC(), 'cache.ts'),
+ `export function read(key: string): string {
+ return key;
+}
+`
+ );
+
+ const cg = CodeGraph.initSync(projectRoot, {
+ config: { include: ['src/**/*.ts'], exclude: [] },
+ });
+ await cg.indexAll();
+ cg.resolveReferences();
+ cg.close();
+
+ viewerDir = path.join(tempDir, 'viewer');
+ fs.mkdirSync(viewerDir, { recursive: true });
+ fs.writeFileSync(path.join(viewerDir, 'index.html'), '');
+
+ api = createGraphApi({ projectRoot });
+ server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+
+ readOnlyApi = createGraphApi({
+ projectRoot,
+ readOnly: true,
+ readOnlyReason: 'This viewer was started with --read-only, so trails cannot be saved.',
+ });
+ readOnlyServer = await startUiServer({
+ projectRoot,
+ viewerDir,
+ port: 0,
+ api: readOnlyApi.handler,
+ });
+}, 120_000);
+
+afterAll(async () => {
+ api?.close();
+ readOnlyApi?.close();
+ await server?.close();
+ await readOnlyServer?.close();
+ if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+/* ------------------------------------------------------------- pure bits -- */
+
+describe('trail ids', () => {
+ it('slugs a name into something that is a filename and not a path', () => {
+ expect(slugify('How a request reaches the handler')).toBe(
+ 'how-a-request-reaches-the-handler'
+ );
+ expect(slugify(' Spaces and --- dashes ')).toBe('spaces-and-dashes');
+ expect(slugify('../../etc/passwd')).toBe('etc-passwd');
+ // A name with no ASCII word characters still has to produce a valid id.
+ expect(slugify('日本語')).toBe('trail');
+ expect(isTrailId(slugify('../../etc/passwd'))).toBe(true);
+ });
+
+ it('refuses anything that is not a slug', () => {
+ for (const bad of ['..', 'a/b', 'A', 'has.dot', '-leading', '', 'a b']) {
+ expect(isTrailId(bad), bad).toBe(false);
+ }
+ });
+});
+
+describe('parseTrail', () => {
+ it('rejects a file that is not a trail rather than half-reading it', () => {
+ expect(parseTrail('x', 'not json')).toBeNull();
+ expect(parseTrail('x', '[]')).toBeNull();
+ expect(parseTrail('x', '{"name":"a"}')).toBeNull();
+ expect(parseTrail('x', '{"name":"a","hops":[]}')).toBeNull();
+ expect(parseTrail('x', '{"name":"","hops":[{"qualifiedName":"a"}]}')).toBeNull();
+ });
+
+ it('takes its id from the FILE, not from the field inside it', () => {
+ const trail = parseTrail('on-disk', '{"name":"a","id":"remembered","hops":[{"name":"f"}]}');
+ expect(trail?.id).toBe('on-disk');
+ });
+});
+
+describe('encodeResolvedRun', () => {
+ const hop = (id: string | null, dir: 'start' | 'down' | 'up' = 'down'): WireTrailHop => ({
+ dir,
+ name: id ?? 'gone',
+ qualifiedName: id ?? 'gone',
+ kind: 'function',
+ savedFile: 'src/a.ts',
+ savedLine: 1,
+ status: id ? 'ok' : 'missing',
+ id,
+ file: id ? 'src/a.ts' : null,
+ line: id ? 1 : null,
+ note: null,
+ });
+
+ it('never stitches across a hole — it takes the longest consecutive run', () => {
+ const run = encodeResolvedRun([hop('a', 'start'), hop(null), hop('c'), hop('d')]);
+ expect(run.encoded).toBe('sc,dd');
+ expect(run.openFrom).toBe(3);
+ expect(run.openCount).toBe(2);
+ expect(run.openId).toBe('d');
+ });
+
+ it('writes the run’s first hop as a start, whatever it was saved as', () => {
+ const run = encodeResolvedRun([hop(null), hop('b', 'up')]);
+ expect(run.encoded).toBe('sb');
+ });
+
+ it('answers nothing when nothing resolves', () => {
+ expect(encodeResolvedRun([hop(null), hop(null)])).toEqual({
+ encoded: null,
+ openFrom: 0,
+ openCount: 0,
+ openId: null,
+ });
+ });
+});
+
+/* ----------------------------------------------------------- the endpoint -- */
+
+describe('GET /api/trails', () => {
+ it('is an empty list, not an error, before anything is saved', async () => {
+ const res = await call('/api/trails');
+ expect(res.status).toBe(200);
+ expect(res.body.trails).toEqual([]);
+ expect(res.body.readOnly).toBe(false);
+ expect(res.body.directory).toBe(TRAILS_RELATIVE_DIR);
+ });
+
+ it('is listed by GET /api', async () => {
+ const res = await call('/api');
+ expect(res.body.endpoints.some((e: any) => e.path === '/api/trails')).toBe(true);
+ // The old blanket claim is gone: the server writes exactly one thing.
+ expect(res.body.readOnly).toBe(false);
+ expect(res.body.writes).toContain('POST /api/trails');
+ });
+});
+
+describe('POST /api/trails', () => {
+ it('saves the walk and answers with the whole list', async () => {
+ const hops = [
+ { dir: 'start', id: await idOf('handleRequest') },
+ { dir: 'down', id: await idOf('load') },
+ { dir: 'down', id: await idOf('read') },
+ ];
+ const res = await call('/api/trails', {
+ method: 'POST',
+ body: { name: 'How a request is served', note: 'the whole path', hops },
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.saved).toBe('how-a-request-is-served');
+ expect(res.body.replaced).toBe(false);
+ expect(res.body.trails).toHaveLength(1);
+
+ const trail = res.body.trails[0];
+ expect(trail.name).toBe('How a request is served');
+ expect(trail.note).toBe('the whole path');
+ expect(trail.intact).toBe(true);
+ expect(trail.resolved).toBe(3);
+ expect(trail.openCount).toBe(3);
+ expect(trail.hops.map((h: any) => h.name)).toEqual(['handleRequest', 'load', 'read']);
+ // The identity that survives an edit, recorded beside the id hint.
+ expect(trail.hops[1].qualifiedName).toBe('load');
+ expect(trail.hops[1].savedFile).toBe('src/service.ts');
+ });
+
+ it('writes one readable JSON file into .codegraph/ui/trails', () => {
+ const file = path.join(trailsDir(), 'how-a-request-is-served.json');
+ expect(fs.existsSync(file)).toBe(true);
+ const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
+ expect(raw.version).toBe(1);
+ expect(raw.hops).toHaveLength(3);
+ expect(raw.hops[0].qualifiedName).toBe('handleRequest');
+ expect(typeof raw.createdAt).toBe('string');
+ // Nothing but trails lands there — no temp file survives the rename.
+ expect(fs.readdirSync(trailsDir())).toEqual(['how-a-request-is-served.json']);
+ });
+
+ it('replaces a trail saved under the same name, keeping its createdAt', async () => {
+ const before = (await call('/api/trails')).body.trails[0];
+ const res = await call('/api/trails', {
+ method: 'POST',
+ body: {
+ name: 'How a request is served',
+ hops: [{ dir: 'start', id: await idOf('handleRequest') }],
+ },
+ });
+ expect(res.body.replaced).toBe(true);
+ expect(res.body.trails).toHaveLength(1);
+ expect(res.body.trails[0].createdAt).toBe(before.createdAt);
+ expect(res.body.trails[0].hops).toHaveLength(1);
+ expect(res.body.trails[0].note).toBe('');
+ });
+
+ it('gives a different name its own file rather than colliding', async () => {
+ const res = await call('/api/trails', {
+ method: 'POST',
+ body: { name: 'How a request is served!', hops: [{ dir: 'start', id: await idOf('load') }] },
+ });
+ expect(res.body.saved).toBe('how-a-request-is-served-2');
+ expect(res.body.trails).toHaveLength(2);
+ });
+
+ it('refuses a hop the index does not hold', async () => {
+ const res = await call('/api/trails', {
+ method: 'POST',
+ body: { name: 'invented', hops: [{ dir: 'start', id: 'function:not-a-real-id' }] },
+ });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toContain('Hop 1 is not in the index');
+ });
+
+ it('refuses a nameless or hopless trail', async () => {
+ const noName = await call('/api/trails', { method: 'POST', body: { name: ' ', hops: [] } });
+ expect(noName.status).toBe(400);
+ const noHops = await call('/api/trails', { method: 'POST', body: { name: 'x', hops: [] } });
+ expect(noHops.status).toBe(400);
+ expect(noHops.body.error).toContain('at least one hop');
+ });
+});
+
+describe('DELETE /api/trails/', () => {
+ it('removes the file and answers with the list that is left', async () => {
+ const res = await call('/api/trails/how-a-request-is-served-2', { method: 'DELETE' });
+ expect(res.status).toBe(200);
+ expect(res.body.deleted).toBe('how-a-request-is-served-2');
+ expect(res.body.trails).toHaveLength(1);
+ expect(fs.existsSync(path.join(trailsDir(), 'how-a-request-is-served-2.json'))).toBe(false);
+ });
+
+ it('is a 404 for a trail that is not there', async () => {
+ const res = await call('/api/trails/never-existed', { method: 'DELETE' });
+ expect(res.status).toBe(404);
+ });
+
+ it('refuses an id shaped like a path before it is joined to anything', async () => {
+ const res = await call('/api/trails/..%2f..%2fetc%2fpasswd', { method: 'DELETE' });
+ // The `..` segments are caught on the RAW url, before WHATWG parsing folds
+ // them away — a traversal attempt is a 404, never the app shell.
+ expect([400, 404]).toContain(res.status);
+ expect(res.headers['content-type']).toContain('application/json');
+ });
+});
+
+/* ------------------------------------------------------- the write boundary */
+
+describe('the write boundary', () => {
+ it('refuses a POST without the marker header', async () => {
+ const res = await call('/api/trails', {
+ method: 'POST',
+ marker: false,
+ body: { name: 'forged', hops: [] },
+ });
+ expect(res.status).toBe(403);
+ expect(res.body.code).toBe('refused');
+ expect(String(res.body.error)).toContain('x-codegraph-ui');
+ });
+
+ it('refuses a POST whose body claims to be a form', async () => {
+ const res = await call('/api/trails', {
+ method: 'POST',
+ contentType: 'application/x-www-form-urlencoded',
+ body: { name: 'forged', hops: [] },
+ });
+ expect(res.status).toBe(403);
+ expect(String(res.body.error)).toContain('application/json');
+ });
+
+ it('refuses a POST from a foreign origin even with the marker', async () => {
+ const res = await call('/api/trails', {
+ method: 'POST',
+ origin: 'https://evil.example',
+ body: { name: 'forged', hops: [] },
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it('refuses a write anywhere but /api/, and still serves the asset on GET', async () => {
+ const post = await call('/index.html', { method: 'POST', body: { a: 1 } });
+ expect(post.status).toBe(405);
+ expect(post.headers.allow).toBe('GET, HEAD');
+ const get = await call('/index.html');
+ expect(get.status).toBe(200);
+ });
+
+ it('still refuses a method it has never answered', async () => {
+ const res = await call('/api/trails', { method: 'PUT' });
+ expect(res.status).toBe(405);
+ });
+
+ it('refuses every write under --read-only, but still lists what is there', async () => {
+ const list = await callOn(readOnlyServer.port, '/api/trails');
+ expect(list.status).toBe(200);
+ expect(list.body.readOnly).toBe(true);
+ expect(list.body.readOnlyReason).toContain('--read-only');
+ expect(list.body.trails.length).toBeGreaterThan(0);
+
+ const save = await callOn(readOnlyServer.port, '/api/trails', {
+ method: 'POST',
+ body: { name: 'nope', hops: [{ dir: 'start', id: 'x' }] },
+ });
+ expect(save.status).toBe(403);
+ expect(save.body.code).toBe('refused');
+
+ const remove = await callOn(readOnlyServer.port, '/api/trails/how-a-request-is-served', {
+ method: 'DELETE',
+ });
+ expect(remove.status).toBe(403);
+ });
+});
+
+/* ------------------------------------------------- surviving a re-index --- */
+
+describe('a saved trail survives a re-index', () => {
+ it('re-resolves hops by qualified name once every node id has changed', async () => {
+ // Save the three-hop walk again, plus a fourth hop that is about to be
+ // deleted outright, so one trail exercises every outcome at once.
+ const saved = await call('/api/trails', {
+ method: 'POST',
+ body: {
+ name: 'The whole walk',
+ hops: [
+ { dir: 'start', id: await idOf('handleRequest') },
+ { dir: 'down', id: await idOf('load') },
+ { dir: 'down', id: await idOf('read') },
+ { dir: 'down', id: await idOf('retired') },
+ ],
+ },
+ });
+ const before = saved.body.trails.find((t: any) => t.id === 'the-whole-walk');
+ expect(before.intact).toBe(true);
+ const idsBefore = before.hops.map((h: any) => h.id);
+
+ // Now move the world underneath it:
+ // - `handleRequest` shifts down its file (a node id contains its start
+ // line, so its id changes while it is the same symbol);
+ // - `read` moves to a different file entirely;
+ // - `retired` is deleted.
+ fs.writeFileSync(
+ path.join(SRC(), 'handler.ts'),
+ `import { load } from './service';
+
+// A comment inserted above the symbol. This alone renames it.
+// Another line.
+// And another.
+
+export function handleRequest(key: string): string {
+ return load(key);
+}
+`
+ );
+ fs.writeFileSync(
+ path.join(SRC(), 'service.ts'),
+ `import { read } from './store';
+
+export function load(key: string): string {
+ return read(key);
+}
+`
+ );
+ fs.writeFileSync(path.join(SRC(), 'cache.ts'), `export const unused = 1;\n`);
+ fs.writeFileSync(
+ path.join(SRC(), 'store.ts'),
+ `export function read(key: string): string {
+ return key;
+}
+`
+ );
+ await reindex();
+
+ const after = (await call('/api/trails')).body.trails.find(
+ (t: any) => t.id === 'the-whole-walk'
+ );
+
+ // Every id really did change — otherwise this test proves nothing.
+ const idsAfter = after.hops.map((h: any) => h.id);
+ expect(idsAfter[0]).not.toBe(idsBefore[0]);
+ expect(idsAfter[0]).toBeTruthy();
+
+ const [handle, load, read, retired] = after.hops;
+ expect(handle.status).toBe('ok');
+ expect(handle.file).toBe('src/handler.ts');
+ expect(handle.line).toBeGreaterThan(handle.savedLine);
+
+ expect(load.status).toBe('ok');
+
+ // Moved to another file: still resolved, and the row says where from.
+ expect(read.status).toBe('moved');
+ expect(read.savedFile).toBe('src/cache.ts');
+ expect(read.file).toBe('src/store.ts');
+ expect(read.note).toContain('src/cache.ts');
+ expect(read.note).toContain('src/store.ts');
+
+ // Deleted: named honestly, with no invented target.
+ expect(retired.status).toBe('missing');
+ expect(retired.id).toBeNull();
+ expect(retired.note).toContain('moved or renamed');
+
+ // And it still opens — the first three hops, not the fourth.
+ expect(after.intact).toBe(false);
+ expect(after.resolved).toBe(3);
+ expect(after.openFrom).toBe(1);
+ expect(after.openCount).toBe(3);
+ expect(after.openId).toBe(idsAfter[2]);
+ expect(after.encoded?.split(',')).toHaveLength(3);
+ expect(after.encoded?.startsWith('s')).toBe(true);
+ }, 120_000);
+});
diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md
index 5407609..838d5e3 100644
--- a/docs/design/codegraph-ui-design-spec.md
+++ b/docs/design/codegraph-ui-design-spec.md
@@ -377,6 +377,47 @@ of an ancestor's member, names the language calls by itself, vendored directorie
islands — the Map's job, not this list's), names the resolver failed to resolve somewhere, names shared with a symbol that IS
referenced, and — the only rule that reads a file — names written more than once in a file that can reach them.
+### 3.12 Saved trails (CG-60)
+A **Save trail** button on the trail bar, and a list of what was saved on the empty screen and the entry-points panel.
+The viewer's only write.
+
+**Saving.** `Save trail` sits with `Read as flow` and `Clear` on the right of the trail bar — sans, `4px 8px`,
+`--rule-soft` border, same as its neighbours — and appears only once the trail has a hop and the answering side accepts
+writes. It opens a **one-field inline form** as a second row inside the bar (never a dialog: naming a walk is a thought the
+reader is already having, and anything modal stops the reading to ask about filing). The row is a 12px `--ink-2` sans label,
+a **30px** `--paper` input with a `--rule-soft` border exactly like the search box, `Save`/`Cancel`, and an 11.5px hint that
+says what will happen *before* it happens: `3 hops · saved to .codegraph/ui/trails`, or, in `--amber`,
+`Replaces the saved trail of the same name.` The name is pre-filled with the current symbol's; Escape closes; a failure
+(a read-only checkout, a full disk) prints in `--accent` beside the buttons rather than vanishing. The trail bar's grid row
+is `auto` for this — it keeps its 34px on its own and grows only while the form is open.
+
+**The list.** Rows follow the search-result grid — `18px | 1fr | auto`, kind glyph of the first hop, name 12.5px mono, then
+`N hops · author` 11px mono `--ink-3` — inside a `--rule-soft` box with `--rule-faint` between rows, so the empty screen
+reads as one list rather than two. Two 11px `--rule-soft` actions sit at the right of each row, always drawn and receding to
+`--ink-3` (a control that appears when the pointer arrives is one a keyboard reader has to guess at): `Export`, and `Delete`
+which arms to `Delete?` in `--accent`/`--accent-soft` before it removes anything. The section sits **above** "Where to start":
+a walk somebody named beats any ranking, when there is one. It draws nothing at all on the empty screen when there are no
+trails, and draws itself explained on the entry-points panel, which is where a reader goes looking for one.
+
+**The honesty line is the feature.** A saved trail is somebody's explanation of code that has since moved, so every hop is
+re-resolved against the current index on the way out and each row prints what became of it, in 11.5px under the name:
+`--amber` for *"1 hop moved or renamed since this was saved — parseToken no longer in the index."* or *"…now names more than
+one symbol — showing the closest match."*, `--ink-3` for a hop that merely moved file. Because the trail is a **path**, a hole
+in it cannot be stitched: the row opens the longest run of *consecutive* resolved hops and says so — `Opens hops 2–4 of 6.` —
+and a trail where nothing resolves is drawn `--ink-3` and is not clickable.
+
+**Where it lives.** One JSON file per trail under `.codegraph/ui/trails/.json`, written atomically (temp + rename),
+newest save first. `.codegraph/.gitignore` already ignores everything, so a trail is local by default; `Export` downloads the
+same file for a reader who wants to commit it somewhere. Each hop is stored as its **qualified name, kind and file** with the
+node id kept only as a fast path — a node id contains its start line, so a trail keyed on ids would break the first time
+anybody edited the code it describes, which is exactly when it matters. Saving under an existing name replaces that trail and
+keeps its `createdAt`.
+
+**What a write has to be.** `POST /api/trails` and `DELETE /api/trails/`, under `/api/` and nowhere else, carrying the
+`X-CodeGraph-UI` header and `Content-Type: application/json` — neither of which a cross-origin form can produce without a
+CORS preflight this server answers none of. `--read-only` refuses both and the screens say so in the answering side's own
+words instead of showing a Save that fails.
+
## 4. Libraries and versions
- Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a
diff --git a/scripts/check-ui-package.mjs b/scripts/check-ui-package.mjs
index 399c4ea..d6772ab 100644
--- a/scripts/check-ui-package.mjs
+++ b/scripts/check-ui-package.mjs
@@ -143,6 +143,7 @@ for (const name of [
'ArchitectureMap',
'DeadCodeView',
'TrailBar',
+ 'SavedTrails',
'SearchPalette',
'CodegraphUi',
'setGraphAdapter',
diff --git a/site/src/content/docs/guides/viewer.md b/site/src/content/docs/guides/viewer.md
index efb1105..440717b 100644
--- a/site/src/content/docs/guides/viewer.md
+++ b/site/src/content/docs/guides/viewer.md
@@ -48,11 +48,27 @@ If the viewer ever loses touch with the server, it retries a handful of times wi
- **Search** with `/` or Cmd-K: every symbol and file, grouped by kind, with signature and `file:line`. Arrow keys and Enter, no mouse needed.
- **Entry points** on the opening screen, and in full on the **Entry points** tab (`e`) — see below.
- **Typing a name also finds entry points.** They come back under their own heading below the symbol matches, so searching `payroll` returns the URL *with* the symbol that serves it, not just the URL.
-- **A trail** records the path you walked, with an arrow per hop showing whether you stepped into a call or up to a caller. Click any hop to jump back to it. The trail lives in the URL, so you can send someone the exact route you took.
+- **A trail** records the path you walked, with an arrow per hop showing whether you stepped into a call or up to a caller. Click any hop to jump back to it. The trail lives in the URL, so you can send someone the exact route you took — or press **Save trail** to keep it (see below).
- **Keyboard:** arrow keys move within a column, left/right switch columns, Enter follows, Backspace steps back.
Clicking any file path opens the **file view**: everything that file depends on, its outline in source order, and everything that depends on it.
+## Saved trails
+
+A trail you want to come back to is worth a name. Press **Save trail** on the trail bar, type one, and it is kept — listed on the opening screen and on the **Entry points** tab, above the derived suggestions. Opening one puts you back at the symbol you left with the whole walk restored in the bar. Explaining "how a request is served" to a new teammate becomes a name and a link.
+
+**A saved trail survives your project changing.** Each step is remembered by what it *is* — its qualified name, its kind, the file it was in — rather than by where it sat, so editing the file above a function does not lose it. When something does move, the row says so rather than quietly showing you something else:
+
+- a step that moved to another file still opens, and the row names both files;
+- a step that was renamed or deleted is called out by name, and the row says how much of the walk still opens (`Opens hops 2–4 of 6`);
+- a name now carried by several symbols is marked as a guess.
+
+A gap is never stitched over. The trail is a *path*, so a row opens the longest run of consecutive steps that still resolve — joining step 2 to step 4 would draw a call that does not exist.
+
+**Where they live.** One JSON file per trail under `.codegraph/ui/trails/`, which git already ignores, so trails are yours by default. **Export** on any row hands you the same file if you would rather commit one for the team; drop it back into that directory in another checkout and it re-resolves against *that* index.
+
+This is the only thing the viewer writes. Start it with `codegraph ui --read-only` and it will not write even this — saved trails can still be opened, just not saved or deleted.
+
## Entry points
The first screen worth opening on a codebase you have never seen. Four lists, all read out of the graph rather than guessed from filenames:
@@ -124,13 +140,16 @@ An eight-hop strip comes out around half a megabyte, well inside what GitHub acc
| `codegraph ui [path]` | Read a specific indexed project instead of the current directory |
| `--port ` | Pin a port. Without it the viewer takes 4747, or the next free one |
| `--no-open` | Print the URL instead of opening a browser (headless boxes, SSH) |
+| `--read-only` | Refuse every write — saved trails can be opened, but not saved or deleted |
| `CODEGRAPH_BROWSER=` | Choose which browser opens. `CODEGRAPH_BROWSER=none` never opens one |
`codegraph web` is an alias for the same command.
## Privacy
-The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It is read-only: it opens an index that already exists, never creates one, and never writes to your project or your graph.
+The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It opens an index that already exists, never creates one, and never changes your graph or a line of your code.
+
+The one thing it writes is a trail you asked it to save, as JSON under `.codegraph/ui/trails/`. Nothing else it serves has a side effect, no other endpoint accepts a write, and `codegraph ui --read-only` refuses that one too.
It sends nothing anywhere — no code, no paths, no analytics. The page in your browser talks only to the server on your own machine, and that server makes no outbound connections at all. See [Telemetry](https://github.com/colbymchenry/codegraph/blob/main/TELEMETRY.md) for the complete picture.
diff --git a/site/src/content/docs/reference/cli.md b/site/src/content/docs/reference/cli.md
index 5511f69..7377dd6 100644
--- a/site/src/content/docs/reference/cli.md
+++ b/site/src/content/docs/reference/cli.md
@@ -60,8 +60,9 @@ codegraph ui # the project you're standing in
codegraph ui ~/code/my-app # a project indexed elsewhere
codegraph ui --port 8080 # pin a port (fails if it's taken)
codegraph ui --no-open # just print the URL (headless boxes, SSH)
+codegraph ui --read-only # refuse every write, including saved trails
```
Without `--port` it takes 4747, or the next free port. `CODEGRAPH_BROWSER=` chooses which browser opens; `CODEGRAPH_BROWSER=none` never opens one. `codegraph web` is an alias.
-The viewer listens on `127.0.0.1` only and is read-only: it opens an index that already exists, never creates one, never writes to your project or your graph, and sends nothing anywhere.
+The viewer listens on `127.0.0.1` only: it opens an index that already exists, never creates one, never changes your graph or a line of your code, and sends nothing anywhere. The one thing it writes is a trail you asked it to save, under `.codegraph/ui/trails/`; `--read-only` refuses even that.
diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts
index 253460c..a4dccb9 100644
--- a/src/bin/codegraph.ts
+++ b/src/bin/codegraph.ts
@@ -1856,8 +1856,10 @@ function printNoIndexGuidance(projectPath: string): void {
* codegraph ui [path] (alias: web)
*
* The browser reader: serves the built viewer (`dist/viewer/`) over loopback
- * and opens it. Read-only in every sense — it answers GET, it opens the index
- * for reading, and it never writes to the project or the graph.
+ * and opens it. It opens the index for reading and never writes to it, never
+ * indexes, and never changes a line of the project's code. The single thing it
+ * writes is a trail the reader saved, as JSON under `.codegraph/ui/trails/`;
+ * `--read-only` turns even that off.
*
* Deliberately absent from TELEMETRY_FLUSH_COMMANDS above: the command's own
* banner tells the user nothing leaves their machine, so it must not be the
@@ -1870,6 +1872,7 @@ program
.description('Open the CodeGraph viewer in your browser — read your indexed project as a graph')
.option('--port ', `Port to listen on (default: ${DEFAULT_UI_PORT}, or the next free one)`)
.option('--no-open', 'Print the URL instead of opening a browser')
+ .option('--read-only', 'Refuse every write — saved trails can be opened but not saved or deleted')
.addHelpText(
'after',
`
@@ -1897,10 +1900,17 @@ The page keeps up with the project while it is open: save a file and it says so
within about a third of a second, and whatever is on screen re-reads the graph
when something re-indexes it. It watches for that; it never polls.
-The viewer listens on 127.0.0.1 only, so nothing on your network can reach it,
-and it is read-only: it opens an index that already exists and never changes
-your project or your graph. Requests from any other host are refused, and
-nothing is sent anywhere: no code, no paths, no analytics.
+Save a walk you want to keep: name the trail and it is written to
+.codegraph/ui/trails/ (already gitignored) as plain JSON, listed on the empty
+screen, and reopened at the symbol you left. Hops are remembered by name rather
+than by position, so a saved trail survives re-indexing and says which hop moved
+when one does. Pass --read-only to refuse every write.
+
+The viewer listens on 127.0.0.1 only, so nothing on your network can reach it.
+It opens an index that already exists, never indexes, and never changes a line
+of your code — the one thing it writes is a trail you asked it to save.
+Requests from any other host are refused, and nothing is sent anywhere: no code,
+no paths, no analytics.
Without --port it takes ${DEFAULT_UI_PORT}, or the next free port if that one is busy.
@@ -1908,7 +1918,7 @@ Set ${BROWSER_ENV}= to choose which browser opens, or
${BROWSER_ENV}=none to never open one.
`
)
- .action(async (pathArg: string | undefined, options: { port?: string; open?: boolean }) => {
+ .action(async (pathArg: string | undefined, options: { port?: string; open?: boolean; readOnly?: boolean }) => {
// An explicit --port stays explicit: a scripted `--port 8080` that quietly
// lands on 8081 is worse than one that says the port is busy. The default
// port is the only one we're free to walk away from.
@@ -1942,10 +1952,17 @@ ${BROWSER_ENV}=none to never open one.
'../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 });
+ // The 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 readOnly = options.readOnly === true;
+ const api = createGraphApi({
+ projectRoot: projectPath,
+ readOnly,
+ readOnlyReason: readOnly
+ ? 'This viewer was started with --read-only, so trails cannot be saved.'
+ : undefined,
+ });
let handle: UiServerHandle;
try {
@@ -1968,7 +1985,12 @@ ${BROWSER_ENV}=none to never open one.
console.log('');
console.log(` ${chalk.dim('Reading')} ${projectPath}`);
console.log(` ${chalk.dim('URL')} ${chalk.cyan(handle.url)}`);
- console.log(` ${chalk.dim('Access')} this machine only ${getGlyphs().dash} read-only, nothing leaves your computer`);
+ console.log(
+ ` ${chalk.dim('Access')} this machine only ${getGlyphs().dash} ` +
+ (readOnly
+ ? 'read-only, nothing leaves your computer'
+ : 'nothing leaves your computer; saved trails are the only thing written')
+ );
console.log('');
const opened = options.open === false ? false : openBrowser(handle.url);
diff --git a/src/index.ts b/src/index.ts
index 6a39882..750d3d6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1373,6 +1373,21 @@ export class CodeGraph {
return this.queries.getNodesByIds(ids);
}
+ /**
+ * Every symbol carrying an exact qualified name.
+ *
+ * The identity that survives a re-index. A node's id contains its start line,
+ * so any edit ABOVE a symbol gives it a different id — anything that has to
+ * name the same symbol across two indexes (a saved trail, a bookmark, a
+ * review comment) has to key on this instead, and then disambiguate the
+ * result by kind and file. Index-backed; unlike
+ * {@link GraphQueryManager.findByQualifiedName} it takes no pattern and scans
+ * nothing.
+ */
+ getNodesByQualifiedName(qualifiedName: string): Node[] {
+ return this.queries.getNodesByQualifiedNameExact(qualifiedName);
+ }
+
/**
* Outgoing edges for many source nodes at once — the batch form of
* {@link getOutgoingEdges}. See {@link QueryBuilder.getOutgoingEdgesFrom}.
diff --git a/src/ui-server/api/index.ts b/src/ui-server/api/index.ts
index 7259191..f7668e9 100644
--- a/src/ui-server/api/index.ts
+++ b/src/ui-server/api/index.ts
@@ -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/ 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/'],
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/ 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[0],
+ res: Parameters[1],
+ ctx: UiRequestContext,
+ session: GraphSession,
+ trails: TrailsOptions
+): Promise {
+ 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/.');
+ }
+ }
+
+ 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/ to remove).'
+ );
+}
+
/**
* The two endpoints that carry their argument in the path.
*
diff --git a/src/ui-server/api/respond.ts b/src/ui-server/api/respond.ts
index 0064e1c..bfccac5 100644
--- a/src/ui-server/api/respond.ts
+++ b/src/ui-server/api/respond.ts
@@ -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 {
+ 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
// =============================================================================
diff --git a/src/ui-server/api/trail-store.ts b/src/ui-server/api/trail-store.ts
new file mode 100644
index 0000000..e6c5e19
--- /dev/null
+++ b/src/ui-server/api/trail-store.ts
@@ -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, `/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 `.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;
+ 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;
+ 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 {
+ 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}`);
+}
diff --git a/src/ui-server/api/trails.ts b/src/ui-server/api/trails.ts
new file mode 100644
index 0000000..5babe4c
--- /dev/null
+++ b/src/ui-server/api/trails.ts
@@ -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 = { 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;
+
+ 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;
+ 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;
+ }
+}
diff --git a/src/ui-server/index.ts b/src/ui-server/index.ts
index be60d85..40c170f 100644
--- a/src/ui-server/index.ts
+++ b/src/ui-server/index.ts
@@ -1,11 +1,16 @@
/**
* The `codegraph ui` server.
*
- * A loopback-only, read-only `node:http` server that hands the browser the
- * built viewer (`dist/viewer/`) and, through the JSON API mounted on the `api`
- * seam below (`./api`), a read-only view of one indexed project. No framework,
- * no new dependency: it answers GET, serves files, and refuses everything
- * else.
+ * A loopback-only `node:http` server that hands the browser the built viewer
+ * (`dist/viewer/`) and, through the JSON API mounted on the `api` seam below
+ * (`./api`), a view of one indexed project. No framework, no new dependency: it
+ * answers GET, serves files, and refuses everything else.
+ *
+ * It is a reader with one exception, added deliberately and scoped as narrowly
+ * as it could be: `POST`/`DELETE /api/trails` saves and removes the reader's own
+ * named trails, as JSON files under `.codegraph/ui/trails/`. Nothing else it
+ * serves has a side effect, no other path accepts a write, and `--read-only`
+ * turns even that one off. See `security.ts` for what a write has to carry.
*
* The interesting part is not the routing, it is the boundary in `security.ts`.
* Read that first.
@@ -17,9 +22,13 @@ import * as path from 'path';
import { resolveViewerDir } from './assets';
import {
ALLOWED_METHODS,
+ READ_METHODS,
+ WRITE_HEADER,
isAllowedHost,
isAllowedOrigin,
isSafeRequestPath,
+ isWriteMethod,
+ isWriteRequest,
resolveStaticAsset,
} from './security';
import { sendFile, sendJson, sendText, shouldFallBackToIndex } from './static';
@@ -35,10 +44,15 @@ export {
} from './constants';
export {
ALLOWED_METHODS,
+ READ_METHODS,
+ WRITE_HEADER,
+ WRITE_METHODS,
PathRefusalError,
isAllowedHost,
isAllowedOrigin,
isSafeRequestPath,
+ isWriteMethod,
+ isWriteRequest,
resolveProjectFile,
resolveStaticAsset,
} from './security';
@@ -58,7 +72,11 @@ export interface UiRequestContext {
query: URLSearchParams;
/** Absolute path of the indexed project this server is reading. */
projectRoot: string;
- /** The request method — `GET` or `HEAD`; nothing else reaches a handler. */
+ /**
+ * The request method. `GET` or `HEAD` for every read; `POST` or `DELETE`
+ * only for a request that already passed {@link isWriteRequest}, which is
+ * `/api/trails` and nothing else.
+ */
method: string;
}
@@ -66,9 +84,9 @@ export interface UiRequestContext {
* A handler mounted under `/api/`. Returns `true` when it answered the request
* (i.e. wrote a response), `false` to fall through to a 404.
*
- * This is the seam the read-only JSON API plugs into. Everything it serves out
- * of the user's repository must go through `resolveProjectFile` — see
- * `security.ts`.
+ * This is the seam the JSON API plugs into. Everything it reads out of — or
+ * writes into — the user's repository must go through `resolveProjectFile`;
+ * see `security.ts`.
*/
export type UiApiHandler = (
req: http.IncomingMessage,
@@ -228,7 +246,7 @@ async function handleRequest(
if (!ALLOWED_METHODS.includes(method)) {
res.setHeader('Allow', ALLOWED_METHODS.join(', '));
- sendText(res, 405, `codegraph ui is read-only — ${method} is not allowed.`, method);
+ sendText(res, 405, `codegraph ui does not answer ${method}.`, method);
return;
}
@@ -258,6 +276,24 @@ async function handleRequest(
// the viewer parses these responses, and a text/plain body here would surface
// as a parse error instead of the refusal it actually is.
const jsonNamespace = rawPath === '/api' || rawPath.startsWith('/api/');
+
+ // The one place this server stops being a pure reader. A write has to be
+ // under /api/ and carry the marker header — see `isWriteRequest` for what
+ // that closes that Host and Origin do not.
+ if (isWriteMethod(method)) {
+ const verdict = isWriteRequest(rawPath, {
+ marker: readHeader(req, WRITE_HEADER),
+ contentType: readHeader(req, 'content-type'),
+ });
+ if (!verdict.ok) {
+ if (!jsonNamespace) res.setHeader('Allow', READ_METHODS.join(', '));
+ const body = `Refused: ${verdict.reason}`;
+ if (jsonNamespace) sendJson(res, 403, { error: body, code: 'refused' }, method);
+ else sendText(res, 405, body, method);
+ return;
+ }
+ }
+
if (!isSafeRequestPath(rawPath)) {
if (jsonNamespace) {
sendJson(res, 404, { error: 'Not found', code: 'not-found' }, method);
diff --git a/src/ui-server/security.ts b/src/ui-server/security.ts
index b7a1cce..e99529e 100644
--- a/src/ui-server/security.ts
+++ b/src/ui-server/security.ts
@@ -20,8 +20,11 @@
* - **No CORS headers, ever.** Not adding `Access-Control-Allow-Origin` is what
* keeps a cross-origin reader from seeing a response body even if it does
* reach us. There is deliberately no way to turn this on.
- * - **GET/HEAD only.** The viewer is a reader; nothing it serves has a side
- * effect, so there is no state for a forged request to change.
+ * - **GET/HEAD everywhere; POST/DELETE only under `/api/`, and only for a
+ * request that could not have been forged by a form.** See
+ * {@link isWriteRequest} below — the viewer went from a pure reader to one
+ * that saves trails into `.codegraph/ui/`, and that is the entire change to
+ * this boundary.
* - **Every path resolves through {@link validatePathWithinRoot}** — the same
* chokepoint the MCP read sinks use, which catches `../` traversal AND
* in-tree symlinks pointing out of the root (#527).
@@ -40,8 +43,65 @@ export { PathRefusalError };
*/
const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(['localhost', '127.0.0.1', '::1']);
-/** HTTP methods the viewer server answers. Everything else is 405. */
-export const ALLOWED_METHODS: readonly string[] = ['GET', 'HEAD'];
+/** Methods that answer anywhere: the viewer's assets and every read endpoint. */
+export const READ_METHODS: readonly string[] = ['GET', 'HEAD'];
+
+/**
+ * Methods that answer under `/api/` only, and only for a request carrying
+ * {@link WRITE_HEADER}. The viewer's one write is a saved trail.
+ */
+export const WRITE_METHODS: readonly string[] = ['POST', 'DELETE'];
+
+/** HTTP methods the viewer server answers at all. Everything else is 405. */
+export const ALLOWED_METHODS: readonly string[] = [...READ_METHODS, ...WRITE_METHODS];
+
+/**
+ * The header a write has to carry.
+ *
+ * Belt and braces behind the `Host` and `Origin` checks, and worth the two
+ * lines because it fails *differently*: a custom request header cannot be sent
+ * cross-origin without a CORS preflight, and this server answers no preflight
+ * and sends no `Access-Control-*` header, so the browser never issues the real
+ * request. That closes the one shape those checks lean on a header for — a
+ * `
+
+
+
+
+
{#if entries.sections.length > 0}
@@ -96,6 +104,11 @@
padding-bottom: 8px;
}
+ .saved {
+ max-width: 800px;
+ padding: 8px 40px 0;
+ }
+
.entries {
max-width: 720px;
padding: 8px 40px 48px;