Add pipeline CLI and tutorial smoke

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 23:28:48 +00:00
1 parent 89fac47646
commit f570e14d02
5 files changed
+1428

No files matched your search

+829
View File
@@ -0,0 +1,829 @@
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;
}
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;
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")
.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")
.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 or reject a case in a review stage")
.argument("<caseId>", "Case ID")
.option("--approve", "Approve the case")
.option("--reject", "Reject 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 = exactlyOneFlag(opts.approve, opts.reject, "--approve", "--reject") === "--approve" ? "approve" : "reject";
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);
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 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);
}
+2
View File
@@ -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);
+368
View File
@@ -0,0 +1,368 @@
# Pipelines Tutorial: Release to Content
This walkthrough uses only the Paperclip CLI to run the release-to-content casework flow:
- one release case fans out into 10 content suggestion cases
- a review inbox approves 7, rejects 2 with reasons, and edits then approves 1
- approved cases fire a drafting routine on stage entry
- a human accepts an agent transition suggestion
- a blocker prevents premature publishing until the upstream case is done
- the release rollup reports a complete done/cancelled split
- event history shows provenance for the case
## Prerequisites
Run this against a dev Paperclip instance with a board token or an agent token that can manage pipelines and routines.
```sh
export PAPERCLIP_API_URL=http://localhost:3100
export PAPERCLIP_COMPANY_ID=<company-id>
export PAPERCLIP_API_KEY=<token>
# Optional: assign the drafting routine to an existing agent.
export DRAFTING_AGENT_ID=<agent-id>
```
Use a unique suffix so the tutorial can be rerun without pipeline key collisions:
```sh
export RUN_KEY="$(date +%Y%m%d%H%M%S)"
export RELEASES_PIPELINE="releases-$RUN_KEY"
export CONTENT_PIPELINE="content-$RUN_KEY"
```
## Create The Drafting Routine
```sh
cat > /tmp/drafting-routine.json <<JSON
{
"title": "Draft approved content",
"description": "Use the embedded Pipeline Case Context to draft the approved content case, then transition the case when the draft is ready.",
"priority": "medium",
"status": "active",
"concurrencyPolicy": "always_enqueue",
"catchUpPolicy": "skip_missed"
${DRAFTING_AGENT_ID:+, "assigneeAgentId": "$DRAFTING_AGENT_ID"}
}
JSON
export DRAFTING_ROUTINE_PAYLOAD="$(jq -c . /tmp/drafting-routine.json)"
export DRAFTING_ROUTINE_ID="$(
paperclipai routine create \
-C "$PAPERCLIP_COMPANY_ID" \
--payload-json "$DRAFTING_ROUTINE_PAYLOAD" \
--json | jq -r '.id'
)"
```
## Create Pipelines
```sh
cat > /tmp/releases-stages.json <<'JSON'
[
{ "key": "released", "name": "Released", "kind": "open", "position": 100 },
{ "key": "complete", "name": "Complete", "kind": "done", "position": 900 },
{ "key": "cancelled", "name": "Cancelled", "kind": "cancelled", "position": 1000 }
]
JSON
paperclipai pipelines create \
-C "$PAPERCLIP_COMPANY_ID" \
--key "$RELEASES_PIPELINE" \
--name "Releases $RUN_KEY" \
--stages-file /tmp/releases-stages.json
```
```sh
cat > /tmp/content-stages.json <<'JSON'
[
{
"key": "review",
"name": "Human review",
"kind": "review",
"position": 100,
"config": {
"approveToStageKey": "drafting",
"rejectToStageKey": "cancelled",
"requireRejectReason": true,
"reviewerKind": "human"
}
},
{ "key": "drafting", "name": "Drafting", "kind": "working", "position": 200 },
{ "key": "ready", "name": "Ready to publish", "kind": "working", "position": 300 },
{ "key": "published", "name": "Published", "kind": "done", "position": 900 },
{ "key": "cancelled", "name": "Cancelled", "kind": "cancelled", "position": 1000 }
]
JSON
paperclipai pipelines create \
-C "$PAPERCLIP_COMPANY_ID" \
--key "$CONTENT_PIPELINE" \
--name "Content $RUN_KEY" \
--stages-file /tmp/content-stages.json
```
Configure transitions, guidance, and the drafting automation:
```sh
cat > /tmp/content-transitions.json <<'JSON'
{
"transitions": [
{ "fromStageKey": "review", "toStageKey": "drafting", "label": "approve" },
{ "fromStageKey": "review", "toStageKey": "cancelled", "label": "reject" },
{ "fromStageKey": "drafting", "toStageKey": "ready", "label": "draft ready" },
{ "fromStageKey": "drafting", "toStageKey": "published", "label": "publish directly" },
{ "fromStageKey": "drafting", "toStageKey": "cancelled", "label": "cancel" },
{ "fromStageKey": "ready", "toStageKey": "published", "label": "publish" }
]
}
JSON
paperclipai pipelines set-transitions \
-C "$PAPERCLIP_COMPANY_ID" \
"$CONTENT_PIPELINE" \
--file /tmp/content-transitions.json
cat > /tmp/content-guidance.md <<'MD'
# Content pipeline guidance
Approve content suggestions that can teach users what changed and reject suggestions
that are too narrow, duplicative, or disconnected from the release.
MD
paperclipai pipelines guidance put \
-C "$PAPERCLIP_COMPANY_ID" \
"$CONTENT_PIPELINE" \
--file /tmp/content-guidance.md
paperclipai pipelines set-automation \
-C "$PAPERCLIP_COMPANY_ID" \
"$CONTENT_PIPELINE" \
--stage drafting \
--routine "$DRAFTING_ROUTINE_ID"
```
## Ingest The Release And Suggestions
```sh
export RELEASE_CASE_ID="$(
paperclipai pipelines ingest \
-C "$PAPERCLIP_COMPANY_ID" \
"$RELEASES_PIPELINE" \
--case-key "release-$RUN_KEY" \
--title "Release $RUN_KEY: Pipeline primitive" \
--summary "A release case that fans out into content suggestions." \
--fields-json '{"release":"v0.pipeline-tutorial"}' \
--json | jq -r '.case.id'
)"
```
Create the 10 parented suggestion cases. This batch file is included verbatim; only `$RELEASE_CASE_ID` is substituted by the shell.
```sh
jq -n --arg parent "$RELEASE_CASE_ID" '{
items: [
{ caseKey: "suggestion-01", title: "Launch blog post", summary: "Explain the release end to end.", fields: { channel: "blog" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-02", title: "Product changelog", summary: "Concise changelog entry.", fields: { channel: "docs" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-03", title: "Launch tweet", summary: "Short tweet after the blog is ready.", fields: { channel: "x" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-04", title: "Customer email", summary: "Email announcement.", fields: { channel: "email" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-05", title: "LinkedIn post", summary: "Professional network announcement.", fields: { channel: "linkedin" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-06", title: "Discord update", summary: "Community update.", fields: { channel: "discord" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-07", title: "Release notes", summary: "Detailed release notes.", fields: { channel: "release_notes" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-08", title: "Overly niche post", summary: "Reject: too niche.", fields: { channel: "blog" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-09", title: "Duplicate tweet", summary: "Reject: duplicate.", fields: { channel: "x" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-10", title: "Short-form video", summary: "Needs edit before approval.", fields: { channel: "video" }, parentCaseId: $parent, stageKey: "review" }
]
}' > /tmp/content-suggestions.json
paperclipai pipelines ingest-batch \
-C "$PAPERCLIP_COMPANY_ID" \
"$CONTENT_PIPELINE" \
--file /tmp/content-suggestions.json \
--json | tee /tmp/content-suggestions-result.json
```
## Review The Inbox
```sh
paperclipai pipelines review-inbox \
-C "$PAPERCLIP_COMPANY_ID" \
--pipeline "$CONTENT_PIPELINE"
```
Approve seven and reject two in bulk:
```sh
case_id() {
jq -r --arg key "$1" '.[] | select(.case.caseKey == $key) | .case.id' /tmp/content-suggestions-result.json
}
export CASE_01="$(case_id suggestion-01)"
export CASE_02="$(case_id suggestion-02)"
export CASE_03="$(case_id suggestion-03)"
export CASE_04="$(case_id suggestion-04)"
export CASE_05="$(case_id suggestion-05)"
export CASE_06="$(case_id suggestion-06)"
export CASE_07="$(case_id suggestion-07)"
export CASE_08="$(case_id suggestion-08)"
export CASE_09="$(case_id suggestion-09)"
export CASE_10="$(case_id suggestion-10)"
jq -n \
--arg c1 "$CASE_01" --arg c2 "$CASE_02" --arg c3 "$CASE_03" --arg c4 "$CASE_04" --arg c5 "$CASE_05" \
--arg c6 "$CASE_06" --arg c7 "$CASE_07" --arg c8 "$CASE_08" --arg c9 "$CASE_09" '{
items: [
{ caseId: $c1, decision: "approve", expectedVersion: 1 },
{ caseId: $c2, decision: "approve", expectedVersion: 1 },
{ caseId: $c3, decision: "approve", expectedVersion: 1 },
{ caseId: $c4, decision: "approve", expectedVersion: 1 },
{ caseId: $c5, decision: "approve", expectedVersion: 1 },
{ caseId: $c6, decision: "approve", expectedVersion: 1 },
{ caseId: $c7, decision: "approve", expectedVersion: 1 },
{ caseId: $c8, decision: "reject", reason: "Too niche for this launch.", expectedVersion: 1 },
{ caseId: $c9, decision: "reject", reason: "Duplicates the launch tweet.", expectedVersion: 1 }
]
}' > /tmp/review-decisions.json
paperclipai pipelines review-bulk \
-C "$PAPERCLIP_COMPANY_ID" \
--file /tmp/review-decisions.json
```
Edit and approve the tenth item:
```sh
paperclipai pipelines case review \
-C "$PAPERCLIP_COMPANY_ID" \
"$CASE_10" \
--approve \
--expected-version 1 \
--title "Edited short-form video" \
--fields-json '{"channel":"video","edited":true}'
```
Approved cases enter `drafting`, which fires the configured routine and links automation issues. Inspect one:
```sh
paperclipai pipelines case get \
-C "$PAPERCLIP_COMPANY_ID" \
"$CASE_01"
```
## Accept An Agent Suggestion
```sh
export SUGGESTION_ID="$(
paperclipai pipelines case suggest \
-C "$PAPERCLIP_COMPANY_ID" \
"$CASE_01" \
--to ready \
--rationale "Draft is complete enough to publish." \
--confidence 0.9 \
--json | jq -r '.suggestion.id'
)"
paperclipai pipelines case resolve-suggestion \
-C "$PAPERCLIP_COMPANY_ID" \
"$CASE_01" \
--suggestion "$SUGGESTION_ID" \
--accept \
--expected-version 2
paperclipai pipelines case transition \
-C "$PAPERCLIP_COMPANY_ID" \
"$CASE_01" \
--to published \
--expected-version 3 \
--reason "Suggestion accepted and content published."
```
## Demonstrate Blockers And The 409 Guard
The tweet waits for the blog post:
```sh
paperclipai pipelines case block \
-C "$PAPERCLIP_COMPANY_ID" \
"$CASE_03" \
--by "$CASE_02"
# This fails with code=blocked and prints a recovery hint.
paperclipai pipelines case transition \
-C "$PAPERCLIP_COMPANY_ID" \
"$CASE_03" \
--to published \
--expected-version 2 \
--reason "Try before blog is published."
```
Resolve the blocker and publish the tweet:
```sh
paperclipai pipelines case transition \
-C "$PAPERCLIP_COMPANY_ID" \
"$CASE_02" \
--to published \
--expected-version 2 \
--reason "Blog published; unblocks tweet."
paperclipai pipelines case transition \
-C "$PAPERCLIP_COMPANY_ID" \
"$CASE_03" \
--to published \
--expected-version 2 \
--reason "Blocker resolved."
```
## Complete The Rollup
Cancel one approved case, publish the rest, then inspect the release rollup:
```sh
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CASE_04" --to cancelled --expected-version 2 --reason "Cancelled after review."
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CASE_05" --to published --expected-version 2 --reason "Published."
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CASE_06" --to published --expected-version 2 --reason "Published."
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CASE_07" --to published --expected-version 2 --reason "Published."
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CASE_10" --to published --expected-version 3 --reason "Edited and published."
paperclipai pipelines case rollup \
-C "$PAPERCLIP_COMPANY_ID" \
"$RELEASE_CASE_ID" \
--json
```
Expected rollup:
```json
{
"total": 10,
"done": 7,
"cancelled": 3,
"open": 0,
"complete": true
}
```
## Dump Provenance
```sh
paperclipai pipelines case events \
-C "$PAPERCLIP_COMPANY_ID" \
"$CASE_01" \
--json
```
Every event includes an actor field. Human actions record a user actor, agent actions require a run id, and system actions record `system`. For this case, look for `ingested`, `review_decided`, `transition_suggested`, `suggestion_resolved`, and `transitioned`.
## Scripted Smoke
The same flow is available as a smoke script:
```sh
PAPERCLIP_API_URL=http://localhost:3100 \
PAPERCLIP_COMPANY_ID=<company-id> \
PAPERCLIP_API_KEY=<token> \
pnpm smoke:pipelines-tutorial
```
The smoke assigns the drafting routine to `DRAFTING_AGENT_ID` when set. If it is not set, it uses `PAPERCLIP_AGENT_ID` when present, otherwise the first non-terminated company agent returned by the CLI, so the routine automation can create linked drafting issues.
+1
View File
@@ -41,6 +41,7 @@
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required for the pipeline tutorial smoke." >&2
exit 1
fi
: "${PAPERCLIP_COMPANY_ID:?Set PAPERCLIP_COMPANY_ID for the target dev company.}"
read -r -a PC_CMD <<< "${PAPERCLIPAI_CMD:-pnpm --silent paperclipai}"
RUN_KEY="${PIPELINE_SMOKE_KEY:-$(date +%Y%m%d%H%M%S)}"
RELEASES_KEY="releases-${RUN_KEY}"
CONTENT_KEY="content-${RUN_KEY}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
pc_json() {
"${PC_CMD[@]}" "$@" --json -C "$PAPERCLIP_COMPANY_ID"
}
pick_agent_id() {
if [[ -n "${DRAFTING_AGENT_ID:-}" ]]; then
echo "$DRAFTING_AGENT_ID"
return
fi
if [[ -n "${PAPERCLIP_AGENT_ID:-}" ]]; then
echo "$PAPERCLIP_AGENT_ID"
return
fi
pc_json agent list 2>/dev/null | jq -r 'map(select(.status != "terminated"))[0].id // empty'
}
require_json() {
local json="$1"
local filter="$2"
local message="$3"
if ! jq -e "$filter" >/dev/null <<<"$json"; then
echo "$message" >&2
echo "$json" | jq . >&2
exit 1
fi
}
cat >"$TMP_DIR/releases-stages.json" <<'JSON'
[
{ "key": "released", "name": "Released", "kind": "open", "position": 100 },
{ "key": "complete", "name": "Complete", "kind": "done", "position": 900 },
{ "key": "cancelled", "name": "Cancelled", "kind": "cancelled", "position": 1000 }
]
JSON
cat >"$TMP_DIR/content-stages.json" <<'JSON'
[
{
"key": "review",
"name": "Human review",
"kind": "review",
"position": 100,
"config": {
"approveToStageKey": "drafting",
"rejectToStageKey": "cancelled",
"requireRejectReason": true,
"reviewerKind": "human"
}
},
{ "key": "drafting", "name": "Drafting", "kind": "working", "position": 200 },
{ "key": "ready", "name": "Ready to publish", "kind": "working", "position": 300 },
{ "key": "published", "name": "Published", "kind": "done", "position": 900 },
{ "key": "cancelled", "name": "Cancelled", "kind": "cancelled", "position": 1000 }
]
JSON
cat >"$TMP_DIR/content-transitions.json" <<'JSON'
{
"transitions": [
{ "fromStageKey": "review", "toStageKey": "drafting", "label": "approve" },
{ "fromStageKey": "review", "toStageKey": "cancelled", "label": "reject" },
{ "fromStageKey": "drafting", "toStageKey": "ready", "label": "draft ready" },
{ "fromStageKey": "drafting", "toStageKey": "published", "label": "publish directly" },
{ "fromStageKey": "drafting", "toStageKey": "cancelled", "label": "cancel" },
{ "fromStageKey": "ready", "toStageKey": "published", "label": "publish" }
]
}
JSON
cat >"$TMP_DIR/guidance.md" <<'MD'
# Content pipeline guidance
Approve content suggestions that can teach users what changed and reject suggestions
that are too narrow, duplicative, or disconnected from the release.
MD
agent_id="$(pick_agent_id)"
routine_payload="$(jq -cn --arg agentId "$agent_id" '{
title: "Pipeline smoke drafting routine",
description: "Draft the approved content case using the embedded Pipeline Case Context, then transition the case when the draft is ready.",
priority: "medium",
status: "active",
concurrencyPolicy: "always_enqueue",
catchUpPolicy: "skip_missed"
} + (if $agentId != "" then { assigneeAgentId: $agentId } else {} end)')"
routine="$(pc_json routine create --payload-json "$routine_payload")"
routine_id="$(jq -r '.id' <<<"$routine")"
releases="$(pc_json pipelines create --key "$RELEASES_KEY" --name "Smoke Releases $RUN_KEY" --stages-file "$TMP_DIR/releases-stages.json")"
content="$(pc_json pipelines create --key "$CONTENT_KEY" --name "Smoke Content $RUN_KEY" --stages-file "$TMP_DIR/content-stages.json")"
require_json "$releases" '.id and (.stages | length == 3)' "Releases pipeline creation failed."
require_json "$content" '.id and (.stages | length == 5)' "Content pipeline creation failed."
pc_json pipelines set-transitions "$CONTENT_KEY" --file "$TMP_DIR/content-transitions.json" >/dev/null
pc_json pipelines guidance put "$CONTENT_KEY" --file "$TMP_DIR/guidance.md" >/dev/null
pc_json pipelines set-automation "$CONTENT_KEY" --stage drafting --routine "$routine_id" >/dev/null
release="$(pc_json pipelines ingest "$RELEASES_KEY" \
--case-key "release-$RUN_KEY" \
--title "Release $RUN_KEY: Pipeline primitive" \
--summary "A release case that fans out into content suggestions." \
--fields-json '{"release":"v0.pipeline-smoke","source":"tutorial-smoke"}')"
release_case_id="$(jq -r '.case.id' <<<"$release")"
pc_json pipelines case edit "$release_case_id" --expected-version 1 --summary "Release case edited by the CLI smoke." >/dev/null
claim="$(pc_json pipelines case claim "$release_case_id" --lease-seconds 60)"
lease_token="$(jq -r '.leaseToken' <<<"$claim")"
pc_json pipelines case release "$release_case_id" --lease-token "$lease_token" >/dev/null
jq -n --arg parent "$release_case_id" '{
items: [
{ caseKey: "suggestion-01", title: "Launch blog post", summary: "Explain the release end to end.", fields: { channel: "blog" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-02", title: "Product changelog", summary: "Concise changelog entry.", fields: { channel: "docs" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-03", title: "Launch tweet", summary: "Short tweet after the blog is ready.", fields: { channel: "x" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-04", title: "Customer email", summary: "Email announcement.", fields: { channel: "email" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-05", title: "LinkedIn post", summary: "Professional network announcement.", fields: { channel: "linkedin" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-06", title: "Discord update", summary: "Community update.", fields: { channel: "discord" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-07", title: "Release notes", summary: "Detailed release notes.", fields: { channel: "release_notes" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-08", title: "Overly niche post", summary: "Reject: too niche.", fields: { channel: "blog" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-09", title: "Duplicate tweet", summary: "Reject: duplicate.", fields: { channel: "x" }, parentCaseId: $parent, stageKey: "review" },
{ caseKey: "suggestion-10", title: "Short-form video", summary: "Needs edit before approval.", fields: { channel: "video" }, parentCaseId: $parent, stageKey: "review" }
]
}' >"$TMP_DIR/suggestions.json"
batch="$(pc_json pipelines ingest-batch "$CONTENT_KEY" --file "$TMP_DIR/suggestions.json")"
require_json "$batch" 'length == 10 and all(.ok == true)' "Batch ingest did not create/find 10 suggestions."
case_id() {
jq -r --arg key "$1" '.[] | select(.case.caseKey == $key) | .case.id' <<<"$batch"
}
case1="$(case_id suggestion-01)"
case2="$(case_id suggestion-02)"
case3="$(case_id suggestion-03)"
case4="$(case_id suggestion-04)"
case5="$(case_id suggestion-05)"
case6="$(case_id suggestion-06)"
case7="$(case_id suggestion-07)"
case8="$(case_id suggestion-08)"
case9="$(case_id suggestion-09)"
case10="$(case_id suggestion-10)"
inbox="$(pc_json pipelines review-inbox --pipeline "$CONTENT_KEY")"
require_json "$inbox" 'length == 10' "Review inbox should contain the 10 suggestion cases."
jq -n \
--arg c1 "$case1" --arg c2 "$case2" --arg c3 "$case3" --arg c4 "$case4" --arg c5 "$case5" \
--arg c6 "$case6" --arg c7 "$case7" --arg c8 "$case8" --arg c9 "$case9" '{
items: [
{ caseId: $c1, decision: "approve", expectedVersion: 1 },
{ caseId: $c2, decision: "approve", expectedVersion: 1 },
{ caseId: $c3, decision: "approve", expectedVersion: 1 },
{ caseId: $c4, decision: "approve", expectedVersion: 1 },
{ caseId: $c5, decision: "approve", expectedVersion: 1 },
{ caseId: $c6, decision: "approve", expectedVersion: 1 },
{ caseId: $c7, decision: "approve", expectedVersion: 1 },
{ caseId: $c8, decision: "reject", reason: "Too niche for this launch.", expectedVersion: 1 },
{ caseId: $c9, decision: "reject", reason: "Duplicates the launch tweet.", expectedVersion: 1 }
]
}' >"$TMP_DIR/decisions.json"
bulk="$(pc_json pipelines review-bulk --file "$TMP_DIR/decisions.json")"
require_json "$bulk" '.results | length == 9 and all(.ok == true)' "Bulk review failed."
pc_json pipelines case review "$case10" \
--approve \
--expected-version 1 \
--title "Edited short-form video" \
--fields-json '{"channel":"video","edited":true}' >/dev/null
case1_detail="$(pc_json pipelines case get "$case1")"
require_json "$case1_detail" '(.links | length) >= 1' "Approved case should have an automation issue link."
suggestion="$(pc_json pipelines case suggest "$case1" --to ready --rationale "Draft is complete enough to publish." --confidence 0.9)"
suggestion_id="$(jq -r '.suggestion.id' <<<"$suggestion")"
pc_json pipelines case resolve-suggestion "$case1" --suggestion "$suggestion_id" --accept --expected-version 2 >/dev/null
pc_json pipelines case transition "$case1" --to published --expected-version 3 --reason "Suggestion accepted and content published." >/dev/null
pc_json pipelines case block "$case3" --by "$case2" >/dev/null
set +e
blocked_output="$(pc_json pipelines case transition "$case3" --to published --expected-version 2 --reason "Try before blog is published." 2>&1)"
blocked_status=$?
set -e
if [[ "$blocked_status" -eq 0 || "$blocked_output" != *"code=blocked"* ]]; then
echo "Expected blocked transition to fail with code=blocked." >&2
echo "$blocked_output" >&2
exit 1
fi
pc_json pipelines case transition "$case2" --to published --expected-version 2 --reason "Blog published; unblocks tweet." >/dev/null
pc_json pipelines case transition "$case3" --to published --expected-version 2 --reason "Blocker resolved." >/dev/null
pc_json pipelines case transition "$case4" --to cancelled --expected-version 2 --reason "Cancelled after review." >/dev/null
pc_json pipelines case transition "$case5" --to published --expected-version 2 --reason "Published." >/dev/null
pc_json pipelines case transition "$case6" --to published --expected-version 2 --reason "Published." >/dev/null
pc_json pipelines case transition "$case7" --to published --expected-version 2 --reason "Published." >/dev/null
pc_json pipelines case transition "$case10" --to published --expected-version 3 --reason "Edited and published." >/dev/null
rollup="$(pc_json pipelines case rollup "$release_case_id")"
require_json "$rollup" '.complete == true and .done == 7 and .cancelled == 3 and .open == 0' "Release rollup did not report the expected done/cancelled split."
events="$(pc_json pipelines case events "$case1")"
require_json "$events" '.items | map(.type) | index("ingested") and index("review_decided") and index("automation_executed") and index("transition_suggested") and index("suggestion_resolved") and index("transitioned")' "Event history is missing expected provenance events."
pc_json pipelines cases "$CONTENT_KEY" --parent "$release_case_id" --terminal >/dev/null
pc_json pipelines case open-conversation "$case3" >/dev/null
echo "Pipeline tutorial smoke passed for $RUN_KEY"