Restore subtree cancel state handling

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-23 09:45:53 -05:00
1 parent 8c67428e79
commit 7290dce4df
7 files changed
+1608 -74

No files matched your search

@@ -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();
@@ -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");
});
});
@@ -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();
+107 -11
View File
@@ -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<string, unknown>).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);
});
+260 -3
View File
@@ -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<IssueStatus>(["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<TreeIssue[]> {
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<string, ActiveCancelSnapshot>();
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<IssueTreeControlPreview> {
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<string, ActiveCancelSnapshot>()),
]);
const runsByIssueId = new Map<string, ActiveRunRow>();
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<TreeStatusUpdateResult> {
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<RestoreTreeStatusResult> {
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<string, IssueTreeHoldMember>();
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<string, IssueStatus>();
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<IssueStatus, string[]>();
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,
+856
View File
@@ -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 }) => <a href={to}>{children}</a>,
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 }) => (
<div>{value || placeholder}</div>
),
}));
vi.mock("../components/IssueChatThread", () => ({
IssueChatThread: () => <div data-testid="issue-chat-thread">Chat thread</div>,
}));
vi.mock("../components/IssueDocumentsSection", () => ({
IssueDocumentsSection: () => <div>Documents</div>,
}));
vi.mock("../components/IssuesList", () => ({
IssuesList: (props: { issueBadgeById?: Map<string, string> }) => {
mockIssuesListRender(props);
return (
<div>
Sub-issues
{Array.from(props.issueBadgeById?.entries() ?? []).map(([issueId, label]) => (
<span key={issueId}>{issueId}:{label}</span>
))}
</div>
);
},
}));
vi.mock("../components/IssueProperties", () => ({
IssueProperties: () => <div>Properties</div>,
}));
vi.mock("../components/IssueRunLedger", () => ({
IssueRunLedger: () => <div>Runs</div>,
}));
vi.mock("../components/IssueWorkspaceCard", () => ({
IssueWorkspaceCard: () => <div>Workspace</div>,
}));
vi.mock("../components/ImageGalleryModal", () => ({
ImageGalleryModal: () => null,
}));
vi.mock("../components/ScrollToBottom", () => ({
ScrollToBottom: () => null,
}));
vi.mock("../components/StatusIcon", () => ({
StatusIcon: ({ status }: { status: string }) => <span>{status}</span>,
}));
vi.mock("../components/PriorityIcon", () => ({
PriorityIcon: ({ priority }: { priority: string }) => <span>{priority}</span>,
}));
vi.mock("../components/ApprovalCard", () => ({
ApprovalCard: () => <div>Approval</div>,
}));
vi.mock("../components/Identity", () => ({
Identity: () => <span>Identity</span>,
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
children,
disabled,
onClick,
type = "button",
variant: _variant,
size: _size,
asChild: _asChild,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string; size?: string; asChild?: boolean }) => (
<button {...props} type={type} disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@/components/ui/separator", () => ({
Separator: () => <hr />,
}));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children?: ReactNode }) => <>{children}</>,
PopoverTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
}));
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
SheetContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
}));
vi.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/ui/skeleton", () => ({
Skeleton: () => <div data-testid="skeleton" />,
}));
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TabsContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TabsList: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TabsTrigger: ({ children }: { children?: ReactNode }) => <button type="button">{children}</button>,
}));
vi.mock("@/components/ui/textarea", () => ({
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: "goal-1",
parentId: null,
title: "Issue detail smoke",
description: "Loads after the initial pending query.",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
currentExecutionWorkspace: null,
createdByAgentId: null,
createdByUserId: null,
identifier: "PAP-1",
issueNumber: 1,
originKind: "manual",
originId: null,
originRunId: null,
originFingerprint: "default",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionPolicy: null,
executionState: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-04-21T00:00:00.000Z"),
updatedAt: new Date("2026-04-21T00:00:00.000Z"),
labels: [],
labelIds: [],
ancestors: [],
documentSummaries: [],
...overrides,
} as Issue;
}
function createPauseHold(overrides: Partial<IssueTreeHold> = {}): IssueTreeHold {
const now = new Date("2026-04-21T00:00:00.000Z");
return {
id: "hold-1",
companyId: "company-1",
rootIssueId: "issue-1",
mode: "pause",
status: "active",
reason: null,
releasePolicy: { strategy: "manual", note: "full_pause" },
createdByActorType: "user",
createdByAgentId: null,
createdByUserId: "user-1",
createdByRunId: null,
releasedAt: null,
releasedByActorType: null,
releasedByAgentId: null,
releasedByUserId: null,
releasedByRunId: null,
releaseReason: null,
releaseMetadata: null,
createdAt: now,
updatedAt: now,
members: [
{
id: "hold-member-root",
companyId: "company-1",
holdId: "hold-1",
issueId: "issue-1",
parentIssueId: null,
depth: 0,
issueIdentifier: "PAP-1",
issueTitle: "Issue detail smoke",
issueStatus: "todo",
assigneeAgentId: null,
assigneeUserId: null,
activeRunId: null,
activeRunStatus: null,
skipped: false,
skipReason: null,
createdAt: now,
},
{
id: "hold-member-child",
companyId: "company-1",
holdId: "hold-1",
issueId: "child-1",
parentIssueId: "issue-1",
depth: 1,
issueIdentifier: "PAP-2",
issueTitle: "Held child",
issueStatus: "todo",
assigneeAgentId: null,
assigneeUserId: null,
activeRunId: null,
activeRunStatus: null,
skipped: false,
skipReason: null,
createdAt: now,
},
],
...overrides,
};
}
function createResumePreview(): IssueTreeControlPreview {
return {
companyId: "company-1",
rootIssueId: "issue-1",
mode: "resume",
generatedAt: new Date("2026-04-21T00:00:00.000Z"),
releasePolicy: { strategy: "manual" },
totals: {
totalIssues: 2,
affectedIssues: 2,
skippedIssues: 0,
activeRuns: 0,
queuedRuns: 0,
affectedAgents: 0,
},
countsByStatus: { todo: 2 },
issues: [
{
id: "issue-1",
identifier: "PAP-1",
title: "Issue detail smoke",
status: "todo",
parentId: null,
depth: 0,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: ["hold-1"],
action: "resume",
skipped: false,
skipReason: null,
},
{
id: "child-1",
identifier: "PAP-2",
title: "Held child",
status: "todo",
parentId: "issue-1",
depth: 1,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: ["hold-1"],
action: "resume",
skipped: false,
skipReason: null,
},
],
skippedIssues: [],
activeRuns: [],
affectedAgents: [],
warnings: [],
};
}
function createRestorePreview(): IssueTreeControlPreview {
return {
companyId: "company-1",
rootIssueId: "issue-1",
mode: "restore",
generatedAt: new Date("2026-04-21T00:00:00.000Z"),
releasePolicy: { strategy: "manual" },
totals: {
totalIssues: 2,
affectedIssues: 1,
skippedIssues: 1,
activeRuns: 0,
queuedRuns: 0,
affectedAgents: 1,
},
countsByStatus: { todo: 1, cancelled: 1 },
issues: [
{
id: "issue-1",
identifier: "PAP-1",
title: "Issue detail smoke",
status: "todo",
parentId: null,
depth: 0,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "restore",
skipped: true,
skipReason: "not_cancelled",
},
{
id: "child-1",
identifier: "PAP-2",
title: "Cancelled child",
status: "cancelled",
parentId: "issue-1",
depth: 1,
assigneeAgentId: "agent-1",
assigneeUserId: null,
activeRun: null,
activeHoldIds: ["cancel-hold-1"],
action: "restore",
skipped: false,
skipReason: null,
},
],
skippedIssues: [
{
id: "issue-1",
identifier: "PAP-1",
title: "Issue detail smoke",
status: "todo",
parentId: null,
depth: 0,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "restore",
skipped: true,
skipReason: "not_cancelled",
},
],
activeRuns: [],
affectedAgents: [{ agentId: "agent-1", issueCount: 1, activeRunCount: 0 }],
warnings: [],
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flushReact();
}
}
throw lastError;
}
describe("IssueDetail", () => {
let container: HTMLDivElement;
let root: Root;
let queryClient: QueryClient;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listComments.mockResolvedValue([]);
mockIssuesApi.listAttachments.mockResolvedValue([]);
mockIssuesApi.listFeedbackVotes.mockResolvedValue([]);
mockIssuesApi.markRead.mockResolvedValue({ id: "issue-1", lastReadAt: new Date().toISOString() });
mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null });
mockIssuesApi.listTreeHolds.mockResolvedValue([]);
mockActivityApi.forIssue.mockResolvedValue([]);
mockActivityApi.runsForIssue.mockResolvedValue([]);
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([]);
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue(null);
mockAgentsApi.list.mockResolvedValue([]);
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
companyIds: ["company-1"],
isInstanceAdmin: true,
source: "session",
keyId: null,
user: null,
userId: null,
});
mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] });
mockAuthApi.getSession.mockResolvedValue({ session: null, user: null });
mockProjectsApi.list.mockResolvedValue([]);
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
});
mockIssuesListRender.mockClear();
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
queryClient.clear();
container.remove();
document.body.innerHTML = "";
vi.restoreAllMocks();
});
it("loads from the pending state into issue detail without changing hook order", async () => {
const issueRequest = createDeferred<Issue>();
mockIssuesApi.get.mockReturnValueOnce(issueRequest.promise);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
issueRequest.resolve(createIssue());
await flushReact();
await flushReact();
expect(container.textContent).toContain("Issue detail smoke");
expect(container.textContent).toContain("Chat thread");
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it("refreshes subtree pause state after resuming a hold", async () => {
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
identifier: "PAP-2",
issueNumber: 2,
title: "Held child",
});
const activeHold = createPauseHold();
const releasedHold = createPauseHold({
status: "released",
releasedAt: new Date("2026-04-21T00:01:00.000Z"),
releasedByActorType: "user",
releasedByUserId: "user-1",
releaseReason: "Ready to continue",
updatedAt: new Date("2026-04-21T00:01:00.000Z"),
});
let activePauseHoldState: null | {
holdId: string;
rootIssueId: string;
issueId: string;
isRoot: boolean;
mode: "pause";
reason: string | null;
releasePolicy: { strategy: "manual" | "after_active_runs_finish"; note?: string | null } | null;
} = {
holdId: "hold-1",
rootIssueId: "issue-1",
issueId: "issue-1",
isRoot: true,
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "full_pause" },
};
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) =>
Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []),
);
mockIssuesApi.getTreeControlState.mockImplementation(() =>
Promise.resolve({ activePauseHold: activePauseHoldState }),
);
mockIssuesApi.listTreeHolds.mockResolvedValue([activeHold]);
mockIssuesApi.previewTreeControl.mockResolvedValue(createResumePreview());
mockIssuesApi.releaseTreeHold.mockImplementation(() => {
activePauseHoldState = null;
return Promise.resolve(releasedHold);
});
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
expect(container.textContent).toContain("Subtree pause is active.");
expect(mockIssuesListRender.mock.calls.at(-1)?.[0].issueBadgeById.get("child-1")).toBe("Paused");
});
const resumeButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Resume subtree");
expect(resumeButton).toBeTruthy();
await act(async () => {
resumeButton!.click();
});
await flushReact();
const applyResumeButton = Array.from(container.querySelectorAll("button"))
.filter((button) => button.textContent?.trim() === "Resume subtree")
.at(-1);
expect(applyResumeButton).toBeTruthy();
await act(async () => {
applyResumeButton!.click();
});
await flushReact();
await flushReact();
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith("PAP-1", "hold-1", {
reason: null,
metadata: { wakeAgents: false },
});
expect(mockIssuesApi.getTreeControlState.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({
title: "Subtree resumed",
body: "Ready to continue",
}));
await waitForAssertion(() => {
expect(container.textContent).not.toContain("Subtree pause is active.");
expect(mockIssuesListRender.mock.calls.at(-1)?.[0].issueBadgeById.has("child-1")).toBe(false);
});
});
it("exposes restore subtree from the issue actions menu", async () => {
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
identifier: "PAP-2",
issueNumber: 2,
title: "Cancelled child",
status: "cancelled",
assigneeAgentId: "agent-1",
});
const cancelHold = createPauseHold({
id: "cancel-hold-1",
mode: "cancel",
reason: "bad plan",
members: [],
});
const restorePreview = createRestorePreview();
const restoreHold = createPauseHold({
id: "restore-hold-1",
mode: "restore",
status: "released",
reason: null,
releaseReason: "Restore operation applied",
releasedAt: new Date("2026-04-21T00:02:00.000Z"),
members: [],
});
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) =>
Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []),
);
mockIssuesApi.listTreeHolds.mockImplementation((_issueId, filters?: { mode?: string }) =>
Promise.resolve(filters?.mode === "cancel" ? [cancelHold] : []),
);
mockIssuesApi.previewTreeControl.mockResolvedValue(restorePreview);
mockIssuesApi.createTreeHold.mockResolvedValue({ hold: restoreHold, preview: restorePreview });
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const moreButton = container.querySelector('button[aria-label="More issue actions"]') as HTMLButtonElement | null;
expect(moreButton).toBeTruthy();
await act(async () => {
moreButton!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
await flushReact();
const restoreMenuButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Restore subtree...");
expect(restoreMenuButton).toBeTruthy();
await act(async () => {
restoreMenuButton!.click();
});
await flushReact();
await flushReact();
expect(mockIssuesApi.previewTreeControl).toHaveBeenCalledWith("PAP-1", {
mode: "restore",
releasePolicy: { strategy: "manual" },
});
expect(container.textContent).toContain("Restore issues cancelled by this subtree operation so work can resume.");
expect(container.textContent).toContain("Issues restored: 1");
const restoreApplyButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Restore 1 issues");
expect(restoreApplyButton).toBeTruthy();
await act(async () => {
restoreApplyButton!.click();
});
await flushReact();
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "restore",
reason: null,
releasePolicy: { strategy: "manual" },
metadata: { wakeAgents: false },
});
});
});
+140 -59
View File
@@ -159,8 +159,8 @@ const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
const TREE_CONTROL_MODE_HELP_TEXT: Record<IssueTreeControlMode, string> = {
pause: "Pause active execution in this issue subtree and hold descendant execution until release.",
resume: "Release the active subtree pause hold so held work can continue.",
cancel: "Cancel active subtree work and stop queued/running work where possible.",
restore: "Restore cancelled issues in the subtree so work can resume.",
cancel: "Cancel non-terminal issues in this subtree and stop queued/running work where possible.",
restore: "Restore issues cancelled by this subtree operation so work can resume.",
};
const TREE_CONTROL_STRATEGY_OPTIONS = [
{ value: "manual", label: "Manual release" },
@@ -1277,6 +1277,15 @@ export function IssueDetail() {
}),
enabled: !!issueId && treeControlState?.activePauseHold?.isRoot === true,
});
const { data: activeCancelHolds = [] } = useQuery({
queryKey: ["issues", "tree-holds", issueId ?? "pending", "active-cancel"],
queryFn: () =>
issuesApi.listTreeHolds(issueId!, {
status: "active",
mode: "cancel",
}),
enabled: !!issueId && canManageTreeControl,
});
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
@@ -1539,6 +1548,9 @@ export function IssueDetail() {
? { note: treeControlPauseVariant }
: {}),
},
...(treeControlMode === "restore"
? { metadata: { wakeAgents: treeControlWakeAgentsOnResume } }
: {}),
});
return { kind: "create" as const, hold: created.hold, preview: created.preview };
},
@@ -1547,20 +1559,39 @@ export function IssueDetail() {
const cancelCount = result.preview?.totals.activeRuns ?? 0;
const pauseModeText = treeControlPauseVariant === "course_correction" ? "Course-correction pause enabled." : "Full subtree pause enabled.";
pushToast({
title: result.hold.mode === "pause" ? "Subtree paused" : `${modeLabel} applied`,
body: result.hold.mode === "pause"
? `${pauseModeText} ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
: result.hold.reason?.trim()
? result.hold.reason
: `Control applied with ${result.hold.releasePolicy?.strategy ?? "manual"} release policy.`,
title: result.kind === "release"
? "Subtree resumed"
: result.hold.mode === "pause"
? "Subtree paused"
: `${modeLabel} applied`,
body: result.kind === "release"
? (result.hold.releaseReason?.trim() || "Active subtree pause released.")
: result.hold.mode === "pause"
? `${pauseModeText} ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
: result.hold.reason?.trim()
? result.hold.reason
: `Control applied with ${result.hold.releasePolicy?.strategy ?? "manual"} release policy.`,
});
setTreeControlOpen(false);
setTreeControlReason("");
setTreeControlWakeAgentsOnResume(false);
setTreeControlCancelConfirmed(false);
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"] }),
queryClient.invalidateQueries({ queryKey: ["issues", "tree-holds", issueId ?? "pending"] }),
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-preview", issueId ?? "pending"] }),
]);
if (selectedCompanyId) {
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }),
...(issue?.id
? [queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(selectedCompanyId, issue.id) })]
: []),
]);
}
},
onError: (err) => {
@@ -2559,6 +2590,50 @@ export function IssueDetail() {
await answerInteraction.mutateAsync({ interaction, answers });
}, [answerInteraction]);
const treePreviewAffectedIssues = useMemo(
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
[treeControlPreview],
);
const activePauseHold = treeControlState?.activePauseHold ?? null;
const activeRootPauseHoldsForDisplay = useMemo(
() => activePauseHold?.isRoot === true ? activeRootPauseHolds : [],
[activePauseHold?.isRoot, activeRootPauseHolds],
);
const heldIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const hold of activeRootPauseHoldsForDisplay) {
for (const member of hold.members ?? []) {
if (member.skipped) continue;
ids.add(member.issueId);
}
}
return ids;
}, [activeRootPauseHoldsForDisplay]);
const mutedChildIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const child of childIssues) {
if (heldIssueIds.has(child.id)) ids.add(child.id);
}
return ids;
}, [childIssues, heldIssueIds]);
const childPauseBadgeById = useMemo(() => {
const badges = new Map<string, string>();
for (const child of childIssues) {
if (!heldIssueIds.has(child.id)) continue;
badges.set(child.id, "Paused");
}
return badges;
}, [childIssues, heldIssueIds]);
const activePauseHoldRoot = useMemo(() => {
if (!activePauseHold) return null;
if (activePauseHold.rootIssueId === issue?.id) return issue ?? null;
return issue?.ancestors?.find((ancestor) => ancestor.id === activePauseHold.rootIssueId) ?? null;
}, [activePauseHold, issue]);
const activeRootPauseHold = useMemo(
() => activeRootPauseHoldsForDisplay.find((hold) => hold.id === activePauseHold?.holdId) ?? null,
[activePauseHold?.holdId, activeRootPauseHoldsForDisplay],
);
if (isLoading) return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!issue) return null;
@@ -2595,61 +2670,34 @@ export function IssueDetail() {
};
const hasAttachments = attachmentList.length > 0;
const treePreviewAffectedIssues = useMemo(
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
[treeControlPreview],
);
const treePreviewWarnings = treeControlPreview?.warnings ?? [];
const activePauseHold = treeControlState?.activePauseHold ?? null;
const heldIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const hold of activeRootPauseHolds) {
for (const member of hold.members ?? []) {
ids.add(member.issueId);
}
}
return ids;
}, [activeRootPauseHolds]);
const mutedChildIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const child of childIssues) {
if (heldIssueIds.has(child.id)) ids.add(child.id);
}
return ids;
}, [childIssues, heldIssueIds]);
const childPauseBadgeById = useMemo(() => {
const badges = new Map<string, string>();
for (const child of childIssues) {
if (!heldIssueIds.has(child.id)) continue;
badges.set(child.id, "Paused");
}
return badges;
}, [childIssues, heldIssueIds]);
const activePauseHoldRoot = useMemo(() => {
if (!activePauseHold) return null;
if (activePauseHold.rootIssueId === issue?.id) return issue ?? null;
return issue?.ancestors?.find((ancestor) => ancestor.id === activePauseHold.rootIssueId) ?? null;
}, [activePauseHold, issue]);
const activeRootPauseHold = useMemo(
() => activeRootPauseHolds.find((hold) => hold.id === activePauseHold?.holdId) ?? null,
[activePauseHold?.holdId, activeRootPauseHolds],
);
const activePauseVariant = parsePauseVariantFromNote(
activeRootPauseHold?.releasePolicy?.note ?? activePauseHold?.releasePolicy?.note,
);
const heldDescendantCount = activeRootPauseHold?.members?.filter((member) => member.depth > 0).length
const heldDescendantCount = activeRootPauseHold?.members?.filter((member) => member.depth > 0 && !member.skipped).length
?? Math.max(heldIssueIds.size - 1, 0);
const canShowSubtreeControls = canManageTreeControl && childIssues.length > 0;
const canResumeSubtree = canShowSubtreeControls && activePauseHold?.isRoot === true;
const canRestoreSubtree = canShowSubtreeControls && activeCancelHolds.length > 0;
const previewTerminalSkippedCount = treeControlPreview?.skippedIssues.filter((candidate) => candidate.skipReason === "terminal_status").length ?? 0;
const previewAffectedIssueCount = treePreviewAffectedIssues.length;
const previewAffectedDescendantCount = treePreviewAffectedIssues.filter((candidate) => candidate.depth > 0).length;
const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0;
const treePreviewPrimaryCountLabel =
treeControlMode === "pause"
? "Issues held"
: treeControlMode === "cancel"
? "Issues cancelled"
: treeControlMode === "restore"
? "Issues restored"
: "Issues released";
const treeControlPrimaryButtonLabel =
treeControlMode === "pause"
? "Pause and stop work"
: treeControlMode === "cancel"
? `Cancel ${treeControlPreview?.totals.affectedIssues ?? 0} issues`
: treeControlMode === "restore"
? "Restore subtree"
? `Cancel ${previewAffectedIssueCount} issues`
: treeControlMode === "restore"
? `Restore ${previewAffectedIssueCount} issues`
: "Resume subtree";
const deferredComposerHint = activePauseHold && !activePauseHold.isRoot
? "Will be delivered when the subtree resumes."
@@ -2670,7 +2718,8 @@ export function IssueDetail() {
Boolean(treeControlPreview)
&& !treeControlPreviewLoading
&& (treeControlMode !== "cancel" || (treeControlReason.trim().length > 0 && treeControlCancelConfirmed))
&& (treeControlMode !== "resume" || !treeControlWakeAgentsOnResume || previewAffectedAgentCount > 0);
&& (treeControlMode !== "resume" || !treeControlWakeAgentsOnResume || previewAffectedAgentCount > 0)
&& (treeControlMode !== "restore" || !treeControlWakeAgentsOnResume || previewAffectedAgentCount > 0);
const attachmentUploadButton = (
<>
<input
@@ -2932,7 +2981,19 @@ export function IssueDetail() {
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-xs" className="shrink-0">
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="More issue actions"
title="More issue actions"
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setMoreOpen(true);
}
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -2977,6 +3038,21 @@ export function IssueDetail() {
<XCircle className="h-3 w-3" />
Cancel subtree...
</button>
{canRestoreSubtree ? (
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
setTreeControlMode("restore");
setTreeControlWakeAgentsOnResume(false);
setTreeControlCancelConfirmed(false);
setTreeControlOpen(true);
setMoreOpen(false);
}}
>
<Repeat className="h-3 w-3" />
Restore subtree...
</button>
) : null}
</>
) : null}
<button
@@ -3382,7 +3458,7 @@ export function IssueDetail() {
<div className="space-y-4">
{treeControlMode === "cancel" ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive">
Cancelling a subtree is destructive. Running and queued work will be interrupted where possible.
Cancelling a subtree is destructive. Non-terminal issues will be marked cancelled, and running or queued work will be interrupted where possible.
</div>
) : null}
@@ -3438,7 +3514,9 @@ export function IssueDetail() {
<span className="text-xs text-muted-foreground">
{previewAffectedAgentCount === 0
? "No assigned agents are eligible to wake from this preview."
: "Unchecked by default. Resume does not wake agents unless explicitly enabled."}
: treeControlMode === "restore"
? "Unchecked by default. Restore does not wake agents unless explicitly enabled."
: "Unchecked by default. Resume does not wake agents unless explicitly enabled."}
</span>
</span>
</label>
@@ -3469,7 +3547,7 @@ export function IssueDetail() {
checked={treeControlCancelConfirmed}
onChange={(event) => setTreeControlCancelConfirmed(event.target.checked)}
/>
<span>I understand this will cancel {treeControlPreview?.totals.affectedIssues ?? 0} issues.</span>
<span>I understand this will cancel {previewAffectedIssueCount} issues.</span>
</label>
) : null}
@@ -3497,11 +3575,14 @@ export function IssueDetail() {
) : treeControlPreview ? (
<div className="mt-2 space-y-2">
<div className="grid grid-cols-1 gap-1 text-xs text-muted-foreground sm:grid-cols-2">
<span>Descendants held: {Math.max(treeControlPreview.totals.affectedIssues - 1, 0)}</span>
<span>{treePreviewPrimaryCountLabel}: {previewAffectedIssueCount}</span>
<span>Descendants affected: {previewAffectedDescendantCount}</span>
<span>Active runs cancelled: {treeControlPreview.totals.activeRuns}</span>
<span>Queued runs dropped: {treeControlPreview.totals.queuedRuns}</span>
<span>Agents affected: {treeControlPreview.totals.affectedAgents}</span>
<span>Terminal issues skipped: {previewTerminalSkippedCount}</span>
{previewTerminalSkippedCount > 0 ? (
<span>Terminal issues skipped: {previewTerminalSkippedCount}</span>
) : null}
</div>
{treePreviewWarnings.length > 0 ? (
<div className="space-y-1">