prreviewbuddy 0.25.8 → 0.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -0
- package/dist/{relative_time-D760FOL9.js → delete_review-B3xciJ03.js} +5198 -4577
- package/dist/main.js +914 -308
- package/dist/server.js +647 -104
- package/package.json +1 -1
- package/static/chunk-B80S6zbH.js +4 -4
- package/static/chunk-CIsM3PMS.js +1 -0
- package/static/{chunk-B9H2wUlQ.js → chunk-gBDI1-Cy.js} +1 -1
- package/static/reviews.js +1 -1
- package/static/settings.js +1 -1
- package/static/shell.css +55 -1
- package/static/workspace.js +42 -42
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as fillFileUrlTemplate, $t as agentFor, A as removeWorktree, At as workspaceRevision, C as recordTelemetryUploadConsent, Ct as recentReviewGroups, D as ReviewBeingDeletedError, Dt as saveWorkspace, E as EXPLAIN_SIMPLY_PROMPT, F as clearServerRecord, Ft as issuesOutstanding, G as DEFAULT_WORKSPACE_PORT, Gt as clearAgentPreference, H as BUILD_VERSION, Ht as git, It as questionsOutstanding, J as parseWorkspacePort, K as MAX_WORKSPACE_PORT, Kt as readAgentPreference, L as readServerRecord, Lt as reviewerDispositions, M as relativeTime$1, Mt as doneVerb, N as readIndexToken, Nt as isIssueOutstanding, O as MANAGED_ROOT, Pt as isQuestionOutstanding, Rt as processDiscussion, S as record, St as previousKissResult, T as withUsageRecorded, Tt as reviewedCommit, V as writeServerRecord, W as feedbackUrl, Wt as resolveModel, X as statedWorkspacePort, Xt as AGENT_IDS, Y as resolveWorkspacePort, Yt as STORE_ROOT, Z as writePortPreference, _ as startKissJob, a as wasBlocked, at as isTerminal, c as updateReview, d as askCheckout, dt as purposeOf, et as checkCodeFreshness, f as UpdateAlreadyRunningError, gt as latestKissRun, h as agentEnvOf, i as liveJobsFor, it as fail, jt as workspaceStamps, k as readMarker, kt as touchWorkspace, l as refreshPrContext, lt as phasesFor, mt as followedRefName, n as deleteReview, nn as AgentUnavailableError, nt as allJobs, o as discardJob, ot as loadJob, p as liveUpdateFor, pt as isClaimed, q as MIN_WORKSPACE_PORT, qt as writeAgentPreference, r as lineageIds, rt as endedAt, s as RefreshUnavailableError, st as saveJob, t as deleteLineage, tn as AgentCancelledError, tt as checkFreshness, u as isUnchanged, ut as progressSteps, vt as lineagePosition, w as telemetryUploadConsent, x as readEvents, y as runJob, yt as loadWorkspace, zt as describeAuthorship } from "./delete_review-B3xciJ03.js";
|
|
2
2
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { createServer } from "node:http";
|
|
7
7
|
//#region ../../packages/review-harness/src/domain/ai_review/review_composition.ts
|
|
@@ -571,6 +571,119 @@ function humanizeModelId(id) {
|
|
|
571
571
|
return display;
|
|
572
572
|
}
|
|
573
573
|
//#endregion
|
|
574
|
+
//#region ../../packages/review-harness/src/domain/utils/agent_label.ts
|
|
575
|
+
/**
|
|
576
|
+
* What a person is shown when a review names the tool that produced it.
|
|
577
|
+
*
|
|
578
|
+
* Here in the domain rather than read off `AgentIdentity.displayName`, because this runs in the
|
|
579
|
+
* extension bundle and the adapter registry reaches `node:child_process`. Deriving the label from
|
|
580
|
+
* the stored `agentId` keeps the client free of node builtins and adds no stored field.
|
|
581
|
+
*
|
|
582
|
+
* A closed set of this product's own adapter ids, which is not the kind of table that goes stale
|
|
583
|
+
* invisibly: adding an adapter is our own act, and anything unrecognised still renders through
|
|
584
|
+
* `humanizeModelId` rather than disappearing. That is why this is not folded into
|
|
585
|
+
* `humanizeModelId` itself, whose value is knowing nothing about which names exist.
|
|
586
|
+
*/
|
|
587
|
+
var AGENT_DISPLAY_NAMES = {
|
|
588
|
+
claude: "Claude Code",
|
|
589
|
+
codex: "Codex",
|
|
590
|
+
gemini: "Gemini CLI"
|
|
591
|
+
};
|
|
592
|
+
/**
|
|
593
|
+
* Absent reads as Claude, matching `agentIdOf` and `agentName`: a session written before agents
|
|
594
|
+
* were selectable ran under Claude, and showing it as unknown would invent a gap in the oldest
|
|
595
|
+
* reviews on disk.
|
|
596
|
+
*/
|
|
597
|
+
var DEFAULT_AGENT_DISPLAY_NAME = AGENT_DISPLAY_NAMES.claude;
|
|
598
|
+
function agentDisplayName(agentId) {
|
|
599
|
+
if (!agentId) return DEFAULT_AGENT_DISPLAY_NAME;
|
|
600
|
+
return AGENT_DISPLAY_NAMES[agentId] ?? humanizeModelId(agentId);
|
|
601
|
+
}
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region ../../packages/review-harness/src/domain/ai_review/model_provenance.ts
|
|
604
|
+
/**
|
|
605
|
+
* Values `model` carried when it meant "which agent", before the field was frozen.
|
|
606
|
+
*
|
|
607
|
+
* A closed set of three of our own historical strings, not a catalogue of vendor models, so it
|
|
608
|
+
* never grows and never goes stale. It lives here rather than in `humanizeModelId` on purpose:
|
|
609
|
+
* the corruption belongs to how sessions were once written, and that label function's whole value
|
|
610
|
+
* is knowing nothing about which models exist.
|
|
611
|
+
*/
|
|
612
|
+
var LEGACY_AGENT_IDS = /* @__PURE__ */ new Set([
|
|
613
|
+
"codex",
|
|
614
|
+
"claude-code",
|
|
615
|
+
"gemini-cli"
|
|
616
|
+
]);
|
|
617
|
+
var present = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
618
|
+
function modelProvenanceOf(session) {
|
|
619
|
+
return {
|
|
620
|
+
requestedModel: present(session.requestedModel) ?? legacyModel(session.model),
|
|
621
|
+
observedModel: present(session.observedModel),
|
|
622
|
+
agentVersion: present(session.agentVersion)
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* A legacy `model`, read as a request or discarded.
|
|
627
|
+
*
|
|
628
|
+
* The extension put the model it was about to call into this field, so for its sessions the value
|
|
629
|
+
* really is what was requested. The CLI put an agent id there, which is not a model at all and is
|
|
630
|
+
* dropped rather than displayed: showing "Reviewed with Codex, model Codex" would be the record
|
|
631
|
+
* inventing provenance out of its own filing.
|
|
632
|
+
*/
|
|
633
|
+
function legacyModel(model) {
|
|
634
|
+
const value = present(model);
|
|
635
|
+
if (value === null || LEGACY_AGENT_IDS.has(value)) return null;
|
|
636
|
+
return value;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Whether a coding agent made this, as opposed to the extension calling a provider API.
|
|
640
|
+
*
|
|
641
|
+
* The awkward case is a session with no `agentId` at all, because two different producers leave it
|
|
642
|
+
* empty. A CLI review from before agents were selectable means Claude, which is why
|
|
643
|
+
* `agentDisplayName` defaults that way. An extension review means no agent ran at all, and naming
|
|
644
|
+
* one would credit Claude Code for somebody's OpenAI review.
|
|
645
|
+
*
|
|
646
|
+
* The legacy sentinel tells them apart, which is the last job that field has: only the CLI ever
|
|
647
|
+
* wrote one, so a record with no agent id and no sentinel came from the extension.
|
|
648
|
+
*/
|
|
649
|
+
function producedByAgent(session) {
|
|
650
|
+
if (session.agentId) return true;
|
|
651
|
+
const legacy = present(session.model);
|
|
652
|
+
return legacy !== null && LEGACY_AGENT_IDS.has(legacy);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The full provenance, for the disclosure rather than the page.
|
|
656
|
+
*
|
|
657
|
+
* Opens with "Reviewed with X" rather than "Agent: X", keeping the sentence that was already here:
|
|
658
|
+
* a review is not owned by an agent, it holds analyses, and this names the one being shown.
|
|
659
|
+
*
|
|
660
|
+
* The line that earns this list its place is the last one: a pinned model that nobody confirmed.
|
|
661
|
+
* `--model X` states what we asked for, and on an agent that reports nothing back there is no way
|
|
662
|
+
* to know it was honoured, so the pin is shown alongside the fact that it is unverified. Without
|
|
663
|
+
* that line a reader would take a requested model for a measured one, which is the overstatement
|
|
664
|
+
* this whole split exists to prevent.
|
|
665
|
+
*
|
|
666
|
+
* Said only when something was pinned. On an unpinned run there is no claim outstanding, and a
|
|
667
|
+
* standing "not reported" would be noise on every review anybody ever runs.
|
|
668
|
+
*
|
|
669
|
+
* Both sentences that name an agent are withheld from a review no agent produced, through the same
|
|
670
|
+
* `producedByAgent` distinction `attributionLabel` makes. An extension review has no agent id, and
|
|
671
|
+
* naming one anyway credited Claude Code for somebody's OpenAI review and then reported that Claude
|
|
672
|
+
* Code had not confirmed a model nobody had asked it for. What remains for such a review is the
|
|
673
|
+
* model it requested and the model that answered, which is the whole of what is known about it.
|
|
674
|
+
*/
|
|
675
|
+
function provenanceDetailLines(session) {
|
|
676
|
+
const agent = producedByAgent(session) ? agentDisplayName(session.agentId) : null;
|
|
677
|
+
const { requestedModel, observedModel, agentVersion } = modelProvenanceOf(session);
|
|
678
|
+
return [
|
|
679
|
+
...agent ? [`Reviewed with ${agent}`] : [],
|
|
680
|
+
...agentVersion ? [`Agent version ${agentVersion}`] : [],
|
|
681
|
+
...requestedModel ? [`Requested model ${humanizeModelId(requestedModel)}`] : [],
|
|
682
|
+
...observedModel ? [`Reported model ${humanizeModelId(observedModel)}`] : [],
|
|
683
|
+
...agent && requestedModel && !observedModel ? [`Reported model not reported by ${agent}`] : []
|
|
684
|
+
];
|
|
685
|
+
}
|
|
686
|
+
//#endregion
|
|
574
687
|
//#region ../../packages/review-harness/src/domain/ui/components/pill_tooltips.ts
|
|
575
688
|
/**
|
|
576
689
|
* What every pill in the sidebar actually means, in one place.
|
|
@@ -881,7 +994,8 @@ var CANCEL_BUTTON = `<button type="button" class="pr-helper-cancel-button">Cance
|
|
|
881
994
|
function renderGeneratingOverview(session, iconUrl) {
|
|
882
995
|
const activeIndex = session.progress.findIndex((step) => !step.done);
|
|
883
996
|
const brandMark = iconUrl ? `<img src="${escapeHtml$1(iconUrl)}" class="pr-helper-brand-mark" alt="" />` : "";
|
|
884
|
-
const
|
|
997
|
+
const pickedModel = modelProvenanceOf(session).requestedModel;
|
|
998
|
+
const poweredByName = session.provider === "openai_compatible" && pickedModel ? humanizeModelId(pickedModel) : PROVIDER_DISPLAY_NAME[session.provider];
|
|
885
999
|
const isThinking = activeIndex === 2;
|
|
886
1000
|
return `
|
|
887
1001
|
<div class="pr-helper-generating pr-helper-generating-focused">
|
|
@@ -991,8 +1105,7 @@ function coverageDetails(session) {
|
|
|
991
1105
|
* agent: it holds analyses, and this names the one it is currently showing.
|
|
992
1106
|
*/
|
|
993
1107
|
function agentDetails(session) {
|
|
994
|
-
|
|
995
|
-
return name ? [`Reviewed with ${name}`] : [];
|
|
1108
|
+
return provenanceDetailLines(session);
|
|
996
1109
|
}
|
|
997
1110
|
/**
|
|
998
1111
|
* Everything the Analysis details disclosure says, as facts rather than as markup.
|
|
@@ -2144,9 +2257,9 @@ function verdictLabel(verdict) {
|
|
|
2144
2257
|
*/
|
|
2145
2258
|
var PHASE_CAVEAT = { preparing: "This can take a minute on a large repository." };
|
|
2146
2259
|
/** The shared phase name as a sentence, with whatever this surface adds to it. */
|
|
2147
|
-
function phaseCopy(phase) {
|
|
2148
|
-
const caveat = PHASE_CAVEAT[phase];
|
|
2149
|
-
return `${
|
|
2260
|
+
function phaseCopy(phase, job) {
|
|
2261
|
+
const caveat = purposeOf(job) === "review" ? PHASE_CAVEAT[phase] : void 0;
|
|
2262
|
+
return `${phasesFor(job)[phase].doing}.${caveat ? ` ${caveat}` : ""}`;
|
|
2150
2263
|
}
|
|
2151
2264
|
/**
|
|
2152
2265
|
* The same phases, as a noun, for the retry sentence.
|
|
@@ -2204,7 +2317,7 @@ function conversationNote$1(outcome) {
|
|
|
2204
2317
|
* than a gap where a sentence should be.
|
|
2205
2318
|
*/
|
|
2206
2319
|
function phaseSentence(job) {
|
|
2207
|
-
return job && isWorkPhase(job.phase) ? phaseCopy(job.phase) : "";
|
|
2320
|
+
return job && isWorkPhase(job.phase) ? phaseCopy(job.phase, job) : "";
|
|
2208
2321
|
}
|
|
2209
2322
|
/**
|
|
2210
2323
|
* A stopped run: what happened, and what would change it.
|
|
@@ -2220,6 +2333,9 @@ function phaseSentence(job) {
|
|
|
2220
2333
|
function renderFailureBody(job, options = {}) {
|
|
2221
2334
|
if (!job.failure) return "";
|
|
2222
2335
|
const noun = isWorkPhase(job.failure.phase) ? PHASE_NOUN[job.failure.phase] : job.failure.phase;
|
|
2336
|
+
if (job.failure.stopped) return `<p>${options.lead === false ? "" : "<strong>Review stopped.</strong> "}Nothing went wrong, and what it had finished is kept.</p>
|
|
2337
|
+
<button type="button" class="prb-job-retry" data-job-retry>Carry on</button>
|
|
2338
|
+
<p class="prb-job-note">It picks up from the ${escapeHtml$1(noun)}, not from the start.</p>`;
|
|
2223
2339
|
const next = job.failure.kind === "agent_authentication_failed" ? `<p class="prb-job-note">Signing that agent in is what changes this. Once it is signed in, update the review.</p>` : `<button type="button" class="prb-job-retry" data-job-retry>Retry</button>
|
|
2224
2340
|
<p class="prb-job-note">Retry picks up from the ${escapeHtml$1(noun)}, not from the start.</p>`;
|
|
2225
2341
|
return `<p>${options.lead === false ? "" : "<strong>Review paused.</strong> "}${escapeHtml$1(job.failure.message)}</p>
|
|
@@ -2248,7 +2364,7 @@ function renderJobState(job) {
|
|
|
2248
2364
|
<p>${escapeHtml$1(note)}</p>
|
|
2249
2365
|
</div>`;
|
|
2250
2366
|
}
|
|
2251
|
-
const copy = isWorkPhase(job.phase) ? phaseCopy(job.phase) : "";
|
|
2367
|
+
const copy = isWorkPhase(job.phase) ? phaseCopy(job.phase, job) : "";
|
|
2252
2368
|
const progress = job.progress ? ` <span class="prb-job-progress">${escapeHtml$1(job.progress)}</span>` : "";
|
|
2253
2369
|
return `<div class="prb-job" data-job-phase="${escapeHtml$1(job.phase)}" role="status" aria-live="polite">
|
|
2254
2370
|
<p><span class="prb-spinner" aria-hidden="true"></span> ${escapeHtml$1(copy)}${progress}</p>
|
|
@@ -2261,7 +2377,7 @@ function jobStatus(job) {
|
|
|
2261
2377
|
progress: "",
|
|
2262
2378
|
failed: false
|
|
2263
2379
|
};
|
|
2264
|
-
const progress = job.phase === "failed" ? job.failure?.message ?? "" : job.progress ?? (isWorkPhase(job.phase) ? phaseCopy(job.phase) : "");
|
|
2380
|
+
const progress = job.phase === "failed" ? job.failure?.message ?? "" : job.progress ?? (isWorkPhase(job.phase) ? phaseCopy(job.phase, job) : "");
|
|
2265
2381
|
return {
|
|
2266
2382
|
phase: job.phase,
|
|
2267
2383
|
progress,
|
|
@@ -2584,7 +2700,7 @@ function provenanceFacts(run) {
|
|
|
2584
2700
|
run.completedAt ? `Completed ${relativeTime$1(run.completedAt)}` : "",
|
|
2585
2701
|
`Commit ${run.reviewedSha.slice(0, 7)}`,
|
|
2586
2702
|
audienceSentence(run.audience),
|
|
2587
|
-
run.agentId ? `Analysed by ${run.agentId}${
|
|
2703
|
+
run.agentId ? `Analysed by ${agentDisplayName(run.agentId)}${modelSuffix(run)}` : "",
|
|
2588
2704
|
`Rubric version ${run.promptVersion}`
|
|
2589
2705
|
].filter((fact) => fact !== "");
|
|
2590
2706
|
}
|
|
@@ -2725,6 +2841,22 @@ function renderLocationList(locations) {
|
|
|
2725
2841
|
</button></li>`;
|
|
2726
2842
|
}).join("")}</ul>`;
|
|
2727
2843
|
}
|
|
2844
|
+
/**
|
|
2845
|
+
* The model this run ran under, as far as it is known.
|
|
2846
|
+
*
|
|
2847
|
+
* Observed over requested, which is the precedence `attributionLabel` applies to a standard
|
|
2848
|
+
* review's line and the reason this reads `modelProvenanceOf` rather than a field: what the agent
|
|
2849
|
+
* said answered outranks what it was asked for, and only one of the two is ever a claim about what
|
|
2850
|
+
* actually happened. Neither known leaves the agent to stand alone rather than inventing a pin.
|
|
2851
|
+
*
|
|
2852
|
+
* `modelProvenanceOf` is also what keeps the frozen `model` out of here, which on a KISS run only
|
|
2853
|
+
* ever held an agent id: "Analysed by Codex (Codex)" was the shape that produced.
|
|
2854
|
+
*/
|
|
2855
|
+
function modelSuffix(run) {
|
|
2856
|
+
const { requestedModel, observedModel } = modelProvenanceOf(run);
|
|
2857
|
+
const model = observedModel ?? requestedModel;
|
|
2858
|
+
return model ? ` (${humanizeModelId(model)})` : "";
|
|
2859
|
+
}
|
|
2728
2860
|
//#endregion
|
|
2729
2861
|
//#region ../../packages/review-harness/src/workspace/update_view.ts
|
|
2730
2862
|
/**
|
|
@@ -3121,9 +3253,10 @@ function changeIsRead(job) {
|
|
|
3121
3253
|
* second renderer, and the two would disagree the first time either changed.
|
|
3122
3254
|
*/
|
|
3123
3255
|
function renderSteps(job) {
|
|
3256
|
+
const names = phasesFor(job);
|
|
3124
3257
|
return `<ol class="prb-progress-steps">${progressSteps(job).map((step) => {
|
|
3125
3258
|
const note = step.note ? `<p class="prb-progress-note" data-step-note>${escapeHtml$1(step.note)}</p>` : "";
|
|
3126
|
-
return `<li class="prb-progress-step" data-phase="${escapeHtml$1(step.phase)}" data-state="${escapeHtml$1(step.state)}" data-label-doing="${escapeHtml$1(
|
|
3259
|
+
return `<li class="prb-progress-step" data-phase="${escapeHtml$1(step.phase)}" data-state="${escapeHtml$1(step.state)}" data-label-doing="${escapeHtml$1(names[step.phase].doing)}" data-label-done="${escapeHtml$1(names[step.phase].done)}">
|
|
3127
3260
|
<span class="prb-progress-mark" aria-hidden="true"></span>
|
|
3128
3261
|
<span class="prb-progress-label">${escapeHtml$1(step.label)}</span>
|
|
3129
3262
|
${note}
|
|
@@ -3165,6 +3298,25 @@ function renderProgressView(job, fileCount) {
|
|
|
3165
3298
|
${renderAvailable(job, fileCount)}
|
|
3166
3299
|
<p class="prb-progress-elapsed" data-started-at="${job.startedAt}">Running for ${escapeHtml$1(elapsedWords(job.startedAt))}.</p>`;
|
|
3167
3300
|
}
|
|
3301
|
+
/**
|
|
3302
|
+
* An update running over the review it is updating.
|
|
3303
|
+
*
|
|
3304
|
+
* A strip above the review rather than the progress pane, because nothing here is missing. The
|
|
3305
|
+
* review below is still the current one until the update applies in a single write, so it stays
|
|
3306
|
+
* readable and in place; taking the page over would hide the one thing worth reading while waiting.
|
|
3307
|
+
*
|
|
3308
|
+
* Nothing once the job has ended. The reload that follows an update is what reports it, through the
|
|
3309
|
+
* outcome notice, and a strip still standing after that would be describing work that is over.
|
|
3310
|
+
*/
|
|
3311
|
+
function renderUpdateProgress(job) {
|
|
3312
|
+
if (!job || job.phase === "done" || job.phase === "failed") return "";
|
|
3313
|
+
return `<div class="prb-update-progress" data-update-phase="${escapeHtml$1(job.phase)}" role="status" aria-live="polite">
|
|
3314
|
+
<p class="prb-update-progress-title"><span class="prb-spinner" aria-hidden="true"></span>Updating this review</p>
|
|
3315
|
+
<p class="prb-update-progress-lead">The review below stays as it is until the update finishes.</p>
|
|
3316
|
+
${renderSteps(job)}
|
|
3317
|
+
<p class="prb-progress-elapsed" data-started-at="${job.startedAt}">Running for ${escapeHtml$1(elapsedWords(job.startedAt))}.</p>
|
|
3318
|
+
</div>`;
|
|
3319
|
+
}
|
|
3168
3320
|
//#endregion
|
|
3169
3321
|
//#region ../../packages/review-harness/src/workspace/page.ts
|
|
3170
3322
|
function renderWorkspace(view) {
|
|
@@ -3194,13 +3346,14 @@ function renderWorkspace(view) {
|
|
|
3194
3346
|
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
3195
3347
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
3196
3348
|
</head>
|
|
3197
|
-
<body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
|
|
3349
|
+
<body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
|
|
3198
3350
|
${renderHeader(view, freshness)}
|
|
3199
3351
|
${showsProgress(view) ? "" : renderJobState(view.job ?? null)}
|
|
3200
3352
|
<div class="prb-fresh-region" data-fresh-region${freshness ? ` data-report="${escapeHtml$1(JSON.stringify(freshness))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderFreshness(freshness ?? null, {
|
|
3201
3353
|
dismissedSha: view.dismissedFreshnessSha,
|
|
3202
3354
|
actions: view.token !== void 0
|
|
3203
3355
|
})}</div>
|
|
3356
|
+
${renderUpdateProgress(view.updateJob)}
|
|
3204
3357
|
${renderUpdateOutcome(view.lastUpdate)}
|
|
3205
3358
|
${renderLineageNotice(view)}
|
|
3206
3359
|
${renderReviewBody(view, sections)}
|
|
@@ -3331,7 +3484,7 @@ function renderStandardViews(view, sections) {
|
|
|
3331
3484
|
detailsInHeader: true
|
|
3332
3485
|
})}</section>
|
|
3333
3486
|
<section class="prb-view" data-view="guide" hidden>${renderGuideTab(session, null, null, { diagrams: true })}</section>
|
|
3334
|
-
<section class="prb-view" data-view="diagram" hidden data-diagram-pane>
|
|
3487
|
+
<section class="prb-view" data-view="diagram" hidden data-diagram-pane${view.pendingDiagram ? ` data-diagram-running="${escapeHtml$1(view.pendingDiagram.sectionId ?? "")}"` : ""}>
|
|
3335
3488
|
<h3 class="prb-doc-title">Diagrams</h3>
|
|
3336
3489
|
${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
|
|
3337
3490
|
</section>
|
|
@@ -4037,6 +4190,9 @@ function renderShutdownDialog() {
|
|
|
4037
4190
|
function renderAsk(view, sections) {
|
|
4038
4191
|
const suggestions = buildSuggestions(view, sections);
|
|
4039
4192
|
const transcript = view.transcript ?? [];
|
|
4193
|
+
const pending = view.pendingAsk ?? null;
|
|
4194
|
+
const pendingTurn = pending ? `<div class="prb-msg prb-msg-user">${escapeHtml$1(pending.question ?? "")}</div>
|
|
4195
|
+
<div class="prb-thinking" data-ask-pending><i></i><i></i><i></i><span>Reading the change…</span></div>` : "";
|
|
4040
4196
|
return `<aside class="prb-ask" aria-label="Ask Buddy AI">
|
|
4041
4197
|
<div
|
|
4042
4198
|
class="prb-ask-resize"
|
|
@@ -4052,8 +4208,8 @@ function renderAsk(view, sections) {
|
|
|
4052
4208
|
</div>
|
|
4053
4209
|
${renderPromptBank(view.prompts ?? [])}
|
|
4054
4210
|
<div class="prb-ask-log" data-ask-log>
|
|
4055
|
-
${transcript.length > 0 ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
|
|
4056
|
-
<div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>`).join("") : `<div class="prb-ask-empty">
|
|
4211
|
+
${transcript.length > 0 || pending ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
|
|
4212
|
+
<div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>`).join("") + pendingTurn : `<div class="prb-ask-empty">
|
|
4057
4213
|
<p>Ask about this change. Answers come from the review, the diff and the repository on disk — not from anywhere else.</p>
|
|
4058
4214
|
${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml$1(text)}</button>`).join("")}
|
|
4059
4215
|
</div>`}
|
|
@@ -4131,6 +4287,21 @@ function statusLabel(status) {
|
|
|
4131
4287
|
if (status === "complete") return "Reviewed";
|
|
4132
4288
|
return status === "in-progress" ? "In progress" : "New";
|
|
4133
4289
|
}
|
|
4290
|
+
/**
|
|
4291
|
+
* Whether any of that is work still going on, which is what sets the polling cadence.
|
|
4292
|
+
*
|
|
4293
|
+
* Here rather than beside `analysisStates` because this module is the DOM-free, filesystem-free
|
|
4294
|
+
* arithmetic both halves of the index share, and the renderer needs this one answer without
|
|
4295
|
+
* needing an import that reads the jobs directory to get it.
|
|
4296
|
+
*
|
|
4297
|
+
* A stopped analysis is a state, not an activity. Counting it as live would leave the index asking
|
|
4298
|
+
* once a second about a review that finished failing an hour ago, for as long as the tab stayed
|
|
4299
|
+
* open.
|
|
4300
|
+
*/
|
|
4301
|
+
function anythingRunning(states) {
|
|
4302
|
+
for (const state of states.values()) if (state.phase !== "failed") return true;
|
|
4303
|
+
return false;
|
|
4304
|
+
}
|
|
4134
4305
|
//#endregion
|
|
4135
4306
|
//#region ../../packages/review-harness/src/workspace/reviews_page.ts
|
|
4136
4307
|
function renderReviewsPage(view) {
|
|
@@ -4147,7 +4318,7 @@ function renderReviewsPage(view) {
|
|
|
4147
4318
|
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
4148
4319
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
4149
4320
|
</head>
|
|
4150
|
-
<body class="prb-index-body">
|
|
4321
|
+
<body class="prb-index-body"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map())}">
|
|
4151
4322
|
<header class="prb-header">
|
|
4152
4323
|
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
4153
4324
|
<span class="prb-change-ref">Reviews</span>
|
|
@@ -4164,7 +4335,7 @@ function renderReviewsPage(view) {
|
|
|
4164
4335
|
<button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
|
|
4165
4336
|
</div>`}
|
|
4166
4337
|
<div class="prb-cards" data-cards>
|
|
4167
|
-
${ordered.map((group) => renderCard(group, now)).join("")}
|
|
4338
|
+
${ordered.map((group) => renderCard(group, now, view.analyses?.get(group.latest.id))).join("")}
|
|
4168
4339
|
</div>
|
|
4169
4340
|
${ordered.length === 0 ? `<p class="prb-index-empty">No reviews stored yet. Ask Claude Code to review a change and one will appear here.</p>` : `<p class="prb-index-none" data-no-matches hidden>Nothing matches that.</p>`}
|
|
4170
4341
|
${ordered.length > 15 ? renderPager() : ""}
|
|
@@ -4265,7 +4436,7 @@ function renderBulkBar() {
|
|
|
4265
4436
|
* card and is shown only in selection mode, so entering that mode is a stylesheet change rather
|
|
4266
4437
|
* than a rebuild of the list.
|
|
4267
4438
|
*/
|
|
4268
|
-
function renderCard(group, now) {
|
|
4439
|
+
function renderCard(group, now, analysis) {
|
|
4269
4440
|
const review = group.latest;
|
|
4270
4441
|
const status = statusOf(review);
|
|
4271
4442
|
const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
@@ -4291,7 +4462,8 @@ function renderCard(group, now) {
|
|
|
4291
4462
|
${renderRisk(review.overallRisk)}
|
|
4292
4463
|
<span>${review.changedFiles} file${review.changedFiles === 1 ? "" : "s"}</span>
|
|
4293
4464
|
<span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">−${review.deletions}</span></span>
|
|
4294
|
-
|
|
4465
|
+
${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
|
|
4466
|
+
${renderAnalysisState(analysis)}
|
|
4295
4467
|
<span class="prb-card-when"><time datetime="${new Date(touchedAt(review)).toISOString()}" title="${escapeHtml$1(new Date(touchedAt(review)).toLocaleString())}">${escapeHtml$1(whenLabel(review, now))}</time></span>
|
|
4296
4468
|
</p>
|
|
4297
4469
|
</a>
|
|
@@ -4426,6 +4598,28 @@ function renderHistoryRow(review, number, latest, now) {
|
|
|
4426
4598
|
</li>`;
|
|
4427
4599
|
}
|
|
4428
4600
|
/**
|
|
4601
|
+
* What is being done to this review right now, when anything is.
|
|
4602
|
+
*
|
|
4603
|
+
* The words come from `PHASES` in `job_progress.ts` and are not written again here. That table is
|
|
4604
|
+
* already what the terminal prints and what the review page's checklist shows, and the last time a
|
|
4605
|
+
* surface kept its own copy the two drifted until a stopped run read "Analysed the change" -- a
|
|
4606
|
+
* tick beside a verb saying the work had happened. One table, three surfaces.
|
|
4607
|
+
*
|
|
4608
|
+
* A failed analysis says so rather than saying nothing. Before this, a review being analysed, one
|
|
4609
|
+
* whose analysis had failed and one never analysed were the same card: all three said "No standard
|
|
4610
|
+
* review yet", which is true of all three and useful about none of them.
|
|
4611
|
+
*
|
|
4612
|
+
* Nothing at all for a job that is neither live nor failed. That is the non-terminal job with no
|
|
4613
|
+
* driver behind it -- killed terminal, crashed process -- which sits in a working phase for up to
|
|
4614
|
+
* half an hour before the sweep calls it stopped. Narrating it as running would be a lie, and
|
|
4615
|
+
* calling it stopped here would be this renderer pre-empting a decision the sweep makes.
|
|
4616
|
+
*/
|
|
4617
|
+
function renderAnalysisState(analysis) {
|
|
4618
|
+
if (analysis === void 0) return "";
|
|
4619
|
+
if (analysis.phase === "failed") return analysis.stopped ? "<span class=\"prb-card-analysis\" data-analysis=\"stopped\">Analysis stopped</span>" : "<span class=\"prb-card-analysis\" data-analysis=\"failed\">Analysis failed</span>";
|
|
4620
|
+
return `<span class="prb-card-analysis" data-analysis="running"><i></i>${escapeHtml$1(phasesFor({ purpose: analysis.purpose })[analysis.phase].doing)}</span>`;
|
|
4621
|
+
}
|
|
4622
|
+
/**
|
|
4429
4623
|
* What this review still has open.
|
|
4430
4624
|
*
|
|
4431
4625
|
* Unresolved counts, not totals: "1 unanswered question" says where the review stands, where
|
|
@@ -4758,6 +4952,132 @@ function renderPrivacySection(settings) {
|
|
|
4758
4952
|
</section>`;
|
|
4759
4953
|
}
|
|
4760
4954
|
//#endregion
|
|
4955
|
+
//#region ../../packages/review-harness/src/workspace/reviews_revision.ts
|
|
4956
|
+
/**
|
|
4957
|
+
* Whether anything the reviews index draws has changed, as one token.
|
|
4958
|
+
*
|
|
4959
|
+
* The index reacts to a change by reloading, which is what makes an aggregate the right shape: it
|
|
4960
|
+
* never needs to know *which* review changed, so it asks one question whose cost is the same for
|
|
4961
|
+
* five reviews and five hundred. Per-card revisions would be more information than the page has any
|
|
4962
|
+
* use for, and a request per card to carry it.
|
|
4963
|
+
*
|
|
4964
|
+
* Opaque and **equality only**, the same contract as `workspaceRevision` in `store.ts`: compare two
|
|
4965
|
+
* tokens for equality, never order them, never parse one. The implementation below is a hash of
|
|
4966
|
+
* facts the page happens to draw today, and the whole point of saying so here is that it can be
|
|
4967
|
+
* replaced by a counter, a watch, or anything else without a page having to be told.
|
|
4968
|
+
*
|
|
4969
|
+
* ## What is in it, and what is deliberately not
|
|
4970
|
+
*
|
|
4971
|
+
* In: every review's id and file revision, and the phase of every analysis running right now.
|
|
4972
|
+
* Between them those cover everything a card shows — the summary, the risk badge, the counts, the
|
|
4973
|
+
* pin, the reviewed mark, the running line — because every one of those reaches disk through
|
|
4974
|
+
* `writeWorkspace`, and the set of ids covers a review appearing or being deleted.
|
|
4975
|
+
*
|
|
4976
|
+
* Not in: **`job.updatedAt` and `job.progress`.** This is the load-bearing omission. An agent
|
|
4977
|
+
* streams its progress into the job record every few seconds, so a token derived from job file
|
|
4978
|
+
* mtimes would differ on nearly every poll, and a page that reloads whenever the token differs
|
|
4979
|
+
* would then reload every few seconds for the whole length of an analysis — a page thrashing
|
|
4980
|
+
* itself, with the reviewer's scroll position going with it. Phase only, which changes four times
|
|
4981
|
+
* in a run.
|
|
4982
|
+
*/
|
|
4983
|
+
/**
|
|
4984
|
+
* What is being done to each review, or was last done and stopped.
|
|
4985
|
+
*
|
|
4986
|
+
* Keyed by review, valued by the phase the card should name. Two states reach it and a third
|
|
4987
|
+
* deliberately does not:
|
|
4988
|
+
*
|
|
4989
|
+
* - **Running.** A job that is not terminal *and* whose driver holds a live claim. The claim is the
|
|
4990
|
+
* load-bearing half: a job whose driver was killed sits in a working phase for up to half an hour
|
|
4991
|
+
* before the sweep calls it stopped, and narrating one of those as "Analysing the change" would
|
|
4992
|
+
* be the index confidently reporting work that nothing is doing.
|
|
4993
|
+
* - **`failed`.** Reported whether or not anything still holds the claim, which is the whole reason
|
|
4994
|
+
* this function is not simply `runningJobs`. It was: `runningJobs` drops terminal jobs, and
|
|
4995
|
+
* `isTerminal` counts `failed` as terminal, so a failed analysis could never reach the renderer
|
|
4996
|
+
* and the card's "Analysis stopped" branch was unreachable in production while passing a unit
|
|
4997
|
+
* test that handed it a state the product could not produce. A review whose analysis failed went
|
|
4998
|
+
* back to reading "No standard review yet" the moment its driver let go.
|
|
4999
|
+
* - **`done`, and an unclaimed job still in a working phase.** Nothing. A finished job has its
|
|
5000
|
+
* result on the card already, and an abandoned one is the sweep's to judge rather than this
|
|
5001
|
+
* renderer's to pre-empt.
|
|
5002
|
+
*
|
|
5003
|
+
* **Every job a review has, not the latest one.** A review accumulates them -- a standard analysis,
|
|
5004
|
+
* a KISS run, a retry -- and two of them genuinely run at once: `handleKiss` refuses only a second
|
|
5005
|
+
* KISS run, and the review page's poll is built around one finishing while the other goes on. An
|
|
5006
|
+
* earlier cut picked the single newest job and then asked what it was, so a KISS run that ended
|
|
5007
|
+
* first spoke for a standard analysis that was still working, and the card fell silent on work that
|
|
5008
|
+
* was happening in front of the reviewer.
|
|
5009
|
+
*
|
|
5010
|
+
* So the precedence is running, then the most recent ending, and it is stated in that order because
|
|
5011
|
+
* that is the property: **nothing that has ended can hide something that has not.**
|
|
5012
|
+
*
|
|
5013
|
+
* `jobs` is injected only so a test can hand the same set in two different orders and prove the
|
|
5014
|
+
* answer does not move. Production always reads the directory.
|
|
5015
|
+
*
|
|
5016
|
+
* A failure is visible for as long as the job record survives, which is the sweep's retention. Once
|
|
5017
|
+
* that passes the card quietly returns to saying nothing, which is the same silence every review
|
|
5018
|
+
* had before this existed.
|
|
5019
|
+
*/
|
|
5020
|
+
function analysisStates(now = Date.now(), jobs = allJobs()) {
|
|
5021
|
+
const byReview = /* @__PURE__ */ new Map();
|
|
5022
|
+
for (const job of jobs) {
|
|
5023
|
+
if (purposeOf(job) === "ask") continue;
|
|
5024
|
+
const held = byReview.get(job.workspaceId);
|
|
5025
|
+
if (held) held.push(job);
|
|
5026
|
+
else byReview.set(job.workspaceId, [job]);
|
|
5027
|
+
}
|
|
5028
|
+
/**
|
|
5029
|
+
* Both comparators are **total**, and that is a requirement rather than tidiness.
|
|
5030
|
+
*
|
|
5031
|
+
* `allJobs` reads a directory, so a tie leaves the winner to whatever order the filesystem
|
|
5032
|
+
* happened to return. This function's output is hashed into the index token, and the page reloads
|
|
5033
|
+
* whenever that token changes -- so an arbitrary winner is not a cosmetic wobble, it is the page
|
|
5034
|
+
* reloading itself for as long as the tie exists, with no cause a reviewer could ever see. The
|
|
5035
|
+
* same hazard the sorting rule was written for, one level further down.
|
|
5036
|
+
*
|
|
5037
|
+
* Ties are real. Two jobs can carry the same timestamp to the millisecond, and a record written
|
|
5038
|
+
* before `finishedAt` existed falls back to `updatedAt`, which two records can share. The id is
|
|
5039
|
+
* the last resort because it is the only field guaranteed to differ.
|
|
5040
|
+
*/
|
|
5041
|
+
const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
5042
|
+
const startedLast = (a, b) => b.startedAt - a.startedAt || byId(a, b);
|
|
5043
|
+
const endedLast = (a, b) => endedAt(b) - endedAt(a) || b.startedAt - a.startedAt || byId(a, b);
|
|
5044
|
+
const states = /* @__PURE__ */ new Map();
|
|
5045
|
+
for (const [reviewId, jobs] of byReview) {
|
|
5046
|
+
const running = jobs.filter((job) => !isTerminal(job) && isClaimed(job.id, now)).sort(startedLast);
|
|
5047
|
+
if (running.length > 0) {
|
|
5048
|
+
states.set(reviewId, {
|
|
5049
|
+
phase: running[0].phase,
|
|
5050
|
+
purpose: purposeOf(running[0])
|
|
5051
|
+
});
|
|
5052
|
+
continue;
|
|
5053
|
+
}
|
|
5054
|
+
const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
|
|
5055
|
+
if (ended[0]?.phase === "failed") states.set(reviewId, {
|
|
5056
|
+
phase: "failed",
|
|
5057
|
+
purpose: "review",
|
|
5058
|
+
...ended[0].failure?.stopped ? { stopped: true } : {}
|
|
5059
|
+
});
|
|
5060
|
+
}
|
|
5061
|
+
return states;
|
|
5062
|
+
}
|
|
5063
|
+
/**
|
|
5064
|
+
* The token.
|
|
5065
|
+
*
|
|
5066
|
+
* Takes the live analyses rather than reading them, so that one request answers with a token and a
|
|
5067
|
+
* running state that describe the same instant. Computing them separately would let a phase change
|
|
5068
|
+
* land between the two, and the page would then be told "nothing has changed" alongside a state it
|
|
5069
|
+
* had not seen.
|
|
5070
|
+
*/
|
|
5071
|
+
function reviewsRevision(live = analysisStates()) {
|
|
5072
|
+
const hash = createHash("sha1");
|
|
5073
|
+
for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
|
|
5074
|
+
for (const id of [...live.keys()].sort()) {
|
|
5075
|
+
const state = live.get(id);
|
|
5076
|
+
hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
|
|
5077
|
+
}
|
|
5078
|
+
return hash.digest("base64url");
|
|
5079
|
+
}
|
|
5080
|
+
//#endregion
|
|
4761
5081
|
//#region ../../packages/review-harness/src/workspace/ask.ts
|
|
4762
5082
|
/** Long enough for a real look at the repository; short enough that a hang is not forever. */
|
|
4763
5083
|
var ASK_TIMEOUT_MS = 18e4;
|
|
@@ -4796,6 +5116,7 @@ var AskFailedError = class extends Error {};
|
|
|
4796
5116
|
async function askAboutChange(workspace, question, sectionId, signal) {
|
|
4797
5117
|
const cwd = await askCheckout(workspace);
|
|
4798
5118
|
const env = agentEnvOf(workspace);
|
|
5119
|
+
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
4799
5120
|
try {
|
|
4800
5121
|
return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor(env).run({
|
|
4801
5122
|
capability: "read-code",
|
|
@@ -4803,10 +5124,11 @@ async function askAboutChange(workspace, question, sectionId, signal) {
|
|
|
4803
5124
|
cwd,
|
|
4804
5125
|
originRepoPath: workspace.repoPath,
|
|
4805
5126
|
env,
|
|
5127
|
+
...requestedModel ? { requestedModel } : {},
|
|
4806
5128
|
timeoutMs: ASK_TIMEOUT_MS,
|
|
4807
5129
|
signal,
|
|
4808
5130
|
onUsage
|
|
4809
|
-
})));
|
|
5131
|
+
}), void 0, requestedModel));
|
|
4810
5132
|
} catch (error) {
|
|
4811
5133
|
if (error instanceof AgentUnavailableError) throw new AskUnavailableError(`${error.message} It is what this review was made with, so the assistant cannot answer.`);
|
|
4812
5134
|
throw explained(error, env);
|
|
@@ -4881,6 +5203,86 @@ function buildPrompt$1(workspace, question, sectionId, cwd) {
|
|
|
4881
5203
|
].join("\n");
|
|
4882
5204
|
}
|
|
4883
5205
|
//#endregion
|
|
5206
|
+
//#region ../../packages/review-harness/src/workspace/assistant_runs.ts
|
|
5207
|
+
var RUN_KINDS = ["ask", "diagram"];
|
|
5208
|
+
var runs = /* @__PURE__ */ new Map();
|
|
5209
|
+
function keyOf(workspaceId, kind) {
|
|
5210
|
+
return `${workspaceId}:${kind}`;
|
|
5211
|
+
}
|
|
5212
|
+
/**
|
|
5213
|
+
* Start a run, or learn that one is already going.
|
|
5214
|
+
*
|
|
5215
|
+
* One call rather than a check followed by a start, so there is no gap between them for a second
|
|
5216
|
+
* request to fall through. The existing run comes back rather than a bare refusal because the
|
|
5217
|
+
* caller's next move is to show it, not to report an error.
|
|
5218
|
+
*
|
|
5219
|
+
* A stopped run gives up its place at once, though its handler may still be unwinding. Stop is
|
|
5220
|
+
* over for the reviewer when it answers, and the page reopens Ask then. A stopped run can keep
|
|
5221
|
+
* nothing, and `endRun` leaves a newer run alone, so the two cannot collide. An answered run still
|
|
5222
|
+
* holds its place until it is saved, or the next question would be asked without it.
|
|
5223
|
+
*/
|
|
5224
|
+
function beginRun(workspaceId, kind, detail, now = Date.now()) {
|
|
5225
|
+
const key = keyOf(workspaceId, kind);
|
|
5226
|
+
const existing = runs.get(key);
|
|
5227
|
+
if (existing && existing.state !== "stopped") return { alreadyRunning: existing };
|
|
5228
|
+
const controller = new AbortController();
|
|
5229
|
+
const run = {
|
|
5230
|
+
workspaceId,
|
|
5231
|
+
kind,
|
|
5232
|
+
question: detail.question ?? null,
|
|
5233
|
+
sectionId: detail.sectionId,
|
|
5234
|
+
startedAt: now,
|
|
5235
|
+
signal: controller.signal,
|
|
5236
|
+
state: "running",
|
|
5237
|
+
controller
|
|
5238
|
+
};
|
|
5239
|
+
runs.set(key, run);
|
|
5240
|
+
return { run };
|
|
5241
|
+
}
|
|
5242
|
+
/** Null for a stopped run as well: it is over for the page, whatever its handler is still doing. */
|
|
5243
|
+
function reportRun(workspaceId, kind) {
|
|
5244
|
+
const run = runs.get(keyOf(workspaceId, kind));
|
|
5245
|
+
if (!run || run.state === "stopped") return null;
|
|
5246
|
+
return {
|
|
5247
|
+
question: run.question,
|
|
5248
|
+
sectionId: run.sectionId,
|
|
5249
|
+
startedAt: run.startedAt
|
|
5250
|
+
};
|
|
5251
|
+
}
|
|
5252
|
+
/**
|
|
5253
|
+
* The reviewer pressed Stop.
|
|
5254
|
+
*
|
|
5255
|
+
* Idempotent: a second press, a press after the answer landed, and a press with nothing running are
|
|
5256
|
+
* all harmless and all answer false. True only when this call is the one that stopped the run.
|
|
5257
|
+
*/
|
|
5258
|
+
function stopRun(workspaceId, kind) {
|
|
5259
|
+
const run = runs.get(keyOf(workspaceId, kind));
|
|
5260
|
+
if (!run || run.state !== "running") return false;
|
|
5261
|
+
run.state = "stopped";
|
|
5262
|
+
run.controller.abort();
|
|
5263
|
+
return true;
|
|
5264
|
+
}
|
|
5265
|
+
/**
|
|
5266
|
+
* The agent answered. Call this in the same continuation that received the answer, before anything
|
|
5267
|
+
* else is awaited, so a Stop handled in between cannot slip ahead of an answer that had already won.
|
|
5268
|
+
*
|
|
5269
|
+
* False when Stop got there first, and then the answer must not be kept however complete it is.
|
|
5270
|
+
*/
|
|
5271
|
+
function completeRun(run) {
|
|
5272
|
+
const held = run;
|
|
5273
|
+
if (held.state !== "running") return false;
|
|
5274
|
+
held.state = "completed";
|
|
5275
|
+
return true;
|
|
5276
|
+
}
|
|
5277
|
+
/**
|
|
5278
|
+
* The run is over, whichever way. Removes this run and only this one: by the time a slow handler
|
|
5279
|
+
* gets here a newer run of the same kind may have begun, and it is not this handler's to end.
|
|
5280
|
+
*/
|
|
5281
|
+
function endRun(run) {
|
|
5282
|
+
const key = keyOf(run.workspaceId, run.kind);
|
|
5283
|
+
if (runs.get(key) === run) runs.delete(key);
|
|
5284
|
+
}
|
|
5285
|
+
//#endregion
|
|
4884
5286
|
//#region ../../packages/review-harness/src/workspace/diagram.ts
|
|
4885
5287
|
/** The same budget a question gets. Drawing is reading, and reading is what takes the time. */
|
|
4886
5288
|
var DIAGRAM_TIMEOUT_MS = 18e4;
|
|
@@ -4937,6 +5339,7 @@ flowchart LR
|
|
|
4937
5339
|
async function drawDiagram(workspace, sectionId, signal) {
|
|
4938
5340
|
const cwd = await askCheckout(workspace);
|
|
4939
5341
|
const env = agentEnvOf(workspace);
|
|
5342
|
+
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
4940
5343
|
let raw;
|
|
4941
5344
|
try {
|
|
4942
5345
|
raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
|
|
@@ -4945,10 +5348,11 @@ async function drawDiagram(workspace, sectionId, signal) {
|
|
|
4945
5348
|
cwd,
|
|
4946
5349
|
originRepoPath: workspace.repoPath,
|
|
4947
5350
|
env,
|
|
5351
|
+
...requestedModel ? { requestedModel } : {},
|
|
4948
5352
|
timeoutMs: DIAGRAM_TIMEOUT_MS,
|
|
4949
5353
|
signal,
|
|
4950
5354
|
onUsage
|
|
4951
|
-
}));
|
|
5355
|
+
}), void 0, requestedModel);
|
|
4952
5356
|
} catch (error) {
|
|
4953
5357
|
if (error instanceof AgentUnavailableError) throw new DiagramUnavailableError(`${error.message} It is what this review was made with, so nothing can be drawn.`);
|
|
4954
5358
|
throw error;
|
|
@@ -5420,6 +5824,64 @@ var CONTENT_TYPES = {
|
|
|
5420
5824
|
".woff2": "font/woff2",
|
|
5421
5825
|
".png": "image/png"
|
|
5422
5826
|
};
|
|
5827
|
+
/**
|
|
5828
|
+
* The freshness policy, in three classes and one place.
|
|
5829
|
+
*
|
|
5830
|
+
* Every response this server sends falls into exactly one of them, and which one is decided by the
|
|
5831
|
+
* helper that writes it rather than by the route that produced it. That is the whole shape of this:
|
|
5832
|
+
* a route may not have an opinion about caching, because the bug this replaced was four HTML
|
|
5833
|
+
* responses that each said nothing and were each individually reasonable.
|
|
5834
|
+
*
|
|
5835
|
+
* mutable workspace state -> no-store never stored, so never reused
|
|
5836
|
+
* fingerprinted asset -> immutable reused indefinitely; the hash is the identity
|
|
5837
|
+
* other static asset -> no-cache may be stored, must revalidate before reuse
|
|
5838
|
+
*
|
|
5839
|
+
* `no-cache` is a rule about reuse and not a ban on storage, which is the thing about these two
|
|
5840
|
+
* names that is worth saying out loud once: the assets already carried it while the pages, which
|
|
5841
|
+
* are the things that actually change, carried nothing at all. A reviewer marked a review reviewed,
|
|
5842
|
+
* pressed Back, and was shown the list as it had been — and the server had been right the whole
|
|
5843
|
+
* time, because it was never asked.
|
|
5844
|
+
*
|
|
5845
|
+
* The browser half of the same policy is `reloadOnRestore` in `client/page_restore.ts`. Nothing
|
|
5846
|
+
* here reaches the back/forward cache reliably: a restored page is a frozen document being thawed
|
|
5847
|
+
* rather than a stored response being reused, and browsers disagree about whether `no-store` should
|
|
5848
|
+
* keep a document out of that cache. Only the page itself can refuse its own restoration.
|
|
5849
|
+
*/
|
|
5850
|
+
var NO_STORE = "no-store";
|
|
5851
|
+
var REVALIDATE = "no-cache";
|
|
5852
|
+
var IMMUTABLE = "public, max-age=31536000, immutable";
|
|
5853
|
+
/**
|
|
5854
|
+
* Whether this asset's name encodes its content, which is the only thing that makes `immutable`
|
|
5855
|
+
* safe to say about it.
|
|
5856
|
+
*
|
|
5857
|
+
* The names come from `vite.client.ts`, which emits `chunk-[hash].js` for everything the bundler
|
|
5858
|
+
* split out and a bare `[name].js` for the three page entries. So the chunks may be kept forever —
|
|
5859
|
+
* a new build produces new names and the old ones are simply never asked for again — while
|
|
5860
|
+
* `workspace.js`, the stylesheets, the font and the icon are all names that survive an upgrade
|
|
5861
|
+
* with different content behind them, and must be revalidated every time they are reused.
|
|
5862
|
+
*
|
|
5863
|
+
* `freshness_policy.test.ts` holds this to the vite option it is derived from. The two halves are
|
|
5864
|
+
* in different packages' build config and source, and a change to the emitted name would otherwise
|
|
5865
|
+
* turn "cache forever" from safe into a stale asset nobody can clear.
|
|
5866
|
+
*/
|
|
5867
|
+
function isFingerprinted(name) {
|
|
5868
|
+
return /^chunk-/.test(name);
|
|
5869
|
+
}
|
|
5870
|
+
/**
|
|
5871
|
+
* An HTML document, which is always a view of mutable workspace state.
|
|
5872
|
+
*
|
|
5873
|
+
* Every HTML response goes through here. There is no second way to write one, and
|
|
5874
|
+
* `freshness_policy.test.ts` is what keeps it that way: the cost of this bug was not that somebody
|
|
5875
|
+
* chose wrong, it was that four routes each got to choose.
|
|
5876
|
+
*/
|
|
5877
|
+
function sendHtml(res, status, html, cookie) {
|
|
5878
|
+
res.writeHead(status, {
|
|
5879
|
+
"content-type": "text/html; charset=utf-8",
|
|
5880
|
+
"cache-control": NO_STORE,
|
|
5881
|
+
...cookie ? { "set-cookie": cookie } : {}
|
|
5882
|
+
});
|
|
5883
|
+
res.end(html);
|
|
5884
|
+
}
|
|
5423
5885
|
function createWorkspaceServer(options = {}) {
|
|
5424
5886
|
return createServer((req, res) => {
|
|
5425
5887
|
handle(req, res, options).catch((error) => {
|
|
@@ -5443,6 +5905,7 @@ async function handle(req, res, options) {
|
|
|
5443
5905
|
if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
|
|
5444
5906
|
if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
|
|
5445
5907
|
if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
|
|
5908
|
+
if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
|
|
5446
5909
|
const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
|
|
5447
5910
|
if (!match) return send(res, 404, { error: "Not found" });
|
|
5448
5911
|
const [, id, action] = match;
|
|
@@ -5451,7 +5914,7 @@ async function handle(req, res, options) {
|
|
|
5451
5914
|
if (!tokenMatches(workspace, url.searchParams.get("t"))) return send(res, 403, { error: "This link is missing its access token." });
|
|
5452
5915
|
if (url.pathname.startsWith("/r/")) {
|
|
5453
5916
|
touchWorkspace(workspace);
|
|
5454
|
-
return
|
|
5917
|
+
return sendWorkspacePage(res, workspace);
|
|
5455
5918
|
}
|
|
5456
5919
|
if (action === "/job") return send(res, 200, jobReport(workspace));
|
|
5457
5920
|
if (action === "/retry") return handleRetry(res, workspace);
|
|
@@ -5462,6 +5925,7 @@ async function handle(req, res, options) {
|
|
|
5462
5925
|
if (action === "/file") return void sendFile(res, workspace, url.searchParams);
|
|
5463
5926
|
if (action === "/ask") return handleAsk(req, res, workspace);
|
|
5464
5927
|
if (action === "/diagram") return handleDiagram(req, res, workspace);
|
|
5928
|
+
if (action === "/stop") return handleStop(req, res, workspace);
|
|
5465
5929
|
if (action === "/kiss") return handleKiss(req, res, workspace);
|
|
5466
5930
|
if (action === "/state") return handleState(req, res, workspace);
|
|
5467
5931
|
if (action === "/event") return handleEvent(req, res, workspace);
|
|
@@ -5525,12 +5989,12 @@ function handleIndex(req, res, params, indexToken) {
|
|
|
5525
5989
|
}
|
|
5526
5990
|
const cookie = indexCookieHeader(indexToken);
|
|
5527
5991
|
if (onward) return redirect(res, onward, cookie);
|
|
5528
|
-
const
|
|
5529
|
-
res
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5992
|
+
const states = analysisStates();
|
|
5993
|
+
sendHtml(res, 200, renderReviewsPage({
|
|
5994
|
+
groups: recentReviewGroups(MAX_LISTED_BRANCHES),
|
|
5995
|
+
analyses: states,
|
|
5996
|
+
revision: reviewsRevision(states)
|
|
5997
|
+
}), cookie);
|
|
5534
5998
|
}
|
|
5535
5999
|
/**
|
|
5536
6000
|
* Where `?go=` is allowed to send someone, which is one shape and no other.
|
|
@@ -5547,6 +6011,7 @@ function safeDestination(value) {
|
|
|
5547
6011
|
function redirect(res, location, cookie) {
|
|
5548
6012
|
res.writeHead(302, {
|
|
5549
6013
|
location,
|
|
6014
|
+
"cache-control": NO_STORE,
|
|
5550
6015
|
...cookie ? { "set-cookie": cookie } : {}
|
|
5551
6016
|
});
|
|
5552
6017
|
res.end();
|
|
@@ -5569,8 +6034,7 @@ function redirect(res, location, cookie) {
|
|
|
5569
6034
|
function refuseIndex(req, res) {
|
|
5570
6035
|
const way = wayBack();
|
|
5571
6036
|
if (!wantsHtml(req)) return send(res, 403, { error: `This link is missing its access token. Run ${wayBackSentence(way)} to get the current one.` });
|
|
5572
|
-
res
|
|
5573
|
-
res.end(page("Your reviews", `<h1>This browser has not been shown your reviews yet</h1>
|
|
6037
|
+
sendHtml(res, 403, page("Your reviews", `<h1>This browser has not been shown your reviews yet</h1>
|
|
5574
6038
|
<p>Nothing is lost. Every review is still here, and the link you came from still works. The
|
|
5575
6039
|
list has its own credential, separate from the one that opens a single review, so a review
|
|
5576
6040
|
page is not able to hand it over.</p>
|
|
@@ -5614,12 +6078,7 @@ async function handleSettings(req, res, params, indexToken) {
|
|
|
5614
6078
|
error
|
|
5615
6079
|
});
|
|
5616
6080
|
}
|
|
5617
|
-
|
|
5618
|
-
res.writeHead(200, {
|
|
5619
|
-
"content-type": "text/html; charset=utf-8",
|
|
5620
|
-
"set-cookie": indexCookieHeader(indexToken)
|
|
5621
|
-
});
|
|
5622
|
-
res.end(html);
|
|
6081
|
+
sendHtml(res, 200, renderSettingsPage({ settings: readSettings() }), indexCookieHeader(indexToken));
|
|
5623
6082
|
}
|
|
5624
6083
|
/**
|
|
5625
6084
|
* Whether the caller is a browser navigating, rather than something reading the API.
|
|
@@ -5693,6 +6152,34 @@ async function handleStatus(req, res, indexToken) {
|
|
|
5693
6152
|
});
|
|
5694
6153
|
}
|
|
5695
6154
|
/**
|
|
6155
|
+
* Has anything the reviews index draws changed, and is anything still being made.
|
|
6156
|
+
*
|
|
6157
|
+
* The whole of the index's liveness, in one request that costs the same whether the machine holds
|
|
6158
|
+
* five reviews or five hundred. The page reloads when `revision` differs from the one it was
|
|
6159
|
+
* rendered with; it never needs to know which review changed, because its answer to any change is
|
|
6160
|
+
* the same answer.
|
|
6161
|
+
*
|
|
6162
|
+
* Both fields describe one instant, which is why `liveAnalyses` is read once and passed in rather
|
|
6163
|
+
* than read twice. Computed separately, a phase could change between them and the page would be
|
|
6164
|
+
* told nothing had changed alongside a running state it had not seen.
|
|
6165
|
+
*
|
|
6166
|
+
* `live` sets the **cadence**, never whether to poll. A page that renders with nothing running
|
|
6167
|
+
* still has to learn that a review was started from a terminal somewhere, and a page whose work has
|
|
6168
|
+
* finished has to slow down without needing a reload to be told. See `client/reviews.ts`.
|
|
6169
|
+
*
|
|
6170
|
+
* Authorised by the index credential like `/pin`, `/delete` and `/status`, because this is the list
|
|
6171
|
+
* asking about itself. Flat rather than under `/api/`, which is the per-review namespace and whose
|
|
6172
|
+
* route requires a review id this question does not have.
|
|
6173
|
+
*/
|
|
6174
|
+
function handleRevision(req, res, indexToken) {
|
|
6175
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
6176
|
+
const states = analysisStates();
|
|
6177
|
+
send(res, 200, {
|
|
6178
|
+
revision: reviewsRevision(states),
|
|
6179
|
+
live: anythingRunning(states)
|
|
6180
|
+
});
|
|
6181
|
+
}
|
|
6182
|
+
/**
|
|
5696
6183
|
* Pin or unpin a review.
|
|
5697
6184
|
*
|
|
5698
6185
|
* Authorised by the index credential rather than the review's own, because pinning is a fact about
|
|
@@ -5876,7 +6363,7 @@ function recordWorkspaceOpened(workspace) {
|
|
|
5876
6363
|
const event = workspaceOpenedEvent(workspace);
|
|
5877
6364
|
if (event) record(event);
|
|
5878
6365
|
}
|
|
5879
|
-
async function
|
|
6366
|
+
async function sendWorkspacePage(res, workspace) {
|
|
5880
6367
|
const freshness = await freshnessOrNull(workspace);
|
|
5881
6368
|
const kissFreshness = await kissFreshnessOrNull(workspace);
|
|
5882
6369
|
const kissRun = latestKissRun(workspace);
|
|
@@ -5897,6 +6384,8 @@ async function sendHtml(res, workspace) {
|
|
|
5897
6384
|
repoPath: workspace.repoPath,
|
|
5898
6385
|
askWidth: readPreferences().askWidth,
|
|
5899
6386
|
transcript: workspace.transcript,
|
|
6387
|
+
pendingAsk: reportRun(workspace.id, "ask"),
|
|
6388
|
+
pendingDiagram: reportRun(workspace.id, "diagram"),
|
|
5900
6389
|
prompts: readPromptBank(),
|
|
5901
6390
|
complete: workspace.completedAt !== void 0,
|
|
5902
6391
|
prContext: workspace.prContext ? {
|
|
@@ -5909,8 +6398,10 @@ async function sendHtml(res, workspace) {
|
|
|
5909
6398
|
lastUpdate: notice,
|
|
5910
6399
|
diagrams: workspace.diagrams,
|
|
5911
6400
|
reviewedSha: reviewedCommit(workspace),
|
|
6401
|
+
revision: workspaceRevision(workspace),
|
|
5912
6402
|
lineage: lineagePosition(workspace),
|
|
5913
6403
|
job: workspace.jobId ? loadJob(workspace.jobId) : void 0,
|
|
6404
|
+
updateJob: liveUpdateFor(workspace.id),
|
|
5914
6405
|
kissRun,
|
|
5915
6406
|
kissJob: kissRun ? loadJob(kissRun.jobId) : void 0,
|
|
5916
6407
|
kissFreshness,
|
|
@@ -5925,8 +6416,7 @@ async function sendHtml(res, workspace) {
|
|
|
5925
6416
|
code: freshness.code.state,
|
|
5926
6417
|
checkout: freshness.checkout.state
|
|
5927
6418
|
});
|
|
5928
|
-
res
|
|
5929
|
-
res.end(html);
|
|
6419
|
+
sendHtml(res, 200, html);
|
|
5930
6420
|
}
|
|
5931
6421
|
function sendAsset(res, pathname) {
|
|
5932
6422
|
const name = pathname.slice(8);
|
|
@@ -5936,7 +6426,7 @@ function sendAsset(res, pathname) {
|
|
|
5936
6426
|
const ext = name.slice(name.lastIndexOf("."));
|
|
5937
6427
|
res.writeHead(200, {
|
|
5938
6428
|
"content-type": CONTENT_TYPES[ext] ?? "application/octet-stream",
|
|
5939
|
-
"cache-control":
|
|
6429
|
+
"cache-control": isFingerprinted(name) ? IMMUTABLE : REVALIDATE
|
|
5940
6430
|
});
|
|
5941
6431
|
res.end(body);
|
|
5942
6432
|
}
|
|
@@ -5998,7 +6488,10 @@ function jobReport(workspace) {
|
|
|
5998
6488
|
const kissRun = latestKissRun(workspace);
|
|
5999
6489
|
return {
|
|
6000
6490
|
...jobStatus(workspace.jobId ? loadJob(workspace.jobId) : null),
|
|
6001
|
-
kiss: jobStatus(kissRun ? loadJob(kissRun.jobId) : null)
|
|
6491
|
+
kiss: jobStatus(kissRun ? loadJob(kissRun.jobId) : null),
|
|
6492
|
+
update: jobStatus(liveUpdateFor(workspace.id)),
|
|
6493
|
+
ask: reportRun(workspace.id, "ask"),
|
|
6494
|
+
diagram: reportRun(workspace.id, "diagram")
|
|
6002
6495
|
};
|
|
6003
6496
|
}
|
|
6004
6497
|
/**
|
|
@@ -6011,10 +6504,12 @@ function jobReport(workspace) {
|
|
|
6011
6504
|
async function handleFreshness(res, workspace) {
|
|
6012
6505
|
const kiss = await kissFreshnessOrNull(workspace);
|
|
6013
6506
|
const report = await freshnessOrNull(workspace);
|
|
6507
|
+
const revision = workspaceRevision(workspace) ?? null;
|
|
6014
6508
|
if (!report) return send(res, 200, {
|
|
6015
6509
|
report: null,
|
|
6016
6510
|
kiss,
|
|
6017
|
-
kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null
|
|
6511
|
+
kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
|
|
6512
|
+
revision
|
|
6018
6513
|
});
|
|
6019
6514
|
record({
|
|
6020
6515
|
event: "freshness_checked",
|
|
@@ -6027,7 +6522,8 @@ async function handleFreshness(res, workspace) {
|
|
|
6027
6522
|
report,
|
|
6028
6523
|
dismissedSha: workspace.dismissedFreshnessSha ?? null,
|
|
6029
6524
|
kiss,
|
|
6030
|
-
kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null
|
|
6525
|
+
kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
|
|
6526
|
+
revision
|
|
6031
6527
|
});
|
|
6032
6528
|
}
|
|
6033
6529
|
/**
|
|
@@ -6095,6 +6591,7 @@ async function handleUpdate(res, workspace) {
|
|
|
6095
6591
|
try {
|
|
6096
6592
|
outcome = await updateReview(workspace);
|
|
6097
6593
|
} catch (error) {
|
|
6594
|
+
if (error instanceof UpdateAlreadyRunningError) return send(res, 409, { error: error.message });
|
|
6098
6595
|
outcome = {
|
|
6099
6596
|
at: Date.now(),
|
|
6100
6597
|
ok: false,
|
|
@@ -6203,6 +6700,15 @@ async function handleAsk(req, res, workspace) {
|
|
|
6203
6700
|
const question = typeof body?.question === "string" ? body.question.trim() : "";
|
|
6204
6701
|
if (!question) return send(res, 400, { error: "No question given." });
|
|
6205
6702
|
const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
|
|
6703
|
+
const started = beginRun(workspace.id, "ask", {
|
|
6704
|
+
question,
|
|
6705
|
+
sectionId
|
|
6706
|
+
});
|
|
6707
|
+
if ("alreadyRunning" in started) return send(res, 409, {
|
|
6708
|
+
error: "Buddy AI is still answering the last question on this review.",
|
|
6709
|
+
running: true
|
|
6710
|
+
});
|
|
6711
|
+
const run = started.run;
|
|
6206
6712
|
record({
|
|
6207
6713
|
event: "assistant_question_asked",
|
|
6208
6714
|
reviewId: workspace.id,
|
|
@@ -6210,40 +6716,64 @@ async function handleAsk(req, res, workspace) {
|
|
|
6210
6716
|
scoped: sectionId !== null,
|
|
6211
6717
|
turn: workspace.transcript.length + 1
|
|
6212
6718
|
});
|
|
6213
|
-
const cancel = new AbortController();
|
|
6214
|
-
res.on("close", () => {
|
|
6215
|
-
if (!res.writableEnded) cancel.abort();
|
|
6216
|
-
});
|
|
6217
6719
|
const startedAt = Date.now();
|
|
6218
|
-
|
|
6720
|
+
const failed = () => record({
|
|
6721
|
+
event: "assistant_answered",
|
|
6722
|
+
reviewId: workspace.id,
|
|
6723
|
+
durationMs: Date.now() - startedAt,
|
|
6724
|
+
ok: false,
|
|
6725
|
+
...run.state === "stopped" ? { cancelled: true } : {}
|
|
6726
|
+
});
|
|
6219
6727
|
try {
|
|
6220
|
-
answer
|
|
6221
|
-
|
|
6222
|
-
|
|
6728
|
+
let answer;
|
|
6729
|
+
try {
|
|
6730
|
+
answer = await askAboutChange(workspace, question, sectionId, run.signal);
|
|
6731
|
+
} catch (error) {
|
|
6732
|
+
failed();
|
|
6733
|
+
if (run.state === "stopped") return sendIfOpen(res, 200, { stopped: true });
|
|
6734
|
+
throw error;
|
|
6735
|
+
}
|
|
6736
|
+
if (!completeRun(run)) {
|
|
6737
|
+
failed();
|
|
6738
|
+
return sendIfOpen(res, 200, { stopped: true });
|
|
6739
|
+
}
|
|
6223
6740
|
record({
|
|
6224
6741
|
event: "assistant_answered",
|
|
6225
6742
|
reviewId: workspace.id,
|
|
6226
6743
|
durationMs: Date.now() - startedAt,
|
|
6227
|
-
ok:
|
|
6228
|
-
...cancelled ? { cancelled: true } : {}
|
|
6744
|
+
ok: true
|
|
6229
6745
|
});
|
|
6230
|
-
|
|
6231
|
-
|
|
6746
|
+
const fresh = loadWorkspace(workspace.id);
|
|
6747
|
+
if (fresh) {
|
|
6748
|
+
fresh.transcript.push({
|
|
6749
|
+
question,
|
|
6750
|
+
answer,
|
|
6751
|
+
sectionId,
|
|
6752
|
+
askedAt: Date.now()
|
|
6753
|
+
});
|
|
6754
|
+
saveWorkspace(fresh);
|
|
6755
|
+
}
|
|
6756
|
+
sendIfOpen(res, 200, { answer });
|
|
6757
|
+
} finally {
|
|
6758
|
+
endRun(run);
|
|
6232
6759
|
}
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6760
|
+
}
|
|
6761
|
+
/** Only while somebody is listening. A page that left took its socket with it. */
|
|
6762
|
+
function sendIfOpen(res, status, body) {
|
|
6763
|
+
if (res.writableEnded || res.destroyed) return;
|
|
6764
|
+
send(res, status, body);
|
|
6765
|
+
}
|
|
6766
|
+
/**
|
|
6767
|
+
* The reviewer pressed Stop, on this page or on another one showing the same review.
|
|
6768
|
+
*
|
|
6769
|
+
* The only way a question or a drawing ends early. Idempotent, so a double press and a press that
|
|
6770
|
+
* lands as the answer does are both harmless: the run decides once which of the two won.
|
|
6771
|
+
*/
|
|
6772
|
+
async function handleStop(req, res, workspace) {
|
|
6773
|
+
const body = await readJson(req);
|
|
6774
|
+
const kind = RUN_KINDS.find((candidate) => candidate === body?.kind);
|
|
6775
|
+
if (!kind) return send(res, 400, { error: "Say what to stop: the question or the drawing." });
|
|
6776
|
+
send(res, 200, { stopped: stopRun(workspace.id, kind) });
|
|
6247
6777
|
}
|
|
6248
6778
|
/**
|
|
6249
6779
|
* Draw the change, or one theme of it, and keep the drawing.
|
|
@@ -6257,46 +6787,60 @@ async function handleDiagram(req, res, workspace) {
|
|
|
6257
6787
|
const body = await readJson(req);
|
|
6258
6788
|
const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
|
|
6259
6789
|
const scope = sectionId ?? "change";
|
|
6260
|
-
const
|
|
6790
|
+
const redraw = (workspace.diagrams ?? []).some((diagram) => diagram.scope === scope);
|
|
6791
|
+
const started = beginRun(workspace.id, "diagram", { sectionId });
|
|
6792
|
+
if ("alreadyRunning" in started) return send(res, 409, {
|
|
6793
|
+
error: "A diagram of this review is already being drawn.",
|
|
6794
|
+
running: true
|
|
6795
|
+
});
|
|
6796
|
+
const run = started.run;
|
|
6261
6797
|
record({
|
|
6262
6798
|
event: "diagram_requested",
|
|
6263
6799
|
reviewId: workspace.id,
|
|
6264
6800
|
scoped: sectionId !== null,
|
|
6265
|
-
redraw
|
|
6266
|
-
});
|
|
6267
|
-
const cancel = new AbortController();
|
|
6268
|
-
res.on("close", () => {
|
|
6269
|
-
if (!res.writableEnded) cancel.abort();
|
|
6801
|
+
redraw
|
|
6270
6802
|
});
|
|
6271
6803
|
const startedAt = Date.now();
|
|
6272
|
-
|
|
6804
|
+
const failed = () => record({
|
|
6805
|
+
event: "diagram_drawn",
|
|
6806
|
+
reviewId: workspace.id,
|
|
6807
|
+
durationMs: Date.now() - startedAt,
|
|
6808
|
+
ok: false,
|
|
6809
|
+
...run.state === "stopped" ? { cancelled: true } : {}
|
|
6810
|
+
});
|
|
6273
6811
|
try {
|
|
6274
|
-
diagram
|
|
6275
|
-
|
|
6276
|
-
|
|
6812
|
+
let diagram;
|
|
6813
|
+
try {
|
|
6814
|
+
diagram = await drawDiagram(workspace, sectionId, run.signal);
|
|
6815
|
+
} catch (error) {
|
|
6816
|
+
failed();
|
|
6817
|
+
if (run.state === "stopped") return sendIfOpen(res, 200, { stopped: true });
|
|
6818
|
+
if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError) return sendIfOpen(res, 502, { error: error.message });
|
|
6819
|
+
throw error;
|
|
6820
|
+
}
|
|
6821
|
+
if (!completeRun(run)) {
|
|
6822
|
+
failed();
|
|
6823
|
+
return sendIfOpen(res, 200, { stopped: true });
|
|
6824
|
+
}
|
|
6277
6825
|
record({
|
|
6278
6826
|
event: "diagram_drawn",
|
|
6279
6827
|
reviewId: workspace.id,
|
|
6280
6828
|
durationMs: Date.now() - startedAt,
|
|
6281
|
-
ok:
|
|
6282
|
-
...cancelled ? { cancelled: true } : {}
|
|
6829
|
+
ok: true
|
|
6283
6830
|
});
|
|
6284
|
-
|
|
6285
|
-
if (
|
|
6286
|
-
|
|
6831
|
+
const fresh = loadWorkspace(workspace.id);
|
|
6832
|
+
if (fresh) {
|
|
6833
|
+
const diagrams = [...fresh.diagrams ?? []];
|
|
6834
|
+
const existing = diagrams.findIndex((one) => one.scope === scope);
|
|
6835
|
+
if (existing === -1) diagrams.push(diagram);
|
|
6836
|
+
else diagrams[existing] = diagram;
|
|
6837
|
+
fresh.diagrams = diagrams;
|
|
6838
|
+
saveWorkspace(fresh);
|
|
6839
|
+
}
|
|
6840
|
+
sendIfOpen(res, 200, { diagram });
|
|
6841
|
+
} finally {
|
|
6842
|
+
endRun(run);
|
|
6287
6843
|
}
|
|
6288
|
-
record({
|
|
6289
|
-
event: "diagram_drawn",
|
|
6290
|
-
reviewId: workspace.id,
|
|
6291
|
-
durationMs: Date.now() - startedAt,
|
|
6292
|
-
ok: true
|
|
6293
|
-
});
|
|
6294
|
-
const diagrams = [...workspace.diagrams ?? []];
|
|
6295
|
-
if (existing === -1) diagrams.push(diagram);
|
|
6296
|
-
else diagrams[existing] = diagram;
|
|
6297
|
-
workspace.diagrams = diagrams;
|
|
6298
|
-
saveWorkspace(workspace);
|
|
6299
|
-
send(res, 200, { diagram });
|
|
6300
6844
|
}
|
|
6301
6845
|
/**
|
|
6302
6846
|
* Stop serving, at the request of a workspace.
|
|
@@ -6385,7 +6929,10 @@ async function readJson(req) {
|
|
|
6385
6929
|
}
|
|
6386
6930
|
}
|
|
6387
6931
|
function send(res, status, payload) {
|
|
6388
|
-
res.writeHead(status, {
|
|
6932
|
+
res.writeHead(status, {
|
|
6933
|
+
"content-type": "application/json; charset=utf-8",
|
|
6934
|
+
"cache-control": NO_STORE
|
|
6935
|
+
});
|
|
6389
6936
|
res.end(JSON.stringify(payload));
|
|
6390
6937
|
}
|
|
6391
6938
|
var ASK_SOURCES = [
|
|
@@ -6506,16 +7053,12 @@ async function sweep(now = Date.now()) {
|
|
|
6506
7053
|
if (markAbandoned(job, now)) report.abandoned += 1;
|
|
6507
7054
|
continue;
|
|
6508
7055
|
}
|
|
6509
|
-
if (now -
|
|
7056
|
+
if (now - endedAt(job) < 216e5) continue;
|
|
6510
7057
|
if (await retire(job, report)) report.retired += 1;
|
|
6511
7058
|
}
|
|
6512
7059
|
await removeOrphans(jobs, report);
|
|
6513
7060
|
return report;
|
|
6514
7061
|
}
|
|
6515
|
-
/** When this job stopped doing work. `finishedAt` predates nothing, but a record can be old. */
|
|
6516
|
-
function finishedAt(job) {
|
|
6517
|
-
return job.finishedAt ?? job.updatedAt;
|
|
6518
|
-
}
|
|
6519
7062
|
/**
|
|
6520
7063
|
* Record that a job stopped, unless it has moved since this pass read it.
|
|
6521
7064
|
*
|