mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Add pipeline CLI and tutorial smoke
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
89fac47646
commit
f570e14d02
5 files changed
+1428
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
Executable
+228
@@ -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"
|
||||
Reference in new issue
Block a user