Files
backnotprop__plannotator/packages/server/guide-persistence.test.ts
Michael Ramos 99d11dca04 Persist Guided Reviews across sessions (#1115)
* feat(guide): add durable guide store with repo-scoped keys and opt-out

Runtime-agnostic guide persistence for #1112: packages/shared/guide-store.ts
writes validated guides to ${PLANNOTATOR_DATA_DIR}/guides/{repo-key}/{id}.json
with atomic tmp+rename writes and graceful corrupt-file handling. The repo key
is a sanitized host__owner__repo from the origin remote (or the PR url), with
a dir-name+hash8 fallback when no remote parses, so PR and branch sessions of
one repository share a shelf and same-named branches in different repos never
collide. Includes the session glue (repo-key/headSha/label resolution plus the
jobId-to-savedId map) shared by both server runtimes, the guideHistory config
key with resolveGuideHistory (PLANNOTATOR_GUIDE_HISTORY, coerced booleans),
and the browser-safe SavedGuideListEntry/CodeGuideData extensions.

* feat(guide): autosave guides and serve saved: ids in both server runtimes

Both packages/server/review.ts and the Pi mirror serverReview.ts now: autosave
a guide the moment it passes the existing validateGuideOutput gate (including
manual-repair submits); write reviewed-state changes on a live job id through
to that job's saved file; serve persisted guides through the existing guide
endpoints as saved:{id} pseudo job ids (GET guide + PUT reviewed); and expose
GET /api/guides (repo-scoped list with progress and a moved flag comparing the
stored head sha to the current head) and DELETE /api/guides/:id. guide-store
joins vendor.sh's flat copy list; cross-runtime endpoint wiring is covered by
packages/server/guide-persistence.test.ts against both servers, including
reviewed-state persistence across a server restart and traversal-id rejection.

* feat(guide): previous-guides list, Saved chip, and outdated Regenerate hint

GuideEmptyState grows a Previous guides section under the Generate controls:
rows show the target label chip, title, age, reviewed progress, a quiet diff
changed flag when the stored head no longer matches, and a per-row delete;
clicking a row loads the guide via its saved:{id} pseudo job id (the existing
useGuideData/GuideScreen id plumbing already treats ids as opaque). GuideView
shows a small Saved chip once the active guide is persisted and, for an
outdated saved guide, one muted hint line whose Regenerate action launches a
fresh guide with the persisted defaults. The engine/model resolution and
launch-param shapes move into the shared useGuideLaunch hook so the empty
state and the hint stay in lockstep. DOM tests cover the new GuideView states.

* docs: document guide persistence endpoints and PLANNOTATOR_GUIDE_HISTORY

* fix(guide): label saved envelopes with launch-time context, not completion-time state

Review finding on #1115: saveForJob read the live session getters when the
job COMPLETED, but guide jobs run for minutes while the session supports
mid-generation PR switching (/api/pr-switch) and diff switches. Launch on PR
A, switch to B, complete: the envelope permanently carried A's content
labeled with B's PR label/url/headSha (and could even land on B's repo
shelf). The review-target context (pr url/label/head, branch label, head
sha) is now snapshotted at job LAUNCH via guideStore.captureLaunchContext()
in the guide buildCommand branch of both runtimes and carried on the job
itself as AgentJobInfo.guideContext, the same discipline as
changedFilesSnapshot, so it is garbage-collected with the job and needs no
separate cleanup. saveForJob prefers the snapshot (falling back to the live
getters only for jobs launched without one), derives the shelf from the
launch-time PR url, and records the shelf alongside the saved id so reviewed
write-through follows the file wherever it landed. Repair jobs reuse the
FAILED job's own snapshot. Covered by new session tests that mutate the
injected getters between launch capture and completion.

* fix(guide): close a saved guide when the review context switches

Review finding on #1115: a saved:{id} guide has no AgentJobInfo, so
GuideScreen's context match passes trivially (unknown ids are tolerated for
the demo path). Switching PRs or worktrees while a saved guide was open left
it mounted over the new context's diff with a stale moved flag. App.tsx now
clears activeGuideJobId on any prMetadata.url / activeWorktreePath change
when it points at a saved: id; the user reopens it from the Previous guides
list. Live job ids are untouched, GuideScreen's own matching handles those.
2026-07-23 15:32:43 -07:00

205 lines
7.6 KiB
TypeScript

/**
* Endpoint wiring for durable guide persistence (#1112), against BOTH server
* runtimes (Bun packages/server/review.ts and the Pi mirror
* apps/pi-extension/server/serverReview.ts):
*
* GET /api/guides — repo-scoped list
* GET /api/guide/saved:{id} — serve a persisted guide
* PUT /api/guide/saved:{id}/reviewed — persist reviewed state
* DELETE /api/guides/:id — remove a saved guide
*
* Both servers are started with no gitContext/PR, so the guide store derives
* its repo key via the no-remote fallback (process.cwd()) — the tests seed the
* store through @plannotator/shared/guide-store under that same key.
*
* Requires `bash apps/pi-extension/vendor.sh` to have been run (same as the
* other cross-runtime tests).
*/
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CodeGuideOutput } from "@plannotator/shared/guide";
import {
deriveGuideRepoKeyFallback,
listGuides,
loadGuide,
saveGuide,
type SavedGuideEnvelope,
} from "@plannotator/shared/guide-store";
import { startReviewServer as startBunReviewServer } from "./review";
import { startReviewServer as startPiReviewServer } from "../../apps/pi-extension/server";
const SPA_HTML = "<!doctype html><html><body>SPA fallback</body></html>";
const GUIDE: CodeGuideOutput = {
title: "Persisted guide",
intent: "Round-trips through the saved: endpoints.",
sections: [
{ title: "Core", overview: "The heart.", diffs: [{ file: "a.ts" }] },
{ title: "Glue", overview: "Wiring.", diffs: [{ file: "b.ts" }] },
],
};
function envelope(overrides: Partial<SavedGuideEnvelope> = {}): SavedGuideEnvelope {
return {
version: 1,
savedAt: 1000,
label: "feature/x",
title: GUIDE.title,
guide: GUIDE,
reviewed: [false, false],
...overrides,
};
}
interface RunningServer {
readonly url: string;
stop(): void;
}
const serverCases = [
{
name: "Bun review",
start: () =>
startBunReviewServer({
rawPatch: "",
gitRef: "HEAD",
origin: "claude-code",
htmlContent: SPA_HTML,
}),
},
{
name: "Pi review",
start: () =>
startPiReviewServer({
rawPatch: "",
gitRef: "HEAD",
origin: "pi",
htmlContent: SPA_HTML,
}),
},
] as const;
let dataDir = "";
let previousDataDir: string | undefined;
let previousPort: string | undefined;
let previousRemote: string | undefined;
// Both servers run with no gitContext/PR/workspace, so the session's repo key
// is the fallback derivation over process.cwd().
const repoKey = deriveGuideRepoKeyFallback(process.cwd());
beforeAll(() => {
previousPort = process.env.PLANNOTATOR_PORT;
previousRemote = process.env.PLANNOTATOR_REMOTE;
delete process.env.PLANNOTATOR_PORT;
process.env.PLANNOTATOR_REMOTE = "0";
});
afterAll(() => {
if (previousPort === undefined) delete process.env.PLANNOTATOR_PORT;
else process.env.PLANNOTATOR_PORT = previousPort;
if (previousRemote === undefined) delete process.env.PLANNOTATOR_REMOTE;
else process.env.PLANNOTATOR_REMOTE = previousRemote;
});
// Fresh data dir per test so seeded guides never leak across cases.
function useTempDataDir() {
dataDir = mkdtempSync(join(tmpdir(), "plannotator-guide-endpoints-"));
previousDataDir = process.env.PLANNOTATOR_DATA_DIR;
process.env.PLANNOTATOR_DATA_DIR = dataDir;
}
afterEach(() => {
if (previousDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
else process.env.PLANNOTATOR_DATA_DIR = previousDataDir;
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
dataDir = "";
});
for (const serverCase of serverCases) {
describe(`${serverCase.name} guide persistence endpoints`, () => {
test("lists, serves, updates reviewed (persisted across restart), and deletes saved guides", async () => {
useTempDataDir();
saveGuide(repoKey, "1000-persisted-guide", envelope());
let server = await serverCase.start();
try {
// GET /api/guides — the seeded guide is listed with progress + moved.
const listRes = await fetch(`${server.url}/api/guides`);
expect(listRes.status).toBe(200);
const list = await listRes.json() as Array<Record<string, unknown>>;
expect(list.length).toBe(1);
expect(list[0].id).toBe("1000-persisted-guide");
expect(list[0].label).toBe("feature/x");
expect(list[0].title).toBe(GUIDE.title);
expect(list[0].progress).toEqual({ reviewed: 0, total: 2 });
expect(list[0].moved).toBe(false); // no headSha stored → never flagged
// GET /api/guide/saved:{id} — full guide + reviewed + saved/moved.
const getRes = await fetch(`${server.url}/api/guide/saved:1000-persisted-guide`);
expect(getRes.status).toBe(200);
const data = await getRes.json() as Record<string, unknown>;
expect(data.title).toBe(GUIDE.title);
expect((data.sections as unknown[]).length).toBe(2);
expect(data.reviewed).toEqual([false, false]);
expect(data.saved).toBe(true);
expect(data.moved).toBe(false);
// Unknown saved id → 404.
const missingRes = await fetch(`${server.url}/api/guide/saved:2000-missing`);
expect(missingRes.status).toBe(404);
// PUT /api/guide/saved:{id}/reviewed persists to disk.
const putRes = await fetch(`${server.url}/api/guide/saved:1000-persisted-guide/reviewed`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reviewed: [true, false] }),
});
expect(putRes.status).toBe(200);
expect(loadGuide(repoKey, "1000-persisted-guide")!.reviewed).toEqual([true, false]);
} finally {
server.stop();
}
// Server restart — the reviewed state survives on the new session.
server = await serverCase.start();
try {
const data = await (await fetch(`${server.url}/api/guide/saved:1000-persisted-guide`)).json() as Record<string, unknown>;
expect(data.reviewed).toEqual([true, false]);
// DELETE /api/guides/:id removes it; a repeat delete 404s.
const delRes = await fetch(`${server.url}/api/guides/1000-persisted-guide`, { method: "DELETE" });
expect(delRes.status).toBe(200);
expect(listGuides(repoKey).length).toBe(0);
const delAgain = await fetch(`${server.url}/api/guides/1000-persisted-guide`, { method: "DELETE" });
expect(delAgain.status).toBe(404);
const listAfter = await (await fetch(`${server.url}/api/guides`)).json() as unknown[];
expect(listAfter).toEqual([]);
} finally {
server.stop();
}
});
test("traversal-shaped ids are rejected and corrupt files load as no guide", async () => {
useTempDataDir();
const server = await serverCase.start();
try {
// Traversal-shaped ids never reach the disk layer.
const evil = await fetch(`${server.url}/api/guide/saved:..%2F..%2Fescape`);
expect(evil.status).toBe(404);
const evilDelete = await fetch(`${server.url}/api/guides/..%2Fescape`, { method: "DELETE" });
expect(evilDelete.status).toBe(404);
// A live (non-saved) job id still routes to the in-memory session.
const live = await fetch(`${server.url}/api/guide/some-live-job-id`);
expect(live.status).toBe(404);
expect(await live.json()).toEqual({ error: "Guide not found" });
} finally {
server.stop();
}
});
});
}