Files
paperclip/server/src/routes/issue-tree-control.ts
T
Aron Prins 70b1a9109d Improve CLI API parity coverage (#6626)
## Thinking Path

> - Paperclip is a control plane for AI-agent companies, with the CLI
acting as a scriptable operator and agent interface to that control
plane.
> - The REST API surface has grown across companies, agents, issues,
routines, plugins, auth, workspaces, secrets, and operational inspection
commands.
> - The CLI had drifted from that API surface: some commands were
missing, some command shapes differed from docs/reference material, and
several edge cases only failed during end-to-end local-source testing.
> - The local development runbook requires these tests to be disposable
and isolated from a real `~/.paperclip`, `~/.codex`, or `~/.claude`
installation.
> - This pull request adds broad CLI/API parity coverage, fixes the
actionable bugs found during that pass, and records the reproducible
test log under `doc/logs`.
> - The benefit is a more complete, scriptable CLI surface with
regression coverage for the command families exercised by the parity
run.

## What Changed

- Added or expanded CLI command coverage for access/auth, companies,
agents, projects, goals, issues and subresources, routines, plugins,
workspaces, activity/run/cost/dashboard inspection, assets, skills,
secrets, tokens, prompt/wake flows, and local setup helpers.
- Fixed CLI/API parity bugs found during the run, including context
profile patching, issue interaction optional payloads, malformed
tree-hold errors, environment duplicate handling, configure
invalid-section exit codes, worktree pnpm invocation, token agent ID
resolution, plugin tool worker lookup, and routine webhook secret
cleanup.
- Added missing CLI wrappers and route coverage for health/access,
invite resolution URL forwarding, join status normalization, secret
lifecycle commands, LLM docs routes, available-skill isolation, positive
board-claim coverage, and interactive `connect` prompt-flow tests.
- Added a schema-backed `/api/openapi.json` route sufficient for CLI
parity and `paperclipai openapi --json` smoke coverage.
- Added `doc/logs/2026-05-24-cli-api-parity-e2e-log.md` with the
detailed living test/bug log and renamed the log directory from
`doc/bugs` to `doc/logs`.
- Added `doc/plans/2026-05-23-cli-api-parity.md` and the OpenAPI parity
reference used during the pass.

OpenAPI note: this PR intentionally does not try to subsume
`feature/openapi-spec`. The OpenAPI implementation here is schema-backed
and better than the earlier route-inventory stub, but
`feature/openapi-spec` is the fuller/better OpenAPI branch because it
includes exact mounted-route coverage tests and additional current route
coverage. That branch should stay as its own PR and can supersede this
OpenAPI route implementation.

## Verification

Targeted automated checks run:

- `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts`
- `pnpm exec vitest run server/src/__tests__/board-claim.test.ts`
- `pnpm exec vitest run cli/src/__tests__/connect.test.ts`
- `pnpm exec vitest run cli/src/__tests__/agent-lifecycle.test.ts`
- `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts`
- `pnpm exec vitest run server/src/__tests__/routines-service.test.ts`
- `pnpm --dir cli typecheck`
- `pnpm --dir server typecheck`

Manual/local E2E verification:

- Ran the full disposable local-source CLI/API parity pass with isolated
`PAPERCLIP_HOME`, `PAPERCLIP_CONFIG`, `PAPERCLIP_CONTEXT`,
`PAPERCLIP_AUTH_STORE`, `CODEX_HOME`, and `CLAUDE_HOME` under
`tmp/cli-api-parity`.
- Verified `DATABASE_URL` and `DATABASE_MIGRATION_URL` stayed unset for
the scratch server.
- Verified live health and schema-backed OpenAPI responses on
non-default port `3197`.
- Revoked created board/agent tokens and cleaned up temporary plugins,
secrets, non-default environments, and project workspaces.
- See `doc/logs/2026-05-24-cli-api-parity-e2e-log.md` for the full
command-by-command reproduction log.

Not run:

- Full `pnpm test`, `pnpm test:run`, or `pnpm build` were not run after
the entire branch because the branch is broad and the parity pass used
focused test/typecheck verification plus live isolated CLI reruns.

## Risks

- This is a broad PR and touches many CLI command modules, so review
surface is high. The changes are grouped around one theme, but a split
may be easier if maintainers prefer narrower PRs.
- The OpenAPI route in this PR is not the final/best OpenAPI
implementation. `feature/openapi-spec` has stronger exact-route coverage
and should remain the source for the dedicated OpenAPI PR.
- The living log is intentionally detailed and large. It is useful for
reproducibility but adds documentation weight.
- No UI changes are intended; screenshots are not applicable.

> 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, GPT-5-based coding agent in Codex desktop. Exact served
model/context-window identifier was not exposed in the local app. Work
used shell/Git/GitHub CLI tooling, local source inspection, targeted
test execution, and live isolated Paperclip CLI/API smoke testing.

## 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
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] 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: Devin Foley <devin@devinfoley.com>
2026-06-02 17:13:29 -07:00

410 lines
14 KiB
TypeScript

import { Router } from "express";
import type { Request } from "express";
import type { Db } from "@paperclipai/db";
import {
createIssueTreeHoldSchema,
isUuidLike,
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";
const TREE_RUN_CANCELLATION_RESPONSE_WAIT_MS = 1_000;
function errorToMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
async function waitForRunCancellationTasks(tasks: Promise<void>[]) {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
Promise.all(tasks),
new Promise((resolve) => {
timeout = setTimeout(resolve, TREE_RUN_CANCELLATION_RESPONSE_WAIT_MS);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
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),
},
});
const runCancellationTasks: Promise<void>[] = [];
if (result.hold.mode === "pause" || result.hold.mode === "cancel") {
const interruptedRunIds = [...new Set(result.preview.activeRuns.map((run) => run.id))];
for (const heartbeatRunId of interruptedRunIds) {
const cancellationTask = (async () => {
try {
await heartbeat.cancelRun(heartbeatRunId);
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: heartbeatRunId,
details: {
holdId: result.hold.id,
rootIssueId: root.id,
reason: result.hold.mode === "pause" ? "active_subtree_pause_hold" : "subtree_cancel_operation",
},
});
} catch (error) {
await Promise.resolve(logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_hold_run_interrupt_failed",
entityType: "heartbeat_run",
entityId: heartbeatRunId,
details: {
holdId: result.hold.id,
rootIssueId: root.id,
reason: result.hold.mode === "pause" ? "active_subtree_pause_hold" : "subtree_cancel_operation",
error: errorToMessage(error),
},
})).catch(() => null);
}
})();
runCancellationTasks.push(cancellationTask);
}
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 (runCancellationTasks.length > 0) {
await waitForRunCancellationTasks(runCancellationTasks);
}
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" || result.hold.mode === "resume" ? 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 holdId = req.params.holdId as string;
if (!isUuidLike(holdId)) {
res.status(400).json({ error: "Invalid hold ID" });
return;
}
const hold = await treeControlSvc.getHold(root.companyId, holdId);
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 holdId = req.params.holdId as string;
if (!isUuidLike(holdId)) {
res.status(400).json({ error: "Invalid hold ID" });
return;
}
const actor = getActorInfo(req);
const hold = await treeControlSvc.releaseHold(root.companyId, root.id, holdId, {
...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;
}