mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
[codex] Add issue subtree pause, cancel, and restore controls (#4332)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - This branch extends the issue control-plane so board operators can pause, cancel, and later restore whole issue subtrees while keeping descendant execution and wake behavior coherent. > - That required new hold state in the database, shared contracts, server routes/services, and issue detail UI controls so subtree actions are durable and auditable instead of ad hoc. > - While this branch was in flight, `master` advanced with new environment lifecycle work, including a new `0065_environments` migration. > - Before opening the PR, this branch had to be rebased onto `paperclipai/paperclip:master` without losing the existing subtree-control work or leaving conflicting migration numbering behind. > - This pull request rebases the subtree pause/cancel/restore feature cleanly onto current `master`, renumbers the hold migration to `0066_issue_tree_holds`, and preserves the full branch diff in a single PR. > - The benefit is that reviewers get one clean, mergeable PR for the subtree-control feature instead of stale branch history with migration conflicts. ## What Changed - Added durable issue subtree hold data structures, shared API/types/validators, server routes/services, and UI flows for subtree pause, cancel, and restore operations. - Added server and UI coverage for subtree previewing, hold creation/release, dependency-aware scheduling under holds, and issue detail subtree controls. - Rebased the branch onto current `paperclipai/paperclip:master` and renumbered the branch migration from `0065_issue_tree_holds` to `0066_issue_tree_holds` so it no longer conflicts with upstream `0065_environments`. - Added a small follow-up commit that makes restore requests return `200 OK` explicitly while keeping pause/cancel hold creation at `201 Created`, and updated the route test to match that contract. ## Verification - `pnpm --filter @paperclipai/db typecheck` - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `cd server && pnpm exec vitest run src/__tests__/issue-tree-control-routes.test.ts src/__tests__/issue-tree-control-service.test.ts src/__tests__/issue-tree-control-service-unit.test.ts src/__tests__/heartbeat-dependency-scheduling.test.ts` - `cd ui && pnpm exec vitest run src/components/IssueChatThread.test.tsx src/pages/IssueDetail.test.tsx` ## Risks - This is a broad cross-layer change touching DB/schema, shared contracts, server orchestration, and UI; regressions are most likely around subtree status restoration or wake suppression/resume edge cases. - The migration was renumbered during PR prep to avoid the new upstream `0065_environments` conflict. Reviewers should confirm the final `0066_issue_tree_holds` ordering is the only hold-related migration that lands. - The issue-tree restore endpoint now responds with `200` instead of relying on implicit behavior, which is semantically better for a restore operation but still changes an API detail that clients or tests could have assumed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent in the Paperclip Codex runtime (GPT-5-class tool-using coding model; exact deployment ID/context window is not exposed inside this session). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
854fa81757
commit
f98c348e2b
31 changed files with 4753 additions and 22 deletions
|
|
@ -4,6 +4,7 @@ export { companySkillRoutes } from "./company-skills.js";
|
|||
export { agentRoutes } from "./agents.js";
|
||||
export { projectRoutes } from "./projects.js";
|
||||
export { issueRoutes } from "./issues.js";
|
||||
export { issueTreeControlRoutes } from "./issue-tree-control.js";
|
||||
export { routineRoutes } from "./routines.js";
|
||||
export { goalRoutes } from "./goals.js";
|
||||
export { approvalRoutes } from "./approvals.js";
|
||||
|
|
|
|||
347
server/src/routes/issue-tree-control.ts
Normal file
347
server/src/routes/issue-tree-control.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import { Router } from "express";
|
||||
import type { Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createIssueTreeHoldSchema,
|
||||
previewIssueTreeControlSchema,
|
||||
releaseIssueTreeHoldSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { heartbeatService, issueService, issueTreeControlService, logActivity } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function issueTreeControlRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const issuesSvc = issueService(db);
|
||||
const treeControlSvc = issueTreeControlService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
async function resolveRootIssue(req: Request) {
|
||||
const rootIssueId = req.params.id as string;
|
||||
const root = await issuesSvc.getById(rootIssueId);
|
||||
return root;
|
||||
}
|
||||
|
||||
router.post("/issues/:id/tree-control/preview", validate(previewIssueTreeControlSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const root = await resolveRootIssue(req);
|
||||
if (!root) {
|
||||
res.status(404).json({ error: "Root issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, root.companyId);
|
||||
|
||||
const preview = await treeControlSvc.preview(root.companyId, root.id, req.body);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: root.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.tree_control_previewed",
|
||||
entityType: "issue",
|
||||
entityId: root.id,
|
||||
details: {
|
||||
mode: preview.mode,
|
||||
totals: preview.totals,
|
||||
warningCodes: preview.warnings.map((warning) => warning.code),
|
||||
},
|
||||
});
|
||||
|
||||
res.json(preview);
|
||||
});
|
||||
|
||||
router.post("/issues/:id/tree-holds", validate(createIssueTreeHoldSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const root = await resolveRootIssue(req);
|
||||
if (!root) {
|
||||
res.status(404).json({ error: "Root issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, root.companyId);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
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: actorInput,
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId: root.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.tree_hold_created",
|
||||
entityType: "issue",
|
||||
entityId: root.id,
|
||||
details: {
|
||||
holdId: result.hold.id,
|
||||
mode: result.hold.mode,
|
||||
reason: result.hold.reason,
|
||||
totals: result.preview.totals,
|
||||
warningCodes: result.preview.warnings.map((warning) => warning.code),
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
await logActivity(db, {
|
||||
companyId: root.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.tree_hold_run_interrupted",
|
||||
entityType: "heartbeat_run",
|
||||
entityId: runId,
|
||||
details: {
|
||||
holdId: result.hold.id,
|
||||
rootIssueId: root.id,
|
||||
reason: result.hold.mode === "pause" ? "active_subtree_pause_hold" : "subtree_cancel_operation",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const cancelledWakeups = await treeControlSvc.cancelUnclaimedWakeupsForTree(
|
||||
root.companyId,
|
||||
root.id,
|
||||
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, {
|
||||
companyId: root.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.tree_hold_wakeup_deferred",
|
||||
entityType: "agent_wakeup_request",
|
||||
entityId: wakeup.id,
|
||||
details: {
|
||||
holdId: result.hold.id,
|
||||
rootIssueId: root.id,
|
||||
agentId: wakeup.agentId,
|
||||
previousReason: wakeup.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
let statusUpdate;
|
||||
try {
|
||||
statusUpdate = await treeControlSvc.restoreIssueStatusesForHold(root.companyId, root.id, result.hold.id, {
|
||||
reason: result.hold.reason,
|
||||
actor: actorInput,
|
||||
});
|
||||
} catch (error) {
|
||||
await treeControlSvc.releaseHold(root.companyId, root.id, result.hold.id, {
|
||||
reason: "Restore operation failed before subtree status updates completed",
|
||||
metadata: {
|
||||
cleanup: "restore_failed_before_apply",
|
||||
},
|
||||
actor: actorInput,
|
||||
}).catch(() => null);
|
||||
throw error;
|
||||
}
|
||||
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(result.hold.mode === "restore" ? 200 : 201).json(result);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/tree-control/state", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const issueId = req.params.id as string;
|
||||
const issue = await issuesSvc.getById(issueId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id);
|
||||
res.json({ activePauseHold });
|
||||
});
|
||||
|
||||
router.get("/issues/:id/tree-holds", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const root = await resolveRootIssue(req);
|
||||
if (!root) {
|
||||
res.status(404).json({ error: "Root issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, root.companyId);
|
||||
const statusParam = typeof req.query.status === "string" ? req.query.status : null;
|
||||
const modeParam = typeof req.query.mode === "string" ? req.query.mode : null;
|
||||
const includeMembers = req.query.includeMembers === "true";
|
||||
const holds = await treeControlSvc.listHolds(root.companyId, root.id, {
|
||||
status: statusParam === "active" || statusParam === "released" ? statusParam : undefined,
|
||||
mode:
|
||||
modeParam === "pause" || modeParam === "resume" || modeParam === "cancel" || modeParam === "restore"
|
||||
? modeParam
|
||||
: undefined,
|
||||
includeMembers,
|
||||
});
|
||||
res.json(holds);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/tree-holds/:holdId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const root = await resolveRootIssue(req);
|
||||
if (!root) {
|
||||
res.status(404).json({ error: "Root issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, root.companyId);
|
||||
|
||||
const hold = await treeControlSvc.getHold(root.companyId, req.params.holdId as string);
|
||||
if (!hold || hold.rootIssueId !== root.id) {
|
||||
res.status(404).json({ error: "Issue tree hold not found" });
|
||||
return;
|
||||
}
|
||||
res.json(hold);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/issues/:id/tree-holds/:holdId/release",
|
||||
validate(releaseIssueTreeHoldSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const root = await resolveRootIssue(req);
|
||||
if (!root) {
|
||||
res.status(404).json({ error: "Root issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, root.companyId);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const hold = await treeControlSvc.releaseHold(root.companyId, root.id, req.params.holdId as string, {
|
||||
...req.body,
|
||||
actor: {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
runId: actor.runId,
|
||||
},
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId: root.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.tree_hold_released",
|
||||
entityType: "issue",
|
||||
entityId: root.id,
|
||||
details: {
|
||||
holdId: hold.id,
|
||||
mode: hold.mode,
|
||||
reason: hold.releaseReason,
|
||||
memberCount: hold.members?.length ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(hold);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue