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/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as resolveWorkspacePort, $n as saveJob, $r as recommendationsOf, $t as pruneNotifications, A as parsePullRequestLink, Ai as shorthandOf, An as ReviewBeingDeletedError, Ar as LIFECYCLES, At as repositoryKey, Bi as AGENT_IDS, Bn as findExistingReview, Br as queueAdd, Bt as announceJob, C as completeRun, Ci as resolveTarget, Cn as GITHUB_MARKDOWN, Cr as recentWorkspaces, Ct as RUNTIME_WORDS, D as relativeTime$1, Di as resolveBase, Dn as escapeHtml$1, Dr as touchWorkspace, Dt as loadArchitecture, E as stopRun, Ei as isCommitReview, En as CODE_COPY_CLASS, Et as checkArchitecture, Fi as clearAgentPreference, Fn as readMarker, Fr as lifecycleItemLabel, Ft as QUOTE_LIMITS, G as stopChild, Gi as AgentUnavailableError, Gn as fetchHead, Gr as doneVerb, Gt as createPrState, Hi as agentFor$1, Hn as checkReview, Hr as queueRemove, Ht as subjectOf, I as settleQueue, Ii as readAgentPreference, In as removeWorktree, Ir as lifecyclePill, It as REPOSITORY_SCOPE, J as writeServerRecord, Jn as allJobs, Jr as issuesOutstanding, Jt as listNotifications, Kn as forgeResolver, Kr as isIssueOutstanding, Kt as createPrUpdate, L as readIndexToken, Li as writeAgentPreference, Ln as requestJobStop, Lr as offersLifecycle, Lt as ancestorsOf, M as runBatch, Mi as refExists, Mn as createWorktree, Mr as activityLabel, Mt as storedScopes, N as activateQueued, Ni as resolveAgent, Nn as holdWorktree, Nr as isLifecycle, Nt as trackedFiles, O as batchIdentity, Oi as AmbiguousRefError, On as renderInlineText, Or as workspaceRevision, Ot as partFiles, P as compactQueue, Pi as resolveModel, Pn as isWorktreeHeld, Pr as lifecycleChip, Pt as ownersOf, Q as parseWorkspacePort, Qn as loadJob, Qr as processDiscussion, Qt as moveIds, Rr as parseLifecycle, Rt as isArchitectureId, S as beginRun, Si as kissOutcome, Sn as hasAuthorContent, Sr as previousKissResult, St as recordRuntimeBestEffort, T as reportRun, Ti as DetachedHeadError, Tn as CODE_BLOCK_CLASS, Tt as ARCHITECTURE_DIR, U as serverEntryExists, Ui as detectAgents, Un as followedBranch, Ur as queueStamp, Ut as categoryOf, V as readServerRecord, Vi as agentById, Vn as checkCodeFreshness, Vr as queueAddMany, Vt as branchOf, W as spawnServer, Wi as AgentCancelledError, Wn as reviewedShaOf, Wr as readQueue, Wt as createArchitectureNotification, X as MAX_WORKSPACE_PORT, Xn as fail$1, Xr as resolvedByConversation, Xt as markRead, Y as DEFAULT_WORKSPACE_PORT, Yn as endedAt, Yr as questionsOutstanding, Yt as markAllRead, Z as MIN_WORKSPACE_PORT, Zn as isTerminal, Zr as reviewerDispositions, Zt as markReadForReview, _i as asString, _n as agentEnvOf, _r as loadWorkspace, _t as BUILD_VERSION, a as wasBlocked, ai as analysisRefOf, an as unreadAround, ar as purposeOf, at as settleReview, b as track, bi as describeAuthorship, bn as UNESTABLISHED_MOTIVATION, bt as waitingHistory, c as RefreshUnavailableError, ci as findingAssessment, cn as existingPaths, cr as decideKissRecommendation, d as mergeUpdateInto, di as isGradeFor, dn as prepareConversationWithin, dr as indexKeptIds, dt as record, ei as explanationActionOf, en as readNotification, et as statedWorkspacePort, f as updateReview, fi as promptGrade, fn as saveRefresh, fr as indexReviewGroups, gi as followUpFindingId, gn as isUnchanged, gr as lineagePosition, h as liveUpdateFor, hi as wasReassessed, hn as refreshPrContext, hr as latestKissRun, ht as telemetryUploadConsent, i as liveJobsFor, ii as ANALYSIS_ONLY, in as toneOf, ir as progressSteps, it as prepareReview, j as unsupportedReason, ji as git, jn as MANAGED_ROOT, jr as activityAttributes, jt as storeArchitecture, k as extractPullRequestLinks, ki as shortNameOf, kr as workspaceStamps, kt as readArchitecture, l as cancelUpdate, li as findingIn, ln as ASK_REREAD_CEILING_MS, lr as followedRefName, m as UpdateAlreadyRunningError, mi as sourcePhrase, mn as unforeseenFailure, mr as kissDecisionsOf, mt as recordTelemetryUploadConsent, n as deleteReview, ni as isExplanationAction, nn as removeNotification, nr as isTerminalJobPhase, nt as failureOf$1, o as discardJob, oi as assessmentLookup, on as unreadCount$1, ot as reanalyseReview, p as askCheckout, pi as reassessIn, pn as storedConversation, pr as kissDecisionRefusal, qn as fillFileUrlTemplate, qr as isQuestionOutstanding, qt as eventName, r as lineageIds, ri as withExplanation, rn as retireNotification, rr as phasesFor, rt as observeReview, s as FETCH_TIMEOUT_MS, si as currentGradeIn, sn as withUsageRecorded, sr as isClaimed, st as runJob, t as deleteLineage, ti as explanationOf, tn as readStateOf, tr as isLiveJob, tt as writePortPreference, u as cancellableHere, ui as gradeScale, un as adoptLegacyBaseline, ur as groupByLineage, ut as readEvents, v as startKissJob, vi as extractJsonText, vn as EXPLAIN_SIMPLY_PROMPT, w as endRun, wi as originRemote, wn as renderMarkdown, wr as reviewedCommit, wt as describeRuntimes, x as RUN_KINDS, xi as WITHHELD_REASON, xn as authorPreview, xr as presentedKissRunOf, xt as mapRuntimes, y as isDriving, yi as objectWithAnyOf, yn as chatConversationSection, yr as mutateWorkspace, yt as feedbackUrl, z as clearServerRecordOwnedBy, zi as STORE_ROOT, zr as sectionOf, zt as writeJsonAtomically } from "./delete_review-DwF1NMJ4.js";
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
- body: `<button type="button" class="prb-rail-subhead" data-rail-toggle="the review path" aria-expanded="true" aria-controls="prb-rail-path" aria-label="Collapse the review path">
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)}" aria-expanded="true" aria-controls="${escapeHtml$1(group.id)}" aria-label="Collapse ${escapeHtml$1(group.collapses)}">${TWISTY}</button>`}
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">&#8943;</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
  /**