import express from "express"; import request from "supertest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), getByIdForUpdate: vi.fn(), findOpenAncestorCreatedByAgent: vi.fn(async () => null), assertCheckoutOwner: vi.fn(), update: vi.fn(), createChild: vi.fn(), addComment: vi.fn(), findMentionedAgents: vi.fn(), getRelationSummaries: vi.fn(), listWakeableBlockedDependents: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), })); const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), triggerIssueMonitor: vi.fn(async () => ({ outcome: "triggered" as const })), reportRunActivity: vi.fn(async () => undefined), getRun: vi.fn(async () => null), getActiveRunForAgent: vi.fn(async () => null), cancelRun: vi.fn(async () => null), })); const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(async () => false), decide: vi.fn(), hasPermission: vi.fn(async () => false), })); const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({ for: () => ({ then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => Promise.resolve([{ id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", agentId: "33333333-3333-4333-8333-333333333333", contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }, permissions: null, }]).then(onFulfilled, onRejected), }), then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => Promise.resolve([{ id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", agentId: "33333333-3333-4333-8333-333333333333", contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }, permissions: null, }]).then(onFulfilled, onRejected), }))); const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere }))); const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom }))); const mockDb = vi.hoisted(() => ({ select: mockDbSelect, transaction: vi.fn(async (callback: (tx: { select: typeof mockDbSelect }) => Promise) => callback({ select: mockDbSelect })), })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockIssueThreadInteractionService = vi.hoisted(() => ({ expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), listForIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), })); const mockIssueApprovalService = vi.hoisted(() => ({ listApprovalsForIssue: vi.fn(async () => []), })); const mockRunnerGoalService = vi.hoisted(() => ({ projection: vi.fn(async () => null), act: vi.fn(), })); function registerModuleMocks() { vi.doMock("../services/queued-interaction-response.js", () => ({ hasQueuedInteractionResponse: vi.fn(async () => false), })); vi.doMock("../services/runner-goals.js", () => ({ runnerGoalService: () => mockRunnerGoalService, RunnerGoalActionError: class RunnerGoalActionError extends Error {}, RunnerGoalConflictError: class RunnerGoalConflictError extends Error {}, })); vi.doMock("../services/index.js", () => ({ companyService: () => ({ getById: vi.fn(async () => ({ id: "company-1" })), }), accessService: () => mockAccessService, agentService: () => ({ getById: vi.fn(async (agentId: string) => ({ id: agentId, companyId: "company-1", permissions: null, })), resolveByReference: vi.fn(async (_companyId: string, reference: string) => ({ ambiguous: false, agent: { id: reference, companyId: "company-1", status: "idle", orgChainHealth: { status: "healthy" }, }, })), }), companySkillService: () => ({ completeTestRunForIssue: vi.fn(async () => null), }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => ({}), feedbackService: () => ({ listIssueVotesForUser: vi.fn(async () => []), saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), }), goalService: () => ({}), heartbeatService: () => mockHeartbeatService, environmentService: () => ({ getById: vi.fn(async () => null), }), instanceSettingsService: () => ({ get: vi.fn(async () => ({ id: "instance-settings-1", general: { censorUsernameInLogs: false, feedbackDataSharingPreference: "prompt", }, })), listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => mockIssueApprovalService, issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ addedReferencedIssues: [], removedReferencedIssues: [], currentReferencedIssues: [], }), emptySummary: () => ({ outbound: [], inbound: [] }), listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), syncComment: async () => undefined, syncDocument: async () => undefined, syncIssue: async () => undefined, }), issueRecoveryActionService: () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), issueService: () => mockIssueService, issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined), }), workProductService: () => ({}), })); } type TestActor = | { type: "board"; userId: string; companyIds: string[]; source: "local_implicit"; isInstanceAdmin: boolean; } | { type: "agent"; agentId: string; companyId: string; runId: string | null; }; function loadAppModules() { return Promise.all([ import("../middleware/index.js"), import("../routes/issues.js"), ]); } async function createApp(actor?: TestActor) { const [{ errorHandler }, { issueRoutes }] = await loadAppModules(); const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = actor ?? { type: "board", userId: "local-board", companyIds: ["company-1"], source: "local_implicit", isInstanceAdmin: false, }; next(); }); app.use("/api", issueRoutes(mockDb as any, {} as any)); app.use(errorHandler); return app; } describe("issue execution policy routes", () => { beforeAll(async () => { vi.resetModules(); vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); await loadAppModules(); }); beforeEach(() => { vi.clearAllMocks(); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.getByIdForUpdate.mockImplementation(async () => mockIssueService.getById()); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]); mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockResolvedValue([]); mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([]); mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom })); mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere })); mockDbSelectWhere.mockImplementation(() => ({ for: () => ({ then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => Promise.resolve([{ id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", agentId: "33333333-3333-4333-8333-333333333333", contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }, permissions: null, }]).then(onFulfilled, onRejected), }), then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => Promise.resolve([{ id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", agentId: "33333333-3333-4333-8333-333333333333", contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }, permissions: null, }]).then(onFulfilled, onRejected), })); mockIssueService.createChild.mockResolvedValue({ issue: { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", companyId: "company-1", identifier: "PAP-1002", title: "Child issue", }, parentBlockerAdded: false, }); mockAccessService.canUser.mockResolvedValue(false); mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => { const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit" ? true : input.actor?.type === "agent" && [ "company_scope:read", "issue:read", "issue:mutate", "runtime:manage", ].includes(input.action ?? "") ? true : Boolean(await mockAccessService.canUser() || await mockAccessService.hasPermission()); return { allowed, action: input.action, reason: allowed ? "allow_explicit_grant" : "deny_missing_grant", explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`, }; }); mockAccessService.hasPermission.mockResolvedValue(false); }); it.each([ { status: "done" }, { status: "cancelled" }, { assigneeAgentId: "33333333-3333-4333-8333-333333333333" }, { assigneeUserId: "operator" }, { interrupt: true }, { comment: "Continue now" }, ])("rejects mixed snapshot-guarded updates before execution side effects: %j", async (controlFields) => { const res = await request(await createApp({ type: "board", source: "local_implicit", userId: "local-board" })) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ expectedExecutionPolicy: null, executionPolicy: null, ...controlFields }); expect(res.status).toBe(422); expect(res.body.error).toContain("policy-only"); expect(mockIssueService.getById).not.toHaveBeenCalled(); expect(mockIssueService.update).not.toHaveBeenCalled(); expect(mockRunnerGoalService.act).not.toHaveBeenCalled(); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); }); it("rejects a snapshot without a policy update", async () => { const res = await request(await createApp({ type: "board", source: "local_implicit", userId: "local-board" })) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ expectedExecutionPolicy: null }); expect(res.status).toBe(422); expect(mockIssueService.getById).not.toHaveBeenCalled(); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("rejects a stale status version before changing or waking a task", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "backlog", statusVersion: 8, title: "Prepared task", identifier: "TEST-1", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockResolvedValue({ ...issue, status: "todo", statusVersion: 9 }); const res = await request(await createApp()) .patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: 7 }); expect(res.status).toBe(409); expect(res.body.details).toMatchObject({ code: "issue_status_version_changed" }); expect(mockIssueService.update).not.toHaveBeenCalled(); expect(mockLogActivity).not.toHaveBeenCalled(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); }); it("does not adopt a checkout when a prepared task became active before promotion", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_progress", statusVersion: 8, title: "Active task", identifier: "TEST-1", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.assertCheckoutOwner.mockResolvedValueOnce({ adoptedFromRunId: "previous-run" }); const res = await request(await createApp({ type: "agent", agentId: issue.assigneeAgentId, companyId: "company-1", runId: "55555555-5555-4555-8555-555555555555", })).patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: 7 }); expect(res.status).toBe(409); expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); expect(mockLogActivity).not.toHaveBeenCalled(); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("forwards a matching status version to the locked update", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "backlog", statusVersion: 7, title: "Prepared task", identifier: "TEST-1", assigneeAgentId: null, assigneeUserId: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockResolvedValue({ ...issue, status: "todo", statusVersion: 8 }); const res = await request(await createApp()) .patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: 7 }); expect(res.status).toBe(200); expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({ status: "todo", expectedStatusVersion: 7, }); }); it("does not audit or wake a promotion rejected by the locked version check", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "backlog", statusVersion: 7, title: "Prepared task", identifier: "TEST-1", assigneeAgentId: null, assigneeUserId: null, }; mockIssueService.getById.mockResolvedValue(issue); const { conflict } = await import("../errors.js"); mockIssueService.update.mockRejectedValueOnce(conflict("The task changed", { code: "issue_status_version_changed" })); const res = await request(await createApp()) .patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: 7 }); expect(res.status).toBe(409); expect(res.body.details).toMatchObject({ code: "issue_status_version_changed" }); expect(mockLogActivity).not.toHaveBeenCalled(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); }); it.each([ { status: "done" }, { status: "cancelled" }, { title: "Changed" }, { assigneeAgentId: "33333333-3333-4333-8333-333333333333" }, { comment: "Start work" }, { interrupt: true }, { executionPolicy: null }, ])("rejects mixed version-guarded promotions before execution effects: %j", async (fields) => { const res = await request(await createApp()).patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "todo", expectedStatusVersion: 0, ...fields }); expect(res.status).toBe(422); expect(mockIssueService.getById).not.toHaveBeenCalled(); expect(mockIssueService.update).not.toHaveBeenCalled(); expect(mockLogActivity).not.toHaveBeenCalled(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); }); it("reauthorizes a terminal verdict against the review policy held under the update lock", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_review", reviewPolicy: "anyone", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1002", title: "Concurrent policy update", executionPolicy: null, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.getByIdForUpdate.mockResolvedValue({ ...issue, reviewPolicy: "human_only", }); const res = await request(await createApp({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "55555555-5555-4555-8555-555555555555", })) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "done" }); expect(res.status).toBe(403); expect(res.body).toMatchObject({ details: { code: "review_policy_denied", policy: "human_only", }, }); expect(mockDb.transaction).toHaveBeenCalled(); expect(mockIssueService.getByIdForUpdate).toHaveBeenCalled(); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("rejects an agent-authored in_review transition without a review path", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "todo", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1003", title: "Missing review path", executionPolicy: null, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); const res = await request(await createApp({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "55555555-5555-4555-8555-555555555555", })) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "in_review" }); expect(res.status).toBe(422); expect(res.body.error).toContain("invalid_issue_disposition"); expect(res.body.error).toContain("request_confirmation"); expect(res.body.details).toMatchObject({ code: "invalid_issue_disposition", missing: "review_path", }); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("allows an agent-authored in_review transition with a pending confirmation interaction", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "todo", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1004", title: "Pending confirmation", executionPolicy: null, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueThreadInteractionService.listForIssue.mockResolvedValue([ { id: "11111111-1111-4111-8111-111111111111", kind: "request_confirmation", status: "pending", createdByAgentId: "33333333-3333-4333-8333-333333333333", sourceRunId: "55555555-5555-4555-8555-555555555555", }, ]); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "55555555-5555-4555-8555-555555555555", })) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "in_review" }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expect.objectContaining({ status: "in_review" }), expect.anything(), ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.updated", details: expect.not.objectContaining({ reviewInteractionId: expect.anything() }), }), expect.any(Array), ); expect(mockLogActivity.mock.calls[0]?.[0]).toBe(mockIssueService.update.mock.calls[0]?.[2]); }); it("binds an explicitly designated same-run confirmation to the review transition", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "todo", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1004", title: "Pending confirmation", executionPolicy: null, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueThreadInteractionService.listForIssue.mockResolvedValue([{ id: "11111111-1111-4111-8111-111111111111", kind: "request_confirmation", status: "pending", createdByAgentId: "33333333-3333-4333-8333-333333333333", sourceRunId: "55555555-5555-4555-8555-555555555555", payload: { version: 1, prompt: "Approve this review?" }, }]); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "55555555-5555-4555-8555-555555555555", })) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "in_review", reviewInteractionId: "11111111-1111-4111-8111-111111111111", }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expect.not.objectContaining({ reviewInteractionId: expect.anything() }), expect.anything(), ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.updated", details: expect.objectContaining({ reviewInteractionId: "11111111-1111-4111-8111-111111111111", }), }), expect.any(Array), ); }); it("binds a user-designated confirmation to the review transition activity", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "todo", assigneeAgentId: null, assigneeUserId: "local-board", createdByUserId: "local-board", identifier: "PAP-1004", title: "Pending confirmation", executionPolicy: null, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueThreadInteractionService.listForIssue.mockResolvedValue([{ id: "11111111-1111-4111-8111-111111111111", kind: "request_confirmation", status: "pending", createdByAgentId: null, createdByUserId: "local-board", sourceRunId: null, payload: { version: 1, prompt: "Approve this review?" }, }]); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, changes: { status: { from: "todo", to: "in_review" } }, updatedAt: new Date(), })); const res = await request(await createApp()) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "in_review", reviewInteractionId: "11111111-1111-4111-8111-111111111111", }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expect.not.objectContaining({ reviewInteractionId: expect.anything() }), expect.anything(), ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.updated", actorType: "user", actorId: "local-board", details: expect.objectContaining({ reviewInteractionId: "11111111-1111-4111-8111-111111111111", }), }), expect.any(Array), ); }); it("keeps a review transition and its confirmation binding in one rollback boundary", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "todo", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1004", title: "Pending confirmation", executionPolicy: null, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueThreadInteractionService.listForIssue.mockResolvedValue([{ id: "11111111-1111-4111-8111-111111111111", kind: "request_confirmation", status: "pending", createdByAgentId: "33333333-3333-4333-8333-333333333333", sourceRunId: "55555555-5555-4555-8555-555555555555", payload: { version: 1, prompt: "Approve this review?" }, }]); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, changes: { status: { from: "todo", to: "in_review" } }, updatedAt: new Date(), })); mockLogActivity.mockRejectedValueOnce(new Error("activity insert failed")); const res = await request(await createApp({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "55555555-5555-4555-8555-555555555555", })) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "in_review", reviewInteractionId: "11111111-1111-4111-8111-111111111111", }); expect(res.status).toBe(500); expect(mockDb.transaction).toHaveBeenCalled(); const updateTx = mockIssueService.update.mock.calls[0]?.[2]; const activityTx = mockLogActivity.mock.calls[0]?.[0]; expect(activityTx).toBe(updateTx); }); it("rejects a review binding to a confirmation from another run", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "todo", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1004", title: "Pending confirmation", executionPolicy: null, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueThreadInteractionService.listForIssue.mockResolvedValue([{ id: "11111111-1111-4111-8111-111111111111", kind: "request_confirmation", status: "pending", createdByAgentId: "33333333-3333-4333-8333-333333333333", sourceRunId: "44444444-4444-4444-8444-444444444444", payload: { version: 1, prompt: "Approve another run's request?" }, }]); const res = await request(await createApp({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "55555555-5555-4555-8555-555555555555", })) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "in_review", reviewInteractionId: "11111111-1111-4111-8111-111111111111", }); expect(res.status).toBe(422); expect(res.body).toMatchObject({ error: expect.stringContaining("created by this agent run"), details: { code: "invalid_review_interaction" }, }); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("allows an agent-authored in_review transition with a typed execution participant", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "todo", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1005", title: "Execution participant", executionPolicy: null, executionState: null, }; const policy = normalizeIssueExecutionPolicy({ stages: [ { id: "11111111-1111-4111-8111-111111111111", type: "review", participants: [{ type: "agent", agentId: "44444444-4444-4444-8444-444444444444" }], }, ], })!; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "55555555-5555-4555-8555-555555555555", })) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "in_review", executionPolicy: policy }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expect.objectContaining({ status: "in_review", executionState: expect.objectContaining({ status: "pending", currentParticipant: expect.objectContaining({ type: "agent", agentId: "44444444-4444-4444-8444-444444444444", }), }), }), expect.anything(), ); }); it("allows an agent-authored in_review transition with a scheduled monitor", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "todo", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1006", title: "External review monitor", executionPolicy: null, executionState: null, monitorAttemptCount: 0, monitorNextCheckAt: null, monitorLastTriggeredAt: null, monitorNotes: null, monitorScheduledBy: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "55555555-5555-4555-8555-555555555555", })) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "in_review", executionPolicy: { monitor: { nextCheckAt: "2026-12-01T12:00:00.000Z", scheduledBy: "assignee", notes: "Wait for external QA report.", }, }, }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expect.objectContaining({ status: "in_review", monitorNextCheckAt: new Date("2026-12-01T12:00:00.000Z"), }), expect.anything(), ); }); it("allows board-authored in_review repair updates without a review path", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "todo", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1007", title: "Board repair", executionPolicy: null, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp()) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "in_review" }); expect(res.status).toBe(200); expect(mockDb.transaction).toHaveBeenCalled(); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.updated", actorType: "user", actorId: "local-board", details: expect.objectContaining({ status: "in_review" }), }), expect.any(Array), ); expect(mockLogActivity.mock.calls[0]?.[0]).toBe(mockIssueService.update.mock.calls[0]?.[2]); expect(mockIssueThreadInteractionService.listForIssue).not.toHaveBeenCalled(); expect(mockIssueApprovalService.listApprovalsForIssue).not.toHaveBeenCalled(); }); it("allows a board user to cancel an active agent review task", async () => { const policy = normalizeIssueExecutionPolicy({ stages: [ { id: "11111111-1111-4111-8111-111111111111", type: "review", participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }], }, ], })!; const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_review", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1008", title: "Active review", executionPolicy: policy, executionState: { status: "pending", currentStageId: "11111111-1111-4111-8111-111111111111", currentStageIndex: 0, currentStageType: "review", currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }, returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" }, completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null, }, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp()) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "cancelled" }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expect.objectContaining({ status: "cancelled", executionState: null, actorAgentId: null, actorUserId: "local-board", }), expect.anything(), undefined, expect.any(Array), ); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); }); it("allows a board user to cancel a drifted pending agent review task", async () => { const policy = normalizeIssueExecutionPolicy({ stages: [ { id: "11111111-1111-4111-8111-111111111111", type: "review", participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }], }, ], })!; const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "blocked", assigneeAgentId: "44444444-4444-4444-8444-444444444444", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1009", title: "Drifted active review", executionPolicy: policy, executionState: { status: "pending", currentStageId: "11111111-1111-4111-8111-111111111111", currentStageIndex: 0, currentStageType: "review", currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }, returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" }, completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null, }, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp()) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "cancelled" }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expect.objectContaining({ status: "cancelled", executionState: null, actorAgentId: null, actorUserId: "local-board", }), expect.anything(), undefined, expect.any(Array), ); const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record; expect(updatePatch.status).toBe("cancelled"); expect(updatePatch.assigneeAgentId).toBeUndefined(); expect(updatePatch.assigneeUserId).toBeUndefined(); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); }); it("keeps the review stage pending when a board user reassigns to an eligible participant", async () => { const policy = normalizeIssueExecutionPolicy({ stages: [ { id: "11111111-1111-4111-8111-111111111111", type: "review", participants: [ { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }, { type: "agent", agentId: "55555555-5555-4555-8555-555555555555" }, ], }, ], })!; const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_review", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1010", title: "Reassigned review", executionPolicy: policy, executionState: { status: "pending", currentStageId: "11111111-1111-4111-8111-111111111111", currentStageIndex: 0, currentStageType: "review", currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }, returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" }, completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null, }, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp()) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ assigneeAgentId: "55555555-5555-4555-8555-555555555555" }); expect(res.status).toBe(200); const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record; expect(updatePatch.status).toBe("in_review"); expect(updatePatch.assigneeAgentId).toBe("55555555-5555-4555-8555-555555555555"); expect(updatePatch.assigneeUserId).toBeNull(); expect(updatePatch.executionState).toMatchObject({ status: "pending", currentStageId: "11111111-1111-4111-8111-111111111111", currentStageType: "review", currentParticipant: { type: "agent", agentId: "55555555-5555-4555-8555-555555555555" }, returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" }, }); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); }); it("dissolves the review when a board user reassigns an in_review task to a non-participant", async () => { const policy = normalizeIssueExecutionPolicy({ stages: [ { id: "11111111-1111-4111-8111-111111111111", type: "review", participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }], }, ], })!; const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_review", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1011", title: "Reassigned away from review", executionPolicy: policy, executionState: { status: "pending", currentStageId: "11111111-1111-4111-8111-111111111111", currentStageIndex: 0, currentStageType: "review", currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }, returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" }, completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null, }, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp()) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ assigneeAgentId: "55555555-5555-4555-8555-555555555555" }); expect(res.status).toBe(200); const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record; expect(updatePatch.status).toBe("in_progress"); expect(updatePatch.executionState).toBeNull(); expect(updatePatch.assigneeAgentId).toBe("55555555-5555-4555-8555-555555555555"); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); }); it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => { const policy = normalizeIssueExecutionPolicy({ stages: [ { id: "11111111-1111-4111-8111-111111111111", type: "review", participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }], }, ], })!; const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_review", assigneeAgentId: null, assigneeUserId: "local-board", createdByUserId: "local-board", identifier: "PAP-999", title: "Execution policy edit", executionPolicy: null, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp()) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ executionPolicy: policy }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expect.objectContaining({ executionPolicy: policy, actorAgentId: null, actorUserId: "local-board", }), ); const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record; expect(updatePatch.status).toBeUndefined(); expect(updatePatch.assigneeAgentId).toBeUndefined(); expect(updatePatch.assigneeUserId).toBeUndefined(); expect(updatePatch.executionState).toBeUndefined(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("triggers a scheduled monitor immediately from the dedicated route", async () => { const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_progress", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1001", title: "Manual monitor trigger", executionPolicy: normalizeIssueExecutionPolicy({ monitor: { nextCheckAt: "2026-04-11T12:30:00.000Z", notes: "Check deployment", scheduledBy: "board", }, }), executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); const res = await request(await createApp()) .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/monitor/check-now") .send({}); expect(res.status).toBe(200); expect(res.body).toEqual({ ok: true }); expect(mockHeartbeatService.triggerIssueMonitor).toHaveBeenCalledWith( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expect.objectContaining({ actorType: "user", actorId: "local-board", agentId: null, }), ); }); it("lets a board user create a child issue with a scheduled monitor", async () => { mockIssueService.getById.mockResolvedValue({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_progress", assigneeAgentId: "11111111-1111-4111-8111-111111111111", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1001", title: "Parent issue", executionPolicy: null, executionState: null, }); const res = await request(await createApp()) .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children") .send({ title: "Child monitor", status: "in_review", assigneeAgentId: "33333333-3333-4333-8333-333333333333", executionPolicy: { monitor: { nextCheckAt: "2026-04-11T12:30:00.000Z", scheduledBy: "assignee", }, }, }); expect(res.status).toBe(201); const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as { executionPolicy: { monitor: { scheduledBy: string } }; }; expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("board"); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.monitor_scheduled", details: expect.objectContaining({ scheduledBy: "board", }), }), ); }); it("rejects child monitor scheduling by a non-assignee agent even with task assignment permission", async () => { mockAccessService.hasPermission.mockResolvedValue(true); mockIssueService.getById.mockResolvedValue({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_progress", assigneeAgentId: "11111111-1111-4111-8111-111111111111", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1001", title: "Parent issue", executionPolicy: null, executionState: null, }); const res = await request(await createApp({ type: "agent", agentId: "22222222-2222-4222-8222-222222222222", companyId: "company-1", runId: "run-1", })) .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children") .send({ title: "Child monitor", status: "in_review", assigneeAgentId: "33333333-3333-4333-8333-333333333333", executionPolicy: { monitor: { nextCheckAt: "2026-04-11T12:30:00.000Z", scheduledBy: "board", }, }, }); expect(res.status).toBe(403); expect(res.body.error).toBe("Only the assignee agent or a board user can manage issue monitors"); expect(mockIssueService.createChild).not.toHaveBeenCalled(); }); it("normalizes spoofed child monitor scheduledBy to the assignee actor", async () => { mockAccessService.hasPermission.mockResolvedValue(true); mockIssueService.getById.mockResolvedValue({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_progress", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1001", title: "Parent issue", executionPolicy: null, executionState: null, }); const res = await request(await createApp({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "run-1", })) .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children") .send({ title: "Child monitor", status: "in_review", assigneeAgentId: "33333333-3333-4333-8333-333333333333", executionPolicy: { monitor: { nextCheckAt: "2026-04-11T12:30:00.000Z", scheduledBy: "board", externalRef: "https://example.test/deploy?token=secret", }, }, }); expect(res.status).toBe(201); const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as { executionPolicy: { monitor: { scheduledBy: string; externalRef: string | null } }; }; expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("assignee"); expect(createPayload.executionPolicy.monitor.externalRef).toBe("[redacted]"); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.monitor_scheduled", entityId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", details: expect.not.objectContaining({ externalRef: expect.anything() }), }), ); }); });