mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Surface graph recovery actions in issue UI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
06a7177d4d
commit
69a0821de8
11 files changed
+221
-27
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user