prreviewbuddy 0.76.9 → 0.78.1

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.
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as resolveWorkspacePort, $r as explanationOf, $t as readNotification, A as parsePullRequestLink, Ai as readAgentPreference, An as MANAGED_ROOT, Ar as activityLabel, At as storeArchitecture, Bn as checkCodeFreshness, Br as queueRemove, Bt as branchOf, C as completeRun, Ci as DetachedHeadError, Cn as renderMarkdown, Ct as describeRuntimes, D as relativeTime$1, Di as resolveAgent, Dn as renderInlineText, Dr as workspaceStamps, Dt as partFiles, E as stopRun, Ei as git, En as escapeHtml$1, Er as workspaceRevision, Et as loadArchitecture, Fi as agentById, Fn as removeWorktree, Fr as offersLifecycle, Ft as REPOSITORY_SCOPE, G as stopChild, Gn as forgeResolver, Gr as isQuestionOutstanding, Gt as createPrUpdate, Hn as followedBranch, Hr as readQueue, Ht as categoryOf, I as settleQueue, Ii as agentFor$1, In as requestJobStop, Ir as parseLifecycle, It as ancestorsOf, J as writeServerRecord, Jn as endedAt, Jr as resolvedByConversation, Jt as markAllRead, Kn as fillFileUrlTemplate, Kr as issuesOutstanding, Kt as eventName, L as readIndexToken, Li as detectAgents, Lr as sectionOf, Lt as isArchitectureId, M as runBatch, Mn as holdWorktree, Mr as lifecycleChip, Mt as trackedFiles, N as activateQueued, Ni as STORE_ROOT, Nn as isWorktreeHeld, Nr as lifecycleItemLabel, Nt as ownersOf, O as batchIdentity, Oi as resolveModel, Or as LIFECYCLES, Ot as readArchitecture, P as compactQueue, Pi as AGENT_IDS, Pn as readMarker, Pr as lifecyclePill, Pt as QUOTE_LIMITS, Q as parseWorkspacePort, Qn as saveJob, Qr as explanationActionOf, Qt as pruneNotifications, Ri as AgentCancelledError, Rr as queueAdd, Rt as writeJsonAtomically, S as beginRun, Si as originRemote, Sn as GITHUB_MARKDOWN, Sr as reviewedCommit, St as RUNTIME_WORDS, T as reportRun, Ti as resolveBase, Tn as CODE_COPY_CLASS, Tr as touchWorkspace, Tt as checkArchitecture, U as serverEntryExists, Un as reviewedShaOf, Ur as doneVerb, Ut as createArchitectureNotification, V as readServerRecord, Vn as checkReview, Vr as queueStamp, Vt as subjectOf, W as spawnServer, Wn as fetchHead, Wr as isIssueOutstanding, Wt as createPrState, X as MAX_WORKSPACE_PORT, Xn as isTerminal, Xr as processDiscussion, Xt as markReadForReview, Y as DEFAULT_WORKSPACE_PORT, Yn as fail, Yr as reviewerDispositions, Yt as markRead, Z as MIN_WORKSPACE_PORT, Zn as loadJob, Zr as recommendationsOf, Zt as moveIds, _i as objectWithAnyOf, _n as EXPLAIN_SIMPLY_PROMPT, _r as mutateWorkspace, a as wasBlocked, ai as currentGradeIn, an as unreadCount$1, ar as isClaimed, at as reanalyseReview, b as track, bi as kissOutcome, bn as authorPreview, br as previousKissResult, bt as mapRuntimes, c as RefreshUnavailableError, ci as gradeScale, cn as ASK_REREAD_CEILING_MS, cr as groupByLineage, d as mergeUpdateInto, di as reassessIn, dn as saveRefresh, dr as kissDecisionRefusal, ei as isExplanationAction, en as readStateOf, er as isLiveJob, et as statedWorkspacePort, f as updateReview, fi as sourcePhrase, fn as storedConversation, fr as kissDecisionsOf, gi as extractJsonText, gn as agentEnvOf, gt as BUILD_VERSION, h as liveUpdateFor, hi as asString, hn as isUnchanged, hr as loadWorkspace, i as liveJobsFor, ii as assessmentLookup, in as unreadAround, it as settleReview, j as unsupportedReason, ji as writeAgentPreference, jn as createWorktree, jr as isLifecycle, jt as storedScopes, k as extractPullRequestLinks, ki as clearAgentPreference, kn as ReviewBeingDeletedError, kr as activityAttributes, kt as repositoryKey, l as cancelUpdate, li as isGradeFor, ln as adoptLegacyBaseline, lr as indexKeptIds, lt as readEvents, m as UpdateAlreadyRunningError, mi as followUpFindingId, mn as refreshPrContext, mr as lineagePosition, mt as telemetryUploadConsent, n as deleteReview, ni as ANALYSIS_ONLY, nn as retireNotification, nr as progressSteps, nt as failureOf$1, o as discardJob, oi as findingAssessment, on as withUsageRecorded, or as decideKissRecommendation, ot as runJob, p as askCheckout, pi as wasReassessed, pn as unforeseenFailure, pr as latestKissRun, pt as recordTelemetryUploadConsent, qn as allJobs, qr as questionsOutstanding, qt as listNotifications, r as lineageIds, ri as analysisRefOf, rn as toneOf, rr as purposeOf, rt as prepareReview, s as FETCH_TIMEOUT_MS, si as findingIn, sn as existingPaths, sr as followedRefName, t as deleteLineage, ti as withExplanation, tn as removeNotification, tr as phasesFor, tt as writePortPreference, u as cancellableHere, ui as promptGrade, un as prepareConversationWithin, ur as indexReviewGroups, ut as record, v as startKissJob, vi as describeAuthorship, vn as chatConversationSection, vt as feedbackUrl, w as endRun, wi as isCommitReview, wn as CODE_BLOCK_CLASS, wt as ARCHITECTURE_DIR, x as RUN_KINDS, xi as resolveTarget, xn as hasAuthorContent, xr as recentWorkspaces, xt as recordRuntimeBestEffort, y as isDriving, yi as WITHHELD_REASON, yn as UNESTABLISHED_MOTIVATION, yr as presentedKissRunOf, yt as waitingHistory, z as clearServerRecordOwnedBy, zi as AgentUnavailableError, zn as findExistingReview, zr as queueAddMany, zt as announceJob } from "./delete_review-BX3lu5Q7.js";
1
+ import { $ as resolveWorkspacePort, $n as saveJob, $r as explanationActionOf, $t as pruneNotifications, A as parsePullRequestLink, Ai as git, An as ReviewBeingDeletedError, Ar as activityAttributes, At as repositoryKey, Bi as agentById, Bn as findExistingReview, Br as queueAddMany, Bt as announceJob, C as completeRun, Ci as originRemote, Cn as GITHUB_MARKDOWN, Cr as reviewedCommit, Ct as RUNTIME_WORDS, D as relativeTime$1, Di as AmbiguousRefError, Dn as escapeHtml$1, Dr as workspaceRevision, Dt as loadArchitecture, E as stopRun, Ei as resolveBase, En as CODE_COPY_CLASS, Er as touchWorkspace, Et as checkArchitecture, Fi as readAgentPreference, Fn as readMarker, Fr as lifecyclePill, Ft as QUOTE_LIMITS, G as stopChild, Gn as fetchHead, Gr as isIssueOutstanding, Gt as createPrState, Hi as detectAgents, Hn as checkReview, Hr as queueStamp, Ht as subjectOf, I as settleQueue, Ii as writeAgentPreference, In as removeWorktree, Ir as offersLifecycle, It as REPOSITORY_SCOPE, J as writeServerRecord, Jn as allJobs, Jr as questionsOutstanding, Jt as listNotifications, Kn as forgeResolver, Kr as isQuestionOutstanding, Kt as createPrUpdate, L as readIndexToken, Ln as requestJobStop, Lr as parseLifecycle, Lt as ancestorsOf, M as runBatch, Mi as resolveAgent, Mn as createWorktree, Mr as isLifecycle, Mt as storedScopes, N as activateQueued, Ni as resolveModel, Nn as holdWorktree, Nr as lifecycleChip, Nt as trackedFiles, O as batchIdentity, Oi as shortNameOf, On as renderInlineText, Or as workspaceStamps, Ot as partFiles, P as compactQueue, Pi as clearAgentPreference, Pn as isWorktreeHeld, Pr as lifecycleItemLabel, Pt as ownersOf, Q as parseWorkspacePort, Qn as loadJob, Qr as recommendationsOf, Qt as moveIds, Ri as STORE_ROOT, Rr as sectionOf, Rt as isArchitectureId, S as beginRun, Si as resolveTarget, Sn as hasAuthorContent, Sr as recentWorkspaces, St as recordRuntimeBestEffort, T as reportRun, Ti as isCommitReview, Tn as CODE_BLOCK_CLASS, Tt as ARCHITECTURE_DIR, U as serverEntryExists, Ui as AgentCancelledError, Un as followedBranch, Ur as readQueue, Ut as categoryOf, V as readServerRecord, Vi as agentFor$1, Vn as checkCodeFreshness, Vr as queueRemove, Vt as branchOf, W as spawnServer, Wi as AgentUnavailableError, Wn as reviewedShaOf, Wr as doneVerb, Wt as createArchitectureNotification, X as MAX_WORKSPACE_PORT, Xn as fail$1, Xr as reviewerDispositions, Xt as markRead, Y as DEFAULT_WORKSPACE_PORT, Yn as endedAt, Yr as resolvedByConversation, Yt as markAllRead, Z as MIN_WORKSPACE_PORT, Zn as isTerminal, Zr as processDiscussion, Zt as markReadForReview, _i as extractJsonText, _n as agentEnvOf, _t as BUILD_VERSION, a as wasBlocked, ai as assessmentLookup, an as unreadAround, at as settleReview, b as track, bi as WITHHELD_REASON, bn as UNESTABLISHED_MOTIVATION, br as presentedKissRunOf, bt as waitingHistory, c as RefreshUnavailableError, ci as findingIn, cn as existingPaths, cr as followedRefName, d as mergeUpdateInto, di as promptGrade, dn as prepareConversationWithin, dr as indexReviewGroups, dt as record, ei as explanationOf, en as readNotification, et as statedWorkspacePort, f as updateReview, fi as reassessIn, fn as saveRefresh, fr as kissDecisionRefusal, gi as asString, gn as isUnchanged, gr as loadWorkspace, h as liveUpdateFor, hi as followUpFindingId, hn as refreshPrContext, hr as lineagePosition, ht as telemetryUploadConsent, i as liveJobsFor, ii as analysisRefOf, in as toneOf, ir as purposeOf, it as prepareReview, j as unsupportedReason, ji as refExists, jn as MANAGED_ROOT, jr as activityLabel, jt as storeArchitecture, k as extractPullRequestLinks, ki as shorthandOf, kr as LIFECYCLES, kt as readArchitecture, l as cancelUpdate, li as gradeScale, ln as ASK_REREAD_CEILING_MS, lr as groupByLineage, m as UpdateAlreadyRunningError, mi as wasReassessed, mn as unforeseenFailure, mr as latestKissRun, mt as recordTelemetryUploadConsent, n as deleteReview, ni as withExplanation, nn as removeNotification, nr as phasesFor, nt as failureOf$1, o as discardJob, oi as currentGradeIn, on as unreadCount$1, or as isClaimed, ot as reanalyseReview, p as askCheckout, pi as sourcePhrase, pn as storedConversation, pr as kissDecisionsOf, qn as fillFileUrlTemplate, qr as issuesOutstanding, qt as eventName, r as lineageIds, ri as ANALYSIS_ONLY, rn as retireNotification, rr as progressSteps, rt as observeReview, s as FETCH_TIMEOUT_MS, si as findingAssessment, sn as withUsageRecorded, sr as decideKissRecommendation, st as runJob, t as deleteLineage, ti as isExplanationAction, tn as readStateOf, tr as isLiveJob, tt as writePortPreference, u as cancellableHere, ui as isGradeFor, un as adoptLegacyBaseline, ur as indexKeptIds, ut as readEvents, v as startKissJob, vi as objectWithAnyOf, vn as EXPLAIN_SIMPLY_PROMPT, vr as mutateWorkspace, w as endRun, wi as DetachedHeadError, wn as renderMarkdown, wt as describeRuntimes, x as RUN_KINDS, xi as kissOutcome, xn as authorPreview, xr as previousKissResult, xt as mapRuntimes, y as isDriving, yi as describeAuthorship, yn as chatConversationSection, yt as feedbackUrl, z as clearServerRecordOwnedBy, zi as AGENT_IDS, zr as queueAdd, zt as writeJsonAtomically } from "./delete_review-BHOzMQ0_.js";
2
2
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -310,9 +310,13 @@ function cardLifecycleChip(pinned, lifecycle, activity) {
310
310
  * The two numbers are different questions and only both worth asking once they differ. A page of
311
311
  * thirteen branches reviewed once each would read "13 reviews · 13 branches", which invites the
312
312
  * reader to work out what the distinction is on the one page where there isn't one.
313
+ *
314
+ * `of` is every review on the page, given while the search or a filter is narrowing the list:
315
+ * "12 of 71 reviews" says the list is filtered and by how much, where "12 reviews" reads as all
316
+ * there are.
313
317
  */
314
- function countLabel(reviews, branches) {
315
- const label = `${reviews} review${reviews === 1 ? "" : "s"}`;
318
+ function countLabel(reviews, branches, of) {
319
+ const label = of === void 0 ? `${reviews} review${reviews === 1 ? "" : "s"}` : `${reviews} of ${of} review${of === 1 ? "" : "s"}`;
316
320
  if (reviews === branches) return label;
317
321
  return `${label} · ${branches} branch${branches === 1 ? "" : "es"}`;
318
322
  }
@@ -9475,9 +9479,17 @@ function renderControls(queued, authors, anyRequest) {
9475
9479
  </select>
9476
9480
  </label>
9477
9481
  ${anyRequest ? renderPrFilter() : ""}
9482
+ ${renderClearFilters()}
9478
9483
  </div>`;
9479
9484
  }
9480
9485
  /**
9486
+ * Puts the search and every filter back at once. Hidden until the browser finds one narrowing the
9487
+ * list: the hash is the browser's to read, and without script there is nothing to clear.
9488
+ */
9489
+ function renderClearFilters() {
9490
+ return `<button type="button" class="prb-index-clear" data-clear-filters hidden><span aria-hidden="true">×</span> Clear filters</button>`;
9491
+ }
9492
+ /**
9481
9493
  * The pull request's state upstream, beside Review state and not inside it, so the two combine.
9482
9494
  *
9483
9495
  * Absent when no card is of a pull request, for the author filter's reason: a select whose every
@@ -9505,11 +9517,16 @@ var GITHUB_MARK = "<svg class=\"prb-index-forge-mark\" viewBox=\"0 0 16 16\" wid
9505
9517
  *
9506
9518
  * Absent when no card carries a login, which is every page of local-branch reviews: a dropdown
9507
9519
  * whose only choice is Everyone is not a choice.
9520
+ *
9521
+ * The button holds the chosen name where the browser draws a select's menu itself, so a long one
9522
+ * can be cut short inside the box's fixed width (shell.css). A browser that does not parses the
9523
+ * button and its contents away and draws the select as it always has.
9508
9524
  */
9509
9525
  function renderAuthorFilter(authors) {
9510
9526
  if (authors.length === 0) return "";
9511
9527
  return `<label class="prb-index-status prb-index-forge">${GITHUB_MARK}Author:
9512
9528
  <select name="author" data-author-filter aria-label="Filter by pull request author">
9529
+ <button><selectedcontent></selectedcontent></button>
9513
9530
  <option value="">Everyone</option>${authors.map((author) => `<option value="${escapeHtml$1(author.handle)}">${escapeHtml$1(authorOptionLabel(author))}</option>`).join("")}
9514
9531
  </select>
9515
9532
  </label>`;
@@ -10688,7 +10705,8 @@ var TOPICS = [
10688
10705
  <li><code>prreviewbuddy open</code> to list the reviews you have and open this workspace</li>
10689
10706
  </ul>
10690
10707
  <p>Or press <kbd class="prb-kbd">⌘P</kbd> (<kbd class="prb-kbd">Ctrl+P</kbd> elsewhere) to add a
10691
- review from here. <code>prreviewbuddy help</code> lists every command, and
10708
+ review from here. The panel follows the reviews it started while it stays open, and closing it
10709
+ stops nothing. <code>prreviewbuddy help</code> lists every command, and
10692
10710
  <code>prreviewbuddy review --help</code> every option a review takes.</p>
10693
10711
  <p>There is no account and no API key. PR Review Buddy uses the coding agent installed on your
10694
10712
  machine and that agent's existing authentication, and never asks you for a credential.</p>`
@@ -11598,16 +11616,24 @@ function createSlots(capacity) {
11598
11616
  }
11599
11617
  /** How many pull requests one submission may name. See `MAX_PULL_REQUESTS_PER_BATCH`. */
11600
11618
  var MAX_BATCH = 20;
11601
- /** How long a finished batch stays answerable, for a panel that is still open on it. */
11619
+ /**
11620
+ * How long a finished batch stays answerable once nobody is asking about it. An open panel keeps
11621
+ * asking (`KEEPALIVE` in the client is well inside this), so a batch is only forgotten after its
11622
+ * panel has gone, and its Retry with it.
11623
+ */
11602
11624
  var FINISHED_BATCH_TTL_MS = 6e5;
11603
11625
  function realAddReviewDeps() {
11604
11626
  return {
11605
11627
  resolveAgent: (flag) => resolveAgent(flag ? { flag } : {}),
11606
- agents: async () => (await detectAgents()).map((agent) => ({
11607
- id: agent.id,
11608
- displayName: agent.displayName,
11609
- installed: agent.state === "installed"
11610
- })),
11628
+ agents: async () => (await detectAgents()).map((agent) => {
11629
+ const modelHelp = modelHelpOf(agent.id);
11630
+ return {
11631
+ id: agent.id,
11632
+ displayName: agent.displayName,
11633
+ installed: agent.state === "installed",
11634
+ ...modelHelp ? { modelHelp } : {}
11635
+ };
11636
+ }),
11611
11637
  folders: () => discoverWorkingFolders(),
11612
11638
  summaries: () => recentWorkspaces(Number.MAX_SAFE_INTEGER),
11613
11639
  remoteOf: originRemote,
@@ -11631,6 +11657,8 @@ function realAddReviewDeps() {
11631
11657
  findExisting: findExistingReview,
11632
11658
  prepare: prepareReview,
11633
11659
  settle: (workspaceId) => settleReview(workspaceId, "standard"),
11660
+ observe: (workspaceId) => observeReview(workspaceId),
11661
+ commitPresent: (root, sha) => refExists(root, sha),
11634
11662
  track,
11635
11663
  chooseFolder: folderChooser(),
11636
11664
  concurrency: 2,
@@ -11646,6 +11674,13 @@ function realAddReviewDeps() {
11646
11674
  home: homedir()
11647
11675
  };
11648
11676
  }
11677
+ function modelHelpOf(agentId) {
11678
+ try {
11679
+ return agentById(agentId).modelSelectionHelp;
11680
+ } catch {
11681
+ return;
11682
+ }
11683
+ }
11649
11684
  function agentNameOf(agentId) {
11650
11685
  try {
11651
11686
  return agentById(agentId).displayName;
@@ -11653,10 +11688,60 @@ function agentNameOf(agentId) {
11653
11688
  return agentId;
11654
11689
  }
11655
11690
  }
11656
- var refuse = (status, error) => ({
11691
+ var refuse = (status, error, extra = {}) => ({
11657
11692
  status,
11658
- body: { error }
11693
+ body: {
11694
+ error,
11695
+ ...extra
11696
+ }
11659
11697
  });
11698
+ function baseChoiceOf(error) {
11699
+ const option = (where) => {
11700
+ const matched = where === "remote" ? error.remote : error.local;
11701
+ const commits = where === "remote" ? error.ranges?.remote : error.ranges?.local;
11702
+ return {
11703
+ base: shorthandOf(matched.ref),
11704
+ display: where === "remote" ? shorthandOf(matched.ref) : shortNameOf(matched.ref),
11705
+ where,
11706
+ ...commits ? { commits: commits.length } : {}
11707
+ };
11708
+ };
11709
+ return {
11710
+ name: shortNameOf(error.local.ref),
11711
+ discovered: error.discovered,
11712
+ ...error.divergence ? { divergence: error.divergence } : {},
11713
+ options: [option("remote"), option("local")]
11714
+ };
11715
+ }
11716
+ /**
11717
+ * What the panel says for an error a review could not get past.
11718
+ *
11719
+ * Two errors are known by their class and said in the panel's own words. Anything else keeps its
11720
+ * message, which is already written for a person, but only its first paragraph is the sentence:
11721
+ * the rest (ref listings, commands to run) goes behind Technical details, kept whole for whoever
11722
+ * needs to diagnose it.
11723
+ */
11724
+ function refusalOf(error) {
11725
+ const text = message(error);
11726
+ if (error instanceof AmbiguousRefError) {
11727
+ const choice = baseChoiceOf(error);
11728
+ return {
11729
+ error: `\`${choice.name}\` is both a local and a remote branch, and they are different commits. Choose which one to compare against.`,
11730
+ detail: text,
11731
+ baseChoice: choice
11732
+ };
11733
+ }
11734
+ if (error instanceof DetachedHeadError) return { error: "This folder is on a detached HEAD, so there is no branch to review. Check out a branch in it, or review its pull request instead." };
11735
+ return briefly(text);
11736
+ }
11737
+ /** A message's first paragraph as the sentence, and the whole of it as the detail when there is more. */
11738
+ function briefly(text) {
11739
+ const first = text.split(/\n\s*\n/)[0].trim();
11740
+ return first && first !== text.trim() ? {
11741
+ error: first,
11742
+ detail: text
11743
+ } : { error: text };
11744
+ }
11660
11745
  /** A path as a person reads it, with their home folder as `~`. */
11661
11746
  function displayPath(path, home) {
11662
11747
  return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
@@ -11685,15 +11770,15 @@ function unresolvedAgent(resolution) {
11685
11770
  * harness's own fallback: an id is always passed down, so `startJob` never has to guess.
11686
11771
  */
11687
11772
  async function agentFor(flag, deps) {
11688
- if (flag !== void 0 && typeof flag !== "string") return refuse(400, "Say which agent by its id.");
11773
+ if (flag !== void 0 && typeof flag !== "string") return refuse(400, "Say which agent by its id.", { field: "agent" });
11689
11774
  const resolution = await deps.resolveAgent(flag || void 0);
11690
11775
  if (resolution.kind === "resolved") return { agentId: resolution.agent.id };
11691
- if (resolution.kind === "ambiguous") return refuse(400, "More than one review agent is installed. Choose one under Review options.");
11692
- return refuse(resolution.kind === "unknown-id" ? 400 : 409, unresolvedAgent(resolution));
11776
+ if (resolution.kind === "ambiguous") return refuse(400, "More than one review agent is installed. Choose one under Review options.", { field: "agent" });
11777
+ return refuse(resolution.kind === "unknown-id" ? 400 : 409, unresolvedAgent(resolution), { field: "agent" });
11693
11778
  }
11694
11779
  function modelOf(value) {
11695
11780
  if (value === void 0 || value === null || value === "") return void 0;
11696
- if (typeof value !== "string" || value.length > 200) return refuse(400, "The model must be a short name.");
11781
+ if (typeof value !== "string" || value.length > 200) return refuse(400, "The model must be a short name.", { field: "model" });
11697
11782
  return value.trim() || void 0;
11698
11783
  }
11699
11784
  /**
@@ -11802,38 +11887,115 @@ async function previewPullRequests(text, deps) {
11802
11887
  }
11803
11888
  var batches = /* @__PURE__ */ new Map();
11804
11889
  function forgetFinished(now) {
11805
- for (const [id, batch] of batches) if (batch.finishedAt !== null && now - batch.finishedAt > FINISHED_BATCH_TTL_MS) batches.delete(id);
11890
+ for (const [id, batch] of batches) if (batch.finishedAt !== null && now - Math.max(batch.finishedAt, batch.seenAt) > FINISHED_BATCH_TTL_MS) batches.delete(id);
11806
11891
  }
11807
11892
  function newBatch(rows) {
11808
11893
  forgetFinished(Date.now());
11894
+ const now = Date.now();
11809
11895
  const batch = {
11810
11896
  id: randomUUID(),
11811
11897
  rows,
11812
- finishedAt: null
11898
+ finishedAt: now,
11899
+ seenAt: now,
11900
+ running: 0,
11901
+ rerun: () => {}
11813
11902
  };
11814
11903
  batches.set(batch.id, batch);
11815
11904
  return batch;
11816
11905
  }
11906
+ /**
11907
+ * Count work against a batch, inside the restart guard. The batch is finished only when nothing
11908
+ * counted against it is still going, so a retry reopens a batch an open panel can then follow again.
11909
+ */
11910
+ function follow(batch, work, deps, driving = true) {
11911
+ batch.running += 1;
11912
+ batch.finishedAt = null;
11913
+ (driving ? deps.track(work) : work).catch(() => {}).finally(() => {
11914
+ batch.running -= 1;
11915
+ if (batch.running === 0) batch.finishedAt = Date.now();
11916
+ });
11917
+ }
11918
+ function fail(row, error) {
11919
+ const refusal = refusalOf(error);
11920
+ row.status = "failed";
11921
+ row.reason = refusal.error;
11922
+ if (refusal.detail) row.detail = refusal.detail;
11923
+ }
11817
11924
  var rowsOf = (batch) => batch.rows.map((row) => ({ ...row }));
11818
11925
  /**
11819
11926
  * Create the review for a row's target, or find the one it has, under the per-target lock. The
11820
11927
  * caller holds an analysis slot: nothing is created without one, so nothing created waits.
11821
11928
  */
11822
- async function prepareRow(row, target, agentId, model, deps) {
11823
- const prepared = await oneCreationAtATime(target, () => deps.prepare(target, agentId, false, model));
11929
+ /**
11930
+ * `fresh` is a fresh start's: a new review of the target as it is now, keeping whatever review the
11931
+ * branch or request already has, as `--fresh` does. Without it the lookup finds the latest review of
11932
+ * the same branch or request, which for a fresh start is the very failure being started over.
11933
+ */
11934
+ async function prepareRow(row, target, agentId, model, deps, fresh = false) {
11935
+ const prepared = await oneCreationAtATime(target, () => deps.prepare(target, agentId, fresh, model));
11824
11936
  row.reviewId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
11825
- if (prepared.kind === "existing") row.status = "already-reviewed";
11937
+ if (prepared.kind === "existing") row.status = prepared.existing.inProgress ? "reviewing" : "already-reviewed";
11826
11938
  else row.status = "reviewing";
11827
11939
  if (prepared.kind === "resume") row.kept = keptOf(prepared.failure.agentId, prepared.failure.requestedModel, deps);
11940
+ else delete row.kept;
11828
11941
  return prepared;
11829
11942
  }
11830
- async function settleRow(row, deps) {
11831
- const settled = await deps.settle(row.reviewId);
11943
+ /**
11944
+ * Take a prepared row to its end. A new or resumed review is run here, holding the slot. One that
11945
+ * something else is already analysing is watched instead, never driven: the watch is handed to the
11946
+ * batch rather than awaited, so the caller returns, and its slot goes back, at once, while the row
11947
+ * stays Analysing until that analysis ends. Counted against the batch, not against restarts.
11948
+ */
11949
+ async function completeRow(row, prepared, batch, deps) {
11950
+ if (prepared.kind !== "existing") return settleRow(row, deps.settle(row.reviewId));
11951
+ if (prepared.existing.inProgress) follow(batch, settleRow(row, deps.observe(row.reviewId)), deps, false);
11952
+ }
11953
+ async function settleRow(row, settling) {
11954
+ const settled = await settling;
11832
11955
  if (settled.ok) row.status = "ready";
11833
- else {
11956
+ else if (settled.stopped) {
11834
11957
  row.status = "failed";
11835
- row.reason = settled.stopped ? "Stopped" : settled.reason;
11958
+ row.reason = "Stopped";
11959
+ } else fail(row, settled.reason);
11960
+ }
11961
+ /**
11962
+ * A retry runs against the commit the row was first resolved to, never a newer one: a retry
11963
+ * resumes what was asked for, and a moved branch or pull request is a fresh review's business. When
11964
+ * that commit has gone (a force push, then a prune), there is nothing left to retry, and the row
11965
+ * says so and offers the fresh start instead of quietly reviewing something else.
11966
+ */
11967
+ async function stillThere(row, target, what, deps) {
11968
+ if (await deps.commitPresent(target.originRepoPath, target.sha)) return true;
11969
+ row.status = "failed";
11970
+ row.reason = `The commit this review was of (${target.sha.slice(0, 7)}) is no longer in this repository, so it can't be retried as it was. Start a fresh review to analyse the ${what} as it is now.`;
11971
+ row.stale = true;
11972
+ return false;
11973
+ }
11974
+ /**
11975
+ * Retry a row whose review exists, by that review's id. Not through the lookup a start uses, which
11976
+ * finds the latest review of the branch or request: one made since, of newer commits, would be
11977
+ * handed back in place of the one that failed, and a retry would quietly become a different review.
11978
+ *
11979
+ * Resumed when it is still a failure nothing is running, as its own page's Retry does. Otherwise
11980
+ * something has taken it up since (that Retry, a terminal), and it is watched rather than run again.
11981
+ */
11982
+ async function retryReview(row, batch, deps) {
11983
+ const reviewId = row.reviewId;
11984
+ const kept = deps.resumeOf(reviewId);
11985
+ row.status = "reviewing";
11986
+ if (kept) {
11987
+ row.kept = kept;
11988
+ return settleRow(row, deps.settle(reviewId));
11836
11989
  }
11990
+ follow(batch, settleRow(row, deps.observe(reviewId)), deps, false);
11991
+ }
11992
+ /**
11993
+ * A fresh start that ended before it made its review leaves the row exactly as it was: the old
11994
+ * target, the old review, and still `stale`. So the row keeps offering the fresh start, and a plain
11995
+ * Retry finds the old commit gone again, rather than resuming the old review against it.
11996
+ */
11997
+ function keepFreshPending(row, replaced) {
11998
+ if (row.status === "failed" && !replaced) row.stale = true;
11837
11999
  }
11838
12000
  function keptOf(agentId, model, deps) {
11839
12001
  return {
@@ -11879,40 +12041,55 @@ async function startPullRequestBatch(body, deps) {
11879
12041
  })));
11880
12042
  /** Which row claimed each pull request, by what it resolved to rather than how it was spelled. */
11881
12043
  const claimed = /* @__PURE__ */ new Map();
11882
- const lane = async (item, index) => {
12044
+ /** Each row's pull request as it was first resolved: head, base and all. What a retry runs against. */
12045
+ const resolved = [];
12046
+ const lane = async (item, index, fresh = false) => {
11883
12047
  const row = batch.rows[index];
11884
12048
  const release = await deps.slots.acquire();
11885
12049
  row.status = "reviewing";
12050
+ /** A fresh start has made its review, so the row now belongs to the new target. */
12051
+ let replaced = false;
11886
12052
  try {
11887
- const remote = await deps.remoteOf(item.folder);
11888
- if (!isGitHubRepository(remote, item.owner, item.repo)) {
11889
- row.status = "failed";
11890
- row.reason = remote ? `This folder is a clone of ${remote.owner}/${remote.repo}, not ${item.owner}/${item.repo}` : `This folder is not a clone of ${item.owner}/${item.repo}`;
11891
- return;
12053
+ let target = fresh ? void 0 : resolved[index];
12054
+ if (target) {
12055
+ if (!await stillThere(row, target, "pull request", deps)) return;
12056
+ if (row.reviewId) return await retryReview(row, batch, deps);
12057
+ } else {
12058
+ const remote = await deps.remoteOf(item.folder);
12059
+ if (!isGitHubRepository(remote, item.owner, item.repo)) {
12060
+ row.status = "failed";
12061
+ row.reason = remote ? `This folder is a clone of ${remote.owner}/${remote.repo}, not ${item.owner}/${item.repo}` : `This folder is not a clone of ${item.owner}/${item.repo}`;
12062
+ return;
12063
+ }
12064
+ target = await deps.resolvePullRequest(item.folder, item.url);
12065
+ if (!fresh) resolved[index] = target;
11892
12066
  }
11893
- const target = await deps.resolvePullRequest(item.folder, item.url);
11894
12067
  const identity = batchIdentity(target);
11895
12068
  const first = claimed.get(identity);
11896
- if (first !== void 0) {
12069
+ if (first !== void 0 && first !== index) {
11897
12070
  row.status = "already-reviewed";
11898
12071
  row.reason = `Same pull request as ${batch.rows[first].label}`;
11899
12072
  return;
11900
12073
  }
11901
12074
  claimed.set(identity, index);
11902
- if ((await prepareRow(row, target, agent.agentId, model, deps)).kind !== "existing") await settleRow(row, deps);
12075
+ const prepared = await prepareRow(row, target, agent.agentId, model, deps, fresh);
12076
+ if (fresh) {
12077
+ resolved[index] = target;
12078
+ replaced = true;
12079
+ }
12080
+ await completeRow(row, prepared, batch, deps);
11903
12081
  } catch (error) {
11904
- row.status = "failed";
11905
- row.reason = message(error);
12082
+ fail(row, error);
11906
12083
  } finally {
12084
+ if (fresh) keepFreshPending(row, replaced);
11907
12085
  release();
11908
12086
  }
11909
12087
  };
11910
- deps.track(runBatch(items, {
12088
+ batch.rerun = (index, fresh) => follow(batch, lane(items[index], index, fresh), deps);
12089
+ follow(batch, runBatch(items, {
11911
12090
  concurrency: deps.concurrency,
11912
12091
  run: lane
11913
- })).catch(() => {}).finally(() => {
11914
- batch.finishedAt = Date.now();
11915
- });
12092
+ }), deps);
11916
12093
  return {
11917
12094
  status: 200,
11918
12095
  body: {
@@ -11922,13 +12099,13 @@ async function startPullRequestBatch(body, deps) {
11922
12099
  };
11923
12100
  }
11924
12101
  /**
11925
- * A batch's rows as they stand. A batch this server does not know was either finished long ago,
11926
- * which an open panel never asks about because it stops asking once a batch finishes, or lost to a
11927
- * restart, which is the case worth saying plainly: whatever had not started never will.
12102
+ * A batch's rows as they stand. Asking counts as watching: a batch an open panel is still asking
12103
+ * about is never forgotten, so its Retry stays good for as long as the panel is open.
11928
12104
  */
11929
12105
  function batchRows(id) {
11930
12106
  const batch = batches.get(id);
11931
- if (!batch) return refuse(404, "The workspace server no longer knows this batch, most likely because it restarted. Reviews that had started are in your list. Any that were still waiting did not start.");
12107
+ if (!batch) return lostBatch();
12108
+ batch.seenAt = Date.now();
11932
12109
  return {
11933
12110
  status: 200,
11934
12111
  body: {
@@ -11937,10 +12114,39 @@ function batchRows(id) {
11937
12114
  }
11938
12115
  };
11939
12116
  }
11940
- /** A target refusal in the panel's words where the CLI's would name a command. */
11941
- function targetRefusal(error) {
11942
- if (error instanceof DetachedHeadError) return "This folder is on a detached HEAD, so there is no branch to review. Check out a branch in it, or review its pull request instead.";
11943
- return message(error);
12117
+ /**
12118
+ * Said without guessing why. A restart forgets every batch, and a batch nobody has asked about for
12119
+ * a while is forgotten too; the server cannot tell which happened, so it says what follows from
12120
+ * either.
12121
+ */
12122
+ var lostBatch = () => refuse(404, "This batch is no longer on the workspace server, so it can’t be followed or retried here. Reviews that had started are in your reviews list. Any that were still queued did not start.");
12123
+ /**
12124
+ * Run one failed row of a batch again, as it was first asked for: the same folder, agent, model
12125
+ * and base, and the same commits, never the branch or pull request as it has since moved on. A
12126
+ * review that was created and then failed resumes, as a Retry does on its own page. `fresh` is the
12127
+ * one exception, sent only when those commits have gone: it looks again at what is there now.
12128
+ * The other rows are left exactly as they are, and the batch is followed again until it ends.
12129
+ */
12130
+ function retryBatchRow(id, key, fresh = false) {
12131
+ const batch = batches.get(id);
12132
+ if (!batch) return lostBatch();
12133
+ const index = batch.rows.findIndex((row) => row.key === key);
12134
+ const row = batch.rows[index];
12135
+ if (!row) return refuse(404, "That review is not part of this batch.");
12136
+ if (row.status !== "failed") return refuse(409, "Only a review that failed can be tried again.");
12137
+ row.status = "waiting";
12138
+ delete row.reason;
12139
+ delete row.detail;
12140
+ delete row.stale;
12141
+ batch.seenAt = Date.now();
12142
+ batch.rerun(index, fresh === true);
12143
+ return {
12144
+ status: 200,
12145
+ body: {
12146
+ rows: rowsOf(batch),
12147
+ finished: false
12148
+ }
12149
+ };
11944
12150
  }
11945
12151
  /** The recent and discoverable folders, for the Branch panel's list. */
11946
12152
  async function listFolders(deps) {
@@ -11978,7 +12184,7 @@ async function inspectFolder(rawPath, deps, rawBase) {
11978
12184
  status: 200,
11979
12185
  body: {
11980
12186
  ok: false,
11981
- error: targetRefusal(error)
12187
+ ...refusalOf(error)
11982
12188
  }
11983
12189
  };
11984
12190
  }
@@ -12008,10 +12214,14 @@ async function inspectFolder(rawPath, deps, rawBase) {
12008
12214
  /**
12009
12215
  * Review the branch checked out in a folder: `prreviewbuddy` run there.
12010
12216
  *
12011
- * With a free analysis slot it answers once the review exists, and the analysis runs on in this
12012
- * process, counted by `track` like a Retry. With none free it answers `waiting` and a one-row batch
12013
- * to watch, and creates nothing until a slot frees: a review created to wait is a job nobody is
12014
- * running, which the sweep would fail. A review the branch already has needs no slot to hand back.
12217
+ * Always answers with a one-row batch, which is what the panel follows, so a branch shows its
12218
+ * progress exactly as a pull request does. With a free analysis slot it answers once the review
12219
+ * exists, and the analysis runs on in this process, counted by `track` like a Retry. With none free
12220
+ * it answers `waiting` and creates nothing until a slot frees: a review created to wait is a job
12221
+ * nobody is running, which the sweep would fail. A review the branch already has needs no slot.
12222
+ *
12223
+ * A refusal before anything exists comes back as a refusal, with the base question when that is
12224
+ * what it was, so the panel can ask it beside the folder rather than as a failed row.
12015
12225
  */
12016
12226
  async function startBranchReview(body, deps) {
12017
12227
  const path = typeof body?.path === "string" ? expandPath(body.path, deps.home) : null;
@@ -12023,7 +12233,10 @@ async function startBranchReview(body, deps) {
12023
12233
  try {
12024
12234
  target = await deps.resolveBranch(path, base);
12025
12235
  } catch (error) {
12026
- return refuse(400, targetRefusal(error));
12236
+ return {
12237
+ status: 400,
12238
+ body: refusalOf(error)
12239
+ };
12027
12240
  }
12028
12241
  const agent = await agentFor(body?.agent, deps);
12029
12242
  if (isAnswer(agent)) return agent;
@@ -12032,55 +12245,82 @@ async function startBranchReview(body, deps) {
12032
12245
  label: target.branch,
12033
12246
  status: "waiting"
12034
12247
  };
12248
+ const batch = newBatch([row]);
12249
+ /**
12250
+ * The branch's run, holding a slot. A retry runs against the commit first resolved, wherever the
12251
+ * branch has moved since, unless `fresh`, which looks at the folder again as it is now.
12252
+ */
12253
+ const run = async (release, again) => {
12254
+ const fresh = again === "fresh";
12255
+ let replaced = false;
12256
+ try {
12257
+ const next = fresh ? await deps.resolveBranch(path, base) : target;
12258
+ if (again === "retry") {
12259
+ if (!await stillThere(row, target, "branch", deps)) return;
12260
+ if (row.reviewId) return await retryReview(row, batch, deps);
12261
+ }
12262
+ const prepared = await prepareRow(row, next, agent.agentId, model, deps, fresh);
12263
+ target = next;
12264
+ replaced = fresh;
12265
+ await completeRow(row, prepared, batch, deps);
12266
+ } catch (error) {
12267
+ fail(row, error);
12268
+ } finally {
12269
+ if (fresh) keepFreshPending(row, replaced);
12270
+ release();
12271
+ }
12272
+ };
12273
+ batch.rerun = (_index, fresh) => follow(batch, (async () => run(await deps.slots.acquire(), fresh ? "fresh" : "retry"))(), deps);
12274
+ const answer = (outcome) => ({
12275
+ status: 200,
12276
+ body: {
12277
+ outcome,
12278
+ batchId: batch.id,
12279
+ rows: rowsOf(batch),
12280
+ ...row.reviewId ? { reviewId: row.reviewId } : {},
12281
+ ...row.kept ? { kept: row.kept } : {}
12282
+ }
12283
+ });
12035
12284
  const release = deps.slots.tryAcquire();
12036
12285
  if (!release) {
12037
12286
  const existing = await deps.findExisting(target);
12038
- if (existing && !deps.resumeOf(existing.id)) return {
12039
- status: 200,
12040
- body: {
12041
- outcome: "existing",
12042
- reviewId: existing.id
12043
- }
12044
- };
12045
- const batch = newBatch([row]);
12046
- deps.track((async () => {
12047
- const later = await deps.slots.acquire();
12048
- try {
12049
- if ((await prepareRow(row, target, agent.agentId, model, deps)).kind !== "existing") await settleRow(row, deps);
12050
- } catch (error) {
12051
- row.status = "failed";
12052
- row.reason = message(error);
12053
- } finally {
12054
- later();
12055
- }
12056
- })()).finally(() => {
12057
- batch.finishedAt = Date.now();
12058
- });
12059
- return {
12060
- status: 200,
12061
- body: {
12062
- outcome: "waiting",
12063
- batchId: batch.id
12287
+ if (existing && !deps.resumeOf(existing.id)) {
12288
+ row.reviewId = existing.id;
12289
+ if (!existing.inProgress) {
12290
+ row.status = "already-reviewed";
12291
+ return answer("existing");
12064
12292
  }
12065
- };
12293
+ row.status = "reviewing";
12294
+ follow(batch, settleRow(row, deps.observe(existing.id)), deps, false);
12295
+ return answer("existing");
12296
+ }
12297
+ follow(batch, (async () => run(await deps.slots.acquire(), "first"))(), deps);
12298
+ return answer("waiting");
12066
12299
  }
12067
12300
  let prepared;
12068
12301
  try {
12069
12302
  prepared = await prepareRow(row, target, agent.agentId, model, deps);
12070
12303
  } catch (error) {
12071
12304
  release();
12072
- return refuse(409, message(error));
12305
+ batches.delete(batch.id);
12306
+ return {
12307
+ status: 409,
12308
+ body: refusalOf(error)
12309
+ };
12073
12310
  }
12074
- if (prepared.kind === "existing") release();
12075
- else deps.track(deps.settle(row.reviewId)).catch(() => {}).finally(release);
12076
- return {
12077
- status: 200,
12078
- body: {
12079
- outcome: prepared.kind,
12080
- reviewId: row.reviewId,
12081
- ...row.kept ? { kept: row.kept } : {}
12311
+ if (prepared.kind === "existing") {
12312
+ await completeRow(row, prepared, batch, deps);
12313
+ release();
12314
+ } else follow(batch, (async () => {
12315
+ try {
12316
+ await settleRow(row, deps.settle(row.reviewId));
12317
+ } catch (error) {
12318
+ fail(row, error);
12319
+ } finally {
12320
+ release();
12082
12321
  }
12083
- };
12322
+ })(), deps);
12323
+ return answer(prepared.kind);
12084
12324
  }
12085
12325
  async function chooseFolder(deps) {
12086
12326
  if (!deps.chooseFolder) return refuse(404, "Choosing a folder is not available on this system. Type its path instead.");
@@ -13916,6 +14156,8 @@ async function handleAddReview(req, res, pathname, options) {
13916
14156
  if (route === "pull-requests") return startPullRequestBatch(body, deps);
13917
14157
  if (route === "folder/inspect") return inspectFolder(body?.path, deps, body?.base);
13918
14158
  if (route === "branch") return startBranchReview(body, deps);
14159
+ const retry = /^batches\/([0-9a-f-]{36})\/retry$/.exec(route);
14160
+ if (retry) return retryBatchRow(retry[1], body?.key, body?.fresh);
13919
14161
  return null;
13920
14162
  };
13921
14163
  const result = await answer();
@@ -15309,7 +15551,7 @@ function markAbandoned(job, now) {
15309
15551
  if (isClaimed(job.id, now)) return false;
15310
15552
  const current = loadJob(job.id);
15311
15553
  if (!current || current.updatedAt !== job.updatedAt) return false;
15312
- const abandoned = fail(current, ABANDONED_MESSAGE);
15554
+ const abandoned = fail$1(current, ABANDONED_MESSAGE);
15313
15555
  saveJob(abandoned);
15314
15556
  announceJob(abandoned);
15315
15557
  return true;