mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
15f8d4fe4c
* feat(review): collapse linguist-generated files by default (#1317) Code review now respects linguist-generated (and linguist-generated=true) from .gitattributes, collapsing those diffs by default the way GitHub does. Server (Bun + Pi mirror): a generatedFiles sidecar rides /api/diff and /api/diff/switch, resolved through git's own attribute machinery — one batched 'git check-attr --stdin -z' over the served patch's paths at the review cwd, so stacked and negated rules land exactly as git resolves them. Plain local git sessions only; PR worktrees, workspace multi-repo, jj, GitButler, and P4 omit the sidecar (degrade to no-collapse). Shared logic in packages/shared/generated-files.ts, vendored to Pi. Client: generated files SEED their CodeView item collapsed (the existing Pierre collapse state — same mechanism as commit-diff folding), render the one-line FileHeader bar with a 'generated' tag next to the +/- counts, and expand per file on click. Expansion is session-local App state so it survives remounts and diff switches. Presentation-only: the diff data, annotations, search, and Edit Mode are untouched; the file tree and single-file tabs list generated files normally (tag, no auto-collapse). Guide viewer manifest pin regenerated (AllFilesCodeView/FileHeader are bundled into the guides.show viewer) from a clean frozen-lockfile install. * feat(review): built-in generated defaults, visible collapsed strip, review-round fixes (#1317) Round 2 on PR #1346, per maintainer review. Built-in generated defaults (industry-standard two-layer detection): packages/shared/generated-files.ts (vendored to Pi) now carries DEFAULT_GENERATED_PATTERNS — lockfiles (package-lock.json, yarn.lock, bun.lock, Cargo.lock, go.sum, ...) plus *.min.js / *.min.css / *.map — matched against the path's last segment only. Explicit .gitattributes wins in BOTH directions: linguist-generated (set/true) marks any file, -linguist-generated / =false un-marks even a built-in name, unspecified falls through to the defaults. In plain local git sessions check-attr refines the defaults; the non-git degrade modes (piped patches, PR worktrees, workspace, jj, GitButler, P4) now emit the sidecar from the name-based defaults alone instead of omitting it. Visible collapsed state: a collapsed generated card no longer renders as a bare header — a GeneratedFileNotice strip ('Generated file collapsed', +N/-N, 'Click to view') styled like the other below-header notices sits in the card, and clicking it expands through the SAME reportFileCollapsed funnel as the chevron. Review findings: - F1: search-match and sidebar-comment navigation expanded items without reporting through the funnel, so those expansions died on diff switch. Both now call syncAllCollapsedMirror + reportFileCollapsed; the funnel invariant comment lists the navigation-driven sites. - F2: the check-attr call gets the same 5000ms timeout as review-core's stdin git callers, and Pi's vcs.ts stdin write gets the one-line EPIPE guard (call-flow.ts shape) a timeout kill makes reachable. - F3: removed the dead prevGeneratedRef + collectSetDelta leg — a changed generated set always remounts via fileSetKey, so the delta path was unreachable. Tests: default-list matching (glob + directory-named-bun.lock), both- direction precedence, non-git sidecar from defaults (dual-runtime), the placeholder strip through the funnel, and search expansion surviving a re-seed round-trip. AGENTS.md payload docs updated. Guide viewer manifest pin regenerated from this clean frozen-lockfile worktree.
3339 lines
136 KiB
TypeScript
3339 lines
136 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { createServer } from "node:http";
|
|
import os from "node:os";
|
|
import { basename, resolve as resolvePath } from "node:path";
|
|
|
|
import { SingleFlight } from "../generated/single-flight.ts";
|
|
import { contentHash, deleteDraft } from "../generated/draft.ts";
|
|
import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, resolveAIEnabled, resolveSharingEnabled, resolveCursorSandbox, resolveGuideHistory, resolveGuideShareUrl } from "../generated/config.ts";
|
|
import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts";
|
|
|
|
export type {
|
|
DiffOption,
|
|
DiffType,
|
|
GitContext,
|
|
} from "../generated/review-core.ts";
|
|
|
|
import {
|
|
getDisplayRepo,
|
|
getMRLabel,
|
|
getMRNumberLabel,
|
|
isSameProject,
|
|
type PRMetadata,
|
|
type PRListItem,
|
|
type PRRef,
|
|
type PRReviewFileComment,
|
|
prRefFromMetadata,
|
|
} from "../generated/pr-types.ts";
|
|
import {
|
|
PR_CONTEXT_HEARTBEAT_COMMENT,
|
|
PR_CONTEXT_HEARTBEAT_INTERVAL_MS,
|
|
createPRContextLiveCache,
|
|
serializePRContextSSEEvent,
|
|
} from "../generated/pr-context-live.ts";
|
|
import {
|
|
fetchPRArtifactContent,
|
|
fetchPRArtifactDocument,
|
|
PRArtifactDocumentError,
|
|
} from "../generated/pr-artifact-document.ts";
|
|
import {
|
|
type DiffType,
|
|
type GitContext,
|
|
type RemoteDefaultInfo,
|
|
type SinceBaseSections,
|
|
detectRemoteDefaultInfo,
|
|
getFileContentsForDiff as getFileContentsForDiffCore,
|
|
getSinceBaseSections,
|
|
isBinaryPatchFile,
|
|
isSameCwdCommitSwitch,
|
|
listPatchFiles,
|
|
parseCommitDiffType,
|
|
parseWorktreeDiffType,
|
|
resolveBaseBranch,
|
|
validateFilePath,
|
|
} from "../generated/review-core.ts";
|
|
import {
|
|
getGitButlerContextRevision,
|
|
getGitButlerPatchFingerprint,
|
|
} from "../generated/gitbutler-core.ts";
|
|
import {
|
|
getCommitDiffInfo,
|
|
listCommitHistory,
|
|
type CommitDiffInfo,
|
|
} from "../generated/commit-history.ts";
|
|
import {
|
|
checkoutPRHead,
|
|
getPRDiffScopeOptions,
|
|
getPRFullStackFingerprint,
|
|
getPRStackInfo,
|
|
resolveStackInfo,
|
|
resolvePRFullStackBaseRef,
|
|
runPRFullStackDiff,
|
|
runPRLayerLocalDiff,
|
|
type PRDiffScope,
|
|
} from "../generated/pr-stack.ts";
|
|
|
|
import { resolvePoolCwd, type WorktreePool } from "../generated/worktree-pool.ts";
|
|
import { createCommitAvatarResolver } from "../generated/commit-avatars.ts";
|
|
import { detectGeneratedFiles, detectGeneratedFilesByName } from "../generated/generated-files.ts";
|
|
|
|
import { createEditorAnnotationHandler } from "./annotations.ts";
|
|
import { createAgentJobHandler, whichCmd as commandExists } from "./agent-jobs.ts";
|
|
import { type AgentJobInfo, REVIEW_OUTPUT_FAILED, getAgentJobAnnotationContext, markJobReviewFailed } from "../generated/agent-jobs.ts";
|
|
import { createExternalAnnotationHandler } from "./external-annotations.ts";
|
|
import {
|
|
handleDraftRequest,
|
|
handleFavicon,
|
|
handleImageRequest,
|
|
readDraftGenerationFromBody,
|
|
readDraftGenerationFromUrl,
|
|
handleUploadRequest,
|
|
} from "./handlers.ts";
|
|
import { handleApiNotFound, html, json, parseBody, parseJsonBody, readBody, requestUrl, send } from "./helpers.ts";
|
|
import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.ts";
|
|
|
|
import { buildAdvertisedUrl, isRemoteSession, listenOnPort } from "./network.ts";
|
|
import { getAvailableOpenInApps, openFileInApp } from "./open-in-apps.ts";
|
|
import { resolveOpenInTarget } from "../generated/html-assets-node.ts";
|
|
import {
|
|
fetchPR,
|
|
fetchPRContext,
|
|
fetchPRFileContent,
|
|
fetchPRList,
|
|
fetchPRStack,
|
|
fetchPRViewedFiles,
|
|
getPRUser,
|
|
markPRFilesViewed,
|
|
parsePRUrl,
|
|
prCommandRuntime,
|
|
submitPRReview,
|
|
} from "./pr.ts";
|
|
import { getRepoInfo } from "./project.ts";
|
|
import {
|
|
composeCodexReviewPrompt,
|
|
buildCodexCommand,
|
|
generateOutputPath,
|
|
parseCodexOutput,
|
|
transformReviewFindings,
|
|
} from "../generated/codex-review.ts";
|
|
import { buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "../generated/agent-review-message.ts";
|
|
import {
|
|
composeClaudeReviewPrompt,
|
|
buildClaudeCommand,
|
|
parseClaudeStreamOutput,
|
|
transformClaudeFindings,
|
|
} from "../generated/claude-review.ts";
|
|
import { createTourSession, TOUR_EMPTY_OUTPUT_ERROR } from "../generated/tour-review.ts";
|
|
import { createGuideSession, GUIDE_EMPTY_OUTPUT_ERROR } from "../generated/guide-review.ts";
|
|
import { GuideShareError, shareGuide, unshareBeforeDelete, unshareGuide } from "../generated/guide-share.ts";
|
|
import { createGuideStoreSession, SAVED_GUIDE_ID_PREFIX, updateGuideShare } from "../generated/guide-store.ts";
|
|
import {
|
|
buildGuideSnapshot,
|
|
createGuideHtml,
|
|
detectGuideLanguages,
|
|
guideExportFilename,
|
|
resolveGuideViewerAssets,
|
|
type GuideLaunchReview,
|
|
type GuideSnapshot,
|
|
type GuideSnapshotSource,
|
|
} from "../generated/guide-format.ts";
|
|
import { GUIDE_VIEWER_MANIFEST } from "../generated/guide-viewer-manifest.ts";
|
|
import {
|
|
MARKER_ENGINES,
|
|
composeMarkerReviewPrompt,
|
|
buildMarkerCommand,
|
|
parseMarkerStreamOutput,
|
|
reduceMarkerStream,
|
|
transformMarkerFindings,
|
|
makeMarkerNonce,
|
|
extractMarkerNonce,
|
|
type MarkerEngineId,
|
|
} from "../generated/marker-review.ts";
|
|
import {
|
|
WorkspaceReviewSession,
|
|
type WorkspaceDiffType,
|
|
} from "../generated/review-workspace.ts";
|
|
import {
|
|
type CodeNavRequest,
|
|
type CodeNavRuntime,
|
|
resolveCodeNav,
|
|
validateCodeNavRequest,
|
|
extractChangedFiles,
|
|
} from "../generated/code-nav.ts";
|
|
import {
|
|
createDefaultSemanticDiffRuntime,
|
|
getSemanticDiffAvailability,
|
|
getSemanticDiffScratchCwd,
|
|
runSemanticDiff,
|
|
semanticDiffCacheKey,
|
|
semanticDiffFileExtsFromSearchParams,
|
|
SemanticDiffResponseCache,
|
|
} from "../generated/semantic-diff.ts";
|
|
import type { SemanticDiffAvailability, SemanticDiffResponse } from "../generated/semantic-diff-types.ts";
|
|
import { CallFlowService } from "../generated/call-flow.ts";
|
|
import { CallFlowInstallCoordinator, callFlowInstallOriginAllowed } from "../generated/call-flow-install.ts";
|
|
import { parseCallFlowInstallRequest, resolveCallFlowInstallTargets } from "../generated/call-flow-languages.ts";
|
|
import type { CallFlowResponse } from "../generated/call-flow-types.ts";
|
|
import { discoverCuratedSkills, resolveRequestedReviewProfile, listAllSkills, enableReviewSkill } from "../generated/review-skill-loader.ts";
|
|
import { readGuideInstructions, writeGuideInstructions } from "../generated/guide-instructions-store.ts";
|
|
import {
|
|
BUILTIN_DEFAULT_PROFILE,
|
|
type ReviewProfilesResponse,
|
|
} from "../generated/review-profiles.ts";
|
|
import {
|
|
canStageFiles,
|
|
detectRemoteDefaultCompareTarget,
|
|
getVcsContext,
|
|
getVcsDiffFingerprint,
|
|
getVcsFileContentsForDiff,
|
|
resolveVcsCwd,
|
|
reviewRuntime,
|
|
materializeVcsSnapshot,
|
|
runVcsDiff,
|
|
stageFile,
|
|
unstageFile,
|
|
vcsOwnsDiffType,
|
|
vcsSupportsSnapshot,
|
|
} from "./vcs.ts";
|
|
|
|
const piCodeNavRuntime: CodeNavRuntime = {
|
|
runCommand(command, args, options) {
|
|
return new Promise((resolve) => {
|
|
const proc = spawn(command, args, {
|
|
cwd: options?.cwd,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
if (options?.timeoutMs) {
|
|
timer = setTimeout(() => proc.kill(), options.timeoutMs);
|
|
}
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
proc.stdout!.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
|
|
proc.stderr!.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
|
|
proc.on("close", (code: number | null) => {
|
|
if (timer) clearTimeout(timer);
|
|
resolve({
|
|
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
|
|
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
|
|
exitCode: code ?? 1,
|
|
});
|
|
});
|
|
proc.on("error", () => {
|
|
if (timer) clearTimeout(timer);
|
|
resolve({ stdout: "", stderr: "command not found", exitCode: 1 });
|
|
});
|
|
});
|
|
},
|
|
};
|
|
|
|
// Review ingestion completion semantics (REVIEW_OUTPUT_FAILED,
|
|
// markJobReviewFailed) now live in the shared agent-jobs module.
|
|
|
|
// Node equivalent of Bun.which(cmd) — used to pick a guide repair engine
|
|
// (prefer whichever schema-enforced CLI is on PATH). Imported as
|
|
// `commandExists` from agent-jobs.ts's `whichCmd` (single source of truth;
|
|
// the other pre-existing copies in ai-runtime.ts / agent-terminal are left
|
|
// alone — out of scope here).
|
|
|
|
/** Detect if running inside WSL (Windows Subsystem for Linux) */
|
|
function detectWSL(): boolean {
|
|
if (process.platform !== "linux") return false;
|
|
if (os.release().toLowerCase().includes("microsoft")) return true;
|
|
try {
|
|
if (existsSync("/proc/version")) {
|
|
const content = readFileSync("/proc/version", "utf-8").toLowerCase();
|
|
return content.includes("wsl") || content.includes("microsoft");
|
|
}
|
|
} catch { /* ignore */ }
|
|
return false;
|
|
}
|
|
|
|
export interface ReviewServerResult {
|
|
port: number;
|
|
portSource: "env" | "remote-default" | "random";
|
|
url: string;
|
|
isRemote: boolean;
|
|
waitForDecision: () => Promise<{
|
|
approved: boolean;
|
|
feedback: string;
|
|
annotations: unknown[];
|
|
agentSwitch?: string;
|
|
exit?: boolean;
|
|
}>;
|
|
stop: () => void;
|
|
}
|
|
|
|
export async function startReviewServer(options: {
|
|
rawPatch: string;
|
|
gitRef: string;
|
|
htmlContent: string;
|
|
origin?: string;
|
|
diffType?: DiffType | WorkspaceDiffType;
|
|
gitContext?: GitContext;
|
|
/**
|
|
* Initial base branch the caller used to compute `rawPatch`. When a caller
|
|
* overrides the detected default (e.g. `openCodeReview({ defaultBranch })`),
|
|
* this must be forwarded so the server's internal `currentBase` state, the
|
|
* `/api/diff` response, and downstream agent prompts stay consistent with
|
|
* the patch that's already on screen.
|
|
*/
|
|
initialBase?: string;
|
|
/** Freshness token captured atomically with the initial provider patch. */
|
|
initialFingerprint?: string;
|
|
error?: string;
|
|
sharingEnabled?: boolean;
|
|
shareBaseUrl?: string;
|
|
pasteApiUrl?: string;
|
|
prMetadata?: PRMetadata;
|
|
/** Platform review writer override used by isolated runtime tests. */
|
|
prReviewSubmitter?: typeof submitPRReview;
|
|
/**
|
|
* The initial layer patch is missing per-file content (platform APIs
|
|
* withhold patches on very large PRs). Enables the local recompute upgrade
|
|
* once a pool checkout is ready.
|
|
*/
|
|
prPatchIncomplete?: boolean;
|
|
/** Working directory for agent processes (e.g., --local worktree). Independent of diff pipeline. */
|
|
agentCwd?: string;
|
|
/** Local parent directory containing multiple child VCS repositories. */
|
|
workspace?: WorkspaceReviewSession;
|
|
/** Per-PR worktree pool. When set, pr-switch creates worktrees instead of checking out. */
|
|
worktreePool?: WorktreePool;
|
|
/** Cleanup callback invoked when server stops (e.g., remove temp worktree) */
|
|
onCleanup?: () => void | Promise<void>;
|
|
/** Called when server starts with the URL, remote status, and port */
|
|
onReady?: (url: string, isRemote: boolean, port: number) => void;
|
|
}): Promise<ReviewServerResult> {
|
|
const gitUser = detectGitUser();
|
|
const aiEnabled = resolveAIEnabled();
|
|
const submitPlatformReview = options.prReviewSubmitter ?? submitPRReview;
|
|
let draftKey = contentHash(options.rawPatch);
|
|
let prMeta = options.prMetadata;
|
|
const isPRMode = !!prMeta;
|
|
const workspace = options.workspace;
|
|
const isWorkspaceMode = !!workspace;
|
|
const hasLocalAccess = !!options.gitContext;
|
|
const sessionVcsType = options.gitContext?.vcsType;
|
|
let clientGitContext = options.gitContext;
|
|
const isRemote = isRemoteSession();
|
|
const wslFlag = detectWSL();
|
|
let prRef = prMeta ? prRefFromMetadata(prMeta) : null;
|
|
const platformUser = prRef ? await getPRUser(prRef) : null;
|
|
let prStackInfo = isPRMode ? getPRStackInfo(prMeta) : null;
|
|
let prDiffScopeOptions = isPRMode
|
|
? getPRDiffScopeOptions(prMeta, !!(options.worktreePool || options.agentCwd))
|
|
: [];
|
|
|
|
let prListCache: PRListItem[] | null = null;
|
|
let prListCacheTime = 0;
|
|
// Platform APIs withhold per-file patches on very large PRs. When the layer
|
|
// patch is incomplete, a local recompute (exact merge-base diff, no size
|
|
// limits) becomes available once a pool checkout exists — the layer
|
|
// fingerprint flips to drive the refresh notice, and the pr-diff-scope
|
|
// "layer" branch performs the upgrade. Tracked per-PR across pr-switch.
|
|
// Partiality is INFORMATION (the platform withheld content) and is always
|
|
// reported; whether a local recompute can be OFFERED is a separate
|
|
// capability, gated on the pool below (layerUpgradeAvailable).
|
|
let layerPatchIncomplete = (options.prPatchIncomplete ?? false) && isPRMode;
|
|
const layerUpgradeAvailable = !!options.worktreePool;
|
|
const prSwitchCache = new Map<string, { metadata: PRMetadata; rawPatch: string; patchIncomplete?: boolean }>();
|
|
if (isPRMode && prMeta) {
|
|
prSwitchCache.set(prMeta.url, {
|
|
metadata: prMeta,
|
|
rawPatch: options.rawPatch,
|
|
patchIncomplete: layerPatchIncomplete,
|
|
});
|
|
}
|
|
const prStackTreeCache = new Map<string, import("../generated/pr-types.ts").PRStackTree | null>();
|
|
const prContextLive = createPRContextLiveCache({ fetchContext: fetchPRContext });
|
|
const warmPRContext = (url: string, ref: PRRef): void => {
|
|
prContextLive.warm(url, ref);
|
|
};
|
|
|
|
// Fetch full stack tree (best-effort — always try in PR mode so root PRs
|
|
// that target the default branch can still discover descendant PRs)
|
|
let prStackTree: import("../generated/pr-types.ts").PRStackTree | null = null;
|
|
if (prRef && prMeta) {
|
|
warmPRContext(prMeta.url, prRef);
|
|
try {
|
|
prStackTree = await fetchPRStack(prRef, prMeta);
|
|
} catch {
|
|
// Non-fatal: client falls back to buildMinimalStackTree()
|
|
}
|
|
prStackTreeCache.set(prMeta.url, prStackTree);
|
|
const resolved = resolveStackInfo(prMeta, prStackTree, prStackInfo);
|
|
if (resolved && !prStackInfo) {
|
|
prStackInfo = resolved;
|
|
prDiffScopeOptions = getPRDiffScopeOptions(prMeta, !!(options.worktreePool || options.agentCwd));
|
|
}
|
|
}
|
|
|
|
// Fetch GitHub viewed file state (non-blocking — errors are silently ignored)
|
|
let initialViewedFiles: string[] = [];
|
|
if (isPRMode && prRef) {
|
|
try {
|
|
const viewedMap = await fetchPRViewedFiles(prRef);
|
|
initialViewedFiles = Object.entries(viewedMap)
|
|
.filter(([, isViewed]) => isViewed)
|
|
.map(([path]) => path);
|
|
} catch {
|
|
// Non-fatal: viewed state is best-effort
|
|
}
|
|
}
|
|
let repoInfo = prMeta
|
|
? {
|
|
display: getDisplayRepo(prMeta),
|
|
branch: `${getMRLabel(prMeta)} ${getMRNumberLabel(prMeta)}`,
|
|
}
|
|
: workspace
|
|
? { display: basename(workspace.root), branch: "Workspace" }
|
|
: getRepoInfo();
|
|
const editorAnnotations = createEditorAnnotationHandler();
|
|
const externalAnnotations = createExternalAnnotationHandler("review");
|
|
|
|
let currentPatch = options.rawPatch;
|
|
let currentGitRef = options.gitRef;
|
|
let currentDiffType: DiffType | WorkspaceDiffType = options.diffType || workspace?.diffType || "uncommitted";
|
|
let currentError = options.error;
|
|
let currentHideWhitespace = loadConfig().diffOptions?.hideWhitespace ?? false;
|
|
let originalPRPatch = options.rawPatch;
|
|
let originalPRGitRef = options.gitRef;
|
|
let originalPRError = options.error;
|
|
let currentPRDiffScope: PRDiffScope = "layer";
|
|
// Monotonic guard for PR scope/switch state writes. Scope requests now park
|
|
// on long awaits (checkout warmup, full recompute) — a request that resumed
|
|
// after a NEWER scope select or pr-switch must not overwrite their state.
|
|
let prScopeEpoch = 0;
|
|
// Monotonic guard for /api/diff/switch (mirrors Bun review.ts) — concurrent
|
|
// switches would otherwise clobber each other's snapshot across awaits.
|
|
let diffSwitchEpoch = 0;
|
|
// Older analysis-setting responses must not describe a superseded view.
|
|
let reviewAnalysisEpoch = 0;
|
|
let reviewAnalysisMutationEpoch: number | null = null;
|
|
// Tracks the base branch the user picked from the UI. Agent review prompts
|
|
// read this (not gitContext.defaultBranch) so they analyze the same diff
|
|
// the reviewer is currently looking at. Honors an explicit initialBase from
|
|
// the caller — e.g. programmatic Pi callers can request a non-detected base.
|
|
const detectedCompareTarget = (): string =>
|
|
options.gitContext?.defaultBranch || options.gitContext?.compareTarget?.fallback || "main";
|
|
let currentBase = options.initialBase || detectedCompareTarget();
|
|
const isGitButlerCommittedView = (diffType: string = currentDiffType as string): boolean =>
|
|
diffType.startsWith("gitbutler:stack:") || diffType.startsWith("gitbutler:branch:");
|
|
let baseEverSwitched = false;
|
|
// True once the user picks a base from the picker (explicitBase on the
|
|
// switch body). Disables the bare-local-name → origin/* canonicalization:
|
|
// the picker offers local and remote refs as distinct choices, so an
|
|
// explicit local pick must be honored even when the two point at
|
|
// different commits.
|
|
let baseExplicitlyChosen = false;
|
|
const resolveReviewBase = (
|
|
requestedBase?: string,
|
|
explicitlyChosen = baseExplicitlyChosen,
|
|
activeBase = currentBase,
|
|
): string => {
|
|
const resolved = resolveBaseBranch(requestedBase, detectedCompareTarget());
|
|
// Canonicalize a bare local default name ("main") to its tracking ref
|
|
// ("origin/main") — the startup upgrade races the first /api/diff, so a
|
|
// client that loaded early re-sends "main" on the next switch/refresh and
|
|
// would revert the server to the stale local branch. Only when the remote
|
|
// default is known, the requested base is exactly its local name, AND the
|
|
// user has never explicitly picked a base — an explicit local pick (and
|
|
// every echo after it) is honored verbatim.
|
|
const remoteBranch = remoteDefaultInfo?.branch;
|
|
if (
|
|
!explicitlyChosen &&
|
|
remoteBranch &&
|
|
remoteBranch.startsWith("origin/") &&
|
|
resolved === remoteBranch.replace(/^origin\//, "")
|
|
) {
|
|
return remoteBranch;
|
|
}
|
|
// Second rule, independent of remoteDefaultInfo: if the SESSION is
|
|
// already on the upgraded tracking ref and a non-explicit request echoes
|
|
// its bare local name, stay on the tracking ref. remoteDefaultInfo comes
|
|
// from a SECOND probe that can lag the startup upgrade by seconds — in
|
|
// that window the rule above is blind, and a diff-type/whitespace switch
|
|
// echoing "main" would commit the session back onto the stale local
|
|
// branch (and set baseEverSwitched, permanently blocking the upgrade).
|
|
if (!explicitlyChosen && activeBase === `origin/${resolved}`) {
|
|
return activeBase;
|
|
}
|
|
return resolved;
|
|
};
|
|
|
|
// --- Diff staleness fingerprint (mirrors packages/server/review.ts) -------
|
|
// Captured beside every patch snapshot; GET /api/diff/fresh recomputes and
|
|
// compares so the client can show a "diff out of date — refresh" notice when
|
|
// files change mid-review. Best-effort: null = "cannot fingerprint" and is
|
|
// reported fresh, never stale.
|
|
let currentFingerprint = options.initialFingerprint ?? getGitButlerPatchFingerprint(
|
|
currentDiffType as DiffType,
|
|
currentPatch,
|
|
clientGitContext,
|
|
);
|
|
const computeDiffFingerprint = async (): Promise<string | null> => {
|
|
try {
|
|
if (workspace) return await workspace.getFingerprint();
|
|
if (isPRMode) {
|
|
if (currentPRDiffScope === "layer") {
|
|
// Platform-computed diff — immutable locally. The :incomplete
|
|
// suffix keeps the baseline honest across the local-recompute
|
|
// upgrade (the upgrade recaptures without it); the upgrade notice
|
|
// itself is client-driven via prPatchIncomplete, not this probe.
|
|
// Recaptured on pr-switch.
|
|
const suffix = layerPatchIncomplete ? ":incomplete" : "";
|
|
return `pr-layer:${prMeta?.url ?? ""}${suffix}`;
|
|
}
|
|
// Full-stack: three-dot diff against the local checkout — fingerprint
|
|
// (merge-base, HEAD), which changes exactly when the patch can.
|
|
const fullStackCwd =
|
|
(options.worktreePool && prMeta ? options.worktreePool.resolve(prMeta.url) : undefined) ??
|
|
options.agentCwd;
|
|
if (!prMeta) return null;
|
|
return await getPRFullStackFingerprint(reviewRuntime, prMeta, fullStackCwd);
|
|
}
|
|
if (!hasLocalAccess) return null;
|
|
return await getVcsDiffFingerprint(
|
|
currentDiffType as DiffType,
|
|
currentBase,
|
|
options.gitContext?.cwd,
|
|
{ hideWhitespace: currentHideWhitespace },
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
// Fire-and-forget capture: never delays the snapshot response it describes.
|
|
// Generation-guarded: two rapid switches can resolve their captures out of
|
|
// order — only the LATEST capture may write the baseline, otherwise a stale
|
|
// fingerprint would make /api/diff/fresh report stale forever.
|
|
let fingerprintGeneration = 0;
|
|
let pendingFingerprintCapture: Promise<string | null> | null = null;
|
|
const fileContentFingerprintProbes = new SingleFlight<string | null>();
|
|
const callFlowService = new CallFlowService();
|
|
// In-app opt-in runtime install (mirrors Bun review.ts). Completion
|
|
// invalidates the service's 30 second runtime probe cache so the very
|
|
// next capability advert resolves available without a server restart.
|
|
const callFlowInstall = new CallFlowInstallCoordinator({
|
|
onSettled: (ok) => {
|
|
if (ok) callFlowService.invalidateRuntimeState();
|
|
},
|
|
});
|
|
const captureDiffFingerprint = (knownFingerprint?: string): void => {
|
|
// A fingerprint capture marks a committed review-view change. Stop work
|
|
// for the prior snapshot even when the new view cannot run CallDiff.
|
|
callFlowService.cancelAll();
|
|
fileContentFingerprintProbes.clear();
|
|
const generation = ++fingerprintGeneration;
|
|
if (knownFingerprint !== undefined) {
|
|
currentFingerprint = knownFingerprint;
|
|
pendingFingerprintCapture = null;
|
|
return;
|
|
}
|
|
// Never leave the previous snapshot's baseline attached while the new
|
|
// capture is pending. Expansion waits for this promise when necessary.
|
|
currentFingerprint = null;
|
|
const capture = computeDiffFingerprint();
|
|
pendingFingerprintCapture = capture;
|
|
void capture.then((fingerprint) => {
|
|
if (generation === fingerprintGeneration) {
|
|
currentFingerprint = fingerprint;
|
|
pendingFingerprintCapture = null;
|
|
}
|
|
});
|
|
};
|
|
if (currentFingerprint === null) captureDiffFingerprint();
|
|
|
|
// --- Base staleness vs the remote (mirrors Bun review.ts) -----------------
|
|
// `origin/<default>` is GitHub's state as of the last fetch. The startup
|
|
// ls-remote also carries the remote tip SHA; comparing it to the local
|
|
// tracking ref tells us whether the baseline is behind. Refreshed lazily at
|
|
// most once a minute (network call, unlike the 5s fingerprint probe).
|
|
let remoteDefaultInfo: RemoteDefaultInfo | null = null;
|
|
let baseBehindRemote = false;
|
|
let lastRemoteBaseCheck = 0;
|
|
const REMOTE_BASE_CHECK_INTERVAL_MS = 60_000;
|
|
const remoteBaseCheckApplies = (): boolean =>
|
|
!!options.gitContext && !isPRMode && (!sessionVcsType || sessionVcsType === "git");
|
|
|
|
// Only base-relative diff types (since-base / branch / merge-base) care
|
|
// about the base being behind the remote; the banner must not show under
|
|
// uncommitted/staged/etc.
|
|
const baseRelevantDiffType = (diffType: string = currentDiffType as string): boolean => {
|
|
const t = parseWorktreeDiffType(diffType)?.subType ?? diffType;
|
|
return t === "since-base" || t === "branch" || t === "merge-base";
|
|
};
|
|
|
|
// Local-only computation from the cached remote tip — no network. Parameters
|
|
// let switch handlers evaluate a staged snapshot before committing it.
|
|
async function computeBaseBehindRemote(
|
|
base: string = currentBase,
|
|
diffType: string = currentDiffType as string,
|
|
explicitlyChosen = baseExplicitlyChosen,
|
|
): Promise<boolean> {
|
|
// Capture once: a concurrent refreshRemoteBaseInfo can null
|
|
// remoteDefaultInfo (transient ls-remote failure) during the rev-parse
|
|
// await below — reading the global after it would throw.
|
|
const remoteInfo = remoteDefaultInfo;
|
|
if (!remoteBaseCheckApplies() || !baseRelevantDiffType(diffType) || !remoteInfo?.remoteHeadSha) {
|
|
return false;
|
|
}
|
|
// Match the remote default branch as either its local name ("main") or
|
|
// the tracking ref ("origin/main"), and compare by RESOLVED SHA — this is
|
|
// what makes the check work when currentBase is the bare local name (the
|
|
// case whenever origin/HEAD's local symref isn't set; Pi forwards that
|
|
// local name as initialBase).
|
|
//
|
|
// A local name the user EXPLICITLY picked is exempt: they chose the local
|
|
// ref over origin/* on purpose, and Fetch advances origin/* — the banner
|
|
// would be un-clearable nagging about a deliberate choice (same treatment
|
|
// as any non-default base).
|
|
const remoteBranch = remoteInfo.branch;
|
|
const localName = remoteBranch.replace(/^origin\//, "");
|
|
const matchesDefault =
|
|
base === remoteBranch ||
|
|
(base === localName && !explicitlyChosen);
|
|
if (!matchesDefault) {
|
|
return false;
|
|
}
|
|
// --verify: without it, `rev-parse --end-of-options <ref>` echoes the flag
|
|
// as a literal first output line, so .trim() never equals the SHA and the
|
|
// banner was stuck true on every repo with a remote.
|
|
const local = await reviewRuntime.runGit(
|
|
["--no-optional-locks", "rev-parse", "--verify", "--end-of-options", base],
|
|
{ cwd: options.gitContext?.cwd },
|
|
);
|
|
return local.exitCode === 0 && local.stdout.trim() !== remoteInfo.remoteHeadSha;
|
|
}
|
|
|
|
async function recomputeBaseBehindRemote(): Promise<void> {
|
|
baseBehindRemote = await computeBaseBehindRemote();
|
|
}
|
|
|
|
async function refreshRemoteBaseInfo(): Promise<void> {
|
|
if (!remoteBaseCheckApplies()) return;
|
|
lastRemoteBaseCheck = Date.now();
|
|
remoteDefaultInfo = await detectRemoteDefaultInfo(reviewRuntime, options.gitContext?.cwd);
|
|
await recomputeBaseBehindRemote();
|
|
}
|
|
|
|
function maybeRefreshRemoteBaseInfo(): void {
|
|
if (!remoteBaseCheckApplies()) return;
|
|
if (Date.now() - lastRemoteBaseCheck < REMOTE_BASE_CHECK_INTERVAL_MS) return;
|
|
lastRemoteBaseCheck = Date.now();
|
|
void refreshRemoteBaseInfo().catch(() => {});
|
|
}
|
|
|
|
// Commit-author avatar resolution for /api/commits — session-scoped so the
|
|
// forge lookups (gh/glab) and their failures are paid at most once.
|
|
const commitAvatars = createCommitAvatarResolver(prCommandRuntime);
|
|
|
|
// --- Since-base sections sidecar (mirrors Bun review.ts) ------------------
|
|
function isSinceBaseActive(diffType: string = currentDiffType as string): boolean {
|
|
if (isPRMode || workspace || !options.gitContext) return false;
|
|
const effective = parseWorktreeDiffType(diffType)?.subType ?? diffType;
|
|
return effective === "since-base";
|
|
}
|
|
// --- Commit metadata sidecar (mirrors Bun review.ts) -----------------------
|
|
// When a commit:<sha> diff is active, the full commit message (rendered as
|
|
// markdown client-side) heads the all-files view. Avatar enrichment reuses
|
|
// the session cache. diffType parameterized for the same pin-before-await
|
|
// discipline as buildSectionsSidecar.
|
|
async function buildCommitInfoSidecar(diffType: string = currentDiffType as string): Promise<CommitDiffInfo | undefined> {
|
|
if (isPRMode || workspace || !options.gitContext) return undefined;
|
|
const effective = parseWorktreeDiffType(diffType)?.subType ?? diffType;
|
|
const sha = parseCommitDiffType(effective as string)?.sha;
|
|
if (!sha) return undefined;
|
|
const cwd = resolveVcsCwd(diffType as DiffType, options.gitContext.cwd);
|
|
const info = await getCommitDiffInfo(reviewRuntime, sha, cwd);
|
|
if (!info) return undefined;
|
|
const avatars = await commitAvatars.resolve(cwd, [info.authorEmail]);
|
|
const avatarUrl = avatars.get(info.authorEmail);
|
|
return avatarUrl ? { ...info, avatarUrl } : info;
|
|
}
|
|
|
|
// Base AND diff type are parameterized so callers can pin them to a
|
|
// snapshot taken before an await — reading the globals inside would race
|
|
// the startup base upgrade and concurrent diff-type switches.
|
|
async function buildSectionsSidecar(
|
|
base: string = currentBase,
|
|
diffType: string = currentDiffType as string,
|
|
): Promise<SinceBaseSections | undefined> {
|
|
if (!isSinceBaseActive(diffType)) return undefined;
|
|
const cwd = resolveVcsCwd(diffType as DiffType, options.gitContext?.cwd);
|
|
return (await getSinceBaseSections(reviewRuntime, base, cwd)) ?? undefined;
|
|
}
|
|
|
|
// --- Generated-files sidecar (#1317, mirrors Bun review.ts) ----------------
|
|
// Two-layer generated detection for the served patch's paths so the client
|
|
// can collapse those diffs by default, GitHub-style: built-in name defaults
|
|
// (lockfiles, minified assets — no git needed) refined by `.gitattributes`
|
|
// `linguist-generated`, which wins in both directions (set marks, unset
|
|
// un-marks even a built-in name, unspecified keeps the default).
|
|
// Presentation-layer only: the patch is never filtered and snapshot/
|
|
// fingerprint semantics are untouched. Attribute refinement runs for plain
|
|
// local Git sessions only — PR worktrees, workspace multi-repo, jj, and
|
|
// GitButler get the name-based defaults alone rather than guessing
|
|
// attributes for a tree git can't authoritatively resolve here. Patch and
|
|
// diff type are parameterized for the same pin-before-await discipline as
|
|
// buildSectionsSidecar.
|
|
async function buildGeneratedFilesSidecar(
|
|
patch: string = currentPatch,
|
|
diffType: string = currentDiffType as string,
|
|
): Promise<string[] | undefined> {
|
|
const paths = listPatchFiles(patch).map((f) => f.path);
|
|
const plainLocalGit =
|
|
!isPRMode && !workspace && options.gitContext && (sessionVcsType ?? "git") === "git";
|
|
const generated = plainLocalGit
|
|
? await detectGeneratedFiles(
|
|
reviewRuntime,
|
|
resolveVcsCwd(diffType as DiffType, options.gitContext!.cwd),
|
|
paths,
|
|
)
|
|
: detectGeneratedFilesByName(paths);
|
|
return generated.length > 0 ? generated : undefined;
|
|
}
|
|
|
|
// Decoupled startup probes (a forwarded initialBase must NOT suppress the
|
|
// staleness check — the Pi divergence): always probe remote staleness, and
|
|
// only upgrade currentBase to the upstream ref when no explicit base given.
|
|
// Upgrade currentBase to the upstream tracking ref ("origin/main") when no
|
|
// explicit base was requested, OR when the forwarded base is just the bare
|
|
// LOCAL name of that same default ("main"). Only origin/* is fetchable, so
|
|
// leaving currentBase as bare "main" makes the "behind GitHub" banner
|
|
// un-clearable (Fetch advances origin/main, not local main). Canonicalizing
|
|
// "main" -> "origin/main" never overrides a deliberately-chosen feature base.
|
|
if (options.gitContext && !isPRMode) {
|
|
const gitCwd = options.gitContext.cwd;
|
|
detectRemoteDefaultCompareTarget(gitCwd, sessionVcsType).then(
|
|
async (remote) => {
|
|
if (remote && !baseEverSwitched && currentBase !== remote) {
|
|
const localName = remote.replace(/^origin\//, "");
|
|
if (!options.initialBase || currentBase === localName) {
|
|
// Rebuild the diff for the upgraded base BEFORE swapping it in, and
|
|
// commit base+patch+ref+fingerprint together — otherwise the initial
|
|
// patch (built against the old base) would be served under the new
|
|
// base label: a mixed-base review. Skip if the user switched meanwhile.
|
|
try {
|
|
const rebuilt = await runVcsDiff(
|
|
currentDiffType as DiffType,
|
|
remote,
|
|
gitCwd,
|
|
{ hideWhitespace: currentHideWhitespace },
|
|
);
|
|
if (!baseEverSwitched) {
|
|
currentBase = remote;
|
|
currentPatch = rebuilt.patch;
|
|
currentGitRef = rebuilt.label;
|
|
currentError = rebuilt.error;
|
|
// draftKey doubles as the snapshot id the freshness probe
|
|
// compares against each client's echoed ?snapshot= — a client
|
|
// that loaded the pre-upgrade patch mismatches and gets the
|
|
// "Diff out of date · Refresh" banner; later loads carry the
|
|
// new id and stay fresh. That per-client signal is what lets
|
|
// the fingerprint re-baseline unconditionally here.
|
|
draftKey = contentHash(currentPatch);
|
|
captureDiffFingerprint();
|
|
}
|
|
} catch {
|
|
/* keep the initial base+patch — they still match each other */
|
|
}
|
|
}
|
|
}
|
|
void refreshRemoteBaseInfo().catch(() => {});
|
|
},
|
|
() => {
|
|
void refreshRemoteBaseInfo().catch(() => {});
|
|
},
|
|
);
|
|
}
|
|
|
|
// Agent jobs — background process manager (late-binds serverUrl via getter).
|
|
// Spawned jobs run on this machine, so their API URL is pinned to loopback
|
|
// and never inherits the advertised-URL host override (a tailnet-only
|
|
// hostname must not break local agent jobs).
|
|
let serverUrl = "";
|
|
let agentApiUrl = "";
|
|
function resolveAgentCwd(): string {
|
|
if (workspace) return workspace.root;
|
|
if (options.worktreePool && prMeta) {
|
|
const poolPath = options.worktreePool.resolve(prMeta.url);
|
|
if (poolPath) return poolPath;
|
|
}
|
|
if (options.agentCwd) return options.agentCwd;
|
|
return resolveVcsCwd(currentDiffType as DiffType, options.gitContext?.cwd) ?? process.cwd();
|
|
}
|
|
// The current PR's local checkout if one is usable, else null. Mirrors the
|
|
// Bun review server's resolvePRLocalCwd: a pool entry that exists but isn't
|
|
// ready yet yields null (no usable checkout), distinct from resolveAgentCwd
|
|
// which always falls back to a cwd for agent/launch resolution. Used to
|
|
// advertise the Open-in root to the client without a page reload.
|
|
function resolvePRLocalCwd(): string | null {
|
|
const pool = options.worktreePool;
|
|
if (pool && prMeta) {
|
|
const r = resolvePoolCwd(pool, prMeta.url);
|
|
if (r.kind === "ready") return r.path;
|
|
if (r.kind === "pending") return null; // warming up — don't fall back
|
|
}
|
|
return options.agentCwd && existsSync(options.agentCwd) ? options.agentCwd : null;
|
|
}
|
|
async function ensurePRCallFlowCwd(): Promise<string | null> {
|
|
if (options.worktreePool && prMeta) {
|
|
try {
|
|
return (await options.worktreePool.ensure(reviewRuntime, prMeta)).path;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return resolvePRLocalCwd();
|
|
}
|
|
// Strict launch root for /api/open-in: in PR pool mode only the PR's own
|
|
// checkout is acceptable — never the launch-repo fallback resolveAgentCwd
|
|
// uses. Returns [] until ready so resolveOpenInTarget rejects (the button is
|
|
// gated off then anyway); non-PR resolves to the working tree as usual.
|
|
function resolveOpenInRoot(): string | string[] {
|
|
if (workspace) return workspace.root;
|
|
if (options.worktreePool && prMeta) return resolvePRLocalCwd() ?? [];
|
|
return options.agentCwd ?? resolveVcsCwd(currentDiffType as DiffType, options.gitContext?.cwd) ?? process.cwd();
|
|
}
|
|
function getWorkspacePromptContext(): WorkspaceReviewPromptContext | undefined {
|
|
if (!workspace) return undefined;
|
|
return workspace.getPromptContext();
|
|
}
|
|
|
|
// GitButler's picker topology can change while the visible patch stays
|
|
// byte-identical. Include its compact context revision in the snapshot id so
|
|
// a refresh in one tab cannot make another tab's stale picker look current.
|
|
// Other VCS snapshot ids remain byte-for-byte unchanged. Mirrors Bun.
|
|
let currentContextRevision = getGitButlerContextRevision(clientGitContext) ?? "";
|
|
|
|
// Ask AI "changes under review" context for the CURRENT view, built by the
|
|
// SAME machine the review jobs use (contextOnly=true). Returned in the diff
|
|
// payloads so the chat latches it onto user messages; recomputed wherever the
|
|
// view changes. Mirrors packages/server/review.ts buildCurrentAiReviewContext.
|
|
// Parameterized so response handlers that SNAPSHOT the served state before
|
|
// an await can build the AI context from that same snapshot — reading the
|
|
// live globals here would let the startup base upgrade hand Ask AI a
|
|
// context for a different changeset than the rendered patch.
|
|
// Snapshot identity clients echo on freshness probes: the content hash
|
|
// PLUS the view mode. Mode is included so a cross-tab mode switch with a
|
|
// byte-identical patch (layer vs full-stack on a single-PR stack) still
|
|
// flags old tabs; the BASE is deliberately excluded so a same-commit base
|
|
// canonicalization (main -> origin/main) stays banner-silent. draftKey
|
|
// itself stays a pure content hash — drafts survive content-identical
|
|
// mode round-trips.
|
|
function currentSnapshotId(): string {
|
|
return `${draftKey}:${currentDiffType}${isPRMode ? `:${currentPRDiffScope}` : ""}${currentContextRevision ? `:${currentContextRevision}` : ""}`;
|
|
}
|
|
|
|
function buildCurrentAiReviewContext(
|
|
patch: string = currentPatch,
|
|
base: string = currentBase,
|
|
diffType: DiffType = currentDiffType as DiffType,
|
|
): string | undefined {
|
|
if (!aiEnabled) return undefined;
|
|
const workspacePrompt = getWorkspacePromptContext();
|
|
if (workspacePrompt) {
|
|
return buildAgentReviewUserMessageForTarget(
|
|
{ kind: "workspace", patch, workspace: workspacePrompt },
|
|
true,
|
|
);
|
|
}
|
|
// Ready-checked (matches Bun): a warming PR checkout must NOT claim local
|
|
// access, or the agent would be told to diff a checkout that isn't there.
|
|
const hasLocalAccess = !!options.gitContext ||
|
|
(options.worktreePool && prMeta ? resolvePRLocalCwd() !== null : !!options.agentCwd);
|
|
return buildAgentReviewUserMessage(
|
|
patch,
|
|
diffType,
|
|
{ defaultBranch: base, hasLocalAccess, prDiffScope: currentPRDiffScope },
|
|
prMeta,
|
|
true,
|
|
);
|
|
}
|
|
const tour = createTourSession();
|
|
const guide = createGuideSession();
|
|
// Durable guide persistence (#1112): autosaves validated guides to
|
|
// ${PLANNOTATOR_DATA_DIR}/guides/{repo-key}/ and serves them back through
|
|
// the existing guide endpoints as `saved:{id}` pseudo job ids. All getters
|
|
// are late-bound — prMeta/currentDiffType can change mid-session.
|
|
// Mirrors packages/server/review.ts's guideStore wiring.
|
|
const guideStore = createGuideStoreSession({
|
|
runGit: async (args, cwd) => {
|
|
const result = await reviewRuntime.runGit(args, { cwd });
|
|
return result.exitCode === 0 ? result.stdout : null;
|
|
},
|
|
getGitCwd: () =>
|
|
isPRMode || isWorkspaceMode
|
|
? undefined
|
|
: options.gitContext
|
|
? resolveVcsCwd(currentDiffType as DiffType, options.gitContext.cwd) ?? options.gitContext.cwd ?? process.cwd()
|
|
: undefined,
|
|
getPRInfo: () =>
|
|
prMeta
|
|
? {
|
|
url: prMeta.url,
|
|
headSha: prMeta.headSha,
|
|
label: `${getMRLabel(prMeta)} ${getMRNumberLabel(prMeta)}`,
|
|
}
|
|
: null,
|
|
getBranchLabel: () => clientGitContext?.currentBranch || options.gitContext?.currentBranch,
|
|
getFallbackDir: () => workspace?.root ?? options.agentCwd ?? process.cwd(),
|
|
writesEnabled: () => resolveGuideHistory(loadConfig()),
|
|
});
|
|
|
|
/** Job fields the store persists for export provenance. Mirrors packages/server/review.ts. */
|
|
const guideSaveJob = (job: AgentJobInfo) => ({
|
|
id: job.id,
|
|
engine: job.engine,
|
|
model: job.model,
|
|
generatedAt: job.endedAt ?? Date.now(),
|
|
});
|
|
|
|
/** Snapshot for exporting a guide (`saved:{id}` → store; live job → session launch review, store fallback). */
|
|
async function resolveGuideSnapshotForExport(jobId: string): Promise<GuideSnapshot | null> {
|
|
if (jobId.startsWith(SAVED_GUIDE_ID_PREFIX)) {
|
|
return guideStore.getSavedGuideSnapshot(jobId.slice(SAVED_GUIDE_ID_PREFIX.length));
|
|
}
|
|
const live = guide.getGuide(jobId);
|
|
const launchReview = guide.getLaunchReview(jobId);
|
|
if (live && launchReview) {
|
|
const job = agentJobs.getJob(jobId);
|
|
return buildGuideSnapshot({
|
|
guide: live,
|
|
reviewed: live.reviewed,
|
|
review: launchReview,
|
|
generator: {
|
|
engine: job?.engine,
|
|
model: job?.model,
|
|
generatedAt: job?.endedAt ? new Date(job.endedAt).toISOString() : undefined,
|
|
},
|
|
});
|
|
}
|
|
return guideStore.getJobGuideSnapshot(jobId);
|
|
}
|
|
const semanticDiffScratchCwd = getSemanticDiffScratchCwd();
|
|
function resolveSemanticDiffCwd(diffType: DiffType = currentDiffType as DiffType): string {
|
|
if (workspace) return workspace.root;
|
|
if (options.worktreePool && prMeta) {
|
|
const poolPath = options.worktreePool.resolve(prMeta.url);
|
|
if (poolPath) return poolPath;
|
|
}
|
|
if (options.agentCwd) return options.agentCwd;
|
|
if (options.gitContext) {
|
|
const vcsCwd = resolveVcsCwd(diffType, options.gitContext.cwd);
|
|
if (vcsCwd) return vcsCwd;
|
|
if (options.gitContext.cwd) return options.gitContext.cwd;
|
|
}
|
|
return semanticDiffScratchCwd;
|
|
}
|
|
const semanticDiffCache = new SemanticDiffResponseCache();
|
|
const semanticDiffAvailabilityCache = new Map<string, Promise<SemanticDiffAvailability>>();
|
|
|
|
const semanticDiffEnabled = (): boolean => loadConfig().reviewAnalysis?.semanticDiff !== false;
|
|
const callFlowEnabled = (): boolean => loadConfig().reviewAnalysis?.callFlow === true;
|
|
|
|
function createSemanticDiffRuntime(cwd: string) {
|
|
return {
|
|
...createDefaultSemanticDiffRuntime(),
|
|
cwd,
|
|
};
|
|
}
|
|
|
|
function getSemanticDiffAvailabilityForCwd(cwd: string): Promise<SemanticDiffAvailability> {
|
|
const cached = semanticDiffAvailabilityCache.get(cwd);
|
|
if (cached) return cached;
|
|
|
|
const next: Promise<SemanticDiffAvailability> = getSemanticDiffAvailability(createSemanticDiffRuntime(cwd)).catch((error) => ({
|
|
available: false,
|
|
reason: "sem-probe-failed",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
}));
|
|
semanticDiffAvailabilityCache.set(cwd, next);
|
|
return next;
|
|
}
|
|
|
|
async function getSemanticDiffAdvert(
|
|
diffType: DiffType = currentDiffType as DiffType,
|
|
enabled = semanticDiffEnabled(),
|
|
) {
|
|
if (!enabled) return { available: false, enabled: false };
|
|
if (isGitButlerCommittedView(diffType)) return { available: false };
|
|
const availability = await getSemanticDiffAvailabilityForCwd(resolveSemanticDiffCwd(diffType));
|
|
return {
|
|
available: availability.available,
|
|
...(availability.semVersion ? { semVersion: availability.semVersion } : {}),
|
|
...(availability.semSource ? { semSource: availability.semSource } : {}),
|
|
};
|
|
}
|
|
|
|
async function getSemanticDiff(url: URL): Promise<SemanticDiffResponse> {
|
|
if (!semanticDiffEnabled()) {
|
|
return { status: "unavailable", reason: "disabled", message: "Semantic diff is disabled in Settings → Analysis." };
|
|
}
|
|
if (isGitButlerCommittedView()) {
|
|
return {
|
|
status: "unavailable",
|
|
reason: "gitbutler-committed-view",
|
|
message: "Semantic diff is unavailable for committed GitButler views because the live workspace may contain other layers.",
|
|
};
|
|
}
|
|
const cwd = resolveSemanticDiffCwd();
|
|
const fileExts = semanticDiffFileExtsFromSearchParams(url.searchParams);
|
|
const cacheKey = semanticDiffCacheKey({ rawPatch: currentPatch, cwd, fileExts });
|
|
const cached = semanticDiffCache.get(cacheKey, currentPatch);
|
|
if (cached) return cached;
|
|
|
|
const result = await runSemanticDiff(
|
|
{ rawPatch: currentPatch, cwd, fileExts },
|
|
createSemanticDiffRuntime(cwd),
|
|
);
|
|
if (result.status === "ok") {
|
|
semanticDiffCache.set(cacheKey, currentPatch, result);
|
|
} else if (result.status === "error") {
|
|
// Cooldown-memoized: request rate (file badges remount on scroll) must
|
|
// not drive sem execution rate when it's failing.
|
|
semanticDiffCache.setFailure(cacheKey, currentPatch, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function getCallFlowAdvert(
|
|
diffType: DiffType = currentDiffType as DiffType,
|
|
enabled = callFlowEnabled(),
|
|
) {
|
|
return callFlowService.getAdvert(enabled, {
|
|
snapshotSupported: !workspace && (isPRMode || vcsSupportsSnapshot(sessionVcsType ?? "git", diffType)),
|
|
rawPatch: currentPatch,
|
|
});
|
|
}
|
|
|
|
async function getCallFlow(url: URL): Promise<CallFlowResponse> {
|
|
const requestedSnapshot = url.searchParams.get("snapshot");
|
|
if (!requestedSnapshot || requestedSnapshot !== currentSnapshotId()) {
|
|
return { status: "stale", reason: "snapshot-mismatch", message: "The review changed before call flow could start. Refresh and try again." };
|
|
}
|
|
if (!callFlowEnabled()) {
|
|
return { status: "disabled", reason: "disabled", message: "Call flow is disabled in Settings → Analysis." };
|
|
}
|
|
if (workspace) {
|
|
return { status: "unsupported", reason: "workspace-unsupported", message: "Call flow does not yet support multi-repository workspace reviews." };
|
|
}
|
|
const advert = await getCallFlowAdvert();
|
|
if (advert.state === "unsupported") {
|
|
return {
|
|
status: "unsupported",
|
|
reason: advert.reason ?? "view-unsupported",
|
|
message: advert.message ?? "Call flow is not available for this review view.",
|
|
};
|
|
}
|
|
|
|
let analysisCwd: string | undefined;
|
|
let analysisDiffType = currentDiffType as string;
|
|
let analysisBase = currentBase;
|
|
let prCommitPair: { from: string; to: string } | undefined;
|
|
if (isPRMode && prMeta) {
|
|
if (currentPRDiffScope === "layer" && layerPatchIncomplete) {
|
|
return { status: "unsupported", reason: "incomplete-patch", message: "Call flow is unavailable until the complete PR layer diff is available locally." };
|
|
}
|
|
analysisCwd = await ensurePRCallFlowCwd() ?? undefined;
|
|
if (!analysisCwd) {
|
|
return { status: "unavailable", reason: "checkout-unavailable", message: "Call flow needs a local PR checkout, which is not ready." };
|
|
}
|
|
if (currentPRDiffScope === "full-stack" && prMeta.defaultBranch) {
|
|
const baseRef = await resolvePRFullStackBaseRef(reviewRuntime, prMeta.defaultBranch, analysisCwd);
|
|
if (!baseRef) return { status: "unavailable", reason: "base-unavailable", message: "The full-stack base commit is unavailable locally." };
|
|
analysisDiffType = "merge-base";
|
|
analysisBase = baseRef;
|
|
} else {
|
|
prCommitPair = { from: prMeta.mergeBaseSha ?? prMeta.baseSha, to: prMeta.headSha };
|
|
}
|
|
} else {
|
|
analysisCwd = resolveVcsCwd(currentDiffType as DiffType, options.gitContext?.cwd)
|
|
?? options.gitContext?.cwd
|
|
?? options.agentCwd
|
|
?? process.cwd();
|
|
}
|
|
if (!analysisCwd) return { status: "unavailable", reason: "checkout-unavailable", message: "Call flow requires a local Git checkout." };
|
|
|
|
const baseline = pendingFingerprintCapture ? await pendingFingerprintCapture : currentFingerprint;
|
|
const before = await computeDiffFingerprint();
|
|
if (requestedSnapshot !== currentSnapshotId() || (baseline && before && baseline !== before)) {
|
|
return { status: "stale", reason: "snapshot-stale", message: "The files changed before call flow could start. Refresh the review first." };
|
|
}
|
|
const analysisVcsType = isPRMode ? "git" : sessionVcsType ?? "git";
|
|
return callFlowService.analyze({
|
|
snapshotId: requestedSnapshot,
|
|
rawPatch: currentPatch,
|
|
snapshot: {
|
|
materialize: ({ includedExtensions, signal }) => materializeVcsSnapshot(analysisVcsType, {
|
|
cwd: analysisCwd,
|
|
diffType: analysisDiffType as DiffType,
|
|
base: analysisBase,
|
|
rawPatch: currentPatch,
|
|
includedExtensions,
|
|
...(prCommitPair ? { prCommitPair } : {}),
|
|
signal,
|
|
}),
|
|
},
|
|
verifySnapshot: async () => {
|
|
const after = await computeDiffFingerprint();
|
|
return requestedSnapshot === currentSnapshotId() && !(before && after && before !== after);
|
|
},
|
|
});
|
|
}
|
|
|
|
const agentJobs = createAgentJobHandler({
|
|
mode: "review",
|
|
getServerUrl: () => agentApiUrl,
|
|
getCwd: resolveAgentCwd,
|
|
|
|
async buildCommand(provider, config) {
|
|
// Snapshot every mutable review selector before any await. A concurrent
|
|
// switch must not retarget the prompt, attribution, or line anchors.
|
|
const launchPrMeta = prMeta;
|
|
const launchPatch = currentPatch;
|
|
const launchDiffType = currentDiffType;
|
|
const launchBase = currentBase;
|
|
const launchScope = currentPRDiffScope;
|
|
const launchGitRef = currentGitRef;
|
|
const launchSnapshotId = currentSnapshotId();
|
|
const launchWorkspacePrompt = getWorkspacePromptContext();
|
|
const launchLayerPatchIncomplete = layerPatchIncomplete;
|
|
// Fail fast in PR-pool mode when this PR's checkout doesn't exist
|
|
// (e.g. a pr-switch whose worktree creation failed): falling back
|
|
// would run the agent against the wrong revision or directory.
|
|
if (options.worktreePool && launchPrMeta && !options.worktreePool.resolve(launchPrMeta.url)) {
|
|
throw new Error(
|
|
"Local PR checkout unavailable — the agent can't run against the PR files. Retry shortly (the checkout may still be recovering).",
|
|
);
|
|
}
|
|
const cwd = resolveAgentCwd();
|
|
const workspacePrompt = launchWorkspacePrompt;
|
|
const hasAgentLocalAccess = !!workspacePrompt || !!options.worktreePool || !!options.agentCwd || !!options.gitContext;
|
|
const userMessageOptions = {
|
|
defaultBranch: launchBase,
|
|
hasLocalAccess: hasAgentLocalAccess,
|
|
prDiffScope: launchScope,
|
|
...(workspacePrompt && { workspace: workspacePrompt }),
|
|
};
|
|
|
|
// Snapshot the diff context at launch (see review.ts buildCommand
|
|
// for the rationale — keeps downstream "Copy All" honest across
|
|
// subsequent context switches).
|
|
const worktreeParts = String(launchDiffType).startsWith("worktree:")
|
|
? parseWorktreeDiffType(launchDiffType as DiffType)
|
|
: null;
|
|
const launchPrUrl = launchPrMeta?.url;
|
|
const launchDiffScope = isPRMode ? launchScope : undefined;
|
|
|
|
const requestedProfileId =
|
|
typeof config?.reviewProfileId === "string" ? config.reviewProfileId : undefined;
|
|
// Resolve the requested review, or throw a clear error. An unresolvable
|
|
// non-default id (renamed/removed skill, stale cookie, malformed request)
|
|
// never silently downgrades to the default — explicit selection is
|
|
// authoritative at this boundary.
|
|
const reviewProfile = resolveRequestedReviewProfile(requestedProfileId);
|
|
|
|
const diffContext: AgentJobInfo["diffContext"] | undefined = workspacePrompt
|
|
? { mode: String(launchDiffType), worktreePath: null }
|
|
: launchPrMeta
|
|
? undefined
|
|
: {
|
|
mode: (worktreeParts?.subType ?? launchDiffType) as string,
|
|
base: launchBase,
|
|
worktreePath: worktreeParts?.path ?? null,
|
|
...(String(launchDiffType).startsWith("gitbutler:") && {
|
|
label: launchGitRef,
|
|
snapshotId: launchSnapshotId,
|
|
}),
|
|
};
|
|
|
|
if (provider === "tour") {
|
|
const built = await tour.buildCommand({
|
|
cwd,
|
|
patch: launchPatch,
|
|
diffType: launchDiffType as DiffType,
|
|
options: userMessageOptions,
|
|
prMetadata: launchPrMeta,
|
|
config,
|
|
});
|
|
return built ? { ...built, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label } : built;
|
|
}
|
|
|
|
if (provider === "guide") {
|
|
// Snapshot ALL launch-relevant mutable closure state into locals
|
|
// before any await below — currentPatch/currentDiffType/prMeta can
|
|
// all be reassigned by a concurrent request (diff/base/PR switch)
|
|
// while this async branch is suspended, and every read below must
|
|
// describe the same launch, not whatever the reviewer has since
|
|
// switched to (mirrors review.ts buildCommand's top-of-function
|
|
// snapshot).
|
|
const launchPRDiffScope = launchScope;
|
|
|
|
// The changed-file list is derived from the same launch-time patch
|
|
// snapshot as the rest of this branch — it's what the model plans
|
|
// section placement against at generation time. The SAME list is
|
|
// snapshotted onto the job (changedFilesSnapshot, below) and reused
|
|
// by onJobComplete to validate refs, rather than re-deriving from
|
|
// whatever patch/diff/base the reviewer has switched to by the time
|
|
// the job finishes — a mid-generation diff/base switch would
|
|
// otherwise invalidate every ref in an otherwise-valid guide.
|
|
let changedFiles = listPatchFiles(launchPatch);
|
|
// Very large PRs: the platform API withholds per-file patches
|
|
// (layerPatchIncomplete) — but the PR-mode prompt tells the agent to
|
|
// read the FULL local diff (git diff origin/<base>...HEAD in the
|
|
// checkout). The changed-files block and the validation snapshot must
|
|
// describe that SAME diff: derived from the partial API patch they
|
|
// under-list files to the model and then validation drops its valid
|
|
// refs (or fails the guide closed when no section survives).
|
|
// Recompute names+counts locally when the checkout is ready; on any
|
|
// failure fall back to the partial list — no worse than before.
|
|
// Mirrors packages/server/review.ts's guide branch.
|
|
// Layer scope only — full-stack launchPatch is already a local full
|
|
// recompute; the layer diff would drop earlier stack layers' files.
|
|
if (launchLayerPatchIncomplete && launchPRDiffScope !== "full-stack" && launchPrMeta?.baseBranch) {
|
|
const localCwd = resolvePRLocalCwd();
|
|
if (localCwd) {
|
|
try {
|
|
const res = await reviewRuntime.runGit(
|
|
["diff", "--numstat", `origin/${launchPrMeta.baseBranch}...HEAD`],
|
|
{ cwd: localCwd },
|
|
);
|
|
if (res.exitCode === 0) {
|
|
const recomputed = res.stdout
|
|
.split("\n")
|
|
.filter((line) => line.trim())
|
|
.map((line) => {
|
|
const [a, d, ...rest] = line.split("\t");
|
|
const raw = rest.join("\t");
|
|
if (!raw) return null;
|
|
// numstat rename forms: "src/{old => new}/f" or "old => new"
|
|
// — refs use post-image paths, matching what the agent sees.
|
|
const brace = raw.match(/^(.*)\{.* => (.*)\}(.*)$/);
|
|
const path = brace
|
|
? `${brace[1]}${brace[2]}${brace[3]}`.replace(/\/\//g, "/")
|
|
: raw.includes(" => ")
|
|
? raw.split(" => ").pop()!
|
|
: raw;
|
|
// Binary files report "-\t-\tpath" — count as 0/0.
|
|
return { path, additions: Number(a) || 0, deletions: Number(d) || 0 };
|
|
})
|
|
.filter((f): f is { path: string; additions: number; deletions: number } => f !== null);
|
|
if (recomputed.length > 0) changedFiles = recomputed;
|
|
}
|
|
} catch {
|
|
// keep the partial-patch list
|
|
}
|
|
}
|
|
}
|
|
|
|
const repairOf = typeof config?.repairOf === "string" ? config.repairOf : undefined;
|
|
let repair: { payload: string } | undefined;
|
|
let guideConfig = config;
|
|
if (repairOf) {
|
|
const payload = guide.getFailedPayload(repairOf);
|
|
if (!payload) {
|
|
throw new Error("No captured output to repair for that job — run the guide again instead.");
|
|
}
|
|
// Prefer the failed job's OWN engine, marker or not, when its
|
|
// binary is present on this machine: the failed job got far
|
|
// enough to produce capturable output, so that engine is
|
|
// PROVABLY runnable here — a fact no other candidate can claim.
|
|
// claude/codex are only a FALLBACK (in that order) when the
|
|
// failed engine's binary is missing, because binary presence
|
|
// alone means installed, not authenticated/usable — a broken
|
|
// claude repair would itself become the newest failed job and
|
|
// hijack the recovery panel next render, a doom loop. Marker
|
|
// engines' binary name can differ from the engine id (Cursor's
|
|
// CLI binary is `agent`, not `cursor`), so resolve via
|
|
// MARKER_ENGINES[...].binary before falling back to the engine
|
|
// id itself for claude/codex.
|
|
const failedEngine = typeof config?.engine === "string" && config.engine ? config.engine : undefined;
|
|
const failedEngineBinary = failedEngine
|
|
? MARKER_ENGINES[failedEngine as MarkerEngineId]?.binary ?? failedEngine
|
|
: undefined;
|
|
const repairEngine =
|
|
failedEngine && commandExists(failedEngineBinary!)
|
|
? failedEngine
|
|
: commandExists("claude")
|
|
? "claude"
|
|
: commandExists("codex")
|
|
? "codex"
|
|
: (failedEngine ?? "claude");
|
|
repair = { payload };
|
|
guideConfig = { ...config, engine: repairEngine };
|
|
}
|
|
|
|
const built = await guide.buildCommand({
|
|
cwd,
|
|
patch: launchPatch,
|
|
diffType: launchDiffType as DiffType,
|
|
options: userMessageOptions,
|
|
prMetadata: launchPrMeta,
|
|
changedFiles,
|
|
config: guideConfig,
|
|
...(repair && { repair }),
|
|
});
|
|
// A repair job's payload is the FAILED job's previously-captured
|
|
// output, not this launch's diff — its file refs were validated
|
|
// (and, for onJobComplete, must be re-validated) against the failed
|
|
// job's own recorded changed-file set. Falling back to this launch's
|
|
// freshly-derived `changedFiles` here would validate a repair against
|
|
// whatever diff/base happens to be on screen right now, reintroducing
|
|
// the destroy-on-switch bug this snapshot exists to prevent — just
|
|
// for repairs instead of the original launch. Fall back only if the
|
|
// failed job's set was never recorded (defensive; shouldn't happen
|
|
// since onJobComplete always records it before returning).
|
|
const changedFilesSnapshot = repairOf
|
|
? guide.getLaunchChangedFiles(repairOf) ?? changedFiles.map((f) => f.path)
|
|
: changedFiles.map((f) => f.path);
|
|
// Snapshot the launch-time review-target context (#1112): guide jobs
|
|
// run for minutes while the session supports mid-generation PR/diff
|
|
// switching, so the persisted envelope must be labeled with the
|
|
// context this guide is GENERATED against — captured now, carried on
|
|
// the job (guideContext), and read back at completion instead of the
|
|
// live session state. Repairs reuse the FAILED job's own snapshot,
|
|
// same as changedFilesSnapshot above. Mirrors packages/server/review.ts.
|
|
const guideContext = (repairOf ? agentJobs.getJob(repairOf)?.guideContext : undefined)
|
|
?? await guideStore.captureLaunchContext();
|
|
// The review this guide describes, for portable export (decision
|
|
// record D6). Mirrors packages/server/review.ts.
|
|
const commitSha = parseCommitDiffType(String(worktreeParts?.subType ?? launchDiffType))?.sha;
|
|
const launchSource: GuideSnapshotSource = workspacePrompt
|
|
? { kind: "workspace", ...(repoInfo?.display && { repo: repoInfo.display }) }
|
|
: launchPrMeta
|
|
? {
|
|
kind: "pr",
|
|
repo: getDisplayRepo(launchPrMeta),
|
|
branch: launchPrMeta.headBranch,
|
|
headSha: launchPrMeta.headSha,
|
|
pr: {
|
|
url: launchPrMeta.url,
|
|
number: launchPrMeta.platform === "github" ? launchPrMeta.number : launchPrMeta.iid,
|
|
title: launchPrMeta.title,
|
|
platform: launchPrMeta.platform,
|
|
},
|
|
}
|
|
: {
|
|
kind: commitSha ? "commit" : "local",
|
|
...(repoInfo?.display && { repo: repoInfo.display }),
|
|
...(clientGitContext?.currentBranch && { branch: clientGitContext.currentBranch }),
|
|
...(guideContext.headSha && { headSha: guideContext.headSha }),
|
|
...(commitSha && { commitSha }),
|
|
};
|
|
const launchReview: GuideLaunchReview = (repairOf ? guide.getLaunchReview(repairOf) : null) ?? {
|
|
rawPatch: launchPatch,
|
|
gitRef: launchGitRef,
|
|
diffType: String(launchDiffType),
|
|
...(launchBase && { base: launchBase }),
|
|
source: launchSource,
|
|
...(typeof config?.instructions === "string" && config.instructions.trim() && { customInstructions: config.instructions }),
|
|
};
|
|
return {
|
|
...built,
|
|
prUrl: launchPrUrl,
|
|
diffScope: launchDiffScope,
|
|
diffContext,
|
|
reviewProfileId: reviewProfile.id,
|
|
reviewProfileLabel: reviewProfile.label,
|
|
changedFilesSnapshot,
|
|
guideContext,
|
|
launchReview,
|
|
};
|
|
}
|
|
|
|
// A custom review skill carries its own instructions and becomes the whole
|
|
// prompt; strip the default framing prose from the user message so only the
|
|
// git/PR context remains. The default review keeps today's message verbatim.
|
|
const isCustomReview = reviewProfile.source === "user";
|
|
const userMessage = workspacePrompt
|
|
? buildAgentReviewUserMessageForTarget({
|
|
kind: "workspace",
|
|
patch: launchPatch,
|
|
workspace: workspacePrompt,
|
|
}, isCustomReview)
|
|
: buildAgentReviewUserMessage(launchPatch, launchDiffType as DiffType, userMessageOptions, launchPrMeta, isCustomReview);
|
|
const jobLabel = workspacePrompt ? "Workspace Review" : "Code Review";
|
|
|
|
if (provider === "codex") {
|
|
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
|
|
const reasoningEffort = typeof config?.reasoningEffort === "string" && config.reasoningEffort ? config.reasoningEffort : undefined;
|
|
const fastMode = config?.fastMode === true;
|
|
const outputPath = generateOutputPath();
|
|
const prompt = composeCodexReviewPrompt(userMessage, reviewProfile);
|
|
const command = await buildCodexCommand({ cwd, outputPath, prompt, model, reasoningEffort, fastMode });
|
|
return { command, outputPath, prompt, cwd, label: jobLabel, model, reasoningEffort, fastMode: fastMode || undefined, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label };
|
|
}
|
|
|
|
if (provider === "claude") {
|
|
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
|
|
const effort = typeof config?.effort === "string" && config.effort ? config.effort : undefined;
|
|
const prompt = composeClaudeReviewPrompt(userMessage, reviewProfile);
|
|
const { command, stdinPrompt } = buildClaudeCommand(prompt, model, effort);
|
|
return { command, stdinPrompt, prompt, cwd, label: jobLabel, captureStdout: true, model, effort, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label };
|
|
}
|
|
|
|
// Marker engines (Cursor, OpenCode, Pi) — one branch, same shape as Claude.
|
|
// None of the three has a schema flag, so composeMarkerReviewPrompt ALWAYS
|
|
// appends the marker-block output contract (even for a custom profile —
|
|
// it's the only thing that makes their prose output parseable). The
|
|
// engine's buildArgv passes the prompt as the trailing positional arg and
|
|
// threads the spawn cwd (--workspace for Cursor, --dir for OpenCode; Pi has
|
|
// no cwd flag — it always uses the process's actual cwd, which spawnJob
|
|
// already sets from this same cwd).
|
|
// captureStdout is required: the marker block comes back on stdout NDJSON.
|
|
const markerEngine = MARKER_ENGINES[provider as MarkerEngineId];
|
|
if (markerEngine) {
|
|
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
|
|
const thinking = typeof config?.thinking === "string" && config.thinking ? config.thinking : undefined;
|
|
// Per-job nonce embedded in the marker contract; recovered from job.prompt
|
|
// at parse time so echoed/quoted bare tags can't be mistaken for the payload.
|
|
const nonce = makeMarkerNonce();
|
|
const prompt = composeMarkerReviewPrompt(reviewProfile, userMessage, nonce);
|
|
const { command } = buildMarkerCommand(markerEngine, prompt, model, cwd, { thinking, cursorSandbox: resolveCursorSandbox(loadConfig()) });
|
|
return { command, prompt, cwd, label: jobLabel, captureStdout: true, model, thinking, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label };
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
async onJobComplete(job, meta) {
|
|
const cwd = meta.cwd ?? resolveAgentCwd();
|
|
const jobPrUrl = job.prUrl;
|
|
const jobDiffScope = job.diffScope;
|
|
const jobPrMeta = jobPrUrl ? prSwitchCache.get(jobPrUrl)?.metadata : undefined;
|
|
const jobPrContext = jobPrMeta ? {
|
|
prUrl: jobPrUrl,
|
|
prNumber: jobPrMeta.platform === "github" ? jobPrMeta.number : jobPrMeta.iid,
|
|
prTitle: jobPrMeta.title,
|
|
prRepo: getDisplayRepo(jobPrMeta),
|
|
} : jobPrUrl ? { prUrl: jobPrUrl } : {};
|
|
|
|
// Only tag annotations with a *custom* profile — the default review needs no tag.
|
|
const profileLabel =
|
|
job.reviewProfileId && job.reviewProfileId !== BUILTIN_DEFAULT_PROFILE.id
|
|
? job.reviewProfileLabel
|
|
: undefined;
|
|
|
|
// Map findings onto annotations and ingest. Shared by both engine branches;
|
|
// no-ops on an empty set so a clean (zero-finding) review stays "done".
|
|
const ingest = <T extends object>(transformed: readonly T[], logTag: string) => {
|
|
if (transformed.length === 0) return undefined;
|
|
const annotations = transformed.map((a) => ({
|
|
...a,
|
|
...jobPrContext,
|
|
...(jobDiffScope && { diffScope: jobDiffScope }),
|
|
...getAgentJobAnnotationContext(job.diffContext),
|
|
...(profileLabel && { reviewProfileLabel: profileLabel }),
|
|
}));
|
|
const result = externalAnnotations.addAnnotations({ annotations });
|
|
if ("error" in result) console.error(`[${logTag}] addAnnotations error:`, result.error);
|
|
return result;
|
|
};
|
|
|
|
if (job.provider === "codex") {
|
|
const output = meta.outputPath ? await parseCodexOutput(meta.outputPath) : null;
|
|
if (!output) {
|
|
// Process exited 0 but output is missing/unparseable — not a green run.
|
|
markJobReviewFailed(job, REVIEW_OUTPUT_FAILED);
|
|
return;
|
|
}
|
|
|
|
const hasBlockingFindings = output.findings.some(f => f.priority !== null && f.priority <= 1);
|
|
job.summary = {
|
|
correctness: hasBlockingFindings ? "Issues Found" : output.overall_correctness,
|
|
explanation: output.overall_explanation,
|
|
confidence: output.overall_confidence_score,
|
|
};
|
|
|
|
ingest(
|
|
transformReviewFindings(
|
|
output.findings,
|
|
job.source,
|
|
cwd,
|
|
"Codex",
|
|
workspace ? (filePath) => workspace.normalizeAnnotationPath(filePath) : undefined,
|
|
),
|
|
"codex-review",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (job.provider === "claude") {
|
|
const stdout = meta.stdout ?? "";
|
|
const output = parseClaudeStreamOutput(stdout);
|
|
if (!output) {
|
|
console.error(`[claude-review] Failed to parse output (${stdout.length} bytes, last 200: ${stdout.slice(-200)})`);
|
|
markJobReviewFailed(job, REVIEW_OUTPUT_FAILED);
|
|
return;
|
|
}
|
|
|
|
// Recompute the verdict from the findings we actually render. Nothing is
|
|
// dropped now (un-pinnable findings become file/general comments), so the
|
|
// count reflects reality and the card can never claim more than it shows.
|
|
const transformed = transformClaudeFindings(
|
|
output.findings,
|
|
job.source,
|
|
cwd,
|
|
workspace ? (filePath) => workspace.normalizeAnnotationPath(filePath) : undefined,
|
|
);
|
|
const counts = { important: 0, nit: 0, pre_existing: 0 };
|
|
for (const a of transformed) counts[a.severity]++;
|
|
const total = counts.important + counts.nit + counts.pre_existing;
|
|
job.summary = {
|
|
correctness: counts.important === 0 ? "Correct" : "Issues Found",
|
|
explanation: `${counts.important} important, ${counts.nit} nit, ${counts.pre_existing} pre-existing`,
|
|
confidence: total === 0 ? 1.0 : Math.max(0, 1.0 - (counts.important * 0.2)),
|
|
};
|
|
|
|
ingest(transformed, "claude-review");
|
|
return;
|
|
}
|
|
|
|
// --- Marker path (Cursor, OpenCode, Pi) ---
|
|
// FAIL-CLOSED: marker output is prompt-enforced (no schema flag), so any
|
|
// missing/malformed/schema/transform/insertion failure must MUTATE the job
|
|
// to failed — NEVER throw (agent-jobs.ts swallows throws, silently leaving
|
|
// an exit-0 job marked done). Mirrors the Tour fail-closed pattern below.
|
|
// Findings carry nullable file/line, classified into line/whole-file/
|
|
// general by transformMarkerFindings — nothing is dropped (same as Claude).
|
|
const markerEngine = MARKER_ENGINES[job.provider as MarkerEngineId];
|
|
if (markerEngine) {
|
|
// Recover the per-job nonce embedded in the prompt; without it no block
|
|
// can be trusted, so parse fails closed below.
|
|
const nonce = extractMarkerNonce(job.prompt ?? "");
|
|
const output = nonce && meta.stdout ? parseMarkerStreamOutput(meta.stdout, markerEngine, nonce) : null;
|
|
if (!output) {
|
|
job.status = "failed";
|
|
const providerError = meta.stdout
|
|
? reduceMarkerStream(meta.stdout, markerEngine).providerError
|
|
: null;
|
|
job.error = providerError
|
|
?? `${markerEngine.author} review output missing or unparseable (no valid marker JSON).`;
|
|
if (providerError) {
|
|
console.error(`[${markerEngine.id}-review] Provider error for job ${job.id}: ${providerError}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Derive the verdict from finding severities (like Claude) rather than
|
|
// trusting the model's free-form `correctness` string. Marker engines
|
|
// have no schema flag, so a model value like "not correct" would be
|
|
// stored verbatim and the detail panel (any string containing "correct"
|
|
// except "incorrect" → green) would invert the displayed result.
|
|
const hasImportant = output.findings.some((f) => f.severity === "important");
|
|
job.summary = {
|
|
correctness: hasImportant ? "Issues Found" : "Correct",
|
|
explanation: output.summary.explanation,
|
|
confidence: output.summary.confidence,
|
|
};
|
|
|
|
// Reuse the shared ingest() decoration; add a fail-closed check on result.
|
|
const result = ingest(
|
|
transformMarkerFindings(
|
|
output.findings,
|
|
job.source,
|
|
markerEngine.author,
|
|
cwd,
|
|
workspace ? (filePath) => workspace.normalizeAnnotationPath(filePath) : undefined,
|
|
),
|
|
`${markerEngine.id}-review`,
|
|
);
|
|
if (result && "error" in result) {
|
|
job.status = "failed";
|
|
job.error = `${markerEngine.author} annotation insertion failed: ${result.error}`;
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (job.provider === "tour") {
|
|
const { summary } = await tour.onJobComplete({ job, meta });
|
|
if (summary) {
|
|
job.summary = summary;
|
|
} else {
|
|
// The process exited 0 but the model returned empty or malformed output
|
|
// and nothing was stored. Flip status so the client doesn't auto-open
|
|
// a successful-looking card that 404s on /api/tour/:id.
|
|
job.status = "failed";
|
|
job.error = TOUR_EMPTY_OUTPUT_ERROR;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (job.provider === "guide") {
|
|
// Validate refs against the LAUNCH-time changed-file set (snapshotted
|
|
// on the job at buildCommand time), not the current patch — the model
|
|
// planned section placement against that exact file set, and the
|
|
// client already degrades stale refs per-file if the reviewer has
|
|
// since switched diff/base/PR. Re-deriving from the current patch
|
|
// here would spuriously invalidate every ref in an otherwise-valid
|
|
// guide the moment the view changes mid-generation. Falls back to the
|
|
// current patch only if the snapshot is missing (defensive; should
|
|
// not happen in practice — see agent-jobs.ts's changedFilesSnapshot).
|
|
const changedFiles = meta.changedFilesSnapshot ?? listPatchFiles(currentPatch).map((f) => f.path);
|
|
const { summary, error } = await guide.onJobComplete({ job, meta, changedFiles, launchReview: meta.launchReview });
|
|
if (summary) {
|
|
job.summary = summary;
|
|
// Autosave (#1112): only guides that passed validateGuideOutput
|
|
// ever reach guideResults, so a getGuide hit here IS the
|
|
// validation gate. Failed/invalid guides never write. The job's
|
|
// launch-time context snapshot labels the envelope — never the
|
|
// live session state, which may have PR/diff-switched mid-run.
|
|
const validated = guide.getGuide(job.id);
|
|
if (validated) await guideStore.saveForJob(guideSaveJob(job), validated, job.guideContext, meta.launchReview);
|
|
} else {
|
|
// Same fail-closed precedent as Tour: an exit-0 job with empty,
|
|
// malformed, or fully-invalidated output must not look like a
|
|
// successful card that 404s on /api/guide/:id.
|
|
job.status = "failed";
|
|
job.error = error ?? GUIDE_EMPTY_OUTPUT_ERROR;
|
|
}
|
|
return;
|
|
}
|
|
},
|
|
});
|
|
const sharingEnabled =
|
|
options.sharingEnabled ?? resolveSharingEnabled(loadConfig());
|
|
const shareBaseUrl =
|
|
(options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined;
|
|
const pasteApiUrl =
|
|
(options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined;
|
|
let resolveDecision!: (result: {
|
|
approved: boolean;
|
|
feedback: string;
|
|
annotations: unknown[];
|
|
agentSwitch?: string;
|
|
exit?: boolean;
|
|
}) => void;
|
|
const decisionPromise = new Promise<{
|
|
approved: boolean;
|
|
feedback: string;
|
|
annotations: unknown[];
|
|
agentSwitch?: string;
|
|
exit?: boolean;
|
|
}>((r) => {
|
|
resolveDecision = r;
|
|
});
|
|
|
|
const aiRuntime = aiEnabled ? await createPiAIRuntime({ getCwd: resolveAgentCwd }) : null;
|
|
|
|
const server = createServer(async (req, res) => {
|
|
const url = requestUrl(req);
|
|
|
|
// API: Get tour result
|
|
if (url.pathname.match(/^\/api\/tour\/[^/]+$/) && req.method === "GET") {
|
|
const jobId = url.pathname.slice("/api/tour/".length);
|
|
const result = tour.getTour(jobId);
|
|
if (!result) {
|
|
json(res, { error: "Tour not found" }, 404);
|
|
return;
|
|
}
|
|
json(res, result);
|
|
return;
|
|
}
|
|
|
|
// API: Save tour checklist state
|
|
const checklistMatch = url.pathname.match(/^\/api\/tour\/([^/]+)\/checklist$/);
|
|
if (checklistMatch && req.method === "PUT") {
|
|
const jobId = checklistMatch[1];
|
|
try {
|
|
const body = await parseBody(req) as { checked: boolean[] };
|
|
if (Array.isArray(body.checked)) tour.saveChecklist(jobId, body.checked);
|
|
json(res, { ok: true });
|
|
} catch {
|
|
json(res, { error: "Invalid JSON" }, 400);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// API: Get guide result — live job ids, or `saved:{id}` for a
|
|
// persisted guide loaded from the on-disk store (#1112).
|
|
if (url.pathname.match(/^\/api\/guide\/[^/]+$/) && req.method === "GET") {
|
|
const jobId = decodeURIComponent(url.pathname.slice("/api/guide/".length));
|
|
if (jobId.startsWith(SAVED_GUIDE_ID_PREFIX)) {
|
|
const saved = await guideStore.getSavedGuideData(jobId.slice(SAVED_GUIDE_ID_PREFIX.length));
|
|
if (!saved) {
|
|
json(res, { error: "Guide not found" }, 404);
|
|
return;
|
|
}
|
|
json(res, saved);
|
|
return;
|
|
}
|
|
const result = guide.getGuide(jobId);
|
|
if (!result) {
|
|
json(res, { error: "Guide not found" }, 404);
|
|
return;
|
|
}
|
|
json(res, { ...result, ...(guideStore.isJobSaved(jobId) ? { saved: true } : {}) });
|
|
return;
|
|
}
|
|
|
|
// API: Save guide reviewed state. Live job ids also write through to
|
|
// the job's autosaved file; `saved:{id}` ids persist directly.
|
|
const reviewedMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/reviewed$/);
|
|
if (reviewedMatch && req.method === "PUT") {
|
|
const jobId = decodeURIComponent(reviewedMatch[1]);
|
|
try {
|
|
const body = await parseBody(req) as { reviewed: boolean[] };
|
|
if (Array.isArray(body.reviewed)) {
|
|
if (jobId.startsWith(SAVED_GUIDE_ID_PREFIX)) {
|
|
const ok = await guideStore.updateSavedReviewed(jobId.slice(SAVED_GUIDE_ID_PREFIX.length), body.reviewed);
|
|
if (!ok) {
|
|
json(res, { error: "Guide not found" }, 404);
|
|
return;
|
|
}
|
|
} else {
|
|
guide.saveReviewed(jobId, body.reviewed);
|
|
await guideStore.writeThroughReviewed(jobId, body.reviewed);
|
|
}
|
|
}
|
|
json(res, { ok: true });
|
|
} catch {
|
|
json(res, { error: "Invalid JSON" }, 400);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// API: Portable export of a guide (decision record D1/D9). Mirrors packages/server/review.ts.
|
|
const guideExportMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/(export|export-info)$/);
|
|
if (guideExportMatch && req.method === "GET") {
|
|
const jobId = decodeURIComponent(guideExportMatch[1]);
|
|
// Unlike Bun.serve, node:http does not turn a thrown error into a
|
|
// 500 — an unguarded throw here would take the extension host down.
|
|
let snapshot: GuideSnapshot | null;
|
|
let html: string;
|
|
try {
|
|
snapshot = await resolveGuideSnapshotForExport(jobId);
|
|
if (!snapshot) {
|
|
json(res, { error: "This guide cannot be exported: its diff was not retained." }, 404);
|
|
return;
|
|
}
|
|
const viewer = resolveGuideViewerAssets(GUIDE_VIEWER_MANIFEST, { baseUrl: process.env.PLANNOTATOR_GUIDE_VIEWER_URL });
|
|
html = createGuideHtml(snapshot, { viewer });
|
|
} catch {
|
|
json(res, { error: "internal error" }, 500);
|
|
return;
|
|
}
|
|
const filename = guideExportFilename(snapshot.guide.title);
|
|
if (guideExportMatch[2] === "export-info") {
|
|
json(res, {
|
|
bytes: Buffer.byteLength(html, "utf8"),
|
|
filename,
|
|
languages: detectGuideLanguages(snapshot.review.rawPatch),
|
|
});
|
|
return;
|
|
}
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
"Cache-Control": "no-store",
|
|
});
|
|
res.end(html);
|
|
return;
|
|
}
|
|
|
|
// API: Share a guide on the guide host (guide share hosting contract §7).
|
|
// Mirrors packages/server/review.ts: POST uploads (encrypted unless
|
|
// `public`) and records the link on the saved envelope; DELETE removes
|
|
// it with the stored token; share-info reports enabled/serviceUrl and
|
|
// any existing link. Mutating verbs carry the cross-origin guard.
|
|
const guideShareMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/(share|share-info)$/);
|
|
if (guideShareMatch && guideShareMatch[2] === "share-info" && req.method === "GET") {
|
|
const jobId = decodeURIComponent(guideShareMatch[1]);
|
|
const config = loadConfig();
|
|
const existing = (await guideStore.locateEnvelope(jobId))?.envelope.share;
|
|
json(res, {
|
|
enabled: resolveSharingEnabled(config),
|
|
serviceUrl: resolveGuideShareUrl(config),
|
|
...(existing ? { existing: { url: existing.url, createdAt: existing.createdAt } } : {}),
|
|
});
|
|
return;
|
|
}
|
|
if (guideShareMatch && guideShareMatch[2] === "share" && (req.method === "POST" || req.method === "DELETE")) {
|
|
if (!callFlowInstallOriginAllowed(req.headers.origin ?? null, req.headers.host ?? "")) {
|
|
json(res, { error: "Cross-origin share requests are not allowed" }, 403);
|
|
return;
|
|
}
|
|
const jobId = decodeURIComponent(guideShareMatch[1]);
|
|
const config = loadConfig();
|
|
const serviceUrl = resolveGuideShareUrl(config);
|
|
if (req.method === "POST") {
|
|
if (!resolveSharingEnabled(config)) {
|
|
json(res, { error: "sharing disabled" }, 403);
|
|
return;
|
|
}
|
|
// Every body field is optional, so no body at all means the defaults.
|
|
let body: { public?: unknown; ttlSeconds?: unknown };
|
|
try {
|
|
const raw = await readBody(req);
|
|
const parsed: unknown = raw.trim() === "" ? {} : JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
|
|
body = parsed as { public?: unknown; ttlSeconds?: unknown };
|
|
} catch {
|
|
json(res, { error: "Invalid JSON" }, 400);
|
|
return;
|
|
}
|
|
if (body.public !== undefined && typeof body.public !== "boolean") {
|
|
json(res, { error: "public must be a boolean" }, 400);
|
|
return;
|
|
}
|
|
if (body.ttlSeconds !== undefined && (typeof body.ttlSeconds !== "number" || !Number.isSafeInteger(body.ttlSeconds) || body.ttlSeconds <= 0)) {
|
|
json(res, { error: "ttlSeconds must be a positive integer" }, 400);
|
|
return;
|
|
}
|
|
// One link per guide: the envelope is the only place the delete
|
|
// token lives, so a second upload would orphan the first on the
|
|
// host. Remove the existing link before creating another.
|
|
const located = await guideStore.locateEnvelope(jobId);
|
|
const existing = located?.envelope.share;
|
|
if (existing) {
|
|
json(res, { error: "This guide already has a share link. Remove it before creating another.", url: existing.url }, 409);
|
|
return;
|
|
}
|
|
try {
|
|
const snapshot = await resolveGuideSnapshotForExport(jobId);
|
|
if (!snapshot) {
|
|
json(res, { error: "This guide cannot be shared: its diff was not retained." }, 404);
|
|
return;
|
|
}
|
|
const shared = await shareGuide(snapshot, {
|
|
serviceUrl,
|
|
mode: body.public === true ? "plain" : "encrypted",
|
|
...(body.ttlSeconds !== undefined ? { ttlSeconds: body.ttlSeconds } : {}),
|
|
viewer: GUIDE_VIEWER_MANIFEST,
|
|
});
|
|
// `recorded` tells the client whether this Plannotator can
|
|
// remove the link later; without an envelope (guide history
|
|
// off, or an autosave that never happened) only the one-time
|
|
// token can.
|
|
const recorded = located
|
|
? updateGuideShare(located.repoKey, located.id, {
|
|
id: shared.id,
|
|
url: shared.url,
|
|
createdAt: new Date().toISOString(),
|
|
deleteToken: shared.deleteToken,
|
|
serviceUrl,
|
|
})
|
|
: false;
|
|
json(res, { ...shared, recorded });
|
|
} catch (e) {
|
|
// node:http does not convert a throw into a 500 the way Bun.serve
|
|
// does; an unguarded throw would take the extension host down.
|
|
json(res, { error: e instanceof GuideShareError ? e.message : "internal error" }, e instanceof GuideShareError ? 502 : 500);
|
|
}
|
|
return;
|
|
}
|
|
// DELETE: the record is the only place the delete token lives, and it
|
|
// names the host the link was created on; the configured share URL
|
|
// may have changed since (or differ from the CLI shell that created
|
|
// the link), and a 404 from the wrong host would forget a link that
|
|
// is still live.
|
|
const located = await guideStore.locateEnvelope(jobId);
|
|
const record = located?.envelope.share;
|
|
if (!located || !record) {
|
|
json(res, { error: "No share link for this guide" }, 404);
|
|
return;
|
|
}
|
|
try {
|
|
await unshareGuide(record.id, record.deleteToken, { serviceUrl: record.serviceUrl });
|
|
} catch (e) {
|
|
// Already gone on the host (expired or removed elsewhere): the
|
|
// link is dead either way, so forget it here too.
|
|
if (!(e instanceof GuideShareError && e.status === 404)) {
|
|
json(res, { error: e instanceof GuideShareError ? e.message : "internal error" }, e instanceof GuideShareError ? 502 : 500);
|
|
return;
|
|
}
|
|
}
|
|
updateGuideShare(located.repoKey, located.id, null);
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// API: List saved guides for the current repo (#1112)
|
|
if (url.pathname === "/api/guides" && req.method === "GET") {
|
|
json(res, await guideStore.listSaved());
|
|
return;
|
|
}
|
|
|
|
// API: Delete a saved guide (#1112). Its share link goes with it, best
|
|
// effort: the envelope is the only copy of the delete token.
|
|
const savedGuideDeleteMatch = url.pathname.match(/^\/api\/guides\/([^/]+)$/);
|
|
if (savedGuideDeleteMatch && req.method === "DELETE") {
|
|
const savedId = decodeURIComponent(savedGuideDeleteMatch[1]);
|
|
await unshareBeforeDelete((await guideStore.locateEnvelope(`${SAVED_GUIDE_ID_PREFIX}${savedId}`))?.envelope.share);
|
|
const ok = await guideStore.deleteSaved(savedId);
|
|
if (!ok) {
|
|
json(res, { error: "Guide not found" }, 404);
|
|
return;
|
|
}
|
|
json(res, { ok: true });
|
|
return;
|
|
}
|
|
|
|
// API: Get a failed guide job's captured raw output for manual repair
|
|
const guideOutputMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/output$/);
|
|
if (guideOutputMatch && req.method === "GET") {
|
|
const jobId = guideOutputMatch[1];
|
|
const payload = guide.getFailedPayload(jobId);
|
|
if (payload === null) {
|
|
json(res, { error: "No captured output" }, 404);
|
|
return;
|
|
}
|
|
json(res, { payload });
|
|
return;
|
|
}
|
|
|
|
// API: Manually submit corrected guide JSON for a failed job
|
|
const guideSubmitMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/submit$/);
|
|
if (guideSubmitMatch && req.method === "POST") {
|
|
const jobId = guideSubmitMatch[1];
|
|
const existingJob = agentJobs.getJob(jobId);
|
|
if (!existingJob) {
|
|
json(res, { error: "Job not found" }, 404);
|
|
return;
|
|
}
|
|
if (existingJob.status !== "failed" && existingJob.status !== "killed") {
|
|
json(res, { error: "This job already has a guide" }, 409);
|
|
return;
|
|
}
|
|
try {
|
|
const body = await parseBody(req) as { payload?: string };
|
|
const payload = typeof body.payload === "string" ? body.payload : "";
|
|
// Fallback only — submitManualOutput prefers the job's own
|
|
// launch-time changed-file set (guide.launchChangedFiles,
|
|
// recorded by onJobComplete) over this current-patch derivation.
|
|
const changedFiles = listPatchFiles(currentPatch).map((f) => f.path);
|
|
const result = guide.submitManualOutput(jobId, payload, changedFiles);
|
|
if ("error" in result) {
|
|
json(res, { error: result.error }, 400);
|
|
return;
|
|
}
|
|
const { sections, files } = result;
|
|
agentJobs.completeJobExternally(jobId, {
|
|
correctness: "Guide Generated",
|
|
explanation: `${sections} section${sections !== 1 ? "s" : ""}, ${files} file${files !== 1 ? "s" : ""} placed (manually repaired)`,
|
|
confidence: 1,
|
|
});
|
|
// A manually repaired guide passed the same validateGuideOutput
|
|
// gate as an automatic one — persist it too (#1112), labeled
|
|
// with the job's own launch-time context snapshot.
|
|
const repaired = guide.getGuide(jobId);
|
|
if (repaired) await guideStore.saveForJob(guideSaveJob(existingJob), repaired, existingJob.guideContext, guide.getLaunchReview(jobId) ?? undefined);
|
|
json(res, { ok: true, sections, files });
|
|
} catch {
|
|
json(res, { error: "Invalid JSON" }, 400);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/diff" && req.method === "GET") {
|
|
maybeRefreshRemoteBaseInfo();
|
|
// Snapshot the served state BEFORE the sidecar await: the startup
|
|
// base upgrade can land mid-await, and reading the globals after
|
|
// it would pair a rebuilt patch with sections computed from the
|
|
// old base — a misgrouped panel. snapshotId travels with the
|
|
// patch it identifies: a mid-await upgrade bumps draftKey, and
|
|
// this client's next freshness probe (echoing the OLD id) raises
|
|
// the Refresh banner for the consistent old snapshot served here.
|
|
const servedPatch = currentPatch;
|
|
const servedBase = currentBase;
|
|
const servedGitRef = currentGitRef;
|
|
const servedError = currentError;
|
|
const servedDiffType = currentDiffType;
|
|
const servedHideWhitespace = currentHideWhitespace;
|
|
const servedPRDiffScope = currentPRDiffScope;
|
|
const servedSnapshotId = currentSnapshotId();
|
|
const servedGitContext = clientGitContext;
|
|
const sections = await buildSectionsSidecar(servedBase, servedDiffType as string);
|
|
const commitInfo = await buildCommitInfoSidecar(servedDiffType as string);
|
|
const generatedFiles = await buildGeneratedFilesSidecar(servedPatch, servedDiffType as string);
|
|
json(res, {
|
|
rawPatch: servedPatch,
|
|
aiReviewContext: buildCurrentAiReviewContext(servedPatch, servedBase, servedDiffType as DiffType),
|
|
aiEnabled,
|
|
gitRef: servedGitRef,
|
|
snapshotId: servedSnapshotId,
|
|
origin: options.origin ?? "pi",
|
|
mode: isWorkspaceMode ? "workspace" : undefined,
|
|
diffType: hasLocalAccess || isWorkspaceMode ? servedDiffType : undefined,
|
|
// Echo the active base so page refresh/reconnect rehydrates the
|
|
// picker to what the server is actually using, not the detected default.
|
|
base: hasLocalAccess ? servedBase : undefined,
|
|
hideWhitespace: servedHideWhitespace,
|
|
...(workspace && { diffOptions: workspace.diffOptions }),
|
|
gitContext: hasLocalAccess ? servedGitContext : undefined,
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
pasteApiUrl,
|
|
repoInfo,
|
|
isWSL: wslFlag,
|
|
// PR mode advertises the ready PR checkout (null while warming), so
|
|
// the Open-in button gates correctly from the initial load — not the
|
|
// launch repo. Non-PR keeps the workspace/local cwd.
|
|
...(isPRMode
|
|
? { agentCwd: resolvePRLocalCwd() }
|
|
: workspace
|
|
? { agentCwd: workspace.root }
|
|
: options.agentCwd
|
|
? { agentCwd: options.agentCwd }
|
|
: {}),
|
|
...(isPRMode && {
|
|
prMetadata: prMeta,
|
|
platformUser,
|
|
prStackInfo,
|
|
prStackTree,
|
|
prDiffScope: servedPRDiffScope,
|
|
prDiffScopeOptions,
|
|
}),
|
|
...(isPRMode && layerPatchIncomplete && { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable }),
|
|
...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }),
|
|
...(sections && { sections }),
|
|
...(commitInfo && { commitInfo }),
|
|
...(generatedFiles && { generatedFiles }),
|
|
...(baseBehindRemote && { baseBehindRemote: true }),
|
|
...(servedError && { error: servedError }),
|
|
semanticDiff: await getSemanticDiffAdvert(servedDiffType as DiffType),
|
|
callFlow: await getCallFlowAdvert(servedDiffType as DiffType),
|
|
serverConfig: getServerConfig(gitUser),
|
|
});
|
|
} else if (url.pathname === "/api/fetch-base" && req.method === "POST") {
|
|
// Fetch the remote default branch so the local baseline catches up
|
|
// with GitHub. Client re-runs /api/diff/switch afterwards.
|
|
if (!remoteBaseCheckApplies()) {
|
|
json(res, { error: "Not available in this mode" }, 400);
|
|
return;
|
|
}
|
|
const branchRef =
|
|
remoteDefaultInfo?.branch ??
|
|
(currentBase.startsWith("origin/") ? currentBase : null);
|
|
if (!branchRef) {
|
|
json(res, { error: "No remote-tracking base to fetch" }, 400);
|
|
return;
|
|
}
|
|
const branchName = branchRef.replace(/^origin\//, "");
|
|
const result = await reviewRuntime.runGit(
|
|
["fetch", "--end-of-options", "origin", branchName],
|
|
{ cwd: options.gitContext?.cwd, timeoutMs: 30_000 },
|
|
);
|
|
if (result.exitCode !== 0) {
|
|
json(res, { error: result.stderr.trim() || "git fetch failed" }, 500);
|
|
return;
|
|
}
|
|
// Re-query the remote (fresh ls-remote) and recompute rather than
|
|
// trusting a cached tip: a narrow fetch refspec can exit 0 without
|
|
// advancing refs/remotes/origin/<branch>, so we must observe the
|
|
// actual post-fetch state instead of silently clearing the banner.
|
|
await refreshRemoteBaseInfo();
|
|
json(res, { ok: true, baseBehindRemote });
|
|
} else if (url.pathname === "/api/diff/fresh" && req.method === "GET") {
|
|
// Cheap staleness probe — has the underlying VCS state changed since
|
|
// the current diff snapshot was computed? Best-effort: anything that
|
|
// cannot be fingerprinted reports fresh (no banner).
|
|
// In PR review the local checkout can appear (pool warmup) or change
|
|
// (in-place PR switch) after the initial /api/diff, so re-advertise it
|
|
// on every probe — the Open-in control tracks the current checkout
|
|
// without a page reload. Null until a usable checkout exists (the pool
|
|
// resolves a path only once ready). Non-PR sessions omit this field.
|
|
const prCwdAdvert = isPRMode ? { agentCwd: resolvePRLocalCwd() } : {};
|
|
const baseline = currentFingerprint;
|
|
// Carry baseBehindRemote on EVERY response — the client sets the flag
|
|
// unconditionally each probe, so omitting it clears the 'behind GitHub'
|
|
// banner for that poll (a flicker) until the next one.
|
|
const behind = baseBehindRemote ? { baseBehindRemote: true } : {};
|
|
// Per-CLIENT staleness: the client echoes the snapshotId it is
|
|
// rendering; a mismatch means the SERVER's snapshot moved under it
|
|
// (startup base upgrade, a switch from another tab, an in-place PR
|
|
// switch) regardless of what the VCS fingerprint says. This is what
|
|
// lets one server serve multiple tabs holding different snapshots
|
|
// without lying to any of them. The "snapshot:" fingerprint keys
|
|
// the client's dismissal to the server snapshot that made it stale.
|
|
const clientSnapshot = url.searchParams.get("snapshot");
|
|
const serverSnapshot = currentSnapshotId();
|
|
if (clientSnapshot && clientSnapshot !== serverSnapshot) {
|
|
json(res, { fresh: false, fingerprint: `snapshot:${serverSnapshot}`, ...behind, ...prCwdAdvert });
|
|
return;
|
|
}
|
|
if (baseline == null) {
|
|
json(res, { fresh: true, ...behind, ...prCwdAdvert });
|
|
return;
|
|
}
|
|
const probe = await computeDiffFingerprint();
|
|
// A diff switch landing mid-probe replaces the snapshot (and its
|
|
// fingerprint); report fresh and let the next poll compare against
|
|
// the new baseline.
|
|
if (currentFingerprint !== baseline) {
|
|
json(res, { fresh: true, ...behind, ...prCwdAdvert });
|
|
return;
|
|
}
|
|
const fresh = probe == null || probe === baseline;
|
|
maybeRefreshRemoteBaseInfo();
|
|
// The probe fingerprint lets the client distinguish "still the same
|
|
// staleness I dismissed" from "ANOTHER change landed since".
|
|
json(res, {
|
|
fresh,
|
|
...(fresh ? {} : { fingerprint: probe }),
|
|
...(baseBehindRemote && { baseBehindRemote: true }),
|
|
...prCwdAdvert,
|
|
});
|
|
} else if (url.pathname === "/api/semantic-diff" && req.method === "GET") {
|
|
json(res, await getSemanticDiff(url));
|
|
} else if (url.pathname === "/api/call-flow" && req.method === "GET") {
|
|
// A throw here must never escape the handler: on Node it becomes an
|
|
// unhandled rejection, and Pi's process-level handler exits the whole
|
|
// session. Contain it as the same JSON error envelope Bun serves.
|
|
let result: CallFlowResponse;
|
|
try {
|
|
result = await getCallFlow(url);
|
|
} catch (error) {
|
|
result = {
|
|
status: "error",
|
|
reason: "analysis-failed",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
res.setHeader("Cache-Control", "no-store");
|
|
json(res, result, result.status === "stale" ? 409 : 200);
|
|
} else if (url.pathname === "/api/call-flow/install" && req.method === "POST") {
|
|
// Opt-in CallDiff runtime install (mirrors Bun review.ts).
|
|
// Single-flighted: concurrent POSTs join the in-flight install.
|
|
// Node preflight runs before any download, and a cross-origin
|
|
// POST is rejected because this endpoint starts a large
|
|
// download and build.
|
|
// requestUrl() parses against a fixed localhost base, so the real
|
|
// request authority is the Host header, not url.host.
|
|
if (!callFlowInstallOriginAllowed(req.headers.origin ?? null, req.headers.host ?? "")) {
|
|
json(res, { error: "Cross-origin install requests are not allowed" }, 403);
|
|
return;
|
|
}
|
|
let installRequest: ReturnType<typeof parseCallFlowInstallRequest>;
|
|
try {
|
|
installRequest = parseCallFlowInstallRequest(await parseJsonBody(req));
|
|
} catch {
|
|
installRequest = null;
|
|
}
|
|
if (!installRequest) return json(res, { error: "Invalid call-flow install request" }, 400);
|
|
const advert = await getCallFlowAdvert(currentDiffType as DiffType, true);
|
|
const languageIds = resolveCallFlowInstallTargets(
|
|
installRequest.languageIds,
|
|
advert.installPlan?.languageIds,
|
|
advert.available,
|
|
);
|
|
if (!advert.installable || languageIds.length === 0) {
|
|
return json(res, { error: advert.message ?? "No call-flow language support needs installation." }, 409);
|
|
}
|
|
const status = await callFlowInstall.start(languageIds);
|
|
res.setHeader("Cache-Control", "no-store");
|
|
json(res, status);
|
|
} else if (url.pathname === "/api/call-flow/install-status" && req.method === "GET") {
|
|
// Poll the in-app runtime install. done persists until the runtime
|
|
// advert resolves available; error persists until the next POST.
|
|
res.setHeader("Cache-Control", "no-store");
|
|
json(res, callFlowInstall.getStatus());
|
|
} else if (url.pathname === "/api/review-analysis" && req.method === "GET") {
|
|
// Read-only capability refresh. It must not participate in the
|
|
// settings mutation epoch or supersede a concurrent toggle write.
|
|
if (reviewAnalysisMutationEpoch !== null) {
|
|
res.setHeader("Cache-Control", "no-store");
|
|
return json(res, { superseded: true });
|
|
}
|
|
const analysisEpoch = reviewAnalysisEpoch;
|
|
const viewEpoch = diffSwitchEpoch;
|
|
const scopeEpoch = prScopeEpoch;
|
|
const [semanticDiff, callFlow] = await Promise.all([
|
|
getSemanticDiffAdvert(),
|
|
getCallFlowAdvert(),
|
|
]);
|
|
if (
|
|
analysisEpoch !== reviewAnalysisEpoch
|
|
|| reviewAnalysisMutationEpoch !== null
|
|
|| viewEpoch !== diffSwitchEpoch
|
|
|| scopeEpoch !== prScopeEpoch
|
|
) {
|
|
return json(res, { superseded: true });
|
|
}
|
|
res.setHeader("Cache-Control", "no-store");
|
|
json(res, { semanticDiff, callFlow });
|
|
} else if (url.pathname === "/api/review-analysis" && req.method === "POST") {
|
|
const analysisEpoch = ++reviewAnalysisEpoch;
|
|
reviewAnalysisMutationEpoch = analysisEpoch;
|
|
const viewEpoch = diffSwitchEpoch;
|
|
const scopeEpoch = prScopeEpoch;
|
|
try {
|
|
const reviewAnalysis = parseReviewAnalysisConfig(await parseBody(req));
|
|
if (!reviewAnalysis) return json(res, { error: "Invalid analysis settings" }, 400);
|
|
if (analysisEpoch !== reviewAnalysisEpoch) return json(res, { superseded: true });
|
|
const nextSemanticDiffEnabled = reviewAnalysis.semanticDiff ?? semanticDiffEnabled();
|
|
const nextCallFlowEnabled = reviewAnalysis.callFlow ?? callFlowEnabled();
|
|
const [semanticDiff, callFlow] = await Promise.all([
|
|
getSemanticDiffAdvert(currentDiffType as DiffType, nextSemanticDiffEnabled),
|
|
getCallFlowAdvert(currentDiffType as DiffType, nextCallFlowEnabled),
|
|
]);
|
|
if (
|
|
analysisEpoch !== reviewAnalysisEpoch
|
|
|| viewEpoch !== diffSwitchEpoch
|
|
|| scopeEpoch !== prScopeEpoch
|
|
) {
|
|
return json(res, { superseded: true });
|
|
}
|
|
saveConfig({ reviewAnalysis });
|
|
if (!nextCallFlowEnabled) callFlowService.cancelAll();
|
|
json(res, { semanticDiff, callFlow });
|
|
} catch {
|
|
json(res, { error: "Invalid request" }, 400);
|
|
} finally {
|
|
// Mark the mutation settled. A read-only refresh that began while
|
|
// it was active must yield rather than publish stale capabilities.
|
|
if (reviewAnalysisEpoch === analysisEpoch) reviewAnalysisEpoch++;
|
|
if (reviewAnalysisMutationEpoch === analysisEpoch) reviewAnalysisMutationEpoch = null;
|
|
}
|
|
} else if (url.pathname === "/api/commits" && req.method === "GET") {
|
|
// Linear commit history for the Commits panel (mirrors Bun review.ts).
|
|
// Git-local sessions only — PR/workspace/jj/p4 don't offer the view.
|
|
// Computed against the active diff's cwd (worktree-aware) and the
|
|
// active base so the divider matches the review baseline.
|
|
if (!options.gitContext || isPRMode || workspace || (sessionVcsType && sessionVcsType !== "git")) {
|
|
json(res, { error: "Commit history is only available for local git reviews" }, 400);
|
|
return;
|
|
}
|
|
const limitParam = Number.parseInt(url.searchParams.get("limit") ?? "", 10);
|
|
const before = url.searchParams.get("before") ?? undefined;
|
|
const commitsCwd = resolveVcsCwd(currentDiffType as DiffType, options.gitContext.cwd);
|
|
const page = await listCommitHistory(reviewRuntime, currentBase, commitsCwd, {
|
|
...(Number.isFinite(limitParam) && { limit: limitParam }),
|
|
...(before !== undefined && { before }),
|
|
});
|
|
if (!page) {
|
|
json(res, { error: "Could not read commit history" }, 500);
|
|
return;
|
|
}
|
|
// Best-effort author avatars from the origin forge (memoized per
|
|
// session; misses just render the initials fallback client-side).
|
|
const avatars = await commitAvatars.resolve(
|
|
commitsCwd,
|
|
page.commits.map((c) => c.authorEmail),
|
|
);
|
|
for (const c of page.commits) {
|
|
const avatarUrl = avatars.get(c.authorEmail);
|
|
if (avatarUrl) c.avatarUrl = avatarUrl;
|
|
}
|
|
json(res, page);
|
|
} else if (url.pathname === "/api/diff/switch" && req.method === "POST") {
|
|
// Capture the ordering token BEFORE any await — body delivery can
|
|
// finish out of arrival order under network jitter, so capturing after
|
|
// parseBody let a slow-body OLDER request overwrite a newer one.
|
|
const switchEpoch = ++diffSwitchEpoch;
|
|
if (!hasLocalAccess && !workspace) {
|
|
json(res, { error: "Not available without local file access" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const body = await parseBody(req);
|
|
const newType = body.diffType as DiffType | WorkspaceDiffType;
|
|
if (typeof newType !== "string" || !newType) {
|
|
json(res, { error: "Missing diffType" }, 400);
|
|
return;
|
|
}
|
|
// Don't commit hideWhitespace to shared state until we win the
|
|
// epoch check — a superseded request must not leave its value.
|
|
const effectiveHideWhitespace = typeof body.hideWhitespace === "boolean"
|
|
? body.hideWhitespace
|
|
: currentHideWhitespace;
|
|
if (workspace) {
|
|
const snapshot = await workspace.rebuild({
|
|
diffType: newType,
|
|
hideWhitespace: effectiveHideWhitespace,
|
|
});
|
|
if (switchEpoch !== diffSwitchEpoch) {
|
|
json(res, { superseded: true });
|
|
return;
|
|
}
|
|
currentHideWhitespace = effectiveHideWhitespace;
|
|
currentPatch = snapshot.rawPatch;
|
|
currentGitRef = snapshot.gitRef;
|
|
currentDiffType = workspace.diffType;
|
|
currentError = snapshot.error;
|
|
draftKey = contentHash(currentPatch);
|
|
captureDiffFingerprint();
|
|
|
|
json(res, {
|
|
rawPatch: currentPatch,
|
|
// Snapshot arg: robust against a future await sneaking in
|
|
// between the epoch check and this response.
|
|
aiReviewContext: buildCurrentAiReviewContext(snapshot.rawPatch),
|
|
gitRef: currentGitRef,
|
|
snapshotId: currentSnapshotId(),
|
|
diffType: currentDiffType,
|
|
diffOptions: workspace.diffOptions,
|
|
hideWhitespace: currentHideWhitespace,
|
|
...(currentError ? { error: currentError } : {}),
|
|
semanticDiff: await getSemanticDiffAdvert(),
|
|
callFlow: await getCallFlowAdvert(),
|
|
});
|
|
return;
|
|
}
|
|
if (sessionVcsType && !vcsOwnsDiffType(sessionVcsType, newType as string)) {
|
|
json(res, { error: `Diff type is not available in this ${sessionVcsType} session` }, 400);
|
|
return;
|
|
}
|
|
// An explicit pick from the base picker is honored verbatim —
|
|
// the local/remote groups are distinct choices, so "main" must
|
|
// not be canonicalized to "origin/main" when the user chose the
|
|
// local ref on purpose. Sticky: later echoes of that choice
|
|
// (diff-type switches, refreshes) must not re-canonicalize it.
|
|
const nextBaseExplicitlyChosen = baseExplicitlyChosen ||
|
|
(body.explicitBase === true && typeof body.base === "string" && !!body.base);
|
|
const base = resolveReviewBase(
|
|
typeof body.base === "string" ? body.base : undefined,
|
|
nextBaseExplicitlyChosen,
|
|
currentBase,
|
|
);
|
|
const defaultCwd = options.gitContext?.cwd;
|
|
const result = await runVcsDiff(newType as DiffType, base, defaultCwd, {
|
|
hideWhitespace: effectiveHideWhitespace,
|
|
});
|
|
const resultContext = sessionVcsType === "gitbutler" && result.gitContext?.vcsType === "gitbutler"
|
|
? result.gitContext
|
|
: undefined;
|
|
const resultBase = resultContext?.defaultBranch ?? base;
|
|
// A newer switch superseded us — don't touch shared state.
|
|
if (switchEpoch !== diffSwitchEpoch) {
|
|
json(res, { superseded: true });
|
|
return;
|
|
}
|
|
// Stage every field locally. No shared review state is written until
|
|
// the final epoch guard, so a newer invalid request cannot strand a
|
|
// patch/fingerprint beside the prior GitButler context revision.
|
|
const previousDiffType = currentDiffType;
|
|
|
|
// Recompute gitContext for the effective cwd so the client's
|
|
// sidebar reflects the worktree we're now reviewing.
|
|
// Best-effort: on failure the client keeps its existing context.
|
|
// Skipped for same-cwd commit:<sha> switches (the commit-rail hot
|
|
// path — mirrors Bun review.ts): the recompute dominated click
|
|
// latency and a historical commit's diff can't change any of it.
|
|
let updatedContext = resultContext;
|
|
let updatedContextRevision = resultContext
|
|
? getGitButlerContextRevision(resultContext) ?? ""
|
|
: undefined;
|
|
if (!updatedContext && options.gitContext && !isSameCwdCommitSwitch(previousDiffType as string, newType as string)) {
|
|
try {
|
|
const effectiveCwd = resolveVcsCwd(newType as DiffType, options.gitContext.cwd);
|
|
updatedContext = await getVcsContext(effectiveCwd, sessionVcsType);
|
|
updatedContextRevision = getGitButlerContextRevision(updatedContext) ?? "";
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
// Base may have changed — re-evaluate behind-ness from the cached
|
|
// remote tip (cheap, local-only).
|
|
// Await (not fire-and-forget) so the switch response carries the
|
|
// freshly-recomputed baseBehindRemote — otherwise the banner lags a
|
|
// poll cycle switching INTO a base-relative mode, or lingers stale
|
|
// switching AWAY from one. Local rev-parse only; cheap.
|
|
const nextBase = updatedContext && sessionVcsType === "gitbutler"
|
|
? updatedContext.defaultBranch
|
|
: resultBase;
|
|
const nextBaseBehindRemote = await computeBaseBehindRemote(
|
|
nextBase,
|
|
newType as string,
|
|
nextBaseExplicitlyChosen,
|
|
).catch(() => false);
|
|
const sections = await buildSectionsSidecar(nextBase, newType as string);
|
|
const commitInfo = await buildCommitInfoSidecar(newType as string);
|
|
const generatedFiles = await buildGeneratedFilesSidecar(result.patch, newType as string);
|
|
const [switchSemanticDiff, switchCallFlow] = await Promise.all([
|
|
getSemanticDiffAdvert(newType as DiffType),
|
|
getCallFlowAdvert(newType as DiffType),
|
|
]);
|
|
// Final guard: a newer switch during trailing awaits wins.
|
|
if (switchEpoch !== diffSwitchEpoch) {
|
|
json(res, { superseded: true });
|
|
return;
|
|
}
|
|
currentHideWhitespace = effectiveHideWhitespace;
|
|
currentPatch = result.patch;
|
|
currentGitRef = result.label;
|
|
currentDiffType = newType;
|
|
currentBase = nextBase;
|
|
baseEverSwitched = true;
|
|
baseExplicitlyChosen = nextBaseExplicitlyChosen;
|
|
baseBehindRemote = nextBaseBehindRemote;
|
|
currentError = result.error;
|
|
draftKey = contentHash(currentPatch);
|
|
if (updatedContext && sessionVcsType === "gitbutler") {
|
|
clientGitContext = updatedContext;
|
|
currentContextRevision = updatedContextRevision ?? "";
|
|
}
|
|
captureDiffFingerprint(result.fingerprint);
|
|
json(res, {
|
|
rawPatch: currentPatch,
|
|
// Snapshot args: robust against a future await sneaking in
|
|
// between the epoch check and this response.
|
|
aiReviewContext: buildCurrentAiReviewContext(result.patch, currentBase),
|
|
gitRef: currentGitRef,
|
|
snapshotId: currentSnapshotId(),
|
|
diffType: currentDiffType,
|
|
// Echo the base the server actually used. resolveBaseBranch
|
|
// trusts the caller verbatim; this echo lets the client
|
|
// confirm the request landed (and pick it up when the client
|
|
// didn't supply one and we fell back to detected default).
|
|
base: currentBase,
|
|
hideWhitespace: currentHideWhitespace,
|
|
...(sections ? { sections } : {}),
|
|
...(commitInfo ? { commitInfo } : {}),
|
|
...(generatedFiles ? { generatedFiles } : {}),
|
|
...(baseBehindRemote ? { baseBehindRemote: true } : {}),
|
|
...(updatedContext ? { gitContext: updatedContext } : {}),
|
|
...(currentError ? { error: currentError } : {}),
|
|
semanticDiff: switchSemanticDiff,
|
|
callFlow: switchCallFlow,
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to switch diff";
|
|
json(res, { error: message }, 500);
|
|
}
|
|
} else if (url.pathname === "/api/pr-diff-scope" && req.method === "POST") {
|
|
if (!isPRMode || !prMeta) {
|
|
json(res, { error: "Not in PR mode" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const body = await parseBody(req) as { scope?: PRDiffScope };
|
|
if (body.scope !== "layer" && body.scope !== "full-stack") {
|
|
json(res, { error: "Invalid PR diff scope" }, 400);
|
|
return;
|
|
}
|
|
|
|
const scopeEpoch = ++prScopeEpoch;
|
|
// A newer scope select or pr-switch landed while this request was
|
|
// parked on an await: drop this request's writes and return the
|
|
// newest state so the client converges on it.
|
|
const respondSuperseded = async () => {
|
|
const [semanticDiff, callFlow] = await Promise.all([
|
|
getSemanticDiffAdvert(),
|
|
getCallFlowAdvert(),
|
|
]);
|
|
json(res, {
|
|
rawPatch: currentPatch,
|
|
aiReviewContext: buildCurrentAiReviewContext(),
|
|
gitRef: currentGitRef,
|
|
snapshotId: currentSnapshotId(),
|
|
prDiffScope: currentPRDiffScope,
|
|
...(layerPatchIncomplete ? { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable } : {}),
|
|
...(currentError ? { error: currentError } : {}),
|
|
semanticDiff,
|
|
callFlow,
|
|
});
|
|
};
|
|
|
|
if (body.scope === "layer") {
|
|
// Upgrade path: the platform withheld per-file content for this
|
|
// PR (too large). Once a pool checkout exists, recompute the
|
|
// exact layer diff locally and replace the truncated API
|
|
// reconstruction. Snapshot the PR before the await — a pr-switch
|
|
// landing mid-recompute must not have its patch overwritten with
|
|
// the previous PR's diff.
|
|
const upgradeMeta = prMeta;
|
|
let upgradeError: string | undefined;
|
|
if (layerPatchIncomplete && options.worktreePool && upgradeMeta) {
|
|
let upgradeCwd: string | undefined;
|
|
try {
|
|
upgradeCwd = (await options.worktreePool.ensure(reviewRuntime, upgradeMeta)).path;
|
|
} catch {
|
|
// Pool can't make a worktree (e.g. cross-repo pool after a
|
|
// pr-switch). The initial clone is still the right repo —
|
|
// pr-switch enforces same-project — and the recompute diffs
|
|
// explicit SHAs (fetching missing ones), so fall back to it.
|
|
upgradeCwd = options.agentCwd && existsSync(options.agentCwd) ? options.agentCwd : undefined;
|
|
}
|
|
if (upgradeCwd && prMeta === upgradeMeta) {
|
|
const result = await runPRLayerLocalDiff(reviewRuntime, upgradeMeta, upgradeCwd);
|
|
if (prMeta === upgradeMeta) {
|
|
if (!result.error) {
|
|
originalPRPatch = result.patch;
|
|
originalPRError = undefined;
|
|
layerPatchIncomplete = false;
|
|
prSwitchCache.set(upgradeMeta.url, {
|
|
metadata: upgradeMeta,
|
|
rawPatch: result.patch,
|
|
patchIncomplete: false,
|
|
});
|
|
} else {
|
|
upgradeError = `Could not recompute the full diff locally: ${result.error}`;
|
|
console.error(`Local PR diff recompute failed: ${result.error}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (scopeEpoch !== prScopeEpoch) return respondSuperseded();
|
|
currentPatch = originalPRPatch;
|
|
currentGitRef = originalPRGitRef;
|
|
currentError = originalPRError;
|
|
currentPRDiffScope = "layer";
|
|
// INVARIANT: every commit point re-keys — draftKey doubles as
|
|
// the snapshotId clients echo on freshness probes AND the
|
|
// draft-storage key, so it must always identify currentPatch.
|
|
// (This was previously conditional on !layerPatchIncomplete,
|
|
// which only stayed consistent because the full-stack branch
|
|
// never re-keyed at all.)
|
|
draftKey = contentHash(currentPatch);
|
|
captureDiffFingerprint();
|
|
json(res, {
|
|
rawPatch: currentPatch,
|
|
aiReviewContext: buildCurrentAiReviewContext(),
|
|
gitRef: currentGitRef,
|
|
snapshotId: currentSnapshotId(),
|
|
prDiffScope: currentPRDiffScope,
|
|
...(layerPatchIncomplete ? { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable } : {}),
|
|
...((currentError ?? upgradeError) ? { error: currentError ?? upgradeError } : {}),
|
|
semanticDiff: await getSemanticDiffAdvert(),
|
|
callFlow: await getCallFlowAdvert(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const fullStackOption = prDiffScopeOptions.find((option) => option.id === "full-stack");
|
|
if (!fullStackOption?.enabled || !(options.worktreePool || options.agentCwd)) {
|
|
json(res, { error: "Full stack diff requires a stacked PR and a local checkout" }, 400);
|
|
return;
|
|
}
|
|
|
|
const fullStackCwd = (options.worktreePool && prMeta ? options.worktreePool.resolve(prMeta.url) : undefined) ?? options.agentCwd;
|
|
const result = await runPRFullStackDiff(reviewRuntime, prMeta, fullStackCwd);
|
|
|
|
if (result.error) {
|
|
json(res, { error: result.error }, 400);
|
|
return;
|
|
}
|
|
|
|
if (scopeEpoch !== prScopeEpoch) return respondSuperseded();
|
|
currentPatch = result.patch;
|
|
currentGitRef = result.label;
|
|
currentError = undefined;
|
|
currentPRDiffScope = "full-stack";
|
|
// INVARIANT: every commit point re-keys (see the layer branch).
|
|
// Skipping this advertised the LAYER snapshot id for the
|
|
// full-stack patch — stale layer tabs never got the banner and
|
|
// full-stack drafts collided with layer drafts.
|
|
draftKey = contentHash(currentPatch);
|
|
captureDiffFingerprint();
|
|
json(res, {
|
|
rawPatch: currentPatch,
|
|
aiReviewContext: buildCurrentAiReviewContext(),
|
|
gitRef: currentGitRef,
|
|
snapshotId: currentSnapshotId(),
|
|
prDiffScope: currentPRDiffScope,
|
|
semanticDiff: await getSemanticDiffAdvert(),
|
|
callFlow: await getCallFlowAdvert(),
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to switch PR diff scope";
|
|
json(res, { error: message }, 500);
|
|
}
|
|
} else if (url.pathname === "/api/pr-switch" && req.method === "POST") {
|
|
if (!isPRMode || !prRef) {
|
|
return json(res, { error: "Not in PR mode" }, 400);
|
|
}
|
|
try {
|
|
const body = (await parseBody(req)) as { url?: string };
|
|
if (!body?.url) return json(res, { error: "Missing PR URL" }, 400);
|
|
const newRef = parsePRUrl(body.url);
|
|
if (!newRef) return json(res, { error: "Invalid PR URL" }, 400);
|
|
if (!isSameProject(newRef, prRef!)) return json(res, { error: "Cannot switch to a PR in a different repository" }, 400);
|
|
|
|
const cached = prSwitchCache.get(body.url);
|
|
const pr = cached ?? await fetchPR(newRef);
|
|
if (!cached) prSwitchCache.set(body.url, pr);
|
|
// Bump the scope epoch so a scope request parked on a long await
|
|
// cannot overwrite this switch.
|
|
prScopeEpoch++;
|
|
prMeta = pr.metadata;
|
|
prRef = prRefFromMetadata(pr.metadata);
|
|
warmPRContext(pr.metadata.url, prRef);
|
|
currentPatch = pr.rawPatch;
|
|
currentGitRef = `${getMRLabel(pr.metadata)} ${getMRNumberLabel(pr.metadata)}`;
|
|
currentError = undefined;
|
|
originalPRPatch = pr.rawPatch;
|
|
originalPRGitRef = currentGitRef;
|
|
originalPRError = undefined;
|
|
currentPRDiffScope = "layer";
|
|
layerPatchIncomplete = pr.patchIncomplete ?? false;
|
|
draftKey = contentHash(pr.rawPatch);
|
|
prListCache = null;
|
|
captureDiffFingerprint();
|
|
|
|
prStackInfo = getPRStackInfo(pr.metadata);
|
|
if (prStackTreeCache.has(body.url)) {
|
|
prStackTree = prStackTreeCache.get(body.url) ?? null;
|
|
} else {
|
|
try {
|
|
prStackTree = await fetchPRStack(prRef, pr.metadata);
|
|
} catch { prStackTree = null; }
|
|
prStackTreeCache.set(body.url, prStackTree);
|
|
}
|
|
|
|
let hasLocalForNewPR = false;
|
|
if (options.worktreePool) {
|
|
try {
|
|
await options.worktreePool.ensure(reviewRuntime, pr.metadata);
|
|
hasLocalForNewPR = true;
|
|
} catch {}
|
|
} else if (options.agentCwd) {
|
|
hasLocalForNewPR = await checkoutPRHead(reviewRuntime, pr.metadata, options.agentCwd);
|
|
}
|
|
|
|
prStackInfo = resolveStackInfo(pr.metadata, prStackTree, prStackInfo);
|
|
|
|
prDiffScopeOptions = prStackInfo
|
|
? getPRDiffScopeOptions(pr.metadata, hasLocalForNewPR)
|
|
: [];
|
|
|
|
let switchedViewedFiles: string[] = [];
|
|
try {
|
|
const viewedMap = await fetchPRViewedFiles(prRef);
|
|
switchedViewedFiles = Object.entries(viewedMap)
|
|
.filter(([, v]) => v).map(([p]) => p);
|
|
} catch {}
|
|
initialViewedFiles = switchedViewedFiles;
|
|
|
|
repoInfo = {
|
|
display: getDisplayRepo(pr.metadata),
|
|
branch: `${getMRLabel(pr.metadata)} ${getMRNumberLabel(pr.metadata)}`,
|
|
};
|
|
|
|
return json(res, {
|
|
rawPatch: currentPatch,
|
|
aiReviewContext: buildCurrentAiReviewContext(),
|
|
gitRef: currentGitRef,
|
|
snapshotId: currentSnapshotId(),
|
|
prMetadata: pr.metadata,
|
|
// The new PR's checkout (null while warming) so Open-in re-roots
|
|
// immediately on switch instead of waiting for the 5s probe.
|
|
agentCwd: resolvePRLocalCwd() ?? null,
|
|
prStackInfo,
|
|
prStackTree,
|
|
prDiffScope: currentPRDiffScope,
|
|
prDiffScopeOptions,
|
|
...(layerPatchIncomplete ? { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable } : {}),
|
|
repoInfo,
|
|
...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }),
|
|
...(currentError ? { error: currentError } : {}),
|
|
semanticDiff: await getSemanticDiffAdvert(),
|
|
callFlow: await getCallFlowAdvert(),
|
|
});
|
|
} catch (err) {
|
|
return json(res, { error: err instanceof Error ? err.message : "Failed to switch PR" }, 500);
|
|
}
|
|
} else if (url.pathname === "/api/pr-list" && req.method === "GET") {
|
|
if (!isPRMode || !prRef) {
|
|
return json(res, { error: "Not in PR mode" }, 400);
|
|
}
|
|
try {
|
|
const now = Date.now();
|
|
if (prListCache && now - prListCacheTime < 30_000) {
|
|
return json(res, { prs: prListCache });
|
|
}
|
|
const prs = await fetchPRList(prRef);
|
|
prListCache = prs;
|
|
prListCacheTime = now;
|
|
return json(res, { prs });
|
|
} catch {
|
|
return json(res, { error: "Failed to fetch PR list" }, 500);
|
|
}
|
|
} else if (url.pathname === "/api/pr-context" && req.method === "GET") {
|
|
if (!isPRMode || !prRef || !prMeta) {
|
|
json(res, { error: "Not in PR mode" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const context = await prContextLive.getContext(prMeta.url, prRef);
|
|
json(res, context);
|
|
} catch (err) {
|
|
json(
|
|
res,
|
|
{
|
|
error:
|
|
err instanceof Error ? err.message : "Failed to fetch PR context",
|
|
},
|
|
500,
|
|
);
|
|
}
|
|
} else if (url.pathname === "/api/pr-artifact-document" && req.method === "GET") {
|
|
if (!isPRMode || !prRef || !prMeta) {
|
|
json(res, { error: "Not in PR mode" }, 400);
|
|
return;
|
|
}
|
|
const artifactUrl = url.searchParams.get("url");
|
|
if (!artifactUrl) {
|
|
json(res, { error: "Missing artifact URL" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const context = await prContextLive.getContext(prMeta.url, prRef);
|
|
const document = await fetchPRArtifactDocument(
|
|
prCommandRuntime,
|
|
prMeta,
|
|
context,
|
|
artifactUrl,
|
|
);
|
|
send(res, document.content, 200, {
|
|
"Content-Type": "text/plain; charset=utf-8",
|
|
"Cache-Control": "private, max-age=300",
|
|
"Content-Security-Policy": "sandbox; default-src 'none'",
|
|
"X-Content-Type-Options": "nosniff",
|
|
});
|
|
} catch (error) {
|
|
const status = error instanceof PRArtifactDocumentError ? error.status : 500;
|
|
const message = error instanceof Error ? error.message : "Failed to fetch artifact document";
|
|
json(res, { error: message }, status);
|
|
}
|
|
} else if (url.pathname === "/api/pr-artifact-content" && req.method === "GET") {
|
|
if (!isPRMode || !prRef || !prMeta) {
|
|
json(res, { error: "Not in PR mode" }, 400);
|
|
return;
|
|
}
|
|
const artifactUrl = url.searchParams.get("url");
|
|
if (!artifactUrl) {
|
|
json(res, { error: "Missing artifact URL" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const context = await prContextLive.getContext(prMeta.url, prRef);
|
|
const content = await fetchPRArtifactContent(
|
|
prCommandRuntime,
|
|
prMeta,
|
|
context,
|
|
artifactUrl,
|
|
{
|
|
sourceUrl: url.searchParams.get("source") ?? undefined,
|
|
range: typeof req.headers.range === "string" ? req.headers.range : undefined,
|
|
},
|
|
);
|
|
send(res, Buffer.from(content.content), content.status, {
|
|
"Content-Type": content.contentType,
|
|
"Cache-Control": "private, max-age=300",
|
|
"Content-Security-Policy": "sandbox",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Content-Length": String(content.content.byteLength),
|
|
...(content.contentRange ? { "Content-Range": content.contentRange } : {}),
|
|
...(content.acceptRanges ? { "Accept-Ranges": content.acceptRanges } : {}),
|
|
});
|
|
} catch (error) {
|
|
const status = error instanceof PRArtifactDocumentError ? error.status : 500;
|
|
const message = error instanceof Error ? error.message : "Failed to fetch artifact content";
|
|
json(res, { error: message }, status);
|
|
}
|
|
} else if (url.pathname === "/api/pr-action" && req.method === "POST") {
|
|
if (!isPRMode || !prMeta || !prRef) {
|
|
json(res, { error: "Not in PR mode" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const body = await parseBody(req);
|
|
const fileComments = (body.fileComments as PRReviewFileComment[]) || [];
|
|
const targetPrUrl = body.targetPrUrl as string | undefined;
|
|
|
|
let targetRef = prRef;
|
|
let targetHeadSha = prMeta.headSha;
|
|
let targetUrl = prMeta.url;
|
|
|
|
if (targetPrUrl) {
|
|
const cached = prSwitchCache.get(targetPrUrl);
|
|
if (!cached) {
|
|
json(res, { error: "Target PR not found in session" }, 400);
|
|
return;
|
|
}
|
|
targetRef = prRefFromMetadata(cached.metadata);
|
|
targetHeadSha = cached.metadata.headSha;
|
|
targetUrl = cached.metadata.url;
|
|
} else if (currentPRDiffScope !== "layer") {
|
|
json(res, { error: "Switch to Layer diff before posting a platform review" }, 400);
|
|
return;
|
|
}
|
|
|
|
console.error(`[pr-action] ${body.action} with ${fileComments.length} file comment(s), target=${targetUrl}, headSha=${targetHeadSha}`);
|
|
const submission = await submitPlatformReview(
|
|
targetRef,
|
|
targetHeadSha,
|
|
body.action as "approve" | "comment",
|
|
body.body as string,
|
|
fileComments,
|
|
);
|
|
console.error(`[pr-action] ${submission.status === "complete" ? "Success" : "Partial success"}`);
|
|
prContextLive.refreshAfterWrite(targetUrl, targetRef);
|
|
json(res, { ok: true, prUrl: targetUrl, submission });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to submit PR review";
|
|
console.error(`[pr-action] Failed: ${message}`);
|
|
json(res, { error: message }, 500);
|
|
}
|
|
} else if (url.pathname === "/api/pr-viewed" && req.method === "POST") {
|
|
if (!isPRMode || !prMeta || !prRef) {
|
|
json(res, { error: "Not in PR mode" }, 400);
|
|
return;
|
|
}
|
|
if (prMeta.platform !== "github") {
|
|
json(res, { error: "Viewed sync only supported for GitHub" }, 400);
|
|
return;
|
|
}
|
|
const prNodeId = prMeta.prNodeId;
|
|
if (!prNodeId) {
|
|
json(res, { error: "PR node ID not available" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const body = await parseBody(req);
|
|
await markPRFilesViewed(
|
|
prRef,
|
|
prNodeId,
|
|
body.filePaths as string[],
|
|
body.viewed as boolean,
|
|
);
|
|
json(res, { ok: true });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to update viewed state";
|
|
console.error("[plannotator] /api/pr-viewed error:", message);
|
|
json(res, { error: message }, 500);
|
|
}
|
|
} else if (url.pathname === "/api/file-content" && req.method === "GET") {
|
|
const filePath = url.searchParams.get("path");
|
|
if (!filePath) {
|
|
json(res, { error: "Missing path" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
validateFilePath(filePath);
|
|
} catch {
|
|
json(res, { error: "Invalid path" }, 400);
|
|
return;
|
|
}
|
|
const oldPath = url.searchParams.get("oldPath") || undefined;
|
|
if (oldPath) {
|
|
try {
|
|
validateFilePath(oldPath);
|
|
} catch {
|
|
json(res, { error: "Invalid path" }, 400);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Bind expansion to the patch snapshot held by this tab. GitButler
|
|
// topology and other VCS state can move without a route change; serving
|
|
// newly-resolved contents beside an old patch would corrupt context.
|
|
const requestedSnapshot = url.searchParams.get("snapshot");
|
|
if (requestedSnapshot) {
|
|
if (requestedSnapshot !== currentSnapshotId()) {
|
|
json(res, { error: "Diff snapshot is stale; refresh before expanding context" }, 409);
|
|
return;
|
|
}
|
|
const baselineGeneration = fingerprintGeneration;
|
|
let baseline = currentFingerprint;
|
|
const pendingCapture = pendingFingerprintCapture;
|
|
if (baseline == null && pendingCapture) {
|
|
baseline = await pendingCapture;
|
|
}
|
|
if (
|
|
requestedSnapshot !== currentSnapshotId() ||
|
|
baselineGeneration !== fingerprintGeneration
|
|
) {
|
|
json(res, { error: "Diff snapshot is stale; refresh before expanding context" }, 409);
|
|
return;
|
|
}
|
|
if (baseline != null) {
|
|
const probe = await fileContentFingerprintProbes.run(
|
|
`${requestedSnapshot}:${baselineGeneration}`,
|
|
computeDiffFingerprint,
|
|
);
|
|
if (
|
|
requestedSnapshot !== currentSnapshotId() ||
|
|
currentFingerprint !== baseline ||
|
|
(probe != null && probe !== baseline)
|
|
) {
|
|
json(res, { error: "Diff snapshot is stale; refresh before expanding context" }, 409);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isBinaryPatchFile(currentPatch, filePath)) {
|
|
json(res, { oldContent: null, newContent: null });
|
|
return;
|
|
}
|
|
|
|
if (workspace) {
|
|
try {
|
|
const result = await workspace.getFileContents(filePath, oldPath);
|
|
json(res, result);
|
|
} catch (error) {
|
|
json(
|
|
res,
|
|
{ error: error instanceof Error ? error.message : "No file access available" },
|
|
400,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const fileContentCwd = (options.worktreePool && prMeta) ? options.worktreePool.resolve(prMeta.url) : options.agentCwd;
|
|
if (
|
|
isPRMode &&
|
|
currentPRDiffScope === "full-stack" &&
|
|
fileContentCwd &&
|
|
prMeta?.defaultBranch
|
|
) {
|
|
const baseRef = await resolvePRFullStackBaseRef(
|
|
reviewRuntime,
|
|
prMeta.defaultBranch,
|
|
fileContentCwd,
|
|
);
|
|
if (!baseRef) {
|
|
json(res, { oldContent: null, newContent: null });
|
|
return;
|
|
}
|
|
const result = await getFileContentsForDiffCore(
|
|
reviewRuntime,
|
|
"merge-base",
|
|
baseRef,
|
|
filePath,
|
|
oldPath,
|
|
fileContentCwd,
|
|
);
|
|
json(res, result);
|
|
return;
|
|
}
|
|
|
|
// Local mode first (matches Bun server priority)
|
|
if (hasLocalAccess && !isPRMode) {
|
|
const base = resolveReviewBase(
|
|
url.searchParams.get("base") ?? undefined,
|
|
);
|
|
const defaultCwd = options.gitContext?.cwd;
|
|
const result = await getVcsFileContentsForDiff(
|
|
currentDiffType as DiffType,
|
|
base,
|
|
filePath,
|
|
oldPath,
|
|
defaultCwd,
|
|
);
|
|
json(res, result);
|
|
return;
|
|
}
|
|
|
|
// PR mode: fetch from platform API using merge-base/head SHAs
|
|
if (isPRMode && prRef && prMeta) {
|
|
try {
|
|
const oldSha = prMeta.mergeBaseSha ?? prMeta.baseSha;
|
|
const [oldContent, newContent] = await Promise.all([
|
|
fetchPRFileContent(prRef, oldSha, oldPath || filePath),
|
|
fetchPRFileContent(prRef, prMeta.headSha, filePath),
|
|
]);
|
|
json(res, { oldContent, newContent });
|
|
} catch (err) {
|
|
json(
|
|
res,
|
|
{
|
|
error:
|
|
err instanceof Error
|
|
? err.message
|
|
: "Failed to fetch file content",
|
|
},
|
|
500,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
json(res, { error: "No file access available" }, 400);
|
|
} else if (url.pathname === "/api/code-nav/resolve" && req.method === "POST") {
|
|
if (isGitButlerCommittedView()) {
|
|
json(res, { error: "Code navigation is unavailable for committed GitButler views" }, 400);
|
|
return;
|
|
}
|
|
const hasCodeNavAccess = !!workspace || !!options.gitContext || !!options.agentCwd || !!options.worktreePool;
|
|
if (!hasCodeNavAccess) {
|
|
json(res, { error: "Code navigation requires local access" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const body = (await parseBody(req)) as unknown as CodeNavRequest;
|
|
const error = validateCodeNavRequest(body);
|
|
if (error) {
|
|
json(res, { error }, 400);
|
|
return;
|
|
}
|
|
const navCwd = resolveAgentCwd();
|
|
const changedFiles = extractChangedFiles(currentPatch);
|
|
const result = await resolveCodeNav(piCodeNavRuntime, body, navCwd, changedFiles);
|
|
json(res, result);
|
|
} catch (err) {
|
|
json(res, { error: err instanceof Error ? err.message : "Code navigation failed" }, 500);
|
|
}
|
|
} else if (url.pathname === "/api/code-nav/file" && req.method === "GET") {
|
|
if (isGitButlerCommittedView()) {
|
|
json(res, { error: "Code navigation is unavailable for committed GitButler views" }, 400);
|
|
return;
|
|
}
|
|
const hasCodeNavAccess = !!workspace || !!options.gitContext || !!options.agentCwd || !!options.worktreePool;
|
|
if (!hasCodeNavAccess) {
|
|
json(res, { error: "Code navigation requires local access" }, 400);
|
|
return;
|
|
}
|
|
const filePath = url.searchParams.get("path");
|
|
if (!filePath) {
|
|
json(res, { error: "Missing path" }, 400);
|
|
return;
|
|
}
|
|
try { validateFilePath(filePath); } catch {
|
|
json(res, { error: "Invalid path" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const navCwd = resolveAgentCwd();
|
|
const content = readFileSync(`${navCwd}/${filePath}`, "utf-8");
|
|
json(res, { content });
|
|
} catch {
|
|
json(res, { error: "File not found" }, 404);
|
|
}
|
|
} else if (url.pathname === "/api/config" && req.method === "POST") {
|
|
try {
|
|
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; favicon?: FaviconStyle; reviewAnalysis?: Record<string, unknown>; conventionalComments?: boolean };
|
|
const toSave: Record<string, unknown> = {};
|
|
if (body.displayName !== undefined) toSave.displayName = body.displayName;
|
|
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
|
|
if (body.theme !== undefined) toSave.theme = body.theme;
|
|
if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon;
|
|
if (body.reviewAnalysis !== undefined) {
|
|
const reviewAnalysis = parseReviewAnalysisConfig(body.reviewAnalysis);
|
|
if (!reviewAnalysis) return json(res, { error: "Invalid analysis settings" }, 400);
|
|
toSave.reviewAnalysis = reviewAnalysis;
|
|
if (reviewAnalysis.callFlow === false) callFlowService.cancelAll();
|
|
}
|
|
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
|
|
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
|
|
json(res, { ok: true });
|
|
} catch {
|
|
json(res, { error: "Invalid request" }, 400);
|
|
}
|
|
} else if (url.pathname === "/api/image") {
|
|
handleImageRequest(res, url);
|
|
} else if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
await handleUploadRequest(req, res);
|
|
} else if (url.pathname === "/api/agents" && req.method === "GET") {
|
|
json(res, { agents: [] });
|
|
} else if (!aiEnabled && url.pathname.startsWith("/api/agents/")) {
|
|
// The exact endpoint above is feedback routing, not a review job.
|
|
if (
|
|
url.pathname.slice("/api/agents/".length) === "capabilities" &&
|
|
req.method === "GET"
|
|
) {
|
|
json(res, { mode: "review", providers: [], available: false });
|
|
} else {
|
|
json(res, { error: "AI features disabled" }, 503);
|
|
}
|
|
} else if (
|
|
url.pathname === "/api/agents/review-profiles" &&
|
|
req.method === "GET"
|
|
) {
|
|
// Custom reviews discovery. Reloaded per request, no file watching.
|
|
// Catalog only — directory listing, no SKILL.md bodies read here.
|
|
// Bodies are read at launch, for the one selected skill.
|
|
const body: ReviewProfilesResponse = {
|
|
profiles: [
|
|
{
|
|
id: BUILTIN_DEFAULT_PROFILE.id,
|
|
label: BUILTIN_DEFAULT_PROFILE.label,
|
|
source: BUILTIN_DEFAULT_PROFILE.source,
|
|
default: BUILTIN_DEFAULT_PROFILE.default,
|
|
},
|
|
...discoverCuratedSkills().map((s) => ({
|
|
id: `skill:${s.name}`,
|
|
label: s.name,
|
|
source: "user" as const,
|
|
sourcePath: s.sourcePath,
|
|
})),
|
|
],
|
|
};
|
|
json(res, body);
|
|
} else if (url.pathname === "/api/agents/skills" && req.method === "GET") {
|
|
// All discovered skills for the "add a review" picker, each flagged
|
|
// with whether it is already enabled.
|
|
json(res, { skills: listAllSkills() });
|
|
} else if (url.pathname === "/api/agents/review-skills" && req.method === "POST") {
|
|
// Enable a skill as a review (curation write to review-skills.json).
|
|
let name: unknown;
|
|
try {
|
|
const body = await parseBody(req);
|
|
name = body.name;
|
|
} catch {
|
|
json(res, { error: "Invalid JSON" }, 400);
|
|
return;
|
|
}
|
|
if (typeof name !== "string" || name.length === 0) {
|
|
json(res, { error: "`name` is required." }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
json(res, enableReviewSkill(name));
|
|
} catch (err) {
|
|
json(res, { error: err instanceof Error ? err.message : "Could not enable review." }, 400);
|
|
}
|
|
} else if (url.pathname === "/api/agents/guide-instructions" && req.method === "GET") {
|
|
// Guided Review standing instructions (#1265) — server-owned, stored in
|
|
// the data dir like review-skills.json. Guide launches apply the stored
|
|
// text when the launch body carries none.
|
|
json(res, { instructions: readGuideInstructions() });
|
|
} else if (url.pathname === "/api/agents/guide-instructions" && req.method === "PUT") {
|
|
let instructions: unknown;
|
|
try {
|
|
const body = await parseBody(req);
|
|
instructions = body.instructions;
|
|
} catch {
|
|
json(res, { error: "Invalid JSON" }, 400);
|
|
return;
|
|
}
|
|
if (typeof instructions !== "string") {
|
|
json(res, { error: "`instructions` must be a string." }, 400);
|
|
return;
|
|
}
|
|
json(res, { instructions: writeGuideInstructions(instructions) });
|
|
} else if (url.pathname === "/api/git-add" && req.method === "POST") {
|
|
try {
|
|
const body = await parseBody(req);
|
|
const filePath = body.filePath as string | undefined;
|
|
if (typeof filePath !== "string" || !filePath) {
|
|
json(res, { error: "Missing filePath" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
validateFilePath(filePath);
|
|
} catch {
|
|
json(res, { error: "Invalid path" }, 400);
|
|
return;
|
|
}
|
|
const undo = body.undo === true;
|
|
|
|
if (workspace) {
|
|
try {
|
|
await workspace.stageFile(filePath, undo);
|
|
json(res, { ok: true });
|
|
} catch (error) {
|
|
json(
|
|
res,
|
|
{ error: error instanceof Error ? error.message : "Failed to stage file" },
|
|
400,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const stageCwd = resolveVcsCwd(currentDiffType as DiffType, options.gitContext?.cwd);
|
|
if (
|
|
isPRMode ||
|
|
(sessionVcsType && !vcsOwnsDiffType(sessionVcsType, currentDiffType as string)) ||
|
|
!(await canStageFiles(currentDiffType as DiffType, stageCwd))
|
|
) {
|
|
json(res, { error: "Staging not available" }, 400);
|
|
return;
|
|
}
|
|
|
|
if (undo) {
|
|
await unstageFile(currentDiffType as DiffType, filePath, stageCwd);
|
|
} else {
|
|
await stageFile(currentDiffType as DiffType, filePath, stageCwd);
|
|
}
|
|
json(res, { ok: true });
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : "Failed to stage file";
|
|
json(res, { error: message }, 500);
|
|
}
|
|
} else if (url.pathname === "/api/open-in/apps" && req.method === "GET") {
|
|
// Remote/headless sessions can't open apps on the user's machine —
|
|
// report unavailable so the UI hides the control entirely.
|
|
if (isRemote) {
|
|
json(res, { available: false, apps: [] });
|
|
return;
|
|
}
|
|
json(res, { available: true, apps: getAvailableOpenInApps() });
|
|
} else if (url.pathname === "/api/open-in" && req.method === "POST") {
|
|
if (isGitButlerCommittedView()) {
|
|
json(res, { error: "Open in app is unavailable for committed GitButler views" }, 400);
|
|
return;
|
|
}
|
|
if (isRemote) {
|
|
json(res, { ok: false, error: "Open in app is unavailable in remote sessions" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const body = await parseBody(req);
|
|
const filePath = body.filePath;
|
|
if (typeof filePath !== "string" || !filePath) {
|
|
json(res, { ok: false, error: "Missing filePath" }, 400);
|
|
return;
|
|
}
|
|
const appId = typeof body.appId === "string" ? body.appId : undefined;
|
|
// Resolve repo-relative `git diff` paths against the VCS root
|
|
// server-side (resolveAgentCwd folds in workspace.root, the PR
|
|
// local checkout, resolveVcsCwd(gitContext.cwd), and process.cwd())
|
|
// — not the client `base`, which is wrong when review runs from a
|
|
// subdirectory — then containment-check.
|
|
const abs = resolveOpenInTarget(filePath, null, resolveOpenInRoot);
|
|
if (abs == null) {
|
|
json(res, { ok: false, error: "Path is outside the allowed directory" }, 403);
|
|
return;
|
|
}
|
|
const result = await openFileInApp(abs, appId);
|
|
json(res, result, 200);
|
|
} catch (err) {
|
|
json(
|
|
res,
|
|
{ ok: false, error: err instanceof Error ? err.message : "Failed to open file" },
|
|
500,
|
|
);
|
|
}
|
|
} else if (url.pathname === "/api/draft") {
|
|
await handleDraftRequest(req, res, draftKey);
|
|
} else if (url.pathname === "/favicon.png") {
|
|
handleFavicon(res);
|
|
} else if (
|
|
isGitButlerCommittedView() &&
|
|
url.pathname === "/api/editor-annotations" &&
|
|
req.method === "GET"
|
|
) {
|
|
json(res, { annotations: [] });
|
|
} else if (
|
|
isGitButlerCommittedView() &&
|
|
url.pathname === "/api/editor-annotation" &&
|
|
req.method === "POST"
|
|
) {
|
|
json(res, { error: "Editor annotations are unavailable for committed GitButler views" }, 400);
|
|
} else if (await editorAnnotations.handle(req, res, url)) {
|
|
return;
|
|
} else if (url.pathname === "/api/pr-context/stream" && req.method === "GET") {
|
|
if (!isPRMode || !prRef || !prMeta) {
|
|
json(res, { error: "Not in PR mode" }, 400);
|
|
return;
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
});
|
|
res.setTimeout(0);
|
|
|
|
const activeRef = prRef;
|
|
const activeUrl = prMeta.url;
|
|
const unsubscribe = prContextLive.watch(activeUrl, activeRef, (event) => {
|
|
res.write(serializePRContextSSEEvent(event));
|
|
});
|
|
const heartbeatTimer = setInterval(() => {
|
|
try {
|
|
res.write(PR_CONTEXT_HEARTBEAT_COMMENT);
|
|
} catch {
|
|
clearInterval(heartbeatTimer);
|
|
unsubscribe();
|
|
}
|
|
}, PR_CONTEXT_HEARTBEAT_INTERVAL_MS);
|
|
|
|
res.on("close", () => {
|
|
clearInterval(heartbeatTimer);
|
|
unsubscribe();
|
|
});
|
|
return;
|
|
} else if (await externalAnnotations.handle(req, res, url)) {
|
|
return;
|
|
} else if (await agentJobs.handle(req, res, url)) {
|
|
return;
|
|
} else if (url.pathname.startsWith("/api/ai/")) {
|
|
// AI sessions pin their cwd at creation — make sure the PR checkout
|
|
// exists first so sessions never root in a transient fallback
|
|
// (mirrors the Bun server; no-op while the pool entry is ready).
|
|
if (aiRuntime && req.method === "POST" && url.pathname === "/api/ai/session" && options.worktreePool && prMeta) {
|
|
// If the checkout can't be produced, refuse instead of starting a
|
|
// session rooted in the wrong directory.
|
|
try {
|
|
await options.worktreePool.ensure(reviewRuntime, prMeta);
|
|
} catch {
|
|
json(res, { error: "Local PR checkout unavailable — Ask AI can't read the PR files right now. Retry shortly." }, 503);
|
|
return;
|
|
}
|
|
}
|
|
if (await handlePiAIRequest(req, res, url, aiRuntime)) return;
|
|
handleApiNotFound(res, url.pathname);
|
|
return;
|
|
} else if (url.pathname === "/api/exit" && req.method === "POST") {
|
|
deleteDraft(draftKey, readDraftGenerationFromUrl(req));
|
|
resolveDecision({ approved: false, feedback: '', annotations: [], exit: true });
|
|
json(res, { ok: true });
|
|
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
|
|
try {
|
|
const body = await parseBody(req);
|
|
deleteDraft(draftKey, readDraftGenerationFromBody(body));
|
|
resolveDecision({
|
|
approved: (body.approved as boolean) ?? false,
|
|
feedback: (body.feedback as string) || "",
|
|
annotations: (body.annotations as unknown[]) || [],
|
|
agentSwitch: body.agentSwitch as string | undefined,
|
|
});
|
|
json(res, { ok: true });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to process feedback";
|
|
json(res, { error: message }, 500);
|
|
}
|
|
} else if (url.pathname.startsWith("/api/")) {
|
|
handleApiNotFound(res, url.pathname);
|
|
} else {
|
|
html(res, options.htmlContent);
|
|
}
|
|
});
|
|
|
|
const { port, portSource } = await listenOnPort(server);
|
|
serverUrl = buildAdvertisedUrl(port);
|
|
agentApiUrl = `http://127.0.0.1:${port}`;
|
|
const exitHandler = () => agentJobs.killAll();
|
|
process.once("exit", exitHandler);
|
|
|
|
if (options.onReady) {
|
|
options.onReady(serverUrl, isRemote, port);
|
|
}
|
|
|
|
return {
|
|
port,
|
|
portSource,
|
|
url: serverUrl,
|
|
isRemote,
|
|
waitForDecision: () => decisionPromise,
|
|
stop: () => {
|
|
// try/finally: a throwing dispose must never leave the listener bound.
|
|
try {
|
|
process.removeListener("exit", exitHandler);
|
|
agentJobs.killAll();
|
|
callFlowService.cancelAll();
|
|
aiRuntime?.dispose();
|
|
} finally {
|
|
server.close();
|
|
// close() only stops the listener; drain browser keep-alive sockets so a
|
|
// stopped session's connections die immediately instead of at the
|
|
// browser's whim (parity with Bun's server.stop(), which closes idle
|
|
// connections). Guarded: jiti can run under hosts whose node:http lacks
|
|
// closeAllConnections.
|
|
server.closeAllConnections?.();
|
|
// Invoke cleanup callback (e.g., remove temp worktree)
|
|
if (options.onCleanup) {
|
|
try {
|
|
const result = options.onCleanup();
|
|
if (result instanceof Promise) result.catch(() => {});
|
|
} catch { /* best effort */ }
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|