mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Add pipeline workflow primitives and operator UI (#7903)
## 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>
This commit is contained in:
1 parent
c79d347abe
commit
43b005b704
94 files changed
+51184
-251
No files matched your search
@@ -0,0 +1,126 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerPipelineCommands } from "../commands/pipelines.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const CASE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
registerPipelineCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]): Promise<void> {
|
||||
await createProgram().parseAsync([
|
||||
...args,
|
||||
"--api-base",
|
||||
"http://localhost:3100",
|
||||
"--api-key",
|
||||
"board-token",
|
||||
"--company-id",
|
||||
COMPANY_ID,
|
||||
], { from: "user" });
|
||||
}
|
||||
|
||||
describe("pipeline CLI commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("sends request-changes review decisions", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ case: { id: CASE_ID } }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run([
|
||||
"pipelines",
|
||||
"case",
|
||||
"review",
|
||||
CASE_ID,
|
||||
"--request-changes",
|
||||
"--reason",
|
||||
"Needs edits",
|
||||
"--expected-version",
|
||||
"2",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/cases/${CASE_ID}/review`);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
|
||||
decision: "request_changes",
|
||||
reason: "Needs edits",
|
||||
expectedVersion: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes request_changes rows through review-bulk", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paperclip-pipeline-cli-"));
|
||||
const file = join(dir, "review-bulk.json");
|
||||
await writeFile(file, JSON.stringify([
|
||||
{ caseId: CASE_ID, decision: "request_changes", reason: "Needs edits", expectedVersion: 2 },
|
||||
]));
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ results: [] }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
await run(["pipelines", "review-bulk", "--file", file]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/review-cases/bulk`);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
|
||||
items: [{ caseId: CASE_ID, decision: "request_changes", reason: "Needs edits", expectedVersion: 2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes blockedByCaseKeys rows through ingest-batch", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paperclip-pipeline-cli-"));
|
||||
const file = join(dir, "ingest-batch.json");
|
||||
await writeFile(file, JSON.stringify([
|
||||
{ caseKey: "tweet", title: "Tweet", blockedByCaseKeys: ["image", "post"] },
|
||||
{ caseKey: "image", title: "Image" },
|
||||
{ caseKey: "post", title: "Post" },
|
||||
]));
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "33333333-3333-4333-8333-333333333333", key: "content", name: "Content" }]))
|
||||
.mockResolvedValueOnce(jsonResponse([]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
await run(["pipelines", "ingest-batch", "content", "--file", file]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3100/api/pipelines/33333333-3333-4333-8333-333333333333/cases/batch");
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
|
||||
items: [
|
||||
{ caseKey: "tweet", title: "Tweet", blockedByCaseKeys: ["image", "post"] },
|
||||
{ caseKey: "image", title: "Image" },
|
||||
{ caseKey: "post", title: "Post" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
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);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { registerRoutineCommands } from "./commands/routines.js";
|
||||
import { registerPipelineCommands } from "./commands/pipelines.js";
|
||||
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
||||
import { registerSecretCommands } from "./commands/client/secrets.js";
|
||||
import { registerCloudCommands } from "./commands/client/cloud.js";
|
||||
@@ -177,6 +178,7 @@ registerAdapterCommands(program);
|
||||
registerAssetCommands(program);
|
||||
registerSkillCommands(program);
|
||||
registerRoutineCommands(program);
|
||||
registerPipelineCommands(program);
|
||||
registerFeedbackCommands(program);
|
||||
registerSecretCommands(program);
|
||||
registerCloudCommands(program);
|
||||
|
||||
Reference in new issue
Block a user