import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, agents, companies, createDb, heartbeatRuns, issueComments, issueRelations, issues, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { errorHandler } from "../middleware/index.js"; import { issueRoutes } from "../routes/issues.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( `Skipping embedded Postgres stale execution lock route tests on this host: ${ embeddedPostgresSupport.reason ?? "unsupported environment" }`, ); } describeEmbeddedPostgres("stale issue execution lock routes", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-stale-execution-lock-routes-"); db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { await db.delete(issueComments); await db.delete(issueRelations); await db.delete(activityLog); await db.delete(issues); await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(companies); }); afterAll(async () => { await tempDb?.cleanup(); }); function createApp(actor: Express.Request["actor"]) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); }); app.use("/api", issueRoutes(db, {} as any)); app.use(errorHandler); return app; } async function seedCompanyAgentAndRuns() { const companyId = randomUUID(); const agentId = randomUUID(); const failedRunId = randomUUID(); const currentRunId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await db.insert(agents).values({ id: agentId, companyId, name: "CodexCoder", role: "engineer", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }); await db.insert(heartbeatRuns).values([ { id: failedRunId, companyId, agentId, status: "failed", invocationSource: "manual", finishedAt: new Date(), }, { id: currentRunId, companyId, agentId, status: "running", invocationSource: "manual", startedAt: new Date(), }, ]); return { companyId, agentId, failedRunId, currentRunId }; } function agentActor(companyId: string, agentId: string, runId: string): Express.Request["actor"] { return { type: "agent", agentId, companyId, runId, source: "agent_jwt", }; } function boardActor(companyId: string): Express.Request["actor"] { return { type: "board", userId: "board-user", companyIds: [companyId], memberships: [{ companyId, membershipRole: "admin", status: "active" }], isInstanceAdmin: false, source: "session", }; } it("allows an assigned agent PATCH to recover a terminal stale executionRunId", async () => { const { companyId, agentId, failedRunId, currentRunId } = await seedCompanyAgentAndRuns(); const issueId = randomUUID(); await db.insert(issues).values({ id: issueId, companyId, title: "Stale execution lock", status: "in_progress", priority: "high", assigneeAgentId: agentId, checkoutRunId: null, executionRunId: failedRunId, executionAgentNameKey: "codexcoder", executionLockedAt: new Date(), }); const res = await request(createApp(agentActor(companyId, agentId, currentRunId))) .patch(`/api/issues/${issueId}`) .send({ title: "Recovered execution lock" }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(res.body.title).toBe("Recovered execution lock"); const row = await db .select({ title: issues.title, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0]); expect(row).toEqual({ title: "Recovered execution lock", checkoutRunId: currentRunId, executionRunId: currentRunId, }); }); it("allows the rightful assignee to release after the owning run failed", async () => { const { companyId, agentId, failedRunId, currentRunId } = await seedCompanyAgentAndRuns(); const issueId = randomUUID(); await db.insert(issues).values({ id: issueId, companyId, title: "Failed run release", status: "in_progress", priority: "high", assigneeAgentId: agentId, checkoutRunId: failedRunId, executionRunId: failedRunId, executionAgentNameKey: "codexcoder", executionLockedAt: new Date(), }); const res = await request(createApp(agentActor(companyId, agentId, currentRunId))) .post(`/api/issues/${issueId}/release`) .send(); expect(res.status, JSON.stringify(res.body)).toBe(200); const row = await db .select({ status: issues.status, assigneeAgentId: issues.assigneeAgentId, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, executionLockedAt: issues.executionLockedAt, }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0]); expect(row).toEqual({ status: "todo", assigneeAgentId: null, checkoutRunId: null, executionRunId: null, executionLockedAt: null, }); }); it("restricts admin force-release to board users with company access and writes an audit event", async () => { const { companyId, agentId, failedRunId, currentRunId } = await seedCompanyAgentAndRuns(); const issueId = randomUUID(); await db.insert(issues).values({ id: issueId, companyId, title: "Admin force release", status: "in_progress", priority: "high", assigneeAgentId: agentId, checkoutRunId: currentRunId, executionRunId: failedRunId, executionAgentNameKey: "codexcoder", executionLockedAt: new Date(), }); await request(createApp(agentActor(companyId, agentId, currentRunId))) .post(`/api/issues/${issueId}/admin/force-release`) .expect(403); await request(createApp({ type: "board", userId: "outside-user", companyIds: [], memberships: [], isInstanceAdmin: false, source: "session", })) .post(`/api/issues/${issueId}/admin/force-release`) .expect(403); const res = await request(createApp(boardActor(companyId))) .post(`/api/issues/${issueId}/admin/force-release?clearAssignee=true`) .send(); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(res.body.issue).toMatchObject({ id: issueId, assigneeAgentId: null, checkoutRunId: null, executionRunId: null, executionLockedAt: null, }); expect(res.body.previous).toEqual({ checkoutRunId: currentRunId, executionRunId: failedRunId, }); const audit = await db .select({ action: activityLog.action, actorType: activityLog.actorType, actorId: activityLog.actorId, details: activityLog.details, }) .from(activityLog) .where(eq(activityLog.action, "issue.admin_force_release")) .then((rows) => rows[0]); expect(audit).toMatchObject({ action: "issue.admin_force_release", actorType: "user", actorId: "board-user", details: { issueId, actorUserId: "board-user", prevCheckoutRunId: currentRunId, prevExecutionRunId: failedRunId, clearAssignee: true, }, }); }); });