@plannotator/pi-extension 0.27.10 → 0.27.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,14 +104,21 @@ export async function createPiAIRuntime(options: CreatePiAIRuntimeOptions = {}):
104
104
  type: "opencode-sdk",
105
105
  cwd,
106
106
  });
107
+ const providerId = registry.register(provider);
108
+ // Deferred like Codex: fetchModels spawns `opencode serve`, so it
109
+ // must NOT run eagerly at startup — that spawned a server on every
110
+ // session for every user with opencode installed, and interrupted
111
+ // sessions orphaned it. The initializer runs on first explicit
112
+ // activation (?activate= from the model picker) or first opencode
113
+ // session.
107
114
  if (provider && "fetchModels" in provider) {
108
- modelDiscovery.push(
109
- (provider as { fetchModels: () => Promise<void> })
110
- .fetchModels()
111
- .catch(() => {}),
115
+ providerInitializers.set(
116
+ providerId,
117
+ ai.createBestEffortOnce(
118
+ () => (provider as { fetchModels: () => Promise<void> }).fetchModels(),
119
+ ),
112
120
  );
113
121
  }
114
- registry.register(provider);
115
122
  }
116
123
  } catch {
117
124
  // OpenCode not available.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Durable feedback archive — Pi (Node) runtime parity.
3
+ *
4
+ * Node mirror of packages/server/feedback-archive.test.ts. Plannotator has two
5
+ * server implementations with the same API surface; a feature wired into only
6
+ * one of them silently drops every Pi user's feedback on the floor. These
7
+ * tests pin that the three Pi handlers (plan deny/approve, review
8
+ * /api/feedback and /api/exit, annotate submit) write the SAME record shape
9
+ * under the same gates.
10
+ *
11
+ * The archive is sandboxed under a temp PLANNOTATOR_DATA_DIR set inside the
12
+ * tests — the vendored module resolves its data dir per call. Plan version
13
+ * history goes through generated/storage.ts, which caches its data directory
14
+ * at import time, so the plan test uses a unique heading (unique slug) and
15
+ * removes only that slug directory afterwards.
16
+ */
17
+
18
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
19
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join, resolve } from "node:path";
22
+ import { startPlanReviewServer } from "./serverPlan.ts";
23
+ import { startReviewServer } from "./serverReview.ts";
24
+ import { startAnnotateServer } from "./serverAnnotate.ts";
25
+ import { getPlanVersionPath } from "../generated/storage.ts";
26
+ import { detectProjectName } from "./project.ts";
27
+ import { parseFeedbackIndex, type FeedbackRecord } from "../generated/feedback-archive.ts";
28
+ import { getPlannotatorDataDir } from "../generated/data-dir.ts";
29
+
30
+ const MINIMAL_HTML = "<html><body>Plannotator</body></html>";
31
+ const PATCH = "diff --git a/src/parse.ts b/src/parse.ts\n@@ -1 +1 @@\n-a\n+b\n";
32
+
33
+ // Annotate version history goes through generated/storage.ts, whose data dir is
34
+ // fixed at import time, so these sessions snapshot into the REAL data dir no
35
+ // matter what PLANNOTATOR_DATA_DIR says here. Distinctive, test-owned project
36
+ // names keep that out of any real project's bucket and let afterAll remove it.
37
+ const PI_FILE_ANNOTATE_PROJECT = "_pi_feedback_archive_test_file";
38
+ const PI_STATELESS_ANNOTATE_PROJECT = "_pi_feedback_archive_test_stateless";
39
+
40
+ const ENV_KEYS = [
41
+ "PLANNOTATOR_DATA_DIR",
42
+ "PLANNOTATOR_FEEDBACK_HISTORY",
43
+ "PLANNOTATOR_ANNOTATE_HISTORY",
44
+ "PLANNOTATOR_AI",
45
+ "PLANNOTATOR_PORT",
46
+ "PLANNOTATOR_REMOTE",
47
+ ] as const;
48
+ const saved: Record<string, string | undefined> = {};
49
+ const tempDirs: string[] = [];
50
+
51
+ function makeTempDir(prefix: string): string {
52
+ const dir = mkdtempSync(join(tmpdir(), prefix));
53
+ tempDirs.push(dir);
54
+ return dir;
55
+ }
56
+
57
+ function useTempDataDir(): string {
58
+ const dir = makeTempDir("plannotator-pi-feedback-");
59
+ process.env.PLANNOTATOR_DATA_DIR = dir;
60
+ return dir;
61
+ }
62
+
63
+ function feedbackDir(dataDir: string): string {
64
+ return join(dataDir, "feedback");
65
+ }
66
+
67
+ function readOnlyIndex(dataDir: string): FeedbackRecord[] {
68
+ const projects = readdirSync(feedbackDir(dataDir));
69
+ expect(projects.length).toBe(1);
70
+ return parseFeedbackIndex(
71
+ readFileSync(join(feedbackDir(dataDir), projects[0], "index.jsonl"), "utf-8"),
72
+ );
73
+ }
74
+
75
+ function sidecarBody(dataDir: string, record: FeedbackRecord): string {
76
+ const projects = readdirSync(feedbackDir(dataDir));
77
+ return readFileSync(join(feedbackDir(dataDir), projects[0], record.recordFile!), "utf-8");
78
+ }
79
+
80
+ beforeEach(() => {
81
+ for (const key of ENV_KEYS) saved[key] = process.env[key];
82
+ delete process.env.PLANNOTATOR_PORT;
83
+ process.env.PLANNOTATOR_REMOTE = "0";
84
+ process.env.PLANNOTATOR_AI = "disabled";
85
+ process.env.PLANNOTATOR_FEEDBACK_HISTORY = "1";
86
+ process.env.PLANNOTATOR_ANNOTATE_HISTORY = "1";
87
+ });
88
+
89
+ afterEach(() => {
90
+ for (const key of ENV_KEYS) {
91
+ if (saved[key] === undefined) delete process.env[key];
92
+ else process.env[key] = saved[key]!;
93
+ }
94
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
95
+ });
96
+
97
+ afterAll(() => {
98
+ // Remove the annotate history these tests deposited in the real data dir.
99
+ // Runs with the env already restored, so this resolves the same directory
100
+ // generated/storage.ts wrote to.
101
+ const historyDir = join(getPlannotatorDataDir(), "history");
102
+ for (const project of [PI_FILE_ANNOTATE_PROJECT, PI_STATELESS_ANNOTATE_PROJECT]) {
103
+ rmSync(join(historyDir, project), { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ describe("pi feedback archive: code review", () => {
108
+ test("feedback submit appends one record with a sidecar, then deletes the draft", async () => {
109
+ const dataDir = useTempDataDir();
110
+ const server = await startReviewServer({
111
+ rawPatch: PATCH,
112
+ gitRef: "HEAD",
113
+ htmlContent: MINIMAL_HTML,
114
+ });
115
+ try {
116
+ await fetch(`${server.url}/api/draft`, {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ body: JSON.stringify({ annotations: [{ id: "a1" }] }),
120
+ });
121
+
122
+ const response = await fetch(`${server.url}/api/feedback`, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({
126
+ approved: false,
127
+ feedback: "parse() still drops the null case.",
128
+ annotations: [
129
+ { id: "a1", type: "comment", text: "null case", filePath: "src/parse.ts", lineStart: 1, lineEnd: 1, side: "new" },
130
+ ],
131
+ }),
132
+ });
133
+ expect(response.status).toBe(200);
134
+
135
+ const records = readOnlyIndex(dataDir);
136
+ expect(records.length).toBe(1);
137
+ expect(records[0].v).toBe(1);
138
+ expect(records[0].client).toBe("plannotator");
139
+ expect(records[0].surface).toBe("review");
140
+ expect(records[0].decision).toBe("feedback");
141
+ expect(records[0].annotations?.[0].file).toBe("src/parse.ts");
142
+ expect(records[0].target?.review?.gitRef).toBe("HEAD");
143
+ expect(records[0].target?.review?.changedFiles).toBe(1);
144
+ // Identity only, never the patch bytes.
145
+ expect(JSON.stringify(records[0])).not.toContain("diff --git");
146
+ expect(sidecarBody(dataDir, records[0])).toContain("parse() still drops the null case.");
147
+
148
+ expect((await fetch(`${server.url}/api/draft`)).status).toBe(404);
149
+ } finally {
150
+ server.stop();
151
+ }
152
+ });
153
+
154
+ test("closing without feedback records a dismissal as a decision-only line", async () => {
155
+ const dataDir = useTempDataDir();
156
+ const server = await startReviewServer({ rawPatch: PATCH, gitRef: "HEAD", htmlContent: MINIMAL_HTML });
157
+ try {
158
+ expect((await fetch(`${server.url}/api/exit`, { method: "POST" })).status).toBe(200);
159
+ const records = readOnlyIndex(dataDir);
160
+ expect(records[0].decision).toBe("dismissed");
161
+ expect(records[0].recordFile).toBeUndefined();
162
+ } finally {
163
+ server.stop();
164
+ }
165
+ });
166
+
167
+ test("a failed archive write keeps the draft and still answers the reviewer with 200", async () => {
168
+ // The one invariant the Pi handler copies by hand rather than inheriting
169
+ // from the shared module: archive first, and only delete the draft when
170
+ // the record actually landed.
171
+ const dataDir = useTempDataDir();
172
+ // The review project is derived from the review cwd; block exactly that
173
+ // archive directory by planting a FILE where the directory must go.
174
+ const repoDir = join(makeTempDir("plannotator-pi-feedback-repo-"), "widgets");
175
+ mkdirSync(repoDir, { recursive: true });
176
+ mkdirSync(feedbackDir(dataDir), { recursive: true });
177
+ writeFileSync(join(feedbackDir(dataDir), "widgets"), "blocked", "utf-8");
178
+
179
+ const server = await startReviewServer({
180
+ rawPatch: PATCH,
181
+ gitRef: "HEAD",
182
+ htmlContent: MINIMAL_HTML,
183
+ agentCwd: repoDir,
184
+ });
185
+ try {
186
+ await fetch(`${server.url}/api/draft`, {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/json" },
189
+ body: JSON.stringify({ annotations: [{ id: "a1" }] }),
190
+ });
191
+
192
+ const response = await fetch(`${server.url}/api/feedback`, {
193
+ method: "POST",
194
+ headers: { "Content-Type": "application/json" },
195
+ body: JSON.stringify({ feedback: "Do not lose this.", annotations: [{ id: "a1" }] }),
196
+ });
197
+ // The submit succeeds for the user...
198
+ expect(response.status).toBe(200);
199
+ // ...and the draft survives as the recovery copy.
200
+ const draft = await fetch(`${server.url}/api/draft`);
201
+ expect(draft.status).toBe(200);
202
+ expect(JSON.stringify(await draft.json())).toContain("a1");
203
+ } finally {
204
+ server.stop();
205
+ }
206
+ });
207
+
208
+ test("a PR-mode session buckets under the project, not the pool checkout", async () => {
209
+ // PR mode never sets gitContext and `--local` points agentCwd at
210
+ // <sessionDir>/pool/pr-<n>; the caller's detected project must win, or
211
+ // every PR review files under `pr-123`.
212
+ const dataDir = useTempDataDir();
213
+ const poolCwd = join(makeTempDir("plannotator-pi-feedback-pool-"), "pool", "pr-123");
214
+ mkdirSync(poolCwd, { recursive: true });
215
+ const server = await startReviewServer({
216
+ rawPatch: PATCH,
217
+ gitRef: "HEAD",
218
+ htmlContent: MINIMAL_HTML,
219
+ project: "plannotator",
220
+ agentCwd: poolCwd,
221
+ });
222
+ try {
223
+ const response = await fetch(`${server.url}/api/feedback`, {
224
+ method: "POST",
225
+ headers: { "Content-Type": "application/json" },
226
+ body: JSON.stringify({ feedback: "Rebase before merging.", annotations: [] }),
227
+ });
228
+ expect(response.status).toBe(200);
229
+ const projects = readdirSync(feedbackDir(dataDir));
230
+ expect(projects).toEqual(["plannotator"]);
231
+ } finally {
232
+ server.stop();
233
+ }
234
+ });
235
+
236
+ test("PLANNOTATOR_FEEDBACK_HISTORY=0 writes nothing and keeps the legacy draft behavior", async () => {
237
+ const dataDir = useTempDataDir();
238
+ process.env.PLANNOTATOR_FEEDBACK_HISTORY = "0";
239
+ const server = await startReviewServer({ rawPatch: PATCH, gitRef: "HEAD", htmlContent: MINIMAL_HTML });
240
+ try {
241
+ await fetch(`${server.url}/api/draft`, {
242
+ method: "POST",
243
+ headers: { "Content-Type": "application/json" },
244
+ body: JSON.stringify({ annotations: [{ id: "a1" }] }),
245
+ });
246
+ const response = await fetch(`${server.url}/api/feedback`, {
247
+ method: "POST",
248
+ headers: { "Content-Type": "application/json" },
249
+ body: JSON.stringify({ feedback: "secret", annotations: [{ id: "a1" }] }),
250
+ });
251
+ expect(response.status).toBe(200);
252
+ expect(existsSync(feedbackDir(dataDir))).toBe(false);
253
+ expect((await fetch(`${server.url}/api/draft`)).status).toBe(404);
254
+ } finally {
255
+ server.stop();
256
+ }
257
+ });
258
+ });
259
+
260
+ describe("pi feedback archive: annotate", () => {
261
+ test("a single local file submission is recorded with its file path", async () => {
262
+ const dataDir = useTempDataDir();
263
+ const dir = makeTempDir("plannotator-pi-feedback-doc-");
264
+ const docPath = join(dir, "notes.md");
265
+ writeFileSync(docPath, "# Notes\n\nBody\n", "utf-8");
266
+ const server = await startAnnotateServer({
267
+ markdown: "# Notes\n\nBody\n",
268
+ filePath: docPath,
269
+ htmlContent: MINIMAL_HTML,
270
+ project: PI_FILE_ANNOTATE_PROJECT,
271
+ });
272
+ try {
273
+ const response = await fetch(`${server.url}/api/feedback`, {
274
+ method: "POST",
275
+ headers: { "Content-Type": "application/json" },
276
+ body: JSON.stringify({ feedback: "Rename the helper.", annotations: [] }),
277
+ });
278
+ expect(response.status).toBe(200);
279
+ const records = readOnlyIndex(dataDir);
280
+ expect(records[0].surface).toBe("annotate");
281
+ expect(records[0].decision).toBe("feedback");
282
+ expect(records[0].target?.filePath).toBe(resolve(docPath));
283
+ } finally {
284
+ server.stop();
285
+ }
286
+ });
287
+
288
+ test("PLANNOTATOR_ANNOTATE_HISTORY=0 keeps annotate sessions fully stateless", async () => {
289
+ const dataDir = useTempDataDir();
290
+ process.env.PLANNOTATOR_ANNOTATE_HISTORY = "0";
291
+ const dir = makeTempDir("plannotator-pi-feedback-stateless-");
292
+ const docPath = join(dir, "doc.md");
293
+ writeFileSync(docPath, "# Doc\n\nBody\n", "utf-8");
294
+ const server = await startAnnotateServer({
295
+ markdown: "# Doc\n\nBody\n",
296
+ filePath: docPath,
297
+ htmlContent: MINIMAL_HTML,
298
+ project: PI_STATELESS_ANNOTATE_PROJECT,
299
+ });
300
+ try {
301
+ const response = await fetch(`${server.url}/api/feedback`, {
302
+ method: "POST",
303
+ headers: { "Content-Type": "application/json" },
304
+ body: JSON.stringify({ feedback: "Secret excerpt", annotations: [{ id: "a1" }] }),
305
+ });
306
+ expect(response.status).toBe(200);
307
+ expect(existsSync(feedbackDir(dataDir))).toBe(false);
308
+ } finally {
309
+ server.stop();
310
+ }
311
+ });
312
+ });
313
+
314
+ describe("pi feedback archive: plan", () => {
315
+ const createdSlugs: Array<{ project: string; slug: string }> = [];
316
+
317
+ afterAll(() => {
318
+ for (const { project, slug } of createdSlugs) {
319
+ const versionPath = getPlanVersionPath(project, slug, 1);
320
+ if (versionPath) rmSync(join(versionPath, ".."), { recursive: true, force: true });
321
+ }
322
+ });
323
+
324
+ test("a denial names the history version file the decision was made on", async () => {
325
+ const dataDir = useTempDataDir();
326
+ const heading = `Pi feedback archive plan ${Math.random().toString(36).slice(2, 10)}`;
327
+ const server = await startPlanReviewServer({
328
+ plan: `# ${heading}\n\nStep one.\n`,
329
+ htmlContent: MINIMAL_HTML,
330
+ origin: "pi",
331
+ });
332
+ try {
333
+ const response = await fetch(`${server.url}/api/deny`, {
334
+ method: "POST",
335
+ headers: { "Content-Type": "application/json" },
336
+ body: JSON.stringify({ feedback: "Split step one in two.", planSave: { enabled: false } }),
337
+ });
338
+ expect(response.status).toBe(200);
339
+
340
+ const records = readOnlyIndex(dataDir);
341
+ expect(records.length).toBe(1);
342
+ expect(records[0].surface).toBe("plan");
343
+ expect(records[0].decision).toBe("denied");
344
+ expect(records[0].origin).toBe("pi");
345
+
346
+ const slug = records[0].target!.slug!;
347
+ const project = detectProjectName();
348
+ createdSlugs.push({ project, slug });
349
+
350
+ const versionFile = records[0].target!.planVersionFile!;
351
+ expect(versionFile).toBe(getPlanVersionPath(project, slug, 1));
352
+ expect(readFileSync(versionFile, "utf-8")).toContain(heading);
353
+ } finally {
354
+ server.stop();
355
+ }
356
+ });
357
+ });
@@ -17,7 +17,8 @@ import { contentHash, deleteDraft } from "../generated/draft.ts";
17
17
  import { getPlanVersion, getVersionCount, listVersions } from "../generated/storage.ts";
18
18
  import { computeAnnotateHistory, deriveAnnotateHistorySlug, persistAnnotateSubmission, type AnnotateHistoryResult } from "../generated/annotate-history.ts";
19
19
  import { htmlDiff } from "../generated/html-diff.ts";
20
- import { saveConfig, detectGitUser, getServerConfig, isAgentTerminalSide, loadConfig, resolveAIEnabled, resolveSharingEnabled, resolveAnnotateHistory, type PromptRuntime } from "../generated/config.ts";
20
+ import { saveConfig, detectGitUser, getServerConfig, isAgentTerminalSide, loadConfig, resolveAIEnabled, resolveSharingEnabled, resolveAnnotateHistory, resolveFeedbackHistory, type PromptRuntime } from "../generated/config.ts";
21
+ import { appendFeedbackRecord, type FeedbackDecision, type FeedbackSurface } from "../generated/feedback-archive.ts";
21
22
  import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts";
22
23
  import { getAnnotateFileFeedbackTemplate, getAnnotateMessageFeedbackTemplate } from "../generated/prompts.ts";
23
24
  import { disabledSourceSave, type SourceSaveRequest } from "../generated/source-save.ts";
@@ -397,6 +398,53 @@ export async function startAnnotateServer(options: {
397
398
  // written, when there was no user content to lose, or when the session
398
399
  // does not persist; false only when a durable write was expected and
399
400
  // failed — the draft then stays behind as the recovery copy.
401
+ // --- Durable feedback archive (Node mirror of packages/server/annotate.ts) ---
402
+ //
403
+ // Unlike the legacy #678 record above, the archive covers EVERY annotate
404
+ // session type: it stores what the reviewer submitted, not a copy of the
405
+ // annotated document. Both gates apply — PLANNOTATOR_ANNOTATE_HISTORY=0
406
+ // still means "no annotate content in the data dir at all", and submitted
407
+ // feedback quotes that content, so the fully-stateless annotate promise
408
+ // stays verbatim true.
409
+ const annotateFeedbackSurface: FeedbackSurface =
410
+ options.mode === "annotate-app"
411
+ ? "annotate-app"
412
+ : options.mode === "annotate-last"
413
+ ? "annotate-last"
414
+ : options.mode === "annotate-folder"
415
+ ? "annotate-folder"
416
+ : singleFileLocalAnnotate
417
+ ? "annotate"
418
+ : "annotate-url";
419
+
420
+ const archiveAnnotateDecision = (
421
+ feedbackText: string,
422
+ annotationList: unknown[],
423
+ decision: FeedbackDecision,
424
+ ): boolean => {
425
+ if (!resolveFeedbackHistory(loadConfig())) return true;
426
+ if (!annotateHistoryEnabled) return true;
427
+ const isUrlTarget = /^https?:\/\//i.test(options.filePath);
428
+ return (
429
+ appendFeedbackRecord({
430
+ project: annotateProjectName,
431
+ origin: options.origin,
432
+ surface: annotateFeedbackSurface,
433
+ decision,
434
+ target:
435
+ options.mode === "annotate-app" && options.liveApp
436
+ ? { url: options.liveApp.targetUrl }
437
+ : isUrlTarget
438
+ ? { url: options.filePath }
439
+ : options.mode === "annotate-last"
440
+ ? { filePath: options.filePath }
441
+ : { filePath: resolvePath(options.filePath) },
442
+ feedback: feedbackText,
443
+ annotations: annotationList,
444
+ }) !== null
445
+ );
446
+ };
447
+
400
448
  const persistSubmittedDecision = (
401
449
  feedback: unknown,
402
450
  annotations: unknown,
@@ -407,18 +455,26 @@ export async function startAnnotateServer(options: {
407
455
  // behavior (settle + delete draft + 200), never throw into a 500.
408
456
  const feedbackText = typeof feedback === "string" ? feedback : "";
409
457
  const annotationList = Array.isArray(annotations) ? annotations : [];
410
- if (!feedbackText.trim() && annotationList.length === 0) return true; // contentless (e.g. bare approve)
411
- if (!annotateHistoryEnabled) return true; // opt-out: stateless annotate sessions
412
- if (!singleFileLocalAnnotate) return true; // stateless modes stay stateless
413
- return (
414
- persistAnnotateSubmission({
415
- project: annotateProjectName,
416
- sessionPath: resolvePath(options.filePath),
417
- feedback: feedbackText,
418
- annotations: annotationList,
419
- approved,
420
- }) !== null
458
+ const hasContent = feedbackText.trim().length > 0 || annotationList.length > 0;
459
+ const archived = archiveAnnotateDecision(
460
+ feedbackText,
461
+ annotationList,
462
+ approved ? (hasContent ? "approved-with-notes" : "approved") : "feedback",
421
463
  );
464
+ // Legacy #678 record: unchanged scope (single local files with content).
465
+ let legacyDurable = true;
466
+ if (hasContent && annotateHistoryEnabled && singleFileLocalAnnotate) {
467
+ legacyDurable =
468
+ persistAnnotateSubmission({
469
+ project: annotateProjectName,
470
+ sessionPath: resolvePath(options.filePath),
471
+ feedback: feedbackText,
472
+ annotations: annotationList,
473
+ approved,
474
+ }) !== null;
475
+ }
476
+ // A failed write only holds the draft back when there was content to lose.
477
+ return legacyDurable && (archived || !hasContent);
422
478
  };
423
479
 
424
480
  // Detect repo info (cached for this session)
@@ -1005,6 +1061,9 @@ export async function startAnnotateServer(options: {
1005
1061
  sendAlreadyDecided(res);
1006
1062
  return;
1007
1063
  }
1064
+ // Decision-only line — a dismissal has no content, so a failed
1065
+ // write must not change the legacy draft behavior.
1066
+ archiveAnnotateDecision("", [], "dismissed");
1008
1067
  deleteDraft(draftKey, readDraftGenerationFromUrl(req));
1009
1068
  clientLease.cancel();
1010
1069
  json(res, { ok: true });
@@ -41,7 +41,8 @@ import {
41
41
  } from "./integrations.ts";
42
42
  import { buildAdvertisedUrl, listenOnPort } from "./network.ts";
43
43
 
44
- import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolveAIEnabled, resolveSharingEnabled } from "../generated/config.ts";
44
+ import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolveAIEnabled, resolveFeedbackHistory, resolveSharingEnabled } from "../generated/config.ts";
45
+ import { appendFeedbackRecord, type FeedbackDecision } from "../generated/feedback-archive.ts";
45
46
  import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts";
46
47
  import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.ts";
47
48
  import { composeImproveContext } from "../generated/pfm-reminder.ts";
@@ -137,6 +138,39 @@ export async function startPlanReviewServer(options: {
137
138
  }
138
139
  : null;
139
140
 
141
+ // Durable feedback archive (Node mirror of packages/server/index.ts).
142
+ // Appends the decision to feedback/{project}/index.jsonl at settlement
143
+ // time, independent of the client-side planSave setting, and names the
144
+ // history version file rather than copying the plan text. Plan policy on
145
+ // failure: log and proceed — an approval is never blocked on the archive.
146
+ //
147
+ // Data-dir asymmetry worth knowing: getPlanVersionPath resolves against the
148
+ // data directory generated/storage.ts captured at import time, while the
149
+ // archive resolves it per call. They agree in every real run and can only
150
+ // disagree if PLANNOTATOR_DATA_DIR changes mid-process, in which case
151
+ // planVersionFile names where the version file was actually written.
152
+ const archivePlanDecision = (decision: FeedbackDecision, feedback?: string): void => {
153
+ if (options.mode === "archive") return;
154
+ if (!resolveFeedbackHistory(loadConfig())) return;
155
+ const version = versionInfo?.version ?? 0;
156
+ appendFeedbackRecord({
157
+ project,
158
+ origin: options.origin ?? "pi",
159
+ surface: "plan",
160
+ decision,
161
+ target: {
162
+ slug,
163
+ ...(version > 0
164
+ ? {
165
+ planVersion: version,
166
+ planVersionFile: getPlanVersionPath(project, slug, version) ?? undefined,
167
+ }
168
+ : {}),
169
+ },
170
+ feedback,
171
+ });
172
+ };
173
+
140
174
  const reviewId = randomUUID();
141
175
  let resolveDecision!: (result: PlanReviewDecision) => void;
142
176
  const decisionListeners = new Set<(result: PlanReviewDecision) => void | Promise<void>>();
@@ -414,6 +448,11 @@ export async function startPlanReviewServer(options: {
414
448
  planSaveCustomPath,
415
449
  );
416
450
  }
451
+ // Archive before the draft delete (#678 ordering, generalized).
452
+ archivePlanDecision(
453
+ typeof feedback === "string" && feedback.trim() ? "approved-with-notes" : "approved",
454
+ feedback,
455
+ );
417
456
  deleteDraft(draftKey, draftGeneration);
418
457
  const effectivePermissionMode = requestedPermissionMode || options.permissionMode;
419
458
  publishDecision({
@@ -456,6 +495,7 @@ export async function startPlanReviewServer(options: {
456
495
  planSaveCustomPath,
457
496
  );
458
497
  }
498
+ archivePlanDecision("denied", feedback);
459
499
  deleteDraft(draftKey, draftGeneration);
460
500
  publishDecision({ approved: false, feedback, savedPath });
461
501
  json(res, { ok: true, savedPath });