@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.
@@ -6,7 +6,8 @@ import { basename, resolve as resolvePath } from "node:path";
6
6
 
7
7
  import { SingleFlight } from "../generated/single-flight.ts";
8
8
  import { contentHash, deleteDraft } from "../generated/draft.ts";
9
- import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, resolveAIEnabled, resolveSharingEnabled, resolveCursorSandbox, resolveGuideHistory, resolveGuideShareUrl } from "../generated/config.ts";
9
+ import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, resolveAIEnabled, resolveSharingEnabled, resolveCursorSandbox, resolveFeedbackHistory, resolveGuideHistory, resolveGuideShareUrl } from "../generated/config.ts";
10
+ import { appendFeedbackRecord, countChangedFiles, deriveFeedbackProject, type FeedbackDecision, type FeedbackReviewTarget } from "../generated/feedback-archive.ts";
10
11
  import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts";
11
12
 
12
13
  export type {
@@ -295,6 +296,15 @@ export async function startReviewServer(options: {
295
296
  * once a pool checkout is ready.
296
297
  */
297
298
  prPatchIncomplete?: boolean;
299
+ /**
300
+ * Detected project name, used to key the durable feedback archive
301
+ * (`feedback/{project}/`). Mirrors the annotate server's `project` option.
302
+ * Callers should pass `detectProjectName()`; without it the server falls
303
+ * back to deriving a name from the review's working directory, which is
304
+ * wrong in PR mode (no `gitContext`, and `--local` points `agentCwd` at a
305
+ * `pool/pr-<n>` checkout, so records would bucket under `pr-123`).
306
+ */
307
+ project?: string;
298
308
  /** Working directory for agent processes (e.g., --local worktree). Independent of diff pipeline. */
299
309
  agentCwd?: string;
300
310
  /** Local parent directory containing multiple child VCS repositories. */
@@ -828,6 +838,77 @@ export async function startReviewServer(options: {
828
838
  return `${draftKey}:${currentDiffType}${isPRMode ? `:${currentPRDiffScope}` : ""}${currentContextRevision ? `:${currentContextRevision}` : ""}`;
829
839
  }
830
840
 
841
+ // --- Durable feedback archive (Node mirror of packages/server/review.ts) ---
842
+ //
843
+ // Code review persisted nothing on submit: the draft was deleted, the
844
+ // decision promise settled, and a timed-out agent meant the review existed
845
+ // nowhere. Every submission now appends one record to
846
+ // feedback/{project}/index.jsonl BEFORE the draft is deleted, carrying diff
847
+ // IDENTITY (refs, view, snapshot id, size) but never the patch bytes.
848
+ // Project bucketing: prefer the caller's detected project name. The cwd
849
+ // fallback is only right for a plain local review — PR mode has no
850
+ // gitContext, and `--local` sets agentCwd to a `pool/pr-<n>` checkout, so
851
+ // deriving from cwd there would file every PR review under `pr-123`.
852
+ //
853
+ // Known limitation, deliberately not chased here: a caller that passes no
854
+ // project AND reviews a moved/renamed working directory buckets under the
855
+ // new directory name, exactly like the rest of the data dir does.
856
+ const feedbackProject = (): string =>
857
+ options.project?.trim()
858
+ ? options.project
859
+ : deriveFeedbackProject(options.gitContext?.cwd ?? options.agentCwd ?? process.cwd());
860
+
861
+ const feedbackReviewTarget = (): FeedbackReviewTarget => {
862
+ const target: FeedbackReviewTarget = {
863
+ diffType: String(currentDiffType),
864
+ base: currentBase,
865
+ gitRef: currentGitRef,
866
+ snapshotId: currentSnapshotId(),
867
+ changedFiles: countChangedFiles(currentPatch),
868
+ patchBytes: currentPatch.length,
869
+ };
870
+ if (sessionVcsType) target.vcsType = sessionVcsType;
871
+ else if (workspace) target.vcsType = "workspace";
872
+ const cwd = options.gitContext?.cwd ?? options.agentCwd;
873
+ if (cwd) target.cwd = cwd;
874
+ if (prMeta) {
875
+ target.pr = {
876
+ provider: prMeta.platform,
877
+ repo:
878
+ prMeta.platform === "github"
879
+ ? `${prMeta.owner}/${prMeta.repo}`
880
+ : prMeta.projectPath,
881
+ number: prMeta.platform === "github" ? prMeta.number : prMeta.iid,
882
+ };
883
+ }
884
+ return target;
885
+ };
886
+
887
+ /**
888
+ * Returns whether the draft delete may proceed: false only when a durable
889
+ * write was expected and failed, so the draft stays as the recovery copy.
890
+ */
891
+ const archiveReviewSubmission = (
892
+ feedback: unknown,
893
+ annotations: unknown,
894
+ decision: FeedbackDecision,
895
+ ): boolean => {
896
+ if (!resolveFeedbackHistory(loadConfig())) return true;
897
+ const feedbackText = typeof feedback === "string" ? feedback : "";
898
+ const annotationList = Array.isArray(annotations) ? annotations : [];
899
+ const hasContent = feedbackText.trim().length > 0 || annotationList.length > 0;
900
+ const written = appendFeedbackRecord({
901
+ project: feedbackProject(),
902
+ origin: options.origin ?? "pi",
903
+ surface: "review",
904
+ decision,
905
+ target: { review: feedbackReviewTarget() },
906
+ feedback: feedbackText,
907
+ annotations: annotationList,
908
+ });
909
+ return written !== null || !hasContent;
910
+ };
911
+
831
912
  function buildCurrentAiReviewContext(
832
913
  patch: string = currentPatch,
833
914
  base: string = currentBase,
@@ -3269,17 +3350,35 @@ export async function startReviewServer(options: {
3269
3350
  handleApiNotFound(res, url.pathname);
3270
3351
  return;
3271
3352
  } else if (url.pathname === "/api/exit" && req.method === "POST") {
3353
+ // Decision-only line: dismissal rate is behavior data, and a
3354
+ // contentless failure must not change the legacy draft behavior.
3355
+ archiveReviewSubmission("", [], "dismissed");
3272
3356
  deleteDraft(draftKey, readDraftGenerationFromUrl(req));
3273
3357
  resolveDecision({ approved: false, feedback: '', annotations: [], exit: true });
3274
3358
  json(res, { ok: true });
3275
3359
  } else if (url.pathname === "/api/feedback" && req.method === "POST") {
3276
3360
  try {
3277
3361
  const body = await parseBody(req);
3278
- deleteDraft(draftKey, readDraftGenerationFromBody(body));
3362
+ // Archive BEFORE the draft delete: a failed write keeps the
3363
+ // draft as the reviewer's recovery copy (#678 ordering).
3364
+ // Defensive on the body's own types: a malformed value must
3365
+ // degrade to the legacy behavior (settle + 200), never throw.
3366
+ const approved = (body.approved as boolean) ?? false;
3367
+ const feedbackText = (body.feedback as string) || "";
3368
+ const annotationList = (body.annotations as unknown[]) || [];
3369
+ const hasContent =
3370
+ (typeof feedbackText === "string" && feedbackText.trim().length > 0) ||
3371
+ (Array.isArray(annotationList) && annotationList.length > 0);
3372
+ const durable = archiveReviewSubmission(
3373
+ feedbackText,
3374
+ annotationList,
3375
+ approved ? (hasContent ? "approved-with-notes" : "lgtm") : "feedback",
3376
+ );
3377
+ if (durable) deleteDraft(draftKey, readDraftGenerationFromBody(body));
3279
3378
  resolveDecision({
3280
- approved: (body.approved as boolean) ?? false,
3281
- feedback: (body.feedback as string) || "",
3282
- annotations: (body.annotations as unknown[]) || [],
3379
+ approved,
3380
+ feedback: feedbackText,
3381
+ annotations: annotationList,
3283
3382
  agentSwitch: body.agentSwitch as string | undefined,
3284
3383
  });
3285
3384
  json(res, { ok: true });