mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
99d11dca04
* 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.
205 lines
7.6 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|
|
}
|