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/CHANGELOG.md +115 -0
- package/dist/main.js +76 -9
- package/dist/{relative_time-CDvK_Zaj.js → relative_time-vn8Ex9E8.js} +847 -505
- package/dist/server.js +312 -42
- package/package.json +1 -1
- package/static/chunk-B80S6zbH.js +4 -4
- package/static/chunk-CIsM3PMS.js +1 -0
- package/static/{chunk-B9H2wUlQ.js → chunk-gBDI1-Cy.js} +1 -1
- package/static/reviews.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +48 -0
- package/static/workspace.js +44 -44
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { $ as checkCodeFreshness, A as
|
|
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 `${
|
|
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(
|
|
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">−${review.deletions}</span></span>
|
|
4294
|
-
|
|
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
|
|
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
|
|
5529
|
-
res
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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":
|
|
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, {
|
|
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 -
|
|
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.
|
|
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",
|