diff --git a/packages/shared/src/validators/issue-tree-control.ts b/packages/shared/src/validators/issue-tree-control.ts index cb0dda9c75f..48d78123cbf 100644 --- a/packages/shared/src/validators/issue-tree-control.ts +++ b/packages/shared/src/validators/issue-tree-control.ts @@ -27,6 +27,7 @@ export const createIssueTreeHoldSchema = z mode: issueTreeControlModeSchema, reason: z.string().trim().min(1).max(1000).optional().nullable(), releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(), + metadata: z.record(z.unknown()).optional().nullable(), }) .strict(); diff --git a/server/src/__tests__/issue-tree-control-routes.test.ts b/server/src/__tests__/issue-tree-control-routes.test.ts index 7bf87b849c3..3f72ee8ced4 100644 --- a/server/src/__tests__/issue-tree-control-routes.test.ts +++ b/server/src/__tests__/issue-tree-control-routes.test.ts @@ -9,6 +9,8 @@ const mockIssueService = vi.hoisted(() => ({ const mockTreeControlService = vi.hoisted(() => ({ preview: vi.fn(), createHold: vi.fn(), + cancelIssueStatusesForHold: vi.fn(), + restoreIssueStatusesForHold: vi.fn(), getHold: vi.fn(), releaseHold: vi.fn(), cancelUnclaimedWakeupsForTree: vi.fn(), @@ -17,6 +19,7 @@ const mockTreeControlService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockHeartbeatService = vi.hoisted(() => ({ cancelRun: vi.fn(), + wakeup: vi.fn(), })); vi.mock("../services/index.js", () => ({ @@ -50,7 +53,15 @@ describe("issue tree control routes", () => { companyId: "company-2", }); mockTreeControlService.cancelUnclaimedWakeupsForTree.mockResolvedValue([]); + mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({ updatedIssueIds: [], updatedIssues: [] }); + mockTreeControlService.restoreIssueStatusesForHold.mockResolvedValue({ + updatedIssueIds: [], + updatedIssues: [], + releasedCancelHoldIds: [], + restoreHold: null, + }); mockHeartbeatService.cancelRun.mockResolvedValue(null); + mockHeartbeatService.wakeup.mockResolvedValue(null); }); it("rejects cross-company preview requests before calling the preview service", async () => { @@ -135,4 +146,114 @@ describe("issue tree control routes", () => { }), ); }); + + it("marks affected issues cancelled when creating a cancel hold", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + mockTreeControlService.createHold.mockResolvedValue({ + hold: { + id: "33333333-3333-4333-8333-333333333333", + mode: "cancel", + reason: "cancel subtree", + }, + preview: { + mode: "cancel", + totals: { affectedIssues: 2 }, + warnings: [], + activeRuns: [], + }, + }); + mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({ + updatedIssueIds: [ + "11111111-1111-4111-8111-111111111111", + "55555555-5555-4555-8555-555555555555", + ], + updatedIssues: [], + }); + + const res = await request(app) + .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") + .send({ mode: "cancel", reason: "cancel subtree" }); + + expect(res.status).toBe(201); + expect(mockTreeControlService.cancelIssueStatusesForHold).toHaveBeenCalledWith( + "company-2", + "11111111-1111-4111-8111-111111111111", + "33333333-3333-4333-8333-333333333333", + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.tree_cancel_status_updated", + details: expect.objectContaining({ cancelledIssueCount: 2 }), + }), + ); + }); + + it("restores affected issues and can request explicit wakeups", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + mockTreeControlService.createHold.mockResolvedValue({ + hold: { + id: "66666666-6666-4666-8666-666666666666", + mode: "restore", + reason: "restore subtree", + }, + preview: { + mode: "restore", + totals: { affectedIssues: 1 }, + warnings: [], + activeRuns: [], + }, + }); + mockTreeControlService.restoreIssueStatusesForHold.mockResolvedValue({ + updatedIssueIds: ["55555555-5555-4555-8555-555555555555"], + updatedIssues: [ + { + id: "55555555-5555-4555-8555-555555555555", + status: "todo", + assigneeAgentId: "22222222-2222-4222-8222-222222222222", + }, + ], + releasedCancelHoldIds: ["33333333-3333-4333-8333-333333333333"], + restoreHold: { + id: "66666666-6666-4666-8666-666666666666", + mode: "restore", + status: "released", + }, + }); + mockHeartbeatService.wakeup.mockResolvedValue({ + id: "77777777-7777-4777-8777-777777777777", + }); + + const res = await request(app) + .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") + .send({ mode: "restore", reason: "restore subtree", metadata: { wakeAgents: true } }); + + expect(res.status).toBe(201); + expect(mockTreeControlService.restoreIssueStatusesForHold).toHaveBeenCalledWith( + "company-2", + "11111111-1111-4111-8111-111111111111", + "66666666-6666-4666-8666-666666666666", + expect.objectContaining({ reason: "restore subtree" }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_tree_restored", + payload: expect.objectContaining({ issueId: "55555555-5555-4555-8555-555555555555" }), + }), + ); + expect(res.body.hold.status).toBe("released"); + }); }); diff --git a/server/src/__tests__/issue-tree-control-service.test.ts b/server/src/__tests__/issue-tree-control-service.test.ts index 81a1a3a0197..a9bf308a12b 100644 --- a/server/src/__tests__/issue-tree-control-service.test.ts +++ b/server/src/__tests__/issue-tree-control-service.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, @@ -211,6 +211,128 @@ describeEmbeddedPostgres("issueTreeControlService", () => { expect(released.members).toHaveLength(1); }); + it("cancels non-terminal issue statuses and restores from the cancel snapshot", async () => { + const companyId = randomUUID(); + const rootIssueId = randomUUID(); + const runningChildId = randomUUID(); + const todoChildId = randomUUID(); + const doneChildId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Root", + status: "done", + priority: "medium", + createdAt: new Date("2026-04-21T10:00:00.000Z"), + }, + { + id: runningChildId, + companyId, + parentId: rootIssueId, + title: "Running child", + status: "in_progress", + priority: "medium", + createdAt: new Date("2026-04-21T10:01:00.000Z"), + }, + { + id: todoChildId, + companyId, + parentId: rootIssueId, + title: "Todo child", + status: "todo", + priority: "medium", + createdAt: new Date("2026-04-21T10:02:00.000Z"), + }, + { + id: doneChildId, + companyId, + parentId: rootIssueId, + title: "Done child", + status: "done", + priority: "medium", + createdAt: new Date("2026-04-21T10:03:00.000Z"), + }, + ]); + + const svc = issueTreeControlService(db); + const cancel = await svc.createHold(companyId, rootIssueId, { + mode: "cancel", + reason: "bad plan", + actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, + }); + expect(cancel.preview.issues.map((issue) => [issue.id, issue.skipped, issue.skipReason])).toEqual([ + [rootIssueId, true, "terminal_status"], + [runningChildId, false, null], + [todoChildId, false, null], + [doneChildId, true, "terminal_status"], + ]); + + const cancelled = await svc.cancelIssueStatusesForHold(companyId, rootIssueId, cancel.hold.id); + expect(cancelled.updatedIssueIds.sort()).toEqual([runningChildId, todoChildId].sort()); + + const afterCancel = await db + .select({ id: issues.id, status: issues.status }) + .from(issues) + .where(inArray(issues.id, [runningChildId, todoChildId, doneChildId])); + expect(Object.fromEntries(afterCancel.map((issue) => [issue.id, issue.status]))).toMatchObject({ + [runningChildId]: "cancelled", + [todoChildId]: "cancelled", + [doneChildId]: "done", + }); + + await db + .update(issues) + .set({ status: "blocked", cancelledAt: null, updatedAt: new Date() }) + .where(eq(issues.id, todoChildId)); + + const restorePreview = await svc.preview(companyId, rootIssueId, { mode: "restore" }); + expect(restorePreview.issues.map((issue) => [issue.id, issue.skipped, issue.skipReason])).toEqual([ + [rootIssueId, true, "not_cancelled"], + [runningChildId, false, null], + [todoChildId, true, "changed_after_cancel"], + [doneChildId, true, "not_cancelled"], + ]); + expect(restorePreview.warnings.map((warning) => warning.code)).toContain("restore_conflicts_present"); + + const restore = await svc.createHold(companyId, rootIssueId, { + mode: "restore", + reason: "resume useful work", + actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, + }); + const restored = await svc.restoreIssueStatusesForHold(companyId, rootIssueId, restore.hold.id, { + reason: "resume useful work", + actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, + }); + expect(restored.updatedIssueIds).toEqual([runningChildId]); + + const afterRestore = await db + .select({ id: issues.id, status: issues.status, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId }) + .from(issues) + .where(inArray(issues.id, [runningChildId, todoChildId, doneChildId])); + expect(Object.fromEntries(afterRestore.map((issue) => [issue.id, issue.status]))).toMatchObject({ + [runningChildId]: "todo", + [todoChildId]: "blocked", + [doneChildId]: "done", + }); + + const holds = await db + .select({ id: issueTreeHolds.id, mode: issueTreeHolds.mode, status: issueTreeHolds.status }) + .from(issueTreeHolds) + .where(inArray(issueTreeHolds.id, [cancel.hold.id, restore.hold.id])); + expect(Object.fromEntries(holds.map((hold) => [hold.mode, hold.status]))).toMatchObject({ + cancel: "released", + restore: "released", + }); + }); + it("blocks held descendant checkout but allows root comment course-correction checkout", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/routes/issue-tree-control.ts b/server/src/routes/issue-tree-control.ts index 53140348fd0..73d3e2be56d 100644 --- a/server/src/routes/issue-tree-control.ts +++ b/server/src/routes/issue-tree-control.ts @@ -62,15 +62,16 @@ export function issueTreeControlRoutes(db: Db) { assertCompanyAccess(req, root.companyId); const actor = getActorInfo(req); - const result = await treeControlSvc.createHold(root.companyId, root.id, { + const actorInput = { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId, + }; + let result = await treeControlSvc.createHold(root.companyId, root.id, { ...req.body, - actor: { - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - userId: actor.actorType === "user" ? actor.actorId : null, - runId: actor.runId, - }, + actor: actorInput, }); await logActivity(db, { companyId: root.companyId, @@ -90,7 +91,7 @@ export function issueTreeControlRoutes(db: Db) { }, }); - if (result.hold.mode === "pause") { + if (result.hold.mode === "pause" || result.hold.mode === "cancel") { const interruptedRunIds = [...new Set(result.preview.activeRuns.map((run) => run.id))]; for (const runId of interruptedRunIds) { await heartbeat.cancelRun(runId); @@ -106,7 +107,7 @@ export function issueTreeControlRoutes(db: Db) { details: { holdId: result.hold.id, rootIssueId: root.id, - reason: "active_subtree_pause_hold", + reason: result.hold.mode === "pause" ? "active_subtree_pause_hold" : "subtree_cancel_operation", }, }); } @@ -114,7 +115,9 @@ export function issueTreeControlRoutes(db: Db) { const cancelledWakeups = await treeControlSvc.cancelUnclaimedWakeupsForTree( root.companyId, root.id, - "Cancelled because an active subtree pause hold was created", + result.hold.mode === "pause" + ? "Cancelled because an active subtree pause hold was created" + : "Cancelled because a subtree cancel operation was applied", ); for (const wakeup of cancelledWakeups) { await logActivity(db, { @@ -136,6 +139,99 @@ export function issueTreeControlRoutes(db: Db) { } } + if (result.hold.mode === "cancel") { + const statusUpdate = await treeControlSvc.cancelIssueStatusesForHold(root.companyId, root.id, result.hold.id); + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_cancel_status_updated", + entityType: "issue", + entityId: root.id, + details: { + holdId: result.hold.id, + cancelledIssueIds: statusUpdate.updatedIssueIds, + cancelledIssueCount: statusUpdate.updatedIssueIds.length, + }, + }); + } + + if (result.hold.mode === "restore") { + const statusUpdate = await treeControlSvc.restoreIssueStatusesForHold(root.companyId, root.id, result.hold.id, { + reason: result.hold.reason, + actor: actorInput, + }); + if (statusUpdate.restoreHold) { + result = { ...result, hold: statusUpdate.restoreHold }; + } + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_restore_status_updated", + entityType: "issue", + entityId: root.id, + details: { + holdId: result.hold.id, + restoredIssueIds: statusUpdate.updatedIssueIds, + restoredIssueCount: statusUpdate.updatedIssueIds.length, + releasedCancelHoldIds: statusUpdate.releasedCancelHoldIds, + }, + }); + + const wakeAgents = typeof req.body.metadata === "object" + && req.body.metadata !== null + && (req.body.metadata as Record).wakeAgents === true; + if (wakeAgents) { + for (const restoredIssue of statusUpdate.updatedIssues) { + if (!restoredIssue.assigneeAgentId) continue; + const wakeRun = await heartbeat + .wakeup(restoredIssue.assigneeAgentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_tree_restored", + payload: { + issueId: restoredIssue.id, + rootIssueId: root.id, + restoreHoldId: result.hold.id, + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: restoredIssue.id, + taskId: restoredIssue.id, + wakeReason: "issue_tree_restored", + source: "issue.tree_restore", + rootIssueId: root.id, + restoreHoldId: result.hold.id, + }, + }) + .catch(() => null); + if (!wakeRun) continue; + await logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.tree_restore_wakeup_requested", + entityType: "heartbeat_run", + entityId: wakeRun.id, + details: { + holdId: result.hold.id, + rootIssueId: root.id, + issueId: restoredIssue.id, + agentId: restoredIssue.assigneeAgentId, + }, + }); + } + } + } + res.status(201).json(result); }); diff --git a/server/src/services/issue-tree-control.ts b/server/src/services/issue-tree-control.ts index 15823013e55..eb21d796d62 100644 --- a/server/src/services/issue-tree-control.ts +++ b/server/src/services/issue-tree-control.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentWakeupRequests, @@ -50,6 +50,22 @@ type ActiveRunRow = { startedAt: Date | null; createdAt: Date; }; +type ActiveCancelSnapshot = { + holdIds: string[]; + member: IssueTreeHoldMember | null; +}; +type TreeStatusUpdateResult = { + updatedIssueIds: string[]; + updatedIssues: Array<{ + id: string; + status: IssueStatus; + assigneeAgentId: string | null; + }>; +}; +type RestoreTreeStatusResult = TreeStatusUpdateResult & { + releasedCancelHoldIds: string[]; + restoreHold: IssueTreeHold | null; +}; const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); const ACTIVE_RUN_STATUSES = ["queued", "running"] as const; @@ -138,10 +154,17 @@ function issueSkipReason(input: { mode: IssueTreeControlMode; issue: TreeIssue; activePauseHoldIds: string[]; + activeCancelSnapshot?: ActiveCancelSnapshot | null; }): string | null { const status = coerceIssueStatus(input.issue.status); if (input.mode === "restore") { - return status === "cancelled" ? null : "not_cancelled"; + if (input.activeCancelSnapshot?.member && status !== "cancelled") { + return "changed_after_cancel"; + } + if (status !== "cancelled") return "not_cancelled"; + if (!input.activeCancelSnapshot?.member) return "not_cancelled_by_tree_control"; + const snapshotStatus = coerceIssueStatus(input.activeCancelSnapshot.member.issueStatus); + return isTerminalIssue(snapshotStatus) ? "terminal_status" : null; } if (isTerminalIssue(status)) { return "terminal_status"; @@ -218,9 +241,28 @@ function buildWarnings(input: { }); } + if (input.mode === "restore") { + const changedIssueIds = input.issuesToPreview + .filter((issue) => issue.skipReason === "changed_after_cancel") + .map((issue) => issue.id); + if (changedIssueIds.length > 0) { + warnings.push({ + code: "restore_conflicts_present", + message: "Some issues changed after subtree cancellation and will be skipped.", + issueIds: changedIssueIds, + }); + } + } + return warnings; } +function restoreStatusFromCancelSnapshot(status: IssueStatus): IssueStatus | null { + if (status === "in_progress") return "todo"; + if (isTerminalIssue(status)) return null; + return status; +} + export function issueTreeControlService(db: Db) { async function listTreeIssues(companyId: string, rootIssueId: string): Promise { const root = await db @@ -343,6 +385,24 @@ export function issueTreeControlService(db: Db) { return byIssueId; } + async function activeCancelSnapshotsByIssueId(companyId: string, rootIssueId: string) { + const activeCancelHolds = await listHolds(companyId, rootIssueId, { + status: "active", + mode: "cancel", + includeMembers: true, + }); + const byIssueId = new Map(); + for (const hold of [...activeCancelHolds].reverse()) { + for (const member of hold.members ?? []) { + const current = byIssueId.get(member.issueId) ?? { holdIds: [], member: null }; + if (!current.holdIds.includes(hold.id)) current.holdIds.push(hold.id); + if (!current.member && !member.skipped) current.member = member; + byIssueId.set(member.issueId, current); + } + } + return byIssueId; + } + async function getActivePauseHoldGate( companyId: string, issueId: string, @@ -405,9 +465,12 @@ export function issueTreeControlService(db: Db) { ): Promise { const treeIssues = await listTreeIssues(companyId, rootIssueId); const issueIds = treeIssues.map((issue) => issue.id); - const [activeRunRows, holdsByIssueId] = await Promise.all([ + const [activeRunRows, holdsByIssueId, activeCancelSnapshots] = await Promise.all([ activeRunsForTree(companyId, treeIssues), activeHoldsByIssueId(companyId, issueIds), + input.mode === "restore" + ? activeCancelSnapshotsByIssueId(companyId, rootIssueId) + : Promise.resolve(new Map()), ]); const runsByIssueId = new Map(); for (const run of activeRunRows) { @@ -423,6 +486,7 @@ export function issueTreeControlService(db: Db) { mode: input.mode, issue, activePauseHoldIds: holdState.pause, + activeCancelSnapshot: activeCancelSnapshots.get(issue.id) ?? null, }); const run = runsByIssueId.get(issue.id); return { @@ -533,6 +597,197 @@ export function issueTreeControlService(db: Db) { }; } + async function cancelIssueStatusesForHold( + companyId: string, + rootIssueId: string, + holdId: string, + ): Promise { + const hold = await getHold(companyId, holdId); + if (!hold) throw notFound("Issue tree hold not found"); + if (hold.rootIssueId !== rootIssueId) { + throw unprocessable("Issue tree hold does not belong to the requested root issue"); + } + if (hold.mode !== "cancel") { + throw unprocessable("Issue tree hold is not a cancel operation"); + } + + const issueIds = [...new Set((hold.members ?? []) + .filter((member) => !member.skipped) + .map((member) => member.issueId))]; + if (issueIds.length === 0) return { updatedIssueIds: [], updatedIssues: [] }; + + const now = new Date(); + const updated = await db + .update(issues) + .set({ + status: "cancelled", + cancelledAt: now, + completedAt: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where( + and( + eq(issues.companyId, companyId), + inArray(issues.id, issueIds), + notInArray(issues.status, ["done", "cancelled"]), + ), + ) + .returning({ + id: issues.id, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + }); + + return { + updatedIssueIds: updated.map((issue) => issue.id), + updatedIssues: updated.map((issue) => ({ + id: issue.id, + status: coerceIssueStatus(issue.status), + assigneeAgentId: issue.assigneeAgentId, + })), + }; + } + + async function restoreIssueStatusesForHold( + companyId: string, + rootIssueId: string, + restoreHoldId: string, + input: { + reason?: string | null; + actor: ActorInput; + }, + ): Promise { + const restoreHold = await getHold(companyId, restoreHoldId); + if (!restoreHold) throw notFound("Issue tree hold not found"); + if (restoreHold.rootIssueId !== rootIssueId) { + throw unprocessable("Issue tree hold does not belong to the requested root issue"); + } + if (restoreHold.mode !== "restore") { + throw unprocessable("Issue tree hold is not a restore operation"); + } + + const activeCancelHolds = await listHolds(companyId, rootIssueId, { + status: "active", + mode: "cancel", + includeMembers: true, + }); + const cancelSnapshotByIssueId = new Map(); + for (const hold of [...activeCancelHolds].reverse()) { + for (const member of hold.members ?? []) { + if (!member.skipped && !cancelSnapshotByIssueId.has(member.issueId)) { + cancelSnapshotByIssueId.set(member.issueId, member); + } + } + } + + const restoreIssueIds = [...new Set((restoreHold.members ?? []) + .filter((member) => !member.skipped) + .map((member) => member.issueId))]; + const restoreStatusByIssueId = new Map(); + for (const issueId of restoreIssueIds) { + const snapshot = cancelSnapshotByIssueId.get(issueId); + if (!snapshot) continue; + const restoredStatus = restoreStatusFromCancelSnapshot(coerceIssueStatus(snapshot.issueStatus)); + if (restoredStatus) restoreStatusByIssueId.set(issueId, restoredStatus); + } + + const issueIdsByStatus = new Map(); + for (const [issueId, status] of restoreStatusByIssueId) { + const current = issueIdsByStatus.get(status) ?? []; + current.push(issueId); + issueIdsByStatus.set(status, current); + } + + const now = new Date(); + const releasedCancelHoldIds = activeCancelHolds.map((hold) => hold.id); + const updatedIssues = await db.transaction(async (tx) => { + const restored: TreeStatusUpdateResult["updatedIssues"] = []; + for (const [status, issueIdsForStatus] of issueIdsByStatus) { + if (issueIdsForStatus.length === 0) continue; + const rows = await tx + .update(issues) + .set({ + status, + cancelledAt: null, + completedAt: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where( + and( + eq(issues.companyId, companyId), + inArray(issues.id, issueIdsForStatus), + eq(issues.status, "cancelled"), + ), + ) + .returning({ + id: issues.id, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + }); + restored.push(...rows.map((issue) => ({ + id: issue.id, + status: coerceIssueStatus(issue.status), + assigneeAgentId: issue.assigneeAgentId, + }))); + } + + if (releasedCancelHoldIds.length > 0) { + await tx + .update(issueTreeHolds) + .set({ + status: "released", + releasedAt: now, + releasedByActorType: input.actor.actorType, + releasedByAgentId: input.actor.agentId ?? null, + releasedByUserId: input.actor.userId ?? (input.actor.actorType === "user" ? input.actor.actorId : null), + releasedByRunId: input.actor.runId ?? null, + releaseReason: input.reason ?? "Restored by subtree restore operation", + releaseMetadata: { + restoreHoldId, + restoredIssueIds: restored.map((issue) => issue.id), + }, + updatedAt: now, + }) + .where(and(eq(issueTreeHolds.companyId, companyId), inArray(issueTreeHolds.id, releasedCancelHoldIds))); + } + + await tx + .update(issueTreeHolds) + .set({ + status: "released", + releasedAt: now, + releasedByActorType: input.actor.actorType, + releasedByAgentId: input.actor.agentId ?? null, + releasedByUserId: input.actor.userId ?? (input.actor.actorType === "user" ? input.actor.actorId : null), + releasedByRunId: input.actor.runId ?? null, + releaseReason: input.reason ?? "Restore operation applied", + releaseMetadata: { + restoredIssueIds: restored.map((issue) => issue.id), + releasedCancelHoldIds, + }, + updatedAt: now, + }) + .where(and(eq(issueTreeHolds.companyId, companyId), eq(issueTreeHolds.id, restoreHoldId))); + + return restored; + }); + + return { + updatedIssueIds: updatedIssues.map((issue) => issue.id), + updatedIssues, + releasedCancelHoldIds, + restoreHold: await getHold(companyId, restoreHoldId), + }; + } + async function getHold(companyId: string, holdId: string) { const hold = await db .select() @@ -680,6 +935,8 @@ export function issueTreeControlService(db: Db) { listTreeIssues, preview, createHold, + cancelIssueStatusesForHold, + restoreIssueStatusesForHold, getHold, listHolds, getActivePauseHoldGate, diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx new file mode 100644 index 00000000000..682bdfa267d --- /dev/null +++ b/ui/src/pages/IssueDetail.test.tsx @@ -0,0 +1,856 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Issue, IssueTreeControlPreview, IssueTreeHold } from "@paperclipai/shared"; +import { act, type ButtonHTMLAttributes, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IssueDetail } from "./IssueDetail"; + +const mockIssuesApi = vi.hoisted(() => ({ + get: vi.fn(), + list: vi.fn(), + listComments: vi.fn(), + listAttachments: vi.fn(), + listFeedbackVotes: vi.fn(), + markRead: vi.fn(), + update: vi.fn(), + previewTreeControl: vi.fn(), + getTreeControlState: vi.fn(), + listTreeHolds: vi.fn(), + createTreeHold: vi.fn(), + releaseTreeHold: vi.fn(), + archiveFromInbox: vi.fn(), + addComment: vi.fn(), + cancelComment: vi.fn(), + upsertFeedbackVote: vi.fn(), + uploadAttachment: vi.fn(), + deleteAttachment: vi.fn(), + upsertDocument: vi.fn(), +})); + +const mockActivityApi = vi.hoisted(() => ({ + forIssue: vi.fn(), + runsForIssue: vi.fn(), +})); + +const mockHeartbeatsApi = vi.hoisted(() => ({ + liveRunsForIssue: vi.fn(), + activeRunForIssue: vi.fn(), + cancel: vi.fn(), +})); + +const mockAgentsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockAccessApi = vi.hoisted(() => ({ + getCurrentBoardAccess: vi.fn(), + listUserDirectory: vi.fn(), +})); + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +const mockProjectsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getGeneral: vi.fn(), +})); + +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockOpenPanel = vi.hoisted(() => vi.fn()); +const mockClosePanel = vi.hoisted(() => vi.fn()); +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockSetMobileToolbar = vi.hoisted(() => vi.fn()); +const mockPushToast = vi.hoisted(() => vi.fn()); +const mockIssuesListRender = vi.hoisted(() => vi.fn()); + +vi.mock("../api/issues", () => ({ + issuesApi: mockIssuesApi, +})); + +vi.mock("../api/activity", () => ({ + activityApi: mockActivityApi, +})); + +vi.mock("../api/heartbeats", () => ({ + heartbeatsApi: mockHeartbeatsApi, +})); + +vi.mock("../api/approvals", () => ({ + approvalsApi: { + approve: vi.fn(), + reject: vi.fn(), + }, +})); + +vi.mock("../api/agents", () => ({ + agentsApi: mockAgentsApi, +})); + +vi.mock("../api/access", () => ({ + accessApi: mockAccessApi, +})); + +vi.mock("../api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: mockProjectsApi, +})); + +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}, + useLocation: () => ({ pathname: "/issues/PAP-1", search: "", hash: "", state: null }), + useNavigate: () => mockNavigate, + useNavigationType: () => "PUSH", + useParams: () => ({ issueId: "PAP-1" }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP", status: "active" }], + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP", status: "active" }, + selectionSource: "manual", + loading: false, + error: null, + setSelectedCompanyId: vi.fn(), + reloadCompanies: vi.fn(), + createCompany: vi.fn(), + }), +})); + +vi.mock("../context/DialogContext", () => ({ + useDialog: () => ({ + openNewIssue: vi.fn(), + }), +})); + +vi.mock("../context/PanelContext", () => ({ + usePanel: () => ({ + openPanel: mockOpenPanel, + closePanel: mockClosePanel, + panelVisible: true, + setPanelVisible: vi.fn(), + }), +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + }), +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ + setBreadcrumbs: mockSetBreadcrumbs, + setMobileToolbar: mockSetMobileToolbar, + }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToastActions: () => ({ + pushToast: mockPushToast, + }), +})); + +vi.mock("../hooks/useProjectOrder", () => ({ + useProjectOrder: ({ projects }: { projects: unknown[] }) => ({ + orderedProjects: projects, + }), +})); + +vi.mock("@/plugins/slots", () => ({ + PluginSlotMount: () => null, + PluginSlotOutlet: () => null, + usePluginSlots: () => ({ slots: [], isLoading: false, errorMessage: null }), +})); + +vi.mock("@/plugins/launchers", () => ({ + PluginLauncherOutlet: () => null, +})); + +vi.mock("../components/InlineEditor", () => ({ + InlineEditor: ({ value, placeholder }: { value?: string; placeholder?: string }) => ( +
{value || placeholder}
+ ), +})); + +vi.mock("../components/IssueChatThread", () => ({ + IssueChatThread: () =>
Chat thread
, +})); + +vi.mock("../components/IssueDocumentsSection", () => ({ + IssueDocumentsSection: () =>
Documents
, +})); + +vi.mock("../components/IssuesList", () => ({ + IssuesList: (props: { issueBadgeById?: Map }) => { + mockIssuesListRender(props); + return ( +
+ Sub-issues + {Array.from(props.issueBadgeById?.entries() ?? []).map(([issueId, label]) => ( + {issueId}:{label} + ))} +
+ ); + }, +})); + +vi.mock("../components/IssueProperties", () => ({ + IssueProperties: () =>
Properties
, +})); + +vi.mock("../components/IssueRunLedger", () => ({ + IssueRunLedger: () =>
Runs
, +})); + +vi.mock("../components/IssueWorkspaceCard", () => ({ + IssueWorkspaceCard: () =>
Workspace
, +})); + +vi.mock("../components/ImageGalleryModal", () => ({ + ImageGalleryModal: () => null, +})); + +vi.mock("../components/ScrollToBottom", () => ({ + ScrollToBottom: () => null, +})); + +vi.mock("../components/StatusIcon", () => ({ + StatusIcon: ({ status }: { status: string }) => {status}, +})); + +vi.mock("../components/PriorityIcon", () => ({ + PriorityIcon: ({ priority }: { priority: string }) => {priority}, +})); + +vi.mock("../components/ApprovalCard", () => ({ + ApprovalCard: () =>
Approval
, +})); + +vi.mock("../components/Identity", () => ({ + Identity: () => Identity, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + disabled, + onClick, + type = "button", + variant: _variant, + size: _size, + asChild: _asChild, + ...props + }: ButtonHTMLAttributes & { variant?: string; size?: string; asChild?: boolean }) => ( + + ), +})); + +vi.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); + +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children?: ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children?: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ?
{children}
: null), + DialogContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

, +})); + +vi.mock("@/components/ui/sheet", () => ({ + Sheet: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ?
{children}
: null), + SheetContent: ({ children }: { children?: ReactNode }) =>
{children}
, + SheetHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + SheetTitle: ({ children }: { children?: ReactNode }) =>

{children}

, +})); + +vi.mock("@/components/ui/scroll-area", () => ({ + ScrollArea: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/skeleton", () => ({ + Skeleton: () =>
, +})); + +vi.mock("@/components/ui/tabs", () => ({ + Tabs: ({ children }: { children?: ReactNode }) =>
{children}
, + TabsContent: ({ children }: { children?: ReactNode }) =>
{children}
, + TabsList: ({ children }: { children?: ReactNode }) =>
{children}
, + TabsTrigger: ({ children }: { children?: ReactNode }) => , +})); + +vi.mock("@/components/ui/textarea", () => ({ + Textarea: (props: React.TextareaHTMLAttributes) =>