From f570e14d029d581bce66719758a5177e452e9a35 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 9 Jun 2026 23:28:48 +0000 Subject: [PATCH] Add pipeline CLI and tutorial smoke Co-Authored-By: Paperclip --- cli/src/commands/pipelines.ts | 829 ++++++++++++++++++++++ cli/src/index.ts | 2 + docs/pipelines-tutorial.md | 368 ++++++++++ package.json | 1 + scripts/smoke/pipelines-tutorial-smoke.sh | 228 ++++++ 5 files changed, 1428 insertions(+) create mode 100644 cli/src/commands/pipelines.ts create mode 100644 docs/pipelines-tutorial.md create mode 100755 scripts/smoke/pipelines-tutorial-smoke.sh diff --git a/cli/src/commands/pipelines.ts b/cli/src/commands/pipelines.ts new file mode 100644 index 00000000000..2e3434fb43e --- /dev/null +++ b/cli/src/commands/pipelines.ts @@ -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; + +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 ", "Pipeline key") + .requiredOption("--name ", "Pipeline name") + .option("--description ", "Pipeline description") + .option("--project-id ", "Project ID") + .option("--enforce-transitions", "Only allow configured transitions") + .option("--stages-json ", "Pipeline stage array as JSON") + .option("--stages-file ", "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(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(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 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 ID or key") + .requiredOption("--file ", "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 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 ID or key") + .option("--file ", "Markdown file") + .option("--body ", "Markdown body") + .option("--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); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index e0af362136e..2adb131a2b0 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -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); diff --git a/docs/pipelines-tutorial.md b/docs/pipelines-tutorial.md new file mode 100644 index 00000000000..7eaef50b9d4 --- /dev/null +++ b/docs/pipelines-tutorial.md @@ -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. diff --git a/package.json b/package.json index 28e27bc944c..c152d3c1fa7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/smoke/pipelines-tutorial-smoke.sh b/scripts/smoke/pipelines-tutorial-smoke.sh new file mode 100755 index 00000000000..104e09ee8e5 --- /dev/null +++ b/scripts/smoke/pipelines-tutorial-smoke.sh @@ -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"