diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts index 89df04a5e2..a247d0ac45 100644 --- a/ui/src/api/execution-workspaces.test.ts +++ b/ui/src/api/execution-workspaces.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockApi = vi.hoisted(() => ({ get: vi.fn(), + post: vi.fn(), })); vi.mock("./client", () => ({ @@ -93,3 +94,30 @@ describe("executionWorkspacesApi.listSummaries", () => { expect(overview.items[0]!.linkedIssues[0]!.updatedAt).toBeInstanceOf(Date); }); }); + +describe("executionWorkspacesApi.reconcile", () => { + beforeEach(() => { + mockApi.post.mockReset(); + mockApi.post.mockResolvedValue({}); + }); + + // Regression pin (PAP-1705): the frontend path must match the reviewed, OpenAPI-documented + // backend contract `POST /execution-workspaces/:id/reconcile-branch` (S4 / PAP-1586). A bare + // `/reconcile` 404s both recovery-card actions. If the two sides drift, this test fails. + it("posts forward reconcile to the /reconcile-branch route", async () => { + await executionWorkspacesApi.reconcile("workspace-1", { mode: "forward" }); + + expect(mockApi.post).toHaveBeenCalledWith("/execution-workspaces/workspace-1/reconcile-branch", { + mode: "forward", + }); + }); + + it("posts break-glass override reconcile to the /reconcile-branch route", async () => { + await executionWorkspacesApi.reconcile("workspace-1", { mode: "override", reason: "operator note" }); + + expect(mockApi.post).toHaveBeenCalledWith("/execution-workspaces/workspace-1/reconcile-branch", { + mode: "override", + reason: "operator note", + }); + }); +}); diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index 9b737d74df..5ca8c49f37 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -115,4 +115,21 @@ export const executionWorkspacesApi = { sanitizeWorkspaceRuntimeControlTarget(target), ), update: (id: string, data: Record) => api.patch(`/execution-workspaces/${id}`, data), + /** + * Reconcile a git-worktree branch divergence via the S4 (`PAP-1586`) op. + * + * Hits `POST /execution-workspaces/:id/reconcile-branch`. That route is the reviewed, + * OpenAPI-documented backend contract and already ships on `master`: it was merged ahead of this + * client change in `server/src/routes/execution-workspaces.ts` (route registration: + * `router.post("/execution-workspaces/:id/reconcile-branch", ...)`, landed in PR #9170, with the + * `forward` auto-reconcile path in PR #9172). This client is therefore additive against an + * existing endpoint, not a call to a missing one. Keep this path byte-identical to the backend + * route; the drift is pinned by a regression test in `execution-workspaces.test.ts`. + * - `mode: "forward"` — server re-verifies `ancestryVerdict === "ancestor"` (client hint is + * never trusted); no `reason` needed. + * - `mode: "override"` — audited break-glass; the server rejects agent actors, re-checks + * `runtime:manage` permission, and requires a non-empty operator `reason`. + */ + reconcile: (id: string, body: { mode: "forward" } | { mode: "override"; reason: string }) => + api.post(`/execution-workspaces/${id}/reconcile-branch`, body), }; diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index b9e6fe1155..a2067c4574 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -428,6 +428,10 @@ interface IssueChatThreadProps { onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void; onReissueIsolatedRecoveryAction?: (request: RecoveryReissueRequest) => void; reissueIsolatedRecoveryActionPending?: boolean; + onReconcileForwardRecoveryAction?: () => void; + onBreakGlassOverrideRecoveryAction?: (reason: string) => void; + canBreakGlassRecoveryAction?: boolean; + reconcileRecoveryActionPending?: boolean; canFalsePositiveRecoveryAction?: boolean; legacyRecoverySourceIssue?: { identifier: string | null; @@ -4166,6 +4170,10 @@ export function IssueChatThread({ onResolveRecoveryAction, onReissueIsolatedRecoveryAction, reissueIsolatedRecoveryActionPending = false, + onReconcileForwardRecoveryAction, + onBreakGlassOverrideRecoveryAction, + canBreakGlassRecoveryAction = false, + reconcileRecoveryActionPending = false, canFalsePositiveRecoveryAction = false, legacyRecoverySourceIssue = null, companyId, @@ -4881,6 +4889,10 @@ export function IssueChatThread({ onResolve={onResolveRecoveryAction} onReissueIsolated={onReissueIsolatedRecoveryAction} reissuePending={reissueIsolatedRecoveryActionPending} + onReconcileForward={onReconcileForwardRecoveryAction} + onBreakGlassOverride={onBreakGlassOverrideRecoveryAction} + canBreakGlass={canBreakGlassRecoveryAction} + reconcilePending={reconcileRecoveryActionPending} canFalsePositive={canFalsePositiveRecoveryAction} /> ) : null} diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index 9f110e2058..e1c6cd665b 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -378,3 +378,127 @@ describe("IssueRecoveryActionCard workspace_validation divergence", () => { expect(trigger?.disabled).toBe(true); }); }); + +function setTextareaValue(element: HTMLTextAreaElement | null, value: string) { + if (!element) throw new Error("Expected a textarea to exist"); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + act(() => { + setter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +describe("IssueRecoveryActionCard W7 reconcile actions", () => { + it("offers 'Reconcile forward & continue' only for an ancestor verdict and calls the handler", () => { + const onReconcileForward = vi.fn(); + const node = render( + , + ); + const button = node.querySelector("[data-testid='recovery-action-reconcile-forward']"); + expect(button).not.toBeNull(); + click(button); + expect(onReconcileForward).toHaveBeenCalledTimes(1); + }); + + it("hides 'Reconcile forward & continue' when the verdict is not an ancestor", () => { + const node = render( + {}} + />, + ); + expect(node.querySelector("[data-testid='recovery-action-reconcile-forward']")).toBeNull(); + }); + + it("disables reconcile-forward while a reconcile is pending", () => { + const node = render( + {}} + reconcilePending + />, + ); + const button = node.querySelector("[data-testid='recovery-action-reconcile-forward']"); + expect(button?.disabled).toBe(true); + }); + + it("never renders the break-glass action for a non-permitted operator", () => { + const node = render( + {}} + canBreakGlass={false} + />, + ); + expect(node.querySelector("[data-testid='recovery-action-breakglass-trigger']")).toBeNull(); + }); + + it("break-glass restates the divergence and gates the override behind a required reason", () => { + const onBreakGlassOverride = vi.fn(); + const node = render( + , + ); + click(node.querySelector("[data-testid='recovery-action-breakglass-trigger']")); + + // The confirm step restates the divergence: both branches, both short SHAs, and the verdict. + const restated = document.body.querySelector("[data-testid='recovery-breakglass-restated-divergence']"); + const restatedText = restated?.textContent ?? ""; + expect(restatedText).toContain("PAP-522-recorded"); + expect(restatedText).toContain("nleach/PAP-1405-live"); + expect(restatedText).toContain("aaaaaaaaaa"); + expect(restatedText).toContain("bbbbbbbbbb"); + expect(restatedText).toContain("Diverged"); + + // The override is disabled until a non-empty reason is recorded. + const confirm = document.body.querySelector( + "[data-testid='recovery-action-breakglass-confirm']", + ); + expect(confirm?.disabled).toBe(true); + click(confirm); + expect(onBreakGlassOverride).not.toHaveBeenCalled(); + + // Whitespace-only reason does not enable it. + setTextareaValue( + document.body.querySelector("[data-testid='recovery-breakglass-reason']"), + " ", + ); + expect( + document.body.querySelector("[data-testid='recovery-action-breakglass-confirm']")?.disabled, + ).toBe(true); + + // A real reason enables the override and is passed (trimmed) to the handler. + setTextareaValue( + document.body.querySelector("[data-testid='recovery-breakglass-reason']"), + " Verified live branch is safe to adopt. ", + ); + const enabledConfirm = document.body.querySelector( + "[data-testid='recovery-action-breakglass-confirm']", + ); + expect(enabledConfirm?.disabled).toBe(false); + click(enabledConfirm); + expect(onBreakGlassOverride).toHaveBeenCalledWith("Verified live branch is safe to adopt."); + }); + + it("does not offer reconcile actions for non-workspace recovery kinds", () => { + const node = render( + {}} + onBreakGlassOverride={() => {}} + canBreakGlass + />, + ); + expect(node.querySelector("[data-testid='recovery-action-reconcile-forward']")).toBeNull(); + expect(node.querySelector("[data-testid='recovery-action-breakglass-trigger']")).toBeNull(); + }); +}); diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index 3181361d58..8df5c7385d 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { Agent, GitWorktreeBranchAncestryVerdict, @@ -19,11 +19,13 @@ import { } from "lucide-react"; import { Link } from "@/lib/router"; import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; import { agentUrl } from "@/lib/utils"; import { cn } from "@/lib/utils"; import { @@ -68,6 +70,27 @@ export interface IssueRecoveryActionCardProps { onReissueIsolated?: (request: RecoveryReissueRequest) => void; /** Whether an isolated re-issue is currently in flight (disables the action + shows a spinner). */ reissuePending?: boolean; + /** + * Handler for action 1 — "Reconcile forward & continue" (workspace_validation only). Rendered + * only for an ancestry-proven (`ancestor`) git-worktree divergence; the caller invokes the S4 + * reconcile op in `forward` mode, which re-verifies ancestry server-side (the client hint is + * never trusted). If omitted, the button is not shown. + */ + onReconcileForward?: () => void; + /** + * Handler for action 2 — the audited break-glass override (workspace_validation only). Receives + * the operator's required, non-empty reason and invokes the S4 reconcile op in `override` mode. + * Rendered only when `canBreakGlass` is true AND this handler is provided; the server independently + * rejects agent actors and re-checks runtime-manage permission, so UI hiding is defense-in-depth. + */ + onBreakGlassOverride?: (reason: string) => void; + /** + * Whether the viewer may run the permission-gated break-glass override. When false, action 2 is + * not rendered at all — a non-permitted user never sees the "reconcile anyway" affordance. + */ + canBreakGlass?: boolean; + /** Whether a reconcile (forward or override) is currently in flight (disables both actions). */ + reconcilePending?: boolean; /** Whether the viewer can run destructive board-only actions (e.g. false-positive dismissal). */ canFalsePositive?: boolean; className?: string; @@ -360,6 +383,120 @@ function DivergenceDiagnosis({ ); } +/** + * Action 2 — the audited break-glass override. Gated by an explicit confirm step that *restates the + * divergence* (both branches + short SHAs + ancestry verdict) and a required, non-empty reason: the + * confirm button stays disabled until the operator records why. The server re-checks the actor and + * permission and appends the reason to the audit log — this UI gate is the operator-facing guardrail, + * not the security boundary. + */ +function BreakGlassOverride({ + divergence, + onConfirm, + pending, +}: { + divergence: WorkspaceDivergence; + onConfirm: (reason: string) => void; + pending: boolean; +}) { + const [reason, setReason] = useState(""); + const trimmedReason = reason.trim(); + const canSubmit = trimmedReason.length > 0 && !pending; + const verdictBadge = ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"]; + const expectedSha = formatShortSha(divergence.expectedHeadSha); + const liveSha = formatShortSha(divergence.liveHeadSha); + return ( + + + + + +
+
+ + Break-glass reconciliation +
+

+ This overrides Paperclip's safety check and points the recorded workspace at the live + branch{" "} + without an ancestry proof. Confirm + the divergence below and record why before continuing. +

+
+
+
+
Recorded · expected
+
+ {divergence.expectedBranch ?? "detached"} + {expectedSha ? ` @ ${expectedSha}` : ""} +
+
+
+
Live · checked out
+
+ {divergence.liveBranch ?? "detached"} + {liveSha ? ` @ ${liveSha}` : ""} +
+
+
+
Ancestry verdict
+
{verdictBadge.label}
+
+
+
+ +