mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(recovery): add "Ask CEO to fix" delegated recovery to the recovery card
Implements the recovery-card UI integration for PAP-12752, wiring the delegated recovery backend contract into the issue detail experience. - issuesApi.delegateRecoveryAction() calls POST /issues/:id/recovery-actions/delegate. - IssueRecoveryActionCard offers "Ask CEO to fix" (outcome delegate_ceo) first in the Resolve popover, with a Recommended pill. It is backend-authoritative and shown only when wakePolicy.type === "manual_repair_required", the state is not resolved/observe_only, and it has not already been delegated. A persistent "Recovery task" link row surfaces the created recovery issue. - IssueDetail wires delegate_ceo through a mutation: on success it refreshes the source issue (now blocked on the recovery task) and shows a success toast linking to the created task; on failure it surfaces the API error without mutating local recovery state. False-positive actions stay board-only. - Tests cover menu rendering + placement, API invocation, success toast/link, and failure handling. Storybook states show the delegate menu and delegated card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
82d4e4162b
commit
71b3e304eb
7 files changed
+331
-5
No files matched your search
@@ -109,4 +109,19 @@ describe("issuesApi.list", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("posts delegated recovery to the source issue delegate endpoint", async () => {
|
||||
await issuesApi.delegateRecoveryAction("issue-1", {
|
||||
actionId: "00000000-0000-0000-0000-0000000000aa",
|
||||
target: "ceo",
|
||||
});
|
||||
|
||||
expect(mockApi.post).toHaveBeenCalledWith(
|
||||
"/issues/issue-1/recovery-actions/delegate",
|
||||
{
|
||||
actionId: "00000000-0000-0000-0000-0000000000aa",
|
||||
target: "ceo",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,13 @@ export type ResolveRecoveryActionResponse = {
|
||||
recoveryAction: IssueRecoveryAction;
|
||||
};
|
||||
|
||||
export type DelegateRecoveryActionResponse = {
|
||||
issue: Issue;
|
||||
recoveryIssue: Issue;
|
||||
recoveryAction: IssueRecoveryAction;
|
||||
reused: boolean;
|
||||
};
|
||||
|
||||
export type IssueListFilters = {
|
||||
attention?: "blocked";
|
||||
status?: string;
|
||||
@@ -170,6 +177,8 @@ export const issuesApi = {
|
||||
resolutionNote?: string | null;
|
||||
},
|
||||
) => api.post<ResolveRecoveryActionResponse>(`/issues/${id}/recovery-actions/resolve`, data),
|
||||
delegateRecoveryAction: (id: string, data: { actionId: string; target: "ceo" }) =>
|
||||
api.post<DelegateRecoveryActionResponse>(`/issues/${id}/recovery-actions/delegate`, data),
|
||||
previewTreeControl: (id: string, data: PreviewIssueTreeControl) =>
|
||||
api.post<IssueTreeControlPreview>(`/issues/${id}/tree-control/preview`, data),
|
||||
createTreeHold: (id: string, data: CreateIssueTreeHold) =>
|
||||
|
||||
@@ -296,6 +296,82 @@ describe("IssueRecoveryActionCard", () => {
|
||||
expect(document.body.textContent).not.toContain("Mark blocked");
|
||||
});
|
||||
|
||||
it("offers 'Ask CEO to fix' first, with a Recommended pill, for manual-repair recovery", () => {
|
||||
const onResolve = vi.fn();
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildAction({
|
||||
kind: "workspace_validation",
|
||||
cause: "workspace_validation_failed",
|
||||
wakePolicy: { type: "manual_repair_required" },
|
||||
})}
|
||||
onResolve={onResolve}
|
||||
/>,
|
||||
);
|
||||
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
|
||||
const delegate = document.body.querySelector("[data-testid='recovery-action-delegate-ceo']");
|
||||
expect(delegate?.textContent).toContain("Ask CEO to fix");
|
||||
expect(delegate?.textContent).toContain("Recommended");
|
||||
expect(delegate?.textContent).toContain("Create a new task for the CEO instead of retrying.");
|
||||
|
||||
// It must render before the standard "Try again" option.
|
||||
const buttons = [...document.body.querySelectorAll("button")];
|
||||
const delegateIdx = buttons.findIndex((b) => b.getAttribute("data-testid") === "recovery-action-delegate-ceo");
|
||||
const tryAgainIdx = buttons.findIndex((b) => b.textContent?.includes("Try again"));
|
||||
expect(delegateIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(delegateIdx).toBeLessThan(tryAgainIdx);
|
||||
|
||||
click(delegate);
|
||||
expect(onResolve).toHaveBeenCalledWith("delegate_ceo");
|
||||
});
|
||||
|
||||
it("hides 'Ask CEO to fix' when the wake policy is not manual_repair_required", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildAction({ wakePolicy: { type: "wake_owner" } })}
|
||||
onResolve={() => {}}
|
||||
/>,
|
||||
);
|
||||
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
expect(document.body.querySelector("[data-testid='recovery-action-delegate-ceo']")).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("Ask CEO to fix");
|
||||
});
|
||||
|
||||
it("hides 'Ask CEO to fix' once the action has already been delegated", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildAction({
|
||||
kind: "workspace_validation",
|
||||
wakePolicy: { type: "manual_repair_required" },
|
||||
recoveryIssueId: "00000000-0000-0000-0000-0000000000cc",
|
||||
outcome: "delegated",
|
||||
})}
|
||||
onResolve={() => {}}
|
||||
/>,
|
||||
);
|
||||
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
expect(document.body.querySelector("[data-testid='recovery-action-delegate-ceo']")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a persistent recovery-task link when recoveryIssueId is present", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildAction({
|
||||
kind: "workspace_validation",
|
||||
wakePolicy: { type: "manual_repair_required" },
|
||||
recoveryIssueId: "00000000-0000-0000-0000-0000000000cc",
|
||||
outcome: "delegated",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(node.textContent).toContain("Recovery task");
|
||||
const link = [...node.querySelectorAll("a")].find((a) =>
|
||||
a.getAttribute("href") === "/issues/00000000-0000-0000-0000-0000000000cc",
|
||||
);
|
||||
expect(link?.textContent).toContain("View recovery task");
|
||||
});
|
||||
|
||||
it("hides false-positive options unless canFalsePositive is set", () => {
|
||||
const first = render(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} />,
|
||||
|
||||
@@ -40,6 +40,7 @@ export type RecoveryCardCardState = RecoveryDisplayState;
|
||||
export const deriveRecoveryCardState = deriveRecoveryDisplayState;
|
||||
|
||||
export type RecoveryResolveOutcome =
|
||||
| "delegate_ceo"
|
||||
| "todo"
|
||||
| "done"
|
||||
| "in_review"
|
||||
@@ -897,6 +898,12 @@ const RESOLVE_OPTIONS: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
const DELEGATE_CEO_OPTION = {
|
||||
outcome: "delegate_ceo" as const,
|
||||
label: "Ask CEO to fix",
|
||||
description: "Create a new task for the CEO instead of retrying.",
|
||||
};
|
||||
|
||||
export function IssueRecoveryActionCard({
|
||||
action,
|
||||
agentMap,
|
||||
@@ -956,6 +963,12 @@ export function IssueRecoveryActionCard({
|
||||
if (option.boardOnly && !canFalsePositive) return false;
|
||||
return true;
|
||||
});
|
||||
const showDelegateOption =
|
||||
showResolveActions &&
|
||||
cardState !== "observe_only" &&
|
||||
readEvidenceString(action.wakePolicy?.type) === "manual_repair_required" &&
|
||||
!action.recoveryIssueId &&
|
||||
action.outcome !== "delegated";
|
||||
const reissueBaseRef = divergence?.reissueBaseRef ?? null;
|
||||
const showReissueAction =
|
||||
onReissueIsolated !== undefined &&
|
||||
@@ -1107,6 +1120,17 @@ export function IssueRecoveryActionCard({
|
||||
) : null}
|
||||
</span>
|
||||
</MetadataRow>
|
||||
{action.recoveryIssueId ? (
|
||||
<MetadataRow label="Recovery task">
|
||||
<Link
|
||||
to={`/issues/${action.recoveryIssueId}`}
|
||||
className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
View recovery task
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
</MetadataRow>
|
||||
) : null}
|
||||
{cardState === "resolved" && action.outcome ? (
|
||||
<MetadataRow label="Resolution">
|
||||
<span className={cn("font-medium", tone.labelClass)}>
|
||||
@@ -1142,6 +1166,27 @@ export function IssueRecoveryActionCard({
|
||||
Resolve recovery
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{showDelegateOption ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="recovery-action-delegate-ceo"
|
||||
onClick={() => onResolve?.(DELEGATE_CEO_OPTION.outcome)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-medium leading-5">{DELEGATE_CEO_OPTION.label}</span>
|
||||
<span className="rounded-full border border-border bg-muted px-1.5 py-px text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-label) text-muted-foreground">
|
||||
Recommended
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-(length:--text-micro) leading-4 text-muted-foreground">
|
||||
{DELEGATE_CEO_OPTION.description}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
{visibleResolveOptions.map((option) => (
|
||||
<button
|
||||
key={option.outcome}
|
||||
|
||||
@@ -39,6 +39,8 @@ const mockIssuesApi = vi.hoisted(() => ({
|
||||
uploadAttachment: vi.fn(),
|
||||
deleteAttachment: vi.fn(),
|
||||
upsertDocument: vi.fn(),
|
||||
resolveRecoveryAction: vi.fn(),
|
||||
delegateRecoveryAction: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockActivityApi = vi.hoisted(() => ({
|
||||
@@ -2638,6 +2640,135 @@ describe("IssueDetail", () => {
|
||||
);
|
||||
expect(footer?.className).toContain("bg-background");
|
||||
});
|
||||
|
||||
function createManualRepairRecoveryAction(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "action-1",
|
||||
companyId: "company-1",
|
||||
sourceIssueId: "issue-1",
|
||||
recoveryIssueId: null,
|
||||
kind: "workspace_validation",
|
||||
status: "active",
|
||||
ownerType: "board",
|
||||
ownerAgentId: null,
|
||||
ownerUserId: null,
|
||||
previousOwnerAgentId: null,
|
||||
returnOwnerAgentId: null,
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint: "fp-1",
|
||||
evidence: { summary: "The task's git workspace could not be validated." },
|
||||
nextAction: "Repair the source issue workspace link before resuming.",
|
||||
wakePolicy: { type: "manual_repair_required" },
|
||||
monitorPolicy: null,
|
||||
attemptCount: 1,
|
||||
maxAttempts: 3,
|
||||
timeoutAt: null,
|
||||
lastAttemptAt: null,
|
||||
outcome: null,
|
||||
resolutionNote: null,
|
||||
resolvedAt: null,
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as unknown as NonNullable<Issue["activeRecoveryAction"]>;
|
||||
}
|
||||
|
||||
// The recovery card lives inside the mocked IssueChatThread, so we drive the wiring by
|
||||
// invoking the onResolveRecoveryAction prop that IssueDetail passes down (the card's own
|
||||
// menu rendering is covered in IssueRecoveryActionCard.test.tsx).
|
||||
async function waitForRecoveryResolver(): Promise<(outcome: string) => void> {
|
||||
let resolver: ((outcome: string) => void) | undefined;
|
||||
await waitForAssertion(() => {
|
||||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as
|
||||
| { recoveryAction?: unknown; onResolveRecoveryAction?: (outcome: string) => void }
|
||||
| undefined;
|
||||
expect(props?.recoveryAction).toBeTruthy();
|
||||
expect(typeof props?.onResolveRecoveryAction).toBe("function");
|
||||
resolver = props?.onResolveRecoveryAction;
|
||||
});
|
||||
return resolver!;
|
||||
}
|
||||
|
||||
it("delegates manual-repair recovery to the CEO and surfaces a link to the created task", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({ status: "todo", activeRecoveryAction: createManualRepairRecoveryAction() }),
|
||||
);
|
||||
mockIssuesApi.delegateRecoveryAction.mockResolvedValue({
|
||||
issue: createIssue({ status: "blocked", activeRecoveryAction: null }),
|
||||
recoveryIssue: createIssue({ id: "rec-1", identifier: "PAP-99", title: "Repair recovery path" }),
|
||||
recoveryAction: createManualRepairRecoveryAction({
|
||||
status: "resolved",
|
||||
outcome: "delegated",
|
||||
recoveryIssueId: "rec-1",
|
||||
}),
|
||||
reused: false,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const resolveRecovery = await waitForRecoveryResolver();
|
||||
await act(async () => {
|
||||
resolveRecovery("delegate_ceo");
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssuesApi.delegateRecoveryAction).toHaveBeenCalledWith("PAP-1", {
|
||||
actionId: "action-1",
|
||||
target: "ceo",
|
||||
});
|
||||
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "Delegated to the CEO",
|
||||
tone: "success",
|
||||
action: { label: "View task →", href: "/issues/PAP-99" },
|
||||
}));
|
||||
});
|
||||
|
||||
it("surfaces the API error and keeps the recovery card when delegation fails", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({ status: "todo", activeRecoveryAction: createManualRepairRecoveryAction() }),
|
||||
);
|
||||
mockIssuesApi.delegateRecoveryAction.mockRejectedValue(
|
||||
new Error("No assignable CEO recovery target exists in this company"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const resolveRecovery = await waitForRecoveryResolver();
|
||||
await act(async () => {
|
||||
resolveRecovery("delegate_ceo");
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "Delegation failed",
|
||||
body: "No assignable CEO recovery target exists in this company",
|
||||
tone: "error",
|
||||
}));
|
||||
// Local recovery state is untouched: the failed mutation never mutates caches, so the
|
||||
// source issue still carries its active recovery action for the next render.
|
||||
const latestProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as
|
||||
| { recoveryAction?: unknown }
|
||||
| undefined;
|
||||
expect(latestProps?.recoveryAction).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canBoardResolveRecoveryAction", () => {
|
||||
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
} from "../lib/optimistic-issue-comments";
|
||||
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd, issueUrl } from "../lib/utils";
|
||||
import { liveBlueBadge } from "../lib/status-colors";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
@@ -2187,6 +2187,39 @@ export function IssueDetail() {
|
||||
}
|
||||
},
|
||||
});
|
||||
const delegateRecoveryAction = useMutation({
|
||||
mutationFn: (data: { actionId: string; target: "ceo" }) =>
|
||||
issuesApi.delegateRecoveryAction(issueId!, data),
|
||||
onSuccess: ({ issue: nextIssue, recoveryIssue, reused }) => {
|
||||
const issueRefs = new Set<string>([issueId!, nextIssue.id]);
|
||||
if (nextIssue.identifier) issueRefs.add(nextIssue.identifier);
|
||||
mergeIssueResponseIntoCaches(issueRefs, nextIssue);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
||||
invalidateIssueCollections();
|
||||
const recoveryRef = recoveryIssue.identifier ?? recoveryIssue.id;
|
||||
pushToast({
|
||||
title: "Delegated to the CEO",
|
||||
body: reused
|
||||
? `Reused existing recovery task ${recoveryRef} and blocked this task on it.`
|
||||
: `Created ${recoveryRef} and blocked this task on it.`,
|
||||
tone: "success",
|
||||
action: { label: "View task →", href: issueUrl(recoveryIssue) },
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Delegation failed",
|
||||
body: err instanceof Error ? err.message : "Unable to delegate this recovery to the CEO",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
const executeTreeControl = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (treeControlMode === "resume") {
|
||||
@@ -3679,6 +3712,9 @@ export function IssueDetail() {
|
||||
const actionId = activeRecoveryActionId;
|
||||
if (!actionId) return;
|
||||
switch (outcome) {
|
||||
case "delegate_ceo":
|
||||
delegateRecoveryAction.mutate({ actionId, target: "ceo" });
|
||||
return;
|
||||
case "todo":
|
||||
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "todo" });
|
||||
return;
|
||||
@@ -3696,7 +3732,7 @@ export function IssueDetail() {
|
||||
return;
|
||||
}
|
||||
},
|
||||
[activeRecoveryActionId, resolveRecoveryAction.mutateAsync],
|
||||
[activeRecoveryActionId, resolveRecoveryAction.mutateAsync, delegateRecoveryAction.mutate],
|
||||
);
|
||||
|
||||
// Action 3 (workspace_validation): one-click re-issue of the stalled task on a fresh isolated
|
||||
|
||||
@@ -130,11 +130,12 @@ function AllStatesPanel() {
|
||||
})}
|
||||
/>
|
||||
<CardPanel
|
||||
caption="State 6 · Workspace validation (kind: workspace_validation)"
|
||||
caption="State 6 · Workspace validation (kind: workspace_validation) — offers 'Ask CEO to fix'"
|
||||
action={buildAction({
|
||||
kind: "workspace_validation",
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint: "fp-workspace",
|
||||
wakePolicy: { type: "manual_repair_required" },
|
||||
evidence: {
|
||||
latestRunErrorCode: "workspace_validation_failed",
|
||||
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
|
||||
@@ -145,7 +146,20 @@ function AllStatesPanel() {
|
||||
canFalsePositive
|
||||
/>
|
||||
<CardPanel
|
||||
caption="State 7 · Invalid external wait — killed background watcher (kind: stranded_assigned_issue)"
|
||||
caption="State 7 · Delegated to CEO (links to recovery task)"
|
||||
action={buildAction({
|
||||
kind: "workspace_validation",
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint: "fp-workspace-delegated",
|
||||
wakePolicy: { type: "manual_repair_required" },
|
||||
outcome: "delegated",
|
||||
recoveryIssueId: "00000000-0000-0000-0000-0000000000cc",
|
||||
nextAction: "A CEO repair task was created; this task is blocked until it resolves.",
|
||||
})}
|
||||
forcedState="in_progress"
|
||||
/>
|
||||
<CardPanel
|
||||
caption="State 8 · Invalid external wait — killed background watcher (kind: stranded_assigned_issue)"
|
||||
action={buildAction({
|
||||
kind: "stranded_assigned_issue",
|
||||
cause: "stranded_assigned_issue",
|
||||
@@ -165,7 +179,7 @@ function AllStatesPanel() {
|
||||
canFalsePositive
|
||||
/>
|
||||
<CardPanel
|
||||
caption="State 8 · Invalid external wait — escalated after the single continuation (kind: stranded_assigned_issue)"
|
||||
caption="State 9 · Invalid external wait — escalated after the single continuation (kind: stranded_assigned_issue)"
|
||||
action={buildAction({
|
||||
kind: "stranded_assigned_issue",
|
||||
cause: "stranded_assigned_issue",
|
||||
|
||||
Reference in new issue
Block a user