prreviewbuddy 0.78.1 → 0.80.2
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 +98 -0
- package/dist/{delete_review-BHOzMQ0_.js → delete_review-BXfGCvQ1.js} +295 -21
- package/dist/main.js +17 -12
- package/dist/server.js +2364 -1305
- package/package.json +1 -1
- package/static/chunk-C3E91izb.js +1 -0
- package/static/chunk-C8d9FvLk.js +1 -0
- package/static/chunk-DnOyRP8m.js +1 -0
- package/static/chunk-Dv0Wowan.js +1 -0
- package/static/chunk-h55PfL_M.js +1 -0
- package/static/help.js +1 -1
- package/static/notifications.js +1 -1
- package/static/reviews.js +1 -1
- package/static/runs.js +1 -0
- package/static/settings.js +1 -1
- package/static/shell.css +90 -6
- package/static/sidebar.css +7 -1
- package/static/workspace.js +81 -81
- package/static/chunk-BMAMO0Tf.js +0 -1
- package/static/chunk-D8xJNBjR.js +0 -1
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as resolveWorkspacePort, $n as
|
|
1
|
+
import { $ as resolveWorkspacePort, $i as AgentUnavailableError, $n as attemptIdOf, $r as isIssueOutstanding, $t as moveIds, A as parsePullRequestLink, Ai as resolveTarget, Ar as recentWorkspaces, At as readArchitecture, Bi as resolveAgent, Bn as requestJobStop, Br as isLifecycle, Bt as writeJsonAtomically, C as completeRun, Ci as followUpFindingId, Cn as hasAuthorContent, Cr as lineagePosition, Ct as RUNTIME_WORDS, D as relativeTime$1, Di as describeAuthorship, Dn as CODE_COPY_CLASS, Dt as checkArchitecture, E as stopRun, Ei as objectWithAnyOf, En as CODE_BLOCK_CLASS, Er as mutateWorkspace, Et as ARCHITECTURE_DIR, Fi as AmbiguousRefError, Fn as createWorktree, Fr as workspaceRevision, Ft as ownersOf, G as stopChild, Gn as checkReview, Gr as parseLifecycle, Gt as createArchitectureNotification, Hi as clearAgentPreference, Hr as lifecycleItemLabel, Ht as branchOf, I as settleQueue, Ii as shortNameOf, In as holdWorktree, Ir as workspaceStamps, It as QUOTE_LIMITS, J as writeServerRecord, Ji as agentById, Jn as fetchHead, Jr as queueAddMany, Jt as eventName, Ki as STORE_ROOT, Kn as followedBranch, Kr as sectionOf, Kt as createPrState, L as readIndexToken, Li as shorthandOf, Ln as isWorktreeHeld, Lr as LIFECYCLES, Lt as REPOSITORY_SCOPE, M as runBatch, Mi as DetachedHeadError, Mn as isBusy, Mt as storeArchitecture, N as activateQueued, Ni as isCommitReview, Nn as withReviewLock, Nt as storedScopes, O as batchIdentity, Oi as WITHHELD_REASON, On as escapeHtml$1, Or as presentedKissRunOf, Ot as loadArchitecture, P as compactQueue, Pi as resolveBase, Pn as MANAGED_ROOT, Pr as touchWorkspace, Pt as trackedFiles, Q as parseWorkspacePort, Qi as AgentCancelledError, Qn as allJobs, Qr as doneVerb, Qt as markReadForReview, Ri as git, Rn as readMarker, Rr as activityAttributes, Rt as ancestorsOf, S as beginRun, Si as wasReassessed, Sn as authorPreview, Sr as latestKissRun, St as recordRuntimeBestEffort, T as reportRun, Ti as extractJsonText, Tn as renderMarkdown, Tt as formatDuration, U as serverEntryExists, Ui as readAgentPreference, Un as findExistingReview, Ur as lifecyclePill, Ut as subjectOf, V as readServerRecord, Vi as resolveModel, Vr as lifecycleChip, Vt as announceJob, W as spawnServer, Wi as writeAgentPreference, Wn as checkCodeFreshness, Wr as offersLifecycle, Wt as categoryOf, X as MAX_WORKSPACE_PORT, Xi as detectAgents, Xn as fillFileUrlTemplate, Xr as queueStamp, Xt as markAllRead, Y as DEFAULT_WORKSPACE_PORT, Yi as agentFor$1, Yn as forgeResolver, Yr as queueRemove, Yt as listNotifications, Z as MIN_WORKSPACE_PORT, Zi as isAgentId, Zn as RUN_RECORD_RETENTION_MS, Zr as readQueue, Zt as markRead, _i as gradeScale, _n as isUnchanged, _r as groupByLineage, _t as BUILD_VERSION, a as wasBlocked, ai as processDiscussion, an as toneOf, ar as saveJob, at as settleReview, b as track, bi as reassessIn, bn as chatConversationSection, br as kissDecisionRefusal, bt as waitingHistory, c as RefreshUnavailableError, ci as explanationOf, cn as withUsageRecorded, cr as isTerminalJobPhase, d as mergeUpdateInto, di as ANALYSIS_ONLY, dn as adoptLegacyBaseline, dr as purposeOf, dt as record, ei as isQuestionOutstanding, en as pruneNotifications, er as endedAt, et as statedWorkspacePort, f as updateReview, fi as analysisRefOf, fn as prepareConversationWithin, gi as findingIn, gn as refreshPrContext, gr as followedRefName, h as liveUpdateFor, hi as findingAssessment, hn as unforeseenFailure, hr as decideKissRecommendation, ht as telemetryUploadConsent, i as liveJobsFor, ii as reviewerDispositions, in as retireNotification, ir as pruneJobResidue, it as prepareReview, j as unsupportedReason, ji as originRemote, jn as ReviewBeingDeletedError, jr as reviewedCommit, jt as repositoryKey, k as extractPullRequestLinks, ki as kissOutcome, kn as renderInlineText, kr as previousKissResult, kt as partFiles, l as cancelUpdate, li as isExplanationAction, ln as existingPaths, lr as phasesFor, m as UpdateAlreadyRunningError, mi as currentGradeIn, mn as storedConversation, mt as recordTelemetryUploadConsent, n as deleteReview, ni as questionsOutstanding, nn as readStateOf, nr as isTerminal, nt as failureOf$1, o as discardJob, oi as recommendationsOf, on as unreadAround, ot as reanalyseReview, p as askCheckout, pi as assessmentLookup, pn as saveRefresh, pr as isClaimed, qi as AGENT_IDS, qn as reviewedShaOf, qr as queueAdd, qt as createPrUpdate, r as lineageIds, ri as resolvedByConversation, rn as removeNotification, rr as loadJob, rt as observeReview, s as FETCH_TIMEOUT_MS, si as explanationActionOf, sn as unreadCount$1, sr as isLiveJob, st as runJob, t as deleteLineage, ti as issuesOutstanding, tn as readNotification, tr as fail$1, tt as writePortPreference, u as cancellableHere, ui as withExplanation, un as ASK_REREAD_CEILING_MS, ur as progressSteps, ut as readEvents, v as startKissJob, vi as isGradeFor, vn as agentEnvOf, vr as indexKeptIds, w as endRun, wi as asString, wn as GITHUB_MARKDOWN, wr as loadWorkspace, wt as describeRuntimes, x as RUN_KINDS, xi as sourcePhrase, xn as UNESTABLISHED_MOTIVATION, xr as kissDecisionsOf, xt as mapRuntimes, y as isDriving, yi as promptGrade, yn as EXPLAIN_SIMPLY_PROMPT, yr as indexReviewGroups, yt as feedbackUrl, z as clearServerRecordOwnedBy, zi as refExists, zn as removeWorktree, zr as activityLabel, zt as isArchitectureId } from "./delete_review-BXfGCvQ1.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";
|
|
@@ -243,12 +243,26 @@ var INDEX_SECTIONS = [
|
|
|
243
243
|
"done"
|
|
244
244
|
];
|
|
245
245
|
/**
|
|
246
|
+
* Every section the list draws, top to bottom: In progress, then the card sections.
|
|
247
|
+
*
|
|
248
|
+
* In progress is not in `INDEX_SECTIONS` because no card is ever placed there. It lists work, one
|
|
249
|
+
* entry per running job or waiting row (`deriveOperations`), and a review being updated stays a
|
|
250
|
+
* card in its own section while its update is listed here. Windows, Load more, the selection and
|
|
251
|
+
* the counts are all about cards, so they keep reading `INDEX_SECTIONS`; folding, its memory and its
|
|
252
|
+
* keyboard commands are about headings, so they read this.
|
|
253
|
+
*/
|
|
254
|
+
var LIST_SECTIONS = ["in-progress", ...INDEX_SECTIONS];
|
|
255
|
+
/**
|
|
246
256
|
* Each section's name, as its heading says it, and the key that folds it from the keyboard
|
|
247
257
|
* (⌘K L and this key). The commands are made from this table, so a section added here is a section
|
|
248
258
|
* the keyboard can fold, with no second list to remember.
|
|
249
259
|
*/
|
|
250
260
|
var lifecycleName = (id) => LIFECYCLES.find((lifecycle) => lifecycle.id === id).name;
|
|
251
261
|
var INDEX_SECTION_NAMES = {
|
|
262
|
+
"in-progress": {
|
|
263
|
+
title: "In progress",
|
|
264
|
+
key: "i"
|
|
265
|
+
},
|
|
252
266
|
pinned: {
|
|
253
267
|
title: "Pinned",
|
|
254
268
|
key: "p"
|
|
@@ -273,8 +287,9 @@ var INDEX_SECTION_NAMES = {
|
|
|
273
287
|
function sectionOpen(section, collapsed) {
|
|
274
288
|
return !(collapsed?.[section] ?? section === "done");
|
|
275
289
|
}
|
|
276
|
-
|
|
277
|
-
|
|
290
|
+
/** A section the list draws a heading for, and so one whose fold can be stored. */
|
|
291
|
+
function isListSection(value) {
|
|
292
|
+
return typeof value === "string" && LIST_SECTIONS.includes(value);
|
|
278
293
|
}
|
|
279
294
|
/**
|
|
280
295
|
* Which section a card is drawn in: Pinned when pinned, and otherwise where its lifecycle says.
|
|
@@ -435,7 +450,8 @@ var PR_FILTER_OPTIONS = [
|
|
|
435
450
|
* once a second about a review that finished failing an hour ago, for as long as the tab stayed
|
|
436
451
|
* open.
|
|
437
452
|
*/
|
|
438
|
-
function anythingRunning(states) {
|
|
453
|
+
function anythingRunning(states, operations = []) {
|
|
454
|
+
if (operations.length > 0) return true;
|
|
439
455
|
for (const state of states.values()) if (state.phase !== "failed") return true;
|
|
440
456
|
return false;
|
|
441
457
|
}
|
|
@@ -688,6 +704,7 @@ var PATHS = {
|
|
|
688
704
|
prcontext: "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>",
|
|
689
705
|
recommendations: "<path d=\"M9 18h6M10 22h4\"/><path d=\"M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.2 1 2V17h6v-.3c0-.8.4-1.5 1-2A7 7 0 0 0 12 2z\"/>",
|
|
690
706
|
reviews: "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"/>",
|
|
707
|
+
runs: "<path d=\"M22 12h-4l-3 9L9 3l-3 9H2\"/>",
|
|
691
708
|
notifications: "<path d=\"M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9\"/><path d=\"M13.7 21a2 2 0 0 1-3.4 0\"/>",
|
|
692
709
|
settings: "<circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z\"/>",
|
|
693
710
|
help: "<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3\"/><path d=\"M12 17h.01\"/>",
|
|
@@ -777,6 +794,12 @@ var WORKSPACE_PLACES = [
|
|
|
777
794
|
icon: "reviews",
|
|
778
795
|
label: "Reviews"
|
|
779
796
|
},
|
|
797
|
+
{
|
|
798
|
+
place: "runs",
|
|
799
|
+
href: "/runs",
|
|
800
|
+
icon: "runs",
|
|
801
|
+
label: "Runs"
|
|
802
|
+
},
|
|
780
803
|
{
|
|
781
804
|
place: "notifications",
|
|
782
805
|
href: "/notifications",
|
|
@@ -829,6 +852,7 @@ function renderGlobalBar(options) {
|
|
|
829
852
|
${live ? renderHeaderNav(options) : ""}
|
|
830
853
|
${live ? renderHistoryControls() : ""}
|
|
831
854
|
${live ? renderStripReviews(options) : ""}
|
|
855
|
+
${live ? renderStripRuns(options) : ""}
|
|
832
856
|
${live ? renderNotificationsBell(options) : ""}
|
|
833
857
|
${live ? renderStripSettings(options) : ""}
|
|
834
858
|
${live ? renderStripRestart(options) : ""}
|
|
@@ -897,6 +921,10 @@ function renderHistoryControls() {
|
|
|
897
921
|
function renderStripReviews(options) {
|
|
898
922
|
return `<a class="prb-strip-action" href="/"${options.current === "reviews" ? " aria-current=\"true\"" : ""} aria-label="Reviews" data-strip-reviews>${iconSvg("reviews")}</a>`;
|
|
899
923
|
}
|
|
924
|
+
/** Runs, beside Reviews, in the rail's order. Its own `aria-label`, for the reason Settings has one. */
|
|
925
|
+
function renderStripRuns(options) {
|
|
926
|
+
return `<a class="prb-strip-action" href="/runs"${options.current === "runs" ? " aria-current=\"true\"" : ""} aria-label="Runs" data-strip-runs>${iconSvg("runs")}</a>`;
|
|
927
|
+
}
|
|
900
928
|
/**
|
|
901
929
|
* Settings, as an icon, beside the bell.
|
|
902
930
|
*
|
|
@@ -6059,7 +6087,7 @@ function showsProgress(view) {
|
|
|
6059
6087
|
*/
|
|
6060
6088
|
function renderReviewBody(view, sections) {
|
|
6061
6089
|
const result = view.session.result;
|
|
6062
|
-
return `<div class="prb-body"${result ? "" : ` data-ask="closed"`}${view.askWidth ? ` style="--ask-width: ${Math.round(view.askWidth)}px"` : ""}>
|
|
6090
|
+
return `<div class="prb-body"${result ? view.reviewLayout?.ask ? ` data-ask="${view.reviewLayout.ask}"` : "" : ` data-ask="closed"`} data-review-layout="${escapeHtml$1(JSON.stringify(view.reviewLayout ?? {}))}"${view.askWidth ? ` style="--ask-width: ${Math.round(view.askWidth)}px"` : ""}>
|
|
6063
6091
|
${renderRail(view, sections, updating(view))}
|
|
6064
6092
|
<main class="prb-doc">
|
|
6065
6093
|
${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
|
|
@@ -6620,11 +6648,12 @@ function renderRail(view, sections, stale = false) {
|
|
|
6620
6648
|
current: true,
|
|
6621
6649
|
badge: null,
|
|
6622
6650
|
collapses: "the review",
|
|
6623
|
-
|
|
6651
|
+
open: railFoldOpen(view, "prb-rail-review"),
|
|
6652
|
+
body: `<button type="button" class="prb-rail-subhead" data-rail-toggle="the review path" ${foldAttributes(railFoldOpen(view, "prb-rail-path"), "the review path")} aria-controls="prb-rail-path">
|
|
6624
6653
|
<span class="prb-rail-heading">Review path</span>
|
|
6625
6654
|
<span class="prb-rail-twisty" aria-hidden="true">${TWISTY}</span>
|
|
6626
6655
|
</button>
|
|
6627
|
-
<div class="prb-rail-path" id="prb-rail-path">
|
|
6656
|
+
<div class="prb-rail-path" id="prb-rail-path"${railFoldOpen(view, "prb-rail-path") ? "" : " hidden"}>
|
|
6628
6657
|
${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
|
|
6629
6658
|
</div>
|
|
6630
6659
|
${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram architecture")}
|
|
@@ -6664,6 +6693,7 @@ function renderRunningRail(view) {
|
|
|
6664
6693
|
current: true,
|
|
6665
6694
|
badge: null,
|
|
6666
6695
|
collapses: "the review",
|
|
6696
|
+
open: railFoldOpen(view, "prb-rail-review"),
|
|
6667
6697
|
body: view.job && changeIsRead(view.job) ? railLink("files", "files", "Files", totalBadge(files, "files")) : ""
|
|
6668
6698
|
});
|
|
6669
6699
|
}
|
|
@@ -6692,9 +6722,18 @@ function renderKissRail(view) {
|
|
|
6692
6722
|
current: view.session.result === null && !showsProgress(view),
|
|
6693
6723
|
badge: counts ? recommendationsBadge(counts) : null,
|
|
6694
6724
|
collapses: "the KISS review",
|
|
6725
|
+
open: railFoldOpen(view, "prb-rail-kiss"),
|
|
6695
6726
|
body: counts ? railLink("kissrecs", "recommendations", "Recommendations", recommendationsBadge(counts)) : ""
|
|
6696
6727
|
});
|
|
6697
6728
|
}
|
|
6729
|
+
/** Open unless the reviewer last left this fold shut, on this review or the one before. */
|
|
6730
|
+
function railFoldOpen(view, fold) {
|
|
6731
|
+
return view.reviewLayout?.rail?.[fold] !== false;
|
|
6732
|
+
}
|
|
6733
|
+
/** A fold control's state and the name that says what pressing it does, as `setRailExpanded` writes them. */
|
|
6734
|
+
function foldAttributes(open, noun) {
|
|
6735
|
+
return `aria-expanded="${open}" aria-label="${open ? "Collapse" : "Expand"} ${escapeHtml$1(noun)}"`;
|
|
6736
|
+
}
|
|
6698
6737
|
/** The one chevron both twisties draw. Rotated by CSS off `aria-expanded`, never by markup. */
|
|
6699
6738
|
var TWISTY = "<svg viewBox=\"0 0 12 12\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M3 4.5 6 8l3-3.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>";
|
|
6700
6739
|
/**
|
|
@@ -6709,6 +6748,7 @@ var TWISTY = "<svg viewBox=\"0 0 12 12\" aria-hidden=\"true\" focusable=\"false\
|
|
|
6709
6748
|
* `hidden` container rather than a list the client repopulates.
|
|
6710
6749
|
*/
|
|
6711
6750
|
function railGroup(group) {
|
|
6751
|
+
const open = group.open !== false;
|
|
6712
6752
|
return `<div class="prb-rail-group">
|
|
6713
6753
|
<div class="prb-rail-row">
|
|
6714
6754
|
<a class="prb-rail-item prb-rail-group-title" href="${escapeHtml$1(hashForView(group.view))}" data-view-nav="${escapeHtml$1(group.view)}"${group.current ? " aria-current=\"true\"" : ""}>
|
|
@@ -6716,9 +6756,9 @@ function railGroup(group) {
|
|
|
6716
6756
|
<span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(group.title)}</span></span>
|
|
6717
6757
|
${renderBadge(group.badge)}
|
|
6718
6758
|
</a>
|
|
6719
|
-
${group.body.trim() === "" ? "" : `<button type="button" class="prb-rail-twisty" data-rail-toggle="${escapeHtml$1(group.collapses)}"
|
|
6759
|
+
${group.body.trim() === "" ? "" : `<button type="button" class="prb-rail-twisty" data-rail-toggle="${escapeHtml$1(group.collapses)}" ${foldAttributes(open, group.collapses)} aria-controls="${escapeHtml$1(group.id)}">${TWISTY}</button>`}
|
|
6720
6760
|
</div>
|
|
6721
|
-
<div class="prb-rail-body" id="${escapeHtml$1(group.id)}">${group.body}</div>
|
|
6761
|
+
<div class="prb-rail-body" id="${escapeHtml$1(group.id)}"${open || group.body.trim() === "" ? "" : " hidden"}>${group.body}</div>
|
|
6722
6762
|
</div>`;
|
|
6723
6763
|
}
|
|
6724
6764
|
/**
|
|
@@ -9396,6 +9436,100 @@ async function checkPrStateNow(workspace, deps = defaultDeps()) {
|
|
|
9396
9436
|
} catch {}
|
|
9397
9437
|
}
|
|
9398
9438
|
//#endregion
|
|
9439
|
+
//#region ../../packages/review-harness/src/workspace/operations.ts
|
|
9440
|
+
/** What a running job is, in the words the section uses. */
|
|
9441
|
+
function operationKind(job) {
|
|
9442
|
+
if (purposeOf(job) === "update") return "updating";
|
|
9443
|
+
if (job.reanalysis) return "re-running";
|
|
9444
|
+
return job.phase === "preparing" ? "starting" : "analysing";
|
|
9445
|
+
}
|
|
9446
|
+
/**
|
|
9447
|
+
* Every operation, running first and newest first, then the queue in the order it was submitted.
|
|
9448
|
+
*
|
|
9449
|
+
* A row is dropped once its review has a job that is running, or one that ended after the row took
|
|
9450
|
+
* its slot. Both halves are about the hand-over, which has two windows. A row learns its review id
|
|
9451
|
+
* before that review's job is claimed, so "has a job" alone would leave nothing on the page for a
|
|
9452
|
+
* moment; and a row learns its job ended only after it has, so "has a running job" alone would
|
|
9453
|
+
* bring a finished review back as Starting. An ending from before the slot was taken is an earlier
|
|
9454
|
+
* attempt, a Retry's predecessor, and says nothing about this one.
|
|
9455
|
+
*
|
|
9456
|
+
* An ending hands over only a row that has its slot. A Retry puts a row back to waiting with the
|
|
9457
|
+
* review, and the job that failed, it already had; until the retry starts, that ending is the
|
|
9458
|
+
* attempt being retried, not this one, and treating it as the hand-over hid a queued retry for as
|
|
9459
|
+
* long as it waited. A running job hands over a row at any stage: the work is on the page either
|
|
9460
|
+
* way, and a waiting row beside it would be the same review listed twice.
|
|
9461
|
+
*
|
|
9462
|
+
* Both orders are total, ending on the key, because the result is hashed into the index token and a
|
|
9463
|
+
* tie left to the directory's order would reload the page for no reason a reviewer could see.
|
|
9464
|
+
*/
|
|
9465
|
+
function deriveOperations(jobs, isLive, rows) {
|
|
9466
|
+
const running = [];
|
|
9467
|
+
const liveOrEnded = [];
|
|
9468
|
+
for (const job of jobs) {
|
|
9469
|
+
if (purposeOf(job) === "ask") continue;
|
|
9470
|
+
if (isTerminalJobPhase(job.phase)) {
|
|
9471
|
+
liveOrEnded.push(job);
|
|
9472
|
+
continue;
|
|
9473
|
+
}
|
|
9474
|
+
if (!isLive(job)) continue;
|
|
9475
|
+
liveOrEnded.push(job);
|
|
9476
|
+
running.push({
|
|
9477
|
+
key: job.id,
|
|
9478
|
+
kind: operationKind(job),
|
|
9479
|
+
reviewId: job.workspaceId,
|
|
9480
|
+
phase: job.phase,
|
|
9481
|
+
purpose: purposeOf(job),
|
|
9482
|
+
...job.reviewType && job.reviewType !== "standard" ? { lens: job.reviewType } : {},
|
|
9483
|
+
startedAt: job.startedAt,
|
|
9484
|
+
...job.source ? { source: job.source } : {},
|
|
9485
|
+
...job.env.agentId ? { agentId: job.env.agentId } : {}
|
|
9486
|
+
});
|
|
9487
|
+
}
|
|
9488
|
+
const handedOver = (row) => row.reviewId !== void 0 && liveOrEnded.some((job) => job.workspaceId === row.reviewId && (!isTerminalJobPhase(job.phase) || row.status === "reviewing" && (job.finishedAt ?? job.updatedAt) >= (row.takenAt ?? Number.POSITIVE_INFINITY)));
|
|
9489
|
+
const queued = [];
|
|
9490
|
+
for (const row of rows) {
|
|
9491
|
+
if (handedOver(row)) continue;
|
|
9492
|
+
const base = {
|
|
9493
|
+
key: row.key,
|
|
9494
|
+
row,
|
|
9495
|
+
...row.reviewId ? { reviewId: row.reviewId } : {}
|
|
9496
|
+
};
|
|
9497
|
+
if (row.status === "waiting") queued.push({
|
|
9498
|
+
...base,
|
|
9499
|
+
kind: "queued"
|
|
9500
|
+
});
|
|
9501
|
+
else running.push({
|
|
9502
|
+
...base,
|
|
9503
|
+
kind: "starting",
|
|
9504
|
+
startedAt: row.takenAt ?? 0
|
|
9505
|
+
});
|
|
9506
|
+
}
|
|
9507
|
+
const byKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
9508
|
+
running.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0) || byKey(a, b));
|
|
9509
|
+
queued.sort((a, b) => (a.row?.order ?? 0) - (b.row?.order ?? 0) || byKey(a, b));
|
|
9510
|
+
return [...running, ...queued];
|
|
9511
|
+
}
|
|
9512
|
+
var LEADS = {
|
|
9513
|
+
queued: "Queued",
|
|
9514
|
+
starting: "Starting",
|
|
9515
|
+
analysing: "Analysing",
|
|
9516
|
+
updating: "Updating review",
|
|
9517
|
+
"re-running": "Re-running"
|
|
9518
|
+
};
|
|
9519
|
+
/**
|
|
9520
|
+
* The entry's status line: what kind of work, then the step it is on, in the words of `PHASES` /
|
|
9521
|
+
* `UPDATE_PHASES`, which the card, the terminal and the review page's checklist already use.
|
|
9522
|
+
*
|
|
9523
|
+
* "Analysing the change" stands alone rather than after "Analysing", which would say it twice.
|
|
9524
|
+
*/
|
|
9525
|
+
function operationLine(operation) {
|
|
9526
|
+
const lead = operation.lens === "kiss" ? "KISS review" : LEADS[operation.kind];
|
|
9527
|
+
if (operation.phase === void 0) return lead;
|
|
9528
|
+
const step = phasesFor({ purpose: operation.purpose })[operation.phase].doing;
|
|
9529
|
+
if (step.toLowerCase().startsWith(lead.toLowerCase())) return step;
|
|
9530
|
+
return `${lead} · ${step}`;
|
|
9531
|
+
}
|
|
9532
|
+
//#endregion
|
|
9399
9533
|
//#region ../../packages/review-harness/src/workspace/reviews_page.ts
|
|
9400
9534
|
function renderReviewsPage(view) {
|
|
9401
9535
|
const now = view.now ?? Date.now();
|
|
@@ -9412,7 +9546,9 @@ function renderReviewsPage(view) {
|
|
|
9412
9546
|
};
|
|
9413
9547
|
for (const group of ordered) sections[indexSectionOf(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), "all")].push(group);
|
|
9414
9548
|
const rank = new Map(ordered.map((group, index) => [group, index]));
|
|
9415
|
-
const
|
|
9549
|
+
const operations = view.operations ?? [];
|
|
9550
|
+
const inProgressOnly = new Set(ordered.filter((group) => group.history.length === 1 && !group.latest.hasStandardReview && operations.some((operation) => operation.reviewId === group.latest.id && operation.purpose !== "update")).map((group) => group.latest.id));
|
|
9551
|
+
const renderCards = (groups) => groups.map((group) => renderCard(group, now, activityOf(group), rank.get(group), view.analyses?.get(group.latest.id), positions.get(group.latest.id), group.history.map((review) => view.moved?.get(review.id)).find((kind) => kind !== void 0), prStateOf(group.latest, view.prStates), inProgressOnly.has(group.latest.id))).join("");
|
|
9416
9552
|
const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
|
|
9417
9553
|
const queue = view.queue ?? [];
|
|
9418
9554
|
const positions = new Map(queue.map((id, position) => [id, position]));
|
|
@@ -9426,13 +9562,13 @@ function renderReviewsPage(view) {
|
|
|
9426
9562
|
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
9427
9563
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
9428
9564
|
</head>
|
|
9429
|
-
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map())}">
|
|
9565
|
+
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map(), operations)}">
|
|
9430
9566
|
${renderGlobalBar({
|
|
9431
9567
|
current: "reviews",
|
|
9432
9568
|
unread: view.unreadNotifications ?? 0,
|
|
9433
9569
|
driving: view.driving
|
|
9434
9570
|
})}
|
|
9435
|
-
${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map(({ latest }) => ({
|
|
9571
|
+
${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls$1(queue.length, authorOptions(ordered.map(({ latest }) => ({
|
|
9436
9572
|
handle: latest.authorHandle,
|
|
9437
9573
|
name: latest.authorName,
|
|
9438
9574
|
createdAt: latest.createdAt
|
|
@@ -9447,7 +9583,8 @@ ${renderWorkspaceRail({
|
|
|
9447
9583
|
<p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
|
|
9448
9584
|
<button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
|
|
9449
9585
|
</div>`}
|
|
9450
|
-
${
|
|
9586
|
+
${renderInProgress$1(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed))}
|
|
9587
|
+
${INDEX_SECTIONS.map((section) => renderSection(section, sections[section], renderCards, sectionOpen(section, view.sectionsCollapsed), sections[section].filter((group) => !inProgressOnly.has(group.latest.id)).length)).join("\n ")}
|
|
9451
9588
|
${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>`}
|
|
9452
9589
|
${ordered.length === 0 ? "" : renderBulkBar()}
|
|
9453
9590
|
<p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Pinned reviews, Done reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened or marked them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
|
|
@@ -9466,7 +9603,7 @@ ${renderWorkspaceRail({
|
|
|
9466
9603
|
* per risk level would be four more controls for a question ("show me only the risky ones") that
|
|
9467
9604
|
* sorting by recency already answers badly and that scanning answers well.
|
|
9468
9605
|
*/
|
|
9469
|
-
function renderControls(queued, authors, anyRequest) {
|
|
9606
|
+
function renderControls$1(queued, authors, anyRequest) {
|
|
9470
9607
|
return `<div class="prb-index-controls">
|
|
9471
9608
|
<input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
|
|
9472
9609
|
${renderAuthorFilter(authors)}
|
|
@@ -9551,10 +9688,10 @@ function renderLoadMore$1(section) {
|
|
|
9551
9688
|
* empty and hidden rather than left out, so a card the browser moves here after a mark or a pin has
|
|
9552
9689
|
* somewhere to go, and an empty section keeps its fold for when something arrives.
|
|
9553
9690
|
*/
|
|
9554
|
-
function renderSection(section, groups, renderCards, open) {
|
|
9691
|
+
function renderSection(section, groups, renderCards, open, shown = groups.length) {
|
|
9555
9692
|
const title = INDEX_SECTION_NAMES[section].title;
|
|
9556
|
-
return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${
|
|
9557
|
-
<summary class="prb-index-section-head">${title} · <span data-section-count>${
|
|
9693
|
+
return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${shown === 0 ? " hidden" : ""}>
|
|
9694
|
+
<summary class="prb-index-section-head">${title} · <span data-section-count>${shown}</span></summary>
|
|
9558
9695
|
<div class="prb-cards" data-cards="${section}">
|
|
9559
9696
|
${renderCards(groups)}
|
|
9560
9697
|
</div>
|
|
@@ -9562,6 +9699,87 @@ function renderSection(section, groups, renderCards, open) {
|
|
|
9562
9699
|
</details>`;
|
|
9563
9700
|
}
|
|
9564
9701
|
/**
|
|
9702
|
+
* In progress: the work going on now, above everything else on the page.
|
|
9703
|
+
*
|
|
9704
|
+
* Not drawn at all when nothing is going on, rather than drawn empty and hidden like the card
|
|
9705
|
+
* sections: nothing ever moves into it in the browser, because every change to it is a change to the
|
|
9706
|
+
* index token and arrives as a reload. Its count is of work, not reviews, so a review being updated
|
|
9707
|
+
* counts once here and once in its own section, which is the point.
|
|
9708
|
+
*/
|
|
9709
|
+
function renderInProgress$1(operations, groups, positions, prStates, now, open) {
|
|
9710
|
+
if (operations.length === 0) return "";
|
|
9711
|
+
const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
|
|
9712
|
+
const entries = operations.map((operation) => renderOperation(operation, reviewOf(operation, reviews), positions, prStates, now)).join("");
|
|
9713
|
+
return `<details class="prb-index-section" data-section="in-progress"${open ? " open" : ""}>
|
|
9714
|
+
<summary class="prb-index-section-head">${INDEX_SECTION_NAMES["in-progress"].title} · <span data-section-count>${operations.length}</span></summary>
|
|
9715
|
+
<div class="prb-cards" data-operations>
|
|
9716
|
+
${entries}
|
|
9717
|
+
</div>
|
|
9718
|
+
</details>`;
|
|
9719
|
+
}
|
|
9720
|
+
/**
|
|
9721
|
+
* The review an entry is about. Its own where it has one. A row still waiting has none yet, and
|
|
9722
|
+
* takes the newest review of the same pull request or branch from the same folder where there is
|
|
9723
|
+
* one, which is what that review's title, author and state still are: the filters then treat a
|
|
9724
|
+
* re-review of a known pull request as that pull request, rather than as somebody unknown.
|
|
9725
|
+
*/
|
|
9726
|
+
function reviewOf(operation, reviews) {
|
|
9727
|
+
if (operation.reviewId !== void 0) return reviews.get(operation.reviewId);
|
|
9728
|
+
const row = operation.row;
|
|
9729
|
+
if (!row?.repoPath) return void 0;
|
|
9730
|
+
let newest;
|
|
9731
|
+
for (const review of reviews.values()) {
|
|
9732
|
+
if (review.repoPath !== row.repoPath) continue;
|
|
9733
|
+
if ((row.prNumber !== void 0 ? review.prNumber === row.prNumber : review.prNumber === void 0 && review.branch === row.branch) && (!newest || review.createdAt > newest.createdAt)) newest = review;
|
|
9734
|
+
}
|
|
9735
|
+
return newest;
|
|
9736
|
+
}
|
|
9737
|
+
/**
|
|
9738
|
+
* One piece of work, in a card's clothes and with none of a card's controls.
|
|
9739
|
+
*
|
|
9740
|
+
* It carries the same filter attributes as its review's card, so the search and the filters answer
|
|
9741
|
+
* the same question about both. No risk, no counts, no review state: there are no results yet, or the
|
|
9742
|
+
* results on show are about to be replaced, and either way they are the card's to show.
|
|
9743
|
+
*
|
|
9744
|
+
* A link to the review, whose own page shows the steps, the cancel and the Retry. A row still waiting
|
|
9745
|
+
* for a slot has no review to open, so it is not a link.
|
|
9746
|
+
*/
|
|
9747
|
+
function renderOperation(operation, review, positions, prStates, now) {
|
|
9748
|
+
const row = operation.row;
|
|
9749
|
+
const own = operation.reviewId !== void 0 && review?.id === operation.reviewId ? review : void 0;
|
|
9750
|
+
const repoPath = review?.repoPath ?? row?.repoPath;
|
|
9751
|
+
const prNumber = review?.prNumber ?? row?.prNumber;
|
|
9752
|
+
const branch = review ? followedRefName(review.branch, review.headResolvedRef) ?? review.branch : row?.branch;
|
|
9753
|
+
const prState = prNumber !== void 0 && repoPath ? prStates?.get(requestKey(repoPath, prNumber)) : void 0;
|
|
9754
|
+
const queued = own ? positions.get(own.id) : void 0;
|
|
9755
|
+
const status = own ? statusOf(own) : "new";
|
|
9756
|
+
const search = [
|
|
9757
|
+
review ? searchText(review) : "",
|
|
9758
|
+
row?.label ?? "",
|
|
9759
|
+
repoPath ? lastSegment(repoPath) : "",
|
|
9760
|
+
prNumber === void 0 ? "" : `#${prNumber} ${prNumber}`,
|
|
9761
|
+
branch ?? ""
|
|
9762
|
+
].join(" ").toLowerCase();
|
|
9763
|
+
const title = own?.title || review?.title || row?.label || "Review";
|
|
9764
|
+
const running = operation.kind !== "queued";
|
|
9765
|
+
const body = `<div class="prb-card-head">
|
|
9766
|
+
<h2>${escapeHtml$1(title)}</h2>
|
|
9767
|
+
</div>
|
|
9768
|
+
<p class="prb-card-where">
|
|
9769
|
+
${repoPath ? `<span class="prb-card-repo">${escapeHtml$1(lastSegment(repoPath))}</span>` : ""}
|
|
9770
|
+
${prNumber === void 0 ? "" : `<span class="prb-card-pr">#${prNumber}</span>`}
|
|
9771
|
+
${branch ? `<span class="prb-card-branch">${escapeHtml$1(branch)}</span>` : ""}
|
|
9772
|
+
</p>
|
|
9773
|
+
<p class="prb-card-meta">
|
|
9774
|
+
<span class="prb-card-analysis" data-analysis="${running ? "running" : "queued"}"><i></i>${escapeHtml$1(operationLine(operation))}</span>
|
|
9775
|
+
${operation.startedAt ? `<span class="prb-card-when"><time datetime="${new Date(operation.startedAt).toISOString()}">${escapeHtml$1(`Started ${relativeTime(operation.startedAt, now)}`)}</time></span>` : ""}
|
|
9776
|
+
</p>`;
|
|
9777
|
+
const href = own ? `/r/${encodeURIComponent(own.id)}?t=${encodeURIComponent(own.token)}` : void 0;
|
|
9778
|
+
return `<article class="prb-card" data-operation="${escapeHtml$1(operation.kind)}" data-key="${escapeHtml$1(operation.key)}" data-id="${escapeHtml$1(operation.reviewId ?? "")}" data-status="${status}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review?.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-search-text="${escapeHtml$1(search)}">
|
|
9779
|
+
${href ? `<a class="prb-card-link" href="${escapeHtml$1(href)}">${body}</a>` : `<div class="prb-card-link">${body}</div>`}
|
|
9780
|
+
</article>`;
|
|
9781
|
+
}
|
|
9782
|
+
/**
|
|
9565
9783
|
* The bulk bar, with its counts left out.
|
|
9566
9784
|
*
|
|
9567
9785
|
* Same division as the pager: every word is written here, where the house style tests read it, and
|
|
@@ -9618,13 +9836,13 @@ function renderBulkBar() {
|
|
|
9618
9836
|
* card and is shown only in selection mode, so entering that mode is a stylesheet change rather
|
|
9619
9837
|
* than a rebuild of the list.
|
|
9620
9838
|
*/
|
|
9621
|
-
function renderCard(group, now, activity, rank, analysis, queued, moved, prState) {
|
|
9839
|
+
function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false) {
|
|
9622
9840
|
const review = group.latest;
|
|
9623
9841
|
const status = statusOf(review);
|
|
9624
9842
|
const chip = cardLifecycleChip(review.pinned === true, review.lifecycle, activity);
|
|
9625
9843
|
const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
9626
9844
|
const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
|
|
9627
|
-
return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-lifecycle="${review.lifecycle?.state ?? "active"}"${review.lifecycle ? ` data-lifecycle-at="${review.lifecycle.at}"` : ""}${activityAttributes(activity)} data-rank="${rank}" data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
|
|
9845
|
+
return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-lifecycle="${review.lifecycle?.state ?? "active"}"${review.lifecycle ? ` data-lifecycle-at="${review.lifecycle.at}"` : ""}${activityAttributes(activity)} data-rank="${rank}" data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}"${inProgressOnly ? " data-in-progress-only hidden" : ""}>
|
|
9628
9846
|
<input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
|
|
9629
9847
|
<button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unpin" : "Pin"} this review">${review.pinned ? "★" : "☆"}</button>
|
|
9630
9848
|
${renderCardMenu(group, followed, queued !== void 0, status, activity)}
|
|
@@ -9926,6 +10144,96 @@ function relativeTime(at, now) {
|
|
|
9926
10144
|
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
9927
10145
|
}
|
|
9928
10146
|
//#endregion
|
|
10147
|
+
//#region ../../packages/review-harness/src/workspace/runs_sections.ts
|
|
10148
|
+
/**
|
|
10149
|
+
* The Runs page's two sections, as `LIST_SECTIONS` is the Reviews page's: what can be folded, what
|
|
10150
|
+
* its heading says, and its key for folding it from the keyboard (⌘K L and this key). Their own,
|
|
10151
|
+
* not the Reviews page's: In progress folded on one page is not a reason to fold it on the other.
|
|
10152
|
+
*
|
|
10153
|
+
* No DOM and no filesystem, so the page's script, the server and the command registry read one list.
|
|
10154
|
+
*/
|
|
10155
|
+
var RUNS_SECTIONS = ["in-progress", "recent"];
|
|
10156
|
+
var RUNS_SECTION_NAMES = {
|
|
10157
|
+
"in-progress": {
|
|
10158
|
+
title: "In progress",
|
|
10159
|
+
key: "i"
|
|
10160
|
+
},
|
|
10161
|
+
recent: {
|
|
10162
|
+
title: "Recent runs and issues",
|
|
10163
|
+
key: "r"
|
|
10164
|
+
}
|
|
10165
|
+
};
|
|
10166
|
+
function runsSectionOpen(section, collapsed) {
|
|
10167
|
+
return !(collapsed?.[section] ?? false);
|
|
10168
|
+
}
|
|
10169
|
+
function isRunsSection(value) {
|
|
10170
|
+
return typeof value === "string" && RUNS_SECTIONS.includes(value);
|
|
10171
|
+
}
|
|
10172
|
+
//#endregion
|
|
10173
|
+
//#region ../../packages/review-harness/src/workspace/review_layout.ts
|
|
10174
|
+
/**
|
|
10175
|
+
* How the reviewer last laid out a review page, kept so the next review opens the same way.
|
|
10176
|
+
*
|
|
10177
|
+
* Three things, each the layout as it last stood rather than a log of what was pressed: whether
|
|
10178
|
+
* Buddy AI was open or closed, whether a surface was expanded (Buddy AI itself, or the document,
|
|
10179
|
+
* whichever pane that is on the next review), and which parts of the rail were folded. A field
|
|
10180
|
+
* nobody has changed has no key, so the page's own defaults still decide for a new reviewer.
|
|
10181
|
+
*
|
|
10182
|
+
* Pure, so the page and the server read it with the same rules. Stored in `ui.json`; see
|
|
10183
|
+
* `preferences.ts`.
|
|
10184
|
+
*/
|
|
10185
|
+
/** The rail's folds, by the id of the body each one hides. Every review renders the same ids. */
|
|
10186
|
+
var RAIL_FOLDS = [
|
|
10187
|
+
"prb-rail-review",
|
|
10188
|
+
"prb-rail-path",
|
|
10189
|
+
"prb-rail-kiss"
|
|
10190
|
+
];
|
|
10191
|
+
function readRail(value) {
|
|
10192
|
+
if (!value || typeof value !== "object") return void 0;
|
|
10193
|
+
const rail = {};
|
|
10194
|
+
for (const fold of RAIL_FOLDS) {
|
|
10195
|
+
const open = value[fold];
|
|
10196
|
+
if (typeof open === "boolean") rail[fold] = open;
|
|
10197
|
+
}
|
|
10198
|
+
return Object.keys(rail).length > 0 ? rail : void 0;
|
|
10199
|
+
}
|
|
10200
|
+
/** A stored layout, keeping only what this page knows how to draw. Anything else reads as unset. */
|
|
10201
|
+
function readReviewLayout(value) {
|
|
10202
|
+
if (!value || typeof value !== "object") return {};
|
|
10203
|
+
const { ask, expanded, rail } = value;
|
|
10204
|
+
const folds = readRail(rail);
|
|
10205
|
+
return {
|
|
10206
|
+
...ask === "open" || ask === "closed" ? { ask } : {},
|
|
10207
|
+
...expanded === "buddy" || expanded === "document" ? { expanded } : {},
|
|
10208
|
+
...folds ? { rail: folds } : {}
|
|
10209
|
+
};
|
|
10210
|
+
}
|
|
10211
|
+
/** A patch from the page, or null when it holds nothing this page could have sent. */
|
|
10212
|
+
function readReviewLayoutPatch(value) {
|
|
10213
|
+
if (!value || typeof value !== "object") return null;
|
|
10214
|
+
const { ask, expanded, rail } = value;
|
|
10215
|
+
const folds = readRail(rail);
|
|
10216
|
+
const patch = {
|
|
10217
|
+
...ask === "open" || ask === "closed" ? { ask } : {},
|
|
10218
|
+
...expanded === "buddy" || expanded === "document" || expanded === null ? { expanded } : {},
|
|
10219
|
+
...folds ? { rail: folds } : {}
|
|
10220
|
+
};
|
|
10221
|
+
return Object.keys(patch).length > 0 ? patch : null;
|
|
10222
|
+
}
|
|
10223
|
+
function applyReviewLayoutPatch(layout, patch) {
|
|
10224
|
+
const { expanded: _old, ...rest } = layout;
|
|
10225
|
+
const expanded = patch.expanded === void 0 ? layout.expanded : patch.expanded;
|
|
10226
|
+
return {
|
|
10227
|
+
...rest,
|
|
10228
|
+
...patch.ask ? { ask: patch.ask } : {},
|
|
10229
|
+
...expanded ? { expanded } : {},
|
|
10230
|
+
...patch.rail ? { rail: {
|
|
10231
|
+
...layout.rail,
|
|
10232
|
+
...patch.rail
|
|
10233
|
+
} } : {}
|
|
10234
|
+
};
|
|
10235
|
+
}
|
|
10236
|
+
//#endregion
|
|
9929
10237
|
//#region ../../packages/review-harness/src/workspace/plain_page.ts
|
|
9930
10238
|
function commandList(way) {
|
|
9931
10239
|
return `<ul class="prb-commands">${way.commands.map(({ command, where }) => `<li><code>${escapeHtml(command)}</code> in ${escapeHtml(where)}</li>`).join("")}</ul>`;
|
|
@@ -9991,14 +10299,16 @@ function readPreferences() {
|
|
|
9991
10299
|
try {
|
|
9992
10300
|
const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
|
|
9993
10301
|
if (!parsed || typeof parsed !== "object") return {};
|
|
9994
|
-
const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme, reviewSections } = parsed;
|
|
10302
|
+
const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme, reviewSections, runsSections, reviewLayout } = parsed;
|
|
9995
10303
|
return {
|
|
9996
10304
|
...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
|
|
9997
10305
|
...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
|
|
9998
10306
|
...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
|
|
9999
10307
|
...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {},
|
|
10000
10308
|
...isTheme(theme) ? { theme } : {},
|
|
10001
|
-
...readReviewSections(reviewSections)
|
|
10309
|
+
...readReviewSections(reviewSections),
|
|
10310
|
+
...readRunsSections(runsSections),
|
|
10311
|
+
...readLayout(reviewLayout)
|
|
10002
10312
|
};
|
|
10003
10313
|
} catch {
|
|
10004
10314
|
return {};
|
|
@@ -10015,6 +10325,22 @@ function writeAskWidth(width) {
|
|
|
10015
10325
|
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
|
|
10016
10326
|
} catch {}
|
|
10017
10327
|
}
|
|
10328
|
+
function readLayout(value) {
|
|
10329
|
+
const layout = readReviewLayout(value);
|
|
10330
|
+
return Object.keys(layout).length > 0 ? { reviewLayout: layout } : {};
|
|
10331
|
+
}
|
|
10332
|
+
/** Remember a change to the review page's layout. Read-modify-write, like every other field here. */
|
|
10333
|
+
function writeReviewLayout(patch) {
|
|
10334
|
+
try {
|
|
10335
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
10336
|
+
const current = readPreferences();
|
|
10337
|
+
const next = {
|
|
10338
|
+
...current,
|
|
10339
|
+
reviewLayout: applyReviewLayoutPatch(current.reviewLayout ?? {}, patch)
|
|
10340
|
+
};
|
|
10341
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
|
|
10342
|
+
} catch {}
|
|
10343
|
+
}
|
|
10018
10344
|
/** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
|
|
10019
10345
|
function writePromptsCollapsed(collapsed) {
|
|
10020
10346
|
try {
|
|
@@ -10039,7 +10365,7 @@ function writeChangeMapRegister(register) {
|
|
|
10039
10365
|
function readReviewSections(value) {
|
|
10040
10366
|
if (!value || typeof value !== "object") return {};
|
|
10041
10367
|
const sections = {};
|
|
10042
|
-
for (const section of
|
|
10368
|
+
for (const section of LIST_SECTIONS) {
|
|
10043
10369
|
const collapsed = value[section];
|
|
10044
10370
|
if (typeof collapsed === "boolean") sections[section] = collapsed;
|
|
10045
10371
|
}
|
|
@@ -10064,6 +10390,31 @@ function writeSectionCollapsed(section, collapsed) {
|
|
|
10064
10390
|
}, null, 2)}\n`);
|
|
10065
10391
|
} catch {}
|
|
10066
10392
|
}
|
|
10393
|
+
/** As `readReviewSections`, for the Runs page. */
|
|
10394
|
+
function readRunsSections(value) {
|
|
10395
|
+
if (!value || typeof value !== "object") return {};
|
|
10396
|
+
const sections = {};
|
|
10397
|
+
for (const section of RUNS_SECTIONS) {
|
|
10398
|
+
const collapsed = value[section];
|
|
10399
|
+
if (typeof collapsed === "boolean") sections[section] = collapsed;
|
|
10400
|
+
}
|
|
10401
|
+
return Object.keys(sections).length === 0 ? {} : { runsSections: sections };
|
|
10402
|
+
}
|
|
10403
|
+
/** As `writeSectionCollapsed`, for the Runs page's sections. */
|
|
10404
|
+
function writeRunsSectionCollapsed(section, collapsed) {
|
|
10405
|
+
try {
|
|
10406
|
+
const current = readPreferences();
|
|
10407
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
10408
|
+
const runsSections = {
|
|
10409
|
+
...current.runsSections,
|
|
10410
|
+
[section]: collapsed
|
|
10411
|
+
};
|
|
10412
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
10413
|
+
...current,
|
|
10414
|
+
runsSections
|
|
10415
|
+
}, null, 2)}\n`);
|
|
10416
|
+
} catch {}
|
|
10417
|
+
}
|
|
10067
10418
|
/** Remember which palette the workspace is drawn in. Read-modify-write, like every other field here. */
|
|
10068
10419
|
function writeTheme(theme) {
|
|
10069
10420
|
try {
|
|
@@ -10212,10 +10563,12 @@ ${renderWorkspaceRail({
|
|
|
10212
10563
|
unread: view.unreadNotifications ?? 0
|
|
10213
10564
|
})}
|
|
10214
10565
|
<main class="prb-index prb-settings">
|
|
10566
|
+
<div class="prb-page-panel">
|
|
10215
10567
|
${renderAppearanceSection(settings)}
|
|
10216
10568
|
${renderWorkspaceSection(settings, address)}
|
|
10217
10569
|
${renderReviewsSection(settings)}
|
|
10218
10570
|
${renderPrivacySection(settings)}
|
|
10571
|
+
</div>
|
|
10219
10572
|
|
|
10220
10573
|
<div class="prb-settings-actions">
|
|
10221
10574
|
<button type="button" class="prb-dialog-primary" data-settings-save>Save changes</button>
|
|
@@ -10261,13 +10614,13 @@ function renderBack() {
|
|
|
10261
10614
|
* The dialog it opens is rendered once on this page. See `renderShutdownDialog`.
|
|
10262
10615
|
*/
|
|
10263
10616
|
function renderServerSection() {
|
|
10264
|
-
return `<section class="prb-settings-section">
|
|
10617
|
+
return `<div class="prb-page-panel"><section class="prb-settings-section">
|
|
10265
10618
|
<h2>Server</h2>
|
|
10266
10619
|
<button type="button" class="prb-settings-danger" data-shutdown>Stop server</button>
|
|
10267
10620
|
<p class="prb-settings-help">Stops the local PR Review Buddy workspace. Nothing is deleted and
|
|
10268
10621
|
every review keeps its address. Start it again with <code>prreviewbuddy open</code>, from any
|
|
10269
10622
|
directory. Restarting instead needs no terminal: the button in the bar above does it.</p>
|
|
10270
|
-
</section>`;
|
|
10623
|
+
</section></div>`;
|
|
10271
10624
|
}
|
|
10272
10625
|
/**
|
|
10273
10626
|
* The port, and the two answers to "which port" that a save creates.
|
|
@@ -10856,7 +11209,12 @@ feature: E</pre>
|
|
|
10856
11209
|
days instead.</li>
|
|
10857
11210
|
<li><b>Isolated checkouts</b> are removed six hours after their work finishes. See
|
|
10858
11211
|
<a href="#isolated-checkouts">Isolated checkouts</a>.</li>
|
|
11212
|
+
<li><b>Runs</b>, the record on the Runs page of what ran and how it ended, are kept for seven
|
|
11213
|
+
days. Deleting a review keeps that record of its runs, with none of their output, until the
|
|
11214
|
+
seven days are up.</li>
|
|
10859
11215
|
</ul>
|
|
11216
|
+
<p>Clearing up is done by the workspace server, when it starts and every ten minutes while it
|
|
11217
|
+
runs. While it is stopped nothing is removed, though nothing past its time is ever shown.</p>
|
|
10860
11218
|
<p><code>prreviewbuddy uninstall</code> removes all of it at once.</p>`
|
|
10861
11219
|
},
|
|
10862
11220
|
{
|
|
@@ -10902,11 +11260,13 @@ ${renderWorkspaceRail({
|
|
|
10902
11260
|
unread: view.unreadNotifications ?? 0
|
|
10903
11261
|
})}
|
|
10904
11262
|
<main class="prb-index prb-help">
|
|
11263
|
+
<div class="prb-page-panel">
|
|
10905
11264
|
${renderContents()}
|
|
10906
11265
|
${TOPICS.map((topic) => `<section class="prb-help-topic" id="${topic.id}">
|
|
10907
11266
|
<h2>${escapeHtml$1(topic.title)}</h2>
|
|
10908
11267
|
${topic.body}
|
|
10909
11268
|
</section>`).join("")}
|
|
11269
|
+
</div>
|
|
10910
11270
|
<p class="prb-help-version">PR Review Buddy v${escapeHtml$1(BUILD_VERSION)}</p>
|
|
10911
11271
|
</main>
|
|
10912
11272
|
</div>
|
|
@@ -11023,1316 +11383,2058 @@ function renderNotificationRow(notification, gone) {
|
|
|
11023
11383
|
</li>`;
|
|
11024
11384
|
}
|
|
11025
11385
|
//#endregion
|
|
11026
|
-
//#region ../../packages/review-harness/src/workspace/
|
|
11027
|
-
/**
|
|
11028
|
-
* Whether anything the reviews index draws has changed, as one token.
|
|
11029
|
-
*
|
|
11030
|
-
* The index reacts to a change by reloading, which is what makes an aggregate the right shape: it
|
|
11031
|
-
* never needs to know *which* review changed, so it asks one question whose cost is the same for
|
|
11032
|
-
* five reviews and five hundred. Per-card revisions would be more information than the page has any
|
|
11033
|
-
* use for, and a request per card to carry it.
|
|
11034
|
-
*
|
|
11035
|
-
* Opaque and **equality only**, the same contract as `workspaceRevision` in `store.ts`: compare two
|
|
11036
|
-
* tokens for equality, never order them, never parse one. The implementation below is a hash of
|
|
11037
|
-
* facts the page happens to draw today, and the whole point of saying so here is that it can be
|
|
11038
|
-
* replaced by a counter, a watch, or anything else without a page having to be told.
|
|
11039
|
-
*
|
|
11040
|
-
* ## What is in it, and what is deliberately not
|
|
11041
|
-
*
|
|
11042
|
-
* In: every review's id and file revision, and the phase of every analysis running right now.
|
|
11043
|
-
* Between them those cover everything a card shows — the summary, the risk badge, the counts, the
|
|
11044
|
-
* pin, the reviewed mark, the running line — because every one of those reaches disk through
|
|
11045
|
-
* `writeWorkspace`, and the set of ids covers a review appearing or being deleted.
|
|
11046
|
-
*
|
|
11047
|
-
* Not in: **`job.updatedAt` and `job.progress`.** This is the load-bearing omission. An agent
|
|
11048
|
-
* streams its progress into the job record every few seconds, so a token derived from job file
|
|
11049
|
-
* mtimes would differ on nearly every poll, and a page that reloads whenever the token differs
|
|
11050
|
-
* would then reload every few seconds for the whole length of an analysis — a page thrashing
|
|
11051
|
-
* itself, with the reviewer's scroll position going with it. Phase only, which changes four times
|
|
11052
|
-
* in a run.
|
|
11053
|
-
*/
|
|
11386
|
+
//#region ../../packages/review-harness/src/workspace/working_folders.ts
|
|
11054
11387
|
/**
|
|
11055
|
-
*
|
|
11056
|
-
*
|
|
11057
|
-
* Keyed by review, valued by the phase the card should name. Two states reach it and a third
|
|
11058
|
-
* deliberately does not:
|
|
11059
|
-
*
|
|
11060
|
-
* - **Running.** A job that is not terminal *and* whose driver holds a live claim. The claim is the
|
|
11061
|
-
* load-bearing half: a job whose driver was killed sits in a working phase for up to half an hour
|
|
11062
|
-
* before the sweep calls it stopped, and narrating one of those as "Analysing the change" would
|
|
11063
|
-
* be the index confidently reporting work that nothing is doing.
|
|
11064
|
-
* - **`failed`.** Reported whether or not anything still holds the claim, which is the whole reason
|
|
11065
|
-
* this function is not simply `runningJobs`. It was: `runningJobs` drops terminal jobs, and
|
|
11066
|
-
* `isTerminal` counts `failed` as terminal, so a failed analysis could never reach the renderer
|
|
11067
|
-
* and the card's "Analysis stopped" branch was unreachable in production while passing a unit
|
|
11068
|
-
* test that handed it a state the product could not produce. A review whose analysis failed went
|
|
11069
|
-
* back to reading "No standard review yet" the moment its driver let go.
|
|
11070
|
-
* - **`done`, and an unclaimed job still in a working phase.** Nothing. A finished job has its
|
|
11071
|
-
* result on the card already, and an abandoned one is the sweep's to judge rather than this
|
|
11072
|
-
* renderer's to pre-empt.
|
|
11073
|
-
*
|
|
11074
|
-
* **Every job a review has, not the latest one.** A review accumulates them -- a standard analysis,
|
|
11075
|
-
* a KISS run, a retry -- and two of them genuinely run at once: `handleKiss` refuses only a second
|
|
11076
|
-
* KISS run, and the review page's poll is built around one finishing while the other goes on. An
|
|
11077
|
-
* earlier cut picked the single newest job and then asked what it was, so a KISS run that ended
|
|
11078
|
-
* first spoke for a standard analysis that was still working, and the card fell silent on work that
|
|
11079
|
-
* was happening in front of the reviewer.
|
|
11388
|
+
* The local Git working folders PR Review Buddy can find right now.
|
|
11080
11389
|
*
|
|
11081
|
-
*
|
|
11082
|
-
*
|
|
11390
|
+
* One question and nothing more: which folders on this machine could a review run in. The answer is
|
|
11391
|
+
* derived each time from what is already known, the folders past reviews ran in and the other
|
|
11392
|
+
* worktrees of those repositories, and nothing is stored. A worktree an agent made yesterday shows
|
|
11393
|
+
* up because `git worktree list` names it, not because anything remembered it.
|
|
11083
11394
|
*
|
|
11084
|
-
*
|
|
11085
|
-
*
|
|
11395
|
+
* A folder is a folder. A linked worktree is its own entry with its own path and is never folded
|
|
11396
|
+
* into the clone it belongs to: a review is filed under the folder it ran in, and the worktree is
|
|
11397
|
+
* where the branch is checked out.
|
|
11086
11398
|
*
|
|
11087
|
-
*
|
|
11088
|
-
* that passes the card quietly returns to saying nothing, which is the same silence every review
|
|
11089
|
-
* had before this existed.
|
|
11399
|
+
* Not a registry. If one is wanted later it replaces `discoverWorkingFolders` and keeps the shape.
|
|
11090
11400
|
*/
|
|
11091
|
-
function
|
|
11092
|
-
|
|
11093
|
-
|
|
11094
|
-
|
|
11095
|
-
|
|
11096
|
-
if (held) held.push(job);
|
|
11097
|
-
else byReview.set(job.workspaceId, [job]);
|
|
11401
|
+
function canonical(path) {
|
|
11402
|
+
try {
|
|
11403
|
+
return realpathSync(path);
|
|
11404
|
+
} catch {
|
|
11405
|
+
return path;
|
|
11098
11406
|
}
|
|
11099
|
-
|
|
11100
|
-
|
|
11101
|
-
|
|
11102
|
-
|
|
11103
|
-
|
|
11104
|
-
|
|
11105
|
-
|
|
11106
|
-
|
|
11107
|
-
|
|
11108
|
-
|
|
11109
|
-
|
|
11110
|
-
|
|
11111
|
-
|
|
11112
|
-
const
|
|
11113
|
-
|
|
11114
|
-
|
|
11115
|
-
|
|
11116
|
-
|
|
11117
|
-
const
|
|
11118
|
-
|
|
11119
|
-
|
|
11120
|
-
|
|
11121
|
-
purpose: purposeOf(running[0])
|
|
11122
|
-
});
|
|
11123
|
-
continue;
|
|
11124
|
-
}
|
|
11125
|
-
const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
|
|
11126
|
-
if (ended[0]?.phase === "failed") states.set(reviewId, {
|
|
11127
|
-
phase: "failed",
|
|
11128
|
-
purpose: "review",
|
|
11129
|
-
...ended[0].failure?.stopped ? { stopped: true } : {}
|
|
11407
|
+
}
|
|
11408
|
+
/** `git worktree list --porcelain`, main worktree first, bare and prunable entries left out. */
|
|
11409
|
+
async function worktreesOf(repoPath) {
|
|
11410
|
+
let output;
|
|
11411
|
+
try {
|
|
11412
|
+
output = await git(repoPath, [
|
|
11413
|
+
"worktree",
|
|
11414
|
+
"list",
|
|
11415
|
+
"--porcelain"
|
|
11416
|
+
]);
|
|
11417
|
+
} catch {
|
|
11418
|
+
return [];
|
|
11419
|
+
}
|
|
11420
|
+
const entries = [];
|
|
11421
|
+
for (const block of output.split(/\n\s*\n/)) {
|
|
11422
|
+
const lines = block.split("\n");
|
|
11423
|
+
const path = lines.find((line) => line.startsWith("worktree "))?.slice(9);
|
|
11424
|
+
if (!path || lines.includes("bare") || lines.some((line) => line.startsWith("prunable"))) continue;
|
|
11425
|
+
const ref = lines.find((line) => line.startsWith("branch "))?.slice(7) ?? null;
|
|
11426
|
+
entries.push({
|
|
11427
|
+
path,
|
|
11428
|
+
branch: ref ? ref.replace(/^refs\/heads\//, "") : null
|
|
11130
11429
|
});
|
|
11131
11430
|
}
|
|
11132
|
-
return
|
|
11431
|
+
return entries;
|
|
11133
11432
|
}
|
|
11134
|
-
|
|
11135
|
-
|
|
11136
|
-
|
|
11137
|
-
|
|
11138
|
-
|
|
11139
|
-
|
|
11140
|
-
* had not seen.
|
|
11141
|
-
*/
|
|
11142
|
-
function reviewsRevision(live = analysisStates()) {
|
|
11143
|
-
const hash = createHash("sha1");
|
|
11144
|
-
for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
|
|
11145
|
-
for (const id of [...live.keys()].sort()) {
|
|
11146
|
-
const state = live.get(id);
|
|
11147
|
-
hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
|
|
11433
|
+
async function discoverWorkingFolders(summaries = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
|
|
11434
|
+
const lastReviewed = /* @__PURE__ */ new Map();
|
|
11435
|
+
for (const summary of summaries) {
|
|
11436
|
+
if (!summary.repoPath) continue;
|
|
11437
|
+
const key = canonical(summary.repoPath);
|
|
11438
|
+
lastReviewed.set(key, Math.max(lastReviewed.get(key) ?? 0, summary.createdAt));
|
|
11148
11439
|
}
|
|
11149
|
-
|
|
11150
|
-
for (const
|
|
11151
|
-
|
|
11152
|
-
|
|
11153
|
-
|
|
11154
|
-
|
|
11155
|
-
|
|
11156
|
-
|
|
11157
|
-
|
|
11158
|
-
}
|
|
11159
|
-
|
|
11160
|
-
|
|
11161
|
-
|
|
11162
|
-
if (body?.op === "decide") {
|
|
11163
|
-
if (typeof body.proposalId !== "string" || body.proposalId === "") return null;
|
|
11164
|
-
if (body.decision !== "accepted" && body.decision !== "declined") return null;
|
|
11165
|
-
return {
|
|
11166
|
-
op: "decide",
|
|
11167
|
-
proposalId: body.proposalId,
|
|
11168
|
-
decision: body.decision
|
|
11169
|
-
};
|
|
11170
|
-
}
|
|
11171
|
-
if (body?.op === "set") {
|
|
11172
|
-
const kind = asKind(body.kind);
|
|
11173
|
-
if (!kind || typeof body.id !== "string" || body.id === "") return null;
|
|
11174
|
-
if (!isGradeFor(kind, body.to)) return null;
|
|
11175
|
-
if (body.from !== null && !isGradeFor(kind, body.from)) return null;
|
|
11176
|
-
if (body.reason !== void 0 && typeof body.reason !== "string") return null;
|
|
11177
|
-
const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 500) : "";
|
|
11178
|
-
return {
|
|
11179
|
-
op: "set",
|
|
11180
|
-
kind,
|
|
11181
|
-
findingId: body.id,
|
|
11182
|
-
from: body.from,
|
|
11183
|
-
to: body.to,
|
|
11184
|
-
...reason ? { reason } : {}
|
|
11185
|
-
};
|
|
11440
|
+
const found = /* @__PURE__ */ new Map();
|
|
11441
|
+
for (const seed of lastReviewed.keys()) {
|
|
11442
|
+
if (found.has(seed) || !existsSync(seed)) continue;
|
|
11443
|
+
(await worktreesOf(seed)).forEach((entry, index) => {
|
|
11444
|
+
const key = canonical(entry.path);
|
|
11445
|
+
if (!found.has(key) && existsSync(key)) found.set(key, {
|
|
11446
|
+
entry: {
|
|
11447
|
+
...entry,
|
|
11448
|
+
path: key
|
|
11449
|
+
},
|
|
11450
|
+
main: index === 0
|
|
11451
|
+
});
|
|
11452
|
+
});
|
|
11186
11453
|
}
|
|
11187
|
-
return
|
|
11188
|
-
|
|
11189
|
-
|
|
11190
|
-
|
|
11191
|
-
|
|
11192
|
-
|
|
11193
|
-
|
|
11194
|
-
outdated: "That suggestion was made against an earlier version of this review. Reassess again to judge it as it stands.",
|
|
11195
|
-
"no-reason": "A suggestion needs a reason.",
|
|
11196
|
-
unknown: "This review has no suggestion with that id.",
|
|
11197
|
-
decided: "That suggestion has already been answered.",
|
|
11198
|
-
"no-finding": "This review has no such finding."
|
|
11199
|
-
};
|
|
11200
|
-
function gradeFragments$1(session, kind, id) {
|
|
11201
|
-
const finding = findingIn(session, kind, id);
|
|
11202
|
-
if (!finding) return null;
|
|
11203
|
-
const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
|
|
11204
|
-
const options = {
|
|
11205
|
-
editable: true,
|
|
11206
|
-
askMore: true
|
|
11207
|
-
};
|
|
11208
|
-
return {
|
|
11209
|
-
kind,
|
|
11210
|
-
id,
|
|
11211
|
-
current: assessment.current,
|
|
11212
|
-
cardClass: cardGradeClass(kind, assessment.current),
|
|
11213
|
-
grade: renderGradeControl(kind, assessment, options),
|
|
11214
|
-
region: renderAssessmentRegion(kind, assessment, options),
|
|
11215
|
-
copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
|
|
11216
|
-
};
|
|
11217
|
-
}
|
|
11218
|
-
/** The finding a request is about: named outright, or through the proposal it decides. */
|
|
11219
|
-
function findingOfRequest(session, request) {
|
|
11220
|
-
if (request.op === "set") return {
|
|
11221
|
-
kind: request.kind,
|
|
11222
|
-
id: request.findingId
|
|
11223
|
-
};
|
|
11224
|
-
const proposal = session.findingReassessments?.find((entry) => entry.id === request.proposalId);
|
|
11225
|
-
return proposal ? {
|
|
11226
|
-
kind: proposal.kind,
|
|
11227
|
-
id: proposal.findingId
|
|
11228
|
-
} : null;
|
|
11454
|
+
return (await Promise.all([...found.values()].map(async ({ entry, main }) => ({
|
|
11455
|
+
path: entry.path,
|
|
11456
|
+
remote: await originRemote(entry.path),
|
|
11457
|
+
branch: entry.branch,
|
|
11458
|
+
main,
|
|
11459
|
+
lastReviewedAt: lastReviewed.get(entry.path) ?? null
|
|
11460
|
+
})))).sort((a, b) => (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0) || Number(b.main) - Number(a.main) || a.path.localeCompare(b.path));
|
|
11229
11461
|
}
|
|
11230
|
-
/**
|
|
11231
|
-
function
|
|
11232
|
-
|
|
11233
|
-
const card = finding ? gradeFragments$1(session, finding.kind, finding.id) : null;
|
|
11234
|
-
return {
|
|
11235
|
-
status: refusal === "stale" || refusal === "outdated" || refusal === "decided" ? 409 : 400,
|
|
11236
|
-
body: {
|
|
11237
|
-
error: REASSESSMENT_REFUSED[refusal],
|
|
11238
|
-
refusal,
|
|
11239
|
-
...card ? { card } : {}
|
|
11240
|
-
}
|
|
11241
|
-
};
|
|
11462
|
+
/** Whether a remote is this github.com repository. GitHub names are case-insensitive. */
|
|
11463
|
+
function isGitHubRepository(remote, owner, repo) {
|
|
11464
|
+
return Boolean(remote && remote.host === "github.com" && remote.owner.toLowerCase() === owner.toLowerCase() && remote.repo.toLowerCase() === repo.toLowerCase());
|
|
11242
11465
|
}
|
|
11243
|
-
//#endregion
|
|
11244
|
-
//#region ../../packages/review-harness/src/workspace/reassessment_state.ts
|
|
11245
11466
|
/**
|
|
11246
|
-
* The
|
|
11467
|
+
* The folders a pull request could be reviewed in, the suggested one first.
|
|
11247
11468
|
*
|
|
11248
|
-
*
|
|
11249
|
-
*
|
|
11250
|
-
*
|
|
11251
|
-
*
|
|
11469
|
+
* Every match is returned, because which one is right is the reviewer's call: an agent's worktree
|
|
11470
|
+
* and the main clone hold the same objects and differ in what is checked out. The order only
|
|
11471
|
+
* decides the default. A folder that already has a review of this pull request comes first, so
|
|
11472
|
+
* reviewing it again finds that review rather than starting a second one filed under another
|
|
11473
|
+
* folder; then the main worktree; then whichever was reviewed in most recently.
|
|
11252
11474
|
*/
|
|
11253
|
-
|
|
11254
|
-
|
|
11255
|
-
|
|
11475
|
+
function candidatesFor(folders, link, summaries) {
|
|
11476
|
+
const reviewedHere = new Set(summaries.filter((summary) => summary.prNumber === link.number).map((summary) => canonical(summary.repoPath)));
|
|
11477
|
+
const matching = folders.filter((folder) => isGitHubRepository(folder.remote, link.owner, link.repo));
|
|
11478
|
+
const rank = (folder) => reviewedHere.has(folder.path) ? 0 : folder.main ? 1 : 2;
|
|
11479
|
+
return [...matching].sort((a, b) => rank(a) - rank(b) || (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0));
|
|
11480
|
+
}
|
|
11481
|
+
/** The review of this pull request already filed under one of these folders, if there is one. */
|
|
11482
|
+
function reviewedIn(folders, link, summaries) {
|
|
11483
|
+
const paths = new Set(folders.map((folder) => folder.path));
|
|
11484
|
+
return summaries.find((summary) => summary.prNumber === link.number && paths.has(canonical(summary.repoPath))) ?? null;
|
|
11256
11485
|
}
|
|
11486
|
+
//#endregion
|
|
11487
|
+
//#region ../../packages/review-harness/src/workspace/choose_folder.ts
|
|
11257
11488
|
/**
|
|
11258
|
-
*
|
|
11259
|
-
*
|
|
11489
|
+
* Ask the person at this machine to pick a folder, with the operating system's own picker.
|
|
11490
|
+
*
|
|
11491
|
+
* The workspace is a page served by a local daemon, and a page cannot learn a folder's path from
|
|
11492
|
+
* the browser's directory picker, so the daemon asks instead. The operation is "choose a working
|
|
11493
|
+
* folder"; how a platform does it stays in this file, so neither the routes nor the page know.
|
|
11494
|
+
*
|
|
11495
|
+
* Only macOS has an implementation, through `osascript`. Elsewhere there is no chooser, and the
|
|
11496
|
+
* Add review panel falls back to the path field and the Recent list, which is all v1 needs.
|
|
11260
11497
|
*/
|
|
11261
|
-
|
|
11262
|
-
|
|
11263
|
-
|
|
11264
|
-
|
|
11265
|
-
|
|
11266
|
-
|
|
11267
|
-
|
|
11268
|
-
|
|
11269
|
-
|
|
11270
|
-
body: { error: "No such review" }
|
|
11271
|
-
};
|
|
11272
|
-
const early = reassessIn(structuredClone(before.session), request, now());
|
|
11273
|
-
if (!early.ok) return refusedReply(before.session, request, early.refusal);
|
|
11274
|
-
let outcome = null;
|
|
11275
|
-
const saved = mutateWorkspace(workspaceId, (current) => {
|
|
11276
|
-
outcome = reassessIn(current.session, request, now());
|
|
11498
|
+
/** Long enough for somebody to browse; short enough that a forgotten dialog does not hold a request for ever. */
|
|
11499
|
+
var CHOOSER_TIMEOUT_MS = 3e5;
|
|
11500
|
+
function folderChooser(platform = process.platform) {
|
|
11501
|
+
if (platform !== "darwin") return null;
|
|
11502
|
+
return () => new Promise((resolve, reject) => {
|
|
11503
|
+
execFile("osascript", ["-e", "POSIX path of (choose folder with prompt \"Choose the working folder to review\")"], { timeout: CHOOSER_TIMEOUT_MS }, (error, stdout, stderr) => {
|
|
11504
|
+
if (error) return /-128/.test(String(stderr)) ? resolve(null) : reject(/* @__PURE__ */ new Error("The folder picker could not be opened."));
|
|
11505
|
+
resolve(stdout.trim().replace(/\/$/, "") || null);
|
|
11506
|
+
});
|
|
11277
11507
|
});
|
|
11278
|
-
|
|
11279
|
-
|
|
11280
|
-
|
|
11508
|
+
}
|
|
11509
|
+
function createSlots(capacity) {
|
|
11510
|
+
let free = capacity;
|
|
11511
|
+
const waiting = [];
|
|
11512
|
+
const releaser = () => {
|
|
11513
|
+
let given = false;
|
|
11514
|
+
return () => {
|
|
11515
|
+
if (given) return;
|
|
11516
|
+
given = true;
|
|
11517
|
+
const next = waiting.shift();
|
|
11518
|
+
if (next) next(releaser());
|
|
11519
|
+
else free += 1;
|
|
11520
|
+
};
|
|
11281
11521
|
};
|
|
11282
|
-
const written = outcome;
|
|
11283
|
-
if (!written.ok) return refusedReply(saved.session, request, written.refusal);
|
|
11284
|
-
const card = gradeFragments(saved, written.entry.kind, written.entry.findingId);
|
|
11285
11522
|
return {
|
|
11286
|
-
|
|
11287
|
-
|
|
11288
|
-
|
|
11289
|
-
|
|
11290
|
-
|
|
11523
|
+
acquire: () => {
|
|
11524
|
+
if (free > 0) {
|
|
11525
|
+
free -= 1;
|
|
11526
|
+
return Promise.resolve(releaser());
|
|
11527
|
+
}
|
|
11528
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
11529
|
+
},
|
|
11530
|
+
tryAcquire: () => {
|
|
11531
|
+
if (free === 0 || waiting.length > 0) return null;
|
|
11532
|
+
free -= 1;
|
|
11533
|
+
return releaser();
|
|
11291
11534
|
}
|
|
11292
11535
|
};
|
|
11293
11536
|
}
|
|
11294
|
-
|
|
11295
|
-
|
|
11296
|
-
/**
|
|
11297
|
-
|
|
11298
|
-
|
|
11299
|
-
|
|
11300
|
-
|
|
11301
|
-
|
|
11302
|
-
function
|
|
11303
|
-
return
|
|
11304
|
-
}
|
|
11305
|
-
|
|
11306
|
-
|
|
11307
|
-
|
|
11308
|
-
|
|
11309
|
-
|
|
11310
|
-
|
|
11311
|
-
|
|
11312
|
-
|
|
11313
|
-
|
|
11314
|
-
|
|
11315
|
-
|
|
11316
|
-
|
|
11317
|
-
|
|
11318
|
-
|
|
11319
|
-
|
|
11320
|
-
|
|
11321
|
-
|
|
11322
|
-
|
|
11323
|
-
|
|
11324
|
-
}
|
|
11325
|
-
|
|
11326
|
-
|
|
11327
|
-
|
|
11328
|
-
|
|
11329
|
-
|
|
11330
|
-
|
|
11331
|
-
|
|
11332
|
-
|
|
11333
|
-
|
|
11334
|
-
|
|
11335
|
-
|
|
11336
|
-
|
|
11337
|
-
|
|
11338
|
-
|
|
11339
|
-
|
|
11340
|
-
|
|
11341
|
-
|
|
11342
|
-
|
|
11343
|
-
|
|
11344
|
-
|
|
11345
|
-
|
|
11346
|
-
|
|
11537
|
+
/** How many pull requests one submission may name. See `MAX_PULL_REQUESTS_PER_BATCH`. */
|
|
11538
|
+
var MAX_BATCH = 20;
|
|
11539
|
+
/**
|
|
11540
|
+
* How long a finished batch stays answerable once nobody is asking about it. An open panel keeps
|
|
11541
|
+
* asking (`KEEPALIVE` in the client is well inside this), so a batch is only forgotten after its
|
|
11542
|
+
* panel has gone, and its Retry with it.
|
|
11543
|
+
*/
|
|
11544
|
+
var FINISHED_BATCH_TTL_MS = 6e5;
|
|
11545
|
+
function realAddReviewDeps() {
|
|
11546
|
+
return {
|
|
11547
|
+
resolveAgent: (flag) => resolveAgent(flag ? { flag } : {}),
|
|
11548
|
+
agents: async () => (await detectAgents()).map((agent) => {
|
|
11549
|
+
const modelHelp = modelHelpOf(agent.id);
|
|
11550
|
+
return {
|
|
11551
|
+
id: agent.id,
|
|
11552
|
+
displayName: agent.displayName,
|
|
11553
|
+
installed: agent.state === "installed",
|
|
11554
|
+
...modelHelp ? { modelHelp } : {}
|
|
11555
|
+
};
|
|
11556
|
+
}),
|
|
11557
|
+
folders: () => discoverWorkingFolders(),
|
|
11558
|
+
summaries: () => recentWorkspaces(Number.MAX_SAFE_INTEGER),
|
|
11559
|
+
remoteOf: originRemote,
|
|
11560
|
+
resolvePullRequest: (folder, url) => resolveTarget({
|
|
11561
|
+
repoPath: folder,
|
|
11562
|
+
prUrl: url,
|
|
11563
|
+
resolveRequest: forgeResolver
|
|
11564
|
+
}),
|
|
11565
|
+
resolveBranch: (folder, base) => resolveTarget({
|
|
11566
|
+
repoPath: folder,
|
|
11567
|
+
...base ? { base } : {}
|
|
11568
|
+
}),
|
|
11569
|
+
defaultBase: async (root) => {
|
|
11570
|
+
try {
|
|
11571
|
+
const base = await resolveBase(root, void 0);
|
|
11572
|
+
return (base.followRef ?? base.display).replace(/^refs\/(?:heads|remotes)\//, "");
|
|
11573
|
+
} catch {
|
|
11574
|
+
return null;
|
|
11575
|
+
}
|
|
11576
|
+
},
|
|
11577
|
+
findExisting: findExistingReview,
|
|
11578
|
+
prepare: (target, agentId, fresh, requestedModel) => prepareReview(target, agentId, fresh, requestedModel, "workspace"),
|
|
11579
|
+
settle: (workspaceId) => settleReview(workspaceId, "standard", void 0, "workspace"),
|
|
11580
|
+
observe: (workspaceId) => observeReview(workspaceId),
|
|
11581
|
+
commitPresent: (root, sha) => refExists(root, sha),
|
|
11582
|
+
track,
|
|
11583
|
+
chooseFolder: folderChooser(),
|
|
11584
|
+
concurrency: 2,
|
|
11585
|
+
slots: createSlots(2),
|
|
11586
|
+
resumeOf: (reviewId) => {
|
|
11587
|
+
const failure = failureOf$1(reviewId);
|
|
11588
|
+
return failure ? {
|
|
11589
|
+
agent: agentNameOf(failure.agentId),
|
|
11590
|
+
...failure.requestedModel ? { model: failure.requestedModel } : {}
|
|
11591
|
+
} : null;
|
|
11592
|
+
},
|
|
11593
|
+
agentName: agentNameOf,
|
|
11594
|
+
home: homedir()
|
|
11595
|
+
};
|
|
11596
|
+
}
|
|
11597
|
+
function modelHelpOf(agentId) {
|
|
11598
|
+
try {
|
|
11599
|
+
return agentById(agentId).modelSelectionHelp;
|
|
11600
|
+
} catch {
|
|
11601
|
+
return;
|
|
11602
|
+
}
|
|
11603
|
+
}
|
|
11604
|
+
function agentNameOf(agentId) {
|
|
11605
|
+
try {
|
|
11606
|
+
return agentById(agentId).displayName;
|
|
11607
|
+
} catch {
|
|
11608
|
+
return agentId;
|
|
11609
|
+
}
|
|
11610
|
+
}
|
|
11611
|
+
var refuse = (status, error, extra = {}) => ({
|
|
11612
|
+
status,
|
|
11613
|
+
body: {
|
|
11614
|
+
error,
|
|
11615
|
+
...extra
|
|
11616
|
+
}
|
|
11617
|
+
});
|
|
11618
|
+
function baseChoiceOf(error) {
|
|
11619
|
+
const option = (where) => {
|
|
11620
|
+
const matched = where === "remote" ? error.remote : error.local;
|
|
11621
|
+
const commits = where === "remote" ? error.ranges?.remote : error.ranges?.local;
|
|
11622
|
+
return {
|
|
11623
|
+
base: shorthandOf(matched.ref),
|
|
11624
|
+
display: where === "remote" ? shorthandOf(matched.ref) : shortNameOf(matched.ref),
|
|
11625
|
+
where,
|
|
11626
|
+
...commits ? { commits: commits.length } : {}
|
|
11627
|
+
};
|
|
11628
|
+
};
|
|
11629
|
+
return {
|
|
11630
|
+
name: shortNameOf(error.local.ref),
|
|
11631
|
+
discovered: error.discovered,
|
|
11632
|
+
...error.divergence ? { divergence: error.divergence } : {},
|
|
11633
|
+
options: [option("remote"), option("local")]
|
|
11634
|
+
};
|
|
11635
|
+
}
|
|
11636
|
+
/**
|
|
11637
|
+
* What the panel says for an error a review could not get past.
|
|
11638
|
+
*
|
|
11639
|
+
* Two errors are known by their class and said in the panel's own words. Anything else keeps its
|
|
11640
|
+
* message, which is already written for a person, but only its first paragraph is the sentence:
|
|
11641
|
+
* the rest (ref listings, commands to run) goes behind Technical details, kept whole for whoever
|
|
11642
|
+
* needs to diagnose it.
|
|
11643
|
+
*/
|
|
11644
|
+
function refusalOf(error) {
|
|
11645
|
+
const text = message(error);
|
|
11646
|
+
if (error instanceof AmbiguousRefError) {
|
|
11647
|
+
const choice = baseChoiceOf(error);
|
|
11648
|
+
return {
|
|
11649
|
+
error: `\`${choice.name}\` is both a local and a remote branch, and they are different commits. Choose which one to compare against.`,
|
|
11650
|
+
detail: text,
|
|
11651
|
+
baseChoice: choice
|
|
11652
|
+
};
|
|
11653
|
+
}
|
|
11654
|
+
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." };
|
|
11655
|
+
return briefly(text);
|
|
11656
|
+
}
|
|
11657
|
+
/** A message's first paragraph as the sentence, and the whole of it as the detail when there is more. */
|
|
11658
|
+
function briefly(text) {
|
|
11659
|
+
const first = text.split(/\n\s*\n/)[0].trim();
|
|
11660
|
+
return first && first !== text.trim() ? {
|
|
11661
|
+
error: first,
|
|
11662
|
+
detail: text
|
|
11663
|
+
} : { error: text };
|
|
11664
|
+
}
|
|
11665
|
+
/** A path as a person reads it, with their home folder as `~`. */
|
|
11666
|
+
function displayPath(path, home) {
|
|
11667
|
+
return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
|
|
11668
|
+
}
|
|
11669
|
+
/** A path a person typed, made absolute: `~` is their home folder, and anything relative is refused. */
|
|
11670
|
+
function expandPath(raw, home) {
|
|
11671
|
+
const trimmed = raw.trim().replace(/(.)\/+$/, "$1");
|
|
11672
|
+
if (!trimmed) return null;
|
|
11673
|
+
const expanded = trimmed === "~" ? home : trimmed.startsWith("~/") ? `${home}${trimmed.slice(1)}` : trimmed;
|
|
11674
|
+
return isAbsolute(expanded) ? expanded : null;
|
|
11675
|
+
}
|
|
11676
|
+
function message(error) {
|
|
11677
|
+
return error instanceof Error ? error.message : String(error);
|
|
11678
|
+
}
|
|
11679
|
+
/** Why no agent could be used, in a sentence for the panel. The CLI's own wording names flags. */
|
|
11680
|
+
function unresolvedAgent(resolution) {
|
|
11681
|
+
switch (resolution.kind) {
|
|
11682
|
+
case "none": return "No review agent is installed on this machine. Install one of the agents PR Review Buddy supports, then try again.";
|
|
11683
|
+
case "unknown-id": return `"${resolution.id}" is not an agent PR Review Buddy knows. Choose another under Review options, or change the default in Settings.`;
|
|
11684
|
+
case "unavailable": return `${resolution.agent.displayName} can't be used from the workspace: ${resolution.reason}`;
|
|
11685
|
+
}
|
|
11686
|
+
}
|
|
11687
|
+
/**
|
|
11688
|
+
* The agent a submission runs under: the one named under Review options, or else the same answer
|
|
11689
|
+
* the CLI reaches with no flag (the configured default, or the only one installed). Never the
|
|
11690
|
+
* harness's own fallback: an id is always passed down, so `startJob` never has to guess.
|
|
11691
|
+
*/
|
|
11692
|
+
async function agentFor(flag, deps) {
|
|
11693
|
+
if (flag !== void 0 && typeof flag !== "string") return refuse(400, "Say which agent by its id.", { field: "agent" });
|
|
11694
|
+
const resolution = await deps.resolveAgent(flag || void 0);
|
|
11695
|
+
if (resolution.kind === "resolved") return { agentId: resolution.agent.id };
|
|
11696
|
+
if (resolution.kind === "ambiguous") return refuse(400, "More than one review agent is installed. Choose one under Review options.", { field: "agent" });
|
|
11697
|
+
return refuse(resolution.kind === "unknown-id" ? 400 : 409, unresolvedAgent(resolution), { field: "agent" });
|
|
11698
|
+
}
|
|
11699
|
+
function modelOf(value) {
|
|
11700
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
11701
|
+
if (typeof value !== "string" || value.length > 200) return refuse(400, "The model must be a short name.", { field: "model" });
|
|
11702
|
+
return value.trim() || void 0;
|
|
11703
|
+
}
|
|
11704
|
+
/**
|
|
11705
|
+
* The tail of each target's queue of creations, by batch identity. In-process, like the fetch
|
|
11706
|
+
* queue in `target.ts`.
|
|
11707
|
+
*
|
|
11708
|
+
* `prepareReview` looks for an existing review and then creates one, and nothing between the two
|
|
11709
|
+
* stops a second start of the same target, from another tab or another paste, from looking in the
|
|
11710
|
+
* same gap and creating a second review of it. One at a time per target closes that gap for every
|
|
11711
|
+
* start this server makes; the second one finds the first one's review.
|
|
11712
|
+
*/
|
|
11713
|
+
var creations = /* @__PURE__ */ new Map();
|
|
11714
|
+
function oneCreationAtATime(target, work) {
|
|
11715
|
+
const key = batchIdentity(target);
|
|
11716
|
+
const mine = (creations.get(key) ?? Promise.resolve()).then(work, work);
|
|
11717
|
+
const tail = mine.catch(() => {});
|
|
11718
|
+
creations.set(key, tail);
|
|
11719
|
+
tail.then(() => {
|
|
11720
|
+
if (creations.get(key) === tail) creations.delete(key);
|
|
11721
|
+
});
|
|
11722
|
+
return mine;
|
|
11723
|
+
}
|
|
11724
|
+
var isAnswer = (value) => typeof value === "object" && value !== null && "status" in value && "body" in value;
|
|
11725
|
+
async function addReviewOptions(deps) {
|
|
11726
|
+
const [agents, resolution] = await Promise.all([deps.agents(), deps.resolveAgent()]);
|
|
11727
|
+
return {
|
|
11728
|
+
status: 200,
|
|
11729
|
+
body: {
|
|
11730
|
+
agents,
|
|
11731
|
+
agent: resolution.kind === "resolved" ? {
|
|
11732
|
+
kind: "resolved",
|
|
11733
|
+
id: resolution.agent.id,
|
|
11734
|
+
displayName: resolution.agent.displayName
|
|
11735
|
+
} : resolution.kind === "ambiguous" ? { kind: "ambiguous" } : {
|
|
11736
|
+
kind: "refused",
|
|
11737
|
+
error: unresolvedAgent(resolution)
|
|
11738
|
+
},
|
|
11739
|
+
canChooseFolder: deps.chooseFolder !== null
|
|
11740
|
+
}
|
|
11741
|
+
};
|
|
11742
|
+
}
|
|
11743
|
+
function reviewedRow(reviewId, deps) {
|
|
11744
|
+
const kept = deps.resumeOf(reviewId);
|
|
11745
|
+
return {
|
|
11746
|
+
reviewId,
|
|
11747
|
+
...kept ? { kept } : {}
|
|
11748
|
+
};
|
|
11749
|
+
}
|
|
11750
|
+
function folderOption(folder, home) {
|
|
11751
|
+
return {
|
|
11752
|
+
path: folder.path,
|
|
11753
|
+
display: displayPath(folder.path, home),
|
|
11754
|
+
branch: folder.branch
|
|
11755
|
+
};
|
|
11756
|
+
}
|
|
11757
|
+
/**
|
|
11758
|
+
* What a paste would start, without starting anything: rows, the folders each could run in, and
|
|
11759
|
+
* which already have a review. No forge is asked and nothing is fetched, so it is cheap enough to
|
|
11760
|
+
* run on every pause in typing.
|
|
11761
|
+
*
|
|
11762
|
+
* Every link gets a row, however many there are. Past the batch limit the panel refuses to start
|
|
11763
|
+
* rather than this quietly dropping the tail: a reviewer who pasted twenty-one pull requests and
|
|
11764
|
+
* got twenty reviews would not find out which one was missing.
|
|
11765
|
+
*/
|
|
11766
|
+
async function previewPullRequests(text, deps) {
|
|
11767
|
+
if (typeof text !== "string") return refuse(400, "Paste the pull request links to review.");
|
|
11768
|
+
const links = extractPullRequestLinks(text);
|
|
11769
|
+
const [folders, summaries] = links.some((link) => link.kind === "github") ? [await deps.folders(), deps.summaries()] : [[], []];
|
|
11770
|
+
return {
|
|
11771
|
+
status: 200,
|
|
11772
|
+
body: {
|
|
11773
|
+
rows: links.map((link) => {
|
|
11774
|
+
if (link.kind === "unsupported") return {
|
|
11775
|
+
key: link.key,
|
|
11776
|
+
label: `${link.provider} ${link.provider === "GitLab" ? "merge request" : "pull request"}`,
|
|
11777
|
+
url: link.url,
|
|
11778
|
+
ok: false,
|
|
11779
|
+
reason: unsupportedReason(link.provider),
|
|
11780
|
+
folders: []
|
|
11781
|
+
};
|
|
11782
|
+
const label = `${link.owner}/${link.repo} #${link.number}`;
|
|
11783
|
+
const candidates = candidatesFor(folders, link, summaries);
|
|
11784
|
+
if (candidates.length === 0) return {
|
|
11785
|
+
key: link.key,
|
|
11786
|
+
label,
|
|
11787
|
+
url: link.url,
|
|
11788
|
+
ok: false,
|
|
11789
|
+
reason: "Local repository not found",
|
|
11790
|
+
needsFolder: true,
|
|
11791
|
+
folders: []
|
|
11792
|
+
};
|
|
11793
|
+
const reviewed = reviewedIn(candidates, link, summaries);
|
|
11794
|
+
return {
|
|
11795
|
+
key: link.key,
|
|
11796
|
+
label,
|
|
11797
|
+
url: link.url,
|
|
11798
|
+
ok: true,
|
|
11799
|
+
folders: candidates.map((folder) => folderOption(folder, deps.home)),
|
|
11800
|
+
folder: candidates[0].path,
|
|
11801
|
+
...reviewed ? { reviewed: reviewedRow(reviewed.id, deps) } : {}
|
|
11802
|
+
};
|
|
11803
|
+
}),
|
|
11804
|
+
limit: MAX_BATCH
|
|
11805
|
+
}
|
|
11806
|
+
};
|
|
11347
11807
|
}
|
|
11348
|
-
var
|
|
11349
|
-
|
|
11350
|
-
|
|
11351
|
-
|
|
11352
|
-
|
|
11353
|
-
|
|
11354
|
-
|
|
11355
|
-
|
|
11356
|
-
|
|
11357
|
-
|
|
11358
|
-
|
|
11359
|
-
|
|
11808
|
+
var batches = /* @__PURE__ */ new Map();
|
|
11809
|
+
var facts = /* @__PURE__ */ new WeakMap();
|
|
11810
|
+
var submitted = 0;
|
|
11811
|
+
function noteRow(row, known) {
|
|
11812
|
+
submitted += 1;
|
|
11813
|
+
facts.set(row, {
|
|
11814
|
+
order: submitted,
|
|
11815
|
+
...known
|
|
11816
|
+
});
|
|
11817
|
+
}
|
|
11818
|
+
/** The row has its slot and is under way. When it took it is kept from the first time only. */
|
|
11819
|
+
function begin(row) {
|
|
11820
|
+
const known = facts.get(row);
|
|
11821
|
+
if (known && row.status !== "reviewing") known.takenAt = Date.now();
|
|
11822
|
+
row.status = "reviewing";
|
|
11823
|
+
}
|
|
11824
|
+
/**
|
|
11825
|
+
* Every row still to start or under way, for the Reviews page's In progress section. Rows that
|
|
11826
|
+
* ended (ready, failed, already reviewed) are not here: what they made is in the list, and how they
|
|
11827
|
+
* failed is in the panel and on the review.
|
|
11828
|
+
*
|
|
11829
|
+
* This server's rows only, from memory: see `liveOperations` for why the queue is process-local.
|
|
11830
|
+
*/
|
|
11831
|
+
function pendingBatchRows() {
|
|
11832
|
+
const pending = [];
|
|
11833
|
+
for (const batch of batches.values()) batch.rows.forEach((row, index) => {
|
|
11834
|
+
if (row.status !== "waiting" && row.status !== "reviewing") return;
|
|
11835
|
+
const known = facts.get(row);
|
|
11836
|
+
pending.push({
|
|
11837
|
+
key: `${batch.id}:${index}`,
|
|
11838
|
+
status: row.status,
|
|
11839
|
+
label: row.label,
|
|
11840
|
+
order: known?.order ?? 0,
|
|
11841
|
+
...known?.takenAt === void 0 ? {} : { takenAt: known.takenAt },
|
|
11842
|
+
...row.reviewId ? { reviewId: row.reviewId } : {},
|
|
11843
|
+
...known?.repoPath ? { repoPath: known.repoPath } : {},
|
|
11844
|
+
...known?.prNumber === void 0 ? {} : { prNumber: known.prNumber },
|
|
11845
|
+
...known?.branch ? { branch: known.branch } : {}
|
|
11360
11846
|
});
|
|
11361
|
-
|
|
11847
|
+
});
|
|
11848
|
+
return pending;
|
|
11849
|
+
}
|
|
11850
|
+
function forgetFinished(now) {
|
|
11851
|
+
for (const [id, batch] of batches) if (batch.finishedAt !== null && now - Math.max(batch.finishedAt, batch.seenAt) > FINISHED_BATCH_TTL_MS) batches.delete(id);
|
|
11852
|
+
}
|
|
11853
|
+
function newBatch(rows) {
|
|
11854
|
+
forgetFinished(Date.now());
|
|
11855
|
+
const now = Date.now();
|
|
11856
|
+
const batch = {
|
|
11857
|
+
id: randomUUID(),
|
|
11858
|
+
rows,
|
|
11859
|
+
finishedAt: now,
|
|
11860
|
+
seenAt: now,
|
|
11861
|
+
running: 0,
|
|
11862
|
+
rerun: () => {}
|
|
11863
|
+
};
|
|
11864
|
+
batches.set(batch.id, batch);
|
|
11865
|
+
return batch;
|
|
11866
|
+
}
|
|
11867
|
+
/**
|
|
11868
|
+
* Count work against a batch, inside the restart guard. The batch is finished only when nothing
|
|
11869
|
+
* counted against it is still going, so a retry reopens a batch an open panel can then follow again.
|
|
11870
|
+
*/
|
|
11871
|
+
function follow(batch, work, deps, driving = true) {
|
|
11872
|
+
batch.running += 1;
|
|
11873
|
+
batch.finishedAt = null;
|
|
11874
|
+
(driving ? deps.track(work) : work).catch(() => {}).finally(() => {
|
|
11875
|
+
batch.running -= 1;
|
|
11876
|
+
if (batch.running === 0) batch.finishedAt = Date.now();
|
|
11877
|
+
});
|
|
11878
|
+
}
|
|
11879
|
+
function fail(row, error) {
|
|
11880
|
+
const refusal = refusalOf(error);
|
|
11881
|
+
row.status = "failed";
|
|
11882
|
+
row.reason = refusal.error;
|
|
11883
|
+
if (refusal.detail) row.detail = refusal.detail;
|
|
11884
|
+
}
|
|
11885
|
+
var rowsOf = (batch) => batch.rows.map((row) => ({ ...row }));
|
|
11886
|
+
/**
|
|
11887
|
+
* Create the review for a row's target, or find the one it has, under the per-target lock. The
|
|
11888
|
+
* caller holds an analysis slot: nothing is created without one, so nothing created waits.
|
|
11889
|
+
*/
|
|
11890
|
+
/**
|
|
11891
|
+
* `fresh` is a fresh start's: a new review of the target as it is now, keeping whatever review the
|
|
11892
|
+
* branch or request already has, as `--fresh` does. Without it the lookup finds the latest review of
|
|
11893
|
+
* the same branch or request, which for a fresh start is the very failure being started over.
|
|
11894
|
+
*/
|
|
11895
|
+
async function prepareRow(row, target, agentId, model, deps, fresh = false) {
|
|
11896
|
+
const prepared = await oneCreationAtATime(target, () => deps.prepare(target, agentId, fresh, model));
|
|
11897
|
+
row.reviewId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
|
|
11898
|
+
if (prepared.kind === "existing" && !prepared.existing.inProgress) row.status = "already-reviewed";
|
|
11899
|
+
else begin(row);
|
|
11900
|
+
if (prepared.kind === "resume") row.kept = keptOf(prepared.failure.agentId, prepared.failure.requestedModel, deps);
|
|
11901
|
+
else delete row.kept;
|
|
11902
|
+
return prepared;
|
|
11903
|
+
}
|
|
11904
|
+
/**
|
|
11905
|
+
* Take a prepared row to its end. A new or resumed review is run here, holding the slot. One that
|
|
11906
|
+
* something else is already analysing is watched instead, never driven: the watch is handed to the
|
|
11907
|
+
* batch rather than awaited, so the caller returns, and its slot goes back, at once, while the row
|
|
11908
|
+
* stays Analysing until that analysis ends. Counted against the batch, not against restarts.
|
|
11909
|
+
*/
|
|
11910
|
+
async function completeRow(row, prepared, batch, deps) {
|
|
11911
|
+
if (prepared.kind !== "existing") return settleRow(row, deps.settle(row.reviewId));
|
|
11912
|
+
if (prepared.existing.inProgress) follow(batch, settleRow(row, deps.observe(row.reviewId)), deps, false);
|
|
11913
|
+
}
|
|
11914
|
+
async function settleRow(row, settling) {
|
|
11915
|
+
const settled = await settling;
|
|
11916
|
+
if (settled.ok) row.status = "ready";
|
|
11917
|
+
else if (settled.stopped) {
|
|
11918
|
+
row.status = "failed";
|
|
11919
|
+
row.reason = "Stopped";
|
|
11920
|
+
} else fail(row, settled.reason);
|
|
11921
|
+
}
|
|
11922
|
+
/**
|
|
11923
|
+
* A retry runs against the commit the row was first resolved to, never a newer one: a retry
|
|
11924
|
+
* resumes what was asked for, and a moved branch or pull request is a fresh review's business. When
|
|
11925
|
+
* that commit has gone (a force push, then a prune), there is nothing left to retry, and the row
|
|
11926
|
+
* says so and offers the fresh start instead of quietly reviewing something else.
|
|
11927
|
+
*/
|
|
11928
|
+
async function stillThere(row, target, what, deps) {
|
|
11929
|
+
if (await deps.commitPresent(target.originRepoPath, target.sha)) return true;
|
|
11930
|
+
row.status = "failed";
|
|
11931
|
+
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.`;
|
|
11932
|
+
row.stale = true;
|
|
11933
|
+
return false;
|
|
11934
|
+
}
|
|
11935
|
+
/**
|
|
11936
|
+
* Retry a row whose review exists, by that review's id. Not through the lookup a start uses, which
|
|
11937
|
+
* finds the latest review of the branch or request: one made since, of newer commits, would be
|
|
11938
|
+
* handed back in place of the one that failed, and a retry would quietly become a different review.
|
|
11939
|
+
*
|
|
11940
|
+
* Resumed when it is still a failure nothing is running, as its own page's Retry does. Otherwise
|
|
11941
|
+
* something has taken it up since (that Retry, a terminal), and it is watched rather than run again.
|
|
11942
|
+
*/
|
|
11943
|
+
async function retryReview(row, batch, deps) {
|
|
11944
|
+
const reviewId = row.reviewId;
|
|
11945
|
+
const kept = deps.resumeOf(reviewId);
|
|
11946
|
+
begin(row);
|
|
11947
|
+
if (kept) {
|
|
11948
|
+
row.kept = kept;
|
|
11949
|
+
return settleRow(row, deps.settle(reviewId));
|
|
11362
11950
|
}
|
|
11363
|
-
|
|
11951
|
+
follow(batch, settleRow(row, deps.observe(reviewId)), deps, false);
|
|
11364
11952
|
}
|
|
11365
11953
|
/**
|
|
11366
|
-
*
|
|
11367
|
-
*
|
|
11368
|
-
*
|
|
11369
|
-
* use it, and nothing is proposed.
|
|
11954
|
+
* A fresh start that ended before it made its review leaves the row exactly as it was: the old
|
|
11955
|
+
* target, the old review, and still `stale`. So the row keeps offering the fresh start, and a plain
|
|
11956
|
+
* Retry finds the old commit gone again, rather than resuming the old review against it.
|
|
11370
11957
|
*/
|
|
11371
|
-
function
|
|
11372
|
-
|
|
11373
|
-
|
|
11374
|
-
|
|
11375
|
-
|
|
11376
|
-
|
|
11958
|
+
function keepFreshPending(row, replaced) {
|
|
11959
|
+
if (row.status === "failed" && !replaced) row.stale = true;
|
|
11960
|
+
}
|
|
11961
|
+
function keptOf(agentId, model, deps) {
|
|
11962
|
+
return {
|
|
11963
|
+
agent: deps.agentName(agentId),
|
|
11964
|
+
...model ? { model } : {}
|
|
11965
|
+
};
|
|
11966
|
+
}
|
|
11967
|
+
/**
|
|
11968
|
+
* Start a batch and answer at once. The analyses take minutes, and the panel is not a progress
|
|
11969
|
+
* dialog: it may watch the batch while it stays open, and the reviews appear in the list either way.
|
|
11970
|
+
*
|
|
11971
|
+
* Each pull request waits for one of the server's analysis slots (`deps.slots`), shared with every
|
|
11972
|
+
* other batch and every branch review, and is resolved and created only once it has one.
|
|
11973
|
+
*/
|
|
11974
|
+
async function startPullRequestBatch(body, deps) {
|
|
11975
|
+
const raw = Array.isArray(body?.items) ? body.items : null;
|
|
11976
|
+
if (!raw || raw.length === 0) return refuse(400, "Say which pull requests to review.");
|
|
11977
|
+
if (raw.length > MAX_BATCH) return refuse(400, `At most ${MAX_BATCH} pull requests can be started at once.`);
|
|
11978
|
+
const items = [];
|
|
11979
|
+
for (const entry of raw) {
|
|
11980
|
+
const url = typeof entry?.url === "string" ? entry.url : "";
|
|
11981
|
+
const link = parsePullRequestLink(url);
|
|
11982
|
+
if (!link || link.kind !== "github") return refuse(400, `${url || "That"} is not a github.com pull request link.`);
|
|
11983
|
+
const folder = typeof entry?.folder === "string" ? expandPath(entry.folder, deps.home) : null;
|
|
11984
|
+
if (!folder) return refuse(400, `Choose a local folder for ${link.owner}/${link.repo} #${link.number}.`);
|
|
11985
|
+
items.push({
|
|
11986
|
+
url: link.url,
|
|
11987
|
+
owner: link.owner,
|
|
11988
|
+
repo: link.repo,
|
|
11989
|
+
number: link.number,
|
|
11990
|
+
key: link.key,
|
|
11991
|
+
folder
|
|
11992
|
+
});
|
|
11377
11993
|
}
|
|
11378
|
-
|
|
11379
|
-
|
|
11380
|
-
const
|
|
11381
|
-
if (
|
|
11382
|
-
const
|
|
11383
|
-
|
|
11384
|
-
|
|
11385
|
-
|
|
11386
|
-
|
|
11994
|
+
const model = modelOf(body?.model);
|
|
11995
|
+
if (isAnswer(model)) return model;
|
|
11996
|
+
const agent = await agentFor(body?.agent, deps);
|
|
11997
|
+
if (isAnswer(agent)) return agent;
|
|
11998
|
+
const batch = newBatch(items.map((item) => ({
|
|
11999
|
+
key: item.key,
|
|
12000
|
+
label: `${item.owner}/${item.repo} #${item.number}`,
|
|
12001
|
+
status: "waiting"
|
|
12002
|
+
})));
|
|
12003
|
+
batch.rows.forEach((row, index) => noteRow(row, {
|
|
12004
|
+
repoPath: items[index].folder,
|
|
12005
|
+
prNumber: items[index].number
|
|
12006
|
+
}));
|
|
12007
|
+
/** Which row claimed each pull request, by what it resolved to rather than how it was spelled. */
|
|
12008
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
12009
|
+
/** Each row's pull request as it was first resolved: head, base and all. What a retry runs against. */
|
|
12010
|
+
const resolved = [];
|
|
12011
|
+
const lane = async (item, index, fresh = false) => {
|
|
12012
|
+
const row = batch.rows[index];
|
|
12013
|
+
const release = await deps.slots.acquire();
|
|
12014
|
+
begin(row);
|
|
12015
|
+
/** A fresh start has made its review, so the row now belongs to the new target. */
|
|
12016
|
+
let replaced = false;
|
|
12017
|
+
try {
|
|
12018
|
+
let target = fresh ? void 0 : resolved[index];
|
|
12019
|
+
if (target) {
|
|
12020
|
+
if (!await stillThere(row, target, "pull request", deps)) return;
|
|
12021
|
+
if (row.reviewId) return await retryReview(row, batch, deps);
|
|
12022
|
+
} else {
|
|
12023
|
+
const remote = await deps.remoteOf(item.folder);
|
|
12024
|
+
if (!isGitHubRepository(remote, item.owner, item.repo)) {
|
|
12025
|
+
row.status = "failed";
|
|
12026
|
+
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}`;
|
|
12027
|
+
return;
|
|
12028
|
+
}
|
|
12029
|
+
target = await deps.resolvePullRequest(item.folder, item.url);
|
|
12030
|
+
if (!fresh) resolved[index] = target;
|
|
12031
|
+
}
|
|
12032
|
+
const identity = batchIdentity(target);
|
|
12033
|
+
const first = claimed.get(identity);
|
|
12034
|
+
if (first !== void 0 && first !== index) {
|
|
12035
|
+
row.status = "already-reviewed";
|
|
12036
|
+
row.reason = `Same pull request as ${batch.rows[first].label}`;
|
|
12037
|
+
return;
|
|
12038
|
+
}
|
|
12039
|
+
claimed.set(identity, index);
|
|
12040
|
+
const prepared = await prepareRow(row, target, agent.agentId, model, deps, fresh);
|
|
12041
|
+
if (fresh) {
|
|
12042
|
+
resolved[index] = target;
|
|
12043
|
+
replaced = true;
|
|
12044
|
+
}
|
|
12045
|
+
await completeRow(row, prepared, batch, deps);
|
|
12046
|
+
} catch (error) {
|
|
12047
|
+
fail(row, error);
|
|
12048
|
+
} finally {
|
|
12049
|
+
if (fresh) keepFreshPending(row, replaced);
|
|
12050
|
+
release();
|
|
12051
|
+
}
|
|
11387
12052
|
};
|
|
11388
|
-
|
|
11389
|
-
|
|
12053
|
+
batch.rerun = (index, fresh) => follow(batch, lane(items[index], index, fresh), deps);
|
|
12054
|
+
follow(batch, runBatch(items, {
|
|
12055
|
+
concurrency: deps.concurrency,
|
|
12056
|
+
run: lane
|
|
12057
|
+
}), deps);
|
|
11390
12058
|
return {
|
|
11391
|
-
|
|
11392
|
-
|
|
11393
|
-
|
|
11394
|
-
|
|
12059
|
+
status: 200,
|
|
12060
|
+
body: {
|
|
12061
|
+
batchId: batch.id,
|
|
12062
|
+
rows: rowsOf(batch)
|
|
12063
|
+
}
|
|
11395
12064
|
};
|
|
11396
12065
|
}
|
|
11397
|
-
//#endregion
|
|
11398
|
-
//#region ../../packages/review-harness/src/workspace/reassess_turn.ts
|
|
11399
12066
|
/**
|
|
11400
|
-
*
|
|
11401
|
-
* is
|
|
11402
|
-
* misremembered where it read it, and a citation that leads nowhere is worse than none. If the check
|
|
11403
|
-
* itself fails, no evidence is kept rather than unchecked evidence.
|
|
11404
|
-
*
|
|
11405
|
-
* Kept, but not disguised: how many were dropped is recorded, so the card says the suggestion has
|
|
11406
|
-
* no verified code evidence rather than drawing it like one whose citations held. Acceptance does
|
|
11407
|
-
* not depend on it; a reason may be sound without a citation.
|
|
12067
|
+
* A batch's rows as they stand. Asking counts as watching: a batch an open panel is still asking
|
|
12068
|
+
* about is never forgotten, so its Retry stays good for as long as the panel is open.
|
|
11408
12069
|
*/
|
|
11409
|
-
|
|
11410
|
-
const
|
|
11411
|
-
if (!
|
|
11412
|
-
|
|
11413
|
-
raw,
|
|
11414
|
-
droppedEvidence: 0
|
|
11415
|
-
};
|
|
11416
|
-
let real;
|
|
11417
|
-
try {
|
|
11418
|
-
real = await check(where.repoPath, [...new Set(answer.evidence.map((place) => place.file))], where.commitSha);
|
|
11419
|
-
} catch {
|
|
11420
|
-
real = /* @__PURE__ */ new Set();
|
|
11421
|
-
}
|
|
11422
|
-
const evidence = answer.evidence.filter((place) => real.has(place.file));
|
|
12070
|
+
function batchRows(id) {
|
|
12071
|
+
const batch = batches.get(id);
|
|
12072
|
+
if (!batch) return lostBatch();
|
|
12073
|
+
batch.seenAt = Date.now();
|
|
11423
12074
|
return {
|
|
11424
|
-
|
|
11425
|
-
|
|
11426
|
-
|
|
11427
|
-
|
|
11428
|
-
|
|
11429
|
-
droppedEvidence: answer.evidence.length - evidence.length
|
|
12075
|
+
status: 200,
|
|
12076
|
+
body: {
|
|
12077
|
+
rows: rowsOf(batch),
|
|
12078
|
+
finished: batch.finishedAt !== null
|
|
12079
|
+
}
|
|
11430
12080
|
};
|
|
11431
12081
|
}
|
|
11432
12082
|
/**
|
|
11433
|
-
*
|
|
11434
|
-
*
|
|
11435
|
-
*
|
|
11436
|
-
* grade it never saw.
|
|
12083
|
+
* Said without guessing why. A restart forgets every batch, and a batch nobody has asked about for
|
|
12084
|
+
* a while is forgotten too; the server cannot tell which happened, so it says what follows from
|
|
12085
|
+
* either.
|
|
11437
12086
|
*/
|
|
11438
|
-
|
|
11439
|
-
|
|
11440
|
-
|
|
11441
|
-
|
|
11442
|
-
|
|
11443
|
-
|
|
11444
|
-
|
|
11445
|
-
|
|
11446
|
-
|
|
11447
|
-
|
|
11448
|
-
|
|
11449
|
-
|
|
11450
|
-
|
|
11451
|
-
|
|
11452
|
-
|
|
12087
|
+
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.");
|
|
12088
|
+
/**
|
|
12089
|
+
* Run one failed row of a batch again, as it was first asked for: the same folder, agent, model
|
|
12090
|
+
* and base, and the same commits, never the branch or pull request as it has since moved on. A
|
|
12091
|
+
* review that was created and then failed resumes, as a Retry does on its own page. `fresh` is the
|
|
12092
|
+
* one exception, sent only when those commits have gone: it looks again at what is there now.
|
|
12093
|
+
* The other rows are left exactly as they are, and the batch is followed again until it ends.
|
|
12094
|
+
*/
|
|
12095
|
+
function retryBatchRow(id, key, fresh = false) {
|
|
12096
|
+
const batch = batches.get(id);
|
|
12097
|
+
if (!batch) return lostBatch();
|
|
12098
|
+
const index = batch.rows.findIndex((row) => row.key === key);
|
|
12099
|
+
const row = batch.rows[index];
|
|
12100
|
+
if (!row) return refuse(404, "That review is not part of this batch.");
|
|
12101
|
+
if (row.status !== "failed") return refuse(409, "Only a review that failed can be tried again.");
|
|
12102
|
+
row.status = "waiting";
|
|
12103
|
+
const known = facts.get(row);
|
|
12104
|
+
if (known) delete known.takenAt;
|
|
12105
|
+
delete row.reason;
|
|
12106
|
+
delete row.detail;
|
|
12107
|
+
delete row.stale;
|
|
12108
|
+
batch.seenAt = Date.now();
|
|
12109
|
+
batch.rerun(index, fresh === true);
|
|
12110
|
+
return {
|
|
12111
|
+
status: 200,
|
|
12112
|
+
body: {
|
|
12113
|
+
rows: rowsOf(batch),
|
|
12114
|
+
finished: false
|
|
11453
12115
|
}
|
|
11454
|
-
}
|
|
12116
|
+
};
|
|
11455
12117
|
}
|
|
11456
|
-
/**
|
|
11457
|
-
function
|
|
11458
|
-
|
|
11459
|
-
|
|
11460
|
-
|
|
11461
|
-
|
|
11462
|
-
|
|
11463
|
-
|
|
12118
|
+
/** The recent and discoverable folders, for the Branch panel's list. */
|
|
12119
|
+
async function listFolders(deps) {
|
|
12120
|
+
return {
|
|
12121
|
+
status: 200,
|
|
12122
|
+
body: { folders: (await deps.folders()).map((folder) => ({
|
|
12123
|
+
...folderOption(folder, deps.home),
|
|
12124
|
+
repo: folder.remote ? folder.remote.repo : basename(folder.path)
|
|
12125
|
+
})) }
|
|
12126
|
+
};
|
|
11464
12127
|
}
|
|
11465
|
-
//#endregion
|
|
11466
|
-
//#region ../../packages/review-harness/src/workspace/working_folders.ts
|
|
11467
12128
|
/**
|
|
11468
|
-
*
|
|
11469
|
-
*
|
|
11470
|
-
* One question and nothing more: which folders on this machine could a review run in. The answer is
|
|
11471
|
-
* derived each time from what is already known, the folders past reviews ran in and the other
|
|
11472
|
-
* worktrees of those repositories, and nothing is stored. A worktree an agent made yesterday shows
|
|
11473
|
-
* up because `git worktree list` names it, not because anything remembered it.
|
|
11474
|
-
*
|
|
11475
|
-
* A folder is a folder. A linked worktree is its own entry with its own path and is never folded
|
|
11476
|
-
* into the clone it belongs to: a review is filed under the folder it ran in, and the worktree is
|
|
11477
|
-
* where the branch is checked out.
|
|
12129
|
+
* What reviewing this folder would review, read without changing anything: the same target a bare
|
|
12130
|
+
* `prreviewbuddy` here would resolve, and the review it would find.
|
|
11478
12131
|
*
|
|
11479
|
-
*
|
|
12132
|
+
* With the base the reviewer named, when they named one, so what this checks is the comparison
|
|
12133
|
+
* that would be submitted. Without it, a repository whose default base is ambiguous could never be
|
|
12134
|
+
* reviewed from here, though naming the base is exactly what settles it.
|
|
11480
12135
|
*/
|
|
11481
|
-
function
|
|
11482
|
-
|
|
11483
|
-
|
|
11484
|
-
|
|
11485
|
-
|
|
11486
|
-
|
|
11487
|
-
|
|
11488
|
-
|
|
11489
|
-
|
|
11490
|
-
|
|
12136
|
+
async function inspectFolder(rawPath, deps, rawBase) {
|
|
12137
|
+
const path = typeof rawPath === "string" ? expandPath(rawPath, deps.home) : null;
|
|
12138
|
+
if (!path) return {
|
|
12139
|
+
status: 200,
|
|
12140
|
+
body: {
|
|
12141
|
+
ok: false,
|
|
12142
|
+
error: "Enter the full path of a folder, or choose one."
|
|
12143
|
+
}
|
|
12144
|
+
};
|
|
12145
|
+
const named = typeof rawBase === "string" && rawBase.trim() ? rawBase.trim() : void 0;
|
|
12146
|
+
let target;
|
|
11491
12147
|
try {
|
|
11492
|
-
|
|
11493
|
-
|
|
11494
|
-
|
|
11495
|
-
|
|
11496
|
-
|
|
11497
|
-
|
|
11498
|
-
|
|
11499
|
-
|
|
11500
|
-
|
|
11501
|
-
for (const block of output.split(/\n\s*\n/)) {
|
|
11502
|
-
const lines = block.split("\n");
|
|
11503
|
-
const path = lines.find((line) => line.startsWith("worktree "))?.slice(9);
|
|
11504
|
-
if (!path || lines.includes("bare") || lines.some((line) => line.startsWith("prunable"))) continue;
|
|
11505
|
-
const ref = lines.find((line) => line.startsWith("branch "))?.slice(7) ?? null;
|
|
11506
|
-
entries.push({
|
|
11507
|
-
path,
|
|
11508
|
-
branch: ref ? ref.replace(/^refs\/heads\//, "") : null
|
|
11509
|
-
});
|
|
11510
|
-
}
|
|
11511
|
-
return entries;
|
|
11512
|
-
}
|
|
11513
|
-
async function discoverWorkingFolders(summaries = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
|
|
11514
|
-
const lastReviewed = /* @__PURE__ */ new Map();
|
|
11515
|
-
for (const summary of summaries) {
|
|
11516
|
-
if (!summary.repoPath) continue;
|
|
11517
|
-
const key = canonical(summary.repoPath);
|
|
11518
|
-
lastReviewed.set(key, Math.max(lastReviewed.get(key) ?? 0, summary.createdAt));
|
|
11519
|
-
}
|
|
11520
|
-
const found = /* @__PURE__ */ new Map();
|
|
11521
|
-
for (const seed of lastReviewed.keys()) {
|
|
11522
|
-
if (found.has(seed) || !existsSync(seed)) continue;
|
|
11523
|
-
(await worktreesOf(seed)).forEach((entry, index) => {
|
|
11524
|
-
const key = canonical(entry.path);
|
|
11525
|
-
if (!found.has(key) && existsSync(key)) found.set(key, {
|
|
11526
|
-
entry: {
|
|
11527
|
-
...entry,
|
|
11528
|
-
path: key
|
|
11529
|
-
},
|
|
11530
|
-
main: index === 0
|
|
11531
|
-
});
|
|
11532
|
-
});
|
|
12148
|
+
target = await deps.resolveBranch(path, named);
|
|
12149
|
+
} catch (error) {
|
|
12150
|
+
return {
|
|
12151
|
+
status: 200,
|
|
12152
|
+
body: {
|
|
12153
|
+
ok: false,
|
|
12154
|
+
...refusalOf(error)
|
|
12155
|
+
}
|
|
12156
|
+
};
|
|
11533
12157
|
}
|
|
11534
|
-
|
|
11535
|
-
|
|
11536
|
-
|
|
11537
|
-
|
|
11538
|
-
|
|
11539
|
-
|
|
11540
|
-
|
|
11541
|
-
|
|
11542
|
-
|
|
11543
|
-
|
|
11544
|
-
|
|
11545
|
-
|
|
11546
|
-
|
|
11547
|
-
|
|
11548
|
-
|
|
11549
|
-
|
|
11550
|
-
|
|
11551
|
-
|
|
11552
|
-
|
|
11553
|
-
|
|
11554
|
-
|
|
11555
|
-
|
|
11556
|
-
const reviewedHere = new Set(summaries.filter((summary) => summary.prNumber === link.number).map((summary) => canonical(summary.repoPath)));
|
|
11557
|
-
const matching = folders.filter((folder) => isGitHubRepository(folder.remote, link.owner, link.repo));
|
|
11558
|
-
const rank = (folder) => reviewedHere.has(folder.path) ? 0 : folder.main ? 1 : 2;
|
|
11559
|
-
return [...matching].sort((a, b) => rank(a) - rank(b) || (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0));
|
|
11560
|
-
}
|
|
11561
|
-
/** The review of this pull request already filed under one of these folders, if there is one. */
|
|
11562
|
-
function reviewedIn(folders, link, summaries) {
|
|
11563
|
-
const paths = new Set(folders.map((folder) => folder.path));
|
|
11564
|
-
return summaries.find((summary) => summary.prNumber === link.number && paths.has(canonical(summary.repoPath))) ?? null;
|
|
12158
|
+
const [remote, base, existing] = await Promise.all([
|
|
12159
|
+
deps.remoteOf(target.originRepoPath),
|
|
12160
|
+
named ? Promise.resolve(named) : deps.defaultBase(target.originRepoPath),
|
|
12161
|
+
deps.findExisting(target)
|
|
12162
|
+
]);
|
|
12163
|
+
const kept = existing ? deps.resumeOf(existing.id) : null;
|
|
12164
|
+
return {
|
|
12165
|
+
status: 200,
|
|
12166
|
+
body: {
|
|
12167
|
+
ok: true,
|
|
12168
|
+
path: target.originRepoPath,
|
|
12169
|
+
display: displayPath(target.originRepoPath, deps.home),
|
|
12170
|
+
repo: remote ? remote.repo : basename(target.originRepoPath),
|
|
12171
|
+
branch: target.branch,
|
|
12172
|
+
base,
|
|
12173
|
+
...existing ? { reviewed: {
|
|
12174
|
+
reviewId: existing.id,
|
|
12175
|
+
inProgress: existing.inProgress,
|
|
12176
|
+
...kept ? { kept } : {}
|
|
12177
|
+
} } : {}
|
|
12178
|
+
}
|
|
12179
|
+
};
|
|
11565
12180
|
}
|
|
11566
|
-
//#endregion
|
|
11567
|
-
//#region ../../packages/review-harness/src/workspace/choose_folder.ts
|
|
11568
12181
|
/**
|
|
11569
|
-
*
|
|
12182
|
+
* Review the branch checked out in a folder: `prreviewbuddy` run there.
|
|
11570
12183
|
*
|
|
11571
|
-
*
|
|
11572
|
-
*
|
|
11573
|
-
*
|
|
12184
|
+
* Always answers with a one-row batch, which is what the panel follows, so a branch shows its
|
|
12185
|
+
* progress exactly as a pull request does. With a free analysis slot it answers once the review
|
|
12186
|
+
* exists, and the analysis runs on in this process, counted by `track` like a Retry. With none free
|
|
12187
|
+
* it answers `waiting` and creates nothing until a slot frees: a review created to wait is a job
|
|
12188
|
+
* nobody is running, which the sweep would fail. A review the branch already has needs no slot.
|
|
11574
12189
|
*
|
|
11575
|
-
*
|
|
11576
|
-
*
|
|
12190
|
+
* A refusal before anything exists comes back as a refusal, with the base question when that is
|
|
12191
|
+
* what it was, so the panel can ask it beside the folder rather than as a failed row.
|
|
11577
12192
|
*/
|
|
11578
|
-
|
|
11579
|
-
|
|
11580
|
-
|
|
11581
|
-
|
|
11582
|
-
|
|
11583
|
-
|
|
11584
|
-
|
|
11585
|
-
|
|
11586
|
-
|
|
11587
|
-
})
|
|
11588
|
-
|
|
11589
|
-
|
|
11590
|
-
|
|
11591
|
-
const waiting = [];
|
|
11592
|
-
const releaser = () => {
|
|
11593
|
-
let given = false;
|
|
11594
|
-
return () => {
|
|
11595
|
-
if (given) return;
|
|
11596
|
-
given = true;
|
|
11597
|
-
const next = waiting.shift();
|
|
11598
|
-
if (next) next(releaser());
|
|
11599
|
-
else free += 1;
|
|
12193
|
+
async function startBranchReview(body, deps) {
|
|
12194
|
+
const path = typeof body?.path === "string" ? expandPath(body.path, deps.home) : null;
|
|
12195
|
+
if (!path) return refuse(400, "Choose the working folder to review.");
|
|
12196
|
+
const base = typeof body?.base === "string" && body.base.trim() ? body.base.trim() : void 0;
|
|
12197
|
+
const model = modelOf(body?.model);
|
|
12198
|
+
if (isAnswer(model)) return model;
|
|
12199
|
+
let target;
|
|
12200
|
+
try {
|
|
12201
|
+
target = await deps.resolveBranch(path, base);
|
|
12202
|
+
} catch (error) {
|
|
12203
|
+
return {
|
|
12204
|
+
status: 400,
|
|
12205
|
+
body: refusalOf(error)
|
|
11600
12206
|
};
|
|
12207
|
+
}
|
|
12208
|
+
const agent = await agentFor(body?.agent, deps);
|
|
12209
|
+
if (isAnswer(agent)) return agent;
|
|
12210
|
+
const row = {
|
|
12211
|
+
key: batchIdentity(target),
|
|
12212
|
+
label: target.branch,
|
|
12213
|
+
status: "waiting"
|
|
11601
12214
|
};
|
|
11602
|
-
|
|
11603
|
-
|
|
11604
|
-
|
|
11605
|
-
|
|
11606
|
-
|
|
12215
|
+
const batch = newBatch([row]);
|
|
12216
|
+
noteRow(row, {
|
|
12217
|
+
repoPath: target.originRepoPath,
|
|
12218
|
+
branch: target.branch
|
|
12219
|
+
});
|
|
12220
|
+
/**
|
|
12221
|
+
* The branch's run, holding a slot. A retry runs against the commit first resolved, wherever the
|
|
12222
|
+
* branch has moved since, unless `fresh`, which looks at the folder again as it is now.
|
|
12223
|
+
*/
|
|
12224
|
+
const run = async (release, again) => {
|
|
12225
|
+
const fresh = again === "fresh";
|
|
12226
|
+
let replaced = false;
|
|
12227
|
+
begin(row);
|
|
12228
|
+
try {
|
|
12229
|
+
const next = fresh ? await deps.resolveBranch(path, base) : target;
|
|
12230
|
+
if (again === "retry") {
|
|
12231
|
+
if (!await stillThere(row, target, "branch", deps)) return;
|
|
12232
|
+
if (row.reviewId) return await retryReview(row, batch, deps);
|
|
11607
12233
|
}
|
|
11608
|
-
|
|
11609
|
-
|
|
11610
|
-
|
|
11611
|
-
|
|
11612
|
-
|
|
11613
|
-
|
|
12234
|
+
const prepared = await prepareRow(row, next, agent.agentId, model, deps, fresh);
|
|
12235
|
+
target = next;
|
|
12236
|
+
replaced = fresh;
|
|
12237
|
+
await completeRow(row, prepared, batch, deps);
|
|
12238
|
+
} catch (error) {
|
|
12239
|
+
fail(row, error);
|
|
12240
|
+
} finally {
|
|
12241
|
+
if (fresh) keepFreshPending(row, replaced);
|
|
12242
|
+
release();
|
|
11614
12243
|
}
|
|
11615
12244
|
};
|
|
11616
|
-
|
|
11617
|
-
|
|
11618
|
-
|
|
11619
|
-
|
|
11620
|
-
|
|
11621
|
-
|
|
11622
|
-
|
|
11623
|
-
|
|
11624
|
-
|
|
11625
|
-
|
|
11626
|
-
|
|
11627
|
-
|
|
11628
|
-
|
|
11629
|
-
|
|
11630
|
-
|
|
11631
|
-
|
|
11632
|
-
|
|
11633
|
-
|
|
11634
|
-
|
|
11635
|
-
};
|
|
11636
|
-
}),
|
|
11637
|
-
folders: () => discoverWorkingFolders(),
|
|
11638
|
-
summaries: () => recentWorkspaces(Number.MAX_SAFE_INTEGER),
|
|
11639
|
-
remoteOf: originRemote,
|
|
11640
|
-
resolvePullRequest: (folder, url) => resolveTarget({
|
|
11641
|
-
repoPath: folder,
|
|
11642
|
-
prUrl: url,
|
|
11643
|
-
resolveRequest: forgeResolver
|
|
11644
|
-
}),
|
|
11645
|
-
resolveBranch: (folder, base) => resolveTarget({
|
|
11646
|
-
repoPath: folder,
|
|
11647
|
-
...base ? { base } : {}
|
|
11648
|
-
}),
|
|
11649
|
-
defaultBase: async (root) => {
|
|
11650
|
-
try {
|
|
11651
|
-
const base = await resolveBase(root, void 0);
|
|
11652
|
-
return (base.followRef ?? base.display).replace(/^refs\/(?:heads|remotes)\//, "");
|
|
11653
|
-
} catch {
|
|
11654
|
-
return null;
|
|
12245
|
+
batch.rerun = (_index, fresh) => follow(batch, (async () => run(await deps.slots.acquire(), fresh ? "fresh" : "retry"))(), deps);
|
|
12246
|
+
const answer = (outcome) => ({
|
|
12247
|
+
status: 200,
|
|
12248
|
+
body: {
|
|
12249
|
+
outcome,
|
|
12250
|
+
batchId: batch.id,
|
|
12251
|
+
rows: rowsOf(batch),
|
|
12252
|
+
...row.reviewId ? { reviewId: row.reviewId } : {},
|
|
12253
|
+
...row.kept ? { kept: row.kept } : {}
|
|
12254
|
+
}
|
|
12255
|
+
});
|
|
12256
|
+
const release = deps.slots.tryAcquire();
|
|
12257
|
+
if (!release) {
|
|
12258
|
+
const existing = await deps.findExisting(target);
|
|
12259
|
+
if (existing && !deps.resumeOf(existing.id)) {
|
|
12260
|
+
row.reviewId = existing.id;
|
|
12261
|
+
if (!existing.inProgress) {
|
|
12262
|
+
row.status = "already-reviewed";
|
|
12263
|
+
return answer("existing");
|
|
11655
12264
|
}
|
|
11656
|
-
|
|
11657
|
-
|
|
11658
|
-
|
|
11659
|
-
|
|
11660
|
-
|
|
11661
|
-
|
|
11662
|
-
track,
|
|
11663
|
-
chooseFolder: folderChooser(),
|
|
11664
|
-
concurrency: 2,
|
|
11665
|
-
slots: createSlots(2),
|
|
11666
|
-
resumeOf: (reviewId) => {
|
|
11667
|
-
const failure = failureOf$1(reviewId);
|
|
11668
|
-
return failure ? {
|
|
11669
|
-
agent: agentNameOf(failure.agentId),
|
|
11670
|
-
...failure.requestedModel ? { model: failure.requestedModel } : {}
|
|
11671
|
-
} : null;
|
|
11672
|
-
},
|
|
11673
|
-
agentName: agentNameOf,
|
|
11674
|
-
home: homedir()
|
|
11675
|
-
};
|
|
11676
|
-
}
|
|
11677
|
-
function modelHelpOf(agentId) {
|
|
11678
|
-
try {
|
|
11679
|
-
return agentById(agentId).modelSelectionHelp;
|
|
11680
|
-
} catch {
|
|
11681
|
-
return;
|
|
12265
|
+
begin(row);
|
|
12266
|
+
follow(batch, settleRow(row, deps.observe(existing.id)), deps, false);
|
|
12267
|
+
return answer("existing");
|
|
12268
|
+
}
|
|
12269
|
+
follow(batch, (async () => run(await deps.slots.acquire(), "first"))(), deps);
|
|
12270
|
+
return answer("waiting");
|
|
11682
12271
|
}
|
|
11683
|
-
|
|
11684
|
-
function agentNameOf(agentId) {
|
|
12272
|
+
let prepared;
|
|
11685
12273
|
try {
|
|
11686
|
-
|
|
11687
|
-
} catch {
|
|
11688
|
-
|
|
12274
|
+
prepared = await prepareRow(row, target, agent.agentId, model, deps);
|
|
12275
|
+
} catch (error) {
|
|
12276
|
+
release();
|
|
12277
|
+
batches.delete(batch.id);
|
|
12278
|
+
return {
|
|
12279
|
+
status: 409,
|
|
12280
|
+
body: refusalOf(error)
|
|
12281
|
+
};
|
|
11689
12282
|
}
|
|
12283
|
+
if (prepared.kind === "existing") {
|
|
12284
|
+
await completeRow(row, prepared, batch, deps);
|
|
12285
|
+
release();
|
|
12286
|
+
} else follow(batch, (async () => {
|
|
12287
|
+
try {
|
|
12288
|
+
await settleRow(row, deps.settle(row.reviewId));
|
|
12289
|
+
} catch (error) {
|
|
12290
|
+
fail(row, error);
|
|
12291
|
+
} finally {
|
|
12292
|
+
release();
|
|
12293
|
+
}
|
|
12294
|
+
})(), deps);
|
|
12295
|
+
return answer(prepared.kind);
|
|
11690
12296
|
}
|
|
11691
|
-
|
|
11692
|
-
|
|
11693
|
-
|
|
11694
|
-
|
|
11695
|
-
...extra
|
|
11696
|
-
}
|
|
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;
|
|
12297
|
+
async function chooseFolder(deps) {
|
|
12298
|
+
if (!deps.chooseFolder) return refuse(404, "Choosing a folder is not available on this system. Type its path instead.");
|
|
12299
|
+
try {
|
|
12300
|
+
const path = await deps.chooseFolder();
|
|
11702
12301
|
return {
|
|
11703
|
-
|
|
11704
|
-
|
|
11705
|
-
where,
|
|
11706
|
-
...commits ? { commits: commits.length } : {}
|
|
12302
|
+
status: 200,
|
|
12303
|
+
body: path ? { path } : { cancelled: true }
|
|
11707
12304
|
};
|
|
11708
|
-
}
|
|
11709
|
-
|
|
11710
|
-
|
|
11711
|
-
discovered: error.discovered,
|
|
11712
|
-
...error.divergence ? { divergence: error.divergence } : {},
|
|
11713
|
-
options: [option("remote"), option("local")]
|
|
11714
|
-
};
|
|
12305
|
+
} catch (error) {
|
|
12306
|
+
return refuse(500, message(error));
|
|
12307
|
+
}
|
|
11715
12308
|
}
|
|
11716
|
-
|
|
11717
|
-
|
|
11718
|
-
|
|
11719
|
-
|
|
11720
|
-
|
|
11721
|
-
|
|
11722
|
-
|
|
11723
|
-
|
|
11724
|
-
|
|
11725
|
-
|
|
11726
|
-
if (
|
|
11727
|
-
|
|
11728
|
-
|
|
11729
|
-
|
|
11730
|
-
|
|
11731
|
-
|
|
12309
|
+
//#endregion
|
|
12310
|
+
//#region ../../packages/review-harness/src/workspace/runs.ts
|
|
12311
|
+
function runKindOf(job) {
|
|
12312
|
+
if (purposeOf(job) === "update") return "update";
|
|
12313
|
+
if (job.reviewType === "kiss") return "kiss";
|
|
12314
|
+
if (job.reanalysis) return "re-run";
|
|
12315
|
+
return "review";
|
|
12316
|
+
}
|
|
12317
|
+
/** The filter an entry in progress belongs to. A waiting Add review row is a review waiting to start. */
|
|
12318
|
+
function operationRunKind(operation) {
|
|
12319
|
+
if (operation.purpose === "update") return "update";
|
|
12320
|
+
if (operation.lens === "kiss") return "kiss";
|
|
12321
|
+
if (operation.kind === "re-running") return "re-run";
|
|
12322
|
+
return "review";
|
|
12323
|
+
}
|
|
12324
|
+
/**
|
|
12325
|
+
* Everything the page lists, in the order it lists it.
|
|
12326
|
+
*
|
|
12327
|
+
* **Not responding** is a job in a working phase that no live driver holds and that has not moved
|
|
12328
|
+
* for a whole claim lease. The lease is the existing bound on how long a dead driver's claim keeps
|
|
12329
|
+
* counting as live, so by the time this says a job is not responding, the claim test had already
|
|
12330
|
+
* stopped calling it running; and a job between being made and being claimed, which is
|
|
12331
|
+
* milliseconds, is never caught by it. It is not counted as running, and not as failed: the sweep
|
|
12332
|
+
* decides that, half an hour after the job last moved, and the entry then reads Failed by itself.
|
|
12333
|
+
*
|
|
12334
|
+
* The history is ordered newest ending first, then by key, a total order, because it is hashed into
|
|
12335
|
+
* the page's revision and a tie left to the directory would reload the page for nothing.
|
|
12336
|
+
*/
|
|
12337
|
+
function deriveRuns(jobs, isLive, rows, now) {
|
|
12338
|
+
const inProgress = deriveOperations(jobs, isLive, rows);
|
|
12339
|
+
const recent = [];
|
|
12340
|
+
for (const job of jobs) {
|
|
12341
|
+
if (purposeOf(job) === "ask") continue;
|
|
12342
|
+
const removed = new Set(job.removedFromRuns ?? []);
|
|
12343
|
+
const driven = isLive(job);
|
|
12344
|
+
const listed = (endedAt) => now - endedAt < RUN_RECORD_RETENTION_MS;
|
|
12345
|
+
const earlier = job.previousAttempts ?? [];
|
|
12346
|
+
const attempts = earlier.length + 1;
|
|
12347
|
+
const shared = {
|
|
12348
|
+
jobId: job.id,
|
|
12349
|
+
reviewId: job.workspaceId,
|
|
12350
|
+
kind: runKindOf(job),
|
|
12351
|
+
attempts,
|
|
12352
|
+
target: job.target,
|
|
12353
|
+
...job.reviewDeleted ? { reviewDeleted: true } : {}
|
|
12354
|
+
};
|
|
12355
|
+
earlier.forEach((attempt, index) => {
|
|
12356
|
+
if (!removed.has(attempt.attemptId) && listed(attempt.finishedAt)) recent.push({
|
|
12357
|
+
...shared,
|
|
12358
|
+
...fromAttempt(attempt),
|
|
12359
|
+
attempt: index + 1,
|
|
12360
|
+
removable: !driven
|
|
12361
|
+
});
|
|
12362
|
+
});
|
|
12363
|
+
const key = attemptIdOf(job);
|
|
12364
|
+
if (removed.has(key)) continue;
|
|
12365
|
+
const current = {
|
|
12366
|
+
...shared,
|
|
12367
|
+
key,
|
|
12368
|
+
attempt: attempts,
|
|
12369
|
+
startedAt: job.startedAt,
|
|
12370
|
+
...job.source ? { source: job.source } : {},
|
|
12371
|
+
...job.env.agentId ? { agentId: job.env.agentId } : {}
|
|
11732
12372
|
};
|
|
12373
|
+
if (isTerminal(job)) {
|
|
12374
|
+
if (!listed(job.finishedAt ?? job.updatedAt)) continue;
|
|
12375
|
+
recent.push({
|
|
12376
|
+
removable: !driven,
|
|
12377
|
+
...current,
|
|
12378
|
+
status: job.phase === "done" ? "completed" : job.failure?.stopped ? "cancelled" : "failed",
|
|
12379
|
+
endedAt: job.finishedAt ?? job.updatedAt,
|
|
12380
|
+
...job.finishedAt !== void 0 ? { durationMs: job.finishedAt - job.startedAt } : {},
|
|
12381
|
+
...job.phase === "failed" && job.failure && !job.failure.stopped ? { failure: {
|
|
12382
|
+
message: job.failure.message,
|
|
12383
|
+
...job.failure.kind ? { kind: job.failure.kind } : {}
|
|
12384
|
+
} } : {},
|
|
12385
|
+
...job.phase === "done" && purposeOf(job) === "update" && job.progress ? { outcome: job.progress } : {}
|
|
12386
|
+
});
|
|
12387
|
+
} else if (!driven && now - job.updatedAt >= 18e4 && listed(job.updatedAt)) recent.push({
|
|
12388
|
+
...current,
|
|
12389
|
+
status: "not-responding",
|
|
12390
|
+
endedAt: job.updatedAt,
|
|
12391
|
+
removable: false
|
|
12392
|
+
});
|
|
11733
12393
|
}
|
|
11734
|
-
|
|
11735
|
-
return
|
|
11736
|
-
|
|
11737
|
-
|
|
11738
|
-
|
|
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 };
|
|
12394
|
+
recent.sort((a, b) => b.endedAt - a.endedAt || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
12395
|
+
return {
|
|
12396
|
+
inProgress,
|
|
12397
|
+
recent
|
|
12398
|
+
};
|
|
11744
12399
|
}
|
|
11745
|
-
|
|
11746
|
-
|
|
11747
|
-
|
|
12400
|
+
function fromAttempt(attempt) {
|
|
12401
|
+
return {
|
|
12402
|
+
key: attempt.attemptId,
|
|
12403
|
+
status: attempt.phase === "done" ? "completed" : attempt.failure?.stopped ? "cancelled" : "failed",
|
|
12404
|
+
startedAt: attempt.startedAt,
|
|
12405
|
+
endedAt: attempt.finishedAt,
|
|
12406
|
+
durationMs: attempt.finishedAt - attempt.startedAt,
|
|
12407
|
+
...attempt.phase === "failed" && attempt.failure && !attempt.failure.stopped ? { failure: {
|
|
12408
|
+
message: attempt.failure.message,
|
|
12409
|
+
...attempt.failure.kind ? { kind: attempt.failure.kind } : {}
|
|
12410
|
+
} } : {},
|
|
12411
|
+
...attempt.source ? { source: attempt.source } : {},
|
|
12412
|
+
...attempt.agentId ? { agentId: attempt.agentId } : {}
|
|
12413
|
+
};
|
|
11748
12414
|
}
|
|
11749
|
-
/**
|
|
11750
|
-
|
|
11751
|
-
|
|
11752
|
-
|
|
11753
|
-
|
|
11754
|
-
|
|
12415
|
+
/**
|
|
12416
|
+
* The three numbers above the lists. Failed counts every failed attempt the page can list, earlier
|
|
12417
|
+
* attempts included and removed ones not: the same population as the history, so the count and the
|
|
12418
|
+
* list cannot disagree. Not responding and cancelled are neither.
|
|
12419
|
+
*/
|
|
12420
|
+
function runCounts(runs) {
|
|
12421
|
+
return {
|
|
12422
|
+
running: runs.inProgress.filter((operation) => operation.kind !== "queued").length,
|
|
12423
|
+
queued: runs.inProgress.filter((operation) => operation.kind === "queued").length,
|
|
12424
|
+
failed: runs.recent.filter((entry) => entry.status === "failed").length
|
|
12425
|
+
};
|
|
11755
12426
|
}
|
|
11756
|
-
|
|
11757
|
-
|
|
12427
|
+
/**
|
|
12428
|
+
* Has anything the Runs page draws changed. The page reloads when this differs from the one it was
|
|
12429
|
+
* rendered with, as the Reviews page does with `reviewsRevision`.
|
|
12430
|
+
*
|
|
12431
|
+
* Built from what the entries say, never from a time or a progress line, so it moves when a run
|
|
12432
|
+
* starts, moves to its next step, ends, is removed, or loses its review, and never on a clock.
|
|
12433
|
+
* Elapsed times are the page's to repaint (`client/runs.ts`). Also from the last cleanup's
|
|
12434
|
+
* trouble, which the page shows beside the runs.
|
|
12435
|
+
*/
|
|
12436
|
+
function runsRevision(runs, cleanup) {
|
|
12437
|
+
const hash = createHash("sha1");
|
|
12438
|
+
if (cleanup && (cleanup.refusals.length > 0 || cleanup.error)) hash.update(`cleanup:${cleanup.at}:${cleanup.error ?? ""}:${cleanup.refusals.join("\0")}\n`);
|
|
12439
|
+
const byKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
12440
|
+
for (const operation of [...runs.inProgress].sort(byKey)) hash.update(`op:${operation.key}:${operation.kind}:${operation.phase ?? ""}:${operation.reviewId ?? ""}\n`);
|
|
12441
|
+
for (const entry of [...runs.recent].sort(byKey)) hash.update(`run:${entry.key}:${entry.status}:${entry.reviewDeleted ? "deleted" : ""}\n`);
|
|
12442
|
+
return hash.digest("hex").slice(0, 16);
|
|
11758
12443
|
}
|
|
11759
|
-
|
|
11760
|
-
|
|
11761
|
-
|
|
11762
|
-
|
|
11763
|
-
|
|
11764
|
-
|
|
11765
|
-
|
|
12444
|
+
//#endregion
|
|
12445
|
+
//#region ../../packages/review-harness/src/workspace/live_operations.ts
|
|
12446
|
+
/**
|
|
12447
|
+
* The review work going on now, as one list: the one place that gathers it.
|
|
12448
|
+
*
|
|
12449
|
+
* The Reviews page's In progress section is its first reader, and anything else that monitors
|
|
12450
|
+
* execution should read this rather than gathering the same facts again. The rule that turns the
|
|
12451
|
+
* facts into entries is `deriveOperations`; this only supplies them, with the claim test
|
|
12452
|
+
* `analysisStates` uses, so the list and a card's analysis line never disagree about what is running.
|
|
12453
|
+
*
|
|
12454
|
+
* **Two scopes, on purpose.**
|
|
12455
|
+
*
|
|
12456
|
+
* - **Running work is machine-wide.** Jobs live in the store on disk and are claimed by whichever
|
|
12457
|
+
* process drives them, so a review started from a terminal, an MCP session or this server is
|
|
12458
|
+
* listed here alike.
|
|
12459
|
+
* - **Queued work is this process's only.** Rows waiting for an analysis slot are held in memory by
|
|
12460
|
+
* the server that accepted them (`pendingBatchRows`, from `add_review.ts`). Another process's
|
|
12461
|
+
* waiting work, such as the later pull requests of a `prreviewbuddy review --pr …` batch, is not
|
|
12462
|
+
* listed until it starts and has a job. A restart forgets this server's own waiting rows too.
|
|
12463
|
+
*
|
|
12464
|
+
* Nothing persists or shares the queue across processes, and nothing here should claim it does.
|
|
12465
|
+
*
|
|
12466
|
+
* `jobs` and `rows` are injected so a caller with its own snapshot, or a test, can hand them in.
|
|
12467
|
+
*/
|
|
12468
|
+
function liveOperations(now = Date.now(), jobs = allJobs(), rows = pendingBatchRows()) {
|
|
12469
|
+
return deriveOperations(jobs, (job) => isClaimed(job.id, now), rows);
|
|
11766
12470
|
}
|
|
11767
12471
|
/**
|
|
11768
|
-
*
|
|
11769
|
-
*
|
|
11770
|
-
*
|
|
12472
|
+
* Everything the Runs page lists, from one reading of the jobs directory: the same in-progress list
|
|
12473
|
+
* as `liveOperations`, with the week of finished runs beside it. The two scopes above apply here
|
|
12474
|
+
* unchanged.
|
|
11771
12475
|
*/
|
|
11772
|
-
|
|
11773
|
-
|
|
11774
|
-
const resolution = await deps.resolveAgent(flag || void 0);
|
|
11775
|
-
if (resolution.kind === "resolved") return { agentId: resolution.agent.id };
|
|
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" });
|
|
12476
|
+
function liveRuns(now = Date.now(), jobs = allJobs(), rows = pendingBatchRows()) {
|
|
12477
|
+
return deriveRuns(jobs, (job) => isClaimed(job.id, now), rows, now);
|
|
11778
12478
|
}
|
|
11779
|
-
|
|
11780
|
-
|
|
11781
|
-
|
|
11782
|
-
|
|
12479
|
+
//#endregion
|
|
12480
|
+
//#region ../../packages/review-harness/src/workspace/reviews_revision.ts
|
|
12481
|
+
/**
|
|
12482
|
+
* Whether anything the reviews index draws has changed, as one token.
|
|
12483
|
+
*
|
|
12484
|
+
* The index reacts to a change by reloading, which is what makes an aggregate the right shape: it
|
|
12485
|
+
* never needs to know *which* review changed, so it asks one question whose cost is the same for
|
|
12486
|
+
* five reviews and five hundred. Per-card revisions would be more information than the page has any
|
|
12487
|
+
* use for, and a request per card to carry it.
|
|
12488
|
+
*
|
|
12489
|
+
* Opaque and **equality only**, the same contract as `workspaceRevision` in `store.ts`: compare two
|
|
12490
|
+
* tokens for equality, never order them, never parse one. The implementation below is a hash of
|
|
12491
|
+
* facts the page happens to draw today, and the whole point of saying so here is that it can be
|
|
12492
|
+
* replaced by a counter, a watch, or anything else without a page having to be told.
|
|
12493
|
+
*
|
|
12494
|
+
* ## What is in it, and what is deliberately not
|
|
12495
|
+
*
|
|
12496
|
+
* In: every review's id and file revision, and the phase of every analysis running right now.
|
|
12497
|
+
* Between them those cover everything a card shows — the summary, the risk badge, the counts, the
|
|
12498
|
+
* pin, the reviewed mark, the running line — because every one of those reaches disk through
|
|
12499
|
+
* `writeWorkspace`, and the set of ids covers a review appearing or being deleted.
|
|
12500
|
+
*
|
|
12501
|
+
* Not in: **`job.updatedAt` and `job.progress`.** This is the load-bearing omission. An agent
|
|
12502
|
+
* streams its progress into the job record every few seconds, so a token derived from job file
|
|
12503
|
+
* mtimes would differ on nearly every poll, and a page that reloads whenever the token differs
|
|
12504
|
+
* would then reload every few seconds for the whole length of an analysis — a page thrashing
|
|
12505
|
+
* itself, with the reviewer's scroll position going with it. Phase only, which changes four times
|
|
12506
|
+
* in a run.
|
|
12507
|
+
*/
|
|
12508
|
+
/**
|
|
12509
|
+
* What is being done to each review, or was last done and stopped.
|
|
12510
|
+
*
|
|
12511
|
+
* Keyed by review, valued by the phase the card should name. Two states reach it and a third
|
|
12512
|
+
* deliberately does not:
|
|
12513
|
+
*
|
|
12514
|
+
* - **Running.** A job that is not terminal *and* whose driver holds a live claim. The claim is the
|
|
12515
|
+
* load-bearing half: a job whose driver was killed sits in a working phase for up to half an hour
|
|
12516
|
+
* before the sweep calls it stopped, and narrating one of those as "Analysing the change" would
|
|
12517
|
+
* be the index confidently reporting work that nothing is doing.
|
|
12518
|
+
* - **`failed`.** Reported whether or not anything still holds the claim, which is the whole reason
|
|
12519
|
+
* this function is not simply `runningJobs`. It was: `runningJobs` drops terminal jobs, and
|
|
12520
|
+
* `isTerminal` counts `failed` as terminal, so a failed analysis could never reach the renderer
|
|
12521
|
+
* and the card's "Analysis stopped" branch was unreachable in production while passing a unit
|
|
12522
|
+
* test that handed it a state the product could not produce. A review whose analysis failed went
|
|
12523
|
+
* back to reading "No standard review yet" the moment its driver let go.
|
|
12524
|
+
* - **`done`, and an unclaimed job still in a working phase.** Nothing. A finished job has its
|
|
12525
|
+
* result on the card already, and an abandoned one is the sweep's to judge rather than this
|
|
12526
|
+
* renderer's to pre-empt.
|
|
12527
|
+
*
|
|
12528
|
+
* **Every job a review has, not the latest one.** A review accumulates them -- a standard analysis,
|
|
12529
|
+
* a KISS run, a retry -- and two of them genuinely run at once: `handleKiss` refuses only a second
|
|
12530
|
+
* KISS run, and the review page's poll is built around one finishing while the other goes on. An
|
|
12531
|
+
* earlier cut picked the single newest job and then asked what it was, so a KISS run that ended
|
|
12532
|
+
* first spoke for a standard analysis that was still working, and the card fell silent on work that
|
|
12533
|
+
* was happening in front of the reviewer.
|
|
12534
|
+
*
|
|
12535
|
+
* So the precedence is running, then the most recent ending, and it is stated in that order because
|
|
12536
|
+
* that is the property: **nothing that has ended can hide something that has not.**
|
|
12537
|
+
*
|
|
12538
|
+
* `jobs` is injected only so a test can hand the same set in two different orders and prove the
|
|
12539
|
+
* answer does not move. Production always reads the directory.
|
|
12540
|
+
*
|
|
12541
|
+
* A failure is visible for as long as the job record survives, which is the sweep's retention. Once
|
|
12542
|
+
* that passes the card quietly returns to saying nothing, which is the same silence every review
|
|
12543
|
+
* had before this existed.
|
|
12544
|
+
*/
|
|
12545
|
+
function analysisStates(now = Date.now(), jobs = allJobs()) {
|
|
12546
|
+
const byReview = /* @__PURE__ */ new Map();
|
|
12547
|
+
for (const job of jobs) {
|
|
12548
|
+
if (purposeOf(job) === "ask") continue;
|
|
12549
|
+
const held = byReview.get(job.workspaceId);
|
|
12550
|
+
if (held) held.push(job);
|
|
12551
|
+
else byReview.set(job.workspaceId, [job]);
|
|
12552
|
+
}
|
|
12553
|
+
/**
|
|
12554
|
+
* Both comparators are **total**, and that is a requirement rather than tidiness.
|
|
12555
|
+
*
|
|
12556
|
+
* `allJobs` reads a directory, so a tie leaves the winner to whatever order the filesystem
|
|
12557
|
+
* happened to return. This function's output is hashed into the index token, and the page reloads
|
|
12558
|
+
* whenever that token changes -- so an arbitrary winner is not a cosmetic wobble, it is the page
|
|
12559
|
+
* reloading itself for as long as the tie exists, with no cause a reviewer could ever see. The
|
|
12560
|
+
* same hazard the sorting rule was written for, one level further down.
|
|
12561
|
+
*
|
|
12562
|
+
* Ties are real. Two jobs can carry the same timestamp to the millisecond, and a record written
|
|
12563
|
+
* before `finishedAt` existed falls back to `updatedAt`, which two records can share. The id is
|
|
12564
|
+
* the last resort because it is the only field guaranteed to differ.
|
|
12565
|
+
*/
|
|
12566
|
+
const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
12567
|
+
const startedLast = (a, b) => b.startedAt - a.startedAt || byId(a, b);
|
|
12568
|
+
const endedLast = (a, b) => endedAt(b) - endedAt(a) || b.startedAt - a.startedAt || byId(a, b);
|
|
12569
|
+
const states = /* @__PURE__ */ new Map();
|
|
12570
|
+
for (const [reviewId, jobs] of byReview) {
|
|
12571
|
+
const running = jobs.filter((job) => !isTerminal(job) && isClaimed(job.id, now)).sort(startedLast);
|
|
12572
|
+
if (running.length > 0) {
|
|
12573
|
+
states.set(reviewId, {
|
|
12574
|
+
phase: running[0].phase,
|
|
12575
|
+
purpose: purposeOf(running[0])
|
|
12576
|
+
});
|
|
12577
|
+
continue;
|
|
12578
|
+
}
|
|
12579
|
+
const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
|
|
12580
|
+
if (ended[0]?.phase === "failed") states.set(reviewId, {
|
|
12581
|
+
phase: "failed",
|
|
12582
|
+
purpose: "review",
|
|
12583
|
+
...ended[0].failure?.stopped ? { stopped: true } : {}
|
|
12584
|
+
});
|
|
12585
|
+
}
|
|
12586
|
+
return states;
|
|
11783
12587
|
}
|
|
11784
12588
|
/**
|
|
11785
|
-
* The
|
|
11786
|
-
* queue in `target.ts`.
|
|
12589
|
+
* The token.
|
|
11787
12590
|
*
|
|
11788
|
-
*
|
|
11789
|
-
*
|
|
11790
|
-
*
|
|
11791
|
-
*
|
|
12591
|
+
* Takes the live analyses rather than reading them, so that one request answers with a token and a
|
|
12592
|
+
* running state that describe the same instant. Computing them separately would let a phase change
|
|
12593
|
+
* land between the two, and the page would then be told "nothing has changed" alongside a state it
|
|
12594
|
+
* had not seen.
|
|
11792
12595
|
*/
|
|
11793
|
-
|
|
11794
|
-
|
|
11795
|
-
const
|
|
11796
|
-
const
|
|
11797
|
-
|
|
11798
|
-
|
|
11799
|
-
|
|
11800
|
-
|
|
11801
|
-
});
|
|
11802
|
-
|
|
12596
|
+
function reviewsRevision(live = analysisStates(), operations = liveOperations()) {
|
|
12597
|
+
const hash = createHash("sha1");
|
|
12598
|
+
for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
|
|
12599
|
+
for (const id of [...live.keys()].sort()) {
|
|
12600
|
+
const state = live.get(id);
|
|
12601
|
+
hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
|
|
12602
|
+
}
|
|
12603
|
+
for (const operation of [...operations].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) hash.update(`op:${operation.key}:${operation.kind}:${operation.phase ?? ""}:${operation.reviewId ?? ""}\n`);
|
|
12604
|
+
hash.update(`queue:${queueStamp()}\n`);
|
|
12605
|
+
for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
|
|
12606
|
+
for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
|
|
12607
|
+
for (const [key, entry] of Object.entries(readWatch()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`head:${key}:${entry.changedAt}\n`);
|
|
12608
|
+
for (const [key, entry] of Object.entries(readPrStates()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`pr-changed:${key}:${entry.changedAt}\n`);
|
|
12609
|
+
return hash.digest("base64url");
|
|
11803
12610
|
}
|
|
11804
|
-
|
|
11805
|
-
|
|
11806
|
-
|
|
11807
|
-
|
|
11808
|
-
|
|
11809
|
-
|
|
11810
|
-
|
|
11811
|
-
|
|
11812
|
-
|
|
11813
|
-
|
|
11814
|
-
|
|
11815
|
-
|
|
11816
|
-
|
|
11817
|
-
|
|
11818
|
-
|
|
11819
|
-
|
|
11820
|
-
|
|
11821
|
-
|
|
12611
|
+
//#endregion
|
|
12612
|
+
//#region ../../packages/review-harness/src/workspace/runs_page.ts
|
|
12613
|
+
/**
|
|
12614
|
+
* The most finished runs a page carries. The whole week goes to the browser, so a filter is
|
|
12615
|
+
* answered over everything kept rather than over the page that happens to be drawn; seven days of
|
|
12616
|
+
* runs is bounded by itself, and this bounds it again. Past it the page says it is showing the
|
|
12617
|
+
* newest of them, so a filter that finds nothing is never read as "nothing ran".
|
|
12618
|
+
*/
|
|
12619
|
+
var MAX_RUNS_SENT = 1e3;
|
|
12620
|
+
var RUN_KIND_LABELS = {
|
|
12621
|
+
review: "Initial review",
|
|
12622
|
+
update: "Update",
|
|
12623
|
+
"re-run": "Re-run",
|
|
12624
|
+
kiss: "KISS review"
|
|
12625
|
+
};
|
|
12626
|
+
var STATUS_LABELS = {
|
|
12627
|
+
completed: "Completed",
|
|
12628
|
+
failed: "Failed",
|
|
12629
|
+
cancelled: "Cancelled",
|
|
12630
|
+
"not-responding": "Not responding"
|
|
12631
|
+
};
|
|
12632
|
+
var SOURCE_LABELS = {
|
|
12633
|
+
workspace: "Workspace",
|
|
12634
|
+
cli: "CLI",
|
|
12635
|
+
mcp: "MCP"
|
|
12636
|
+
};
|
|
12637
|
+
function renderRunsPage(view) {
|
|
12638
|
+
const now = view.now ?? Date.now();
|
|
12639
|
+
const { inProgress, recent } = view.runs;
|
|
12640
|
+
const counts = runCounts(view.runs);
|
|
12641
|
+
const shown = recent.slice(0, MAX_RUNS_SENT);
|
|
12642
|
+
const nothing = inProgress.length === 0 && recent.length === 0;
|
|
12643
|
+
const notResponding = recent.some((entry) => entry.status === "not-responding");
|
|
12644
|
+
return `<!doctype html>
|
|
12645
|
+
<html lang="en"${themeAttribute(view.theme)}>
|
|
12646
|
+
<head>
|
|
12647
|
+
<meta charset="utf-8">
|
|
12648
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
12649
|
+
<title>Runs · PR Review Buddy</title>
|
|
12650
|
+
<link rel="icon" type="image/png" href="/assets/icon128.png">
|
|
12651
|
+
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
12652
|
+
<link rel="stylesheet" href="/assets/shell.css">
|
|
12653
|
+
</head>
|
|
12654
|
+
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}"${view.revision === void 0 ? "" : ` data-runs-revision="${escapeHtml$1(view.revision)}"`} data-live="${inProgress.length > 0}">
|
|
12655
|
+
${renderGlobalBar({
|
|
12656
|
+
current: "runs",
|
|
12657
|
+
unread: view.unreadNotifications ?? 0,
|
|
12658
|
+
driving: view.driving
|
|
12659
|
+
})}
|
|
12660
|
+
${renderContextBar("Runs", nothing ? "" : renderControls(notResponding))}
|
|
12661
|
+
<div class="prb-shell">
|
|
12662
|
+
${renderWorkspaceRail({
|
|
12663
|
+
current: "runs",
|
|
12664
|
+
unread: view.unreadNotifications ?? 0
|
|
12665
|
+
})}
|
|
12666
|
+
<main class="prb-index prb-runs">
|
|
12667
|
+
<p class="prb-runs-lede">Monitor review executions and recent activity.</p>
|
|
12668
|
+
${renderSummary(counts)}
|
|
12669
|
+
${nothing ? `<p class="prb-index-empty" data-runs-empty>Nothing has run in the last seven days. Reviews, updates and re-runs appear here as soon as they start, whether from this workspace, a terminal or an MCP client.</p>` : `${renderInProgress(inProgress, view.reviews, now, runsSectionOpen("in-progress", view.sectionsCollapsed))}
|
|
12670
|
+
${renderRecent(shown, recent.length, view, now, runsSectionOpen("recent", view.sectionsCollapsed))}
|
|
12671
|
+
<p class="prb-index-none" data-no-matches hidden>No runs match these filters. <button type="button" class="prb-index-clear" data-clear-filters-inline>Clear filters</button></p>`}
|
|
12672
|
+
${renderCleanup(view.cleanup, now)}
|
|
12673
|
+
<p class="prb-index-note">Runs are kept for seven days, on this machine only. Older ones are never shown, and their records are deleted by the workspace server, which checks every ten minutes while it is running and once when it starts. Removing a run takes it off this page and changes nothing about its review.</p>
|
|
12674
|
+
</main>
|
|
12675
|
+
</div>
|
|
12676
|
+
<script src="/assets/runs.js" type="module"><\/script>
|
|
12677
|
+
</body>
|
|
12678
|
+
</html>`;
|
|
12679
|
+
}
|
|
12680
|
+
/**
|
|
12681
|
+
* A cleanup that could not finish, said where retention is said. Nothing when the last pass did
|
|
12682
|
+
* everything it set out to: a working sweep is not news. The first refusal is quoted, because it
|
|
12683
|
+
* names the directory and the reason, which is what a person needs to act on it.
|
|
12684
|
+
*/
|
|
12685
|
+
function renderCleanup(cleanup, now) {
|
|
12686
|
+
if (!cleanup || cleanup.refusals.length === 0 && !cleanup.error) return "";
|
|
12687
|
+
const when = relativeTime(cleanup.at, now);
|
|
12688
|
+
const what = cleanup.error ? `The last cleanup, ${when}, stopped with an error: ${cleanup.error}` : `The last cleanup, ${when}, could not remove ${cleanup.refusals.length === 1 ? "one item" : `${cleanup.refusals.length} items`}. ${cleanup.refusals[0]}`;
|
|
12689
|
+
return `<p class="prb-runs-scope" data-runs-cleanup role="status">${escapeHtml$1(`${what} It tries again every ten minutes while the workspace server is running.`)}</p>`;
|
|
11822
12690
|
}
|
|
11823
|
-
|
|
11824
|
-
|
|
11825
|
-
|
|
11826
|
-
|
|
11827
|
-
|
|
11828
|
-
|
|
12691
|
+
/**
|
|
12692
|
+
* Three numbers, quietly. Running is work a live driver holds; Queued is this server's waiting Add
|
|
12693
|
+
* review requests; Failed is over the same week the history covers, and says so rather than reading
|
|
12694
|
+
* as a total for all time.
|
|
12695
|
+
*/
|
|
12696
|
+
function renderSummary(counts) {
|
|
12697
|
+
const item = (name, label, value) => `<div class="prb-runs-count" data-runs-count="${name}"><dt>${escapeHtml$1(label)}</dt><dd>${value}</dd></div>`;
|
|
12698
|
+
return `<dl class="prb-runs-summary">
|
|
12699
|
+
${item("running", "Running", counts.running)}
|
|
12700
|
+
${item("queued", "Queued", counts.queued)}
|
|
12701
|
+
${item("failed", "Failed · last 7 days", counts.failed)}
|
|
12702
|
+
</dl>`;
|
|
11829
12703
|
}
|
|
11830
|
-
|
|
11831
|
-
|
|
11832
|
-
|
|
11833
|
-
|
|
11834
|
-
|
|
11835
|
-
|
|
12704
|
+
/**
|
|
12705
|
+
* Search and two filters, about execution only. The Reviews page's review-state filter is the
|
|
12706
|
+
* reviewer's own say on a review and means nothing about a run, so it is not here.
|
|
12707
|
+
*/
|
|
12708
|
+
function renderControls(notResponding) {
|
|
12709
|
+
return `<div class="prb-index-controls">
|
|
12710
|
+
<input type="search" class="prb-index-search" data-search placeholder="Search repo, pull request, branch or title…" aria-label="Search runs" spellcheck="false">
|
|
12711
|
+
<label class="prb-index-status">Run status:
|
|
12712
|
+
<select data-run-status-filter aria-label="Filter by run status">
|
|
12713
|
+
<option value="all">All statuses</option>
|
|
12714
|
+
<option value="in-progress">In progress</option>
|
|
12715
|
+
<option value="completed">Completed</option>
|
|
12716
|
+
<option value="failed">Failed</option>
|
|
12717
|
+
<option value="cancelled">Cancelled</option>
|
|
12718
|
+
${notResponding ? "<option value=\"not-responding\">Not responding</option>" : ""}
|
|
12719
|
+
</select>
|
|
12720
|
+
</label>
|
|
12721
|
+
<label class="prb-index-status">Run type:
|
|
12722
|
+
<select data-run-kind-filter aria-label="Filter by run type">
|
|
12723
|
+
<option value="all">All types</option>
|
|
12724
|
+
${Object.keys(RUN_KIND_LABELS).map((kind) => `<option value="${kind}">${escapeHtml$1(RUN_KIND_LABELS[kind])}</option>`).join("")}
|
|
12725
|
+
</select>
|
|
12726
|
+
</label>
|
|
12727
|
+
<button type="button" class="prb-index-clear" data-clear-filters hidden><span aria-hidden="true">×</span> Clear filters</button>
|
|
12728
|
+
</div>`;
|
|
11836
12729
|
}
|
|
11837
12730
|
/**
|
|
11838
|
-
* What
|
|
11839
|
-
*
|
|
11840
|
-
*
|
|
12731
|
+
* What is going on now, from the same list as the Reviews page's In progress section. The note is
|
|
12732
|
+
* the scope that list has on purpose: work that is running is seen machine-wide, while a request
|
|
12733
|
+
* still waiting for a slot is held by the server that accepted it and nowhere else.
|
|
12734
|
+
*/
|
|
12735
|
+
function renderInProgress(operations, reviews, now, open) {
|
|
12736
|
+
return `<details class="prb-index-section prb-runs-section" data-runs-section="in-progress"${open ? " open" : ""}>
|
|
12737
|
+
<summary class="prb-index-section-head">${RUNS_SECTION_NAMES["in-progress"].title} · <span data-section-count>${operations.length}</span></summary>
|
|
12738
|
+
${operations.length === 0 ? "<p class=\"prb-runs-idle\" data-runs-idle>Nothing is running.</p>" : ""}
|
|
12739
|
+
<div class="prb-cards" data-run-list>
|
|
12740
|
+
${operations.map((operation) => renderOperationRun(operation, reviewOf(operation, reviews), now)).join("")}
|
|
12741
|
+
</div>
|
|
12742
|
+
<p class="prb-runs-scope">Queued shows the reviews added from this workspace. Pull requests waiting in a terminal batch appear once they start.</p>
|
|
12743
|
+
</details>`;
|
|
12744
|
+
}
|
|
12745
|
+
/**
|
|
12746
|
+
* The week's finished runs, every one carried and the page shown by the browser: it filters first and
|
|
12747
|
+
* only then shows the newest matches a page at a time, so the run a filter is looking for is found
|
|
12748
|
+
* however far back it is. Without script every entry is shown, and Show older runs never appears.
|
|
12749
|
+
*/
|
|
12750
|
+
function renderRecent(shown, total, view, now, open) {
|
|
12751
|
+
return `<details class="prb-index-section prb-runs-section" data-runs-section="recent"${open ? " open" : ""}>
|
|
12752
|
+
<summary class="prb-index-section-head">${RUNS_SECTION_NAMES.recent.title} · Last 7 days · <span data-section-count>${shown.length}</span></summary>
|
|
12753
|
+
${shown.length === 0 ? "<p class=\"prb-runs-idle\">Nothing has finished in the last seven days.</p>" : ""}
|
|
12754
|
+
<div class="prb-cards" data-run-list>
|
|
12755
|
+
${shown.map((entry) => renderRecentRun(entry, view.reviews.get(entry.reviewId), now)).join("")}
|
|
12756
|
+
</div>
|
|
12757
|
+
<p class="prb-runs-more" data-runs-more-row hidden><button type="button" class="prb-index-more" data-runs-more>Show older runs</button> <span data-runs-more-note></span></p>
|
|
12758
|
+
${total > shown.length ? `<p class="prb-runs-scope" data-runs-truncated>${escapeHtml$1(`This page carries the newest ${shown.length} of the ${total} runs kept from the last seven days; filters search those.`)}</p>` : ""}
|
|
12759
|
+
</details>`;
|
|
12760
|
+
}
|
|
12761
|
+
/**
|
|
12762
|
+
* Whether this run's results have been replaced, answered by the review rather than by the clock.
|
|
11841
12763
|
*
|
|
11842
|
-
*
|
|
11843
|
-
*
|
|
11844
|
-
*
|
|
12764
|
+
* The review names the job whose analysis it presents, and the job behind its KISS result. A
|
|
12765
|
+
* completed run that is not that job no longer has its results on show: a re-run took them over.
|
|
12766
|
+
* Nothing else counts. An update changes an analysis in place and an update that found nothing
|
|
12767
|
+
* changes nothing, so neither replaces a run nor is replaced; a failure or a stop produced no
|
|
12768
|
+
* results to replace. An earlier attempt of a job is never labelled either, because the review
|
|
12769
|
+
* records jobs, not attempts, and so cannot say.
|
|
11845
12770
|
*/
|
|
11846
|
-
|
|
11847
|
-
if (
|
|
11848
|
-
const
|
|
11849
|
-
|
|
11850
|
-
return {
|
|
11851
|
-
status: 200,
|
|
11852
|
-
body: {
|
|
11853
|
-
rows: links.map((link) => {
|
|
11854
|
-
if (link.kind === "unsupported") return {
|
|
11855
|
-
key: link.key,
|
|
11856
|
-
label: `${link.provider} ${link.provider === "GitLab" ? "merge request" : "pull request"}`,
|
|
11857
|
-
url: link.url,
|
|
11858
|
-
ok: false,
|
|
11859
|
-
reason: unsupportedReason(link.provider),
|
|
11860
|
-
folders: []
|
|
11861
|
-
};
|
|
11862
|
-
const label = `${link.owner}/${link.repo} #${link.number}`;
|
|
11863
|
-
const candidates = candidatesFor(folders, link, summaries);
|
|
11864
|
-
if (candidates.length === 0) return {
|
|
11865
|
-
key: link.key,
|
|
11866
|
-
label,
|
|
11867
|
-
url: link.url,
|
|
11868
|
-
ok: false,
|
|
11869
|
-
reason: "Local repository not found",
|
|
11870
|
-
needsFolder: true,
|
|
11871
|
-
folders: []
|
|
11872
|
-
};
|
|
11873
|
-
const reviewed = reviewedIn(candidates, link, summaries);
|
|
11874
|
-
return {
|
|
11875
|
-
key: link.key,
|
|
11876
|
-
label,
|
|
11877
|
-
url: link.url,
|
|
11878
|
-
ok: true,
|
|
11879
|
-
folders: candidates.map((folder) => folderOption(folder, deps.home)),
|
|
11880
|
-
folder: candidates[0].path,
|
|
11881
|
-
...reviewed ? { reviewed: reviewedRow(reviewed.id, deps) } : {}
|
|
11882
|
-
};
|
|
11883
|
-
}),
|
|
11884
|
-
limit: MAX_BATCH
|
|
11885
|
-
}
|
|
11886
|
-
};
|
|
12771
|
+
function replaced(entry, review) {
|
|
12772
|
+
if (!review || entry.status !== "completed" || entry.kind === "update" || entry.attempt !== entry.attempts) return false;
|
|
12773
|
+
const presented = entry.kind === "kiss" ? review.presentedKissJobId : review.analysisJobId;
|
|
12774
|
+
return presented !== void 0 && presented !== entry.jobId;
|
|
11887
12775
|
}
|
|
11888
|
-
|
|
11889
|
-
|
|
11890
|
-
|
|
12776
|
+
function renderWhere(where) {
|
|
12777
|
+
return `<p class="prb-card-where">
|
|
12778
|
+
${where.repo ? `<span class="prb-card-repo">${escapeHtml$1(where.repo)}</span>` : ""}
|
|
12779
|
+
${where.pr === void 0 ? "" : `<span class="prb-card-pr">#${where.pr}</span>`}
|
|
12780
|
+
${where.branch ? `<span class="prb-card-branch">${escapeHtml$1(where.branch)}</span>` : ""}
|
|
12781
|
+
</p>`;
|
|
11891
12782
|
}
|
|
11892
|
-
function
|
|
11893
|
-
|
|
11894
|
-
|
|
11895
|
-
|
|
11896
|
-
|
|
11897
|
-
|
|
11898
|
-
|
|
11899
|
-
|
|
11900
|
-
|
|
11901
|
-
|
|
12783
|
+
function searchOf(title, where) {
|
|
12784
|
+
return [
|
|
12785
|
+
title,
|
|
12786
|
+
where.repo ?? "",
|
|
12787
|
+
where.pr === void 0 ? "" : `#${where.pr} ${where.pr}`,
|
|
12788
|
+
where.branch ?? ""
|
|
12789
|
+
].join(" ").toLowerCase();
|
|
12790
|
+
}
|
|
12791
|
+
function reviewHref(review) {
|
|
12792
|
+
return `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
12793
|
+
}
|
|
12794
|
+
/** The agent's name, only where the record names one this build knows. */
|
|
12795
|
+
function agentName(agentId) {
|
|
12796
|
+
return agentId && isAgentId(agentId) ? agentById(agentId).displayName : void 0;
|
|
12797
|
+
}
|
|
12798
|
+
/** Who asked and who ran it, each only when known. Two facts, never one guessed from the other. */
|
|
12799
|
+
function provenance(source, agentId) {
|
|
12800
|
+
const parts = [source ? SOURCE_LABELS[source] : void 0, agentName(agentId)].filter((part) => part !== void 0);
|
|
12801
|
+
return parts.length === 0 ? "" : `<span class="prb-run-provenance">${escapeHtml$1(parts.join(" · "))}</span>`;
|
|
12802
|
+
}
|
|
12803
|
+
/** A relative time the browser keeps current. `data-since` is what `client/runs.ts` repaints. */
|
|
12804
|
+
function since(prefix, at, now) {
|
|
12805
|
+
return `<span class="prb-card-when"><time datetime="${new Date(at).toISOString()}" data-since="${at}" data-since-prefix="${escapeHtml$1(prefix)}">${escapeHtml$1(`${prefix} ${relativeTime(at, now)}`)}</time></span>`;
|
|
12806
|
+
}
|
|
12807
|
+
/**
|
|
12808
|
+
* Work going on now. A link to its review where there is one, whose page shows the steps and holds
|
|
12809
|
+
* the Cancel; a request still waiting for its slot has no review yet and is not a link.
|
|
12810
|
+
*/
|
|
12811
|
+
function renderOperationRun(operation, review, now) {
|
|
12812
|
+
const row = operation.row;
|
|
12813
|
+
const own = operation.reviewId !== void 0 && review?.id === operation.reviewId ? review : void 0;
|
|
12814
|
+
const where = {
|
|
12815
|
+
repo: review ? lastSegment(review.repoPath) : row?.repoPath ? lastSegment(row.repoPath) : void 0,
|
|
12816
|
+
pr: review?.prNumber ?? row?.prNumber,
|
|
12817
|
+
branch: review ? followedRefName(review.branch, review.headResolvedRef) ?? review.branch : row?.branch
|
|
11902
12818
|
};
|
|
11903
|
-
|
|
11904
|
-
|
|
12819
|
+
const title = own?.title || review?.title || row?.label || "Review";
|
|
12820
|
+
const queued = operation.kind === "queued";
|
|
12821
|
+
const kind = operationRunKind(operation);
|
|
12822
|
+
const body = `<div class="prb-card-head"><h3>${escapeHtml$1(title)}</h3></div>
|
|
12823
|
+
${renderWhere(where)}
|
|
12824
|
+
<p class="prb-card-meta">
|
|
12825
|
+
<span class="prb-card-analysis" data-analysis="${queued ? "queued" : "running"}" role="status"><i aria-hidden="true"></i><span class="prb-visually-hidden">${queued ? "" : "Running: "}</span>${escapeHtml$1(queued ? "Queued · Waiting for an analysis slot" : operationLine(operation))}</span>
|
|
12826
|
+
${!queued && kind === "review" ? `<span>${escapeHtml$1(RUN_KIND_LABELS[kind])}</span>` : ""}
|
|
12827
|
+
${queued ? "<span class=\"prb-run-provenance\">Workspace</span>" : provenance(operation.source, operation.agentId)}
|
|
12828
|
+
${operation.startedAt ? since("Started", operation.startedAt, now) : ""}
|
|
12829
|
+
</p>`;
|
|
12830
|
+
return `<article class="prb-card prb-run" data-run data-run-status="in-progress" data-run-kind="${kind}" data-key="${escapeHtml$1(operation.key)}" data-search-text="${escapeHtml$1(searchOf(title, where))}">
|
|
12831
|
+
${own ? `<a class="prb-card-link" href="${escapeHtml$1(reviewHref(own))}">${body}</a>` : `<div class="prb-card-link">${body}</div>`}
|
|
12832
|
+
</article>`;
|
|
11905
12833
|
}
|
|
11906
12834
|
/**
|
|
11907
|
-
*
|
|
11908
|
-
*
|
|
12835
|
+
* A run that has ended, or stopped answering.
|
|
12836
|
+
*
|
|
12837
|
+
* The whole entry opens its review, as a Reviews card does, and opens it as it is now. That is not
|
|
12838
|
+
* always what this run produced, so where the review says another run's results are the ones it
|
|
12839
|
+
* now presents, the entry says so in words; where nothing says, it says nothing. A run whose review
|
|
12840
|
+
* was deleted, or is no longer kept, says that instead and links nowhere.
|
|
12841
|
+
*
|
|
12842
|
+
* Remove, in the card's menu, only on an ended run: work running or waiting is not history yet, and
|
|
12843
|
+
* one not responding may still be. It hides the entry and nothing else; the server refuses anything
|
|
12844
|
+
* else.
|
|
11909
12845
|
*/
|
|
11910
|
-
function
|
|
11911
|
-
|
|
11912
|
-
|
|
11913
|
-
|
|
11914
|
-
|
|
11915
|
-
|
|
11916
|
-
|
|
12846
|
+
function renderRecentRun(entry, stored, now) {
|
|
12847
|
+
const review = entry.reviewDeleted ? void 0 : stored;
|
|
12848
|
+
const request = entry.target.pullRequest;
|
|
12849
|
+
const where = {
|
|
12850
|
+
repo: review ? lastSegment(review.repoPath) : request?.source ? `${request.source.repo.owner}/${request.source.repo.repo}` : lastSegment(entry.target.originRepoPath),
|
|
12851
|
+
pr: review?.prNumber ?? request?.number,
|
|
12852
|
+
branch: review ? followedRefName(review.branch, review.headResolvedRef) ?? review.branch : request?.headRef ?? entry.target.branch
|
|
12853
|
+
};
|
|
12854
|
+
const live = review;
|
|
12855
|
+
const title = live?.title || (where.pr !== void 0 ? `${where.repo ?? "Pull request"} #${where.pr}` : where.branch || "Review");
|
|
12856
|
+
const ended = entry.status !== "not-responding";
|
|
12857
|
+
const detail = entry.status === "not-responding" ? "Nothing has heard from it for a while, so whatever was running it has probably stopped. It is marked Failed half an hour after it last moved." : entry.failure?.message ? entry.failure.message : entry.outcome ? entry.outcome : replaced(entry, live) ? "The review now shows the results of a later run, not this one." : "";
|
|
12858
|
+
const attempt = entry.attempts > 1 ? ` · attempt ${entry.attempt} of ${entry.attempts}` : "";
|
|
12859
|
+
const body = `<div class="prb-card-head"><h3>${escapeHtml$1(title)}</h3></div>
|
|
12860
|
+
${renderWhere(where)}
|
|
12861
|
+
<p class="prb-card-meta">
|
|
12862
|
+
<span class="prb-run-status" data-run-mark="${entry.status}"><i aria-hidden="true"></i>${escapeHtml$1(STATUS_LABELS[entry.status])}</span>
|
|
12863
|
+
<span>${escapeHtml$1(`${RUN_KIND_LABELS[entry.kind]}${attempt}`)}</span>
|
|
12864
|
+
${provenance(entry.source, entry.agentId)}
|
|
12865
|
+
${since(ended ? "Ended" : "Last heard from", entry.endedAt, now)}
|
|
12866
|
+
${ended && entry.durationMs !== void 0 ? `<span>${escapeHtml$1(`Took ${formatDuration(entry.durationMs)}`)}</span>` : ""}
|
|
12867
|
+
</p>
|
|
12868
|
+
${detail ? `<p class="prb-run-detail">${escapeHtml$1(detail)}</p>` : ""}
|
|
12869
|
+
${live ? "" : `<p class="prb-run-gone">${entry.reviewDeleted ? "Review deleted" : "Review no longer kept"}</p>`}`;
|
|
12870
|
+
return `<article class="prb-card prb-run" data-run data-run-status="${entry.status}" data-run-kind="${entry.kind}" data-key="${escapeHtml$1(entry.key)}" data-search-text="${escapeHtml$1(searchOf(title, where))}">
|
|
12871
|
+
${entry.removable ? renderRunMenu(entry.key) : ""}
|
|
12872
|
+
${live ? `<a class="prb-card-link" href="${escapeHtml$1(reviewHref(live))}">${body}</a>` : `<div class="prb-card-link">${body}</div>`}
|
|
12873
|
+
</article>`;
|
|
11917
12874
|
}
|
|
11918
|
-
|
|
11919
|
-
|
|
11920
|
-
|
|
11921
|
-
|
|
11922
|
-
|
|
12875
|
+
/**
|
|
12876
|
+
* The card's `…` menu, built as the Reviews page's is so the shared menus open, close and key it.
|
|
12877
|
+
* Remove is its one item for now: a hide, not a deletion, so it asks nothing first.
|
|
12878
|
+
*/
|
|
12879
|
+
function renderRunMenu(key) {
|
|
12880
|
+
return `<div class="prb-menu" data-menu>
|
|
12881
|
+
<button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="Run actions">⋯</button>
|
|
12882
|
+
<div class="prb-menu-panel" data-menu-panel hidden>
|
|
12883
|
+
<div data-menu-items>
|
|
12884
|
+
<button type="button" class="prb-menu-item" data-run-remove="${escapeHtml$1(key)}">Remove from Runs</button>
|
|
12885
|
+
</div>
|
|
12886
|
+
</div>
|
|
12887
|
+
</div>`;
|
|
12888
|
+
}
|
|
12889
|
+
//#endregion
|
|
12890
|
+
//#region ../../packages/review-harness/src/workspace/run_removal.ts
|
|
12891
|
+
async function removeRun(attemptId, jobs = allJobs()) {
|
|
12892
|
+
const holder = jobs.find((job) => attemptIdOf(job) === attemptId || job.previousAttempts?.some((attempt) => attempt.attemptId === attemptId));
|
|
12893
|
+
if (!holder) return "not-found";
|
|
12894
|
+
const outcome = await withReviewLock(holder.workspaceId, () => {
|
|
12895
|
+
const job = loadJob(holder.id);
|
|
12896
|
+
if (!job) return "not-found";
|
|
12897
|
+
if (isClaimed(job.id)) return "not-ended";
|
|
12898
|
+
const earlier = job.previousAttempts ?? [];
|
|
12899
|
+
if (earlier.some((attempt) => attempt.attemptId === attemptId)) {
|
|
12900
|
+
saveJob({
|
|
12901
|
+
...job,
|
|
12902
|
+
previousAttempts: earlier.filter((attempt) => attempt.attemptId !== attemptId)
|
|
12903
|
+
});
|
|
12904
|
+
return "removed";
|
|
12905
|
+
}
|
|
12906
|
+
if (attemptIdOf(job) !== attemptId) return "not-found";
|
|
12907
|
+
if (!isTerminal(job)) return "not-ended";
|
|
12908
|
+
const removed = job.removedFromRuns ?? [];
|
|
12909
|
+
if (!removed.includes(attemptId)) saveJob({
|
|
12910
|
+
...job,
|
|
12911
|
+
removedFromRuns: [...removed, attemptId]
|
|
12912
|
+
});
|
|
12913
|
+
return "removed";
|
|
12914
|
+
});
|
|
12915
|
+
return isBusy(outcome) ? "busy" : outcome;
|
|
11923
12916
|
}
|
|
11924
|
-
var rowsOf = (batch) => batch.rows.map((row) => ({ ...row }));
|
|
11925
12917
|
/**
|
|
11926
|
-
*
|
|
11927
|
-
*
|
|
12918
|
+
* How long a working job may go without anything changing before it is called stopped.
|
|
12919
|
+
*
|
|
12920
|
+
* This has to clear the longest a healthy job can legitimately sit silent, and that bound is
|
|
12921
|
+
* knowable rather than guessed. `updatedAt` moves when a phase changes and on every progress line
|
|
12922
|
+
* the agent streams; the phases that stream nothing are bounded by their own ceilings, and the
|
|
12923
|
+
* largest of those is the twenty minute analysis timeout. Thirty minutes is that bound with half
|
|
12924
|
+
* again on top, which is why it is not fifteen: a slow review on a large repository would be
|
|
12925
|
+
* declared dead while it was still working.
|
|
12926
|
+
*
|
|
12927
|
+
* The write is conditional on `updatedAt` not having moved since it was read, so a job that is
|
|
12928
|
+
* merely slow and then speaks up cannot be overwritten by a sweep that had already decided.
|
|
11928
12929
|
*/
|
|
12930
|
+
var ABANDONED_AFTER_MS = 18e5;
|
|
11929
12931
|
/**
|
|
11930
|
-
*
|
|
11931
|
-
*
|
|
11932
|
-
*
|
|
12932
|
+
* The reviewer-facing half of that. A job whose driver was killed did not fail: it stopped
|
|
12933
|
+
* updating, which the page had no way to tell from working. This is the sentence that makes the
|
|
12934
|
+
* difference visible, and marking the job failed is what puts Retry on the page.
|
|
11933
12935
|
*/
|
|
11934
|
-
|
|
11935
|
-
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
11940
|
-
|
|
11941
|
-
|
|
12936
|
+
var ABANDONED_MESSAGE = "Nothing has updated it for over half an hour, so whatever was making it is no longer running.";
|
|
12937
|
+
async function sweep(now = Date.now()) {
|
|
12938
|
+
const report = {
|
|
12939
|
+
abandoned: 0,
|
|
12940
|
+
retired: 0,
|
|
12941
|
+
checkoutsRemoved: 0,
|
|
12942
|
+
orphansRemoved: 0,
|
|
12943
|
+
refusals: []
|
|
12944
|
+
};
|
|
12945
|
+
const jobs = allJobs();
|
|
12946
|
+
for (const job of jobs) {
|
|
12947
|
+
if (!isTerminal(job)) {
|
|
12948
|
+
if (markAbandoned(job, now)) report.abandoned += 1;
|
|
12949
|
+
continue;
|
|
12950
|
+
}
|
|
12951
|
+
if (now - endedAt(job) >= 6048e5) {
|
|
12952
|
+
if (await retire(job, report)) report.retired += 1;
|
|
12953
|
+
continue;
|
|
12954
|
+
}
|
|
12955
|
+
await keepRecord(job, now, report);
|
|
12956
|
+
}
|
|
12957
|
+
await removeOrphans(jobs, report);
|
|
12958
|
+
pruneJobResidue(now, ABANDONED_AFTER_MS);
|
|
12959
|
+
pruneNotifications(now);
|
|
12960
|
+
return report;
|
|
11942
12961
|
}
|
|
11943
12962
|
/**
|
|
11944
|
-
*
|
|
11945
|
-
*
|
|
11946
|
-
*
|
|
11947
|
-
*
|
|
12963
|
+
* Record that a job stopped, unless it has moved since this pass read it.
|
|
12964
|
+
*
|
|
12965
|
+
* The reload is the whole of the safety here. Between `allJobs` and this line a driver in another
|
|
12966
|
+
* process can have written a progress line, and saving a failure over that would take a review
|
|
12967
|
+
* that is being made and tell its reviewer it stopped.
|
|
11948
12968
|
*/
|
|
11949
|
-
|
|
11950
|
-
if (
|
|
11951
|
-
if (
|
|
12969
|
+
function markAbandoned(job, now) {
|
|
12970
|
+
if (now - job.updatedAt < 18e5) return false;
|
|
12971
|
+
if (isClaimed(job.id, now)) return false;
|
|
12972
|
+
const current = loadJob(job.id);
|
|
12973
|
+
if (!current || current.updatedAt !== job.updatedAt) return false;
|
|
12974
|
+
const abandoned = fail$1(current, ABANDONED_MESSAGE);
|
|
12975
|
+
saveJob(abandoned);
|
|
12976
|
+
announceJob(abandoned);
|
|
12977
|
+
return true;
|
|
11952
12978
|
}
|
|
11953
|
-
|
|
11954
|
-
|
|
11955
|
-
|
|
11956
|
-
|
|
11957
|
-
|
|
11958
|
-
|
|
11959
|
-
|
|
12979
|
+
/**
|
|
12980
|
+
* A finished record within its week: the checkout goes after six hours, and each earlier attempt
|
|
12981
|
+
* goes when its own week is up. One conditional write for both.
|
|
12982
|
+
*
|
|
12983
|
+
* **Under the review's lock, from the first question to the last write.** A Retry claims its job
|
|
12984
|
+
* under that same lock (`runJob`), so holding it here means no retry can begin while the checkout is
|
|
12985
|
+
* being removed: one asked for meanwhile is refused as busy and can be asked again, rather than
|
|
12986
|
+
* starting in a directory this is deleting. The claim is asked again inside the lock, since one
|
|
12987
|
+
* taken just before it was is a retry already under way, whose checkout and record are its own.
|
|
12988
|
+
* A review the lock cannot be had for is left to the next pass.
|
|
12989
|
+
*
|
|
12990
|
+
* The checkout before the record, as `job_cleanup.ts` orders it: the record names the directory
|
|
12991
|
+
* until the directory is gone. A directory already gone names nothing, which is how a crash between
|
|
12992
|
+
* the two is recovered on the next pass instead of refused on every one.
|
|
12993
|
+
*/
|
|
12994
|
+
async function keepRecord(job, now, report) {
|
|
12995
|
+
const attempts = job.previousAttempts ?? [];
|
|
12996
|
+
const keptAttempts = attempts.filter((attempt) => now - attempt.finishedAt < RUN_RECORD_RETENTION_MS);
|
|
12997
|
+
const checkoutDue = job.worktreePath !== void 0 && now - endedAt(job) >= 216e5;
|
|
12998
|
+
if (!checkoutDue && keptAttempts.length === attempts.length) return;
|
|
12999
|
+
await withReviewLock(job.workspaceId, async () => {
|
|
13000
|
+
if (isClaimed(job.id, now)) return;
|
|
13001
|
+
const before = loadJob(job.id);
|
|
13002
|
+
if (!before || before.updatedAt !== job.updatedAt) return;
|
|
13003
|
+
let releaseCheckout = false;
|
|
13004
|
+
if (checkoutDue && job.worktreePath) {
|
|
13005
|
+
if (existsSync(job.worktreePath)) {
|
|
13006
|
+
const outcome = await removeWorktree({
|
|
13007
|
+
originRepoPath: job.target.originRepoPath,
|
|
13008
|
+
worktreePath: job.worktreePath
|
|
13009
|
+
});
|
|
13010
|
+
if (outcome.removed) releaseCheckout = true;
|
|
13011
|
+
else report.refusals.push(outcome.message);
|
|
13012
|
+
} else releaseCheckout = true;
|
|
13013
|
+
}
|
|
13014
|
+
if (!releaseCheckout && keptAttempts.length === attempts.length) return;
|
|
13015
|
+
const current = loadJob(job.id);
|
|
13016
|
+
if (!current || current.updatedAt !== job.updatedAt) return;
|
|
13017
|
+
const { worktreePath: _removed, ...withoutCheckout } = current;
|
|
13018
|
+
const next = releaseCheckout ? withoutCheckout : current;
|
|
13019
|
+
const present = /* @__PURE__ */ new Set([attemptIdOf(next), ...keptAttempts.map((attempt) => attempt.attemptId)]);
|
|
13020
|
+
saveJob({
|
|
13021
|
+
...next,
|
|
13022
|
+
...attempts.length > 0 ? { previousAttempts: keptAttempts } : {},
|
|
13023
|
+
...next.removedFromRuns ? { removedFromRuns: next.removedFromRuns.filter((id) => present.has(id)) } : {}
|
|
13024
|
+
});
|
|
13025
|
+
if (releaseCheckout) report.checkoutsRemoved += 1;
|
|
13026
|
+
});
|
|
13027
|
+
}
|
|
13028
|
+
/** A finished job, discarded on the shared sequence. See `job_cleanup.ts` for why it is an order. */
|
|
13029
|
+
async function retire(job, report) {
|
|
13030
|
+
const outcome = await discardJob(job);
|
|
13031
|
+
if (outcome.refusal) report.refusals.push(outcome.refusal);
|
|
13032
|
+
return outcome.removed;
|
|
11960
13033
|
}
|
|
11961
13034
|
/**
|
|
11962
|
-
*
|
|
11963
|
-
*
|
|
11964
|
-
*
|
|
11965
|
-
*
|
|
13035
|
+
* Directories under the managed root that no job record names.
|
|
13036
|
+
*
|
|
13037
|
+
* A crash between creating a worktree and saving the record that points at it leaves exactly this.
|
|
13038
|
+
* The origin repository comes from the directory's own marker, because there is nothing else left
|
|
13039
|
+
* to ask, and that does make `removeWorktree`'s marker check tautological on this path. It is not
|
|
13040
|
+
* the guard here and was never meant to be: git is asked first and answers about a real repository,
|
|
13041
|
+
* and where git has disowned the path what remains is the managed root and `ORPHAN_MIN_AGE_MS`,
|
|
13042
|
+
* which is the ordering the removal guard has always had.
|
|
11966
13043
|
*/
|
|
11967
|
-
async function
|
|
11968
|
-
|
|
11969
|
-
|
|
11970
|
-
|
|
11971
|
-
|
|
11972
|
-
|
|
13044
|
+
async function removeOrphans(jobs, report) {
|
|
13045
|
+
const claimed = new Set(jobs.map((job) => job.worktreePath).filter((path) => !!path));
|
|
13046
|
+
let names;
|
|
13047
|
+
try {
|
|
13048
|
+
names = readdirSync(MANAGED_ROOT);
|
|
13049
|
+
} catch {
|
|
13050
|
+
return;
|
|
13051
|
+
}
|
|
13052
|
+
for (const name of names) {
|
|
13053
|
+
const path = join(MANAGED_ROOT, name);
|
|
13054
|
+
if (claimed.has(path) || isWorktreeHeld(path)) continue;
|
|
13055
|
+
try {
|
|
13056
|
+
if (!statSync(path).isDirectory()) continue;
|
|
13057
|
+
} catch {
|
|
13058
|
+
continue;
|
|
13059
|
+
}
|
|
13060
|
+
const marker = readMarker(path);
|
|
13061
|
+
if (!marker) continue;
|
|
13062
|
+
const outcome = await removeWorktree({
|
|
13063
|
+
originRepoPath: marker.originRepoPath,
|
|
13064
|
+
worktreePath: path
|
|
13065
|
+
});
|
|
13066
|
+
if (outcome.removed) report.orphansRemoved += 1;
|
|
13067
|
+
else if (outcome.reason !== "too-young") report.refusals.push(outcome.message);
|
|
13068
|
+
}
|
|
11973
13069
|
}
|
|
11974
13070
|
/**
|
|
11975
|
-
*
|
|
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.
|
|
13071
|
+
* How often the pass runs.
|
|
11978
13072
|
*
|
|
11979
|
-
*
|
|
11980
|
-
*
|
|
13073
|
+
* Chosen from what it costs to be late rather than what it costs to run. A sweep is a directory
|
|
13074
|
+
* listing and a `git worktree list` per finished job, so ten minutes is nothing; being an hour
|
|
13075
|
+
* late telling a reviewer their review stopped is not. Ten minutes puts that answer between thirty
|
|
13076
|
+
* and forty minutes after the last sign of life.
|
|
11981
13077
|
*/
|
|
11982
|
-
|
|
11983
|
-
|
|
11984
|
-
|
|
11985
|
-
|
|
11986
|
-
|
|
11987
|
-
|
|
11988
|
-
|
|
13078
|
+
var SWEEP_INTERVAL_MS = 6e5;
|
|
13079
|
+
/** Where the last pass says how it went. */
|
|
13080
|
+
var SWEEP_STATUS_PATH = join(STORE_ROOT, "sweep.json");
|
|
13081
|
+
/** One pass, with its outcome written down whatever it was. Never throws. */
|
|
13082
|
+
async function runSweep(now = Date.now()) {
|
|
13083
|
+
let status;
|
|
13084
|
+
try {
|
|
13085
|
+
status = {
|
|
13086
|
+
at: now,
|
|
13087
|
+
refusals: (await sweep(now)).refusals
|
|
13088
|
+
};
|
|
13089
|
+
} catch (error) {
|
|
13090
|
+
status = {
|
|
13091
|
+
at: now,
|
|
13092
|
+
refusals: [],
|
|
13093
|
+
error: error instanceof Error ? error.message : String(error)
|
|
13094
|
+
};
|
|
11989
13095
|
}
|
|
11990
|
-
|
|
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;
|
|
13096
|
+
writeJsonAtomically(SWEEP_STATUS_PATH, status);
|
|
13097
|
+
return status;
|
|
11999
13098
|
}
|
|
12000
|
-
function
|
|
12001
|
-
|
|
12002
|
-
|
|
12003
|
-
|
|
12004
|
-
|
|
13099
|
+
function lastSweep() {
|
|
13100
|
+
try {
|
|
13101
|
+
const parsed = JSON.parse(readFileSync(SWEEP_STATUS_PATH, "utf8"));
|
|
13102
|
+
if (typeof parsed.at !== "number" || !Array.isArray(parsed.refusals)) return null;
|
|
13103
|
+
return {
|
|
13104
|
+
at: parsed.at,
|
|
13105
|
+
refusals: parsed.refusals.filter((line) => typeof line === "string"),
|
|
13106
|
+
...typeof parsed.error === "string" ? { error: parsed.error } : {}
|
|
13107
|
+
};
|
|
13108
|
+
} catch {
|
|
13109
|
+
return null;
|
|
13110
|
+
}
|
|
12005
13111
|
}
|
|
12006
13112
|
/**
|
|
12007
|
-
*
|
|
12008
|
-
* dialog: it may watch the batch while it stays open, and the reviews appear in the list either way.
|
|
13113
|
+
* Run the pass on a timer, and return the stop.
|
|
12009
13114
|
*
|
|
12010
|
-
*
|
|
12011
|
-
*
|
|
13115
|
+
* Once immediately, because a server that has just started is very often one that restarted after
|
|
13116
|
+
* whatever killed the last one, and the jobs that died with it are exactly what this finds.
|
|
13117
|
+
*
|
|
13118
|
+
* Nothing here throws outward. A sweep that fails is disk it did not reclaim, and the server it
|
|
13119
|
+
* runs inside is serving reviews, which matters more.
|
|
12012
13120
|
*/
|
|
12013
|
-
|
|
12014
|
-
const
|
|
12015
|
-
|
|
12016
|
-
|
|
12017
|
-
|
|
12018
|
-
|
|
12019
|
-
|
|
12020
|
-
|
|
12021
|
-
|
|
12022
|
-
|
|
12023
|
-
|
|
12024
|
-
|
|
12025
|
-
|
|
12026
|
-
|
|
12027
|
-
|
|
12028
|
-
|
|
12029
|
-
|
|
12030
|
-
|
|
12031
|
-
|
|
13121
|
+
function startSweeping() {
|
|
13122
|
+
const run = () => {
|
|
13123
|
+
runSweep();
|
|
13124
|
+
};
|
|
13125
|
+
run();
|
|
13126
|
+
const timer = setInterval(run, SWEEP_INTERVAL_MS);
|
|
13127
|
+
timer.unref();
|
|
13128
|
+
return () => clearInterval(timer);
|
|
13129
|
+
}
|
|
13130
|
+
function asKind(value) {
|
|
13131
|
+
return value === "issue" || value === "question" ? value : null;
|
|
13132
|
+
}
|
|
13133
|
+
/** What the page sent, or null when it is not a request a host understands. */
|
|
13134
|
+
function parseReassessmentRequest(value) {
|
|
13135
|
+
const body = value;
|
|
13136
|
+
if (body?.op === "decide") {
|
|
13137
|
+
if (typeof body.proposalId !== "string" || body.proposalId === "") return null;
|
|
13138
|
+
if (body.decision !== "accepted" && body.decision !== "declined") return null;
|
|
13139
|
+
return {
|
|
13140
|
+
op: "decide",
|
|
13141
|
+
proposalId: body.proposalId,
|
|
13142
|
+
decision: body.decision
|
|
13143
|
+
};
|
|
12032
13144
|
}
|
|
12033
|
-
|
|
12034
|
-
|
|
12035
|
-
|
|
12036
|
-
|
|
12037
|
-
|
|
12038
|
-
|
|
12039
|
-
|
|
12040
|
-
|
|
12041
|
-
|
|
12042
|
-
|
|
12043
|
-
|
|
12044
|
-
|
|
12045
|
-
|
|
12046
|
-
|
|
12047
|
-
|
|
12048
|
-
|
|
12049
|
-
|
|
12050
|
-
|
|
12051
|
-
|
|
12052
|
-
|
|
12053
|
-
|
|
12054
|
-
|
|
12055
|
-
|
|
12056
|
-
|
|
12057
|
-
|
|
12058
|
-
|
|
12059
|
-
|
|
12060
|
-
|
|
12061
|
-
|
|
12062
|
-
|
|
12063
|
-
|
|
12064
|
-
|
|
12065
|
-
|
|
12066
|
-
|
|
12067
|
-
|
|
12068
|
-
|
|
12069
|
-
if (first !== void 0 && first !== index) {
|
|
12070
|
-
row.status = "already-reviewed";
|
|
12071
|
-
row.reason = `Same pull request as ${batch.rows[first].label}`;
|
|
12072
|
-
return;
|
|
12073
|
-
}
|
|
12074
|
-
claimed.set(identity, index);
|
|
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);
|
|
12081
|
-
} catch (error) {
|
|
12082
|
-
fail(row, error);
|
|
12083
|
-
} finally {
|
|
12084
|
-
if (fresh) keepFreshPending(row, replaced);
|
|
12085
|
-
release();
|
|
12086
|
-
}
|
|
13145
|
+
if (body?.op === "set") {
|
|
13146
|
+
const kind = asKind(body.kind);
|
|
13147
|
+
if (!kind || typeof body.id !== "string" || body.id === "") return null;
|
|
13148
|
+
if (!isGradeFor(kind, body.to)) return null;
|
|
13149
|
+
if (body.from !== null && !isGradeFor(kind, body.from)) return null;
|
|
13150
|
+
if (body.reason !== void 0 && typeof body.reason !== "string") return null;
|
|
13151
|
+
const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 500) : "";
|
|
13152
|
+
return {
|
|
13153
|
+
op: "set",
|
|
13154
|
+
kind,
|
|
13155
|
+
findingId: body.id,
|
|
13156
|
+
from: body.from,
|
|
13157
|
+
to: body.to,
|
|
13158
|
+
...reason ? { reason } : {}
|
|
13159
|
+
};
|
|
13160
|
+
}
|
|
13161
|
+
return null;
|
|
13162
|
+
}
|
|
13163
|
+
var BAD_REASSESSMENT_REQUEST = "A reassessment either moves a finding to a grade on its scale, or accepts or declines a suggestion.";
|
|
13164
|
+
var REASSESSMENT_REFUSED = {
|
|
13165
|
+
"not-a-grade": "That is not a grade this finding can have.",
|
|
13166
|
+
unchanged: "The finding already has that grade.",
|
|
13167
|
+
stale: "The grade has changed since this page was drawn. The card now shows it as it stands.",
|
|
13168
|
+
outdated: "That suggestion was made against an earlier version of this review. Reassess again to judge it as it stands.",
|
|
13169
|
+
"no-reason": "A suggestion needs a reason.",
|
|
13170
|
+
unknown: "This review has no suggestion with that id.",
|
|
13171
|
+
decided: "That suggestion has already been answered.",
|
|
13172
|
+
"no-finding": "This review has no such finding."
|
|
13173
|
+
};
|
|
13174
|
+
function gradeFragments$1(session, kind, id) {
|
|
13175
|
+
const finding = findingIn(session, kind, id);
|
|
13176
|
+
if (!finding) return null;
|
|
13177
|
+
const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
|
|
13178
|
+
const options = {
|
|
13179
|
+
editable: true,
|
|
13180
|
+
askMore: true
|
|
12087
13181
|
};
|
|
12088
|
-
batch.rerun = (index, fresh) => follow(batch, lane(items[index], index, fresh), deps);
|
|
12089
|
-
follow(batch, runBatch(items, {
|
|
12090
|
-
concurrency: deps.concurrency,
|
|
12091
|
-
run: lane
|
|
12092
|
-
}), deps);
|
|
12093
13182
|
return {
|
|
12094
|
-
|
|
12095
|
-
|
|
12096
|
-
|
|
12097
|
-
|
|
12098
|
-
|
|
13183
|
+
kind,
|
|
13184
|
+
id,
|
|
13185
|
+
current: assessment.current,
|
|
13186
|
+
cardClass: cardGradeClass(kind, assessment.current),
|
|
13187
|
+
grade: renderGradeControl(kind, assessment, options),
|
|
13188
|
+
region: renderAssessmentRegion(kind, assessment, options),
|
|
13189
|
+
copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
|
|
12099
13190
|
};
|
|
12100
13191
|
}
|
|
12101
|
-
/**
|
|
12102
|
-
|
|
12103
|
-
|
|
12104
|
-
|
|
12105
|
-
|
|
12106
|
-
const batch = batches.get(id);
|
|
12107
|
-
if (!batch) return lostBatch();
|
|
12108
|
-
batch.seenAt = Date.now();
|
|
12109
|
-
return {
|
|
12110
|
-
status: 200,
|
|
12111
|
-
body: {
|
|
12112
|
-
rows: rowsOf(batch),
|
|
12113
|
-
finished: batch.finishedAt !== null
|
|
12114
|
-
}
|
|
13192
|
+
/** The finding a request is about: named outright, or through the proposal it decides. */
|
|
13193
|
+
function findingOfRequest(session, request) {
|
|
13194
|
+
if (request.op === "set") return {
|
|
13195
|
+
kind: request.kind,
|
|
13196
|
+
id: request.findingId
|
|
12115
13197
|
};
|
|
13198
|
+
const proposal = session.findingReassessments?.find((entry) => entry.id === request.proposalId);
|
|
13199
|
+
return proposal ? {
|
|
13200
|
+
kind: proposal.kind,
|
|
13201
|
+
id: proposal.findingId
|
|
13202
|
+
} : null;
|
|
12116
13203
|
}
|
|
12117
|
-
/**
|
|
12118
|
-
|
|
12119
|
-
|
|
12120
|
-
|
|
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);
|
|
13204
|
+
/** A refusal, with the card as the session has it so the page is brought up to date by the answer. */
|
|
13205
|
+
function refusedReply(session, request, refusal) {
|
|
13206
|
+
const finding = findingOfRequest(session, request);
|
|
13207
|
+
const card = finding ? gradeFragments$1(session, finding.kind, finding.id) : null;
|
|
12143
13208
|
return {
|
|
12144
|
-
status:
|
|
13209
|
+
status: refusal === "stale" || refusal === "outdated" || refusal === "decided" ? 409 : 400,
|
|
12145
13210
|
body: {
|
|
12146
|
-
|
|
12147
|
-
|
|
13211
|
+
error: REASSESSMENT_REFUSED[refusal],
|
|
13212
|
+
refusal,
|
|
13213
|
+
...card ? { card } : {}
|
|
12148
13214
|
}
|
|
12149
13215
|
};
|
|
12150
13216
|
}
|
|
12151
|
-
|
|
12152
|
-
|
|
12153
|
-
return {
|
|
12154
|
-
status: 200,
|
|
12155
|
-
body: { folders: (await deps.folders()).map((folder) => ({
|
|
12156
|
-
...folderOption(folder, deps.home),
|
|
12157
|
-
repo: folder.remote ? folder.remote.repo : basename(folder.path)
|
|
12158
|
-
})) }
|
|
12159
|
-
};
|
|
12160
|
-
}
|
|
13217
|
+
//#endregion
|
|
13218
|
+
//#region ../../packages/review-harness/src/workspace/reassessment_state.ts
|
|
12161
13219
|
/**
|
|
12162
|
-
*
|
|
12163
|
-
* `prreviewbuddy` here would resolve, and the review it would find.
|
|
13220
|
+
* The reviewer's grade moves and their answers to proposals, as `/state` receives them.
|
|
12164
13221
|
*
|
|
12165
|
-
*
|
|
12166
|
-
*
|
|
12167
|
-
*
|
|
13222
|
+
* The request, its refusals and the card parts a reply carries are shared with the extension
|
|
13223
|
+
* (domain/ui/components/reassessment_reply.ts). What is the workspace's own is the store: checked
|
|
13224
|
+
* against a fresh read before writing, so a refusal leaves the file alone, and checked again inside
|
|
13225
|
+
* the write, where the answer is final.
|
|
12168
13226
|
*/
|
|
12169
|
-
|
|
12170
|
-
|
|
12171
|
-
|
|
12172
|
-
|
|
12173
|
-
|
|
12174
|
-
|
|
12175
|
-
|
|
12176
|
-
|
|
13227
|
+
/** A stored review's card parts for one finding. */
|
|
13228
|
+
function gradeFragments(workspace, kind, id) {
|
|
13229
|
+
return gradeFragments$1(workspace.session, kind, id);
|
|
13230
|
+
}
|
|
13231
|
+
/**
|
|
13232
|
+
* One write, onto the review as it is now, answered with the card as it now stands. `now` is
|
|
13233
|
+
* injectable so a test can name the time an entry records.
|
|
13234
|
+
*/
|
|
13235
|
+
function applyReassessmentRequest(workspaceId, value, now = Date.now) {
|
|
13236
|
+
const request = parseReassessmentRequest(value);
|
|
13237
|
+
if (!request) return {
|
|
13238
|
+
status: 400,
|
|
13239
|
+
body: { error: BAD_REASSESSMENT_REQUEST }
|
|
12177
13240
|
};
|
|
12178
|
-
const
|
|
12179
|
-
|
|
12180
|
-
|
|
12181
|
-
|
|
12182
|
-
}
|
|
12183
|
-
|
|
12184
|
-
|
|
12185
|
-
|
|
12186
|
-
|
|
12187
|
-
|
|
12188
|
-
|
|
12189
|
-
|
|
12190
|
-
|
|
12191
|
-
|
|
12192
|
-
|
|
12193
|
-
|
|
12194
|
-
|
|
12195
|
-
|
|
12196
|
-
const kept = existing ? deps.resumeOf(existing.id) : null;
|
|
13241
|
+
const before = loadWorkspace(workspaceId);
|
|
13242
|
+
if (!before) return {
|
|
13243
|
+
status: 404,
|
|
13244
|
+
body: { error: "No such review" }
|
|
13245
|
+
};
|
|
13246
|
+
const early = reassessIn(structuredClone(before.session), request, now());
|
|
13247
|
+
if (!early.ok) return refusedReply(before.session, request, early.refusal);
|
|
13248
|
+
let outcome = null;
|
|
13249
|
+
const saved = mutateWorkspace(workspaceId, (current) => {
|
|
13250
|
+
outcome = reassessIn(current.session, request, now());
|
|
13251
|
+
});
|
|
13252
|
+
if (!saved || !outcome) return {
|
|
13253
|
+
status: 404,
|
|
13254
|
+
body: { error: "No such review" }
|
|
13255
|
+
};
|
|
13256
|
+
const written = outcome;
|
|
13257
|
+
if (!written.ok) return refusedReply(saved.session, request, written.refusal);
|
|
13258
|
+
const card = gradeFragments(saved, written.entry.kind, written.entry.findingId);
|
|
12197
13259
|
return {
|
|
12198
13260
|
status: 200,
|
|
12199
13261
|
body: {
|
|
12200
13262
|
ok: true,
|
|
12201
|
-
|
|
12202
|
-
|
|
12203
|
-
repo: remote ? remote.repo : basename(target.originRepoPath),
|
|
12204
|
-
branch: target.branch,
|
|
12205
|
-
base,
|
|
12206
|
-
...existing ? { reviewed: {
|
|
12207
|
-
reviewId: existing.id,
|
|
12208
|
-
inProgress: existing.inProgress,
|
|
12209
|
-
...kept ? { kept } : {}
|
|
12210
|
-
} } : {}
|
|
13263
|
+
entry: written.entry,
|
|
13264
|
+
...card ? { card } : {}
|
|
12211
13265
|
}
|
|
12212
13266
|
};
|
|
12213
13267
|
}
|
|
12214
|
-
|
|
12215
|
-
|
|
12216
|
-
|
|
12217
|
-
|
|
12218
|
-
|
|
12219
|
-
|
|
12220
|
-
|
|
12221
|
-
|
|
12222
|
-
|
|
12223
|
-
|
|
12224
|
-
|
|
12225
|
-
|
|
12226
|
-
|
|
12227
|
-
|
|
12228
|
-
|
|
12229
|
-
|
|
12230
|
-
const model = modelOf(body?.model);
|
|
12231
|
-
if (isAnswer(model)) return model;
|
|
12232
|
-
let target;
|
|
12233
|
-
try {
|
|
12234
|
-
target = await deps.resolveBranch(path, base);
|
|
12235
|
-
} catch (error) {
|
|
12236
|
-
return {
|
|
12237
|
-
status: 400,
|
|
12238
|
-
body: refusalOf(error)
|
|
12239
|
-
};
|
|
13268
|
+
//#endregion
|
|
13269
|
+
//#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
|
|
13270
|
+
/** More than any real answer cites; a list longer than this is padding, not evidence. */
|
|
13271
|
+
var MAX_EVIDENCE = 10;
|
|
13272
|
+
function scaleText(kind) {
|
|
13273
|
+
return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
|
|
13274
|
+
}
|
|
13275
|
+
/** What the grade means, in the words the analysis prompt uses for it. */
|
|
13276
|
+
function gradeMeaning(kind) {
|
|
13277
|
+
return kind === "issue" ? "Severity is how much the issue would matter if it is real. It is not how sure anyone is that it is real." : "Importance is how much of the reviewer's attention the question deserves before they can approve.";
|
|
13278
|
+
}
|
|
13279
|
+
function findingText(kind, finding) {
|
|
13280
|
+
if (kind === "issue") {
|
|
13281
|
+
const issue = finding;
|
|
13282
|
+
const where = issue.file ? `\nLocation: ${issue.file}${issue.line ? `:${issue.line}` : ""}` : "";
|
|
13283
|
+
return `Issue: ${issue.title.trim()}\n${issue.description.trim()}${where}`;
|
|
12240
13284
|
}
|
|
12241
|
-
const
|
|
12242
|
-
|
|
12243
|
-
|
|
12244
|
-
|
|
12245
|
-
|
|
12246
|
-
|
|
12247
|
-
};
|
|
12248
|
-
|
|
12249
|
-
|
|
12250
|
-
|
|
12251
|
-
|
|
12252
|
-
|
|
12253
|
-
|
|
12254
|
-
|
|
12255
|
-
|
|
12256
|
-
|
|
12257
|
-
|
|
12258
|
-
|
|
12259
|
-
|
|
12260
|
-
|
|
12261
|
-
|
|
12262
|
-
|
|
12263
|
-
|
|
12264
|
-
|
|
12265
|
-
|
|
12266
|
-
|
|
12267
|
-
|
|
12268
|
-
|
|
12269
|
-
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
|
|
12274
|
-
|
|
12275
|
-
|
|
12276
|
-
|
|
12277
|
-
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
|
|
12281
|
-
|
|
12282
|
-
|
|
12283
|
-
|
|
12284
|
-
|
|
12285
|
-
|
|
12286
|
-
const
|
|
12287
|
-
|
|
12288
|
-
|
|
12289
|
-
|
|
12290
|
-
|
|
12291
|
-
|
|
12292
|
-
}
|
|
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");
|
|
13285
|
+
const question = finding;
|
|
13286
|
+
const why = question.whyThisMatters?.trim() ? `\nWhy it matters: ${question.whyThisMatters.trim()}` : "";
|
|
13287
|
+
return `Question: ${question.question.trim()}${why}${question.file ? `\nFile: ${question.file}` : ""}`;
|
|
13288
|
+
}
|
|
13289
|
+
function historyText(assessment) {
|
|
13290
|
+
if (!assessment.history.length) return "It has not been reassessed before.";
|
|
13291
|
+
return ["Earlier moves of this grade, oldest first:", ...assessment.history.map((step) => `- ${step.from ?? "ungraded"} → ${step.to}, ${sourcePhrase(step.source)}${step.reason ? `: ${step.reason}` : ""}`)].join("\n");
|
|
13292
|
+
}
|
|
13293
|
+
function threadText(thread) {
|
|
13294
|
+
if (!thread) return [];
|
|
13295
|
+
const comments = thread.comments.filter((comment) => !comment.authorIsBot && comment.body.trim());
|
|
13296
|
+
if (!comments.length) return [];
|
|
13297
|
+
return [`THE PULL REQUEST THREAD THIS FINDING WAS TIED TO (${thread.resolved ? "resolved" : "unresolved"}):`, ...comments.map((comment) => `@${comment.author}: ${comment.body.trim()}`)];
|
|
13298
|
+
}
|
|
13299
|
+
/** The block a Reassess turn adds to the ordinary Buddy prompt. */
|
|
13300
|
+
function buildReassessInstructions(input) {
|
|
13301
|
+
const { kind, finding, assessment } = input;
|
|
13302
|
+
const noun = kind === "issue" ? "severity" : "importance";
|
|
13303
|
+
const current = assessment.current;
|
|
13304
|
+
return [
|
|
13305
|
+
"---",
|
|
13306
|
+
`THE REVIEWER ASKED YOU TO REASSESS THE ${noun.toUpperCase()} OF THIS ${kind.toUpperCase()}:`,
|
|
13307
|
+
findingText(kind, finding),
|
|
13308
|
+
`Its ${noun} now: ${promptGrade(assessment)}.`,
|
|
13309
|
+
historyText(assessment),
|
|
13310
|
+
...input.explanation?.trim() ? ["An explanation of it you gave earlier:", input.explanation.trim()] : [],
|
|
13311
|
+
...threadText(input.thread),
|
|
13312
|
+
"---",
|
|
13313
|
+
`Judge whether its ${noun} should change, using the code in the checkout and everything above, including this conversation. ${gradeMeaning(kind)} Read the code that decides it rather than reasoning from the finding's wording.`,
|
|
13314
|
+
`Raising it is as legitimate as lowering it. "keep" is a good answer, and the right one whenever the evidence does not clearly move it; do not change it because you were asked.`,
|
|
13315
|
+
"Reply with a single JSON object and nothing else: no prose before or after it, no Markdown fence. Exactly this shape:",
|
|
13316
|
+
`{ "verdict": "keep" | "change", "to"?: ${scaleText(kind)}, "reason": string, "evidence"?: [{ "file": string, "line"?: number }] }`,
|
|
13317
|
+
`"to" is the new ${noun}, given only with "change", and must differ from ${current ? `"${current}"` : "the current one"}.`,
|
|
13318
|
+
"\"reason\" is one to three sentences the reviewer will read on the card: name what in the code or the conversation moves it, or what leaves it where it is.",
|
|
13319
|
+
"\"evidence\" is the places in the code that settle it, as paths relative to the repository root. Omit it rather than citing a file you did not read."
|
|
13320
|
+
];
|
|
13321
|
+
}
|
|
13322
|
+
var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
|
|
13323
|
+
function readEvidence(value) {
|
|
13324
|
+
if (!Array.isArray(value)) return [];
|
|
13325
|
+
const out = [];
|
|
13326
|
+
for (const item of value) {
|
|
13327
|
+
const entry = item;
|
|
13328
|
+
const file = typeof entry?.file === "string" ? entry.file.trim().replace(/^\.\//, "") : "";
|
|
13329
|
+
if (!file) continue;
|
|
13330
|
+
const line = typeof entry?.line === "number" && Number.isInteger(entry.line) && entry.line > 0 ? entry.line : void 0;
|
|
13331
|
+
out.push(line === void 0 ? { file } : {
|
|
13332
|
+
file,
|
|
13333
|
+
line
|
|
13334
|
+
});
|
|
13335
|
+
if (out.length === MAX_EVIDENCE) break;
|
|
12299
13336
|
}
|
|
12300
|
-
|
|
13337
|
+
return out;
|
|
13338
|
+
}
|
|
13339
|
+
/**
|
|
13340
|
+
* The answer, or null when it cannot be read as one. Strict where it matters: a `change` must name a
|
|
13341
|
+
* grade on this finding's scale that differs from the current one, and every answer must give a
|
|
13342
|
+
* reason. A thin or confused answer is not repaired into a suggestion; the turn says it could not
|
|
13343
|
+
* use it, and nothing is proposed.
|
|
13344
|
+
*/
|
|
13345
|
+
function parseReassessAnswer(raw, kind, current) {
|
|
13346
|
+
let parsed;
|
|
12301
13347
|
try {
|
|
12302
|
-
|
|
12303
|
-
} catch
|
|
12304
|
-
|
|
12305
|
-
batches.delete(batch.id);
|
|
12306
|
-
return {
|
|
12307
|
-
status: 409,
|
|
12308
|
-
body: refusalOf(error)
|
|
12309
|
-
};
|
|
13348
|
+
parsed = JSON.parse(extractJsonText(raw, REASSESS_ANSWER));
|
|
13349
|
+
} catch {
|
|
13350
|
+
return null;
|
|
12310
13351
|
}
|
|
12311
|
-
if (
|
|
12312
|
-
|
|
12313
|
-
|
|
12314
|
-
|
|
12315
|
-
|
|
12316
|
-
|
|
12317
|
-
|
|
12318
|
-
|
|
12319
|
-
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
|
|
12323
|
-
return
|
|
13352
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
13353
|
+
const obj = parsed;
|
|
13354
|
+
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
|
|
13355
|
+
if (!reason) return null;
|
|
13356
|
+
const evidence = readEvidence(obj.evidence);
|
|
13357
|
+
if (obj.verdict === "keep") return {
|
|
13358
|
+
verdict: "keep",
|
|
13359
|
+
reason,
|
|
13360
|
+
evidence
|
|
13361
|
+
};
|
|
13362
|
+
if (obj.verdict !== "change") return null;
|
|
13363
|
+
if (!isGradeFor(kind, obj.to) || obj.to === current) return null;
|
|
13364
|
+
return {
|
|
13365
|
+
verdict: "change",
|
|
13366
|
+
to: obj.to,
|
|
13367
|
+
reason,
|
|
13368
|
+
evidence
|
|
13369
|
+
};
|
|
12324
13370
|
}
|
|
12325
|
-
|
|
12326
|
-
|
|
13371
|
+
//#endregion
|
|
13372
|
+
//#region ../../packages/review-harness/src/workspace/reassess_turn.ts
|
|
13373
|
+
/**
|
|
13374
|
+
* The answer, with every cited place checked against the commit the review describes. A path that
|
|
13375
|
+
* is not there is dropped and the suggestion kept: the judgement may stand even when the model
|
|
13376
|
+
* misremembered where it read it, and a citation that leads nowhere is worse than none. If the check
|
|
13377
|
+
* itself fails, no evidence is kept rather than unchecked evidence.
|
|
13378
|
+
*
|
|
13379
|
+
* Kept, but not disguised: how many were dropped is recorded, so the card says the suggestion has
|
|
13380
|
+
* no verified code evidence rather than drawing it like one whose citations held. Acceptance does
|
|
13381
|
+
* not depend on it; a reason may be sound without a citation.
|
|
13382
|
+
*/
|
|
13383
|
+
async function readReassessTurn(raw, target, where, check = existingPaths) {
|
|
13384
|
+
const answer = parseReassessAnswer(raw, target.kind, target.from);
|
|
13385
|
+
if (!answer || !answer.evidence.length) return {
|
|
13386
|
+
answer,
|
|
13387
|
+
raw,
|
|
13388
|
+
droppedEvidence: 0
|
|
13389
|
+
};
|
|
13390
|
+
let real;
|
|
12327
13391
|
try {
|
|
12328
|
-
|
|
12329
|
-
|
|
12330
|
-
|
|
12331
|
-
body: path ? { path } : { cancelled: true }
|
|
12332
|
-
};
|
|
12333
|
-
} catch (error) {
|
|
12334
|
-
return refuse(500, message(error));
|
|
13392
|
+
real = await check(where.repoPath, [...new Set(answer.evidence.map((place) => place.file))], where.commitSha);
|
|
13393
|
+
} catch {
|
|
13394
|
+
real = /* @__PURE__ */ new Set();
|
|
12335
13395
|
}
|
|
13396
|
+
const evidence = answer.evidence.filter((place) => real.has(place.file));
|
|
13397
|
+
return {
|
|
13398
|
+
answer: {
|
|
13399
|
+
...answer,
|
|
13400
|
+
evidence
|
|
13401
|
+
},
|
|
13402
|
+
raw,
|
|
13403
|
+
droppedEvidence: answer.evidence.length - evidence.length
|
|
13404
|
+
};
|
|
13405
|
+
}
|
|
13406
|
+
/**
|
|
13407
|
+
* Writes the suggestion onto the session, when there is one to write. Run inside the store write on
|
|
13408
|
+
* the fresh copy: the grade is checked against what is on disk now, so a move the reviewer made
|
|
13409
|
+
* while Buddy AI was answering makes the suggestion out of date rather than letting it land on a
|
|
13410
|
+
* grade it never saw.
|
|
13411
|
+
*/
|
|
13412
|
+
function keepReassessTurn(session, target, turn, askedAt) {
|
|
13413
|
+
if (!turn.answer) return "unreadable";
|
|
13414
|
+
if (turn.answer.verdict === "keep" || !turn.answer.to) return "kept";
|
|
13415
|
+
return reassessIn(session, {
|
|
13416
|
+
op: "propose",
|
|
13417
|
+
kind: target.kind,
|
|
13418
|
+
findingId: target.id,
|
|
13419
|
+
from: target.from,
|
|
13420
|
+
to: turn.answer.to,
|
|
13421
|
+
reason: turn.answer.reason,
|
|
13422
|
+
...turn.answer.evidence.length ? { evidence: turn.answer.evidence } : {},
|
|
13423
|
+
...turn.droppedEvidence ? { droppedEvidence: turn.droppedEvidence } : {},
|
|
13424
|
+
source: {
|
|
13425
|
+
kind: "buddy",
|
|
13426
|
+
askedAt
|
|
13427
|
+
}
|
|
13428
|
+
}, askedAt).ok ? "proposed" : "stale";
|
|
13429
|
+
}
|
|
13430
|
+
/** What the conversation shows for the turn, in words, never the JSON. */
|
|
13431
|
+
function reassessTurnText(target, turn, outcome) {
|
|
13432
|
+
const from = target.from ?? "ungraded";
|
|
13433
|
+
const answer = turn.answer;
|
|
13434
|
+
if (!answer) return `${turn.raw.trim()}\n\n*This answer was not in the form a suggestion needs, so nothing was suggested and the grade is unchanged.*`;
|
|
13435
|
+
if (answer.verdict === "keep") return `**Keep ${from}.** ${answer.reason}`;
|
|
13436
|
+
const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
|
|
13437
|
+
return outcome === "stale" ? `${move}\n\n*The grade changed while this was being answered, so the suggestion was not kept. Reassess again to judge it as it stands.*` : `${move}\n\n*Accept or keep it on the card.*`;
|
|
12336
13438
|
}
|
|
12337
13439
|
//#endregion
|
|
12338
13440
|
//#region ../../packages/review-harness/src/workspace/ask.ts
|
|
@@ -13485,6 +14587,10 @@ async function handle(req, res, options) {
|
|
|
13485
14587
|
if (url.pathname === "/open") return handleOpen(req, res, url.searchParams, options.indexToken);
|
|
13486
14588
|
if (url.pathname === "/settings") return void handleSettings(req, res, url.searchParams, options.indexToken);
|
|
13487
14589
|
if (url.pathname === "/notifications") return void handleNotifications(req, res, url.searchParams, options.indexToken);
|
|
14590
|
+
if (url.pathname === "/runs") return handleRuns(req, res, url.searchParams, options.indexToken);
|
|
14591
|
+
if (url.pathname === "/runs/revision") return handleRunsRevision(req, res, options.indexToken);
|
|
14592
|
+
if (url.pathname === "/runs/remove") return void handleRunRemoval(req, res, options.indexToken);
|
|
14593
|
+
if (url.pathname === "/runs/sections") return void handleRunsSections(req, res, options.indexToken);
|
|
13488
14594
|
if (url.pathname === "/help") return void handleHelp(req, res, url.searchParams, options.indexToken);
|
|
13489
14595
|
if (url.pathname === "/notifications/open") return handleNotificationOpen(req, res, url.searchParams, options.indexToken);
|
|
13490
14596
|
if (url.pathname === "/notifications/recent") return handleRecentNotifications(req, res, url.searchParams, options.indexToken);
|
|
@@ -13592,6 +14698,7 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
13592
14698
|
const cookie = indexCookieHeader(indexToken);
|
|
13593
14699
|
if (onward) return redirect(res, onward, cookie);
|
|
13594
14700
|
const states = analysisStates();
|
|
14701
|
+
const operations = liveOperations();
|
|
13595
14702
|
const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
|
|
13596
14703
|
const everyGroup = groupByLineage(reviews);
|
|
13597
14704
|
const queue = settleQueue(everyGroup);
|
|
@@ -13603,10 +14710,11 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
13603
14710
|
groups,
|
|
13604
14711
|
queue: queue.filter((id) => rendered.has(id)),
|
|
13605
14712
|
analyses: states,
|
|
14713
|
+
operations,
|
|
13606
14714
|
moved: movedReviews(),
|
|
13607
14715
|
prStates: prStatesByRequest(),
|
|
13608
14716
|
activity,
|
|
13609
|
-
revision: reviewsRevision(states),
|
|
14717
|
+
revision: reviewsRevision(states, operations),
|
|
13610
14718
|
unreadNotifications: unreadCount$1(),
|
|
13611
14719
|
driving: isDriving(),
|
|
13612
14720
|
theme: readPreferences().theme,
|
|
@@ -13831,6 +14939,55 @@ async function handleNotifications(req, res, params, indexToken) {
|
|
|
13831
14939
|
}), indexCookieHeader(indexToken));
|
|
13832
14940
|
}
|
|
13833
14941
|
/**
|
|
14942
|
+
* The Runs page. One reading of the jobs directory feeds both the markup and the revision it is
|
|
14943
|
+
* compared against, for the reason `handleIndex` reads once.
|
|
14944
|
+
*/
|
|
14945
|
+
function handleRuns(req, res, params, indexToken) {
|
|
14946
|
+
if (!indexAuthorised(req, params, indexToken)) return refuseIndex(req, res);
|
|
14947
|
+
const runs = liveRuns();
|
|
14948
|
+
const cleanup = lastSweep();
|
|
14949
|
+
const reviews = new Map(recentWorkspaces(Number.MAX_SAFE_INTEGER).map((review) => [review.id, review]));
|
|
14950
|
+
const preferences = readPreferences();
|
|
14951
|
+
sendHtml(res, 200, renderRunsPage({
|
|
14952
|
+
runs,
|
|
14953
|
+
reviews,
|
|
14954
|
+
cleanup,
|
|
14955
|
+
revision: runsRevision(runs, cleanup),
|
|
14956
|
+
unreadNotifications: unreadCount$1(),
|
|
14957
|
+
driving: isDriving(),
|
|
14958
|
+
theme: preferences.theme,
|
|
14959
|
+
sectionsCollapsed: preferences.runsSections
|
|
14960
|
+
}), indexCookieHeader(indexToken));
|
|
14961
|
+
}
|
|
14962
|
+
/**
|
|
14963
|
+
* Has anything the Runs page draws changed, the last cleanup's trouble included, and is anything
|
|
14964
|
+
* in progress. `/revision`'s question, for
|
|
14965
|
+
* this page: `live` sets how often the page asks again, never whether it does, because a run started
|
|
14966
|
+
* from a terminal has to reach a page that was showing nothing.
|
|
14967
|
+
*/
|
|
14968
|
+
function handleRunsRevision(req, res, indexToken) {
|
|
14969
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
14970
|
+
const runs = liveRuns();
|
|
14971
|
+
send(res, 200, {
|
|
14972
|
+
revision: runsRevision(runs, lastSweep()),
|
|
14973
|
+
live: runs.inProgress.length > 0,
|
|
14974
|
+
unread: unreadCount$1()
|
|
14975
|
+
});
|
|
14976
|
+
}
|
|
14977
|
+
/** Removing one ended run from the Runs page. See `run_removal.ts` for what that does and does not touch. */
|
|
14978
|
+
async function handleRunRemoval(req, res, indexToken) {
|
|
14979
|
+
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
14980
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
14981
|
+
const body = await readJson(req);
|
|
14982
|
+
const key = typeof body?.key === "string" ? body.key : null;
|
|
14983
|
+
if (!key) return send(res, 400, { error: "Say which run to remove." });
|
|
14984
|
+
const outcome = await removeRun(key);
|
|
14985
|
+
if (outcome === "removed") return send(res, 200, { ok: true });
|
|
14986
|
+
if (outcome === "not-found") return send(res, 404, { error: "That run is no longer listed." });
|
|
14987
|
+
if (outcome === "busy") return send(res, 409, { error: "Its review is being changed. Try again in a moment." });
|
|
14988
|
+
return send(res, 409, { error: "Only a run that has ended can be removed." });
|
|
14989
|
+
}
|
|
14990
|
+
/**
|
|
13834
14991
|
* What an open Activity page polls while it is visible: how every row now stands, and, when it asks
|
|
13835
14992
|
* for them by id, the rows it does not have yet.
|
|
13836
14993
|
*
|
|
@@ -14099,9 +15256,10 @@ function establishBaseline(reviewIds) {
|
|
|
14099
15256
|
function handleRevision(req, res, indexToken) {
|
|
14100
15257
|
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
14101
15258
|
const states = analysisStates();
|
|
15259
|
+
const operations = liveOperations();
|
|
14102
15260
|
send(res, 200, {
|
|
14103
|
-
revision: reviewsRevision(states),
|
|
14104
|
-
live: anythingRunning(states),
|
|
15261
|
+
revision: reviewsRevision(states, operations),
|
|
15262
|
+
live: anythingRunning(states, operations),
|
|
14105
15263
|
driving: isDriving(),
|
|
14106
15264
|
unread: unreadCount$1()
|
|
14107
15265
|
});
|
|
@@ -14202,11 +15360,20 @@ async function handlePin(req, res, indexToken) {
|
|
|
14202
15360
|
* The index cookie, like `handlePin`: the list is the only page with these sections. One section
|
|
14203
15361
|
* per request, merged into what is stored, so the page never sends a copy of the others.
|
|
14204
15362
|
*/
|
|
15363
|
+
/** The Runs page's folds, as `/sections` is the Reviews page's, with the same credential. */
|
|
15364
|
+
async function handleRunsSections(req, res, indexToken) {
|
|
15365
|
+
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
15366
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
15367
|
+
const body = await readJson(req);
|
|
15368
|
+
if (!isRunsSection(body?.section) || typeof body?.collapsed !== "boolean") return send(res, 400, { error: "Not a section this page can fold." });
|
|
15369
|
+
writeRunsSectionCollapsed(body.section, body.collapsed);
|
|
15370
|
+
send(res, 200, { ok: true });
|
|
15371
|
+
}
|
|
14205
15372
|
async function handleSections(req, res, indexToken) {
|
|
14206
15373
|
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
14207
15374
|
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
14208
15375
|
const body = await readJson(req);
|
|
14209
|
-
if (!
|
|
15376
|
+
if (!isListSection(body?.section) || typeof body?.collapsed !== "boolean") return send(res, 400, { error: "Not a section this page can fold." });
|
|
14210
15377
|
writeSectionCollapsed(body.section, body.collapsed);
|
|
14211
15378
|
send(res, 200, { ok: true });
|
|
14212
15379
|
}
|
|
@@ -14507,6 +15674,7 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
14507
15674
|
theme: readPreferences().theme,
|
|
14508
15675
|
dismissedNotices: readPreferences().dismissedNotices,
|
|
14509
15676
|
promptsCollapsed: readPreferences().promptsCollapsed === true,
|
|
15677
|
+
reviewLayout: readPreferences().reviewLayout ?? {},
|
|
14510
15678
|
changeMapRegister: readPreferences().changeMapRegister ?? "simple",
|
|
14511
15679
|
architecture: await architecturePane(workspace),
|
|
14512
15680
|
transcript: workspace.transcript,
|
|
@@ -14757,7 +15925,7 @@ async function handleUpdate(req, res, workspace) {
|
|
|
14757
15925
|
}
|
|
14758
15926
|
let outcome;
|
|
14759
15927
|
try {
|
|
14760
|
-
outcome = await track(updateReview(workspace));
|
|
15928
|
+
outcome = await track(updateReview(workspace, { source: "workspace" }));
|
|
14761
15929
|
} catch (error) {
|
|
14762
15930
|
if (error instanceof UpdateAlreadyRunningError) return send(res, 409, { error: error.message });
|
|
14763
15931
|
outcome = {
|
|
@@ -14814,7 +15982,7 @@ async function handleRetry(res, workspace) {
|
|
|
14814
15982
|
if (!workspace.jobId) return send(res, 404, { error: "No job for this review." });
|
|
14815
15983
|
const job = loadJob(workspace.jobId);
|
|
14816
15984
|
if (!job || !isTerminal(job) || isClaimed(job.id)) return send(res, 409, { error: "This review is still being made. Wait for it to finish before retrying." });
|
|
14817
|
-
track(runJob(workspace.id)).catch(() => {});
|
|
15985
|
+
track(runJob(workspace.id, {}, "standard", { source: "workspace" })).catch(() => {});
|
|
14818
15986
|
send(res, 200, { ok: true });
|
|
14819
15987
|
}
|
|
14820
15988
|
/**
|
|
@@ -14832,7 +16000,7 @@ async function handleReanalyse(req, res, workspace) {
|
|
|
14832
16000
|
const run = reanalyseReview(workspace.id, void 0, () => {
|
|
14833
16001
|
send(res, 200, { ok: true });
|
|
14834
16002
|
resolve();
|
|
14835
|
-
});
|
|
16003
|
+
}, void 0, "workspace");
|
|
14836
16004
|
track(run).catch((error) => {
|
|
14837
16005
|
if (!res.headersSent) send(res, 409, { error: error instanceof Error ? error.message : String(error) });
|
|
14838
16006
|
resolve();
|
|
@@ -14870,7 +16038,8 @@ async function handleKiss(req, res, workspace) {
|
|
|
14870
16038
|
if (current && current.status === "generating" && isClaimed(current.jobId)) return send(res, 409, { error: "A KISS review of this change is already running." });
|
|
14871
16039
|
startKissJob({
|
|
14872
16040
|
workspaceId: workspace.id,
|
|
14873
|
-
audience
|
|
16041
|
+
audience,
|
|
16042
|
+
source: "workspace"
|
|
14874
16043
|
}).catch(() => {});
|
|
14875
16044
|
send(res, 200, { ok: true });
|
|
14876
16045
|
}
|
|
@@ -15302,6 +16471,8 @@ async function handlePrefs(req, res) {
|
|
|
15302
16471
|
if (typeof askWidth === "number") writeAskWidth(askWidth);
|
|
15303
16472
|
else if (askWidth === null) clearAskWidth();
|
|
15304
16473
|
if (typeof body?.promptsCollapsed === "boolean") writePromptsCollapsed(body.promptsCollapsed);
|
|
16474
|
+
const layout = readReviewLayoutPatch(body?.reviewLayout);
|
|
16475
|
+
if (layout) writeReviewLayout(layout);
|
|
15305
16476
|
if (body?.changeMapRegister === "simple" || body?.changeMapRegister === "technical") writeChangeMapRegister(body.changeMapRegister);
|
|
15306
16477
|
if (typeof body?.dismissNotice === "string" && !dismissNotice(body.dismissNotice)) return send(res, 400, { error: "Not a notice this page can dismiss." });
|
|
15307
16478
|
send(res, 200, { ok: true });
|
|
@@ -15513,118 +16684,6 @@ function toEvent(body, reviewId) {
|
|
|
15513
16684
|
default: return null;
|
|
15514
16685
|
}
|
|
15515
16686
|
}
|
|
15516
|
-
/**
|
|
15517
|
-
* The reviewer-facing half of that. A job whose driver was killed did not fail: it stopped
|
|
15518
|
-
* updating, which the page had no way to tell from working. This is the sentence that makes the
|
|
15519
|
-
* difference visible, and marking the job failed is what puts Retry on the page.
|
|
15520
|
-
*/
|
|
15521
|
-
var ABANDONED_MESSAGE = "Nothing has updated it for over half an hour, so whatever was making it is no longer running.";
|
|
15522
|
-
async function sweep(now = Date.now()) {
|
|
15523
|
-
const report = {
|
|
15524
|
-
abandoned: 0,
|
|
15525
|
-
retired: 0,
|
|
15526
|
-
orphansRemoved: 0,
|
|
15527
|
-
refusals: []
|
|
15528
|
-
};
|
|
15529
|
-
const jobs = allJobs();
|
|
15530
|
-
for (const job of jobs) {
|
|
15531
|
-
if (!isTerminal(job)) {
|
|
15532
|
-
if (markAbandoned(job, now)) report.abandoned += 1;
|
|
15533
|
-
continue;
|
|
15534
|
-
}
|
|
15535
|
-
if (now - endedAt(job) < 216e5) continue;
|
|
15536
|
-
if (await retire(job, report)) report.retired += 1;
|
|
15537
|
-
}
|
|
15538
|
-
await removeOrphans(jobs, report);
|
|
15539
|
-
pruneNotifications(now);
|
|
15540
|
-
return report;
|
|
15541
|
-
}
|
|
15542
|
-
/**
|
|
15543
|
-
* Record that a job stopped, unless it has moved since this pass read it.
|
|
15544
|
-
*
|
|
15545
|
-
* The reload is the whole of the safety here. Between `allJobs` and this line a driver in another
|
|
15546
|
-
* process can have written a progress line, and saving a failure over that would take a review
|
|
15547
|
-
* that is being made and tell its reviewer it stopped.
|
|
15548
|
-
*/
|
|
15549
|
-
function markAbandoned(job, now) {
|
|
15550
|
-
if (now - job.updatedAt < 18e5) return false;
|
|
15551
|
-
if (isClaimed(job.id, now)) return false;
|
|
15552
|
-
const current = loadJob(job.id);
|
|
15553
|
-
if (!current || current.updatedAt !== job.updatedAt) return false;
|
|
15554
|
-
const abandoned = fail$1(current, ABANDONED_MESSAGE);
|
|
15555
|
-
saveJob(abandoned);
|
|
15556
|
-
announceJob(abandoned);
|
|
15557
|
-
return true;
|
|
15558
|
-
}
|
|
15559
|
-
/** A finished job, discarded on the shared sequence. See `job_cleanup.ts` for why it is an order. */
|
|
15560
|
-
async function retire(job, report) {
|
|
15561
|
-
const outcome = await discardJob(job);
|
|
15562
|
-
if (outcome.refusal) report.refusals.push(outcome.refusal);
|
|
15563
|
-
return outcome.removed;
|
|
15564
|
-
}
|
|
15565
|
-
/**
|
|
15566
|
-
* Directories under the managed root that no job record names.
|
|
15567
|
-
*
|
|
15568
|
-
* A crash between creating a worktree and saving the record that points at it leaves exactly this.
|
|
15569
|
-
* The origin repository comes from the directory's own marker, because there is nothing else left
|
|
15570
|
-
* to ask, and that does make `removeWorktree`'s marker check tautological on this path. It is not
|
|
15571
|
-
* the guard here and was never meant to be: git is asked first and answers about a real repository,
|
|
15572
|
-
* and where git has disowned the path what remains is the managed root and `ORPHAN_MIN_AGE_MS`,
|
|
15573
|
-
* which is the ordering the removal guard has always had.
|
|
15574
|
-
*/
|
|
15575
|
-
async function removeOrphans(jobs, report) {
|
|
15576
|
-
const claimed = new Set(jobs.map((job) => job.worktreePath).filter((path) => !!path));
|
|
15577
|
-
let names;
|
|
15578
|
-
try {
|
|
15579
|
-
names = readdirSync(MANAGED_ROOT);
|
|
15580
|
-
} catch {
|
|
15581
|
-
return;
|
|
15582
|
-
}
|
|
15583
|
-
for (const name of names) {
|
|
15584
|
-
const path = join(MANAGED_ROOT, name);
|
|
15585
|
-
if (claimed.has(path) || isWorktreeHeld(path)) continue;
|
|
15586
|
-
try {
|
|
15587
|
-
if (!statSync(path).isDirectory()) continue;
|
|
15588
|
-
} catch {
|
|
15589
|
-
continue;
|
|
15590
|
-
}
|
|
15591
|
-
const marker = readMarker(path);
|
|
15592
|
-
if (!marker) continue;
|
|
15593
|
-
const outcome = await removeWorktree({
|
|
15594
|
-
originRepoPath: marker.originRepoPath,
|
|
15595
|
-
worktreePath: path
|
|
15596
|
-
});
|
|
15597
|
-
if (outcome.removed) report.orphansRemoved += 1;
|
|
15598
|
-
else if (outcome.reason !== "too-young") report.refusals.push(outcome.message);
|
|
15599
|
-
}
|
|
15600
|
-
}
|
|
15601
|
-
/**
|
|
15602
|
-
* How often the pass runs.
|
|
15603
|
-
*
|
|
15604
|
-
* Chosen from what it costs to be late rather than what it costs to run. A sweep is a directory
|
|
15605
|
-
* listing and a `git worktree list` per finished job, so ten minutes is nothing; being an hour
|
|
15606
|
-
* late telling a reviewer their review stopped is not. Ten minutes puts that answer between thirty
|
|
15607
|
-
* and forty minutes after the last sign of life.
|
|
15608
|
-
*/
|
|
15609
|
-
var SWEEP_INTERVAL_MS = 6e5;
|
|
15610
|
-
/**
|
|
15611
|
-
* Run the pass on a timer, and return the stop.
|
|
15612
|
-
*
|
|
15613
|
-
* Once immediately, because a server that has just started is very often one that restarted after
|
|
15614
|
-
* whatever killed the last one, and the jobs that died with it are exactly what this finds.
|
|
15615
|
-
*
|
|
15616
|
-
* Nothing here throws outward. A sweep that fails is disk it did not reclaim, and the server it
|
|
15617
|
-
* runs inside is serving reviews, which matters more.
|
|
15618
|
-
*/
|
|
15619
|
-
function startSweeping() {
|
|
15620
|
-
const run = () => {
|
|
15621
|
-
sweep().catch(() => {});
|
|
15622
|
-
};
|
|
15623
|
-
run();
|
|
15624
|
-
const timer = setInterval(run, SWEEP_INTERVAL_MS);
|
|
15625
|
-
timer.unref();
|
|
15626
|
-
return () => clearInterval(timer);
|
|
15627
|
-
}
|
|
15628
16687
|
//#endregion
|
|
15629
16688
|
//#region ../../packages/review-harness/src/cli/server.ts
|
|
15630
16689
|
/**
|