Files
backnotprop__plannotator/packages/core/agent-jobs.ts
Michael Ramos 99d11dca04 Persist Guided Reviews across sessions (#1115)
* feat(guide): add durable guide store with repo-scoped keys and opt-out

Runtime-agnostic guide persistence for #1112: packages/shared/guide-store.ts
writes validated guides to ${PLANNOTATOR_DATA_DIR}/guides/{repo-key}/{id}.json
with atomic tmp+rename writes and graceful corrupt-file handling. The repo key
is a sanitized host__owner__repo from the origin remote (or the PR url), with
a dir-name+hash8 fallback when no remote parses, so PR and branch sessions of
one repository share a shelf and same-named branches in different repos never
collide. Includes the session glue (repo-key/headSha/label resolution plus the
jobId-to-savedId map) shared by both server runtimes, the guideHistory config
key with resolveGuideHistory (PLANNOTATOR_GUIDE_HISTORY, coerced booleans),
and the browser-safe SavedGuideListEntry/CodeGuideData extensions.

* feat(guide): autosave guides and serve saved: ids in both server runtimes

Both packages/server/review.ts and the Pi mirror serverReview.ts now: autosave
a guide the moment it passes the existing validateGuideOutput gate (including
manual-repair submits); write reviewed-state changes on a live job id through
to that job's saved file; serve persisted guides through the existing guide
endpoints as saved:{id} pseudo job ids (GET guide + PUT reviewed); and expose
GET /api/guides (repo-scoped list with progress and a moved flag comparing the
stored head sha to the current head) and DELETE /api/guides/:id. guide-store
joins vendor.sh's flat copy list; cross-runtime endpoint wiring is covered by
packages/server/guide-persistence.test.ts against both servers, including
reviewed-state persistence across a server restart and traversal-id rejection.

* feat(guide): previous-guides list, Saved chip, and outdated Regenerate hint

GuideEmptyState grows a Previous guides section under the Generate controls:
rows show the target label chip, title, age, reviewed progress, a quiet diff
changed flag when the stored head no longer matches, and a per-row delete;
clicking a row loads the guide via its saved:{id} pseudo job id (the existing
useGuideData/GuideScreen id plumbing already treats ids as opaque). GuideView
shows a small Saved chip once the active guide is persisted and, for an
outdated saved guide, one muted hint line whose Regenerate action launches a
fresh guide with the persisted defaults. The engine/model resolution and
launch-param shapes move into the shared useGuideLaunch hook so the empty
state and the hint stay in lockstep. DOM tests cover the new GuideView states.

* docs: document guide persistence endpoints and PLANNOTATOR_GUIDE_HISTORY

* fix(guide): label saved envelopes with launch-time context, not completion-time state

Review finding on #1115: saveForJob read the live session getters when the
job COMPLETED, but guide jobs run for minutes while the session supports
mid-generation PR switching (/api/pr-switch) and diff switches. Launch on PR
A, switch to B, complete: the envelope permanently carried A's content
labeled with B's PR label/url/headSha (and could even land on B's repo
shelf). The review-target context (pr url/label/head, branch label, head
sha) is now snapshotted at job LAUNCH via guideStore.captureLaunchContext()
in the guide buildCommand branch of both runtimes and carried on the job
itself as AgentJobInfo.guideContext, the same discipline as
changedFilesSnapshot, so it is garbage-collected with the job and needs no
separate cleanup. saveForJob prefers the snapshot (falling back to the live
getters only for jobs launched without one), derives the shelf from the
launch-time PR url, and records the shelf alongside the saved id so reviewed
write-through follows the file wherever it landed. Repair jobs reuse the
FAILED job's own snapshot. Covered by new session tests that mutate the
injected getters between launch capture and completion.

* fix(guide): close a saved guide when the review context switches

Review finding on #1115: a saved:{id} guide has no AgentJobInfo, so
GuideScreen's context match passes trivially (unknown ids are tolerated for
the demo path). Switching PRs or worktrees while a saved guide was open left
it mounted over the new context's diff with a stale moved flag. App.tsx now
clears activeGuideJobId on any prMetadata.url / activeWorktreePath change
when it points at a saved: id; the user reopens it from the Previous guides
list. Live job ids are untouched, GuideScreen's own matching handles those.
2026-07-23 15:32:43 -07:00

202 lines
7.8 KiB
TypeScript

/**
* Agent Jobs — shared types, state machine, and SSE helpers.
*
* Runtime-agnostic: no node:fs, no node:http, no Bun APIs.
* Both the Bun server handler and (future) Node handler import
* this module and wrap it with their respective HTTP transport layers.
*
* Mirrors packages/shared/external-annotation.ts in structure.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type AgentJobStatus = "starting" | "running" | "done" | "failed" | "killed";
/**
* Snapshot of the diff the reviewer was looking at when this job was launched.
* Carried on the job so downstream UIs (agent-result panel "Copy All") export
* the same `**Diff:** ...` header the job was actually run against — if the
* reviewer switches the UI to a different diff afterwards, the job's snapshot
* still reflects truth. Structurally compatible with the UI-side
* `FeedbackDiffContext` in `packages/review-editor/utils/exportFeedback.ts`.
*/
export interface AgentJobDiffContext {
mode: string;
base?: string;
worktreePath?: string | null;
/** Human-readable label captured with provider-specific selectors. */
label?: string;
/** Exact server snapshot that supplied the reviewed line coordinates. */
snapshotId?: string;
}
export interface AgentJobAnnotationContext {
commitSha?: string;
gitButlerDiffType?: string;
gitButlerDiffLabel?: string;
gitButlerBase?: string;
gitButlerSnapshotId?: string;
}
/** Stamp completed review findings with the diff snapshot the agent reviewed. */
export function getAgentJobAnnotationContext(
diffContext?: AgentJobDiffContext,
): AgentJobAnnotationContext {
if (!diffContext) return {};
if (diffContext.mode.startsWith("commit:")) {
const commitSha = diffContext.mode.slice("commit:".length);
return commitSha ? { commitSha } : {};
}
if (!diffContext.mode.startsWith("gitbutler:")) return {};
return {
gitButlerDiffType: diffContext.mode,
...(diffContext.label ? { gitButlerDiffLabel: diffContext.label } : {}),
...(diffContext.base ? { gitButlerBase: diffContext.base } : {}),
...(diffContext.snapshotId ? { gitButlerSnapshotId: diffContext.snapshotId } : {}),
};
}
export interface AgentJobInfo {
/** Unique job identifier (UUID). */
id: string;
/** Source identifier for external annotations — "agent-{id prefix}". */
source: string;
/** Provider that spawned this job — "claude", "codex", "tour", "shell", etc. */
provider: string;
/** Underlying engine used (e.g., "claude" or "codex"). Set when provider is "tour". */
engine?: string;
/** Model used (e.g., "sonnet", "opus"). Set when provider is "tour" with Claude engine. */
model?: string;
/** Claude --effort level (e.g., "low", "medium", "high", "xhigh", "max"). */
effort?: string;
/** Codex reasoning effort level (e.g., "high", "medium"). */
reasoningEffort?: string;
/** Whether Codex fast mode (service_tier=fast) was enabled. */
fastMode?: boolean;
/** Pi's unified reasoning level (marker engines only), e.g. "minimal", "high". */
thinking?: string;
/** Human-readable label for the job. */
label: string;
/** Current lifecycle status. */
status: AgentJobStatus;
/** Timestamp when the job was created. */
startedAt: number;
/** Timestamp when the job reached a terminal state. */
endedAt?: number;
/** Process exit code (set on done/failed). */
exitCode?: number;
/** Last ~500 chars of stderr on failure. */
error?: string;
/** The actual command that was spawned (for display/debug). */
command: string[];
/** Working directory where the process was spawned. */
cwd?: string;
/** The review prompt text (system + user message). Stored separately from command for providers that use stdin. */
prompt?: string;
/** Review summary set by the agent on completion. */
summary?: {
correctness: string;
explanation: string;
confidence: number;
};
/** PR URL at launch time — used to attribute findings to the correct PR. */
prUrl?: string;
/** PR diff scope at launch time — "layer" or "full-stack". */
diffScope?: string;
/** Diff context at launch time (see AgentJobDiffContext). */
diffContext?: AgentJobDiffContext;
/** Resolved review profile id at launch time (e.g. "builtin:default", "user:security"). */
reviewProfileId?: string;
/** Resolved review profile label — rides on findings so the UI can show a profile tag. */
reviewProfileLabel?: string;
/**
* Launch-time review-target snapshot for guide jobs (#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 the guide was
* GENERATED against — captured here at build time (same discipline as
* changedFilesSnapshot), never re-read from live session state at
* completion. Structurally matches guide-store's GuideLaunchContext.
*/
guideContext?: {
pr?: { url: string; headSha: string; label: string } | null;
branchLabel?: string;
headSha?: string;
};
}
export interface AgentCapability {
id: string;
name: string;
available: boolean;
/**
* Provider-discovered model catalog (currently only Cursor). Best-effort and
* account-specific — populated from the provider CLI at capability-detection
* time, empty when discovery fails or the CLI is unauthenticated. The UI
* drives its model picker from this instead of a hardcoded list.
*/
models?: { id: string; label: string }[];
}
export interface AgentCapabilities {
mode: "plan" | "review" | "annotate";
providers: AgentCapability[];
/** True if at least one provider is available. */
available: boolean;
}
// ---------------------------------------------------------------------------
// SSE event types
// ---------------------------------------------------------------------------
export type AgentJobEvent =
| { type: "snapshot"; jobs: AgentJobInfo[] }
| { type: "job:started"; job: AgentJobInfo }
| { type: "job:updated"; job: AgentJobInfo }
| { type: "job:completed"; job: AgentJobInfo }
| { type: "job:log"; jobId: string; delta: string }
| { type: "jobs:cleared" };
// ---------------------------------------------------------------------------
// SSE helpers
// ---------------------------------------------------------------------------
/** Heartbeat comment to keep SSE connections alive (sent every 30s). */
export const AGENT_HEARTBEAT_COMMENT = ":\n\n";
/** Interval in ms between heartbeat comments. */
export const AGENT_HEARTBEAT_INTERVAL_MS = 30_000;
/** Encode an event as an SSE `data:` line. */
export function serializeAgentSSEEvent(event: AgentJobEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Check if a status is terminal (no further transitions). */
export function isTerminalStatus(status: AgentJobStatus): boolean {
return status === "done" || status === "failed" || status === "killed";
}
/** Generate the source identifier for a job from its ID. */
export function jobSource(id: string): string {
return "agent-" + id.slice(0, 8);
}
// ---------------------------------------------------------------------------
// Review ingestion completion semantics
// ---------------------------------------------------------------------------
/** Calm, provider-neutral failure reason. Never leak schema/CLI internals. */
export const REVIEW_OUTPUT_FAILED = "Review finished but produced no usable findings.";
/** Flip a job to failed with a calm one-liner (Code Tour precedent). */
export function markJobReviewFailed(job: AgentJobInfo, error: string): void {
job.status = "failed";
job.error = error;
}