From 69a0821de8a6abca146b57b6328fc7de7567f6b1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 16 May 2026 20:24:52 -0500 Subject: [PATCH] Surface graph recovery actions in issue UI Co-Authored-By: Paperclip --- packages/shared/src/validators/issue.test.ts | 16 ++++- packages/shared/src/validators/issue.ts | 12 ++++ server/src/routes/issues.ts | 68 +++++++++++++++++-- server/src/services/recovery/service.ts | 2 +- ui/src/api/issues.ts | 2 +- ui/src/components/IssueChatThread.tsx | 1 + .../IssueRecoveryActionCard.test.tsx | 39 ++++++++--- ui/src/components/IssueRecoveryActionCard.tsx | 40 +++++++++-- ui/src/components/IssueRow.test.tsx | 47 +++++++++++++ ui/src/pages/InstanceExperimentalSettings.tsx | 8 +-- ui/src/pages/IssueDetail.tsx | 13 +++- 11 files changed, 221 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 0d8374ecb7e..5b11c2809ec 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -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", diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index b39fe7f8eca..77bea01a6e5 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -267,6 +267,7 @@ export type IssueRecoveryActionReadModel = z.infer issue.id); + const issuesWithRelations = result as Array; + 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); } diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 54e0658ee5c..8162b38457b 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -3116,7 +3116,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) let escalation: Awaited>; 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", diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 0bf061d6d60..cc455dff907 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -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; }, diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index b25eb39fd40..65a42fe4174 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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} diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index 56953732bba..caf11355d23 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -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( , ); 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( - {}} 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( + , + ); + 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", () => { diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index 88fdcebb8ea..fb147c2e6c2 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -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({
{visibleResolveOptions.map((option) => ( + (() => { + const disabled = option.requiresBlocker && !canMarkBlocked; + return ( + ); + })() ))}
diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 222f1cf1ed8..2b08568424c 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -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(); + }); + + 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({ diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 6d6a39f0dbf..51451420497 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -53,7 +53,7 @@ function RecoveryPreviewDialog({ Confirm auto-recovery {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."} @@ -61,7 +61,7 @@ function RecoveryPreviewDialog({
{preview && preview.items.length === 0 ? (
- 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.
) : null} @@ -320,9 +320,9 @@ export function InstanceExperimentalSettings() {
-

Auto-Create Issue Recovery Tasks

+

Auto-Open Graph Recovery Actions

- 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.

diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b8e71f2e670..ce165a3d0a4 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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;