mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 17:40:37 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The pipeline subsystem models repeatable work as items moving through stages, with agent automation, review gates, blockers, drift notices, and linked work. > - Operators need this to be usable as one coherent workflow surface, not just as backend primitives or disconnected route experiments. > - The branch now carries the pipeline data model, service/routes, CLI/tutorial path, aggregation feeds, operator UI, stage automation controls, liveness/retry handling, and follow-up polish that make the primitive reviewable end to end. > - This pull request is the single review target for that pipeline workflow primitive stack. > - The benefit is that reviewers can evaluate the full operator experience and server contract together against `master`. ## Linked Issues or Issue Description No public GitHub issue exists for this work. The underlying feature request is described inline. ### Problem or motivation Paperclip needs a first-class way to model multi-stage agent/company workflows where upstream items can spawn downstream work, request review, carry fields across pipelines, surface drift, retry automation, and show operators where work is blocked or active. Without a unified pipeline primitive, these workflows spread across ad hoc issues, routines, and comments, making the state hard to inspect or operate. ### Proposed solution Add the pipeline workflow primitive stack: database schema and migrations, shared validators/types, server services and REST routes, aggregation and liveness helpers, CLI/tutorial smoke support, and the React operator UI for pipeline lists, boards, item detail, review/learnings views, settings, stage automation, secrets, carry-over fields, and retry/recovery flows. ### Alternatives considered - Keep workflows as loosely linked issues and routines: rejected because operators need a single board/detail/settings surface for repeated workflow patterns. - Ship backend primitives first and defer UI: rejected for this branch because the operator experience is the main way to validate the primitive. - Add a narrower one-off content workflow: rejected because the same primitives are useful across future company processes. ## What Changed - Added and evolved pipeline schema, migrations, shared contracts, server services, REST routes, route tests, and CLI/tutorial smoke support. - Added pipeline aggregation, health/liveness, drift acknowledgment, blocker/carry-over, automation retry, stage automation environment, and permission recovery behavior. - Added the operator UI for pipeline index/board/item detail/settings/review/learnings flows, including stage secrets, automation controls, markdown/item descriptions, linked issue assets, liveness banners, and source automation metadata. - Refactored issue document frame rendering through the shared `DocumentFrameHeader` component to keep document controls consistent with the pipeline document surfaces. - Kept this PR as the single base-branch review target for the current pipeline branch. ## Verification Current branch refresh: - `pnpm vitest run server/src/__tests__/pipelines-service.test.ts` — 31 passed - `pnpm vitest run server/src/__tests__/pipelines-routes.test.ts` — 19 passed - `pnpm --filter ./server typecheck` — passed - `pnpm --filter ./ui typecheck` — passed - Verified Pipelines remains gated by `enablePipelines === true`: sidebar item is hidden unless the flag is enabled, direct pipeline routes redirect to `/dashboard` when disabled, and the Experimental settings UI still has no Pipelines toggle. - GitHub status checks on `df071c710646de625131064c3fb6588b5e97964a` — all complete with no failing conclusions, including Actions, Socket, Superagent/Security, and Greptile Review - Greptile summary on `df071c710646de625131064c3fb6588b5e97964a` — Confidence Score 5/5 - GitHub review-thread sweep — 0 unresolved Greptile threads Previously recorded during branch development: - Server pipeline service/route and aggregation tests - Shared validator tests - UI pipeline page/settings/item-detail/learnings/liveness tests - Pipeline tutorial smoke path ## Risks - High review surface: this is a large feature branch spanning database, shared contracts, server behavior, CLI/docs, and UI. - Migration ordering and schema compatibility need reviewer attention because this branch has been kept current across multiple `master` syncs. - GitHub still reports merge state `BLOCKED` because the PR is awaiting normal human review/branch-protection completion; all current status checks are green. - Branch-name checklist exception: this PR uses the pre-existing requested branch name, which predates the current public-branch naming rule. The PR title/body avoid internal issue references. > 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 based on GPT-5, with repository tool use, shell execution, git/GitHub CLI operations, and local verification commands. Earlier commits in this branch were assisted by Paperclip agents and other AI coding agents as recorded in commit authorship. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
847 lines
32 KiB
TypeScript
847 lines
32 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { Command } from "commander";
|
|
import pc from "picocolors";
|
|
import { ApiRequestError } from "../client/http.js";
|
|
import {
|
|
addCommonClientOptions,
|
|
apiPath,
|
|
formatInlineRecord,
|
|
printOutput,
|
|
resolveCommandContext,
|
|
type BaseClientOptions,
|
|
type ResolvedClientContext,
|
|
} from "./client/common.js";
|
|
|
|
type JsonObject = Record<string, unknown>;
|
|
|
|
type PipelineStage = {
|
|
id: string;
|
|
key: string;
|
|
name: string;
|
|
kind: string;
|
|
position: number;
|
|
config?: JsonObject;
|
|
};
|
|
|
|
type PipelineSummary = {
|
|
id: string;
|
|
key: string;
|
|
name: string;
|
|
description?: string | null;
|
|
enforceTransitions?: boolean;
|
|
stageCount?: number;
|
|
openCaseCount?: number;
|
|
};
|
|
|
|
type PipelineDetail = PipelineSummary & {
|
|
stages?: PipelineStage[];
|
|
transitions?: Array<{ fromStageId: string; toStageId: string; label?: string | null }>;
|
|
documentKeys?: Array<{ key: string; documentId: string }>;
|
|
};
|
|
|
|
type PipelineCase = {
|
|
id: string;
|
|
caseKey: string;
|
|
title: string;
|
|
summary?: string | null;
|
|
pipelineId: string;
|
|
stageId: string;
|
|
version: number;
|
|
terminalKind?: string | null;
|
|
childCount?: number;
|
|
terminalChildCount?: number;
|
|
pendingSuggestion?: JsonObject | null;
|
|
};
|
|
|
|
type CaseListRow = {
|
|
case: PipelineCase;
|
|
stage: PipelineStage;
|
|
};
|
|
|
|
type CaseDetail = CaseListRow & {
|
|
pipeline: PipelineSummary;
|
|
allowedNextStages?: PipelineStage[];
|
|
blockers?: unknown[];
|
|
blocks?: unknown[];
|
|
links?: unknown[];
|
|
childrenSummary?: JsonObject;
|
|
pendingSuggestion?: JsonObject | null;
|
|
};
|
|
|
|
interface PipelineOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
}
|
|
|
|
interface CreateOptions extends PipelineOptions {
|
|
key: string;
|
|
name: string;
|
|
description?: string;
|
|
projectId?: string;
|
|
enforceTransitions?: boolean;
|
|
stagesJson?: string;
|
|
stagesFile?: string;
|
|
}
|
|
|
|
interface TransitionSetOptions extends PipelineOptions {
|
|
file: string;
|
|
enforce?: boolean;
|
|
}
|
|
|
|
interface GuidancePutOptions extends PipelineOptions {
|
|
file?: string;
|
|
body?: string;
|
|
title?: string;
|
|
}
|
|
|
|
interface AutomationOptions extends PipelineOptions {
|
|
stage: string;
|
|
routine: string;
|
|
note?: string;
|
|
}
|
|
|
|
interface IngestOptions extends PipelineOptions {
|
|
caseKey?: string;
|
|
title: string;
|
|
summary?: string;
|
|
fieldsJson?: string;
|
|
fieldsFile?: string;
|
|
stage?: string;
|
|
parentCase?: string;
|
|
workspaceRefJson?: string;
|
|
blockedBy?: string;
|
|
blockedByKey?: string;
|
|
}
|
|
|
|
interface IngestBatchOptions extends PipelineOptions {
|
|
file: string;
|
|
}
|
|
|
|
interface CasesOptions extends PipelineOptions {
|
|
stage?: string;
|
|
parent?: string;
|
|
terminal?: boolean;
|
|
q?: string;
|
|
}
|
|
|
|
interface EditOptions extends PipelineOptions {
|
|
expectedVersion?: string;
|
|
title?: string;
|
|
summary?: string;
|
|
fieldsJson?: string;
|
|
fieldsFile?: string;
|
|
workspaceRefJson?: string;
|
|
parentCase?: string;
|
|
leaseToken?: string;
|
|
}
|
|
|
|
interface ClaimOptions extends PipelineOptions {
|
|
leaseSeconds?: string;
|
|
}
|
|
|
|
interface ReleaseOptions extends PipelineOptions {
|
|
leaseToken?: string;
|
|
force?: boolean;
|
|
}
|
|
|
|
interface CaseTransitionOptions extends PipelineOptions {
|
|
to: string;
|
|
expectedVersion: string;
|
|
reason?: string;
|
|
leaseToken?: string;
|
|
acceptSuggestion?: string;
|
|
}
|
|
|
|
interface SuggestOptions extends PipelineOptions {
|
|
to: string;
|
|
rationale: string;
|
|
confidence?: string;
|
|
}
|
|
|
|
interface ResolveSuggestionOptions extends PipelineOptions {
|
|
suggestion: string;
|
|
accept?: boolean;
|
|
dismiss?: boolean;
|
|
expectedVersion?: string;
|
|
reason?: string;
|
|
leaseToken?: string;
|
|
}
|
|
|
|
interface ReviewOptions extends PipelineOptions {
|
|
approve?: boolean;
|
|
reject?: boolean;
|
|
requestChanges?: boolean;
|
|
reason?: string;
|
|
expectedVersion: string;
|
|
editsJson?: string;
|
|
editsFile?: string;
|
|
title?: string;
|
|
summary?: string;
|
|
fieldsJson?: string;
|
|
fieldsFile?: string;
|
|
leaseToken?: string;
|
|
}
|
|
|
|
interface BlockOptions extends PipelineOptions {
|
|
by: string;
|
|
}
|
|
|
|
interface ReviewInboxOptions extends PipelineOptions {
|
|
pipeline?: string;
|
|
parent?: string;
|
|
}
|
|
|
|
interface ReviewBulkOptions extends PipelineOptions {
|
|
file: string;
|
|
}
|
|
|
|
export function registerPipelineCommands(program: Command): void {
|
|
const pipelines = program.command("pipelines").description("Pipeline and case operations");
|
|
|
|
addPipelineOptions(
|
|
pipelines
|
|
.command("create")
|
|
.description("Create a pipeline")
|
|
.requiredOption("--key <key>", "Pipeline key")
|
|
.requiredOption("--name <name>", "Pipeline name")
|
|
.option("--description <text>", "Pipeline description")
|
|
.option("--project-id <id>", "Project ID")
|
|
.option("--enforce-transitions", "Only allow configured transitions")
|
|
.option("--stages-json <json>", "Pipeline stage array as JSON")
|
|
.option("--stages-file <path>", "Read pipeline stage array from JSON file")
|
|
.action((opts: CreateOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const body: JsonObject = {
|
|
key: opts.key,
|
|
name: opts.name,
|
|
};
|
|
setIfDefined(body, "description", opts.description);
|
|
setIfDefined(body, "projectId", opts.projectId);
|
|
setIfDefined(body, "enforceTransitions", opts.enforceTransitions);
|
|
const stages = await readJsonFromOptions(opts.stagesJson, opts.stagesFile);
|
|
if (stages !== undefined) body.stages = stages;
|
|
printPipeline(await ctx.api.post<PipelineDetail>(apiPath`/api/companies/${ctx.companyId}/pipelines`, body), ctx);
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
pipelines
|
|
.command("list")
|
|
.description("List pipelines")
|
|
.action((opts: PipelineOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const rows = await ctx.api.get<PipelineSummary[]>(apiPath`/api/companies/${ctx.companyId}/pipelines`) ?? [];
|
|
if (ctx.json) return printOutput(rows, { json: true });
|
|
if (rows.length === 0) return printOutput([]);
|
|
rows.forEach((row) => console.log(formatPipeline(row)));
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
pipelines
|
|
.command("get")
|
|
.description("Get a pipeline by ID or key")
|
|
.argument("<pipeline>", "Pipeline ID or key")
|
|
.action((pipeline: string, opts: PipelineOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
printPipeline(await getPipeline(ctx, pipeline), ctx);
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
pipelines
|
|
.command("set-transitions")
|
|
.description("Replace a pipeline transition edge set")
|
|
.argument("<pipeline>", "Pipeline ID or key")
|
|
.requiredOption("--file <path>", "JSON file with transition array or { transitions }")
|
|
.option("--enforce", "Enable transition enforcement")
|
|
.action((pipeline: string, opts: TransitionSetOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const pipelineId = await resolvePipelineId(ctx, pipeline);
|
|
const input = await readJsonFile(opts.file);
|
|
const body = Array.isArray(input) ? { transitions: input } : asObject(input);
|
|
if (opts.enforce !== undefined) body.enforceTransitions = true;
|
|
printOutput(await ctx.api.put(apiPath`/api/pipelines/${pipelineId}/transitions`, body), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
const guidance = pipelines.command("guidance").description("Pipeline guidance document operations");
|
|
addPipelineOptions(
|
|
guidance
|
|
.command("get")
|
|
.description("Get pipeline guidance")
|
|
.argument("<pipeline>", "Pipeline ID or key")
|
|
.action((pipeline: string, opts: PipelineOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const pipelineId = await resolvePipelineId(ctx, pipeline);
|
|
const result = await ctx.api.get(apiPath`/api/pipelines/${pipelineId}/documents/guidance`);
|
|
printOutput(result, { json: ctx.json });
|
|
})),
|
|
);
|
|
addPipelineOptions(
|
|
guidance
|
|
.command("put")
|
|
.description("Create or replace pipeline guidance")
|
|
.argument("<pipeline>", "Pipeline ID or key")
|
|
.option("--file <path>", "Markdown file")
|
|
.option("--body <markdown>", "Markdown body")
|
|
.option("--title <title>", "Document title")
|
|
.action((pipeline: string, opts: GuidancePutOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const pipelineId = await resolvePipelineId(ctx, pipeline);
|
|
const body = opts.body ?? (opts.file ? await readFile(opts.file, "utf8") : undefined);
|
|
if (body === undefined) throw new Error("Guidance body is required. Pass --file or --body.");
|
|
printOutput(await ctx.api.put(apiPath`/api/pipelines/${pipelineId}/documents/guidance`, {
|
|
title: opts.title ?? "Pipeline guidance",
|
|
body,
|
|
}), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
pipelines
|
|
.command("set-automation")
|
|
.description("Set a run_routine onEnter automation on a stage")
|
|
.argument("<pipeline>", "Pipeline ID or key")
|
|
.requiredOption("--stage <key>", "Stage key")
|
|
.requiredOption("--routine <id>", "Routine ID")
|
|
.option("--note <text>", "Automation note")
|
|
.action((pipeline: string, opts: AutomationOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const detail = await getPipeline(ctx, pipeline);
|
|
const stage = detail.stages?.find((item) => item.key === opts.stage);
|
|
if (!stage) throw new Error(`Stage not found on pipeline ${detail.key}: ${opts.stage}`);
|
|
const config = {
|
|
...(stage.config ?? {}),
|
|
onEnter: {
|
|
...(asOptionalObject(stage.config?.onEnter) ?? {}),
|
|
type: "run_routine",
|
|
routineId: opts.routine,
|
|
...(opts.note ? { note: opts.note } : {}),
|
|
},
|
|
};
|
|
printOutput(await ctx.api.patch(apiPath`/api/pipelines/${detail.id}/stages/${stage.id}`, { config }), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
pipelines
|
|
.command("ingest")
|
|
.description("Ingest one case into a pipeline")
|
|
.argument("<pipeline>", "Pipeline ID or key")
|
|
.option("--case-key <key>", "Case idempotency key")
|
|
.requiredOption("--title <title>", "Case title")
|
|
.option("--summary <text>", "Case summary")
|
|
.option("--fields-json <json>", "Case fields JSON object")
|
|
.option("--fields-file <path>", "Read case fields JSON object from file")
|
|
.option("--stage <key>", "Initial stage key")
|
|
.option("--parent-case <id>", "Parent case ID")
|
|
.option("--workspace-ref-json <json>", "Workspace ref JSON object")
|
|
.option("--blocked-by <csv>", "Comma-separated blocker case IDs")
|
|
.option("--blocked-by-key <csv>", "Comma-separated blocker case keys")
|
|
.action((pipeline: string, opts: IngestOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const pipelineId = await resolvePipelineId(ctx, pipeline);
|
|
const body = await buildIngestBody(opts);
|
|
printOutput(await ctx.api.post(apiPath`/api/pipelines/${pipelineId}/cases`, body), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
pipelines
|
|
.command("ingest-batch")
|
|
.description("Ingest a batch of cases")
|
|
.argument("<pipeline>", "Pipeline ID or key")
|
|
.requiredOption("--file <path>", "JSON file containing an array or { items }")
|
|
.action((pipeline: string, opts: IngestBatchOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const pipelineId = await resolvePipelineId(ctx, pipeline);
|
|
const input = await readJsonFile(opts.file);
|
|
const body = Array.isArray(input) ? { items: input } : asObject(input);
|
|
printOutput(await ctx.api.post(apiPath`/api/pipelines/${pipelineId}/cases/batch`, body), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
pipelines
|
|
.command("cases")
|
|
.description("List cases in a pipeline")
|
|
.argument("<pipeline>", "Pipeline ID or key")
|
|
.option("--stage <key>", "Filter by stage key")
|
|
.option("--parent <caseId>", "Filter by parent case ID")
|
|
.option("--terminal", "Only terminal cases")
|
|
.option("--q <text>", "Search title/summary")
|
|
.action((pipeline: string, opts: CasesOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const pipelineId = await resolvePipelineId(ctx, pipeline);
|
|
const params = new URLSearchParams();
|
|
if (opts.stage) params.set("stageKey", opts.stage);
|
|
if (opts.parent) params.set("parentCaseId", opts.parent);
|
|
if (opts.terminal) params.set("terminal", "true");
|
|
if (opts.q) params.set("q", opts.q);
|
|
const query = params.toString();
|
|
const rows = await ctx.api.get<CaseListRow[]>(`${apiPath`/api/pipelines/${pipelineId}/cases`}${query ? `?${query}` : ""}`) ?? [];
|
|
printCases(rows, ctx);
|
|
})),
|
|
);
|
|
|
|
const caseCommand = pipelines.command("case").description("Pipeline case operations");
|
|
registerCaseCommands(caseCommand);
|
|
|
|
addPipelineOptions(
|
|
pipelines
|
|
.command("review-inbox")
|
|
.description("List cases waiting in review stages")
|
|
.option("--pipeline <idOrKey>", "Filter to one pipeline")
|
|
.option("--parent <caseId>", "Filter by parent case ID")
|
|
.action((opts: ReviewInboxOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const params = new URLSearchParams();
|
|
if (opts.pipeline) params.set("pipelineId", await resolvePipelineId(ctx, opts.pipeline));
|
|
if (opts.parent) params.set("parentCaseId", opts.parent);
|
|
const query = params.toString();
|
|
const rows = await ctx.api.get<CaseListRow[]>(`${apiPath`/api/companies/${ctx.companyId}/review-cases`}${query ? `?${query}` : ""}`) ?? [];
|
|
printCases(rows, ctx);
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
pipelines
|
|
.command("review-bulk")
|
|
.description("Apply bulk review decisions: approve, reject, or request_changes")
|
|
.requiredOption("--file <path>", "JSON file containing an array or { items }")
|
|
.action((opts: ReviewBulkOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const input = await readJsonFile(opts.file);
|
|
const body = Array.isArray(input) ? { items: input } : asObject(input);
|
|
printOutput(await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/review-cases/bulk`, body), { json: ctx.json });
|
|
})),
|
|
);
|
|
}
|
|
|
|
function registerCaseCommands(caseCommand: Command): void {
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("get")
|
|
.description("Get a case")
|
|
.argument("<caseId>", "Case ID")
|
|
.action((caseId: string, opts: PipelineOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
printCaseDetail(await ctx.api.get<CaseDetail>(apiPath`/api/cases/${caseId}`), ctx);
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("events")
|
|
.description("List case events")
|
|
.argument("<caseId>", "Case ID")
|
|
.action((caseId: string, opts: PipelineOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
printOutput(await ctx.api.get(apiPath`/api/cases/${caseId}/events`), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("rollup")
|
|
.description("Get recursive case rollup")
|
|
.argument("<caseId>", "Case ID")
|
|
.action((caseId: string, opts: PipelineOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
printOutput(await ctx.api.get(apiPath`/api/cases/${caseId}/rollup`), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("edit")
|
|
.description("Edit case content")
|
|
.argument("<caseId>", "Case ID")
|
|
.option("--expected-version <n>", "Expected case version")
|
|
.option("--title <title>", "New title")
|
|
.option("--summary <text>", "New summary")
|
|
.option("--fields-json <json>", "Replacement fields JSON object")
|
|
.option("--fields-file <path>", "Read replacement fields from JSON file")
|
|
.option("--workspace-ref-json <json>", "Workspace ref JSON object")
|
|
.option("--parent-case <id>", "Parent case ID")
|
|
.option("--lease-token <token>", "Lease token")
|
|
.action((caseId: string, opts: EditOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const body: JsonObject = {};
|
|
setIfDefined(body, "title", opts.title);
|
|
setIfDefined(body, "summary", opts.summary);
|
|
setIfDefined(body, "parentCaseId", opts.parentCase);
|
|
setIfDefined(body, "leaseToken", opts.leaseToken);
|
|
if (opts.expectedVersion) body.expectedVersion = parsePositiveInt(opts.expectedVersion, "expected version");
|
|
const fields = await readJsonFromOptions(opts.fieldsJson, opts.fieldsFile);
|
|
if (fields !== undefined) body.fields = fields;
|
|
if (opts.workspaceRefJson) body.workspaceRef = parseJson(opts.workspaceRefJson);
|
|
printOutput(await ctx.api.patch(apiPath`/api/cases/${caseId}`, body), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("claim")
|
|
.description("Claim a case lease")
|
|
.argument("<caseId>", "Case ID")
|
|
.option("--lease-seconds <n>", "Lease duration in seconds")
|
|
.action((caseId: string, opts: ClaimOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const body = opts.leaseSeconds ? { leaseSeconds: parsePositiveInt(opts.leaseSeconds, "lease seconds") } : {};
|
|
printOutput(await ctx.api.post(apiPath`/api/cases/${caseId}/claim`, body), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("release")
|
|
.description("Release a case lease")
|
|
.argument("<caseId>", "Case ID")
|
|
.option("--lease-token <token>", "Lease token")
|
|
.option("--force", "Force release as board/user")
|
|
.action((caseId: string, opts: ReleaseOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
printOutput(await ctx.api.post(apiPath`/api/cases/${caseId}/release`, {
|
|
leaseToken: opts.leaseToken,
|
|
force: opts.force,
|
|
}), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("transition")
|
|
.description("Transition a case to another stage")
|
|
.argument("<caseId>", "Case ID")
|
|
.requiredOption("--to <stageKey>", "Target stage key")
|
|
.requiredOption("--expected-version <n>", "Expected case version")
|
|
.option("--reason <text>", "Transition reason")
|
|
.option("--lease-token <token>", "Lease token")
|
|
.option("--accept-suggestion <id>", "Accepted suggestion ID")
|
|
.action((caseId: string, opts: CaseTransitionOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
printOutput(await ctx.api.post(apiPath`/api/cases/${caseId}/transition`, {
|
|
toStageKey: opts.to,
|
|
expectedVersion: parsePositiveInt(opts.expectedVersion, "expected version"),
|
|
reason: opts.reason,
|
|
leaseToken: opts.leaseToken,
|
|
acceptSuggestionId: opts.acceptSuggestion,
|
|
}), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("suggest")
|
|
.description("Suggest a transition without moving the case")
|
|
.argument("<caseId>", "Case ID")
|
|
.requiredOption("--to <stageKey>", "Target stage key")
|
|
.requiredOption("--rationale <text>", "Suggestion rationale")
|
|
.option("--confidence <n>", "Confidence 0..1")
|
|
.action((caseId: string, opts: SuggestOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const body: JsonObject = {
|
|
toStageKey: opts.to,
|
|
rationale: opts.rationale,
|
|
};
|
|
if (opts.confidence) body.confidence = Number(opts.confidence);
|
|
printOutput(await ctx.api.post(apiPath`/api/cases/${caseId}/suggest-transition`, body), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("resolve-suggestion")
|
|
.description("Accept or dismiss a pending transition suggestion")
|
|
.argument("<caseId>", "Case ID")
|
|
.requiredOption("--suggestion <id>", "Suggestion ID")
|
|
.option("--accept", "Accept the suggestion")
|
|
.option("--dismiss", "Dismiss the suggestion")
|
|
.option("--expected-version <n>", "Expected case version")
|
|
.option("--reason <text>", "Decision reason")
|
|
.option("--lease-token <token>", "Lease token")
|
|
.action((caseId: string, opts: ResolveSuggestionOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const decision = exactlyOneFlag(opts.accept, opts.dismiss, "--accept", "--dismiss") === "--accept" ? "accept" : "dismiss";
|
|
const body: JsonObject = {
|
|
suggestionId: opts.suggestion,
|
|
resolution: decision,
|
|
reason: opts.reason,
|
|
leaseToken: opts.leaseToken,
|
|
};
|
|
if (opts.expectedVersion) body.expectedVersion = parsePositiveInt(opts.expectedVersion, "expected version");
|
|
printOutput(await ctx.api.post(apiPath`/api/cases/${caseId}/resolve-suggestion`, body), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("review")
|
|
.description("Approve, reject, or request changes for a case in a review stage")
|
|
.argument("<caseId>", "Case ID")
|
|
.option("--approve", "Approve the case")
|
|
.option("--reject", "Reject the case")
|
|
.option("--request-changes", "Request changes for the case")
|
|
.option("--reason <text>", "Decision reason")
|
|
.requiredOption("--expected-version <n>", "Expected case version")
|
|
.option("--edits-json <json>", "Review edits JSON")
|
|
.option("--edits-file <path>", "Read review edits JSON from file")
|
|
.option("--title <title>", "Edit title before decision")
|
|
.option("--summary <text>", "Edit summary before decision")
|
|
.option("--fields-json <json>", "Edit fields before decision")
|
|
.option("--fields-file <path>", "Read edit fields from JSON file")
|
|
.option("--lease-token <token>", "Lease token")
|
|
.action((caseId: string, opts: ReviewOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
const decision = reviewDecisionFromOptions(opts);
|
|
const edits = await buildReviewEdits(opts);
|
|
printOutput(await ctx.api.post(apiPath`/api/cases/${caseId}/review`, {
|
|
decision,
|
|
reason: opts.reason,
|
|
edits,
|
|
expectedVersion: parsePositiveInt(opts.expectedVersion, "expected version"),
|
|
leaseToken: opts.leaseToken,
|
|
}), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("block")
|
|
.description("Replace a case blocker set")
|
|
.argument("<caseId>", "Case ID")
|
|
.requiredOption("--by <csv>", "Comma-separated blocker case IDs, or empty string to clear")
|
|
.action((caseId: string, opts: BlockOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
printOutput(await ctx.api.put(apiPath`/api/cases/${caseId}/blockers`, {
|
|
blockedByCaseIds: parseCsv(opts.by),
|
|
}), { json: ctx.json });
|
|
})),
|
|
);
|
|
|
|
addPipelineOptions(
|
|
caseCommand
|
|
.command("open-conversation")
|
|
.description("Open or return the case conversation issue")
|
|
.argument("<caseId>", "Case ID")
|
|
.action((caseId: string, opts: PipelineOptions) => withPipelineErrors(async () => {
|
|
const ctx = resolvePipelineContext(opts);
|
|
printOutput(await ctx.api.post(apiPath`/api/cases/${caseId}/open-conversation`, {}), { json: ctx.json });
|
|
})),
|
|
);
|
|
}
|
|
|
|
function addPipelineOptions(command: Command): Command {
|
|
return addCommonClientOptions(command, { includeCompany: true });
|
|
}
|
|
|
|
function resolvePipelineContext(opts: PipelineOptions): ResolvedClientContext & { companyId: string } {
|
|
return resolveCommandContext(opts, { requireCompany: true }) as ResolvedClientContext & { companyId: string };
|
|
}
|
|
|
|
async function resolvePipelineId(ctx: ResolvedClientContext & { companyId: string }, pipeline: string): Promise<string> {
|
|
if (looksLikeUuid(pipeline)) return pipeline;
|
|
const rows = await ctx.api.get<PipelineSummary[]>(apiPath`/api/companies/${ctx.companyId}/pipelines`) ?? [];
|
|
const match = rows.find((row) => row.key === pipeline || row.id === pipeline);
|
|
if (!match) throw new Error(`Pipeline not found by key or id: ${pipeline}`);
|
|
return match.id;
|
|
}
|
|
|
|
async function getPipeline(ctx: ResolvedClientContext & { companyId: string }, pipeline: string): Promise<PipelineDetail> {
|
|
const pipelineId = await resolvePipelineId(ctx, pipeline);
|
|
const detail = await ctx.api.get<PipelineDetail>(apiPath`/api/pipelines/${pipelineId}`);
|
|
if (!detail) throw new Error(`Pipeline not found: ${pipeline}`);
|
|
return detail;
|
|
}
|
|
|
|
async function buildIngestBody(opts: IngestOptions): Promise<JsonObject> {
|
|
const body: JsonObject = { title: opts.title };
|
|
setIfDefined(body, "caseKey", opts.caseKey);
|
|
setIfDefined(body, "summary", opts.summary);
|
|
setIfDefined(body, "stageKey", opts.stage);
|
|
setIfDefined(body, "parentCaseId", opts.parentCase);
|
|
if (opts.fieldsJson || opts.fieldsFile) body.fields = await readJsonFromOptions(opts.fieldsJson, opts.fieldsFile);
|
|
if (opts.workspaceRefJson) body.workspaceRef = parseJson(opts.workspaceRefJson);
|
|
if (opts.blockedBy) body.blockedByCaseIds = parseCsv(opts.blockedBy);
|
|
if (opts.blockedByKey) body.blockedByCaseKeys = parseCsv(opts.blockedByKey);
|
|
return body;
|
|
}
|
|
|
|
async function buildReviewEdits(opts: ReviewOptions): Promise<JsonObject | undefined> {
|
|
const fromFile = await readJsonFromOptions(opts.editsJson, opts.editsFile);
|
|
const edits = fromFile === undefined ? {} : asObject(fromFile);
|
|
setIfDefined(edits, "title", opts.title);
|
|
setIfDefined(edits, "summary", opts.summary);
|
|
const fields = await readJsonFromOptions(opts.fieldsJson, opts.fieldsFile);
|
|
if (fields !== undefined) edits.fields = fields;
|
|
return Object.keys(edits).length ? edits : undefined;
|
|
}
|
|
|
|
async function readJsonFromOptions(json?: string, file?: string): Promise<unknown | undefined> {
|
|
if (json && file) throw new Error("Pass either inline JSON or a JSON file, not both.");
|
|
if (json) return parseJson(json);
|
|
if (file) return readJsonFile(file);
|
|
return undefined;
|
|
}
|
|
|
|
async function readJsonFile(file: string): Promise<unknown> {
|
|
return parseJson(await readFile(file, "utf8"));
|
|
}
|
|
|
|
function parseJson(value: string): unknown {
|
|
try {
|
|
return JSON.parse(value) as unknown;
|
|
} catch (error) {
|
|
throw new Error(`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
function asObject(value: unknown): JsonObject {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error("Expected a JSON object.");
|
|
}
|
|
return value as JsonObject;
|
|
}
|
|
|
|
function asOptionalObject(value: unknown): JsonObject | undefined {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined;
|
|
}
|
|
|
|
function parsePositiveInt(value: string, label: string): number {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`Invalid ${label}: ${value}`);
|
|
return parsed;
|
|
}
|
|
|
|
function parseCsv(value: string): string[] {
|
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
|
|
function setIfDefined(target: JsonObject, key: string, value: unknown): void {
|
|
if (value !== undefined) target[key] = value;
|
|
}
|
|
|
|
function looksLikeUuid(value: string): boolean {
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
}
|
|
|
|
function exactlyOneFlag(first: boolean | undefined, second: boolean | undefined, firstName: string, secondName: string): string {
|
|
if (Boolean(first) === Boolean(second)) throw new Error(`Pass exactly one of ${firstName} or ${secondName}.`);
|
|
return first ? firstName : secondName;
|
|
}
|
|
|
|
function reviewDecisionFromOptions(opts: ReviewOptions): "approve" | "reject" | "request_changes" {
|
|
const selected = [
|
|
opts.approve ? { flag: "--approve", decision: "approve" as const } : null,
|
|
opts.reject ? { flag: "--reject", decision: "reject" as const } : null,
|
|
opts.requestChanges ? { flag: "--request-changes", decision: "request_changes" as const } : null,
|
|
].filter((item): item is NonNullable<typeof item> => item !== null);
|
|
if (selected.length !== 1) {
|
|
throw new Error("Pass exactly one of --approve, --reject, or --request-changes.");
|
|
}
|
|
return selected[0]!.decision;
|
|
}
|
|
|
|
function printPipeline(row: PipelineDetail | PipelineSummary | null, ctx: ResolvedClientContext): void {
|
|
if (!row) return printOutput(null, { json: ctx.json });
|
|
if (ctx.json) return printOutput(row, { json: true });
|
|
console.log(formatPipeline(row));
|
|
if ("stages" in row && row.stages?.length) {
|
|
console.log(pc.bold("Stages"));
|
|
row.stages.forEach((stage) => {
|
|
console.log(` ${formatInlineRecord({
|
|
id: stage.id,
|
|
key: stage.key,
|
|
name: stage.name,
|
|
kind: stage.kind,
|
|
position: stage.position,
|
|
})}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
function formatPipeline(row: PipelineSummary): string {
|
|
return formatInlineRecord({
|
|
id: row.id,
|
|
key: row.key,
|
|
name: row.name,
|
|
enforceTransitions: row.enforceTransitions,
|
|
stageCount: row.stageCount,
|
|
openCaseCount: row.openCaseCount,
|
|
});
|
|
}
|
|
|
|
function printCases(rows: CaseListRow[], ctx: ResolvedClientContext): void {
|
|
if (ctx.json) return printOutput(rows, { json: true });
|
|
if (rows.length === 0) return printOutput([]);
|
|
rows.forEach((row) => console.log(formatCase(row.case, row.stage)));
|
|
}
|
|
|
|
function printCaseDetail(detail: CaseDetail | null, ctx: ResolvedClientContext): void {
|
|
if (!detail) return printOutput(null, { json: ctx.json });
|
|
if (ctx.json) return printOutput(detail, { json: true });
|
|
console.log(formatCase(detail.case, detail.stage, detail.pipeline));
|
|
console.log(JSON.stringify({
|
|
pendingSuggestion: detail.pendingSuggestion ?? detail.case.pendingSuggestion ?? null,
|
|
childrenSummary: detail.childrenSummary,
|
|
blockers: detail.blockers,
|
|
blocks: detail.blocks,
|
|
links: detail.links,
|
|
}, null, 2));
|
|
}
|
|
|
|
function formatCase(row: PipelineCase, stage: PipelineStage, pipeline?: PipelineSummary): string {
|
|
return formatInlineRecord({
|
|
id: row.id,
|
|
caseKey: row.caseKey,
|
|
title: row.title,
|
|
pipeline: pipeline?.key,
|
|
stage: stage.key,
|
|
stageKind: stage.kind,
|
|
version: row.version,
|
|
terminalKind: row.terminalKind,
|
|
children: row.childCount === undefined ? undefined : `${row.terminalChildCount ?? 0}/${row.childCount}`,
|
|
});
|
|
}
|
|
|
|
async function withPipelineErrors(fn: () => Promise<void>): Promise<void> {
|
|
try {
|
|
await fn();
|
|
} catch (error) {
|
|
handlePipelineError(error);
|
|
}
|
|
}
|
|
|
|
function handlePipelineError(error: unknown): never {
|
|
if (error instanceof ApiRequestError) {
|
|
const body = asOptionalObject(error.body);
|
|
const details = asOptionalObject(error.details);
|
|
const code = stringValue(details?.code) ?? stringValue(body?.code);
|
|
const stage = details?.stage ?? details?.currentStage ?? details?.currentStageKey ?? details?.stageKey;
|
|
const version = details?.version ?? details?.currentVersion;
|
|
const parts = [`API error ${error.status}: ${error.message}`];
|
|
if (code) parts.push(`code=${code}`);
|
|
if (version !== undefined) parts.push(`currentVersion=${String(version)}`);
|
|
if (stage !== undefined) parts.push(`currentStage=${formatStageForError(stage)}`);
|
|
console.error(pc.red(parts.join(" ")));
|
|
if (error.status === 409) {
|
|
console.error(pc.yellow("Recovery: re-read the case with `paperclipai pipelines case get <case-id> --json`, then retry with the current version/stage."));
|
|
}
|
|
if (error.details !== undefined && !code) console.error(pc.dim(`details=${JSON.stringify(error.details)}`));
|
|
process.exit(1);
|
|
}
|
|
console.error(pc.red(error instanceof Error ? error.message : String(error)));
|
|
process.exit(1);
|
|
}
|
|
|
|
function stringValue(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
}
|
|
|
|
function formatStageForError(stage: unknown): string {
|
|
if (typeof stage === "string") return stage;
|
|
if (stage && typeof stage === "object" && "key" in stage) return String((stage as { key: unknown }).key);
|
|
return JSON.stringify(stage);
|
|
}
|