mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Implement delegated recovery backend contract
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user