prreviewbuddy 0.34.0 → 0.36.4
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 +95 -0
- package/README.md +7 -3
- package/dist/{delete_review-TNIFNct3.js → delete_review-Kup1LXD_.js} +29 -7
- package/dist/main.js +1 -1
- package/dist/server.js +384 -79
- package/package.json +1 -1
- package/static/chunk-B80S6zbH.js +4 -4
- package/static/chunk-DmbjXWI0.js +1 -0
- package/static/notifications.js +1 -1
- package/static/reviews.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +271 -35
- package/static/workspace.js +4 -4
- package/static/chunk-CBxYYqda.js +0 -1
- package/static/chunk-CUFGKuJi.js +0 -1
- package/static/chunk-X60fsCZA.js +0 -1
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as BUILD_VERSION, $t as issuesOutstanding, A as markRead, C as recordTelemetryUploadConsent, D as headline, E as eventName, Et as followedRefName, F as EXPLAIN_SIMPLY_PROMPT, Ft as previousKissResult, Gt as queueAdd, H as readIndexToken, Ht as workspaceRevision, I as ReviewBeingDeletedError, It as recentWorkspaces, J as serverEntryExists, Jt as readQueue, K as readServerRecord, Kt as queueRemove, L as MANAGED_ROOT, Lt as reviewedCommit, M as readNotification, Mt as loadWorkspace, N as unreadCount$1, O as listNotifications, Ot as indexReviewGroups, P as withUsageRecorded, Q as writeServerRecord, Qt as isQuestionOutstanding, R as readMarker, S as record, St as purposeOf, T as announceJob, Tt as isClaimed, Ut as workspaceStamps, V as relativeTime$1, Vt as touchWorkspace, W as clearServerRecord, Wt as dequeueOnComplete, Xt as doneVerb, Y as spawnServer, Yt as writeQueue, Zt as isIssueOutstanding, _ as startKissJob, _n as agentFor, _t as loadJob, a as wasBlocked, at as parseWorkspacePort, bn as AgentUnavailableError, bt as phasesFor, c as updateReview, cn as resolveModel, ct as writePortPreference, d as askCheckout, dn as writeAgentPreference, dt as checkCodeFreshness, en as questionsOutstanding, f as UpdateAlreadyRunningError, ft as checkReview, gt as isTerminal, h as agentEnvOf, ht as fail, i as liveJobsFor, it as MIN_WORKSPACE_PORT, j as pruneNotifications, jt as lineagePosition, k as markAllRead, kt as latestKissRun, l as refreshPrContext, ln as clearAgentPreference, mn as AGENT_IDS, mt as endedAt, n as deleteReview, nn as processDiscussion, nt as DEFAULT_WORKSPACE_PORT, o as discardJob, on as git, ot as resolveWorkspacePort, p as liveUpdateFor, pn as STORE_ROOT, pt as allJobs, qt as queueStamp, r as lineageIds, rn as describeAuthorship, rt as MAX_WORKSPACE_PORT, s as RefreshUnavailableError, st as statedWorkspacePort, t as deleteLineage, tn as reviewerDispositions, tt as feedbackUrl, u as isUnchanged, un as readAgentPreference, ut as fillFileUrlTemplate, vt as saveJob, w as telemetryUploadConsent, x as readEvents, xt as progressSteps, y as runJob, yn as AgentCancelledError, z as removeWorktree, zt as saveWorkspace } from "./delete_review-Kup1LXD_.js";
|
|
2
2
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
@@ -253,7 +253,8 @@ var PATHS = {
|
|
|
253
253
|
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\"/>",
|
|
254
254
|
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\"/>",
|
|
255
255
|
feedback: "<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>",
|
|
256
|
-
stop: "<path d=\"M12 2v10\"/><path d=\"M18.4 6.6a9 9 0 1 1-12.8 0\"/>"
|
|
256
|
+
stop: "<path d=\"M12 2v10\"/><path d=\"M18.4 6.6a9 9 0 1 1-12.8 0\"/>",
|
|
257
|
+
restart: "<path d=\"M21 12a9 9 0 1 1-2.64-6.36\"/><path d=\"M21 3v6h-6\"/>"
|
|
257
258
|
};
|
|
258
259
|
/** The icon slot for one rail row. Hidden from assistive technology: the label beside it says it. */
|
|
259
260
|
function railIcon(icon) {
|
|
@@ -289,6 +290,14 @@ function unreadLabel(unread) {
|
|
|
289
290
|
return unread === 0 ? "Nothing unread" : `${unread} unread`;
|
|
290
291
|
}
|
|
291
292
|
//#endregion
|
|
293
|
+
//#region ../../packages/review-harness/src/workspace/server_status.ts
|
|
294
|
+
/** The dot's accessible name, which is the only form of it a screen reader gets. */
|
|
295
|
+
function serverStatusLabel(status) {
|
|
296
|
+
if (status === "restarting") return "Workspace server restarting";
|
|
297
|
+
if (status === "unreachable") return "Workspace server not running";
|
|
298
|
+
return "Workspace server running";
|
|
299
|
+
}
|
|
300
|
+
//#endregion
|
|
292
301
|
//#region ../../packages/review-harness/src/workspace/workspace_nav.ts
|
|
293
302
|
/**
|
|
294
303
|
* The workspace's own places, and the one model every page draws them from.
|
|
@@ -322,10 +331,133 @@ var WORKSPACE_PLACES = [
|
|
|
322
331
|
* The count beside Notifications. Always rendered, hidden at zero, so the page can reveal it when
|
|
323
332
|
* `/revision` or Mark all read reports a new number. See `client/unread_counts.ts`.
|
|
324
333
|
*/
|
|
325
|
-
function unreadCount(unread, style) {
|
|
326
|
-
const
|
|
327
|
-
const text =
|
|
328
|
-
return `<span class="${
|
|
334
|
+
function unreadCount(unread, style, className) {
|
|
335
|
+
const cls = className ?? (style === "rail" ? "prb-rail-count prb-rail-unread" : "prb-unread-dot");
|
|
336
|
+
const text = style === "rail" && unread > 0 ? unreadLabel(unread) : "";
|
|
337
|
+
return `<span class="${cls}" data-unread-count data-unread-style="${style}"${style === "dot" ? " aria-hidden=\"true\"" : ""}${unread === 0 ? " hidden" : ""}>${text}</span>`;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The global strip: the same utility bar above every workspace page, review pages included.
|
|
341
|
+
*
|
|
342
|
+
* It exists because global controls and the current review's identity were sharing one layer, and
|
|
343
|
+
* that layer had run out of room — which is why Notifications had nowhere to live but the foot of
|
|
344
|
+
* the rail, where it was easy to miss. Splitting them gives attention state the top-right corner
|
|
345
|
+
* people already look at.
|
|
346
|
+
*
|
|
347
|
+
* Deliberately thin and deliberately quiet: the wordmark, four icon-sized controls and the build. The bar below
|
|
348
|
+
* it is the one that should read as "this is the thing I am reviewing", so anything that would make
|
|
349
|
+
* this strip compete with it — a fat search field, a row of actions, Settings — belongs in the rail
|
|
350
|
+
* or nowhere. See `shell.css`, `.prb-strip`.
|
|
351
|
+
*
|
|
352
|
+
* The middle is a search slot, laid out in the stylesheet and empty until there is a search behind
|
|
353
|
+
* it. Nothing is rendered there in the meantime: a control that does nothing teaches the reader the
|
|
354
|
+
* page is broken.
|
|
355
|
+
*/
|
|
356
|
+
function renderGlobalBar(options) {
|
|
357
|
+
const live = options.live ?? true;
|
|
358
|
+
return `<header class="prb-strip">
|
|
359
|
+
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
360
|
+
${live ? renderHeaderNav(options) : ""}
|
|
361
|
+
${live ? renderStripReviews(options) : ""}
|
|
362
|
+
${live ? renderNotificationsBell(options) : ""}
|
|
363
|
+
${live ? renderStripSettings(options) : ""}
|
|
364
|
+
${live ? renderStripRestart(options) : ""}
|
|
365
|
+
${live ? renderServerStatus() : ""}
|
|
366
|
+
<p class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</p>
|
|
367
|
+
</header>`;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The bar below the strip: what page this is, and the controls that belong to it.
|
|
371
|
+
*
|
|
372
|
+
* The dominant bar of the composition on every page, as the review's own bar is on a review page.
|
|
373
|
+
* That is the point of the split: the strip is utility chrome and this says what you are looking at.
|
|
374
|
+
*
|
|
375
|
+
* `controls` is the row that page used to draw at the top of its own column — the reviews filters,
|
|
376
|
+
* the Mark all read line — moved up rather than added, so the strip above is the only band the
|
|
377
|
+
* redesign actually costs.
|
|
378
|
+
*/
|
|
379
|
+
function renderContextBar(title, controls = "") {
|
|
380
|
+
return `<div class="prb-context">
|
|
381
|
+
<h1 class="prb-page-title">${escapeHtml$1(title)}</h1>
|
|
382
|
+
${controls}
|
|
383
|
+
</div>`;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Attention state, where attention state is looked for.
|
|
387
|
+
*
|
|
388
|
+
* Two counts inside one control: the digit the reader sees, capped by `badgeLabel`, and the same
|
|
389
|
+
* fact in words for a screen reader. Both are `data-unread-count`, so `paintUnreadCounts` repaints
|
|
390
|
+
* them from one number — which is why the bell cannot end up saying something the rail contradicts,
|
|
391
|
+
* and why neither needs an `aria-label` that could go stale while the digit moved.
|
|
392
|
+
*
|
|
393
|
+
* A link, not a menu. The bell says something is waiting; the Notifications page is where it is
|
|
394
|
+
* read. Pressing it marks nothing: read state changes only by a deliberate act.
|
|
395
|
+
*/
|
|
396
|
+
function renderNotificationsBell(options) {
|
|
397
|
+
return `<a class="prb-strip-action prb-bell" href="/notifications"${options.current === "notifications" ? " aria-current=\"true\"" : ""} aria-label="Notifications">
|
|
398
|
+
${iconSvg("notifications")}
|
|
399
|
+
${unreadCount(options.unread, "dot")}
|
|
400
|
+
${unreadCount(options.unread, "rail", "prb-visually-hidden")}
|
|
401
|
+
</a>`;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The reviews list, first of the places, as the rail orders them.
|
|
405
|
+
*
|
|
406
|
+
* Every workspace place now has an icon here, which is the point: the rail is the only other way to
|
|
407
|
+
* any of them and a narrow window hides it, so from a review page the list a session starts on was
|
|
408
|
+
* one browser Back or nothing. First rather than beside the bell, so the strip reads in the rail's
|
|
409
|
+
* order wherever a reader learned it.
|
|
410
|
+
*/
|
|
411
|
+
function renderStripReviews(options) {
|
|
412
|
+
return `<a class="prb-strip-action" href="/"${options.current === "reviews" ? " aria-current=\"true\"" : ""} aria-label="Reviews" data-strip-reviews>${iconSvg("reviews")}</a>`;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Settings, as an icon, beside the bell.
|
|
416
|
+
*
|
|
417
|
+
* The controls a reviewer reaches for from anywhere, in the corner they are reached for from. The
|
|
418
|
+
* rail still carries each with its name beside it; these are the shortcut, which is why they are
|
|
419
|
+
* icons and not a second set of labels — and why each carries an `aria-label`, since an icon alone
|
|
420
|
+
* names nothing.
|
|
421
|
+
*/
|
|
422
|
+
function renderStripSettings(options) {
|
|
423
|
+
return `<a class="prb-strip-action" href="/settings"${options.current === "settings" ? " aria-current=\"true\"" : ""} aria-label="Settings" data-strip-settings>${iconSvg("settings")}</a>`;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Restart: the everyday lifecycle action, and the reason this slot is not Stop server.
|
|
427
|
+
*
|
|
428
|
+
* Restarting is recoverable and needs no terminal — the running server starts its replacement — so
|
|
429
|
+
* it can be a single press with no dialog in front of it. Stopping is a one-way door that only the
|
|
430
|
+
* CLI reopens, which is why it lives on Settings instead.
|
|
431
|
+
*
|
|
432
|
+
* Disabled, with the reason beside it, while this server is making a review: that work runs in the
|
|
433
|
+
* process a restart would end. The note is always rendered and hidden when there is nothing to
|
|
434
|
+
* explain, so a refusal from the server can reveal it without the bar being rebuilt.
|
|
435
|
+
*/
|
|
436
|
+
function renderStripRestart(options) {
|
|
437
|
+
const driving = options.driving === true;
|
|
438
|
+
return `<button type="button" class="prb-strip-action" aria-label="Restart server" data-restart${driving ? " disabled" : ""}>${iconSvg("restart")}</button>
|
|
439
|
+
<span class="prb-strip-note" data-restart-note role="status"${driving ? "" : " hidden"}>${escapeHtml$1(RESTART_WHEN_IDLE)}</span>`;
|
|
440
|
+
}
|
|
441
|
+
/** Said in the strip rather than in a `title`, which this product does not use. */
|
|
442
|
+
var RESTART_WHEN_IDLE = "Restart available when current work finishes";
|
|
443
|
+
/**
|
|
444
|
+
* Whether the workspace server is there, as a dot beside the thing that restarts it.
|
|
445
|
+
*
|
|
446
|
+
* Always `running` when it is rendered, because the server rendering it is the answer: a page being
|
|
447
|
+
* served by a server that is not there cannot happen. What it is *for* is the two states after
|
|
448
|
+
* that — amber while a restart is in flight, red once nothing is answering — which is why it is
|
|
449
|
+
* subtle. It is a health indicator, not a badge.
|
|
450
|
+
*
|
|
451
|
+
* Absent from the offline preview entirely. There is no server behind that page, and a dot claiming
|
|
452
|
+
* one would be the only false thing on it.
|
|
453
|
+
*
|
|
454
|
+
* Its whole visible form is a colour, so its name is a visually-hidden word rather than a `title`:
|
|
455
|
+
* a tooltip is a fact only a mouse can reach, and this product uses none.
|
|
456
|
+
*/
|
|
457
|
+
function renderServerStatus() {
|
|
458
|
+
return `<span class="prb-server-status" data-server-status="running">
|
|
459
|
+
<span class="prb-visually-hidden" data-server-status-label>${escapeHtml$1(serverStatusLabel("running"))}</span>
|
|
460
|
+
</span>`;
|
|
329
461
|
}
|
|
330
462
|
/** The workspace rows, for a rail group. */
|
|
331
463
|
function renderWorkspaceNav(options) {
|
|
@@ -335,16 +467,6 @@ function renderWorkspaceNav(options) {
|
|
|
335
467
|
${item.place === "notifications" ? unreadCount(options.unread, "rail") : ""}
|
|
336
468
|
</a>`).join("");
|
|
337
469
|
}
|
|
338
|
-
/**
|
|
339
|
-
* Stop server. Operational rather than navigation, and it ends every open review, so it sits apart
|
|
340
|
-
* from the places at the foot of the rail. Pair it with `renderShutdownDialog` on the same page.
|
|
341
|
-
*/
|
|
342
|
-
function renderStopServer() {
|
|
343
|
-
return `<button type="button" class="prb-rail-item prb-rail-link prb-rail-danger" data-shutdown>
|
|
344
|
-
${railIcon("stop")}
|
|
345
|
-
<span class="prb-rail-label"><span class="prb-rail-title">Stop server</span></span>
|
|
346
|
-
</button>`;
|
|
347
|
-
}
|
|
348
470
|
function renderShutdownDialog() {
|
|
349
471
|
return `<dialog class="prb-dialog" data-shutdown-dialog aria-labelledby="prb-shutdown-title">
|
|
350
472
|
<h2 id="prb-shutdown-title">Stop the workspace server?</h2>
|
|
@@ -367,22 +489,28 @@ function renderSendFeedback() {
|
|
|
367
489
|
</a>`;
|
|
368
490
|
}
|
|
369
491
|
/**
|
|
370
|
-
* The whole rail of a workspace page that is not a review: the places at the top, then Send
|
|
371
|
-
*
|
|
492
|
+
* The whole rail of a workspace page that is not a review: the places at the top, then Send feedback
|
|
493
|
+
* at the foot, in the order a review page's rail ends with it.
|
|
494
|
+
*
|
|
495
|
+
* No Stop server. The rail is navigation, and what starts or ends the server is not a place: the
|
|
496
|
+
* strip restarts it and Settings stops it.
|
|
372
497
|
*/
|
|
373
498
|
function renderWorkspaceRail(options) {
|
|
374
499
|
return `<nav class="prb-rail" aria-label="Workspace">
|
|
375
500
|
<div class="prb-rail-group">${renderWorkspaceNav(options)}</div>
|
|
376
501
|
<div class="prb-rail-group prb-rail-foot">${renderSendFeedback()}</div>
|
|
377
|
-
<div class="prb-rail-group prb-rail-ops">${renderStopServer()}</div>
|
|
378
502
|
</nav>`;
|
|
379
503
|
}
|
|
380
504
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
505
|
+
* What the rail carries that the strip does not, shown only while a narrow window hides the rail.
|
|
506
|
+
*
|
|
507
|
+
* Which is now the feedback form alone. Every workspace place is an icon in this same strip at
|
|
508
|
+
* every width, so a text link beside one of them would be the same destination twice; before the
|
|
509
|
+
* Reviews icon existed this is where Reviews had to go, and the filter that put it here is gone
|
|
510
|
+
* with the reason for it.
|
|
383
511
|
*/
|
|
384
|
-
function renderHeaderNav(
|
|
385
|
-
return `<nav class="prb-header-nav" aria-label="Workspace"
|
|
512
|
+
function renderHeaderNav(_options) {
|
|
513
|
+
return `<nav class="prb-header-nav" aria-label="Workspace"><a class="prb-feedback" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">Send feedback</a></nav>`;
|
|
386
514
|
}
|
|
387
515
|
//#endregion
|
|
388
516
|
//#region ../../packages/review-harness/src/domain/utils/file_path.ts
|
|
@@ -3622,7 +3750,12 @@ function renderWorkspace(view) {
|
|
|
3622
3750
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
3623
3751
|
</head>
|
|
3624
3752
|
<body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}" data-notifications-since="${Date.now()}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
|
|
3625
|
-
${
|
|
3753
|
+
${renderGlobalBar({
|
|
3754
|
+
unread: view.unreadNotifications ?? 0,
|
|
3755
|
+
live: Boolean(view.token),
|
|
3756
|
+
driving: view.driving
|
|
3757
|
+
})}
|
|
3758
|
+
${renderReviewBar(view, freshness)}
|
|
3626
3759
|
${showsProgress(view) ? "" : renderJobState(view.job ?? null)}
|
|
3627
3760
|
<div class="prb-fresh-region" data-fresh-region${freshness ? ` data-report="${escapeHtml$1(JSON.stringify(freshness))}"` : ""}${view.availability ? ` data-availability="${escapeHtml$1(JSON.stringify(view.availability))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderFreshness(freshness ?? null, {
|
|
3628
3761
|
dismissedSha: view.dismissedFreshnessSha,
|
|
@@ -3633,7 +3766,6 @@ ${renderUpdateProgress(view.updateJob)}
|
|
|
3633
3766
|
${renderUpdateOutcome(view.lastUpdate)}
|
|
3634
3767
|
${renderLineageNotice(view)}
|
|
3635
3768
|
${renderReviewBody(view, sections)}
|
|
3636
|
-
${view.token ? renderShutdownDialog() : ""}
|
|
3637
3769
|
${view.token ? renderPromptDialog() : ""}
|
|
3638
3770
|
<script type="module" src="/assets/workspace.js"><\/script>
|
|
3639
3771
|
</body>
|
|
@@ -3923,13 +4055,19 @@ function renderSubject(changeSet) {
|
|
|
3923
4055
|
${arrow}
|
|
3924
4056
|
<span>${against}</span>`;
|
|
3925
4057
|
}
|
|
3926
|
-
|
|
4058
|
+
/**
|
|
4059
|
+
* The review's own bar, below the global strip.
|
|
4060
|
+
*
|
|
4061
|
+
* Everything here is about this review and nothing else is: the strip above carries the workspace's
|
|
4062
|
+
* own controls, which is what makes room for this one to stay about the change. It is deliberately
|
|
4063
|
+
* the dominant bar of the two — the reviewer came here for the review, not for the product.
|
|
4064
|
+
*/
|
|
4065
|
+
function renderReviewBar(view, freshness) {
|
|
3927
4066
|
const { changeSet } = view;
|
|
3928
4067
|
const additions = changeSet.data.files.reduce((sum, f) => sum + f.additions, 0);
|
|
3929
4068
|
const deletions = changeSet.data.files.reduce((sum, f) => sum + f.deletions, 0);
|
|
3930
4069
|
const fileCount = changeSet.data.files.length;
|
|
3931
|
-
return `<
|
|
3932
|
-
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
4070
|
+
return `<div class="prb-header prb-review-bar">
|
|
3933
4071
|
<span class="prb-change-ref" title="${escapeHtml$1(changeSet.repoPath)}">
|
|
3934
4072
|
${changeSet.isLinkedWorktree ? `<span class="prb-checkout">${escapeHtml$1(changeSet.checkoutName)}</span>` : ""}
|
|
3935
4073
|
${view.prContext ? `<a class="prb-pr-chip" href="${escapeHtml$1(view.prContext.prContext.pullRequest.url)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml$1(view.prContext.prContext.pullRequest.title ?? "")}">#${view.prContext.prContext.pullRequest.number}</a>` : ""}
|
|
@@ -3948,7 +4086,7 @@ function renderHeader(view, freshness) {
|
|
|
3948
4086
|
${view.id ? renderIdMenu(view.id, Boolean(view.token)) : ""}
|
|
3949
4087
|
${view.session.result ? "<button type=\"button\" class=\"prb-ask-toggle\" data-ask-toggle aria-expanded=\"false\">Buddy AI</button>" : ""}
|
|
3950
4088
|
</div>
|
|
3951
|
-
</
|
|
4089
|
+
</div>`;
|
|
3952
4090
|
}
|
|
3953
4091
|
/**
|
|
3954
4092
|
* This review's ID, where somebody looking at this review would think to find it.
|
|
@@ -4006,10 +4144,6 @@ function renderRailFoot(view) {
|
|
|
4006
4144
|
return `<div class="prb-rail-group prb-rail-foot">
|
|
4007
4145
|
${view.token ? renderWorkspaceNav({ unread: view.unreadNotifications ?? 0 }) : ""}
|
|
4008
4146
|
${renderSendFeedback()}
|
|
4009
|
-
</div>
|
|
4010
|
-
<div class="prb-rail-group prb-rail-ops">
|
|
4011
|
-
${view.token ? renderStopServer() : ""}
|
|
4012
|
-
<p class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</p>
|
|
4013
4147
|
</div>`;
|
|
4014
4148
|
}
|
|
4015
4149
|
/**
|
|
@@ -4650,24 +4784,18 @@ function renderReviewsPage(view) {
|
|
|
4650
4784
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
4651
4785
|
</head>
|
|
4652
4786
|
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map())}">
|
|
4653
|
-
|
|
4654
|
-
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
4655
|
-
<span class="prb-page-title">Reviews</span>
|
|
4656
|
-
<div class="prb-header-stats">
|
|
4657
|
-
${renderHeaderNav({
|
|
4787
|
+
${renderGlobalBar({
|
|
4658
4788
|
current: "reviews",
|
|
4659
|
-
unread: view.unreadNotifications ?? 0
|
|
4789
|
+
unread: view.unreadNotifications ?? 0,
|
|
4790
|
+
driving: view.driving
|
|
4660
4791
|
})}
|
|
4661
|
-
|
|
4662
|
-
</div>
|
|
4663
|
-
</header>
|
|
4792
|
+
${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map((group) => group.latest.authorHandle))))}
|
|
4664
4793
|
<div class="prb-shell">
|
|
4665
4794
|
${renderWorkspaceRail({
|
|
4666
4795
|
current: "reviews",
|
|
4667
4796
|
unread: view.unreadNotifications ?? 0
|
|
4668
4797
|
})}
|
|
4669
4798
|
<main class="prb-index">
|
|
4670
|
-
${ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map((group) => group.latest.authorHandle)))}
|
|
4671
4799
|
${front === void 0 ? "" : renderContinue(front)}
|
|
4672
4800
|
${ordered.length === 0 ? "" : `<div class="prb-index-summary">
|
|
4673
4801
|
<p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
|
|
@@ -4682,7 +4810,6 @@ ${renderWorkspaceRail({
|
|
|
4682
4810
|
<p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Saved reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
|
|
4683
4811
|
</main>
|
|
4684
4812
|
</div>
|
|
4685
|
-
${renderShutdownDialog()}
|
|
4686
4813
|
<script src="/assets/reviews.js" type="module"><\/script>
|
|
4687
4814
|
</body>
|
|
4688
4815
|
</html>`;
|
|
@@ -5234,24 +5361,18 @@ function renderSettingsPage(view) {
|
|
|
5234
5361
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
5235
5362
|
</head>
|
|
5236
5363
|
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${Date.now()}">
|
|
5237
|
-
|
|
5238
|
-
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
5239
|
-
<span class="prb-page-title">Settings</span>
|
|
5240
|
-
<div class="prb-header-stats">
|
|
5241
|
-
${renderHeaderNav({
|
|
5364
|
+
${renderGlobalBar({
|
|
5242
5365
|
current: "settings",
|
|
5243
|
-
unread: view.unreadNotifications ?? 0
|
|
5366
|
+
unread: view.unreadNotifications ?? 0,
|
|
5367
|
+
driving: view.driving
|
|
5244
5368
|
})}
|
|
5245
|
-
|
|
5246
|
-
</div>
|
|
5247
|
-
</header>
|
|
5369
|
+
${renderContextBar("Settings", renderBack())}
|
|
5248
5370
|
<div class="prb-shell">
|
|
5249
5371
|
${renderWorkspaceRail({
|
|
5250
5372
|
current: "settings",
|
|
5251
5373
|
unread: view.unreadNotifications ?? 0
|
|
5252
5374
|
})}
|
|
5253
5375
|
<main class="prb-index prb-settings">
|
|
5254
|
-
${renderBack()}
|
|
5255
5376
|
${renderWorkspaceSection(settings, address)}
|
|
5256
5377
|
${renderReviewsSection(settings)}
|
|
5257
5378
|
${renderPrivacySection(settings)}
|
|
@@ -5264,6 +5385,8 @@ ${renderWorkspaceRail({
|
|
|
5264
5385
|
<p class="prb-index-note">These are kept in <code>~/.prreviewbuddy/config.json</code>, which you can
|
|
5265
5386
|
edit by hand, and they are the same settings <code>prreviewbuddy config</code> reads and writes.
|
|
5266
5387
|
Run <code>prreviewbuddy config path</code> to be told where that file is.</p>
|
|
5388
|
+
|
|
5389
|
+
${renderServerSection()}
|
|
5267
5390
|
</main>
|
|
5268
5391
|
</div>
|
|
5269
5392
|
${renderShutdownDialog()}
|
|
@@ -5287,6 +5410,25 @@ function renderBack() {
|
|
|
5287
5410
|
return `<a class="prb-settings-back" href="/" data-settings-back><span aria-hidden="true">‹</span> All reviews</a>`;
|
|
5288
5411
|
}
|
|
5289
5412
|
/**
|
|
5413
|
+
* Stopping the server, on the one page where stopping it is a decision rather than a stray click.
|
|
5414
|
+
*
|
|
5415
|
+
* It used to sit at the foot of every rail, one press from ending every open review, beside the
|
|
5416
|
+
* places you navigate with. It is the only action in this product the workspace cannot undo — the
|
|
5417
|
+
* page it leaves behind has no server to ask for anything — so it says outright what brings it
|
|
5418
|
+
* back, and the strip carries Restart instead, which needs no terminal at all.
|
|
5419
|
+
*
|
|
5420
|
+
* The dialog it opens is rendered once on this page. See `renderShutdownDialog`.
|
|
5421
|
+
*/
|
|
5422
|
+
function renderServerSection() {
|
|
5423
|
+
return `<section class="prb-settings-section">
|
|
5424
|
+
<h2>Server</h2>
|
|
5425
|
+
<button type="button" class="prb-settings-danger" data-shutdown>Stop server</button>
|
|
5426
|
+
<p class="prb-settings-help">Stops the local PR Review Buddy workspace. Nothing is deleted and
|
|
5427
|
+
every review keeps its address. Start it again with <code>prreviewbuddy open</code>, from any
|
|
5428
|
+
directory. Restarting instead needs no terminal: the button in the bar above does it.</p>
|
|
5429
|
+
</section>`;
|
|
5430
|
+
}
|
|
5431
|
+
/**
|
|
5290
5432
|
* The port, and the two answers to "which port" that a save creates.
|
|
5291
5433
|
*
|
|
5292
5434
|
* A running workspace keeps the port it started on, so between saving a change and restarting there
|
|
@@ -5365,39 +5507,42 @@ function renderNotificationsPage(view) {
|
|
|
5365
5507
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
5366
5508
|
</head>
|
|
5367
5509
|
<body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}">
|
|
5368
|
-
|
|
5369
|
-
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
5370
|
-
<span class="prb-page-title">Notifications</span>
|
|
5371
|
-
<div class="prb-header-stats">
|
|
5372
|
-
${renderHeaderNav({
|
|
5510
|
+
${renderGlobalBar({
|
|
5373
5511
|
current: "notifications",
|
|
5374
|
-
unread: view.unreadNotifications ?? 0
|
|
5512
|
+
unread: view.unreadNotifications ?? 0,
|
|
5513
|
+
driving: view.driving
|
|
5375
5514
|
})}
|
|
5376
|
-
|
|
5377
|
-
</div>
|
|
5378
|
-
</header>
|
|
5515
|
+
${renderContextBar("Notifications", view.notifications.length === 0 ? "" : renderReadAll(unread))}
|
|
5379
5516
|
<div class="prb-shell">
|
|
5380
5517
|
${renderWorkspaceRail({
|
|
5381
5518
|
current: "notifications",
|
|
5382
5519
|
unread: view.unreadNotifications ?? 0
|
|
5383
5520
|
})}
|
|
5384
5521
|
<main class="prb-index prb-notifications-page">
|
|
5385
|
-
${view.notifications.length === 0 ? `<p class="prb-index-empty">Nothing yet. When a review finishes or fails, it appears here.</p>` : `<
|
|
5386
|
-
<p class="prb-index-count" data-notifications-count role="status" aria-live="polite">${escapeHtml$1(unreadLabel(unread))}</p>
|
|
5387
|
-
<button type="button" class="prb-index-select" data-notifications-read-all${unread === 0 ? " disabled" : ""}>Mark all read</button>
|
|
5388
|
-
</div>
|
|
5389
|
-
<ol class="prb-notifications" data-notifications>
|
|
5522
|
+
${view.notifications.length === 0 ? `<p class="prb-index-empty">Nothing yet. When a review finishes or fails, it appears here.</p>` : `<ol class="prb-notifications" data-notifications>
|
|
5390
5523
|
${view.notifications.map((notification) => renderEntry(notification, view.gone?.has(notification.id) === true)).join("")}
|
|
5391
5524
|
</ol>`}
|
|
5392
5525
|
<p class="prb-index-note">Kept for thirty days, on this machine only.</p>
|
|
5393
5526
|
</main>
|
|
5394
5527
|
</div>
|
|
5395
|
-
${renderShutdownDialog()}
|
|
5396
5528
|
<script src="/assets/notifications.js" type="module"><\/script>
|
|
5397
5529
|
</body>
|
|
5398
5530
|
</html>`;
|
|
5399
5531
|
}
|
|
5400
5532
|
/**
|
|
5533
|
+
* The unread count and the one act that clears it, in the bar rather than above the list.
|
|
5534
|
+
*
|
|
5535
|
+
* It belongs beside the page's name because it is about the page: how many are waiting, and the
|
|
5536
|
+
* single control that answers all of them at once. A row's own link marks that row read; this marks
|
|
5537
|
+
* the rest. Absent entirely when nothing has ever arrived, because there is then nothing to count.
|
|
5538
|
+
*/
|
|
5539
|
+
function renderReadAll(unread) {
|
|
5540
|
+
return `<div class="prb-context-controls">
|
|
5541
|
+
<p class="prb-index-count" data-notifications-count role="status" aria-live="polite">${escapeHtml$1(unreadLabel(unread))}</p>
|
|
5542
|
+
<button type="button" class="prb-index-select" data-notifications-read-all${unread === 0 ? " disabled" : ""}>Mark all read</button>
|
|
5543
|
+
</div>`;
|
|
5544
|
+
}
|
|
5545
|
+
/**
|
|
5401
5546
|
* One event: what happened, then what to, then when. The whole row opens the review and so marks it
|
|
5402
5547
|
* read, which is why a row has no Mark read of its own. An event, not a card: nothing about the
|
|
5403
5548
|
* review's findings belongs here.
|
|
@@ -5549,6 +5694,32 @@ function reviewsRevision(live = analysisStates()) {
|
|
|
5549
5694
|
return hash.digest("base64url");
|
|
5550
5695
|
}
|
|
5551
5696
|
//#endregion
|
|
5697
|
+
//#region ../../packages/review-harness/src/workspace/server_work.ts
|
|
5698
|
+
/**
|
|
5699
|
+
* Whether this server process is driving any review work of its own.
|
|
5700
|
+
*
|
|
5701
|
+
* A restart ends this process, and `runJob` and `updateReview` run *inside* it when a page asks for
|
|
5702
|
+
* them — so a restart during either loses that work. A review driven from a terminal has its own
|
|
5703
|
+
* process and is untouched, which is exactly why this is a count kept here rather than a look at
|
|
5704
|
+
* the jobs on disk: `/revision`'s `live` is machine-wide, and refusing a restart because somebody
|
|
5705
|
+
* else's terminal is busy would be the control lying about what it costs.
|
|
5706
|
+
*
|
|
5707
|
+
* In memory on purpose. The question is "is this process busy", and a process cannot outlive its
|
|
5708
|
+
* own answer, so there is nothing to keep in a file and nothing that can go stale. A restart or a
|
|
5709
|
+
* crash resets it to the truth.
|
|
5710
|
+
*/
|
|
5711
|
+
var driving = 0;
|
|
5712
|
+
/** Count one piece of work for as long as it runs, however it ends. */
|
|
5713
|
+
function track(work) {
|
|
5714
|
+
driving += 1;
|
|
5715
|
+
return work.finally(() => {
|
|
5716
|
+
driving -= 1;
|
|
5717
|
+
});
|
|
5718
|
+
}
|
|
5719
|
+
function isDriving() {
|
|
5720
|
+
return driving > 0;
|
|
5721
|
+
}
|
|
5722
|
+
//#endregion
|
|
5552
5723
|
//#region ../../packages/review-harness/src/workspace/ask.ts
|
|
5553
5724
|
/** Long enough for a real look at the repository; short enough that a hang is not forever. */
|
|
5554
5725
|
var ASK_TIMEOUT_MS = 18e4;
|
|
@@ -6367,7 +6538,8 @@ async function handle(req, res, options) {
|
|
|
6367
6538
|
if (url.pathname === "/health") return send(res, 200, {
|
|
6368
6539
|
ok: true,
|
|
6369
6540
|
version: BUILD_VERSION,
|
|
6370
|
-
assets: ASSETS.size
|
|
6541
|
+
assets: ASSETS.size,
|
|
6542
|
+
startedAt: startedAt$1(options)
|
|
6371
6543
|
});
|
|
6372
6544
|
if (url.pathname.startsWith("/assets/")) return sendAsset(res, url.pathname);
|
|
6373
6545
|
if (url.pathname === "/") return handleIndex(req, res, url.searchParams, options.indexToken);
|
|
@@ -6382,6 +6554,7 @@ async function handle(req, res, options) {
|
|
|
6382
6554
|
if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
|
|
6383
6555
|
if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
|
|
6384
6556
|
if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
|
|
6557
|
+
if (url.pathname === "/restart") return handleIndexRestart(req, res, options);
|
|
6385
6558
|
const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
|
|
6386
6559
|
if (!match) return send(res, 404, { error: "Not found" });
|
|
6387
6560
|
const [, id, action] = match;
|
|
@@ -6408,7 +6581,7 @@ async function handle(req, res, options) {
|
|
|
6408
6581
|
if (action === "/event") return handleEvent(req, res, workspace);
|
|
6409
6582
|
if (action === "/prefs") return handlePrefs(req, res);
|
|
6410
6583
|
if (action === "/prompts") return handlePrompts(req, res);
|
|
6411
|
-
if (action === "/
|
|
6584
|
+
if (action === "/restart") return handleRestart(req, res, options);
|
|
6412
6585
|
return send(res, 404, { error: "Not found" });
|
|
6413
6586
|
}
|
|
6414
6587
|
/**
|
|
@@ -6477,7 +6650,8 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
6477
6650
|
queue: queue.filter((id) => rendered.has(id)),
|
|
6478
6651
|
analyses: states,
|
|
6479
6652
|
revision: reviewsRevision(states),
|
|
6480
|
-
unreadNotifications: unreadCount$1()
|
|
6653
|
+
unreadNotifications: unreadCount$1(),
|
|
6654
|
+
driving: isDriving()
|
|
6481
6655
|
}), cookie);
|
|
6482
6656
|
}
|
|
6483
6657
|
/**
|
|
@@ -6564,7 +6738,8 @@ async function handleSettings(req, res, params, indexToken) {
|
|
|
6564
6738
|
}
|
|
6565
6739
|
sendHtml(res, 200, renderSettingsPage({
|
|
6566
6740
|
settings: readSettings(),
|
|
6567
|
-
unreadNotifications: unreadCount$1()
|
|
6741
|
+
unreadNotifications: unreadCount$1(),
|
|
6742
|
+
driving: isDriving()
|
|
6568
6743
|
}), indexCookieHeader(indexToken));
|
|
6569
6744
|
}
|
|
6570
6745
|
/**
|
|
@@ -6646,7 +6821,8 @@ async function handleNotifications(req, res, params, indexToken) {
|
|
|
6646
6821
|
sendHtml(res, 200, renderNotificationsPage({
|
|
6647
6822
|
notifications,
|
|
6648
6823
|
gone: new Set(notifications.filter((entry) => !loadWorkspace(entry.reviewId)).map((entry) => entry.id)),
|
|
6649
|
-
unreadNotifications: unreadCount$1()
|
|
6824
|
+
unreadNotifications: unreadCount$1(),
|
|
6825
|
+
driving: isDriving()
|
|
6650
6826
|
}), indexCookieHeader(indexToken));
|
|
6651
6827
|
}
|
|
6652
6828
|
/**
|
|
@@ -6732,6 +6908,7 @@ function handleRevision(req, res, indexToken) {
|
|
|
6732
6908
|
send(res, 200, {
|
|
6733
6909
|
revision: reviewsRevision(states),
|
|
6734
6910
|
live: anythingRunning(states),
|
|
6911
|
+
driving: isDriving(),
|
|
6735
6912
|
unread: unreadCount$1()
|
|
6736
6913
|
});
|
|
6737
6914
|
}
|
|
@@ -7009,6 +7186,7 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
7009
7186
|
prompts: readPromptBank(),
|
|
7010
7187
|
complete: workspace.completedAt !== void 0,
|
|
7011
7188
|
unreadNotifications: unreadCount$1(),
|
|
7189
|
+
driving: isDriving(),
|
|
7012
7190
|
prContext: workspace.prContext ? {
|
|
7013
7191
|
prContext: workspace.prContext,
|
|
7014
7192
|
processed: processDiscussion(workspace.prContext.discussion)
|
|
@@ -7213,7 +7391,7 @@ async function handleRefreshPr(res, workspace) {
|
|
|
7213
7391
|
async function handleUpdate(res, workspace) {
|
|
7214
7392
|
let outcome;
|
|
7215
7393
|
try {
|
|
7216
|
-
outcome = await updateReview(workspace);
|
|
7394
|
+
outcome = await track(updateReview(workspace));
|
|
7217
7395
|
} catch (error) {
|
|
7218
7396
|
if (error instanceof UpdateAlreadyRunningError) return send(res, 409, { error: error.message });
|
|
7219
7397
|
outcome = {
|
|
@@ -7266,7 +7444,7 @@ async function handleRetry(res, workspace) {
|
|
|
7266
7444
|
if (!workspace.jobId) return send(res, 404, { error: "No job for this review." });
|
|
7267
7445
|
const job = loadJob(workspace.jobId);
|
|
7268
7446
|
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." });
|
|
7269
|
-
runJob(workspace.id).catch(() => {});
|
|
7447
|
+
track(runJob(workspace.id)).catch(() => {});
|
|
7270
7448
|
send(res, 200, { ok: true });
|
|
7271
7449
|
}
|
|
7272
7450
|
/**
|
|
@@ -7485,6 +7663,42 @@ function handleIndexShutdown(req, res, options) {
|
|
|
7485
7663
|
if (req.method !== "POST") return send(res, 405, { error: "Stopping the server is a POST." });
|
|
7486
7664
|
handleShutdown(res, options);
|
|
7487
7665
|
}
|
|
7666
|
+
/**
|
|
7667
|
+
* When this server began, for anyone asking whether it is still the same one.
|
|
7668
|
+
*
|
|
7669
|
+
* Fixed per process. A server created without being told falls back to when it was created, which
|
|
7670
|
+
* is the same answer for every purpose this serves.
|
|
7671
|
+
*/
|
|
7672
|
+
var CREATED_AT = Date.now();
|
|
7673
|
+
function startedAt$1(options) {
|
|
7674
|
+
return options.startedAt ?? CREATED_AT;
|
|
7675
|
+
}
|
|
7676
|
+
function handleIndexRestart(req, res, options) {
|
|
7677
|
+
if (!options.indexToken || !secretMatches(options.indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
7678
|
+
handleRestart(req, res, options);
|
|
7679
|
+
}
|
|
7680
|
+
/**
|
|
7681
|
+
* Restart this server: the everyday, recoverable lifecycle action.
|
|
7682
|
+
*
|
|
7683
|
+
* Refused while this process is driving review work of its own, because that work runs here and
|
|
7684
|
+
* would die with the process. Work driven from a terminal is another process and is none of this
|
|
7685
|
+
* one's business — which is why the guard is `isDriving()` and not the machine-wide `live`.
|
|
7686
|
+
*
|
|
7687
|
+
* The answer goes out before anything happens, like a shutdown's, and carries the `startedAt` the
|
|
7688
|
+
* caller is replacing: the page watches `/health` for that number to change, which is the only way
|
|
7689
|
+
* from a browser to tell a handover that worked from one that failed and left this server up.
|
|
7690
|
+
*/
|
|
7691
|
+
function handleRestart(req, res, options) {
|
|
7692
|
+
if (req.method !== "POST") return send(res, 405, { error: "Restarting the server is a POST." });
|
|
7693
|
+
if (isDriving()) return send(res, 409, { error: "This server is making a review right now. Restarting it would end that; it can be restarted when the current work finishes." });
|
|
7694
|
+
const restart = options.onRestart;
|
|
7695
|
+
if (!restart) return send(res, 500, { error: "This server cannot restart itself." });
|
|
7696
|
+
if (!restart()) return send(res, 500, { error: "The workspace server could not start a replacement, so it is still running. Nothing was stopped." });
|
|
7697
|
+
send(res, 200, {
|
|
7698
|
+
ok: true,
|
|
7699
|
+
startedAt: startedAt$1(options)
|
|
7700
|
+
});
|
|
7701
|
+
}
|
|
7488
7702
|
function handleShutdown(res, options) {
|
|
7489
7703
|
send(res, 200, { ok: true });
|
|
7490
7704
|
const stop = options.onShutdown ?? (() => process.exit(0));
|
|
@@ -7781,17 +7995,107 @@ function startSweeping() {
|
|
|
7781
7995
|
timer.unref();
|
|
7782
7996
|
return () => clearInterval(timer);
|
|
7783
7997
|
}
|
|
7998
|
+
//#endregion
|
|
7999
|
+
//#region ../../packages/review-harness/src/cli/server.ts
|
|
8000
|
+
/**
|
|
8001
|
+
* The workspace server process.
|
|
8002
|
+
*
|
|
8003
|
+
* Started detached by `ensureServer` and left running, so a workspace stays usable after the
|
|
8004
|
+
* Claude Code session that produced it has closed. One server serves every review; individual
|
|
8005
|
+
* reviews are addressed by id and gated by their own token.
|
|
8006
|
+
*/
|
|
8007
|
+
var indexToken = readIndexToken();
|
|
8008
|
+
/** The same budget the launcher gives a server it starts; this is the same start, from here. */
|
|
8009
|
+
var RESTART_TIMEOUT_MS = 15e3;
|
|
8010
|
+
/**
|
|
8011
|
+
* When this process began serving. One value, used by the record and published on `/health`, so a
|
|
8012
|
+
* browser watching a restart can tell the replacement from the server it asked to replace.
|
|
8013
|
+
*/
|
|
8014
|
+
var startedAt = Date.now();
|
|
7784
8015
|
var server = createWorkspaceServer({
|
|
7785
|
-
indexToken
|
|
7786
|
-
|
|
8016
|
+
indexToken,
|
|
8017
|
+
startedAt,
|
|
8018
|
+
onShutdown: () => process.kill(process.pid, "SIGTERM"),
|
|
8019
|
+
onRestart: beginRestart
|
|
7787
8020
|
});
|
|
8021
|
+
/**
|
|
8022
|
+
* Hand this port over to a fresh process, or refuse and stay exactly as we are.
|
|
8023
|
+
*
|
|
8024
|
+
* The ordering is the whole design, and none of it is interchangeable:
|
|
8025
|
+
*
|
|
8026
|
+
* 1. Refuse outright if there is nothing to start. A restart that tore this server down and then
|
|
8027
|
+
* failed would leave no server at all and no way back except a terminal — the very thing a
|
|
8028
|
+
* restart button exists to save someone from.
|
|
8029
|
+
* 2. `server.close()` first, so the listener is released, then `closeAllConnections()` to cut the
|
|
8030
|
+
* keep-alive sockets a browser is holding. Without the second the close never completes: the
|
|
8031
|
+
* page that asked for the restart is itself the connection keeping it open.
|
|
8032
|
+
* 3. Spawn only from inside the close callback. Spawning alongside it would race the replacement
|
|
8033
|
+
* against a port this process still owns, and the loser exits on a bind error.
|
|
8034
|
+
* 4. Exit only once the replacement is answering. If it never does, resume listening and put the
|
|
8035
|
+
* record back: a failed restart must be a no-op, not an outage.
|
|
8036
|
+
*
|
|
8037
|
+
* Deferred by a tick so the 200 reaches the browser before the listener closes.
|
|
8038
|
+
*/
|
|
8039
|
+
function beginRestart() {
|
|
8040
|
+
if (!serverEntryExists()) return false;
|
|
8041
|
+
setTimeout(handOver, 50).unref();
|
|
8042
|
+
return true;
|
|
8043
|
+
}
|
|
8044
|
+
function handOver() {
|
|
8045
|
+
stopSweeping();
|
|
8046
|
+
clearServerRecord();
|
|
8047
|
+
server.close(() => void replaceThisProcess());
|
|
8048
|
+
server.closeAllConnections();
|
|
8049
|
+
}
|
|
8050
|
+
async function replaceThisProcess() {
|
|
8051
|
+
const port = resolveWorkspacePort();
|
|
8052
|
+
spawnServer();
|
|
8053
|
+
if (await waitForReplacement(port)) process.exit(0);
|
|
8054
|
+
server.listen(port, "127.0.0.1", () => {
|
|
8055
|
+
writeServerRecord({
|
|
8056
|
+
pid: process.pid,
|
|
8057
|
+
port,
|
|
8058
|
+
startedAt
|
|
8059
|
+
});
|
|
8060
|
+
});
|
|
8061
|
+
stopSweeping = startSweeping();
|
|
8062
|
+
}
|
|
8063
|
+
/**
|
|
8064
|
+
* The replacement is a different process, so a different `startedAt` is what proves it is there.
|
|
8065
|
+
*
|
|
8066
|
+
* Deliberately *not* `checkHealth`'s `'ours'`, which means "reports this build's version". The
|
|
8067
|
+
* commonest reason to restart at all is that the file on disk is a newer build than this process,
|
|
8068
|
+
* and that replacement reports a version this one has never heard of. Waiting for `'ours'` would
|
|
8069
|
+
* time out precisely when the restart had worked, and this process would then fight its own
|
|
8070
|
+
* successor for the port.
|
|
8071
|
+
*
|
|
8072
|
+
* A number that is not ours is enough: nothing else on this machine answers `/health` with a
|
|
8073
|
+
* `startedAt`, and a foreign server gives us `null`.
|
|
8074
|
+
*/
|
|
8075
|
+
async function waitForReplacement(port) {
|
|
8076
|
+
const deadline = Date.now() + RESTART_TIMEOUT_MS;
|
|
8077
|
+
while (Date.now() < deadline) {
|
|
8078
|
+
const since = await servingSince(port);
|
|
8079
|
+
if (since !== null && since !== startedAt) return true;
|
|
8080
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
8081
|
+
}
|
|
8082
|
+
return false;
|
|
8083
|
+
}
|
|
8084
|
+
async function servingSince(port) {
|
|
8085
|
+
try {
|
|
8086
|
+
const payload = await (await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1e3) })).json();
|
|
8087
|
+
return typeof payload.startedAt === "number" ? payload.startedAt : null;
|
|
8088
|
+
} catch {
|
|
8089
|
+
return null;
|
|
8090
|
+
}
|
|
8091
|
+
}
|
|
7788
8092
|
server.listen(resolveWorkspacePort(), "127.0.0.1", () => {
|
|
7789
8093
|
const address = server.address();
|
|
7790
8094
|
if (address === null || typeof address === "string") process.exit(1);
|
|
7791
8095
|
writeServerRecord({
|
|
7792
8096
|
pid: process.pid,
|
|
7793
8097
|
port: address.port,
|
|
7794
|
-
startedAt
|
|
8098
|
+
startedAt
|
|
7795
8099
|
});
|
|
7796
8100
|
});
|
|
7797
8101
|
server.on("error", () => process.exit(1));
|
|
@@ -7800,6 +8104,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => {
|
|
|
7800
8104
|
stopSweeping();
|
|
7801
8105
|
clearServerRecord();
|
|
7802
8106
|
server.close(() => process.exit(0));
|
|
8107
|
+
server.closeAllConnections();
|
|
7803
8108
|
});
|
|
7804
8109
|
//#endregion
|
|
7805
8110
|
export {};
|