mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Restore subtree cancel state handling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user