Implement delegated recovery backend contract

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-31 23:30:43 +00:00
1 parent f51cba33fa
commit 82d4e4162b
12 files changed
+672 -1

No files matched your search

@@ -0,0 +1,5 @@
CREATE UNIQUE INDEX IF NOT EXISTS "issues_delegated_recovery_fingerprint_uq"
ON "issues" USING btree ("company_id", "origin_kind", "origin_fingerprint")
WHERE "origin_kind" = 'delegated_recovery'
AND "origin_fingerprint" <> 'default'
AND "hidden_at" IS NULL;
@@ -1366,6 +1366,13 @@
"when": 1785170001001,
"tag": "0196_drop_cloud_upstream_tables",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1785172216294,
"tag": "0197_delegated_recovery_issue_dedupe",
"breakpoints": true
}
]
}
+7
View File
@@ -168,5 +168,12 @@ export const issues = pgTable(
and ${table.hiddenAt} is null
and ${table.status} not in ('done', 'cancelled')`,
),
delegatedRecoveryFingerprintIdx: uniqueIndex("issues_delegated_recovery_fingerprint_uq")
.on(table.companyId, table.originKind, table.originFingerprint)
.where(
sql`${table.originKind} = 'delegated_recovery'
and ${table.originFingerprint} <> 'default'
and ${table.hiddenAt} is null`,
),
}),
);
+1
View File
@@ -293,6 +293,7 @@ export const ISSUE_ORIGIN_KINDS = [
"harness_liveness_escalation",
"issue_productivity_review",
"stranded_issue_recovery",
"delegated_recovery",
"task_watchdog",
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
] as const;
+2
View File
@@ -1572,6 +1572,7 @@ export {
updateIssueSchema,
issueExecutionPolicySchema,
issueExecutionStateSchema,
delegateIssueRecoveryActionSchema,
resolveIssueRecoveryActionSchema,
issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema,
@@ -1668,6 +1669,7 @@ export {
type CreateAcceptedPlanDecomposition,
type CreateIssueLabel,
type UpdateIssue,
type DelegateIssueRecoveryAction,
type ResolveIssueRecoveryAction,
type CheckoutIssue,
type AddIssueComment,
+2
View File
@@ -384,6 +384,7 @@ export {
issueExecutionPolicySchema,
issueExecutionStateSchema,
issueRecoveryActionReadModelSchema,
delegateIssueRecoveryActionSchema,
resolveIssueRecoveryActionSchema,
issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema,
@@ -441,6 +442,7 @@ export {
type UpdateIssue,
type IssueExecutionWorkspaceSettings,
type IssueRecoveryActionReadModel,
type DelegateIssueRecoveryAction,
type ResolveIssueRecoveryAction,
type CheckoutIssue,
type AddIssueComment,
@@ -3,6 +3,7 @@ import { MAX_ISSUE_REQUEST_DEPTH } from "../index.js";
import {
addIssueCommentSchema,
createIssueSchema,
delegateIssueRecoveryActionSchema,
issueBlockedInboxAttentionSchema,
resolveIssueRecoveryActionSchema,
respondIssueThreadInteractionSchema,
@@ -142,6 +143,26 @@ describe("issue validators", () => {
).toBe(false);
});
it("accepts delegated recovery action requests for the CEO target", () => {
const actionId = "11111111-1111-4111-8111-111111111111";
expect(
delegateIssueRecoveryActionSchema.parse({
actionId,
target: "ceo",
}),
).toEqual({
actionId,
target: "ceo",
});
expect(
delegateIssueRecoveryActionSchema.safeParse({
actionId,
target: "cto",
}).success,
).toBe(false);
});
it("allows restored recovery resolutions to return the source issue to todo", () => {
expect(
resolveIssueRecoveryActionSchema.parse({
+7
View File
@@ -317,6 +317,13 @@ export const issueRecoveryActionReadModelSchema = z.object({
export type IssueRecoveryActionReadModel = z.infer<typeof issueRecoveryActionReadModelSchema>;
export const delegateIssueRecoveryActionSchema = z.object({
actionId: z.string().uuid(),
target: z.enum(["ceo"]),
}).strict();
export type DelegateIssueRecoveryAction = z.infer<typeof delegateIssueRecoveryActionSchema>;
const RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES = [
"restored",
"false_positive",
@@ -12,10 +12,13 @@ import {
environmentLeases,
environments,
heartbeatRuns,
heartbeatRunEvents,
issueComments,
issueRecoveryActions,
issueRelations,
issues,
goals,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -137,10 +140,13 @@ describeEmbeddedPostgres("issue recovery actions", () => {
await db.delete(issueComments);
await db.delete(environmentLeases);
await db.delete(activityLog);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(environments);
await db.delete(issues);
await db.delete(projects);
await db.delete(goals);
await db.delete(agents);
await db.delete(companies);
});
@@ -200,6 +206,53 @@ describeEmbeddedPostgres("issue recovery actions", () => {
return { companyId, managerId, coderId, sourceIssueId, prefix, sourceIssue: sourceIssue! };
}
async function seedCeo(companyId: string) {
const ceoId = randomUUID();
await db.insert(agents).values({
id: ceoId,
companyId,
name: "CEO",
role: "ceo",
status: "idle",
reportsTo: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
return ceoId;
}
async function seedSourceContext(input: { companyId: string; sourceIssueId: string }) {
const goalId = randomUUID();
const projectId = randomUUID();
await db.insert(goals).values({
id: goalId,
companyId: input.companyId,
title: "Recovery Goal",
level: "company",
status: "active",
});
await db.insert(projects).values({
id: projectId,
companyId: input.companyId,
name: "Recovery Project",
status: "in_progress",
goalId,
});
await db
.update(issues)
.set({
projectId,
goalId,
billingCode: "platform-recovery",
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "reuse_existing" },
})
.where(eq(issues.id, input.sourceIssueId));
return { goalId, projectId, billingCode: "platform-recovery" };
}
async function seedHeartbeatRun(input: {
companyId: string;
agentId: string;
@@ -1275,6 +1328,267 @@ describeEmbeddedPostgres("issue recovery actions", () => {
});
});
it("delegates an active recovery action to a CEO-owned repair issue and blocks the source", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const ceoId = await seedCeo(companyId);
const context = await seedSourceContext({ companyId, sourceIssueId });
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "workspace_validation",
ownerType: "agent",
ownerAgentId: managerId,
cause: "workspace_validation_failed",
fingerprint: "workspace-validation:broken-worktree",
evidence: { reason: "git_worktree_branch_incoherence" },
nextAction: "Repair the source issue workspace link before resuming.",
wakePolicy: { type: "manual_repair_required" },
});
const app = createApp();
const delegated = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/delegate`)
.send({
actionId: action.id,
target: "ceo",
})
.expect(201);
expect(delegated.body.issue).toMatchObject({
id: sourceIssueId,
status: "blocked",
activeRecoveryAction: null,
});
expect(delegated.body.recoveryIssue).toMatchObject({
assigneeAgentId: ceoId,
status: "todo",
projectId: context.projectId,
goalId: context.goalId,
billingCode: context.billingCode,
originKind: "delegated_recovery",
originId: sourceIssueId,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
});
expect(delegated.body.recoveryIssue.originFingerprint).toBe(`delegated_recovery:${action.id}:target:ceo`);
expect(delegated.body.recoveryAction).toMatchObject({
id: action.id,
status: "resolved",
outcome: "delegated",
recoveryIssueId: delegated.body.recoveryIssue.id,
});
const [blockerRelation] = await db
.select()
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.issueId, delegated.body.recoveryIssue.id),
eq(issueRelations.relatedIssueId, sourceIssueId),
eq(issueRelations.type, "blocks"),
),
);
expect(blockerRelation).toBeTruthy();
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "resolved",
outcome: "delegated",
recoveryIssueId: delegated.body.recoveryIssue.id,
});
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("reuses the delegated recovery issue when the delegate action is clicked again", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await seedCeo(companyId);
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "workspace_validation",
ownerType: "agent",
ownerAgentId: managerId,
cause: "workspace_validation_failed",
fingerprint: "workspace-validation:duplicate-click",
evidence: { reason: "git_worktree_branch_incoherence" },
nextAction: "Repair the source issue workspace link before resuming.",
wakePolicy: { type: "manual_repair_required" },
});
const app = createApp();
const first = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/delegate`)
.send({ actionId: action.id, target: "ceo" })
.expect(201);
const second = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/delegate`)
.send({ actionId: action.id, target: "ceo" })
.expect(200);
expect(second.body).toMatchObject({
reused: true,
recoveryIssue: {
id: first.body.recoveryIssue.id,
},
recoveryAction: {
id: action.id,
status: "resolved",
outcome: "delegated",
recoveryIssueId: first.body.recoveryIssue.id,
},
});
const delegatedIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "delegated_recovery")));
expect(delegatedIssues).toHaveLength(1);
});
it("rejects delegated recovery when no assignable CEO target exists", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "workspace_validation",
ownerType: "agent",
ownerAgentId: managerId,
cause: "workspace_validation_failed",
fingerprint: "workspace-validation:no-ceo",
evidence: { reason: "git_worktree_branch_incoherence" },
nextAction: "Repair the source issue workspace link before resuming.",
wakePolicy: { type: "manual_repair_required" },
});
const app = createApp();
const rejected = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/delegate`)
.send({ actionId: action.id, target: "ceo" })
.expect(422);
expect(rejected.body.error).toContain("No assignable CEO recovery target");
const delegatedIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "delegated_recovery")));
expect(delegatedIssues).toHaveLength(0);
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow?.status).toBe("active");
});
it("enforces company scope when delegating recovery actions", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await seedCeo(companyId);
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "workspace_validation",
ownerType: "agent",
ownerAgentId: managerId,
cause: "workspace_validation_failed",
fingerprint: "workspace-validation:cross-company",
evidence: { reason: "git_worktree_branch_incoherence" },
nextAction: "Repair the source issue workspace link before resuming.",
wakePolicy: { type: "manual_repair_required" },
});
const app = createApp({
type: "agent",
agentId: randomUUID(),
companyId: randomUUID(),
runId: randomUUID(),
source: "agent_jwt",
});
await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/delegate`)
.send({ actionId: action.id, target: "ceo" })
.expect(403);
const delegatedIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "delegated_recovery")));
expect(delegatedIssues).toHaveLength(0);
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow?.status).toBe("active");
});
it("rejects peer-agent delegated recovery outside the recovery authority boundary", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await seedCeo(companyId);
const peerAgentId = randomUUID();
await db.insert(agents).values({
id: peerAgentId,
companyId,
name: "Peer",
role: "engineer",
status: "idle",
reportsTo: managerId,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "workspace_validation",
ownerType: "agent",
ownerAgentId: managerId,
cause: "workspace_validation_failed",
fingerprint: "workspace-validation:peer-agent",
evidence: { reason: "git_worktree_branch_incoherence" },
nextAction: "Repair the source issue workspace link before resuming.",
wakePolicy: { type: "manual_repair_required" },
});
const app = createApp({
type: "agent",
agentId: peerAgentId,
companyId,
runId: randomUUID(),
source: "agent_jwt",
});
await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/delegate`)
.send({ actionId: action.id, target: "ceo" })
.expect(403);
const delegatedIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "delegated_recovery")));
expect(delegatedIssues).toHaveLength(0);
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow?.status).toBe("active");
});
it("resolves an active recovery action and removes it from active projections", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
+279 -1
View File
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import { and, asc, desc, eq, inArray, isNull, notInArray } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
@@ -44,6 +44,7 @@ import {
createDocumentAnnotationThreadSchema,
createChildIssueSchema,
createIssueSchema,
delegateIssueRecoveryActionSchema,
resolveCreateIssueStatusDefault,
resolveIssueRecoveryActionSchema,
feedbackTargetTypeSchema,
@@ -168,6 +169,7 @@ import {
ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
readAcceptedPlanConfirmationTarget,
} from "../services/issues.js";
import { assertAssignableAgent } from "../services/agent-assignability.js";
import { authorizationDeniedDetails } from "../services/authorization.js";
import { environmentService } from "../services/environments.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
@@ -4345,6 +4347,85 @@ export function issueRoutes(
return false;
}
async function resolveDelegatedRecoveryTargetAgentId(companyId: string, target: "ceo") {
if (target !== "ceo") {
throw unprocessable("Unsupported delegated recovery target");
}
const ceoCandidates = await db
.select({
id: agents.id,
})
.from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.role, "ceo")))
.orderBy(sql`case when ${agents.reportsTo} is null then 0 else 1 end`, asc(agents.createdAt));
for (const candidate of ceoCandidates) {
try {
await assertAssignableAgent(db, companyId, candidate.id, { kind: "work" });
return candidate.id;
} catch (error) {
if (error instanceof HttpError && [404, 409, 422].includes(error.status)) continue;
throw error;
}
}
throw unprocessable("No assignable CEO recovery target exists in this company");
}
function delegatedRecoveryFingerprint(actionId: string, target: "ceo") {
return `delegated_recovery:${actionId}:target:${target}`;
}
function isDelegatedRecoveryIssueConflict(error: unknown) {
const maybe = error as { code?: string; constraint?: string; constraint_name?: string; message?: string } | null;
return Boolean(
maybe &&
maybe.code === "23505" &&
(
maybe.constraint === "issues_delegated_recovery_fingerprint_uq" ||
maybe.constraint_name === "issues_delegated_recovery_fingerprint_uq" ||
typeof maybe.message === "string" && maybe.message.includes("issues_delegated_recovery_fingerprint_uq")
),
);
}
async function getDelegatedRecoveryIssue(companyId: string, fingerprint: string) {
return db
.select()
.from(issueRows)
.where(
and(
eq(issueRows.companyId, companyId),
eq(issueRows.originKind, "delegated_recovery"),
eq(issueRows.originFingerprint, fingerprint),
isNull(issueRows.hiddenAt),
),
)
.orderBy(desc(issueRows.createdAt))
.limit(1)
.then((rows) => rows[0] ?? null);
}
function delegatedRecoveryDescription(input: {
sourceIssue: { id: string; identifier: string | null; title: string };
recoveryActionId: string;
target: "ceo";
}) {
const identifier = input.sourceIssue.identifier ?? input.sourceIssue.id;
const prefix = input.sourceIssue.identifier?.split("-")[0] || "PAP";
return [
"Paperclip delegated a source-scoped recovery action into a fresh repair task.",
"",
`- Source issue: [${identifier}](/${prefix}/issues/${identifier})`,
`- Source title: ${input.sourceIssue.title}`,
`- Recovery action: \`${input.recoveryActionId}\``,
`- Delegation target: \`${input.target}\``,
"",
"Repair the source issue's broken execution path or record a clear disposition, then resolve the source blocker.",
].join("\n");
}
async function resolveActiveIssueRun(issue: {
id: string;
assigneeAgentId: string | null;
@@ -5646,6 +5727,203 @@ export function issueRoutes(
});
});
router.post(
"/issues/:id/recovery-actions/delegate",
validate(delegateIssueRecoveryActionSchema),
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, target } = req.body;
const activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id);
if (activeRecoveryAction?.id !== actionId) {
const resolvedAction = await recoveryActionsSvc.getById(existing.companyId, actionId);
if (
resolvedAction?.sourceIssueId === existing.id &&
resolvedAction.status === "resolved" &&
resolvedAction.outcome === "delegated" &&
resolvedAction.recoveryIssueId
) {
const recoveryIssue = await svc.getById(resolvedAction.recoveryIssueId);
if (recoveryIssue && recoveryIssue.companyId === existing.companyId) {
res.json({
issue: existing,
recoveryIssue,
recoveryAction: resolvedAction,
reused: true,
});
return;
}
}
res.status(404).json({ error: "Active recovery action not found" });
return;
}
if (
!(await assertRecoveryActionAuthority(
req,
res,
existing,
activeRecoveryAction,
{ source: "recovery_action_resolution" },
))
) {
return;
}
const actor = getActorInfo(req);
const assigneeAgentId = await resolveDelegatedRecoveryTargetAgentId(existing.companyId, target);
const fingerprint = delegatedRecoveryFingerprint(actionId, target);
let recoveryIssue = await getDelegatedRecoveryIssue(existing.companyId, fingerprint);
let createdRecoveryIssue = false;
if (!recoveryIssue) {
try {
recoveryIssue = await svc.create(existing.companyId, {
title: `Repair recovery path for ${existing.identifier ?? existing.title}`,
description: delegatedRecoveryDescription({
sourceIssue: existing,
recoveryActionId: actionId,
target,
}),
status: "todo",
priority: existing.priority,
workMode: "standard",
assigneeAgentId,
parentId: existing.parentId ?? null,
projectId: existing.projectId ?? null,
goalId: existing.goalId ?? null,
billingCode: existing.billingCode ?? null,
originKind: "delegated_recovery",
originId: existing.id,
originFingerprint: fingerprint,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
actorRunId: actor.runId,
actorResponsibleUserId: authenticatedActorResponsibleUserId(req),
trustExplicitResponsibleUserId: actor.actorType === "user",
});
createdRecoveryIssue = true;
} catch (error) {
if (!isDelegatedRecoveryIssueConflict(error)) throw error;
recoveryIssue = await getDelegatedRecoveryIssue(existing.companyId, fingerprint);
if (!recoveryIssue) throw error;
}
}
const sourceRelations = await svc.getRelationSummaries(existing.id);
const existingBlockerIds = sourceRelations.blockedBy.map((blocker) => blocker.id);
const nextBlockedByIssueIds = [...new Set([...existingBlockerIds, recoveryIssue.id])];
const updatedSource = await svc.update(existing.id, {
status: "blocked",
blockedByIssueIds: nextBlockedByIssueIds,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
});
if (!updatedSource) throw notFound("Issue not found");
const recoveryAction = await recoveryActionsSvc.resolveActiveForIssue({
companyId: existing.companyId,
sourceIssueId: existing.id,
actionId,
recoveryIssueId: recoveryIssue.id,
status: "resolved",
outcome: "delegated",
resolutionNote: `Delegated to ${target} recovery issue ${recoveryIssue.identifier ?? recoveryIssue.id}.`,
});
if (!recoveryAction) throw notFound("Active recovery action not found");
await issueReferencesSvc.syncIssue(recoveryIssue.id);
await externalObjectsSvc.syncIssueSafely(recoveryIssue.id);
await routinesSvc.syncRunStatusForIssue(updatedSource.id);
if (createdRecoveryIssue) {
await logActivity(db, {
companyId: recoveryIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.created",
entityType: "issue",
entityId: recoveryIssue.id,
details: {
title: recoveryIssue.title,
identifier: recoveryIssue.identifier,
source: "recovery_action_delegate",
sourceIssueId: existing.id,
sourceIssueIdentifier: existing.identifier,
recoveryActionId: actionId,
target,
},
});
}
await logActivity(db, {
companyId: updatedSource.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: updatedSource.id,
details: {
identifier: updatedSource.identifier,
status: updatedSource.status,
source: "recovery_action_delegate",
recoveryActionId: actionId,
recoveryIssueId: recoveryIssue.id,
blockedByIssueIds: nextBlockedByIssueIds,
_previous: {
status: existing.status,
blockedByIssueIds: existingBlockerIds,
},
},
});
await logActivity(db, {
companyId: updatedSource.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.recovery_action_resolved",
entityType: "issue",
entityId: updatedSource.id,
details: {
identifier: updatedSource.identifier,
recoveryActionId: recoveryAction.id,
recoveryActionStatus: recoveryAction.status,
outcome: recoveryAction.outcome,
recoveryIssueId: recoveryIssue.id,
target,
sourceIssueStatus: updatedSource.status,
resolutionNote: recoveryAction.resolutionNote,
},
});
res.status(createdRecoveryIssue ? 201 : 200).json({
issue: {
...updatedSource,
activeRecoveryAction: null,
},
recoveryIssue,
recoveryAction,
reused: !createdRecoveryIssue,
});
},
);
router.post("/issues/:id/recovery-actions/resolve", validate(resolveIssueRecoveryActionSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
+9
View File
@@ -29,6 +29,7 @@ import {
createIssueLabelSchema,
addIssueCommentSchema,
checkoutIssueSchema,
delegateIssueRecoveryActionSchema,
linkIssueApprovalSchema,
createIssueWorkProductSchema,
updateIssueWorkProductSchema,
@@ -5934,6 +5935,14 @@ registerCurrentRoute({
summary: "List issue recovery actions",
});
registerCurrentRoute({
method: "post",
path: "/api/issues/{id}/recovery-actions/delegate",
tags: ["issues"],
summary: "Delegate an issue recovery action",
body: delegateIssueRecoveryActionSchema,
});
registerCurrentRoute({
method: "post",
path: "/api/issues/{id}/recovery-actions/resolve",
@@ -44,6 +44,7 @@ export type ResolveIssueRecoveryActionInput = {
kind?: IssueRecoveryActionKind | null;
cause?: string | null;
fingerprint?: string | null;
recoveryIssueId?: string | null;
status: Extract<IssueRecoveryActionStatus, "resolved" | "cancelled">;
outcome: IssueRecoveryActionOutcome;
resolutionNote?: string | null;
@@ -160,6 +161,21 @@ export function issueRecoveryActionService(db: Db) {
return result;
}
async function getById(companyId: string, actionId: string): Promise<IssueRecoveryAction | null> {
const row = await db
.select()
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
eq(issueRecoveryActions.id, actionId),
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
return row ? toReadModel(row) : null;
}
async function retryUpsertSourceScoped(
input: UpsertIssueRecoveryActionInput,
retryCount: number,
@@ -287,6 +303,7 @@ export function issueRecoveryActionService(db: Db) {
.update(issueRecoveryActions)
.set({
status: input.status,
...(input.recoveryIssueId !== undefined ? { recoveryIssueId: input.recoveryIssueId } : {}),
outcome: input.outcome,
resolutionNote: input.resolutionNote ?? null,
resolvedAt: now,
@@ -299,6 +316,7 @@ export function issueRecoveryActionService(db: Db) {
}
return {
getById,
getActiveForIssue,
listActiveForIssues,
resolveActiveForIssue,