Resolve source recovery actions

This commit is contained in:
Dotta committed 2026-05-09 21:38:41 -05:00
1 parent ed2c6382e3
commit d604d323fd
11 files changed
+458 -23

No files matched your search

+2
View File
@@ -761,6 +761,7 @@ export {
updateIssueSchema,
issueExecutionPolicySchema,
issueExecutionStateSchema,
resolveIssueRecoveryActionSchema,
issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
@@ -820,6 +821,7 @@ export {
type CreateChildIssue,
type CreateIssueLabel,
type UpdateIssue,
type ResolveIssueRecoveryAction,
type CheckoutIssue,
type AddIssueComment,
type CreateIssueThreadInteraction,
+2
View File
@@ -157,6 +157,7 @@ export {
issueExecutionPolicySchema,
issueExecutionStateSchema,
issueRecoveryActionReadModelSchema,
resolveIssueRecoveryActionSchema,
issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
@@ -200,6 +201,7 @@ export {
type UpdateIssue,
type IssueExecutionWorkspaceSettings,
type IssueRecoveryActionReadModel,
type ResolveIssueRecoveryAction,
type CheckoutIssue,
type AddIssueComment,
type CreateIssueThreadInteraction,
+48
View File
@@ -202,6 +202,54 @@ export const issueRecoveryActionReadModelSchema = z.object({
export type IssueRecoveryActionReadModel = z.infer<typeof issueRecoveryActionReadModelSchema>;
export const resolveIssueRecoveryActionSchema = z.object({
actionId: z.string().uuid().optional(),
outcome: z.enum(ISSUE_RECOVERY_ACTION_OUTCOMES),
sourceIssueStatus: z.enum(["done", "in_review", "blocked"]).optional().nullable(),
resolutionNote: multilineTextSchema.optional().nullable(),
}).strict().superRefine((value, ctx) => {
if (value.outcome === "delegated" || value.outcome === "escalated") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "This recovery outcome is not supported by the source-scoped resolution endpoint",
path: ["outcome"],
});
return;
}
if (value.outcome === "restored") {
if (value.sourceIssueStatus !== "done" && value.sourceIssueStatus !== "in_review") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Restored recovery actions must move the source issue to done or in_review",
path: ["sourceIssueStatus"],
});
}
return;
}
if (value.outcome === "blocked") {
if (value.sourceIssueStatus !== "blocked") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Blocked recovery actions must move the source issue to blocked",
path: ["sourceIssueStatus"],
});
}
return;
}
if (value.sourceIssueStatus !== undefined && value.sourceIssueStatus !== null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "This recovery outcome does not change the source issue status",
path: ["sourceIssueStatus"],
});
}
});
export type ResolveIssueRecoveryAction = z.infer<typeof resolveIssueRecoveryActionSchema>;
const issueRequestDepthInputSchema = z
.number()
.int()
@@ -16,6 +16,7 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import { recoveryService } from "../services/recovery/service.js";
@@ -102,6 +103,18 @@ describeEmbeddedPostgres("issue recovery actions", () => {
return { companyId, managerId, coderId, sourceIssueId, prefix, sourceIssue: sourceIssue! };
}
function createApp(actor: any = { type: "board", source: "local_implicit" }) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
it("upserts one active source-scoped action per issue and keeps company scoping explicit", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const svc = issueRecoveryActionService(db);
@@ -376,12 +389,7 @@ describeEmbeddedPostgres("issue recovery actions", () => {
nextAction: "Choose a valid issue disposition.",
wakePolicy: { type: "wake_owner" },
});
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "board", source: "local_implicit" };
next();
});
app.use("/api", issueRoutes(db, {} as any));
const app = createApp();
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body.activeRecoveryAction).toMatchObject({
@@ -395,4 +403,136 @@ describeEmbeddedPostgres("issue recovery actions", () => {
expect(list.body.active).toMatchObject({ id: action.id });
expect(list.body.actions).toHaveLength(1);
});
it("resolves an active recovery action and removes it from active projections", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "missing_disposition",
ownerType: "agent",
ownerAgentId: managerId,
cause: "successful_run_missing_issue_disposition",
fingerprint: "missing-disposition:fingerprint",
evidence: { sourceRunId: "run-1" },
nextAction: "Choose a valid issue disposition.",
wakePolicy: { type: "wake_owner" },
});
const app = createApp();
const resolved = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "restored",
sourceIssueStatus: "done",
resolutionNote: "Operator confirmed the source issue is complete.",
})
.expect(200);
expect(resolved.body.issue).toMatchObject({
id: sourceIssueId,
status: "done",
activeRecoveryAction: null,
});
expect(resolved.body.recoveryAction).toMatchObject({
id: action.id,
status: "resolved",
outcome: "restored",
resolutionNote: "Operator confirmed the source issue is complete.",
});
expect(resolved.body.recoveryAction.resolvedAt).toBeTruthy();
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull();
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body.activeRecoveryAction).toBeNull();
const activityRows = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, sourceIssueId));
expect(activityRows.map((row) => row.action)).toEqual(
expect.arrayContaining(["issue.updated", "issue.recovery_action_resolved"]),
);
});
it("records false positives without cancelling the source issue", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:fingerprint",
evidence: { latestIssueStatus: "in_progress" },
nextAction: "Confirm whether the issue is actually stranded.",
wakePolicy: { type: "manual" },
});
const app = createApp();
await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "false_positive",
resolutionNote: "The source issue still has a live execution path.",
})
.expect(200);
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(sourceIssue?.status).toBe("in_progress");
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "resolved",
outcome: "false_positive",
resolutionNote: "The source issue still has a live execution path.",
});
});
it("enforces company scope when resolving recovery actions", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "missing_disposition",
ownerType: "agent",
ownerAgentId: managerId,
cause: "successful_run_missing_issue_disposition",
fingerprint: "missing-disposition:fingerprint",
evidence: { sourceRunId: "run-1" },
nextAction: "Choose a valid issue disposition.",
wakePolicy: { type: "wake_owner" },
});
const app = createApp({
type: "agent",
agentId: randomUUID(),
companyId: randomUUID(),
runId: randomUUID(),
source: "agent_jwt",
});
await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "restored",
sourceIssueStatus: "done",
})
.expect(403);
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow?.status).toBe("active");
});
});
+109
View File
@@ -18,6 +18,7 @@ import {
createChildIssueSchema,
createIssueSchema,
resolveCreateIssueStatusDefault,
resolveIssueRecoveryActionSchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
@@ -1754,6 +1755,114 @@ export function issueRoutes(
});
});
router.post("/issues/:id/recovery-actions/resolve", validate(resolveIssueRecoveryActionSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body;
if (outcome === "false_positive" || outcome === "cancelled") {
assertBoard(req);
}
const actor = getActorInfo(req);
const updateFields = sourceIssueStatus ? { status: sourceIssueStatus } : {};
await assertAgentInReviewReviewPath({
existing,
updateFields,
actorType: req.actor.type,
});
const actionStatus = outcome === "cancelled" ? "cancelled" : "resolved";
const result = await db.transaction(async (tx) => {
let issue = existing;
if (sourceIssueStatus) {
const updatedIssue = await svc.update(
id,
{
status: sourceIssueStatus,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
},
tx,
);
if (!updatedIssue) throw notFound("Issue not found");
issue = updatedIssue;
}
const recoveryAction = await recoveryActionsSvc.resolveActiveForIssue(
{
companyId: existing.companyId,
sourceIssueId: existing.id,
actionId: actionId ?? null,
status: actionStatus,
outcome,
resolutionNote: resolutionNote ?? null,
},
tx,
);
if (!recoveryAction) throw notFound("Active recovery action not found");
return { issue, recoveryAction };
});
await routinesSvc.syncRunStatusForIssue(result.issue.id);
if (sourceIssueStatus && existing.status !== result.issue.status) {
await logActivity(db, {
companyId: result.issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: result.issue.id,
details: {
identifier: result.issue.identifier,
status: result.issue.status,
source: "recovery_action_resolution",
recoveryActionId: result.recoveryAction.id,
_previous: {
status: existing.status,
},
},
});
}
await logActivity(db, {
companyId: result.issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.recovery_action_resolved",
entityType: "issue",
entityId: result.issue.id,
details: {
identifier: result.issue.identifier,
recoveryActionId: result.recoveryAction.id,
recoveryActionStatus: result.recoveryAction.status,
outcome: result.recoveryAction.outcome,
sourceIssueStatus: sourceIssueStatus ?? null,
resolutionNote: result.recoveryAction.resolutionNote,
},
});
res.json({
issue: {
...result.issue,
activeRecoveryAction: null,
},
recoveryAction: result.recoveryAction,
});
});
router.get("/issues/:id/work-products", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@@ -5,6 +5,7 @@ import type {
IssueRecoveryAction,
IssueRecoveryActionKind,
IssueRecoveryActionOwnerType,
IssueRecoveryActionOutcome,
IssueRecoveryActionStatus,
} from "@paperclipai/shared";
@@ -33,6 +34,15 @@ export type UpsertIssueRecoveryActionInput = {
lastAttemptAt?: Date | null;
};
export type ResolveIssueRecoveryActionInput = {
companyId: string;
sourceIssueId: string;
actionId?: string | null;
status: Extract<IssueRecoveryActionStatus, "resolved" | "cancelled">;
outcome: IssueRecoveryActionOutcome;
resolutionNote?: string | null;
};
function toReadModel(row: IssueRecoveryActionRow): IssueRecoveryAction {
return {
id: row.id,
@@ -189,9 +199,39 @@ export function issueRecoveryActionService(db: Db) {
}
}
async function resolveActiveForIssue(
input: ResolveIssueRecoveryActionInput,
dbOrTx: any = db,
): Promise<IssueRecoveryAction | null> {
const now = new Date();
const predicates = [
eq(issueRecoveryActions.companyId, input.companyId),
eq(issueRecoveryActions.sourceIssueId, input.sourceIssueId),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
];
if (input.actionId) {
predicates.push(eq(issueRecoveryActions.id, input.actionId));
}
const [updated] = await dbOrTx
.update(issueRecoveryActions)
.set({
status: input.status,
outcome: input.outcome,
resolutionNote: input.resolutionNote ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(...predicates))
.returning();
return updated ? toReadModel(updated) : null;
}
return {
getActiveForIssue,
listActiveForIssues,
resolveActiveForIssue,
upsertSourceScoped,
};
}
+20
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockApi = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}));
vi.mock("./client", () => ({
@@ -13,7 +14,9 @@ import { issuesApi } from "./issues";
describe("issuesApi.list", () => {
beforeEach(() => {
mockApi.get.mockReset();
mockApi.post.mockReset();
mockApi.get.mockResolvedValue([]);
mockApi.post.mockResolvedValue({});
});
it("passes parentId through to the company issues endpoint", async () => {
@@ -47,4 +50,21 @@ describe("issuesApi.list", () => {
"/companies/company-1/issues?limit=500&offset=1500",
);
});
it("posts recovery action resolution to the source issue endpoint", async () => {
await issuesApi.resolveRecoveryAction("issue-1", {
actionId: "00000000-0000-0000-0000-0000000000aa",
outcome: "restored",
sourceIssueStatus: "done",
});
expect(mockApi.post).toHaveBeenCalledWith(
"/issues/issue-1/recovery-actions/resolve",
{
actionId: "00000000-0000-0000-0000-0000000000aa",
outcome: "restored",
sourceIssueStatus: "done",
},
);
});
});
+16
View File
@@ -12,6 +12,8 @@ import type {
IssueComment,
IssueDocument,
IssueLabel,
IssueRecoveryAction,
IssueRecoveryActionOutcome,
IssueRetryNowResponse,
IssueThreadInteraction,
IssueTreeControlPreview,
@@ -27,6 +29,11 @@ export type IssueUpdateResponse = Issue & {
comment?: IssueComment | null;
};
export type ResolveRecoveryActionResponse = {
issue: Issue;
recoveryAction: IssueRecoveryAction;
};
export const issuesApi = {
list: (
companyId: string,
@@ -94,6 +101,15 @@ export const issuesApi = {
api.post<Issue>(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
resolveRecoveryAction: (
id: string,
data: {
actionId?: string;
outcome: IssueRecoveryActionOutcome;
sourceIssueStatus?: "done" | "in_review" | "blocked" | null;
resolutionNote?: string | null;
},
) => api.post<ResolveRecoveryActionResponse>(`/issues/${id}/recovery-actions/resolve`, data),
previewTreeControl: (id: string, data: PreviewIssueTreeControl) =>
api.post<IssueTreeControlPreview>(`/issues/${id}/tree-control/preview`, data),
createTreeHold: (id: string, data: CreateIssueTreeHold) =>
@@ -36,6 +36,13 @@ function render(element: ReactElement) {
return container;
}
function click(element: Element | null) {
if (!element) throw new Error("Expected element to exist");
act(() => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
const ownerAgent: Agent = {
id: "11111111-1111-1111-1111-111111111111",
companyId: "company-1",
@@ -157,10 +164,39 @@ describe("IssueRecoveryActionCard", () => {
expect(node.textContent).toContain("Resolved as restored");
});
it("hides false_positive option unless canCancelRecovery is set", () => {
it("calls resolve with done and does not offer 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("Delegate follow-up issue");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark issue done")) ?? null);
expect(onResolve).toHaveBeenCalledWith("done");
});
it("hides false_positive option unless canCancelRecovery is set", () => {
const first = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} />,
);
expect(node.querySelector("[data-testid='recovery-action-resolve-trigger']")).not.toBeNull();
click(first.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).not.toContain("Mark false positive");
act(() => root?.unmount());
root = null;
container?.remove();
container = null;
const onResolve = vi.fn();
const second = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} canCancelRecovery />,
);
click(second.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Mark false positive");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark false positive")) ?? null);
expect(onResolve).toHaveBeenCalledWith("false_positive");
});
});
@@ -28,7 +28,6 @@ export type RecoveryResolveOutcome =
| "done"
| "in_review"
| "blocked"
| "delegated"
| "false_positive";
export interface IssueRecoveryActionCardProps {
@@ -317,12 +316,7 @@ const RESOLVE_OPTIONS: Array<{
{
outcome: "blocked",
label: "Mark blocked…",
description: "Block on a real follow-up issue.",
},
{
outcome: "delegated",
label: "Delegate follow-up issue…",
description: "Create a child issue and block this one on it.",
description: "Resolve recovery by recording that the source issue is blocked.",
},
{
outcome: "false_positive",
+36 -8
View File
@@ -148,6 +148,7 @@ import {
type Issue,
type IssueAttachment,
type IssueComment,
type IssueRecoveryActionOutcome,
type IssueWorkMode,
type IssueThreadInteraction,
type RequestConfirmationInteraction,
@@ -1725,6 +1726,34 @@ export function IssueDetail() {
}
},
});
const resolveRecoveryAction = useMutation({
mutationFn: (data: {
actionId?: string;
outcome: IssueRecoveryActionOutcome;
sourceIssueStatus?: "done" | "in_review" | "blocked" | null;
resolutionNote?: string | null;
}) => issuesApi.resolveRecoveryAction(issueId!, data),
onSuccess: ({ issue: nextIssue }) => {
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();
},
onError: (err) => {
pushToast({
title: "Recovery resolution failed",
body: err instanceof Error ? err.message : "Unable to resolve recovery action",
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") {
@@ -2927,25 +2956,24 @@ export function IssueDetail() {
}, [updateIssue.mutateAsync]);
const handleResolveRecoveryAction = useCallback(
(outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => {
const actionId = issue?.activeRecoveryAction?.id;
if (!actionId) return;
switch (outcome) {
case "done":
void updateIssue.mutateAsync({ status: "done" });
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" });
return;
case "in_review":
void updateIssue.mutateAsync({ status: "in_review" });
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "in_review" });
return;
case "blocked":
void updateIssue.mutateAsync({ status: "blocked" });
return;
case "delegated":
if (issue) openNewIssue(buildSubIssueDefaultsForViewer(issue, currentUserId));
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "blocked", sourceIssueStatus: "blocked" });
return;
case "false_positive":
void updateIssue.mutateAsync({ status: "cancelled" });
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive" });
return;
}
},
[currentUserId, issue, openNewIssue, updateIssue.mutateAsync],
[issue, resolveRecoveryAction.mutateAsync],
);
const treePreviewAffectedIssues = useMemo(