prreviewbuddy 0.79.0 → 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 +71 -0
- package/dist/{delete_review-DwF1NMJ4.js → delete_review-BXfGCvQ1.js} +295 -21
- package/dist/main.js +17 -12
- package/dist/server.js +917 -133
- 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 +77 -6
- package/static/workspace.js +80 -80
- package/static/chunk-B6LyLjZ7.js +0 -1
- package/static/chunk-DYrelqtW.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";
|
|
@@ -704,6 +704,7 @@ var PATHS = {
|
|
|
704
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\"/>",
|
|
705
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\"/>",
|
|
706
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\"/>",
|
|
707
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\"/>",
|
|
708
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\"/>",
|
|
709
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\"/>",
|
|
@@ -793,6 +794,12 @@ var WORKSPACE_PLACES = [
|
|
|
793
794
|
icon: "reviews",
|
|
794
795
|
label: "Reviews"
|
|
795
796
|
},
|
|
797
|
+
{
|
|
798
|
+
place: "runs",
|
|
799
|
+
href: "/runs",
|
|
800
|
+
icon: "runs",
|
|
801
|
+
label: "Runs"
|
|
802
|
+
},
|
|
796
803
|
{
|
|
797
804
|
place: "notifications",
|
|
798
805
|
href: "/notifications",
|
|
@@ -845,6 +852,7 @@ function renderGlobalBar(options) {
|
|
|
845
852
|
${live ? renderHeaderNav(options) : ""}
|
|
846
853
|
${live ? renderHistoryControls() : ""}
|
|
847
854
|
${live ? renderStripReviews(options) : ""}
|
|
855
|
+
${live ? renderStripRuns(options) : ""}
|
|
848
856
|
${live ? renderNotificationsBell(options) : ""}
|
|
849
857
|
${live ? renderStripSettings(options) : ""}
|
|
850
858
|
${live ? renderStripRestart(options) : ""}
|
|
@@ -913,6 +921,10 @@ function renderHistoryControls() {
|
|
|
913
921
|
function renderStripReviews(options) {
|
|
914
922
|
return `<a class="prb-strip-action" href="/"${options.current === "reviews" ? " aria-current=\"true\"" : ""} aria-label="Reviews" data-strip-reviews>${iconSvg("reviews")}</a>`;
|
|
915
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
|
+
}
|
|
916
928
|
/**
|
|
917
929
|
* Settings, as an icon, beside the bell.
|
|
918
930
|
*
|
|
@@ -6075,7 +6087,7 @@ function showsProgress(view) {
|
|
|
6075
6087
|
*/
|
|
6076
6088
|
function renderReviewBody(view, sections) {
|
|
6077
6089
|
const result = view.session.result;
|
|
6078
|
-
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"` : ""}>
|
|
6079
6091
|
${renderRail(view, sections, updating(view))}
|
|
6080
6092
|
<main class="prb-doc">
|
|
6081
6093
|
${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
|
|
@@ -6636,11 +6648,12 @@ function renderRail(view, sections, stale = false) {
|
|
|
6636
6648
|
current: true,
|
|
6637
6649
|
badge: null,
|
|
6638
6650
|
collapses: "the review",
|
|
6639
|
-
|
|
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">
|
|
6640
6653
|
<span class="prb-rail-heading">Review path</span>
|
|
6641
6654
|
<span class="prb-rail-twisty" aria-hidden="true">${TWISTY}</span>
|
|
6642
6655
|
</button>
|
|
6643
|
-
<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"}>
|
|
6644
6657
|
${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
|
|
6645
6658
|
</div>
|
|
6646
6659
|
${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram architecture")}
|
|
@@ -6680,6 +6693,7 @@ function renderRunningRail(view) {
|
|
|
6680
6693
|
current: true,
|
|
6681
6694
|
badge: null,
|
|
6682
6695
|
collapses: "the review",
|
|
6696
|
+
open: railFoldOpen(view, "prb-rail-review"),
|
|
6683
6697
|
body: view.job && changeIsRead(view.job) ? railLink("files", "files", "Files", totalBadge(files, "files")) : ""
|
|
6684
6698
|
});
|
|
6685
6699
|
}
|
|
@@ -6708,9 +6722,18 @@ function renderKissRail(view) {
|
|
|
6708
6722
|
current: view.session.result === null && !showsProgress(view),
|
|
6709
6723
|
badge: counts ? recommendationsBadge(counts) : null,
|
|
6710
6724
|
collapses: "the KISS review",
|
|
6725
|
+
open: railFoldOpen(view, "prb-rail-kiss"),
|
|
6711
6726
|
body: counts ? railLink("kissrecs", "recommendations", "Recommendations", recommendationsBadge(counts)) : ""
|
|
6712
6727
|
});
|
|
6713
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
|
+
}
|
|
6714
6737
|
/** The one chevron both twisties draw. Rotated by CSS off `aria-expanded`, never by markup. */
|
|
6715
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>";
|
|
6716
6739
|
/**
|
|
@@ -6725,6 +6748,7 @@ var TWISTY = "<svg viewBox=\"0 0 12 12\" aria-hidden=\"true\" focusable=\"false\
|
|
|
6725
6748
|
* `hidden` container rather than a list the client repopulates.
|
|
6726
6749
|
*/
|
|
6727
6750
|
function railGroup(group) {
|
|
6751
|
+
const open = group.open !== false;
|
|
6728
6752
|
return `<div class="prb-rail-group">
|
|
6729
6753
|
<div class="prb-rail-row">
|
|
6730
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\"" : ""}>
|
|
@@ -6732,9 +6756,9 @@ function railGroup(group) {
|
|
|
6732
6756
|
<span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(group.title)}</span></span>
|
|
6733
6757
|
${renderBadge(group.badge)}
|
|
6734
6758
|
</a>
|
|
6735
|
-
${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>`}
|
|
6736
6760
|
</div>
|
|
6737
|
-
<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>
|
|
6738
6762
|
</div>`;
|
|
6739
6763
|
}
|
|
6740
6764
|
/**
|
|
@@ -9456,7 +9480,9 @@ function deriveOperations(jobs, isLive, rows) {
|
|
|
9456
9480
|
phase: job.phase,
|
|
9457
9481
|
purpose: purposeOf(job),
|
|
9458
9482
|
...job.reviewType && job.reviewType !== "standard" ? { lens: job.reviewType } : {},
|
|
9459
|
-
startedAt: job.startedAt
|
|
9483
|
+
startedAt: job.startedAt,
|
|
9484
|
+
...job.source ? { source: job.source } : {},
|
|
9485
|
+
...job.env.agentId ? { agentId: job.env.agentId } : {}
|
|
9460
9486
|
});
|
|
9461
9487
|
}
|
|
9462
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)));
|
|
@@ -9542,7 +9568,7 @@ ${renderGlobalBar({
|
|
|
9542
9568
|
unread: view.unreadNotifications ?? 0,
|
|
9543
9569
|
driving: view.driving
|
|
9544
9570
|
})}
|
|
9545
|
-
${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 }) => ({
|
|
9546
9572
|
handle: latest.authorHandle,
|
|
9547
9573
|
name: latest.authorName,
|
|
9548
9574
|
createdAt: latest.createdAt
|
|
@@ -9557,7 +9583,7 @@ ${renderWorkspaceRail({
|
|
|
9557
9583
|
<p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
|
|
9558
9584
|
<button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
|
|
9559
9585
|
</div>`}
|
|
9560
|
-
${renderInProgress(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed))}
|
|
9586
|
+
${renderInProgress$1(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed))}
|
|
9561
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 ")}
|
|
9562
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>`}
|
|
9563
9589
|
${ordered.length === 0 ? "" : renderBulkBar()}
|
|
@@ -9577,7 +9603,7 @@ ${renderWorkspaceRail({
|
|
|
9577
9603
|
* per risk level would be four more controls for a question ("show me only the risky ones") that
|
|
9578
9604
|
* sorting by recency already answers badly and that scanning answers well.
|
|
9579
9605
|
*/
|
|
9580
|
-
function renderControls(queued, authors, anyRequest) {
|
|
9606
|
+
function renderControls$1(queued, authors, anyRequest) {
|
|
9581
9607
|
return `<div class="prb-index-controls">
|
|
9582
9608
|
<input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
|
|
9583
9609
|
${renderAuthorFilter(authors)}
|
|
@@ -9680,7 +9706,7 @@ function renderSection(section, groups, renderCards, open, shown = groups.length
|
|
|
9680
9706
|
* index token and arrives as a reload. Its count is of work, not reviews, so a review being updated
|
|
9681
9707
|
* counts once here and once in its own section, which is the point.
|
|
9682
9708
|
*/
|
|
9683
|
-
function renderInProgress(operations, groups, positions, prStates, now, open) {
|
|
9709
|
+
function renderInProgress$1(operations, groups, positions, prStates, now, open) {
|
|
9684
9710
|
if (operations.length === 0) return "";
|
|
9685
9711
|
const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
|
|
9686
9712
|
const entries = operations.map((operation) => renderOperation(operation, reviewOf(operation, reviews), positions, prStates, now)).join("");
|
|
@@ -10118,6 +10144,96 @@ function relativeTime(at, now) {
|
|
|
10118
10144
|
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
10119
10145
|
}
|
|
10120
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
|
|
10121
10237
|
//#region ../../packages/review-harness/src/workspace/plain_page.ts
|
|
10122
10238
|
function commandList(way) {
|
|
10123
10239
|
return `<ul class="prb-commands">${way.commands.map(({ command, where }) => `<li><code>${escapeHtml(command)}</code> in ${escapeHtml(where)}</li>`).join("")}</ul>`;
|
|
@@ -10183,14 +10299,16 @@ function readPreferences() {
|
|
|
10183
10299
|
try {
|
|
10184
10300
|
const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
|
|
10185
10301
|
if (!parsed || typeof parsed !== "object") return {};
|
|
10186
|
-
const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme, reviewSections } = parsed;
|
|
10302
|
+
const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme, reviewSections, runsSections, reviewLayout } = parsed;
|
|
10187
10303
|
return {
|
|
10188
10304
|
...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
|
|
10189
10305
|
...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
|
|
10190
10306
|
...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
|
|
10191
10307
|
...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {},
|
|
10192
10308
|
...isTheme(theme) ? { theme } : {},
|
|
10193
|
-
...readReviewSections(reviewSections)
|
|
10309
|
+
...readReviewSections(reviewSections),
|
|
10310
|
+
...readRunsSections(runsSections),
|
|
10311
|
+
...readLayout(reviewLayout)
|
|
10194
10312
|
};
|
|
10195
10313
|
} catch {
|
|
10196
10314
|
return {};
|
|
@@ -10207,6 +10325,22 @@ function writeAskWidth(width) {
|
|
|
10207
10325
|
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
|
|
10208
10326
|
} catch {}
|
|
10209
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
|
+
}
|
|
10210
10344
|
/** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
|
|
10211
10345
|
function writePromptsCollapsed(collapsed) {
|
|
10212
10346
|
try {
|
|
@@ -10256,6 +10390,31 @@ function writeSectionCollapsed(section, collapsed) {
|
|
|
10256
10390
|
}, null, 2)}\n`);
|
|
10257
10391
|
} catch {}
|
|
10258
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
|
+
}
|
|
10259
10418
|
/** Remember which palette the workspace is drawn in. Read-modify-write, like every other field here. */
|
|
10260
10419
|
function writeTheme(theme) {
|
|
10261
10420
|
try {
|
|
@@ -10404,10 +10563,12 @@ ${renderWorkspaceRail({
|
|
|
10404
10563
|
unread: view.unreadNotifications ?? 0
|
|
10405
10564
|
})}
|
|
10406
10565
|
<main class="prb-index prb-settings">
|
|
10566
|
+
<div class="prb-page-panel">
|
|
10407
10567
|
${renderAppearanceSection(settings)}
|
|
10408
10568
|
${renderWorkspaceSection(settings, address)}
|
|
10409
10569
|
${renderReviewsSection(settings)}
|
|
10410
10570
|
${renderPrivacySection(settings)}
|
|
10571
|
+
</div>
|
|
10411
10572
|
|
|
10412
10573
|
<div class="prb-settings-actions">
|
|
10413
10574
|
<button type="button" class="prb-dialog-primary" data-settings-save>Save changes</button>
|
|
@@ -10453,13 +10614,13 @@ function renderBack() {
|
|
|
10453
10614
|
* The dialog it opens is rendered once on this page. See `renderShutdownDialog`.
|
|
10454
10615
|
*/
|
|
10455
10616
|
function renderServerSection() {
|
|
10456
|
-
return `<section class="prb-settings-section">
|
|
10617
|
+
return `<div class="prb-page-panel"><section class="prb-settings-section">
|
|
10457
10618
|
<h2>Server</h2>
|
|
10458
10619
|
<button type="button" class="prb-settings-danger" data-shutdown>Stop server</button>
|
|
10459
10620
|
<p class="prb-settings-help">Stops the local PR Review Buddy workspace. Nothing is deleted and
|
|
10460
10621
|
every review keeps its address. Start it again with <code>prreviewbuddy open</code>, from any
|
|
10461
10622
|
directory. Restarting instead needs no terminal: the button in the bar above does it.</p>
|
|
10462
|
-
</section>`;
|
|
10623
|
+
</section></div>`;
|
|
10463
10624
|
}
|
|
10464
10625
|
/**
|
|
10465
10626
|
* The port, and the two answers to "which port" that a save creates.
|
|
@@ -11048,7 +11209,12 @@ feature: E</pre>
|
|
|
11048
11209
|
days instead.</li>
|
|
11049
11210
|
<li><b>Isolated checkouts</b> are removed six hours after their work finishes. See
|
|
11050
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>
|
|
11051
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>
|
|
11052
11218
|
<p><code>prreviewbuddy uninstall</code> removes all of it at once.</p>`
|
|
11053
11219
|
},
|
|
11054
11220
|
{
|
|
@@ -11094,11 +11260,13 @@ ${renderWorkspaceRail({
|
|
|
11094
11260
|
unread: view.unreadNotifications ?? 0
|
|
11095
11261
|
})}
|
|
11096
11262
|
<main class="prb-index prb-help">
|
|
11263
|
+
<div class="prb-page-panel">
|
|
11097
11264
|
${renderContents()}
|
|
11098
11265
|
${TOPICS.map((topic) => `<section class="prb-help-topic" id="${topic.id}">
|
|
11099
11266
|
<h2>${escapeHtml$1(topic.title)}</h2>
|
|
11100
11267
|
${topic.body}
|
|
11101
11268
|
</section>`).join("")}
|
|
11269
|
+
</div>
|
|
11102
11270
|
<p class="prb-help-version">PR Review Buddy v${escapeHtml$1(BUILD_VERSION)}</p>
|
|
11103
11271
|
</main>
|
|
11104
11272
|
</div>
|
|
@@ -11407,8 +11575,8 @@ function realAddReviewDeps() {
|
|
|
11407
11575
|
}
|
|
11408
11576
|
},
|
|
11409
11577
|
findExisting: findExistingReview,
|
|
11410
|
-
prepare: prepareReview,
|
|
11411
|
-
settle: (workspaceId) => settleReview(workspaceId, "standard"),
|
|
11578
|
+
prepare: (target, agentId, fresh, requestedModel) => prepareReview(target, agentId, fresh, requestedModel, "workspace"),
|
|
11579
|
+
settle: (workspaceId) => settleReview(workspaceId, "standard", void 0, "workspace"),
|
|
11412
11580
|
observe: (workspaceId) => observeReview(workspaceId),
|
|
11413
11581
|
commitPresent: (root, sha) => refExists(root, sha),
|
|
11414
11582
|
track,
|
|
@@ -12139,6 +12307,141 @@ async function chooseFolder(deps) {
|
|
|
12139
12307
|
}
|
|
12140
12308
|
}
|
|
12141
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 } : {}
|
|
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
|
+
});
|
|
12393
|
+
}
|
|
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
|
+
};
|
|
12399
|
+
}
|
|
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
|
+
};
|
|
12414
|
+
}
|
|
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
|
+
};
|
|
12426
|
+
}
|
|
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);
|
|
12443
|
+
}
|
|
12444
|
+
//#endregion
|
|
12142
12445
|
//#region ../../packages/review-harness/src/workspace/live_operations.ts
|
|
12143
12446
|
/**
|
|
12144
12447
|
* The review work going on now, as one list: the one place that gathers it.
|
|
@@ -12165,6 +12468,14 @@ async function chooseFolder(deps) {
|
|
|
12165
12468
|
function liveOperations(now = Date.now(), jobs = allJobs(), rows = pendingBatchRows()) {
|
|
12166
12469
|
return deriveOperations(jobs, (job) => isClaimed(job.id, now), rows);
|
|
12167
12470
|
}
|
|
12471
|
+
/**
|
|
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.
|
|
12475
|
+
*/
|
|
12476
|
+
function liveRuns(now = Date.now(), jobs = allJobs(), rows = pendingBatchRows()) {
|
|
12477
|
+
return deriveRuns(jobs, (job) => isClaimed(job.id, now), rows, now);
|
|
12478
|
+
}
|
|
12168
12479
|
//#endregion
|
|
12169
12480
|
//#region ../../packages/review-harness/src/workspace/reviews_revision.ts
|
|
12170
12481
|
/**
|
|
@@ -12297,6 +12608,525 @@ function reviewsRevision(live = analysisStates(), operations = liveOperations())
|
|
|
12297
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`);
|
|
12298
12609
|
return hash.digest("base64url");
|
|
12299
12610
|
}
|
|
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>`;
|
|
12690
|
+
}
|
|
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>`;
|
|
12703
|
+
}
|
|
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>`;
|
|
12729
|
+
}
|
|
12730
|
+
/**
|
|
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.
|
|
12763
|
+
*
|
|
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.
|
|
12770
|
+
*/
|
|
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;
|
|
12775
|
+
}
|
|
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>`;
|
|
12782
|
+
}
|
|
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
|
|
12818
|
+
};
|
|
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>`;
|
|
12833
|
+
}
|
|
12834
|
+
/**
|
|
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.
|
|
12845
|
+
*/
|
|
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>`;
|
|
12874
|
+
}
|
|
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;
|
|
12916
|
+
}
|
|
12917
|
+
/**
|
|
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.
|
|
12929
|
+
*/
|
|
12930
|
+
var ABANDONED_AFTER_MS = 18e5;
|
|
12931
|
+
/**
|
|
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.
|
|
12935
|
+
*/
|
|
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;
|
|
12961
|
+
}
|
|
12962
|
+
/**
|
|
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.
|
|
12968
|
+
*/
|
|
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;
|
|
12978
|
+
}
|
|
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;
|
|
13033
|
+
}
|
|
13034
|
+
/**
|
|
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.
|
|
13043
|
+
*/
|
|
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
|
+
}
|
|
13069
|
+
}
|
|
13070
|
+
/**
|
|
13071
|
+
* How often the pass runs.
|
|
13072
|
+
*
|
|
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.
|
|
13077
|
+
*/
|
|
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
|
+
};
|
|
13095
|
+
}
|
|
13096
|
+
writeJsonAtomically(SWEEP_STATUS_PATH, status);
|
|
13097
|
+
return status;
|
|
13098
|
+
}
|
|
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
|
+
}
|
|
13111
|
+
}
|
|
13112
|
+
/**
|
|
13113
|
+
* Run the pass on a timer, and return the stop.
|
|
13114
|
+
*
|
|
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.
|
|
13120
|
+
*/
|
|
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
|
+
}
|
|
12300
13130
|
function asKind(value) {
|
|
12301
13131
|
return value === "issue" || value === "question" ? value : null;
|
|
12302
13132
|
}
|
|
@@ -13757,6 +14587,10 @@ async function handle(req, res, options) {
|
|
|
13757
14587
|
if (url.pathname === "/open") return handleOpen(req, res, url.searchParams, options.indexToken);
|
|
13758
14588
|
if (url.pathname === "/settings") return void handleSettings(req, res, url.searchParams, options.indexToken);
|
|
13759
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);
|
|
13760
14594
|
if (url.pathname === "/help") return void handleHelp(req, res, url.searchParams, options.indexToken);
|
|
13761
14595
|
if (url.pathname === "/notifications/open") return handleNotificationOpen(req, res, url.searchParams, options.indexToken);
|
|
13762
14596
|
if (url.pathname === "/notifications/recent") return handleRecentNotifications(req, res, url.searchParams, options.indexToken);
|
|
@@ -14105,6 +14939,55 @@ async function handleNotifications(req, res, params, indexToken) {
|
|
|
14105
14939
|
}), indexCookieHeader(indexToken));
|
|
14106
14940
|
}
|
|
14107
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
|
+
/**
|
|
14108
14991
|
* What an open Activity page polls while it is visible: how every row now stands, and, when it asks
|
|
14109
14992
|
* for them by id, the rows it does not have yet.
|
|
14110
14993
|
*
|
|
@@ -14477,6 +15360,15 @@ async function handlePin(req, res, indexToken) {
|
|
|
14477
15360
|
* The index cookie, like `handlePin`: the list is the only page with these sections. One section
|
|
14478
15361
|
* per request, merged into what is stored, so the page never sends a copy of the others.
|
|
14479
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
|
+
}
|
|
14480
15372
|
async function handleSections(req, res, indexToken) {
|
|
14481
15373
|
if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
|
|
14482
15374
|
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
@@ -14782,6 +15674,7 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
14782
15674
|
theme: readPreferences().theme,
|
|
14783
15675
|
dismissedNotices: readPreferences().dismissedNotices,
|
|
14784
15676
|
promptsCollapsed: readPreferences().promptsCollapsed === true,
|
|
15677
|
+
reviewLayout: readPreferences().reviewLayout ?? {},
|
|
14785
15678
|
changeMapRegister: readPreferences().changeMapRegister ?? "simple",
|
|
14786
15679
|
architecture: await architecturePane(workspace),
|
|
14787
15680
|
transcript: workspace.transcript,
|
|
@@ -15032,7 +15925,7 @@ async function handleUpdate(req, res, workspace) {
|
|
|
15032
15925
|
}
|
|
15033
15926
|
let outcome;
|
|
15034
15927
|
try {
|
|
15035
|
-
outcome = await track(updateReview(workspace));
|
|
15928
|
+
outcome = await track(updateReview(workspace, { source: "workspace" }));
|
|
15036
15929
|
} catch (error) {
|
|
15037
15930
|
if (error instanceof UpdateAlreadyRunningError) return send(res, 409, { error: error.message });
|
|
15038
15931
|
outcome = {
|
|
@@ -15089,7 +15982,7 @@ async function handleRetry(res, workspace) {
|
|
|
15089
15982
|
if (!workspace.jobId) return send(res, 404, { error: "No job for this review." });
|
|
15090
15983
|
const job = loadJob(workspace.jobId);
|
|
15091
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." });
|
|
15092
|
-
track(runJob(workspace.id)).catch(() => {});
|
|
15985
|
+
track(runJob(workspace.id, {}, "standard", { source: "workspace" })).catch(() => {});
|
|
15093
15986
|
send(res, 200, { ok: true });
|
|
15094
15987
|
}
|
|
15095
15988
|
/**
|
|
@@ -15107,7 +16000,7 @@ async function handleReanalyse(req, res, workspace) {
|
|
|
15107
16000
|
const run = reanalyseReview(workspace.id, void 0, () => {
|
|
15108
16001
|
send(res, 200, { ok: true });
|
|
15109
16002
|
resolve();
|
|
15110
|
-
});
|
|
16003
|
+
}, void 0, "workspace");
|
|
15111
16004
|
track(run).catch((error) => {
|
|
15112
16005
|
if (!res.headersSent) send(res, 409, { error: error instanceof Error ? error.message : String(error) });
|
|
15113
16006
|
resolve();
|
|
@@ -15145,7 +16038,8 @@ async function handleKiss(req, res, workspace) {
|
|
|
15145
16038
|
if (current && current.status === "generating" && isClaimed(current.jobId)) return send(res, 409, { error: "A KISS review of this change is already running." });
|
|
15146
16039
|
startKissJob({
|
|
15147
16040
|
workspaceId: workspace.id,
|
|
15148
|
-
audience
|
|
16041
|
+
audience,
|
|
16042
|
+
source: "workspace"
|
|
15149
16043
|
}).catch(() => {});
|
|
15150
16044
|
send(res, 200, { ok: true });
|
|
15151
16045
|
}
|
|
@@ -15577,6 +16471,8 @@ async function handlePrefs(req, res) {
|
|
|
15577
16471
|
if (typeof askWidth === "number") writeAskWidth(askWidth);
|
|
15578
16472
|
else if (askWidth === null) clearAskWidth();
|
|
15579
16473
|
if (typeof body?.promptsCollapsed === "boolean") writePromptsCollapsed(body.promptsCollapsed);
|
|
16474
|
+
const layout = readReviewLayoutPatch(body?.reviewLayout);
|
|
16475
|
+
if (layout) writeReviewLayout(layout);
|
|
15580
16476
|
if (body?.changeMapRegister === "simple" || body?.changeMapRegister === "technical") writeChangeMapRegister(body.changeMapRegister);
|
|
15581
16477
|
if (typeof body?.dismissNotice === "string" && !dismissNotice(body.dismissNotice)) return send(res, 400, { error: "Not a notice this page can dismiss." });
|
|
15582
16478
|
send(res, 200, { ok: true });
|
|
@@ -15788,118 +16684,6 @@ function toEvent(body, reviewId) {
|
|
|
15788
16684
|
default: return null;
|
|
15789
16685
|
}
|
|
15790
16686
|
}
|
|
15791
|
-
/**
|
|
15792
|
-
* The reviewer-facing half of that. A job whose driver was killed did not fail: it stopped
|
|
15793
|
-
* updating, which the page had no way to tell from working. This is the sentence that makes the
|
|
15794
|
-
* difference visible, and marking the job failed is what puts Retry on the page.
|
|
15795
|
-
*/
|
|
15796
|
-
var ABANDONED_MESSAGE = "Nothing has updated it for over half an hour, so whatever was making it is no longer running.";
|
|
15797
|
-
async function sweep(now = Date.now()) {
|
|
15798
|
-
const report = {
|
|
15799
|
-
abandoned: 0,
|
|
15800
|
-
retired: 0,
|
|
15801
|
-
orphansRemoved: 0,
|
|
15802
|
-
refusals: []
|
|
15803
|
-
};
|
|
15804
|
-
const jobs = allJobs();
|
|
15805
|
-
for (const job of jobs) {
|
|
15806
|
-
if (!isTerminal(job)) {
|
|
15807
|
-
if (markAbandoned(job, now)) report.abandoned += 1;
|
|
15808
|
-
continue;
|
|
15809
|
-
}
|
|
15810
|
-
if (now - endedAt(job) < 216e5) continue;
|
|
15811
|
-
if (await retire(job, report)) report.retired += 1;
|
|
15812
|
-
}
|
|
15813
|
-
await removeOrphans(jobs, report);
|
|
15814
|
-
pruneNotifications(now);
|
|
15815
|
-
return report;
|
|
15816
|
-
}
|
|
15817
|
-
/**
|
|
15818
|
-
* Record that a job stopped, unless it has moved since this pass read it.
|
|
15819
|
-
*
|
|
15820
|
-
* The reload is the whole of the safety here. Between `allJobs` and this line a driver in another
|
|
15821
|
-
* process can have written a progress line, and saving a failure over that would take a review
|
|
15822
|
-
* that is being made and tell its reviewer it stopped.
|
|
15823
|
-
*/
|
|
15824
|
-
function markAbandoned(job, now) {
|
|
15825
|
-
if (now - job.updatedAt < 18e5) return false;
|
|
15826
|
-
if (isClaimed(job.id, now)) return false;
|
|
15827
|
-
const current = loadJob(job.id);
|
|
15828
|
-
if (!current || current.updatedAt !== job.updatedAt) return false;
|
|
15829
|
-
const abandoned = fail$1(current, ABANDONED_MESSAGE);
|
|
15830
|
-
saveJob(abandoned);
|
|
15831
|
-
announceJob(abandoned);
|
|
15832
|
-
return true;
|
|
15833
|
-
}
|
|
15834
|
-
/** A finished job, discarded on the shared sequence. See `job_cleanup.ts` for why it is an order. */
|
|
15835
|
-
async function retire(job, report) {
|
|
15836
|
-
const outcome = await discardJob(job);
|
|
15837
|
-
if (outcome.refusal) report.refusals.push(outcome.refusal);
|
|
15838
|
-
return outcome.removed;
|
|
15839
|
-
}
|
|
15840
|
-
/**
|
|
15841
|
-
* Directories under the managed root that no job record names.
|
|
15842
|
-
*
|
|
15843
|
-
* A crash between creating a worktree and saving the record that points at it leaves exactly this.
|
|
15844
|
-
* The origin repository comes from the directory's own marker, because there is nothing else left
|
|
15845
|
-
* to ask, and that does make `removeWorktree`'s marker check tautological on this path. It is not
|
|
15846
|
-
* the guard here and was never meant to be: git is asked first and answers about a real repository,
|
|
15847
|
-
* and where git has disowned the path what remains is the managed root and `ORPHAN_MIN_AGE_MS`,
|
|
15848
|
-
* which is the ordering the removal guard has always had.
|
|
15849
|
-
*/
|
|
15850
|
-
async function removeOrphans(jobs, report) {
|
|
15851
|
-
const claimed = new Set(jobs.map((job) => job.worktreePath).filter((path) => !!path));
|
|
15852
|
-
let names;
|
|
15853
|
-
try {
|
|
15854
|
-
names = readdirSync(MANAGED_ROOT);
|
|
15855
|
-
} catch {
|
|
15856
|
-
return;
|
|
15857
|
-
}
|
|
15858
|
-
for (const name of names) {
|
|
15859
|
-
const path = join(MANAGED_ROOT, name);
|
|
15860
|
-
if (claimed.has(path) || isWorktreeHeld(path)) continue;
|
|
15861
|
-
try {
|
|
15862
|
-
if (!statSync(path).isDirectory()) continue;
|
|
15863
|
-
} catch {
|
|
15864
|
-
continue;
|
|
15865
|
-
}
|
|
15866
|
-
const marker = readMarker(path);
|
|
15867
|
-
if (!marker) continue;
|
|
15868
|
-
const outcome = await removeWorktree({
|
|
15869
|
-
originRepoPath: marker.originRepoPath,
|
|
15870
|
-
worktreePath: path
|
|
15871
|
-
});
|
|
15872
|
-
if (outcome.removed) report.orphansRemoved += 1;
|
|
15873
|
-
else if (outcome.reason !== "too-young") report.refusals.push(outcome.message);
|
|
15874
|
-
}
|
|
15875
|
-
}
|
|
15876
|
-
/**
|
|
15877
|
-
* How often the pass runs.
|
|
15878
|
-
*
|
|
15879
|
-
* Chosen from what it costs to be late rather than what it costs to run. A sweep is a directory
|
|
15880
|
-
* listing and a `git worktree list` per finished job, so ten minutes is nothing; being an hour
|
|
15881
|
-
* late telling a reviewer their review stopped is not. Ten minutes puts that answer between thirty
|
|
15882
|
-
* and forty minutes after the last sign of life.
|
|
15883
|
-
*/
|
|
15884
|
-
var SWEEP_INTERVAL_MS = 6e5;
|
|
15885
|
-
/**
|
|
15886
|
-
* Run the pass on a timer, and return the stop.
|
|
15887
|
-
*
|
|
15888
|
-
* Once immediately, because a server that has just started is very often one that restarted after
|
|
15889
|
-
* whatever killed the last one, and the jobs that died with it are exactly what this finds.
|
|
15890
|
-
*
|
|
15891
|
-
* Nothing here throws outward. A sweep that fails is disk it did not reclaim, and the server it
|
|
15892
|
-
* runs inside is serving reviews, which matters more.
|
|
15893
|
-
*/
|
|
15894
|
-
function startSweeping() {
|
|
15895
|
-
const run = () => {
|
|
15896
|
-
sweep().catch(() => {});
|
|
15897
|
-
};
|
|
15898
|
-
run();
|
|
15899
|
-
const timer = setInterval(run, SWEEP_INTERVAL_MS);
|
|
15900
|
-
timer.unref();
|
|
15901
|
-
return () => clearInterval(timer);
|
|
15902
|
-
}
|
|
15903
16687
|
//#endregion
|
|
15904
16688
|
//#region ../../packages/review-harness/src/cli/server.ts
|
|
15905
16689
|
/**
|