prreviewbuddy 0.25.7 → 0.25.13

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,7 +1,7 @@
1
- import { $ as checkCodeFreshness, A as agentEnvOf, At as processDiscussion, B as BUILD_VERSION, Bt as STORE_ROOT, C as statedWorkspacePort, Ct as touchWorkspace, D as refreshPrContext, Dt as issuesOutstanding, E as updateReview, Et as isQuestionOutstanding, H as feedbackUrl, I as readEvents, It as clearAgentPreference, K as fillFileUrlTemplate, Kt as AgentCancelledError, L as record, Lt as readAgentPreference, M as startKissJob, O as isUnchanged, Ot as questionsOutstanding, P as runJob, Pt as git, Q as removeWorktree, R as recordTelemetryUploadConsent, Rt as writeAgentPreference, S as resolveWorkspacePort, T as RefreshUnavailableError, Tt as isIssueOutstanding, U as withUsageRecorded, Vt as AGENT_IDS, W as EXPLAIN_SIMPLY_PROMPT, Wt as agentFor, X as MANAGED_ROOT, Y as isClaimed, Z as readMarker, _ as writeServerRecord, _t as recentReviewGroups, a as liveJobsFor, at as saveJob, b as MIN_WORKSPACE_PORT, c as discardJob, ct as followedRefName, d as clearServerRecord, et as checkFreshness, ft as lineagePosition, gt as previousKissResult, i as lineageIds, it as loadJob, jt as describeAuthorship, k as askCheckout, kt as reviewerDispositions, l as readIndexToken, n as deleteLineage, nt as fail, o as wasBlocked, ot as PHASES, p as readServerRecord, pt as loadWorkspace, q as ReviewBeingDeletedError, qt as AgentUnavailableError, r as deleteReview, rt as isTerminal, st as progressSteps, t as relativeTime$1, tt as allJobs, ut as latestKissRun, v as DEFAULT_WORKSPACE_PORT, w as writePortPreference, wt as doneVerb, x as parseWorkspacePort, xt as saveWorkspace, y as MAX_WORKSPACE_PORT, yt as reviewedCommit, z as telemetryUploadConsent } from "./relative_time-CDvK_Zaj.js";
1
+ import { $ as checkCodeFreshness, $t as AgentCancelledError, A as liveUpdateFor, At as workspaceStamps, B as recordTelemetryUploadConsent, C as writePortPreference, D as isUnchanged, E as refreshPrContext, Et as saveWorkspace, Ft as questionsOutstanding, G as withUsageRecorded, Gt as writeAgentPreference, H as BUILD_VERSION, I as runJob, It as reviewerDispositions, J as fillFileUrlTemplate, Jt as AGENT_IDS, K as EXPLAIN_SIMPLY_PROMPT, Lt as processDiscussion, M as agentEnvOf, Mt as isIssueOutstanding, Nt as isQuestionOutstanding, O as askCheckout, Ot as touchWorkspace, P as startKissJob, Pt as issuesOutstanding, Q as removeWorktree, R as readEvents, Rt as describeAuthorship, S as statedWorkspacePort, St as recentReviewGroups, T as updateReview, Ut as clearAgentPreference, V as telemetryUploadConsent, Vt as git, W as feedbackUrl, Wt as readAgentPreference, X as MANAGED_ROOT, Y as ReviewBeingDeletedError, Z as readMarker, Zt as agentFor, _ as DEFAULT_WORKSPACE_PORT, _t as lineagePosition, a as liveJobsFor, at as loadJob, b as parseWorkspacePort, c as readIndexToken, ct as phasesFor, en as AgentUnavailableError, et as checkFreshness, f as readServerRecord, ft as isClaimed, g as writeServerRecord, ht as latestKissRun, i as lineageIds, it as isTerminal, jt as doneVerb, k as UpdateAlreadyRunningError, kt as workspaceRevision, lt as progressSteps, n as deleteLineage, nt as endedAt, o as wasBlocked, ot as saveJob, pt as followedRefName, qt as STORE_ROOT, r as deleteReview, rt as fail, s as discardJob, t as relativeTime$1, tt as allJobs, u as clearServerRecord, ut as purposeOf, v as MAX_WORKSPACE_PORT, vt as loadWorkspace, w as RefreshUnavailableError, wt as reviewedCommit, x as resolveWorkspacePort, xt as previousKissResult, y as MIN_WORKSPACE_PORT, z as record } from "./relative_time-vn8Ex9E8.js";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
- import { timingSafeEqual } from "node:crypto";
4
+ import { createHash, timingSafeEqual } from "node:crypto";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createServer } from "node:http";
7
7
  //#region ../../packages/review-harness/src/domain/ai_review/review_composition.ts
@@ -2144,9 +2144,9 @@ function verdictLabel(verdict) {
2144
2144
  */
2145
2145
  var PHASE_CAVEAT = { preparing: "This can take a minute on a large repository." };
2146
2146
  /** The shared phase name as a sentence, with whatever this surface adds to it. */
2147
- function phaseCopy(phase) {
2148
- const caveat = PHASE_CAVEAT[phase];
2149
- return `${PHASES[phase].doing}.${caveat ? ` ${caveat}` : ""}`;
2147
+ function phaseCopy(phase, job) {
2148
+ const caveat = purposeOf(job) === "review" ? PHASE_CAVEAT[phase] : void 0;
2149
+ return `${phasesFor(job)[phase].doing}.${caveat ? ` ${caveat}` : ""}`;
2150
2150
  }
2151
2151
  /**
2152
2152
  * The same phases, as a noun, for the retry sentence.
@@ -2204,7 +2204,7 @@ function conversationNote$1(outcome) {
2204
2204
  * than a gap where a sentence should be.
2205
2205
  */
2206
2206
  function phaseSentence(job) {
2207
- return job && isWorkPhase(job.phase) ? phaseCopy(job.phase) : "";
2207
+ return job && isWorkPhase(job.phase) ? phaseCopy(job.phase, job) : "";
2208
2208
  }
2209
2209
  /**
2210
2210
  * A stopped run: what happened, and what would change it.
@@ -2248,7 +2248,7 @@ function renderJobState(job) {
2248
2248
  <p>${escapeHtml$1(note)}</p>
2249
2249
  </div>`;
2250
2250
  }
2251
- const copy = isWorkPhase(job.phase) ? phaseCopy(job.phase) : "";
2251
+ const copy = isWorkPhase(job.phase) ? phaseCopy(job.phase, job) : "";
2252
2252
  const progress = job.progress ? ` <span class="prb-job-progress">${escapeHtml$1(job.progress)}</span>` : "";
2253
2253
  return `<div class="prb-job" data-job-phase="${escapeHtml$1(job.phase)}" role="status" aria-live="polite">
2254
2254
  <p><span class="prb-spinner" aria-hidden="true"></span> ${escapeHtml$1(copy)}${progress}</p>
@@ -2261,7 +2261,7 @@ function jobStatus(job) {
2261
2261
  progress: "",
2262
2262
  failed: false
2263
2263
  };
2264
- const progress = job.phase === "failed" ? job.failure?.message ?? "" : job.progress ?? (isWorkPhase(job.phase) ? phaseCopy(job.phase) : "");
2264
+ const progress = job.phase === "failed" ? job.failure?.message ?? "" : job.progress ?? (isWorkPhase(job.phase) ? phaseCopy(job.phase, job) : "");
2265
2265
  return {
2266
2266
  phase: job.phase,
2267
2267
  progress,
@@ -3121,9 +3121,10 @@ function changeIsRead(job) {
3121
3121
  * second renderer, and the two would disagree the first time either changed.
3122
3122
  */
3123
3123
  function renderSteps(job) {
3124
+ const names = phasesFor(job);
3124
3125
  return `<ol class="prb-progress-steps">${progressSteps(job).map((step) => {
3125
3126
  const note = step.note ? `<p class="prb-progress-note" data-step-note>${escapeHtml$1(step.note)}</p>` : "";
3126
- return `<li class="prb-progress-step" data-phase="${escapeHtml$1(step.phase)}" data-state="${escapeHtml$1(step.state)}" data-label-doing="${escapeHtml$1(PHASES[step.phase].doing)}" data-label-done="${escapeHtml$1(PHASES[step.phase].done)}">
3127
+ return `<li class="prb-progress-step" data-phase="${escapeHtml$1(step.phase)}" data-state="${escapeHtml$1(step.state)}" data-label-doing="${escapeHtml$1(names[step.phase].doing)}" data-label-done="${escapeHtml$1(names[step.phase].done)}">
3127
3128
  <span class="prb-progress-mark" aria-hidden="true"></span>
3128
3129
  <span class="prb-progress-label">${escapeHtml$1(step.label)}</span>
3129
3130
  ${note}
@@ -3165,6 +3166,25 @@ function renderProgressView(job, fileCount) {
3165
3166
  ${renderAvailable(job, fileCount)}
3166
3167
  <p class="prb-progress-elapsed" data-started-at="${job.startedAt}">Running for ${escapeHtml$1(elapsedWords(job.startedAt))}.</p>`;
3167
3168
  }
3169
+ /**
3170
+ * An update running over the review it is updating.
3171
+ *
3172
+ * A strip above the review rather than the progress pane, because nothing here is missing. The
3173
+ * review below is still the current one until the update applies in a single write, so it stays
3174
+ * readable and in place; taking the page over would hide the one thing worth reading while waiting.
3175
+ *
3176
+ * Nothing once the job has ended. The reload that follows an update is what reports it, through the
3177
+ * outcome notice, and a strip still standing after that would be describing work that is over.
3178
+ */
3179
+ function renderUpdateProgress(job) {
3180
+ if (!job || job.phase === "done" || job.phase === "failed") return "";
3181
+ return `<div class="prb-update-progress" data-update-phase="${escapeHtml$1(job.phase)}" role="status" aria-live="polite">
3182
+ <p class="prb-update-progress-title"><span class="prb-spinner" aria-hidden="true"></span>Updating this review</p>
3183
+ <p class="prb-update-progress-lead">The review below stays as it is until the update finishes.</p>
3184
+ ${renderSteps(job)}
3185
+ <p class="prb-progress-elapsed" data-started-at="${job.startedAt}">Running for ${escapeHtml$1(elapsedWords(job.startedAt))}.</p>
3186
+ </div>`;
3187
+ }
3168
3188
  //#endregion
3169
3189
  //#region ../../packages/review-harness/src/workspace/page.ts
3170
3190
  function renderWorkspace(view) {
@@ -3194,13 +3214,14 @@ function renderWorkspace(view) {
3194
3214
  <link rel="stylesheet" href="/assets/sidebar.css">
3195
3215
  <link rel="stylesheet" href="/assets/shell.css">
3196
3216
  </head>
3197
- <body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
3217
+ <body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
3198
3218
  ${renderHeader(view, freshness)}
3199
3219
  ${showsProgress(view) ? "" : renderJobState(view.job ?? null)}
3200
3220
  <div class="prb-fresh-region" data-fresh-region${freshness ? ` data-report="${escapeHtml$1(JSON.stringify(freshness))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderFreshness(freshness ?? null, {
3201
3221
  dismissedSha: view.dismissedFreshnessSha,
3202
3222
  actions: view.token !== void 0
3203
3223
  })}</div>
3224
+ ${renderUpdateProgress(view.updateJob)}
3204
3225
  ${renderUpdateOutcome(view.lastUpdate)}
3205
3226
  ${renderLineageNotice(view)}
3206
3227
  ${renderReviewBody(view, sections)}
@@ -4131,6 +4152,21 @@ function statusLabel(status) {
4131
4152
  if (status === "complete") return "Reviewed";
4132
4153
  return status === "in-progress" ? "In progress" : "New";
4133
4154
  }
4155
+ /**
4156
+ * Whether any of that is work still going on, which is what sets the polling cadence.
4157
+ *
4158
+ * Here rather than beside `analysisStates` because this module is the DOM-free, filesystem-free
4159
+ * arithmetic both halves of the index share, and the renderer needs this one answer without
4160
+ * needing an import that reads the jobs directory to get it.
4161
+ *
4162
+ * A stopped analysis is a state, not an activity. Counting it as live would leave the index asking
4163
+ * once a second about a review that finished failing an hour ago, for as long as the tab stayed
4164
+ * open.
4165
+ */
4166
+ function anythingRunning(states) {
4167
+ for (const state of states.values()) if (state.phase !== "failed") return true;
4168
+ return false;
4169
+ }
4134
4170
  //#endregion
4135
4171
  //#region ../../packages/review-harness/src/workspace/reviews_page.ts
4136
4172
  function renderReviewsPage(view) {
@@ -4147,7 +4183,7 @@ function renderReviewsPage(view) {
4147
4183
  <link rel="stylesheet" href="/assets/sidebar.css">
4148
4184
  <link rel="stylesheet" href="/assets/shell.css">
4149
4185
  </head>
4150
- <body class="prb-index-body">
4186
+ <body class="prb-index-body"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map())}">
4151
4187
  <header class="prb-header">
4152
4188
  <span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
4153
4189
  <span class="prb-change-ref">Reviews</span>
@@ -4164,7 +4200,7 @@ function renderReviewsPage(view) {
4164
4200
  <button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
4165
4201
  </div>`}
4166
4202
  <div class="prb-cards" data-cards>
4167
- ${ordered.map((group) => renderCard(group, now)).join("")}
4203
+ ${ordered.map((group) => renderCard(group, now, view.analyses?.get(group.latest.id))).join("")}
4168
4204
  </div>
4169
4205
  ${ordered.length === 0 ? `<p class="prb-index-empty">No reviews stored yet. Ask Claude Code to review a change and one will appear here.</p>` : `<p class="prb-index-none" data-no-matches hidden>Nothing matches that.</p>`}
4170
4206
  ${ordered.length > 15 ? renderPager() : ""}
@@ -4265,7 +4301,7 @@ function renderBulkBar() {
4265
4301
  * card and is shown only in selection mode, so entering that mode is a stylesheet change rather
4266
4302
  * than a rebuild of the list.
4267
4303
  */
4268
- function renderCard(group, now) {
4304
+ function renderCard(group, now, analysis) {
4269
4305
  const review = group.latest;
4270
4306
  const status = statusOf(review);
4271
4307
  const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
@@ -4291,7 +4327,8 @@ function renderCard(group, now) {
4291
4327
  ${renderRisk(review.overallRisk)}
4292
4328
  <span>${review.changedFiles} file${review.changedFiles === 1 ? "" : "s"}</span>
4293
4329
  <span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">&minus;${review.deletions}</span></span>
4294
- <span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>
4330
+ ${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
4331
+ ${renderAnalysisState(analysis)}
4295
4332
  <span class="prb-card-when"><time datetime="${new Date(touchedAt(review)).toISOString()}" title="${escapeHtml$1(new Date(touchedAt(review)).toLocaleString())}">${escapeHtml$1(whenLabel(review, now))}</time></span>
4296
4333
  </p>
4297
4334
  </a>
@@ -4426,6 +4463,28 @@ function renderHistoryRow(review, number, latest, now) {
4426
4463
  </li>`;
4427
4464
  }
4428
4465
  /**
4466
+ * What is being done to this review right now, when anything is.
4467
+ *
4468
+ * The words come from `PHASES` in `job_progress.ts` and are not written again here. That table is
4469
+ * already what the terminal prints and what the review page's checklist shows, and the last time a
4470
+ * surface kept its own copy the two drifted until a stopped run read "Analysed the change" -- a
4471
+ * tick beside a verb saying the work had happened. One table, three surfaces.
4472
+ *
4473
+ * A failed analysis says so rather than saying nothing. Before this, a review being analysed, one
4474
+ * whose analysis had failed and one never analysed were the same card: all three said "No standard
4475
+ * review yet", which is true of all three and useful about none of them.
4476
+ *
4477
+ * Nothing at all for a job that is neither live nor failed. That is the non-terminal job with no
4478
+ * driver behind it -- killed terminal, crashed process -- which sits in a working phase for up to
4479
+ * half an hour before the sweep calls it stopped. Narrating it as running would be a lie, and
4480
+ * calling it stopped here would be this renderer pre-empting a decision the sweep makes.
4481
+ */
4482
+ function renderAnalysisState(analysis) {
4483
+ if (analysis === void 0) return "";
4484
+ if (analysis.phase === "failed") return "<span class=\"prb-card-analysis\" data-analysis=\"stopped\">Analysis stopped</span>";
4485
+ return `<span class="prb-card-analysis" data-analysis="running"><i></i>${escapeHtml$1(phasesFor({ purpose: analysis.purpose })[analysis.phase].doing)}</span>`;
4486
+ }
4487
+ /**
4429
4488
  * What this review still has open.
4430
4489
  *
4431
4490
  * Unresolved counts, not totals: "1 unanswered question" says where the review stands, where
@@ -4758,6 +4817,130 @@ function renderPrivacySection(settings) {
4758
4817
  </section>`;
4759
4818
  }
4760
4819
  //#endregion
4820
+ //#region ../../packages/review-harness/src/workspace/reviews_revision.ts
4821
+ /**
4822
+ * Whether anything the reviews index draws has changed, as one token.
4823
+ *
4824
+ * The index reacts to a change by reloading, which is what makes an aggregate the right shape: it
4825
+ * never needs to know *which* review changed, so it asks one question whose cost is the same for
4826
+ * five reviews and five hundred. Per-card revisions would be more information than the page has any
4827
+ * use for, and a request per card to carry it.
4828
+ *
4829
+ * Opaque and **equality only**, the same contract as `workspaceRevision` in `store.ts`: compare two
4830
+ * tokens for equality, never order them, never parse one. The implementation below is a hash of
4831
+ * facts the page happens to draw today, and the whole point of saying so here is that it can be
4832
+ * replaced by a counter, a watch, or anything else without a page having to be told.
4833
+ *
4834
+ * ## What is in it, and what is deliberately not
4835
+ *
4836
+ * In: every review's id and file revision, and the phase of every analysis running right now.
4837
+ * Between them those cover everything a card shows — the summary, the risk badge, the counts, the
4838
+ * pin, the reviewed mark, the running line — because every one of those reaches disk through
4839
+ * `writeWorkspace`, and the set of ids covers a review appearing or being deleted.
4840
+ *
4841
+ * Not in: **`job.updatedAt` and `job.progress`.** This is the load-bearing omission. An agent
4842
+ * streams its progress into the job record every few seconds, so a token derived from job file
4843
+ * mtimes would differ on nearly every poll, and a page that reloads whenever the token differs
4844
+ * would then reload every few seconds for the whole length of an analysis — a page thrashing
4845
+ * itself, with the reviewer's scroll position going with it. Phase only, which changes four times
4846
+ * in a run.
4847
+ */
4848
+ /**
4849
+ * What is being done to each review, or was last done and stopped.
4850
+ *
4851
+ * Keyed by review, valued by the phase the card should name. Two states reach it and a third
4852
+ * deliberately does not:
4853
+ *
4854
+ * - **Running.** A job that is not terminal *and* whose driver holds a live claim. The claim is the
4855
+ * load-bearing half: a job whose driver was killed sits in a working phase for up to half an hour
4856
+ * before the sweep calls it stopped, and narrating one of those as "Analysing the change" would
4857
+ * be the index confidently reporting work that nothing is doing.
4858
+ * - **`failed`.** Reported whether or not anything still holds the claim, which is the whole reason
4859
+ * this function is not simply `runningJobs`. It was: `runningJobs` drops terminal jobs, and
4860
+ * `isTerminal` counts `failed` as terminal, so a failed analysis could never reach the renderer
4861
+ * and the card's "Analysis stopped" branch was unreachable in production while passing a unit
4862
+ * test that handed it a state the product could not produce. A review whose analysis failed went
4863
+ * back to reading "No standard review yet" the moment its driver let go.
4864
+ * - **`done`, and an unclaimed job still in a working phase.** Nothing. A finished job has its
4865
+ * result on the card already, and an abandoned one is the sweep's to judge rather than this
4866
+ * renderer's to pre-empt.
4867
+ *
4868
+ * **Every job a review has, not the latest one.** A review accumulates them -- a standard analysis,
4869
+ * a KISS run, a retry -- and two of them genuinely run at once: `handleKiss` refuses only a second
4870
+ * KISS run, and the review page's poll is built around one finishing while the other goes on. An
4871
+ * earlier cut picked the single newest job and then asked what it was, so a KISS run that ended
4872
+ * first spoke for a standard analysis that was still working, and the card fell silent on work that
4873
+ * was happening in front of the reviewer.
4874
+ *
4875
+ * So the precedence is running, then the most recent ending, and it is stated in that order because
4876
+ * that is the property: **nothing that has ended can hide something that has not.**
4877
+ *
4878
+ * `jobs` is injected only so a test can hand the same set in two different orders and prove the
4879
+ * answer does not move. Production always reads the directory.
4880
+ *
4881
+ * A failure is visible for as long as the job record survives, which is the sweep's retention. Once
4882
+ * that passes the card quietly returns to saying nothing, which is the same silence every review
4883
+ * had before this existed.
4884
+ */
4885
+ function analysisStates(now = Date.now(), jobs = allJobs()) {
4886
+ const byReview = /* @__PURE__ */ new Map();
4887
+ for (const job of jobs) {
4888
+ if (purposeOf(job) === "ask") continue;
4889
+ const held = byReview.get(job.workspaceId);
4890
+ if (held) held.push(job);
4891
+ else byReview.set(job.workspaceId, [job]);
4892
+ }
4893
+ /**
4894
+ * Both comparators are **total**, and that is a requirement rather than tidiness.
4895
+ *
4896
+ * `allJobs` reads a directory, so a tie leaves the winner to whatever order the filesystem
4897
+ * happened to return. This function's output is hashed into the index token, and the page reloads
4898
+ * whenever that token changes -- so an arbitrary winner is not a cosmetic wobble, it is the page
4899
+ * reloading itself for as long as the tie exists, with no cause a reviewer could ever see. The
4900
+ * same hazard the sorting rule was written for, one level further down.
4901
+ *
4902
+ * Ties are real. Two jobs can carry the same timestamp to the millisecond, and a record written
4903
+ * before `finishedAt` existed falls back to `updatedAt`, which two records can share. The id is
4904
+ * the last resort because it is the only field guaranteed to differ.
4905
+ */
4906
+ const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
4907
+ const startedLast = (a, b) => b.startedAt - a.startedAt || byId(a, b);
4908
+ const endedLast = (a, b) => endedAt(b) - endedAt(a) || b.startedAt - a.startedAt || byId(a, b);
4909
+ const states = /* @__PURE__ */ new Map();
4910
+ for (const [reviewId, jobs] of byReview) {
4911
+ const running = jobs.filter((job) => !isTerminal(job) && isClaimed(job.id, now)).sort(startedLast);
4912
+ if (running.length > 0) {
4913
+ states.set(reviewId, {
4914
+ phase: running[0].phase,
4915
+ purpose: purposeOf(running[0])
4916
+ });
4917
+ continue;
4918
+ }
4919
+ if (jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast)[0]?.phase === "failed") states.set(reviewId, {
4920
+ phase: "failed",
4921
+ purpose: "review"
4922
+ });
4923
+ }
4924
+ return states;
4925
+ }
4926
+ /**
4927
+ * The token.
4928
+ *
4929
+ * Takes the live analyses rather than reading them, so that one request answers with a token and a
4930
+ * running state that describe the same instant. Computing them separately would let a phase change
4931
+ * land between the two, and the page would then be told "nothing has changed" alongside a state it
4932
+ * had not seen.
4933
+ */
4934
+ function reviewsRevision(live = analysisStates()) {
4935
+ const hash = createHash("sha1");
4936
+ for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
4937
+ for (const id of [...live.keys()].sort()) {
4938
+ const state = live.get(id);
4939
+ hash.update(`${id}@${state.purpose}:${state.phase}\n`);
4940
+ }
4941
+ return hash.digest("base64url");
4942
+ }
4943
+ //#endregion
4761
4944
  //#region ../../packages/review-harness/src/workspace/ask.ts
4762
4945
  /** Long enough for a real look at the repository; short enough that a hang is not forever. */
4763
4946
  var ASK_TIMEOUT_MS = 18e4;
@@ -5420,6 +5603,64 @@ var CONTENT_TYPES = {
5420
5603
  ".woff2": "font/woff2",
5421
5604
  ".png": "image/png"
5422
5605
  };
5606
+ /**
5607
+ * The freshness policy, in three classes and one place.
5608
+ *
5609
+ * Every response this server sends falls into exactly one of them, and which one is decided by the
5610
+ * helper that writes it rather than by the route that produced it. That is the whole shape of this:
5611
+ * a route may not have an opinion about caching, because the bug this replaced was four HTML
5612
+ * responses that each said nothing and were each individually reasonable.
5613
+ *
5614
+ * mutable workspace state -> no-store never stored, so never reused
5615
+ * fingerprinted asset -> immutable reused indefinitely; the hash is the identity
5616
+ * other static asset -> no-cache may be stored, must revalidate before reuse
5617
+ *
5618
+ * `no-cache` is a rule about reuse and not a ban on storage, which is the thing about these two
5619
+ * names that is worth saying out loud once: the assets already carried it while the pages, which
5620
+ * are the things that actually change, carried nothing at all. A reviewer marked a review reviewed,
5621
+ * pressed Back, and was shown the list as it had been — and the server had been right the whole
5622
+ * time, because it was never asked.
5623
+ *
5624
+ * The browser half of the same policy is `reloadOnRestore` in `client/page_restore.ts`. Nothing
5625
+ * here reaches the back/forward cache reliably: a restored page is a frozen document being thawed
5626
+ * rather than a stored response being reused, and browsers disagree about whether `no-store` should
5627
+ * keep a document out of that cache. Only the page itself can refuse its own restoration.
5628
+ */
5629
+ var NO_STORE = "no-store";
5630
+ var REVALIDATE = "no-cache";
5631
+ var IMMUTABLE = "public, max-age=31536000, immutable";
5632
+ /**
5633
+ * Whether this asset's name encodes its content, which is the only thing that makes `immutable`
5634
+ * safe to say about it.
5635
+ *
5636
+ * The names come from `vite.client.ts`, which emits `chunk-[hash].js` for everything the bundler
5637
+ * split out and a bare `[name].js` for the three page entries. So the chunks may be kept forever —
5638
+ * a new build produces new names and the old ones are simply never asked for again — while
5639
+ * `workspace.js`, the stylesheets, the font and the icon are all names that survive an upgrade
5640
+ * with different content behind them, and must be revalidated every time they are reused.
5641
+ *
5642
+ * `freshness_policy.test.ts` holds this to the vite option it is derived from. The two halves are
5643
+ * in different packages' build config and source, and a change to the emitted name would otherwise
5644
+ * turn "cache forever" from safe into a stale asset nobody can clear.
5645
+ */
5646
+ function isFingerprinted(name) {
5647
+ return /^chunk-/.test(name);
5648
+ }
5649
+ /**
5650
+ * An HTML document, which is always a view of mutable workspace state.
5651
+ *
5652
+ * Every HTML response goes through here. There is no second way to write one, and
5653
+ * `freshness_policy.test.ts` is what keeps it that way: the cost of this bug was not that somebody
5654
+ * chose wrong, it was that four routes each got to choose.
5655
+ */
5656
+ function sendHtml(res, status, html, cookie) {
5657
+ res.writeHead(status, {
5658
+ "content-type": "text/html; charset=utf-8",
5659
+ "cache-control": NO_STORE,
5660
+ ...cookie ? { "set-cookie": cookie } : {}
5661
+ });
5662
+ res.end(html);
5663
+ }
5423
5664
  function createWorkspaceServer(options = {}) {
5424
5665
  return createServer((req, res) => {
5425
5666
  handle(req, res, options).catch((error) => {
@@ -5443,6 +5684,7 @@ async function handle(req, res, options) {
5443
5684
  if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
5444
5685
  if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
5445
5686
  if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
5687
+ if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
5446
5688
  const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
5447
5689
  if (!match) return send(res, 404, { error: "Not found" });
5448
5690
  const [, id, action] = match;
@@ -5451,7 +5693,7 @@ async function handle(req, res, options) {
5451
5693
  if (!tokenMatches(workspace, url.searchParams.get("t"))) return send(res, 403, { error: "This link is missing its access token." });
5452
5694
  if (url.pathname.startsWith("/r/")) {
5453
5695
  touchWorkspace(workspace);
5454
- return sendHtml(res, workspace);
5696
+ return sendWorkspacePage(res, workspace);
5455
5697
  }
5456
5698
  if (action === "/job") return send(res, 200, jobReport(workspace));
5457
5699
  if (action === "/retry") return handleRetry(res, workspace);
@@ -5525,12 +5767,12 @@ function handleIndex(req, res, params, indexToken) {
5525
5767
  }
5526
5768
  const cookie = indexCookieHeader(indexToken);
5527
5769
  if (onward) return redirect(res, onward, cookie);
5528
- const html = renderReviewsPage({ groups: recentReviewGroups(MAX_LISTED_BRANCHES) });
5529
- res.writeHead(200, {
5530
- "content-type": "text/html; charset=utf-8",
5531
- "set-cookie": cookie
5532
- });
5533
- res.end(html);
5770
+ const states = analysisStates();
5771
+ sendHtml(res, 200, renderReviewsPage({
5772
+ groups: recentReviewGroups(MAX_LISTED_BRANCHES),
5773
+ analyses: states,
5774
+ revision: reviewsRevision(states)
5775
+ }), cookie);
5534
5776
  }
5535
5777
  /**
5536
5778
  * Where `?go=` is allowed to send someone, which is one shape and no other.
@@ -5547,6 +5789,7 @@ function safeDestination(value) {
5547
5789
  function redirect(res, location, cookie) {
5548
5790
  res.writeHead(302, {
5549
5791
  location,
5792
+ "cache-control": NO_STORE,
5550
5793
  ...cookie ? { "set-cookie": cookie } : {}
5551
5794
  });
5552
5795
  res.end();
@@ -5569,8 +5812,7 @@ function redirect(res, location, cookie) {
5569
5812
  function refuseIndex(req, res) {
5570
5813
  const way = wayBack();
5571
5814
  if (!wantsHtml(req)) return send(res, 403, { error: `This link is missing its access token. Run ${wayBackSentence(way)} to get the current one.` });
5572
- res.writeHead(403, { "content-type": "text/html; charset=utf-8" });
5573
- res.end(page("Your reviews", `<h1>This browser has not been shown your reviews yet</h1>
5815
+ sendHtml(res, 403, page("Your reviews", `<h1>This browser has not been shown your reviews yet</h1>
5574
5816
  <p>Nothing is lost. Every review is still here, and the link you came from still works. The
5575
5817
  list has its own credential, separate from the one that opens a single review, so a review
5576
5818
  page is not able to hand it over.</p>
@@ -5614,12 +5856,7 @@ async function handleSettings(req, res, params, indexToken) {
5614
5856
  error
5615
5857
  });
5616
5858
  }
5617
- const html = renderSettingsPage({ settings: readSettings() });
5618
- res.writeHead(200, {
5619
- "content-type": "text/html; charset=utf-8",
5620
- "set-cookie": indexCookieHeader(indexToken)
5621
- });
5622
- res.end(html);
5859
+ sendHtml(res, 200, renderSettingsPage({ settings: readSettings() }), indexCookieHeader(indexToken));
5623
5860
  }
5624
5861
  /**
5625
5862
  * Whether the caller is a browser navigating, rather than something reading the API.
@@ -5693,6 +5930,34 @@ async function handleStatus(req, res, indexToken) {
5693
5930
  });
5694
5931
  }
5695
5932
  /**
5933
+ * Has anything the reviews index draws changed, and is anything still being made.
5934
+ *
5935
+ * The whole of the index's liveness, in one request that costs the same whether the machine holds
5936
+ * five reviews or five hundred. The page reloads when `revision` differs from the one it was
5937
+ * rendered with; it never needs to know which review changed, because its answer to any change is
5938
+ * the same answer.
5939
+ *
5940
+ * Both fields describe one instant, which is why `liveAnalyses` is read once and passed in rather
5941
+ * than read twice. Computed separately, a phase could change between them and the page would be
5942
+ * told nothing had changed alongside a running state it had not seen.
5943
+ *
5944
+ * `live` sets the **cadence**, never whether to poll. A page that renders with nothing running
5945
+ * still has to learn that a review was started from a terminal somewhere, and a page whose work has
5946
+ * finished has to slow down without needing a reload to be told. See `client/reviews.ts`.
5947
+ *
5948
+ * Authorised by the index credential like `/pin`, `/delete` and `/status`, because this is the list
5949
+ * asking about itself. Flat rather than under `/api/`, which is the per-review namespace and whose
5950
+ * route requires a review id this question does not have.
5951
+ */
5952
+ function handleRevision(req, res, indexToken) {
5953
+ if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
5954
+ const states = analysisStates();
5955
+ send(res, 200, {
5956
+ revision: reviewsRevision(states),
5957
+ live: anythingRunning(states)
5958
+ });
5959
+ }
5960
+ /**
5696
5961
  * Pin or unpin a review.
5697
5962
  *
5698
5963
  * Authorised by the index credential rather than the review's own, because pinning is a fact about
@@ -5876,7 +6141,7 @@ function recordWorkspaceOpened(workspace) {
5876
6141
  const event = workspaceOpenedEvent(workspace);
5877
6142
  if (event) record(event);
5878
6143
  }
5879
- async function sendHtml(res, workspace) {
6144
+ async function sendWorkspacePage(res, workspace) {
5880
6145
  const freshness = await freshnessOrNull(workspace);
5881
6146
  const kissFreshness = await kissFreshnessOrNull(workspace);
5882
6147
  const kissRun = latestKissRun(workspace);
@@ -5909,8 +6174,10 @@ async function sendHtml(res, workspace) {
5909
6174
  lastUpdate: notice,
5910
6175
  diagrams: workspace.diagrams,
5911
6176
  reviewedSha: reviewedCommit(workspace),
6177
+ revision: workspaceRevision(workspace),
5912
6178
  lineage: lineagePosition(workspace),
5913
6179
  job: workspace.jobId ? loadJob(workspace.jobId) : void 0,
6180
+ updateJob: liveUpdateFor(workspace.id),
5914
6181
  kissRun,
5915
6182
  kissJob: kissRun ? loadJob(kissRun.jobId) : void 0,
5916
6183
  kissFreshness,
@@ -5925,8 +6192,7 @@ async function sendHtml(res, workspace) {
5925
6192
  code: freshness.code.state,
5926
6193
  checkout: freshness.checkout.state
5927
6194
  });
5928
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
5929
- res.end(html);
6195
+ sendHtml(res, 200, html);
5930
6196
  }
5931
6197
  function sendAsset(res, pathname) {
5932
6198
  const name = pathname.slice(8);
@@ -5936,7 +6202,7 @@ function sendAsset(res, pathname) {
5936
6202
  const ext = name.slice(name.lastIndexOf("."));
5937
6203
  res.writeHead(200, {
5938
6204
  "content-type": CONTENT_TYPES[ext] ?? "application/octet-stream",
5939
- "cache-control": "no-cache"
6205
+ "cache-control": isFingerprinted(name) ? IMMUTABLE : REVALIDATE
5940
6206
  });
5941
6207
  res.end(body);
5942
6208
  }
@@ -5998,7 +6264,8 @@ function jobReport(workspace) {
5998
6264
  const kissRun = latestKissRun(workspace);
5999
6265
  return {
6000
6266
  ...jobStatus(workspace.jobId ? loadJob(workspace.jobId) : null),
6001
- kiss: jobStatus(kissRun ? loadJob(kissRun.jobId) : null)
6267
+ kiss: jobStatus(kissRun ? loadJob(kissRun.jobId) : null),
6268
+ update: jobStatus(liveUpdateFor(workspace.id))
6002
6269
  };
6003
6270
  }
6004
6271
  /**
@@ -6011,10 +6278,12 @@ function jobReport(workspace) {
6011
6278
  async function handleFreshness(res, workspace) {
6012
6279
  const kiss = await kissFreshnessOrNull(workspace);
6013
6280
  const report = await freshnessOrNull(workspace);
6281
+ const revision = workspaceRevision(workspace) ?? null;
6014
6282
  if (!report) return send(res, 200, {
6015
6283
  report: null,
6016
6284
  kiss,
6017
- kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null
6285
+ kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
6286
+ revision
6018
6287
  });
6019
6288
  record({
6020
6289
  event: "freshness_checked",
@@ -6027,7 +6296,8 @@ async function handleFreshness(res, workspace) {
6027
6296
  report,
6028
6297
  dismissedSha: workspace.dismissedFreshnessSha ?? null,
6029
6298
  kiss,
6030
- kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null
6299
+ kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
6300
+ revision
6031
6301
  });
6032
6302
  }
6033
6303
  /**
@@ -6095,6 +6365,7 @@ async function handleUpdate(res, workspace) {
6095
6365
  try {
6096
6366
  outcome = await updateReview(workspace);
6097
6367
  } catch (error) {
6368
+ if (error instanceof UpdateAlreadyRunningError) return send(res, 409, { error: error.message });
6098
6369
  outcome = {
6099
6370
  at: Date.now(),
6100
6371
  ok: false,
@@ -6385,7 +6656,10 @@ async function readJson(req) {
6385
6656
  }
6386
6657
  }
6387
6658
  function send(res, status, payload) {
6388
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
6659
+ res.writeHead(status, {
6660
+ "content-type": "application/json; charset=utf-8",
6661
+ "cache-control": NO_STORE
6662
+ });
6389
6663
  res.end(JSON.stringify(payload));
6390
6664
  }
6391
6665
  var ASK_SOURCES = [
@@ -6506,16 +6780,12 @@ async function sweep(now = Date.now()) {
6506
6780
  if (markAbandoned(job, now)) report.abandoned += 1;
6507
6781
  continue;
6508
6782
  }
6509
- if (now - finishedAt(job) < 216e5) continue;
6783
+ if (now - endedAt(job) < 216e5) continue;
6510
6784
  if (await retire(job, report)) report.retired += 1;
6511
6785
  }
6512
6786
  await removeOrphans(jobs, report);
6513
6787
  return report;
6514
6788
  }
6515
- /** When this job stopped doing work. `finishedAt` predates nothing, but a record can be old. */
6516
- function finishedAt(job) {
6517
- return job.finishedAt ?? job.updatedAt;
6518
- }
6519
6789
  /**
6520
6790
  * Record that a job stopped, unless it has moved since this pass read it.
6521
6791
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prreviewbuddy",
3
- "version": "0.25.7",
3
+ "version": "0.25.13",
4
4
  "description": "Review a branch or pull request in an isolated checkout, from your terminal, with the coding agent you already have installed.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://prreviewbuddy.com",