diff --git a/src/components/layout/EditorPanel.tsx b/src/components/layout/EditorPanel.tsx index 4c94ea99..101e1dee 100644 --- a/src/components/layout/EditorPanel.tsx +++ b/src/components/layout/EditorPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { ConfirmDialog } from "@/components/layout/ConfirmDialog"; import { toast } from "@/components/ui"; @@ -7,12 +7,13 @@ import { workspaceFsAdapter } from "@/lib/fs"; import type { WorkspaceCreateEntryResult, WorkspaceDeleteEntryResult, WorkspaceDirectoryEntry } from "@/lib/fs/fs.types"; import { parseUnifiedDiffToBuffers } from "@/lib/source-control-diff"; import { hasSourceControlStagedChanges, type SourceControlStatusItem } from "@/lib/source-control-status"; +import { resolveWorkspaceTodoStatus } from "@/lib/workspace-information"; import { useAppStore } from "@/store/app.store"; import type { SectionId } from "@/components/layout/settings-dialog.schema"; import { RightRailPanelShell } from "./RightRailPanelShell"; import { WorkspaceScriptsPanel } from "./WorkspaceScriptsPanel"; import { WorkspaceSkillsPanel } from "./WorkspaceSkillsPanel"; -import { WorkspaceChangesPanel } from "./WorkspaceChangesPanel"; +import { WorkspaceChangesPanel, type WorkspaceChecksViewModel } from "./WorkspaceChangesPanel"; import { WorkspaceExplorerPanel } from "./WorkspaceExplorerPanel"; import { WorkspaceInformationPanel } from "./WorkspaceInformationPanel"; import { WorkspaceLensPanel } from "./WorkspaceLensPanel"; @@ -110,6 +111,11 @@ export function EditorPanel(props: EditorPanelProps) { refreshProjectFiles, closeEditorTab, updateSettings, + requestVerificationFix, + workspaceTodos, + workspacePrInfo, + isDefaultWorkspace, + fetchWorkspacePrStatus, ] = useAppStore(useShallow((state) => [ state.activeWorkspaceId, state.hasHydratedWorkspaces, @@ -126,8 +132,51 @@ export function EditorPanel(props: EditorPanelProps) { state.refreshProjectFiles, state.closeEditorTab, state.updateSettings, + state.requestVerificationFix, + state.workspaceInformation.todos, + state.workspacePrInfoById[state.activeWorkspaceId] ?? null, + Boolean(state.workspaceDefaultById[state.activeWorkspaceId]), + state.fetchWorkspacePrStatus, ] as const)); + const handleFixVerificationWithAgent = useCallback( + async (args?: { scriptId?: string }) => { + const result = await requestVerificationFix({ + workspaceId: activeWorkspaceId, + scriptId: args?.scriptId, + }); + if (result.status === "blocked") { + toast.error("Couldn't forward the failures to the agent"); + return; + } + toast.success( + result.status === "queued" + ? "Queued fix for the next turn" + : "Sent failing checks to the agent", + ); + }, + [requestVerificationFix, activeWorkspaceId], + ); + + const checks = useMemo(() => { + const openTodos = workspaceTodos.filter( + (todo) => resolveWorkspaceTodoStatus(todo) !== "completed", + ); + return { + prStatus: workspacePrInfo?.derived ?? "no_pr", + pr: workspacePrInfo?.pr + ? { + number: workspacePrInfo.pr.number, + title: workspacePrInfo.pr.title, + url: workspacePrInfo.pr.url, + } + : null, + openTodoCount: openTodos.length, + totalTodoCount: workspaceTodos.length, + openTodos: openTodos.map((todo) => todo.text), + }; + }, [workspaceTodos, workspacePrInfo]); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [explorerDirectoryStateByPath, setExplorerDirectoryStateByPath] = useState>({}); const [explorerError, setExplorerError] = useState(""); @@ -351,6 +400,21 @@ export function EditorPanel(props: EditorPanelProps) { } }, [rightTab, sidebarOverlayVisible, workspaceCwd]); + // Keep the Checks tab's PR readiness fresh. Gated on the changes tab so it + // never double-fetches alongside the Information panel (mutually exclusive + // right-rail tabs). + useEffect(() => { + if (rightTab !== "changes" || !sidebarOverlayVisible) return; + if (!activeWorkspaceId || isDefaultWorkspace) return; + void fetchWorkspacePrStatus({ workspaceId: activeWorkspaceId }); + }, [ + rightTab, + sidebarOverlayVisible, + activeWorkspaceId, + isDefaultWorkspace, + fetchWorkspacePrStatus, + ]); + const loadScmStatusRef = useRef(loadScmStatus); loadScmStatusRef.current = loadScmStatus; const isScmBusyRef = useRef(isScmBusy); @@ -948,6 +1012,10 @@ export function EditorPanel(props: EditorPanelProps) { onAutoRefreshSecondsChange={(seconds) => updateSettings({ patch: { scmAutoRefreshSeconds: seconds } })} verification={turnVerification ?? null} intentCompliance={turnIntentCompliance ?? null} + checks={checks} + onFixVerificationWithAgent={(args) => + void handleFixVerificationWithAgent(args) + } /> ) : null} diff --git a/src/components/layout/FleetView.tsx b/src/components/layout/FleetView.tsx index 79358471..1611e5d8 100644 --- a/src/components/layout/FleetView.tsx +++ b/src/components/layout/FleetView.tsx @@ -28,7 +28,11 @@ import { import { classifyTaskStatus, compareFleetTaskStatus, + deriveFleetLifecycleStatus, + FLEET_LIFECYCLE_LABEL, + groupFleetWorkspacesByLane, hasFleetTaskAttentionStatus, + type FleetLifecycleStatus, type FleetTaskStatus, } from "@/lib/fleet/task-status"; import { @@ -333,6 +337,10 @@ function FleetWorkspaceSection(args: { workspaceId: string, target: FleetAttentionTarget | null, ) => void; + onLifecycleChange: ( + workspaceId: string, + status: FleetLifecycleStatus | null, + ) => void; }) { const [ activeWorkspaceId, @@ -520,6 +528,23 @@ function FleetWorkspaceSection(args: { return () => args.onAttentionTargetChange(args.workspace.id, null); }, [args.onAttentionTargetChange, args.workspace.id, firstAttentionTarget]); + const lifecycle = useMemo( + () => + deriveFleetLifecycleStatus({ + prStatus, + hasRunningTask: rows.some( + (row) => row.status !== "unknown" && row.status !== "idle", + ), + hasRecentActivity: rows.some((row) => row.messageCount > 0), + }), + [prStatus, rows], + ); + + useEffect(() => { + args.onLifecycleChange(args.workspace.id, lifecycle); + return () => args.onLifecycleChange(args.workspace.id, null); + }, [args.onLifecycleChange, args.workspace.id, lifecycle]); + return (
@@ -585,12 +610,38 @@ function FleetWorkspaceSection(args: { const MemoizedFleetWorkspaceSection = memo(FleetWorkspaceSection); +const FLEET_LIFECYCLE_DOT: Record = { + "in-progress": "bg-primary", + "in-review": "bg-warning", + backlog: "bg-muted-foreground/50", + done: "bg-success", +}; + +function FleetLaneHeader(args: { lane: FleetLifecycleStatus; count: number }) { + return ( +
+ + + {FLEET_LIFECYCLE_LABEL[args.lane]} + + + {args.count} + +
+ ); +} + export function FleetView() { const projects = useFleetProjects(); const focusTaskAttention = useAppStore((state) => state.focusTaskAttention); const closeFleetView = useAppStore((state) => state.closeFleetView); const [attentionTargetsByWorkspaceId, setAttentionTargetsByWorkspaceId] = useState>({}); + const [lifecycleByWorkspaceId, setLifecycleByWorkspaceId] = useState< + Record + >({}); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -626,6 +677,24 @@ export function FleetView() { [], ); + const handleLifecycleChange = useCallback( + (workspaceId: string, status: FleetLifecycleStatus | null) => { + setLifecycleByWorkspaceId((current) => { + if ((current[workspaceId] ?? null) === status) { + return current; + } + const next = { ...current }; + if (status) { + next[workspaceId] = status; + } else { + delete next[workspaceId]; + } + return next; + }); + }, + [], + ); + const nextAttentionTarget = useMemo(() => { for (const project of projects) { for (const workspace of project.workspaces) { @@ -729,15 +798,40 @@ export function FleetView() { No workspaces
) : ( - project.workspaces.map((workspace) => ( - - )) + (() => { + const laneGroups = groupFleetWorkspacesByLane({ + workspaces: project.workspaces, + lifecycleByWorkspaceId, + }); + // Only label lanes once a project actually spans more than + // one — a single-lane project stays as clean as before. + const showLanes = laneGroups.length > 1; + const elements: ReactNode[] = []; + for (const group of laneGroups) { + if (showLanes) { + elements.push( + , + ); + } + for (const workspace of group.workspaces) { + elements.push( + , + ); + } + } + return elements; + })() )}
))} diff --git a/src/components/layout/WorkspaceChangesPanel.tsx b/src/components/layout/WorkspaceChangesPanel.tsx index 10771da2..fae196df 100644 --- a/src/components/layout/WorkspaceChangesPanel.tsx +++ b/src/components/layout/WorkspaceChangesPanel.tsx @@ -1,12 +1,19 @@ -import { Check, Copy, Crosshair, File, GitBranch, GitCommitHorizontal, History, LoaderCircle, Minus, Plus, RefreshCw, RotateCcw, Timer } from "lucide-react"; +import { Check, ClipboardList, Copy, Crosshair, File, GitBranch, GitCommitHorizontal, GitPullRequest, History, ListChecks, LoaderCircle, Minus, Plus, RefreshCw, RotateCcw, Timer, Wrench } from "lucide-react"; import { useState, type ReactNode } from "react"; import { Badge, Button, Input, Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverTrigger, Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { + PR_STATUS_VISUAL, + PR_TONE_ICON_CLASS, + type PrStatusTone, + type WorkspacePrStatus, +} from "@/lib/pr-status"; import type { SourceControlStatusItem } from "@/lib/source-control-status"; import { cn } from "@/lib/utils"; import { type TurnVerificationResult, + type TurnVerificationStatus, VERIFICATION_STATUS_VISUAL, describeTurnVerification, } from "@/lib/workspace-scripts"; @@ -15,7 +22,267 @@ import { VerificationStatusIcon } from "./VerificationStatusIcon"; import { WorkspaceFileIcon } from "./explorer-entry-icon"; import type { SourceControlItemViewModel, SourceControlSection, SourceControlSummary } from "./editor-panel.utils"; -type SourceControlPanelView = "changes" | "history"; +type SourceControlPanelView = "changes" | "history" | "checks"; + +/** + * Pre-merge roll-up data that is not already carried by the panel's other + * props (verification + intent live in their own props). Computed by the parent + * from workspace information + cached PR status. + */ +export interface WorkspaceChecksViewModel { + prStatus: WorkspacePrStatus; + pr: { number: number; title: string; url: string } | null; + openTodoCount: number; + totalTodoCount: number; + openTodos: string[]; +} + +type ChecksTone = "ok" | "warn" | "fail" | "neutral"; + +const CHECKS_TONE_TEXT: Record = { + ok: "text-success", + warn: "text-warning", + fail: "text-destructive", + neutral: "text-muted-foreground", +}; + +/** Collapse a verification/intent status into the local check tone. */ +function statusToChecksTone(status: TurnVerificationStatus): ChecksTone { + if (status === "pass") return "ok"; + if (status === "warn") return "warn"; + return "fail"; +} + +/** Collapse a GitHub PR tone into the local check tone. */ +function prToneToChecksTone(tone: PrStatusTone): ChecksTone { + if (tone === "open" || tone === "done") return "ok"; + if (tone === "attention") return "warn"; + if (tone === "danger" || tone === "closed") return "fail"; + return "neutral"; +} + +function ChecksSection(args: { + icon: ReactNode; + title: string; + summary: string; + tone: ChecksTone; + children?: ReactNode; +}) { + return ( +
+
+ + {args.icon} + +

{args.title}

+ + {args.summary} + +
+ {args.children} +
+ ); +} + +function ChecksTabContent(props: { + checks: WorkspaceChecksViewModel; + verification?: TurnVerificationResult | null; + intentCompliance?: TurnIntentComplianceResult | null; + sourceControlSummary: SourceControlSummary; + sourceBranch: string; + changedCount: number; + onSelectDiff: (path: string) => Promise; + onFixVerificationWithAgent?: (args?: { scriptId?: string }) => void; +}) { + const verification = props.verification ?? null; + const intent = props.intentCompliance ?? null; + const { stagedCount, workingTreeCount, conflictCount } = + props.sourceControlSummary; + const { openTodoCount, totalTodoCount, openTodos } = props.checks; + const prVisual = PR_STATUS_VISUAL[props.checks.prStatus]; + + const verificationSummary = !verification + ? "Not run" + : verification.status === "pass" + ? "Passed" + : `${verification.failures.length} ${verification.status === "fail" ? "failing" : "warnings"}`; + const intentSummary = !intent + ? "Not run" + : intent.findings.length === 0 + ? "Consistent" + : `${intent.findings.length} to review`; + const treeTone: ChecksTone = + conflictCount > 0 ? "fail" : props.changedCount > 0 ? "neutral" : "ok"; + const treeSummary = + conflictCount > 0 + ? `${conflictCount} conflict${conflictCount === 1 ? "" : "s"}` + : props.changedCount > 0 + ? `${props.changedCount} changed` + : "Clean"; + + return ( +
+ } + title="Pull request" + summary={prVisual.label} + tone={prToneToChecksTone(prVisual.tone)} + > + {props.checks.pr ? ( +

+ + #{props.checks.pr.number} + {" "} + {props.checks.pr.title} +

+ ) : ( +

+ No pull request linked to this branch yet. +

+ )} +
+ + } + title="Verification" + summary={verificationSummary} + tone={verification ? statusToChecksTone(verification.status) : "neutral"} + > + {verification && verification.failures.length > 0 ? ( +
+ {props.onFixVerificationWithAgent ? ( + + ) : null} +
    + {verification.failures.map((failure, index) => ( +
  • +
    + + {failure.blocking ? "blocking" : "warn"} + + {failure.scriptId} + {props.onFixVerificationWithAgent && + verification.failures.length > 1 ? ( + + ) : null} +
    +

    + {failure.message} +

    +
  • + ))} +
+
+ ) : null} +
+ + } + title="Intent guard" + summary={intentSummary} + tone={intent ? statusToChecksTone(intent.status) : "neutral"} + > + {intent && intent.findings.length > 0 ? ( +
    + {intent.findings.map((finding, index) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ + } + title="Working tree" + summary={treeSummary} + tone={treeTone} + > +

+ {props.sourceBranch} + {" · "} + {stagedCount} staged · {workingTreeCount} working tree + {conflictCount > 0 ? ` · ${conflictCount} conflicts` : ""} +

+
+ + } + title="Todos" + summary={ + totalTodoCount === 0 ? "None" : `${openTodoCount} open / ${totalTodoCount}` + } + tone={openTodoCount > 0 ? "warn" : "ok"} + > + {openTodos.length > 0 ? ( +
    + {openTodos.slice(0, 6).map((text, index) => ( +
  • + + {text} +
  • + ))} + {openTodos.length > 6 ? ( +
  • +{openTodos.length - 6} more
  • + ) : null} +
+ ) : null} +
+
+ ); +} interface SourceControlHistoryEntry { hash: string; @@ -260,8 +527,25 @@ export function WorkspaceChangesPanel(props: { onAutoRefreshSecondsChange: (seconds: number) => void; verification?: TurnVerificationResult | null; intentCompliance?: TurnIntentComplianceResult | null; + /** + * Forward failing verification checks back to the agent as the next turn. + * Omit a `scriptId` to fix every failure; pass one to fix a single check. + * When absent, the fix actions are hidden. + */ + onFixVerificationWithAgent?: (args?: { scriptId?: string }) => void; + /** Pre-merge roll-up data for the Checks tab. When absent, the tab is hidden. */ + checks?: WorkspaceChecksViewModel | null; }) { const [view, setView] = useState("changes"); + const verificationFailureCount = props.verification?.failures.length ?? 0; + const showChecksTab = Boolean(props.checks); + // Count the actionable, merge-blocking signals surfaced on the Checks tab. + const checksAttentionCount = + (props.verification && props.verification.status !== "pass" + ? props.verification.failures.length + : 0) + + (props.intentCompliance?.findings.length ?? 0) + + props.sourceControlSummary.conflictCount; const showStageAll = props.sourceControlSummary.workingTreeCount > 0; const showUnstageAll = props.canUnstageAnyChanges; const showComposer = props.filteredScmItems.length > 0 || props.commitMessage.trim().length > 0; @@ -290,6 +574,15 @@ export function WorkspaceChangesPanel(props: { History {props.sourceHistory.length} + {showChecksTab ? ( + + + Checks + {checksAttentionCount > 0 ? ( + {checksAttentionCount} + ) : null} + + ) : null} {props.verification ? ( props.verification.failures.length > 0 ? ( @@ -309,7 +602,24 @@ export function WorkspaceChangesPanel(props: { - Verification +
+ Verification + {props.onFixVerificationWithAgent ? ( + + ) : null} +

{describeTurnVerification(props.verification)}

@@ -332,6 +642,24 @@ export function WorkspaceChangesPanel(props: { {failure.blocking ? "blocking" : "warn"} {failure.scriptId} + {props.onFixVerificationWithAgent && + verificationFailureCount > 1 ? ( + + ) : null}

{failure.message} @@ -634,6 +962,21 @@ export function WorkspaceChangesPanel(props: { )} + + {props.checks ? ( + + + + ) : null} ); } diff --git a/src/lib/fleet/task-status.ts b/src/lib/fleet/task-status.ts index 4205ebf7..adf74913 100644 --- a/src/lib/fleet/task-status.ts +++ b/src/lib/fleet/task-status.ts @@ -2,6 +2,7 @@ import { resolveProviderTurnDisplayState, type ProviderTurnActivitySnapshot, } from "@/lib/providers/turn-status"; +import type { WorkspacePrStatus } from "@/lib/pr-status"; import type { ProviderId } from "@/lib/providers/provider.types"; import { getRespondingProviderId, @@ -152,6 +153,88 @@ export function summarizeFleetRespondingTasks(args: { }; } +// --------------------------------------------------------------------------- +// Workspace lifecycle lanes — a *lifecycle* dimension (backlog → done) that is +// orthogonal to the per-task *runtime* status above. Derived (no schema +// change): a linked PR is the strongest signal, then live/recent work. +// --------------------------------------------------------------------------- + +export type FleetLifecycleStatus = + | "in-progress" + | "in-review" + | "backlog" + | "done"; + +/** Top-to-bottom lane order: live/actionable first, archived (done) last. */ +export const FLEET_LIFECYCLE_DISPLAY_ORDER: readonly FleetLifecycleStatus[] = [ + "in-progress", + "in-review", + "backlog", + "done", +]; + +export const FLEET_LIFECYCLE_LABEL: Record = { + "in-progress": "In progress", + "in-review": "In review", + backlog: "Backlog", + done: "Done", +}; + +/** + * Derive a workspace lifecycle lane from its linked-PR status and task + * activity. Pure + deterministic. PR status wins (it is the clearest lifecycle + * marker); absent a PR we fall back to whether work is live/recent. + */ +export function deriveFleetLifecycleStatus(args: { + prStatus: WorkspacePrStatus | null; + /** Any task currently running / waiting / errored (an active turn). */ + hasRunningTask: boolean; + /** Any task has exchanged messages (work has started at some point). */ + hasRecentActivity: boolean; +}): FleetLifecycleStatus { + if (args.prStatus === "merged" || args.prStatus === "closed_unmerged") { + return "done"; + } + if (args.prStatus && args.prStatus !== "no_pr") { + // An open PR of any state means the work is up for review. + return "in-review"; + } + if (args.hasRunningTask || args.hasRecentActivity) { + return "in-progress"; + } + return "backlog"; +} + +/** + * Group workspaces into lifecycle lanes in display order, dropping empty + * lanes. Workspaces without a reported lifecycle default to `backlog`. Order + * within a lane is preserved from the input. Generic so the renderer can pass + * its own view-model type. + */ +export function groupFleetWorkspacesByLane(args: { + workspaces: T[]; + lifecycleByWorkspaceId: Record; +}): Array<{ lane: FleetLifecycleStatus; workspaces: T[] }> { + const byLane = new Map(); + for (const workspace of args.workspaces) { + const lane = args.lifecycleByWorkspaceId[workspace.id] ?? "backlog"; + const bucket = byLane.get(lane); + if (bucket) { + bucket.push(workspace); + } else { + byLane.set(lane, [workspace]); + } + } + const groups: Array<{ lane: FleetLifecycleStatus; workspaces: T[] }> = []; + for (const lane of FLEET_LIFECYCLE_DISPLAY_ORDER) { + const workspaces = byLane.get(lane); + if (workspaces && workspaces.length > 0) { + groups.push({ lane, workspaces }); + } + } + return groups; +} + export function countFleetAttentionTasks(args: { tasks: FleetTaskStatusTask[]; messagesByTask: Record; diff --git a/src/lib/workspace-scripts/index.ts b/src/lib/workspace-scripts/index.ts index 0ee2ba1c..fbc59f3d 100644 --- a/src/lib/workspace-scripts/index.ts +++ b/src/lib/workspace-scripts/index.ts @@ -53,13 +53,16 @@ export type { FileVerificationStatus, TurnVerificationResult, TurnVerificationStatus, + VerificationFixPromptOptions, VerificationStatusVisual, } from "./verification"; export { buildTurnVerificationResult, + buildVerificationFixPrompt, deriveFileVerificationStatuses, describeTurnVerification, deriveTurnVerificationStatus, + VERIFICATION_FIX_OUTPUT_LIMIT, VERIFICATION_STATUS_VISUAL, } from "./verification"; diff --git a/src/lib/workspace-scripts/verification.ts b/src/lib/workspace-scripts/verification.ts index acd39a56..60f993b1 100644 --- a/src/lib/workspace-scripts/verification.ts +++ b/src/lib/workspace-scripts/verification.ts @@ -122,6 +122,71 @@ export function deriveFileVerificationStatuses(args: { return statuses; } +/** + * Max characters of a single failure's captured output included in a fix + * prompt. Bounds the prompt size so a noisy reporter (e.g. a full test run) + * can't blow past the model's context. + */ +export const VERIFICATION_FIX_OUTPUT_LIMIT = 4000; + +/** + * Truncate captured output to a bounded size, keeping the head (which usually + * names the failing check/file) and the tail (which usually summarizes the + * failures) and marking where the middle was cut. + */ +function boundFailureOutput(output: string, limit: number): string { + if (output.length <= limit) { + return output; + } + const head = Math.floor(limit * 0.4); + const tail = limit - head; + const removed = output.length - limit; + return `${output.slice(0, head)}\n…[truncated ${removed} chars]…\n${output.slice(output.length - tail)}`; +} + +export interface VerificationFixPromptOptions { + /** Limit to a single failing check by `scriptId`; omit to include every failure. */ + scriptId?: string; + /** Max characters of captured output per failure (defaults to {@link VERIFICATION_FIX_OUTPUT_LIMIT}). */ + outputLimit?: number; +} + +/** + * Build a follow-up turn prompt that asks the agent to fix verification + * failures. Provider-agnostic plain text (works for Claude + Codex). Pure and + * deterministic so it is unit-testable; it never submits anything — callers + * submit it explicitly as the next turn. Returns an empty string when there is + * nothing actionable to fix (no matching failure). + */ +export function buildVerificationFixPrompt( + result: TurnVerificationResult, + options: VerificationFixPromptOptions = {}, +): string { + const limit = options.outputLimit ?? VERIFICATION_FIX_OUTPUT_LIMIT; + const failures = options.scriptId + ? result.failures.filter((failure) => failure.scriptId === options.scriptId) + : result.failures; + if (failures.length === 0) { + return ""; + } + const intro = + failures.length === 1 + ? `The \`${failures[0]?.scriptId}\` verification check failed after the last turn. Please fix it.` + : `${failures.length} verification checks failed after the last turn. Please fix them.`; + const blocks = failures.map((failure) => { + const label = failure.blocking ? "blocking" : "warning"; + const header = `### ${failure.scriptId} (${label})`; + const message = failure.message?.trim() ? `\n${failure.message.trim()}` : ""; + const output = failure.output?.trim() + ? `\n\n\`\`\`\n${boundFailureOutput(failure.output.trim(), limit)}\n\`\`\`` + : ""; + return `${header}${message}${output}`; + }); + const outro = + "Make the smallest change that makes the check pass without weakening or skipping the check itself. The verification re-runs automatically after this turn."; + return `${intro}\n\n${blocks.join("\n\n")}\n\n${outro}`; +} + /** Human-readable tooltip describing a turn verification result. */ export function describeTurnVerification(result: TurnVerificationResult): string { if (result.status === "pass") { diff --git a/src/store/app.store.ts b/src/store/app.store.ts index 64f05e2d..770f6c27 100644 --- a/src/store/app.store.ts +++ b/src/store/app.store.ts @@ -53,6 +53,7 @@ import { type ScriptTrigger, type TurnVerificationResult, buildTurnVerificationResult, + buildVerificationFixPrompt, } from "@/lib/workspace-scripts"; import type { AppNotification, @@ -1329,6 +1330,16 @@ interface AppState { mimeType: string; }>; }) => Promise; + /** + * Forward a workspace's failing verification checks back to its agent as the + * next turn. Builds a prompt from the stored {@link TurnVerificationResult} + * (optionally limited to one `scriptId`) and submits it via + * {@link sendUserMessage}. Only ever runs on an explicit user action. + */ + requestVerificationFix: (args: { + workspaceId: string; + scriptId?: string; + }) => Promise; abortTaskTurn: (args: { taskId: string }) => void; resolveApproval: (args: { taskId: string; @@ -9194,6 +9205,17 @@ export const useAppStore = create()( return result; }, + requestVerificationFix: async ({ workspaceId, scriptId }) => { + const result = get().turnVerificationByWorkspace[workspaceId]; + if (!result || result.failures.length === 0 || !result.taskId) { + return { status: "blocked" } satisfies SendUserMessageResult; + } + const content = buildVerificationFixPrompt(result, { scriptId }); + if (!content.trim()) { + return { status: "blocked" } satisfies SendUserMessageResult; + } + return get().sendUserMessage({ taskId: result.taskId, content }); + }, sendUserMessage: async ({ taskId, content, diff --git a/tests/fleet-task-status.test.ts b/tests/fleet-task-status.test.ts index b454f9b4..15abe0d7 100644 --- a/tests/fleet-task-status.test.ts +++ b/tests/fleet-task-status.test.ts @@ -3,6 +3,8 @@ import { classifyTaskStatus, compareFleetTaskStatus, countFleetAttentionTasks, + deriveFleetLifecycleStatus, + groupFleetWorkspacesByLane, hasFleetTaskAttentionStatus, summarizeFleetRespondingTasks, } from "../src/lib/fleet/task-status"; @@ -290,3 +292,113 @@ describe("fleet task status helpers", () => { ).toBe(2); }); }); + +describe("deriveFleetLifecycleStatus", () => { + test("merged or closed PR is done", () => { + expect( + deriveFleetLifecycleStatus({ + prStatus: "merged", + hasRunningTask: true, + hasRecentActivity: true, + }), + ).toBe("done"); + expect( + deriveFleetLifecycleStatus({ + prStatus: "closed_unmerged", + hasRunningTask: false, + hasRecentActivity: false, + }), + ).toBe("done"); + }); + + test("any open PR is in-review (wins over running work)", () => { + for (const prStatus of [ + "draft", + "review_required", + "changes_requested", + "checks_pending", + "checks_failed", + "merge_conflict", + "behind_base", + "ready_to_merge", + ] as const) { + expect( + deriveFleetLifecycleStatus({ + prStatus, + hasRunningTask: true, + hasRecentActivity: true, + }), + ).toBe("in-review"); + } + }); + + test("no PR but live or recent work is in-progress", () => { + expect( + deriveFleetLifecycleStatus({ + prStatus: "no_pr", + hasRunningTask: true, + hasRecentActivity: false, + }), + ).toBe("in-progress"); + expect( + deriveFleetLifecycleStatus({ + prStatus: null, + hasRunningTask: false, + hasRecentActivity: true, + }), + ).toBe("in-progress"); + }); + + test("no PR and no activity is backlog", () => { + expect( + deriveFleetLifecycleStatus({ + prStatus: null, + hasRunningTask: false, + hasRecentActivity: false, + }), + ).toBe("backlog"); + }); +}); + +describe("groupFleetWorkspacesByLane", () => { + const workspaces = [ + { id: "ws-backlog" }, + { id: "ws-progress" }, + { id: "ws-review" }, + { id: "ws-done" }, + { id: "ws-unreported" }, + ]; + + test("orders lanes live-first and defaults unreported to backlog", () => { + const groups = groupFleetWorkspacesByLane({ + workspaces, + lifecycleByWorkspaceId: { + "ws-backlog": "backlog", + "ws-progress": "in-progress", + "ws-review": "in-review", + "ws-done": "done", + }, + }); + expect(groups.map((group) => group.lane)).toEqual([ + "in-progress", + "in-review", + "backlog", + "done", + ]); + const backlog = groups.find((group) => group.lane === "backlog"); + expect(backlog?.workspaces.map((workspace) => workspace.id)).toEqual([ + "ws-backlog", + "ws-unreported", + ]); + }); + + test("drops empty lanes", () => { + const groups = groupFleetWorkspacesByLane({ + workspaces: [{ id: "a" }, { id: "b" }], + lifecycleByWorkspaceId: { a: "in-progress", b: "in-progress" }, + }); + expect(groups).toHaveLength(1); + expect(groups[0]?.lane).toBe("in-progress"); + expect(groups[0]?.workspaces).toHaveLength(2); + }); +}); diff --git a/tests/turn-verification.test.ts b/tests/turn-verification.test.ts index 7f39679b..9824b32a 100644 --- a/tests/turn-verification.test.ts +++ b/tests/turn-verification.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "bun:test"; import { + type TurnVerificationResult, type WorkspaceScriptHookRunSummary, + VERIFICATION_FIX_OUTPUT_LIMIT, buildTurnVerificationResult, + buildVerificationFixPrompt, deriveFileVerificationStatuses, deriveTurnVerificationStatus, } from "@/lib/workspace-scripts"; @@ -80,6 +83,91 @@ describe("buildTurnVerificationResult", () => { }); }); +describe("buildVerificationFixPrompt", () => { + function makeResult( + failures: TurnVerificationResult["failures"], + ): TurnVerificationResult { + return { + workspaceId: "ws-1", + taskId: "task-1", + turnId: "turn-1", + status: "fail", + totalEntries: failures.length + 1, + executedEntries: failures.length, + failures, + completedAt: 1, + }; + } + + it("returns an empty string when there is nothing to fix", () => { + expect(buildVerificationFixPrompt(makeResult([]))).toBe(""); + }); + + it("returns an empty string when the scriptId matches no failure", () => { + const result = makeResult([ + { scriptId: "lint", message: "x", blocking: false }, + ]); + expect(buildVerificationFixPrompt(result, { scriptId: "test" })).toBe(""); + }); + + it("includes the scriptId, label, message, and output for a single failure", () => { + const result = makeResult([ + { + scriptId: "test", + message: "1 test failed", + blocking: true, + output: "FAIL src/a.test.ts > adds numbers", + }, + ]); + const prompt = buildVerificationFixPrompt(result); + expect(prompt).toContain("`test` verification check failed"); + expect(prompt).toContain("### test (blocking)"); + expect(prompt).toContain("1 test failed"); + expect(prompt).toContain("FAIL src/a.test.ts > adds numbers"); + // never weaken the check + expect(prompt).toContain("without weakening or skipping the check"); + }); + + it("includes every failure when no scriptId is given", () => { + const result = makeResult([ + { scriptId: "lint", message: "lint broke", blocking: false }, + { scriptId: "test", message: "test broke", blocking: true }, + ]); + const prompt = buildVerificationFixPrompt(result); + expect(prompt).toContain("2 verification checks failed"); + expect(prompt).toContain("### lint (warning)"); + expect(prompt).toContain("### test (blocking)"); + }); + + it("narrows to a single failure by scriptId", () => { + const result = makeResult([ + { scriptId: "lint", message: "lint broke", blocking: false }, + { scriptId: "test", message: "test broke", blocking: true }, + ]); + const prompt = buildVerificationFixPrompt(result, { scriptId: "test" }); + expect(prompt).toContain("### test (blocking)"); + expect(prompt).not.toContain("### lint"); + }); + + it("bounds captured output and marks the truncation", () => { + const huge = "x".repeat(VERIFICATION_FIX_OUTPUT_LIMIT + 5000); + const result = makeResult([ + { scriptId: "test", message: "boom", blocking: true, output: huge }, + ]); + const prompt = buildVerificationFixPrompt(result); + expect(prompt).toContain("…[truncated 5000 chars]…"); + expect(prompt.length).toBeLessThan(huge.length); + }); + + it("omits the output fence when there is no captured output", () => { + const result = makeResult([ + { scriptId: "format", message: "needs formatting", blocking: false }, + ]); + const prompt = buildVerificationFixPrompt(result); + expect(prompt).not.toContain("```"); + }); +}); + describe("deriveFileVerificationStatuses", () => { const changedPaths = ["src/a.ts", "src/b.ts", "src/c.ts"];