Surface graph recovery actions in issue UI

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-16 21:06:32 -05:00
1 parent 06a7177d4d
commit 69a0821de8
11 files changed
+221 -27

No files matched your search

+15 -1
View File
@@ -98,13 +98,27 @@ describe("issue validators", () => {
).toBe(false);
});
it("rejects recovery outcomes that are not supported by the source-scoped resolution endpoint", () => {
it("allows delegated recovery resolutions to leave a live follow-up path", () => {
expect(
resolveIssueRecoveryActionSchema.parse({
outcome: "delegated",
sourceIssueStatus: "in_review",
resolutionNote: "Delegated to a child follow-up.",
}),
).toMatchObject({
outcome: "delegated",
sourceIssueStatus: "in_review",
});
expect(
resolveIssueRecoveryActionSchema.safeParse({
outcome: "delegated",
sourceIssueStatus: "done",
}).success,
).toBe(false);
});
it("rejects recovery outcomes that are not supported by the source-scoped resolution endpoint", () => {
expect(
resolveIssueRecoveryActionSchema.safeParse({
outcome: "escalated",
+12
View File
@@ -267,6 +267,7 @@ export type IssueRecoveryActionReadModel = z.infer<typeof issueRecoveryActionRea
const RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES = [
"restored",
"delegated",
"false_positive",
"blocked",
"cancelled",
@@ -300,6 +301,17 @@ export const resolveIssueRecoveryActionSchema = z.object({
return;
}
if (value.outcome === "delegated") {
if (value.sourceIssueStatus !== "in_review" && value.sourceIssueStatus !== "blocked") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Delegated recovery actions must leave the source issue in_review or blocked",
path: ["sourceIssueStatus"],
});
}
return;
}
if (value.outcome === "false_positive" || value.outcome === "cancelled") {
if (
value.sourceIssueStatus !== "done" &&
+64 -4
View File
@@ -1141,6 +1141,39 @@ export function issueRoutes(
return true;
}
async function assertRecoveryActionResolutionAllowed(
req: Request,
res: Response,
input: {
issue: { id: string; companyId: string };
actionId?: string | null;
},
) {
const activeAction = await recoveryActionsSvc.getActiveForIssue(input.issue.companyId, input.issue.id);
if (!activeAction || (input.actionId && activeAction.id !== input.actionId)) {
return { allowed: true, activeAction: null as typeof activeAction };
}
if (req.actor.type === "board") {
return { allowed: true, activeAction };
}
const actorAgentId = req.actor.type === "agent" ? req.actor.agentId : null;
if (activeAction.ownerType === "agent" && actorAgentId && activeAction.ownerAgentId === actorAgentId) {
return { allowed: true, activeAction };
}
res.status(403).json({
error: "Only the board or the assigned recovery owner can resolve this recovery action",
details: {
issueId: input.issue.id,
recoveryActionId: activeAction.id,
recoveryOwnerType: activeAction.ownerType,
recoveryOwnerAgentId: activeAction.ownerAgentId,
actorAgentId: actorAgentId ?? null,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return { allowed: false, activeAction };
}
function assertStructuredCommentFieldsAllowed(
req: Request,
res: Response,
@@ -1507,13 +1540,36 @@ export function issueRoutes(
limit,
offset,
});
const issueIds = result.map((issue) => issue.id);
const issuesWithRelations = result as Array<typeof result[number] & {
blockedBy?: IssueRelationIssueSummary[];
blocks?: IssueRelationIssueSummary[];
}>;
const issueIds = issuesWithRelations.map((issue) => issue.id);
const relationRecoveryCandidateIds = [
...new Set(issuesWithRelations.flatMap((issue) => [
...((issue.blockedBy ?? []).flatMap((summary) => [
summary.id,
...((summary.terminalBlockers ?? []).map((terminal) => terminal.id)),
])),
...((issue.blocks ?? []).flatMap((summary) => [
summary.id,
...((summary.terminalBlockers ?? []).map((terminal) => terminal.id)),
])),
])),
];
const [handoffStates, recoveryActionByIssue] = await Promise.all([
listSuccessfulRunHandoffStates(db, companyId, issueIds),
recoveryActionsSvc.listActiveForIssues(companyId, issueIds),
recoveryActionsSvc.listActiveForIssues(companyId, [...issueIds, ...relationRecoveryCandidateIds]),
]);
res.json(result.map((issue) => ({
const augmentRelation = (summary: IssueRelationIssueSummary): IssueRelationIssueSummary => ({
...summary,
activeRecoveryAction: recoveryActionByIssue.get(summary.id) ?? summary.activeRecoveryAction ?? null,
terminalBlockers: summary.terminalBlockers?.map(augmentRelation),
});
res.json(issuesWithRelations.map((issue) => ({
...issue,
blockedBy: issue.blockedBy?.map(augmentRelation),
blocks: issue.blocks?.map(augmentRelation),
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
})));
@@ -1838,9 +1894,13 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, existing.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body;
const recoveryResolutionAuthz = await assertRecoveryActionResolutionAllowed(req, res, {
issue: existing,
actionId: actionId ?? null,
});
if (!recoveryResolutionAuthz.allowed) return;
if (outcome === "false_positive" || outcome === "cancelled") {
assertBoard(req);
}
+1 -1
View File
@@ -3116,7 +3116,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
let escalation: Awaited<ReturnType<typeof issuesSvc.create>>;
try {
escalation = await issuesSvc.create(issue.companyId, {
title: `Unblock liveness incident for ${recoveryIssue.identifier ?? recoveryIssue.title}`,
title: `Restore live path for ${recoveryIssue.identifier ?? recoveryIssue.title}`,
description: buildLivenessEscalationDescription(input.finding),
status: "todo",
priority: "high",
+1 -1
View File
@@ -130,7 +130,7 @@ export const issuesApi = {
id: string,
data: {
actionId?: string;
outcome: "restored" | "false_positive" | "blocked" | "cancelled";
outcome: "restored" | "delegated" | "false_positive" | "blocked" | "cancelled";
sourceIssueStatus: "done" | "in_review" | "blocked";
resolutionNote?: string | null;
},
+1
View File
@@ -4265,6 +4265,7 @@ export function IssueChatThread({
action={recoveryAction}
agentMap={agentMap}
onResolve={onResolveRecoveryAction}
canMarkBlocked={(blockedBy ?? []).some((blocker) => blocker.status !== "done" && blocker.status !== "cancelled")}
canFalsePositive={canFalsePositiveRecoveryAction}
/>
) : null}
@@ -165,32 +165,51 @@ describe("IssueRecoveryActionCard", () => {
expect(node.textContent).toContain("Resolved as restored");
});
it("calls resolve with done and does not offer delegated recovery", () => {
it("calls resolve with restored done and shows delegated recovery", () => {
const onResolve = vi.fn();
const node = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} />,
);
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Mark issue done");
expect(document.body.textContent).not.toContain("Mark blocked");
expect(document.body.textContent).not.toContain("Delegate follow-up issue");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark issue done")) ?? null);
expect(document.body.textContent).toContain("Restored, mark done");
expect(document.body.textContent).toContain("Delegated follow-up");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Delegated follow-up")) ?? null);
expect(onResolve).toHaveBeenCalledWith("delegated");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Restored, mark done")) ?? null);
expect(onResolve).toHaveBeenCalledWith("done");
});
it("does not offer blocked recovery resolution without a blocker selection flow", () => {
it("disables blocked recovery resolution without a first-class blocker", () => {
const onResolve = vi.fn();
const node = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} canFalsePositive />,
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} canFalsePositive />,
);
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Mark issue done");
expect(document.body.textContent).toContain("Send for review");
expect(document.body.textContent).toContain("Restored, mark done");
expect(document.body.textContent).toContain("Restored, send for review");
expect(document.body.textContent).toContain("Mark blocked");
expect(document.body.textContent).toContain("Add a first-class blocker before using this outcome.");
expect(document.body.textContent).toContain("False positive, done");
expect(document.body.textContent).toContain("False positive, review");
expect(document.body.textContent).not.toContain("Mark blocked");
const blockedButton = [...document.body.querySelectorAll("button")]
.find((button) => button.textContent?.includes("Mark blocked"));
expect(blockedButton?.hasAttribute("disabled")).toBe(true);
click(blockedButton ?? null);
expect(onResolve).not.toHaveBeenCalled();
});
it("calls resolve with blocked when a source blocker exists", () => {
const onResolve = vi.fn();
const node = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} canMarkBlocked />,
);
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark blocked")) ?? null);
expect(onResolve).toHaveBeenCalledWith("blocked");
});
it("hides false-positive options unless canFalsePositive is set", () => {
+35 -5
View File
@@ -27,6 +27,8 @@ export const deriveRecoveryCardState = deriveRecoveryDisplayState;
export type RecoveryResolveOutcome =
| "done"
| "in_review"
| "blocked"
| "delegated"
| "false_positive_done"
| "false_positive_in_review";
@@ -37,6 +39,8 @@ export interface IssueRecoveryActionCardProps {
forcedState?: RecoveryCardCardState;
/** Optional click handler for resolve menu actions. If omitted, the buttons are not rendered. */
onResolve?: (outcome: RecoveryResolveOutcome) => void;
/** Whether blocked resolution can succeed immediately because the source issue already has a blocker. */
canMarkBlocked?: boolean;
/** Whether the viewer can run destructive board-only actions (e.g. false-positive dismissal). */
canFalsePositive?: boolean;
className?: string;
@@ -291,17 +295,29 @@ const RESOLVE_OPTIONS: Array<{
description: string;
destructive?: boolean;
boardOnly?: boolean;
requiresBlocker?: boolean;
}> = [
{
outcome: "done",
label: "Mark issue done",
label: "Restored, mark done",
description: "Restore by recording the requested work as complete.",
},
{
outcome: "in_review",
label: "Send for review",
label: "Restored, send for review",
description: "Hand off to a reviewer with a real review path.",
},
{
outcome: "blocked",
label: "Mark blocked",
description: "Resolve recovery by leaving the source issue blocked on its linked blocker.",
requiresBlocker: true,
},
{
outcome: "delegated",
label: "Delegated follow-up",
description: "Resolve recovery after delegating the remaining work to a follow-up path.",
},
{
outcome: "false_positive_done",
label: "False positive, done",
@@ -323,6 +339,7 @@ export function IssueRecoveryActionCard({
agentMap,
forcedState,
onResolve,
canMarkBlocked = false,
canFalsePositive = false,
className,
}: IssueRecoveryActionCardProps) {
@@ -500,19 +517,32 @@ export function IssueRecoveryActionCard({
</div>
<div className="flex flex-col">
{visibleResolveOptions.map((option) => (
(() => {
const disabled = option.requiresBlocker && !canMarkBlocked;
return (
<button
key={option.outcome}
type="button"
onClick={() => onResolve?.(option.outcome)}
onClick={() => {
if (disabled) return;
onResolve?.(option.outcome);
}}
disabled={disabled}
aria-disabled={disabled}
title={disabled ? "Add a first-class blocker before marking recovery blocked." : undefined}
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",
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:bg-transparent",
option.destructive ? "text-destructive" : null,
)}
>
<span className="font-medium leading-5">{option.label}</span>
<span className="text-[11px] leading-4 text-muted-foreground">{option.description}</span>
<span className="text-[11px] leading-4 text-muted-foreground">
{disabled ? "Add a first-class blocker before using this outcome." : option.description}
</span>
</button>
);
})()
))}
</div>
</PopoverContent>
+47
View File
@@ -288,6 +288,53 @@ describe("IssueRow", () => {
});
});
it("flags rows with an active source-scoped recovery action", () => {
const root = createRoot(container);
const issue = createIssue({
activeRecoveryAction: {
id: "recovery-1",
companyId: "company-1",
sourceIssueId: "issue-1",
recoveryIssueId: null,
kind: "issue_graph_liveness",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-1",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "blocked_chain_stalled",
fingerprint: "fp",
evidence: {},
nextAction: "restore a live path",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
},
});
act(() => {
root.render(<IssueRow issue={issue} />);
});
const badge = container.querySelector('[data-testid="issue-row-recovery-indicator"]');
expect(badge).not.toBeNull();
expect(badge?.getAttribute("data-recovery-state")).toBe("needed");
expect(badge?.textContent).toContain("Recovery needed");
act(() => {
root.unmount();
});
});
it("does not show the parked-work badge when assigned blocker is not in backlog", () => {
const root = createRoot(container);
const issue = createIssue({
@@ -53,7 +53,7 @@ function RecoveryPreviewDialog({
<DialogTitle>Confirm auto-recovery</DialogTitle>
<DialogDescription>
{preview
? `${count} recovery ${count === 1 ? "task" : "tasks"} match the last ${preview.lookbackHours} hours.`
? `${count} recovery ${count === 1 ? "action" : "actions"} match the last ${preview.lookbackHours} hours.`
: "Checking recovery candidates before enabling."}
</DialogDescription>
</DialogHeader>
@@ -61,7 +61,7 @@ function RecoveryPreviewDialog({
<div className="max-h-[min(28rem,65vh)] space-y-3 overflow-y-auto pr-1">
{preview && preview.items.length === 0 ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-4 text-sm text-muted-foreground">
No recovery tasks would be created right now. Auto-recovery can still run for future liveness incidents in
No source-scoped recovery actions would be opened right now. Auto-recovery can still run for future graph stalls in
this window.
</div>
) : null}
@@ -320,9 +320,9 @@ export function InstanceExperimentalSettings() {
<div className="flex flex-col gap-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Auto-Create Issue Recovery Tasks</h2>
<h2 className="text-sm font-semibold">Auto-Open Graph Recovery Actions</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Let the heartbeat scheduler create recovery issues for issue dependency chains found inside the
Let the heartbeat scheduler open source-scoped recovery actions for issue dependency chains found inside the
configured lookback window.
</p>
</div>
+12 -1
View File
@@ -158,7 +158,7 @@ import {
type CommentReassignment = IssueCommentReassignment;
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
type ResolveRecoveryActionOutcome = "restored" | "delegated" | "false_positive" | "blocked" | "cancelled";
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
runId?: string | null;
runAgentId?: string | null;
@@ -3006,6 +3006,17 @@ export function IssueDetail() {
case "in_review":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "in_review" });
return;
case "blocked":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "blocked", sourceIssueStatus: "blocked" });
return;
case "delegated":
void resolveRecoveryAction.mutateAsync({
actionId,
outcome: "delegated",
sourceIssueStatus: "in_review",
resolutionNote: "Recovery delegated to follow-up work.",
});
return;
case "false_positive_done":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "done" });
return;