feat(recovery-card): W7 reconcile-forward + break-glass actions
Adds the task-page recovery affordance for workspace branch divergence: reconcile-forward when ancestry is safe, and a break-glass override flow that requires explicit confirmation and a reason. Includes client wiring for the existing reconcile-branch endpoint, issue-detail refresh after successful reconciliation, runtime-management gating for break-glass, and tests for action visibility, payloads, permission gating, and workspace-target selection. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
83f5f59842
commit
4f5abf6007
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mockApi = vi.hoisted(() => ({
|
const mockApi = vi.hoisted(() => ({
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./client", () => ({
|
vi.mock("./client", () => ({
|
||||||
|
|
@ -93,3 +94,30 @@ describe("executionWorkspacesApi.listSummaries", () => {
|
||||||
expect(overview.items[0]!.linkedIssues[0]!.updatedAt).toBeInstanceOf(Date);
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -115,4 +115,21 @@ export const executionWorkspacesApi = {
|
||||||
sanitizeWorkspaceRuntimeControlTarget(target),
|
sanitizeWorkspaceRuntimeControlTarget(target),
|
||||||
),
|
),
|
||||||
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
|
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/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<ExecutionWorkspace>(`/execution-workspaces/${id}/reconcile-branch`, body),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -428,6 +428,10 @@ interface IssueChatThreadProps {
|
||||||
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
|
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
|
||||||
onReissueIsolatedRecoveryAction?: (request: RecoveryReissueRequest) => void;
|
onReissueIsolatedRecoveryAction?: (request: RecoveryReissueRequest) => void;
|
||||||
reissueIsolatedRecoveryActionPending?: boolean;
|
reissueIsolatedRecoveryActionPending?: boolean;
|
||||||
|
onReconcileForwardRecoveryAction?: () => void;
|
||||||
|
onBreakGlassOverrideRecoveryAction?: (reason: string) => void;
|
||||||
|
canBreakGlassRecoveryAction?: boolean;
|
||||||
|
reconcileRecoveryActionPending?: boolean;
|
||||||
canFalsePositiveRecoveryAction?: boolean;
|
canFalsePositiveRecoveryAction?: boolean;
|
||||||
legacyRecoverySourceIssue?: {
|
legacyRecoverySourceIssue?: {
|
||||||
identifier: string | null;
|
identifier: string | null;
|
||||||
|
|
@ -4166,6 +4170,10 @@ export function IssueChatThread({
|
||||||
onResolveRecoveryAction,
|
onResolveRecoveryAction,
|
||||||
onReissueIsolatedRecoveryAction,
|
onReissueIsolatedRecoveryAction,
|
||||||
reissueIsolatedRecoveryActionPending = false,
|
reissueIsolatedRecoveryActionPending = false,
|
||||||
|
onReconcileForwardRecoveryAction,
|
||||||
|
onBreakGlassOverrideRecoveryAction,
|
||||||
|
canBreakGlassRecoveryAction = false,
|
||||||
|
reconcileRecoveryActionPending = false,
|
||||||
canFalsePositiveRecoveryAction = false,
|
canFalsePositiveRecoveryAction = false,
|
||||||
legacyRecoverySourceIssue = null,
|
legacyRecoverySourceIssue = null,
|
||||||
companyId,
|
companyId,
|
||||||
|
|
@ -4881,6 +4889,10 @@ export function IssueChatThread({
|
||||||
onResolve={onResolveRecoveryAction}
|
onResolve={onResolveRecoveryAction}
|
||||||
onReissueIsolated={onReissueIsolatedRecoveryAction}
|
onReissueIsolated={onReissueIsolatedRecoveryAction}
|
||||||
reissuePending={reissueIsolatedRecoveryActionPending}
|
reissuePending={reissueIsolatedRecoveryActionPending}
|
||||||
|
onReconcileForward={onReconcileForwardRecoveryAction}
|
||||||
|
onBreakGlassOverride={onBreakGlassOverrideRecoveryAction}
|
||||||
|
canBreakGlass={canBreakGlassRecoveryAction}
|
||||||
|
reconcilePending={reconcileRecoveryActionPending}
|
||||||
canFalsePositive={canFalsePositiveRecoveryAction}
|
canFalsePositive={canFalsePositiveRecoveryAction}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -378,3 +378,127 @@ describe("IssueRecoveryActionCard workspace_validation divergence", () => {
|
||||||
expect(trigger?.disabled).toBe(true);
|
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(
|
||||||
|
<IssueRecoveryActionCard
|
||||||
|
action={buildWorkspaceValidationAction({ provenance: { ancestryVerdict: "ancestor" } })}
|
||||||
|
onReconcileForward={onReconcileForward}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<IssueRecoveryActionCard
|
||||||
|
action={buildWorkspaceValidationAction({ provenance: { ancestryVerdict: "diverged" } })}
|
||||||
|
onReconcileForward={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(node.querySelector("[data-testid='recovery-action-reconcile-forward']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables reconcile-forward while a reconcile is pending", () => {
|
||||||
|
const node = render(
|
||||||
|
<IssueRecoveryActionCard
|
||||||
|
action={buildWorkspaceValidationAction({ provenance: { ancestryVerdict: "ancestor" } })}
|
||||||
|
onReconcileForward={() => {}}
|
||||||
|
reconcilePending
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const button = node.querySelector<HTMLButtonElement>("[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(
|
||||||
|
<IssueRecoveryActionCard
|
||||||
|
action={buildWorkspaceValidationAction()}
|
||||||
|
onBreakGlassOverride={() => {}}
|
||||||
|
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(
|
||||||
|
<IssueRecoveryActionCard
|
||||||
|
action={buildWorkspaceValidationAction()}
|
||||||
|
onBreakGlassOverride={onBreakGlassOverride}
|
||||||
|
canBreakGlass
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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<HTMLButtonElement>(
|
||||||
|
"[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<HTMLTextAreaElement>("[data-testid='recovery-breakglass-reason']"),
|
||||||
|
" ",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
document.body.querySelector<HTMLButtonElement>("[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<HTMLTextAreaElement>("[data-testid='recovery-breakglass-reason']"),
|
||||||
|
" Verified live branch is safe to adopt. ",
|
||||||
|
);
|
||||||
|
const enabledConfirm = document.body.querySelector<HTMLButtonElement>(
|
||||||
|
"[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(
|
||||||
|
<IssueRecoveryActionCard
|
||||||
|
action={buildAction()}
|
||||||
|
onReconcileForward={() => {}}
|
||||||
|
onBreakGlassOverride={() => {}}
|
||||||
|
canBreakGlass
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(node.querySelector("[data-testid='recovery-action-reconcile-forward']")).toBeNull();
|
||||||
|
expect(node.querySelector("[data-testid='recovery-action-breakglass-trigger']")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
GitWorktreeBranchAncestryVerdict,
|
GitWorktreeBranchAncestryVerdict,
|
||||||
|
|
@ -19,11 +19,13 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { agentUrl } from "@/lib/utils";
|
import { agentUrl } from "@/lib/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
|
|
@ -68,6 +70,27 @@ export interface IssueRecoveryActionCardProps {
|
||||||
onReissueIsolated?: (request: RecoveryReissueRequest) => void;
|
onReissueIsolated?: (request: RecoveryReissueRequest) => void;
|
||||||
/** Whether an isolated re-issue is currently in flight (disables the action + shows a spinner). */
|
/** Whether an isolated re-issue is currently in flight (disables the action + shows a spinner). */
|
||||||
reissuePending?: boolean;
|
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). */
|
/** Whether the viewer can run destructive board-only actions (e.g. false-positive dismissal). */
|
||||||
canFalsePositive?: boolean;
|
canFalsePositive?: boolean;
|
||||||
className?: string;
|
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 (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending}
|
||||||
|
data-testid="recovery-action-breakglass-trigger"
|
||||||
|
className="border-red-400/60 text-red-700 hover:bg-red-500/10 dark:border-red-500/40 dark:text-red-300"
|
||||||
|
>
|
||||||
|
<OctagonAlert className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
I've verified this — reconcile anyway
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
sideOffset={6}
|
||||||
|
aria-labelledby="recovery-breakglass-title"
|
||||||
|
className="w-96 max-w-[calc(100vw-2rem)] space-y-3 p-3"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div
|
||||||
|
id="recovery-breakglass-title"
|
||||||
|
className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-red-700 dark:text-red-300"
|
||||||
|
>
|
||||||
|
<OctagonAlert className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
Break-glass reconciliation
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] leading-5 text-muted-foreground">
|
||||||
|
This overrides Paperclip's safety check and points the recorded workspace at the live
|
||||||
|
branch{" "}
|
||||||
|
<span className="font-medium text-foreground/80">without an ancestry proof</span>. Confirm
|
||||||
|
the divergence below and record why before continuing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl
|
||||||
|
data-testid="recovery-breakglass-restated-divergence"
|
||||||
|
className="space-y-1.5 rounded-md border border-red-400/40 bg-red-500/5 px-2.5 py-2 text-[11px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<dt className="shrink-0 text-muted-foreground">Recorded · expected</dt>
|
||||||
|
<dd className="min-w-0 truncate font-mono text-foreground/90">
|
||||||
|
{divergence.expectedBranch ?? "detached"}
|
||||||
|
{expectedSha ? ` @ ${expectedSha}` : ""}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<dt className="shrink-0 text-muted-foreground">Live · checked out</dt>
|
||||||
|
<dd className="min-w-0 truncate font-mono text-foreground/90">
|
||||||
|
{divergence.liveBranch ?? "detached"}
|
||||||
|
{liveSha ? ` @ ${liveSha}` : ""}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<dt className="shrink-0 text-muted-foreground">Ancestry verdict</dt>
|
||||||
|
<dd className="font-medium">{verdictBadge.label}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="recovery-breakglass-reason" className="text-[11px] text-muted-foreground">
|
||||||
|
Reason <span className="text-red-600 dark:text-red-400">(required — recorded in the audit log)</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="recovery-breakglass-reason"
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
placeholder="e.g. Verified the live branch carries only the intended follow-up commits; safe to adopt."
|
||||||
|
className="min-h-20 text-[12px]"
|
||||||
|
data-testid="recovery-breakglass-reason"
|
||||||
|
aria-required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
data-testid="recovery-action-breakglass-confirm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
onConfirm(trimmedReason);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pending ? "Reconciling…" : "Reconcile anyway (break-glass)"}
|
||||||
|
</Button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function readWakePolicySummary(action: IssueRecoveryAction): string | null {
|
function readWakePolicySummary(action: IssueRecoveryAction): string | null {
|
||||||
const policy = action.wakePolicy;
|
const policy = action.wakePolicy;
|
||||||
if (!policy) return null;
|
if (!policy) return null;
|
||||||
|
|
@ -531,6 +668,10 @@ export function IssueRecoveryActionCard({
|
||||||
onResolve,
|
onResolve,
|
||||||
onReissueIsolated,
|
onReissueIsolated,
|
||||||
reissuePending = false,
|
reissuePending = false,
|
||||||
|
onReconcileForward,
|
||||||
|
onBreakGlassOverride,
|
||||||
|
canBreakGlass = false,
|
||||||
|
reconcilePending = false,
|
||||||
canFalsePositive = false,
|
canFalsePositive = false,
|
||||||
className,
|
className,
|
||||||
}: IssueRecoveryActionCardProps) {
|
}: IssueRecoveryActionCardProps) {
|
||||||
|
|
@ -585,7 +726,22 @@ export function IssueRecoveryActionCard({
|
||||||
const reissueVerdictBadge = divergence
|
const reissueVerdictBadge = divergence
|
||||||
? ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"]
|
? ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"]
|
||||||
: null;
|
: null;
|
||||||
const showFooter = showResolveActions || showReissueAction;
|
// Action 1 — the ancestry-proven safe path. Only offered when the server-computed verdict is
|
||||||
|
// "ancestor"; the server re-verifies before mutating, so this gate mirrors (not replaces) it.
|
||||||
|
const showReconcileForward =
|
||||||
|
onReconcileForward !== undefined &&
|
||||||
|
cardState !== "resolved" &&
|
||||||
|
divergence !== null &&
|
||||||
|
divergence.ancestryVerdict === "ancestor";
|
||||||
|
// Action 2 — the break-glass override. Permission-hidden: absent entirely unless the viewer is a
|
||||||
|
// permitted operator. The confirm step (restated divergence + required reason) lives in the popover.
|
||||||
|
const showBreakGlass =
|
||||||
|
onBreakGlassOverride !== undefined &&
|
||||||
|
cardState !== "resolved" &&
|
||||||
|
divergence !== null &&
|
||||||
|
canBreakGlass;
|
||||||
|
const showFooter =
|
||||||
|
showResolveActions || showReissueAction || showReconcileForward || showBreakGlass;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|
@ -739,6 +895,23 @@ export function IssueRecoveryActionCard({
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showReconcileForward ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
disabled={reconcilePending}
|
||||||
|
data-testid="recovery-action-reconcile-forward"
|
||||||
|
onClick={() => onReconcileForward?.()}
|
||||||
|
>
|
||||||
|
{reconcilePending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
Reconcile forward & continue
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{showReissueAction && divergence && reissueBaseRef ? (
|
{showReissueAction && divergence && reissueBaseRef ? (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -805,6 +978,13 @@ export function IssueRecoveryActionCard({
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showBreakGlass && divergence ? (
|
||||||
|
<BreakGlassOverride
|
||||||
|
divergence={divergence}
|
||||||
|
pending={reconcilePending}
|
||||||
|
onConfirm={(reason) => onBreakGlassOverride?.(reason)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{showResolveActions ? (
|
{showResolveActions ? (
|
||||||
cardState === "observe_only" ? (
|
cardState === "observe_only" ? (
|
||||||
<span className="text-(length:--text-micro) text-muted-foreground">
|
<span className="text-(length:--text-micro) text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import { flushSync } from "react-dom";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
canBoardManageRuntime,
|
||||||
canBoardResolveRecoveryAction,
|
canBoardResolveRecoveryAction,
|
||||||
IssueDetail,
|
IssueDetail,
|
||||||
|
readRecoveryReconcileWorkspaceId,
|
||||||
shouldScrollIssueDetailToTopOnNavigation,
|
shouldScrollIssueDetailToTopOnNavigation,
|
||||||
} from "./IssueDetail";
|
} from "./IssueDetail";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
@ -2510,6 +2512,132 @@ describe("canBoardResolveRecoveryAction", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("canBoardManageRuntime", () => {
|
||||||
|
it("falls back to companyIds when memberships are not populated", () => {
|
||||||
|
expect(
|
||||||
|
canBoardManageRuntime("company-1", {
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
memberships: [],
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
source: "session",
|
||||||
|
keyId: null,
|
||||||
|
user: null,
|
||||||
|
userId: "user-1",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies viewers the runtime-manage-gated break-glass affordance", () => {
|
||||||
|
expect(
|
||||||
|
canBoardManageRuntime("company-1", {
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
memberships: [
|
||||||
|
{
|
||||||
|
companyId: "company-1",
|
||||||
|
membershipRole: "viewer",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
source: "session",
|
||||||
|
keyId: null,
|
||||||
|
user: null,
|
||||||
|
userId: "user-1",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows non-viewer active members (mirrors the backend runtime:manage member gate)", () => {
|
||||||
|
expect(
|
||||||
|
canBoardManageRuntime("company-1", {
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
memberships: [
|
||||||
|
{
|
||||||
|
companyId: "company-1",
|
||||||
|
membershipRole: "operator",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
source: "session",
|
||||||
|
keyId: null,
|
||||||
|
user: null,
|
||||||
|
userId: "user-1",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readRecoveryReconcileWorkspaceId", () => {
|
||||||
|
const makeAction = (evidence: Record<string, unknown>, kind = "workspace_validation") =>
|
||||||
|
({ kind, evidence } as unknown as Parameters<typeof readRecoveryReconcileWorkspaceId>[0]);
|
||||||
|
|
||||||
|
it("returns null when the action is missing", () => {
|
||||||
|
expect(readRecoveryReconcileWorkspaceId(null)).toBeNull();
|
||||||
|
expect(readRecoveryReconcileWorkspaceId(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-workspace_validation actions even with a workspace id in evidence", () => {
|
||||||
|
expect(
|
||||||
|
readRecoveryReconcileWorkspaceId(
|
||||||
|
makeAction(
|
||||||
|
{ workspaceValidation: { persistedExecutionWorkspaceId: "ws-1" } },
|
||||||
|
"stranded_assigned_issue",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers persistedExecutionWorkspaceId (git_worktree_branch_incoherence shape)", () => {
|
||||||
|
expect(
|
||||||
|
readRecoveryReconcileWorkspaceId(
|
||||||
|
makeAction({
|
||||||
|
workspaceValidation: {
|
||||||
|
reason: "git_worktree_branch_incoherence",
|
||||||
|
persistedExecutionWorkspaceId: "ws-diverged",
|
||||||
|
executionWorkspaceId: "ws-other",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("ws-diverged");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to executionWorkspaceId (git_worktree_not_reusable shape)", () => {
|
||||||
|
expect(
|
||||||
|
readRecoveryReconcileWorkspaceId(
|
||||||
|
makeAction({
|
||||||
|
workspaceValidation: {
|
||||||
|
reason: "git_worktree_not_reusable",
|
||||||
|
executionWorkspaceId: "ws-not-reusable",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("ws-not-reusable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the evidence carries no workspace reference (so the caller falls back to the page-level id)", () => {
|
||||||
|
expect(readRecoveryReconcileWorkspaceId(makeAction({}))).toBeNull();
|
||||||
|
expect(
|
||||||
|
readRecoveryReconcileWorkspaceId(
|
||||||
|
makeAction({ workspaceValidation: { reason: "git_worktree_branch_incoherence" } }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-string / empty workspace ids", () => {
|
||||||
|
expect(
|
||||||
|
readRecoveryReconcileWorkspaceId(
|
||||||
|
makeAction({ workspaceValidation: { persistedExecutionWorkspaceId: "" } }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
readRecoveryReconcileWorkspaceId(
|
||||||
|
makeAction({ workspaceValidation: { persistedExecutionWorkspaceId: 42 } }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("shouldScrollIssueDetailToTopOnNavigation", () => {
|
describe("shouldScrollIssueDetailToTopOnNavigation", () => {
|
||||||
it("does not scroll when only URL search params changed for the same issue", () => {
|
it("does not scroll when only URL search params changed for the same issue", () => {
|
||||||
expect(shouldScrollIssueDetailToTopOnNavigation({
|
expect(shouldScrollIssueDetailToTopOnNavigation({
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { accessApi, type CurrentBoardAccess } from "../api/access";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialogActions } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
|
|
@ -182,6 +183,7 @@ import {
|
||||||
type Agent,
|
type Agent,
|
||||||
type FeedbackVote,
|
type FeedbackVote,
|
||||||
type Issue,
|
type Issue,
|
||||||
|
type IssueRecoveryAction,
|
||||||
type IssueAttachment,
|
type IssueAttachment,
|
||||||
type IssueComment,
|
type IssueComment,
|
||||||
type IssueWorkProduct,
|
type IssueWorkProduct,
|
||||||
|
|
@ -290,6 +292,60 @@ export function canBoardResolveRecoveryAction(
|
||||||
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
|
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort client mirror of the backend `runtime:manage` gate that the break-glass override
|
||||||
|
* reconcile (`POST /execution-workspaces/:id/reconcile-branch` in `override` mode) actually
|
||||||
|
* enforces. The server re-checks `runtime:manage` for every reconcile and is authoritative, so
|
||||||
|
* this is defense-in-depth: it hides the "reconcile anyway" affordance from viewers rather than
|
||||||
|
* showing a button that always 403s. For human board members `runtime:manage` grants on the
|
||||||
|
* same non-viewer, active-membership condition as recovery resolution (see
|
||||||
|
* `server/src/services/authorization.ts`), so the shape matches; per-permission-key overrides
|
||||||
|
* are not surfaced to the client and remain the server's call.
|
||||||
|
*/
|
||||||
|
export function canBoardManageRuntime(
|
||||||
|
companyId: string | null | undefined,
|
||||||
|
boardAccess: CurrentBoardAccess | undefined,
|
||||||
|
) {
|
||||||
|
if (!companyId || !boardAccess) return false;
|
||||||
|
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
|
||||||
|
if (!boardAccess.memberships || boardAccess.memberships.length === 0) {
|
||||||
|
return boardAccess.companyIds.includes(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = boardAccess.memberships.find(
|
||||||
|
(item) => item.companyId === companyId && item.status === "active",
|
||||||
|
);
|
||||||
|
if (!membership) return false;
|
||||||
|
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The execution workspace a reconcile action should target. The recovery card is rendered from a
|
||||||
|
* specific `workspace_validation` recovery action whose evidence pins the workspace that diverged;
|
||||||
|
* that workspace — not the page-level `issue.executionWorkspaceId` — is the authoritative target.
|
||||||
|
* The page-level id can drift (e.g. a re-issue rebinds the issue to a new workspace) while the card
|
||||||
|
* still shows the older action, so we prefer the action's evidence and only fall back to the
|
||||||
|
* page-level id when the evidence carries no workspace reference.
|
||||||
|
*
|
||||||
|
* The branch-incoherence failure (the one that renders the reconcile-forward / break-glass actions)
|
||||||
|
* records the workspace under `persistedExecutionWorkspaceId`; the not-reusable failure records it
|
||||||
|
* under `executionWorkspaceId`. We accept either key so both divergence shapes pin correctly.
|
||||||
|
*/
|
||||||
|
export function readRecoveryReconcileWorkspaceId(
|
||||||
|
action: IssueRecoveryAction | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!action || action.kind !== "workspace_validation") return null;
|
||||||
|
const workspaceValidation = asRecord(action.evidence?.workspaceValidation);
|
||||||
|
if (!workspaceValidation) return null;
|
||||||
|
const persisted = workspaceValidation.persistedExecutionWorkspaceId;
|
||||||
|
if (typeof persisted === "string" && persisted.length > 0) return persisted;
|
||||||
|
const executionWorkspaceId = workspaceValidation.executionWorkspaceId;
|
||||||
|
if (typeof executionWorkspaceId === "string" && executionWorkspaceId.length > 0) {
|
||||||
|
return executionWorkspaceId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldScrollIssueDetailToTopOnNavigation(input: {
|
export function shouldScrollIssueDetailToTopOnNavigation(input: {
|
||||||
previousIssueId: string | undefined;
|
previousIssueId: string | undefined;
|
||||||
nextIssueId: string | undefined;
|
nextIssueId: string | undefined;
|
||||||
|
|
@ -837,6 +893,10 @@ type IssueDetailChatTabProps = {
|
||||||
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
|
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
|
||||||
onReissueIsolatedRecoveryAction?: (request: import("../components/IssueRecoveryActionCard").RecoveryReissueRequest) => void;
|
onReissueIsolatedRecoveryAction?: (request: import("../components/IssueRecoveryActionCard").RecoveryReissueRequest) => void;
|
||||||
reissueIsolatedRecoveryActionPending?: boolean;
|
reissueIsolatedRecoveryActionPending?: boolean;
|
||||||
|
onReconcileForwardRecoveryAction?: () => void;
|
||||||
|
onBreakGlassOverrideRecoveryAction?: (reason: string) => void;
|
||||||
|
canBreakGlassRecoveryAction?: boolean;
|
||||||
|
reconcileRecoveryActionPending?: boolean;
|
||||||
canFalsePositiveRecoveryAction?: boolean;
|
canFalsePositiveRecoveryAction?: boolean;
|
||||||
legacyRecoverySourceIssue?: {
|
legacyRecoverySourceIssue?: {
|
||||||
identifier: string | null;
|
identifier: string | null;
|
||||||
|
|
@ -916,6 +976,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||||
onResolveRecoveryAction,
|
onResolveRecoveryAction,
|
||||||
onReissueIsolatedRecoveryAction,
|
onReissueIsolatedRecoveryAction,
|
||||||
reissueIsolatedRecoveryActionPending,
|
reissueIsolatedRecoveryActionPending,
|
||||||
|
onReconcileForwardRecoveryAction,
|
||||||
|
onBreakGlassOverrideRecoveryAction,
|
||||||
|
canBreakGlassRecoveryAction,
|
||||||
|
reconcileRecoveryActionPending,
|
||||||
canFalsePositiveRecoveryAction,
|
canFalsePositiveRecoveryAction,
|
||||||
legacyRecoverySourceIssue,
|
legacyRecoverySourceIssue,
|
||||||
comments,
|
comments,
|
||||||
|
|
@ -1135,6 +1199,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||||
onResolveRecoveryAction={onResolveRecoveryAction}
|
onResolveRecoveryAction={onResolveRecoveryAction}
|
||||||
onReissueIsolatedRecoveryAction={onReissueIsolatedRecoveryAction}
|
onReissueIsolatedRecoveryAction={onReissueIsolatedRecoveryAction}
|
||||||
reissueIsolatedRecoveryActionPending={reissueIsolatedRecoveryActionPending}
|
reissueIsolatedRecoveryActionPending={reissueIsolatedRecoveryActionPending}
|
||||||
|
onReconcileForwardRecoveryAction={onReconcileForwardRecoveryAction}
|
||||||
|
onBreakGlassOverrideRecoveryAction={onBreakGlassOverrideRecoveryAction}
|
||||||
|
canBreakGlassRecoveryAction={canBreakGlassRecoveryAction}
|
||||||
|
reconcileRecoveryActionPending={reconcileRecoveryActionPending}
|
||||||
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
|
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
|
||||||
legacyRecoverySourceIssue={legacyRecoverySourceIssue ?? null}
|
legacyRecoverySourceIssue={legacyRecoverySourceIssue ?? null}
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
|
|
@ -1686,6 +1754,9 @@ export function IssueDetail() {
|
||||||
&& boardAccess?.companyIds?.includes(selectedCompanyId),
|
&& boardAccess?.companyIds?.includes(selectedCompanyId),
|
||||||
);
|
);
|
||||||
const canResolveBoardRecoveryAction = canBoardResolveRecoveryAction(selectedCompanyId, boardAccess);
|
const canResolveBoardRecoveryAction = canBoardResolveRecoveryAction(selectedCompanyId, boardAccess);
|
||||||
|
// The break-glass override reconcile is `runtime:manage`-gated server-side, not gated on the
|
||||||
|
// recovery-resolution permission — so hide its affordance behind the matching client check.
|
||||||
|
const canManageBoardRuntime = canBoardManageRuntime(selectedCompanyId, boardAccess);
|
||||||
const { data: feedbackVotes } = useQuery({
|
const { data: feedbackVotes } = useQuery({
|
||||||
queryKey: queryKeys.issues.feedbackVotes(issueId!),
|
queryKey: queryKeys.issues.feedbackVotes(issueId!),
|
||||||
queryFn: () => issuesApi.listFeedbackVotes(issueId!),
|
queryFn: () => issuesApi.listFeedbackVotes(issueId!),
|
||||||
|
|
@ -3629,6 +3700,83 @@ export function IssueDetail() {
|
||||||
[reissueIsolatedRecoveryAction.mutateAsync],
|
[reissueIsolatedRecoveryAction.mutateAsync],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Actions 1 & 2 (workspace_validation): reconcile the recorded workspace branch to the live one
|
||||||
|
// via the S4 (PAP-1586) op. `forward` is the ancestry-proven safe path (server re-verifies);
|
||||||
|
// `override` is the audited, permission-gated break-glass carrying the operator's reason. Both
|
||||||
|
// resolve the matching recovery action server-side, so the task resumes via the existing flow.
|
||||||
|
const reconcileRecoveryAction = useMutation({
|
||||||
|
// The target workspace id is captured at click time (see the handlers below) and threaded
|
||||||
|
// through as an explicit argument, so the in-flight mutation always reconciles the workspace
|
||||||
|
// the operator saw on the card — never a value re-read from a `issue` snapshot that may have
|
||||||
|
// been refetched to a different `executionWorkspaceId` while the request was pending.
|
||||||
|
mutationFn: async (
|
||||||
|
input:
|
||||||
|
| { workspaceId: string; mode: "forward" }
|
||||||
|
| { workspaceId: string; mode: "override"; reason: string },
|
||||||
|
) => {
|
||||||
|
const { workspaceId, ...body } = input;
|
||||||
|
return executionWorkspacesApi.reconcile(workspaceId, body);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Refresh the detail card itself (not just the list collections): a successful reconcile
|
||||||
|
// clears the active recovery action, so the card must re-fetch to stop showing stale actions.
|
||||||
|
invalidateIssueDetail();
|
||||||
|
invalidateIssueCollections();
|
||||||
|
pushToast({
|
||||||
|
title: "Workspace branch reconciled",
|
||||||
|
body: "The recorded branch now matches the live branch; the task will resume.",
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
pushToast({
|
||||||
|
title: "Reconcile failed",
|
||||||
|
body: err instanceof Error ? err.message : "Unable to reconcile the workspace branch.",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Bind the workspace id at the moment the operator clicks, from the same render that produced the
|
||||||
|
// visible recovery card, rather than re-reading it inside the async mutation body. The target is
|
||||||
|
// the workspace pinned by the recovery action's evidence — the workspace that actually diverged —
|
||||||
|
// not the page-level `issue.executionWorkspaceId`, which can drift (e.g. a re-issue rebinds the
|
||||||
|
// issue to a new workspace) while the card still shows the older action. Fall back to the
|
||||||
|
// page-level id only when the action carries no workspace reference.
|
||||||
|
const reconcileExecutionWorkspaceId =
|
||||||
|
readRecoveryReconcileWorkspaceId(issue?.activeRecoveryAction) ?? issue?.executionWorkspaceId ?? null;
|
||||||
|
const handleReconcileForwardRecoveryAction = useCallback(() => {
|
||||||
|
if (!reconcileExecutionWorkspaceId) {
|
||||||
|
pushToast({
|
||||||
|
title: "Reconcile failed",
|
||||||
|
body: "This task has no execution workspace to reconcile.",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void reconcileRecoveryAction.mutateAsync({
|
||||||
|
workspaceId: reconcileExecutionWorkspaceId,
|
||||||
|
mode: "forward",
|
||||||
|
});
|
||||||
|
}, [reconcileExecutionWorkspaceId, reconcileRecoveryAction.mutateAsync, pushToast]);
|
||||||
|
const handleBreakGlassOverrideRecoveryAction = useCallback(
|
||||||
|
(reason: string) => {
|
||||||
|
if (!reconcileExecutionWorkspaceId) {
|
||||||
|
pushToast({
|
||||||
|
title: "Reconcile failed",
|
||||||
|
body: "This task has no execution workspace to reconcile.",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void reconcileRecoveryAction.mutateAsync({
|
||||||
|
workspaceId: reconcileExecutionWorkspaceId,
|
||||||
|
mode: "override",
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[reconcileExecutionWorkspaceId, reconcileRecoveryAction.mutateAsync, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
const treePreviewAffectedIssues = useMemo(
|
const treePreviewAffectedIssues = useMemo(
|
||||||
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
|
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
|
||||||
[treeControlPreview],
|
[treeControlPreview],
|
||||||
|
|
@ -4522,6 +4670,10 @@ export function IssueDetail() {
|
||||||
onResolveRecoveryAction={handleResolveRecoveryAction}
|
onResolveRecoveryAction={handleResolveRecoveryAction}
|
||||||
onReissueIsolatedRecoveryAction={handleReissueIsolatedRecoveryAction}
|
onReissueIsolatedRecoveryAction={handleReissueIsolatedRecoveryAction}
|
||||||
reissueIsolatedRecoveryActionPending={reissueIsolatedRecoveryAction.isPending}
|
reissueIsolatedRecoveryActionPending={reissueIsolatedRecoveryAction.isPending}
|
||||||
|
onReconcileForwardRecoveryAction={handleReconcileForwardRecoveryAction}
|
||||||
|
onBreakGlassOverrideRecoveryAction={handleBreakGlassOverrideRecoveryAction}
|
||||||
|
canBreakGlassRecoveryAction={canManageBoardRuntime}
|
||||||
|
reconcileRecoveryActionPending={reconcileRecoveryAction.isPending}
|
||||||
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
|
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
|
||||||
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
|
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
|
||||||
comments={threadComments}
|
comments={threadComments}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue