From 82d4e4162bd94b8d4b3a787e83bbd5defd4d31b1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 6 Jul 2026 13:11:19 +0000 Subject: [PATCH] Implement delegated recovery backend contract Co-Authored-By: Paperclip --- .../0197_delegated_recovery_issue_dedupe.sql | 5 + packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/issues.ts | 7 + packages/shared/src/constants.ts | 1 + packages/shared/src/index.ts | 2 + packages/shared/src/validators/index.ts | 2 + packages/shared/src/validators/issue.test.ts | 21 ++ packages/shared/src/validators/issue.ts | 7 + .../__tests__/issue-recovery-actions.test.ts | 314 ++++++++++++++++++ server/src/routes/issues.ts | 280 +++++++++++++++- server/src/routes/openapi.ts | 9 + server/src/services/issue-recovery-actions.ts | 18 + 12 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 packages/db/src/migrations/0197_delegated_recovery_issue_dedupe.sql diff --git a/packages/db/src/migrations/0197_delegated_recovery_issue_dedupe.sql b/packages/db/src/migrations/0197_delegated_recovery_issue_dedupe.sql new file mode 100644 index 00000000000..4800520a271 --- /dev/null +++ b/packages/db/src/migrations/0197_delegated_recovery_issue_dedupe.sql @@ -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; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 21e680bc2db..ff1fd0bc523 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index 5be902a3252..e8e5eeaf11b 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -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`, + ), }), ); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 42c002ef809..e82b8515682 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 521616c7bdc..cd8d034109e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 0dca4411082..e1c0f6814a7 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -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, diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 57650ba0df6..4df11cf7a0b 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -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({ diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index e7a2746ca1b..9c06c1a8d4b 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -317,6 +317,13 @@ export const issueRecoveryActionReadModelSchema = z.object({ export type IssueRecoveryActionReadModel = z.infer; +export const delegateIssueRecoveryActionSchema = z.object({ + actionId: z.string().uuid(), + target: z.enum(["ceo"]), +}).strict(); + +export type DelegateIssueRecoveryAction = z.infer; + const RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES = [ "restored", "false_positive", diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 302065e96ed..6bd99207e01 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -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); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f414934ec71..2b798c823d9 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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"); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 43fd6a4fe48..252865c429e 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/services/issue-recovery-actions.ts b/server/src/services/issue-recovery-actions.ts index 3ed00bc863b..0100715b850 100644 --- a/server/src/services/issue-recovery-actions.ts +++ b/server/src/services/issue-recovery-actions.ts @@ -44,6 +44,7 @@ export type ResolveIssueRecoveryActionInput = { kind?: IssueRecoveryActionKind | null; cause?: string | null; fingerprint?: string | null; + recoveryIssueId?: string | null; status: Extract; outcome: IssueRecoveryActionOutcome; resolutionNote?: string | null; @@ -160,6 +161,21 @@ export function issueRecoveryActionService(db: Db) { return result; } + async function getById(companyId: string, actionId: string): Promise { + 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,