prreviewbuddy 0.25.13 → 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 +78 -0
- package/dist/{relative_time-vn8Ex9E8.js → delete_review-B3xciJ03.js} +5479 -5173
- package/dist/main.js +861 -306
- package/dist/server.js +341 -68
- package/package.json +1 -1
- package/static/reviews.js +1 -1
- package/static/shell.css +8 -2
- package/static/workspace.js +10 -10
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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
4
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
@@ -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.
|
|
@@ -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>
|
|
@@ -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
|
/**
|
|
@@ -3352,7 +3484,7 @@ function renderStandardViews(view, sections) {
|
|
|
3352
3484
|
detailsInHeader: true
|
|
3353
3485
|
})}</section>
|
|
3354
3486
|
<section class="prb-view" data-view="guide" hidden>${renderGuideTab(session, null, null, { diagrams: true })}</section>
|
|
3355
|
-
<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 ?? "")}"` : ""}>
|
|
3356
3488
|
<h3 class="prb-doc-title">Diagrams</h3>
|
|
3357
3489
|
${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
|
|
3358
3490
|
</section>
|
|
@@ -4058,6 +4190,9 @@ function renderShutdownDialog() {
|
|
|
4058
4190
|
function renderAsk(view, sections) {
|
|
4059
4191
|
const suggestions = buildSuggestions(view, sections);
|
|
4060
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>` : "";
|
|
4061
4196
|
return `<aside class="prb-ask" aria-label="Ask Buddy AI">
|
|
4062
4197
|
<div
|
|
4063
4198
|
class="prb-ask-resize"
|
|
@@ -4073,8 +4208,8 @@ function renderAsk(view, sections) {
|
|
|
4073
4208
|
</div>
|
|
4074
4209
|
${renderPromptBank(view.prompts ?? [])}
|
|
4075
4210
|
<div class="prb-ask-log" data-ask-log>
|
|
4076
|
-
${transcript.length > 0 ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
|
|
4077
|
-
<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">
|
|
4078
4213
|
<p>Ask about this change. Answers come from the review, the diff and the repository on disk — not from anywhere else.</p>
|
|
4079
4214
|
${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml$1(text)}</button>`).join("")}
|
|
4080
4215
|
</div>`}
|
|
@@ -4481,7 +4616,7 @@ function renderHistoryRow(review, number, latest, now) {
|
|
|
4481
4616
|
*/
|
|
4482
4617
|
function renderAnalysisState(analysis) {
|
|
4483
4618
|
if (analysis === void 0) return "";
|
|
4484
|
-
if (analysis.phase === "failed") return "<span class=\"prb-card-analysis\" data-analysis=\"stopped\">Analysis stopped</span>";
|
|
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>";
|
|
4485
4620
|
return `<span class="prb-card-analysis" data-analysis="running"><i></i>${escapeHtml$1(phasesFor({ purpose: analysis.purpose })[analysis.phase].doing)}</span>`;
|
|
4486
4621
|
}
|
|
4487
4622
|
/**
|
|
@@ -4916,9 +5051,11 @@ function analysisStates(now = Date.now(), jobs = allJobs()) {
|
|
|
4916
5051
|
});
|
|
4917
5052
|
continue;
|
|
4918
5053
|
}
|
|
4919
|
-
|
|
5054
|
+
const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
|
|
5055
|
+
if (ended[0]?.phase === "failed") states.set(reviewId, {
|
|
4920
5056
|
phase: "failed",
|
|
4921
|
-
purpose: "review"
|
|
5057
|
+
purpose: "review",
|
|
5058
|
+
...ended[0].failure?.stopped ? { stopped: true } : {}
|
|
4922
5059
|
});
|
|
4923
5060
|
}
|
|
4924
5061
|
return states;
|
|
@@ -4936,7 +5073,7 @@ function reviewsRevision(live = analysisStates()) {
|
|
|
4936
5073
|
for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
|
|
4937
5074
|
for (const id of [...live.keys()].sort()) {
|
|
4938
5075
|
const state = live.get(id);
|
|
4939
|
-
hash.update(`${id}@${state.purpose}:${state.phase}\n`);
|
|
5076
|
+
hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
|
|
4940
5077
|
}
|
|
4941
5078
|
return hash.digest("base64url");
|
|
4942
5079
|
}
|
|
@@ -4979,6 +5116,7 @@ var AskFailedError = class extends Error {};
|
|
|
4979
5116
|
async function askAboutChange(workspace, question, sectionId, signal) {
|
|
4980
5117
|
const cwd = await askCheckout(workspace);
|
|
4981
5118
|
const env = agentEnvOf(workspace);
|
|
5119
|
+
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
4982
5120
|
try {
|
|
4983
5121
|
return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor(env).run({
|
|
4984
5122
|
capability: "read-code",
|
|
@@ -4986,10 +5124,11 @@ async function askAboutChange(workspace, question, sectionId, signal) {
|
|
|
4986
5124
|
cwd,
|
|
4987
5125
|
originRepoPath: workspace.repoPath,
|
|
4988
5126
|
env,
|
|
5127
|
+
...requestedModel ? { requestedModel } : {},
|
|
4989
5128
|
timeoutMs: ASK_TIMEOUT_MS,
|
|
4990
5129
|
signal,
|
|
4991
5130
|
onUsage
|
|
4992
|
-
})));
|
|
5131
|
+
}), void 0, requestedModel));
|
|
4993
5132
|
} catch (error) {
|
|
4994
5133
|
if (error instanceof AgentUnavailableError) throw new AskUnavailableError(`${error.message} It is what this review was made with, so the assistant cannot answer.`);
|
|
4995
5134
|
throw explained(error, env);
|
|
@@ -5064,6 +5203,86 @@ function buildPrompt$1(workspace, question, sectionId, cwd) {
|
|
|
5064
5203
|
].join("\n");
|
|
5065
5204
|
}
|
|
5066
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
|
|
5067
5286
|
//#region ../../packages/review-harness/src/workspace/diagram.ts
|
|
5068
5287
|
/** The same budget a question gets. Drawing is reading, and reading is what takes the time. */
|
|
5069
5288
|
var DIAGRAM_TIMEOUT_MS = 18e4;
|
|
@@ -5120,6 +5339,7 @@ flowchart LR
|
|
|
5120
5339
|
async function drawDiagram(workspace, sectionId, signal) {
|
|
5121
5340
|
const cwd = await askCheckout(workspace);
|
|
5122
5341
|
const env = agentEnvOf(workspace);
|
|
5342
|
+
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
5123
5343
|
let raw;
|
|
5124
5344
|
try {
|
|
5125
5345
|
raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
|
|
@@ -5128,10 +5348,11 @@ async function drawDiagram(workspace, sectionId, signal) {
|
|
|
5128
5348
|
cwd,
|
|
5129
5349
|
originRepoPath: workspace.repoPath,
|
|
5130
5350
|
env,
|
|
5351
|
+
...requestedModel ? { requestedModel } : {},
|
|
5131
5352
|
timeoutMs: DIAGRAM_TIMEOUT_MS,
|
|
5132
5353
|
signal,
|
|
5133
5354
|
onUsage
|
|
5134
|
-
}));
|
|
5355
|
+
}), void 0, requestedModel);
|
|
5135
5356
|
} catch (error) {
|
|
5136
5357
|
if (error instanceof AgentUnavailableError) throw new DiagramUnavailableError(`${error.message} It is what this review was made with, so nothing can be drawn.`);
|
|
5137
5358
|
throw error;
|
|
@@ -5704,6 +5925,7 @@ async function handle(req, res, options) {
|
|
|
5704
5925
|
if (action === "/file") return void sendFile(res, workspace, url.searchParams);
|
|
5705
5926
|
if (action === "/ask") return handleAsk(req, res, workspace);
|
|
5706
5927
|
if (action === "/diagram") return handleDiagram(req, res, workspace);
|
|
5928
|
+
if (action === "/stop") return handleStop(req, res, workspace);
|
|
5707
5929
|
if (action === "/kiss") return handleKiss(req, res, workspace);
|
|
5708
5930
|
if (action === "/state") return handleState(req, res, workspace);
|
|
5709
5931
|
if (action === "/event") return handleEvent(req, res, workspace);
|
|
@@ -6162,6 +6384,8 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
6162
6384
|
repoPath: workspace.repoPath,
|
|
6163
6385
|
askWidth: readPreferences().askWidth,
|
|
6164
6386
|
transcript: workspace.transcript,
|
|
6387
|
+
pendingAsk: reportRun(workspace.id, "ask"),
|
|
6388
|
+
pendingDiagram: reportRun(workspace.id, "diagram"),
|
|
6165
6389
|
prompts: readPromptBank(),
|
|
6166
6390
|
complete: workspace.completedAt !== void 0,
|
|
6167
6391
|
prContext: workspace.prContext ? {
|
|
@@ -6265,7 +6489,9 @@ function jobReport(workspace) {
|
|
|
6265
6489
|
return {
|
|
6266
6490
|
...jobStatus(workspace.jobId ? loadJob(workspace.jobId) : null),
|
|
6267
6491
|
kiss: jobStatus(kissRun ? loadJob(kissRun.jobId) : null),
|
|
6268
|
-
update: jobStatus(liveUpdateFor(workspace.id))
|
|
6492
|
+
update: jobStatus(liveUpdateFor(workspace.id)),
|
|
6493
|
+
ask: reportRun(workspace.id, "ask"),
|
|
6494
|
+
diagram: reportRun(workspace.id, "diagram")
|
|
6269
6495
|
};
|
|
6270
6496
|
}
|
|
6271
6497
|
/**
|
|
@@ -6474,6 +6700,15 @@ async function handleAsk(req, res, workspace) {
|
|
|
6474
6700
|
const question = typeof body?.question === "string" ? body.question.trim() : "";
|
|
6475
6701
|
if (!question) return send(res, 400, { error: "No question given." });
|
|
6476
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;
|
|
6477
6712
|
record({
|
|
6478
6713
|
event: "assistant_question_asked",
|
|
6479
6714
|
reviewId: workspace.id,
|
|
@@ -6481,40 +6716,64 @@ async function handleAsk(req, res, workspace) {
|
|
|
6481
6716
|
scoped: sectionId !== null,
|
|
6482
6717
|
turn: workspace.transcript.length + 1
|
|
6483
6718
|
});
|
|
6484
|
-
const cancel = new AbortController();
|
|
6485
|
-
res.on("close", () => {
|
|
6486
|
-
if (!res.writableEnded) cancel.abort();
|
|
6487
|
-
});
|
|
6488
6719
|
const startedAt = Date.now();
|
|
6489
|
-
|
|
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
|
+
});
|
|
6490
6727
|
try {
|
|
6491
|
-
answer
|
|
6492
|
-
|
|
6493
|
-
|
|
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
|
+
}
|
|
6494
6740
|
record({
|
|
6495
6741
|
event: "assistant_answered",
|
|
6496
6742
|
reviewId: workspace.id,
|
|
6497
6743
|
durationMs: Date.now() - startedAt,
|
|
6498
|
-
ok:
|
|
6499
|
-
...cancelled ? { cancelled: true } : {}
|
|
6744
|
+
ok: true
|
|
6500
6745
|
});
|
|
6501
|
-
|
|
6502
|
-
|
|
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);
|
|
6503
6759
|
}
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
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) });
|
|
6518
6777
|
}
|
|
6519
6778
|
/**
|
|
6520
6779
|
* Draw the change, or one theme of it, and keep the drawing.
|
|
@@ -6528,46 +6787,60 @@ async function handleDiagram(req, res, workspace) {
|
|
|
6528
6787
|
const body = await readJson(req);
|
|
6529
6788
|
const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
|
|
6530
6789
|
const scope = sectionId ?? "change";
|
|
6531
|
-
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;
|
|
6532
6797
|
record({
|
|
6533
6798
|
event: "diagram_requested",
|
|
6534
6799
|
reviewId: workspace.id,
|
|
6535
6800
|
scoped: sectionId !== null,
|
|
6536
|
-
redraw
|
|
6537
|
-
});
|
|
6538
|
-
const cancel = new AbortController();
|
|
6539
|
-
res.on("close", () => {
|
|
6540
|
-
if (!res.writableEnded) cancel.abort();
|
|
6801
|
+
redraw
|
|
6541
6802
|
});
|
|
6542
6803
|
const startedAt = Date.now();
|
|
6543
|
-
|
|
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
|
+
});
|
|
6544
6811
|
try {
|
|
6545
|
-
diagram
|
|
6546
|
-
|
|
6547
|
-
|
|
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
|
+
}
|
|
6548
6825
|
record({
|
|
6549
6826
|
event: "diagram_drawn",
|
|
6550
6827
|
reviewId: workspace.id,
|
|
6551
6828
|
durationMs: Date.now() - startedAt,
|
|
6552
|
-
ok:
|
|
6553
|
-
...cancelled ? { cancelled: true } : {}
|
|
6829
|
+
ok: true
|
|
6554
6830
|
});
|
|
6555
|
-
|
|
6556
|
-
if (
|
|
6557
|
-
|
|
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);
|
|
6558
6843
|
}
|
|
6559
|
-
record({
|
|
6560
|
-
event: "diagram_drawn",
|
|
6561
|
-
reviewId: workspace.id,
|
|
6562
|
-
durationMs: Date.now() - startedAt,
|
|
6563
|
-
ok: true
|
|
6564
|
-
});
|
|
6565
|
-
const diagrams = [...workspace.diagrams ?? []];
|
|
6566
|
-
if (existing === -1) diagrams.push(diagram);
|
|
6567
|
-
else diagrams[existing] = diagram;
|
|
6568
|
-
workspace.diagrams = diagrams;
|
|
6569
|
-
saveWorkspace(workspace);
|
|
6570
|
-
send(res, 200, { diagram });
|
|
6571
6844
|
}
|
|
6572
6845
|
/**
|
|
6573
6846
|
* Stop serving, at the request of a workspace.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prreviewbuddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.2",
|
|
4
4
|
"description": "Review a branch or pull request in an isolated checkout, from your terminal, with the coding agent you already have installed.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://prreviewbuddy.com",
|