paperclip/docs/pipelines-tutorial.md

650 lines
21 KiB
Markdown

# Pipelines Tutorial: Release to Published Content
This walkthrough is the CLI/API version of the 12-step release-to-content worked example. It uses three linked pipelines:
- `release-coverage`: one release case answers "is this release covered?"
- `feature-content`: one feature case per approved feature rolls up content coverage.
- `content-production`: one content-piece case moves through drafting, assets, assembly, final review, publishing, and a terminal result.
The walkthrough intentionally labels conventions separately from primitives. Those conventions are future primitive candidates: if they hurt, we want to see exactly where.
## Prerequisites
Run this against a dev Paperclip instance with a board token or an agent token that can manage pipelines, routines, and issues.
```sh
export PAPERCLIP_API_URL=http://localhost:3100
export PAPERCLIP_COMPANY_ID=<company-id>
export PAPERCLIP_API_KEY=<token>
# Optional: assign routine-created drafting issues to a specific agent.
export DRAFTING_AGENT_ID=<agent-id>
export RUN_KEY="$(date +%Y%m%d%H%M%S)"
export RELEASE_PIPELINE="release-coverage-$RUN_KEY"
export FEATURE_PIPELINE="feature-content-$RUN_KEY"
export CONTENT_PIPELINE="content-production-$RUN_KEY"
```
## Step 1: Setup The Three Pipelines
Create Release Coverage. It is thin on purpose: the release case stays in `intake` until its feature children are terminal, then `autoAdvanceOnChildrenTerminal` moves it to `covered`.
```sh
cat > /tmp/release-stages.json <<'JSON'
[
{
"key": "intake",
"name": "Intake",
"kind": "open",
"position": 100,
"config": { "autoAdvanceOnChildrenTerminal": "covered" }
},
{ "key": "covered", "name": "Covered", "kind": "done", "position": 900 },
{ "key": "cancelled", "name": "Cancelled", "kind": "cancelled", "position": 1000 }
]
JSON
paperclipai pipelines create \
-C "$PAPERCLIP_COMPANY_ID" \
--key "$RELEASE_PIPELINE" \
--name "Release Coverage $RUN_KEY" \
--stages-file /tmp/release-stages.json
```
Create Feature Content. The review stage lets the human approve features into production or drop them from this release.
```sh
cat > /tmp/feature-stages.json <<'JSON'
[
{ "key": "suggesting", "name": "Suggesting", "kind": "open", "position": 100 },
{
"key": "suggestion_review",
"name": "Suggestion Review",
"kind": "review",
"position": 200,
"config": {
"approveToStageKey": "producing",
"rejectToStageKey": "cancelled",
"requestChangesToStageKey": "suggesting",
"requireRejectReason": true,
"reviewerKind": "human"
}
},
{
"key": "producing",
"name": "Producing",
"kind": "working",
"position": 300,
"config": { "autoAdvanceOnChildrenTerminal": "covered" }
},
{ "key": "covered", "name": "Covered", "kind": "done", "position": 900 },
{ "key": "cancelled", "name": "Cancelled", "kind": "cancelled", "position": 1000 }
]
JSON
paperclipai pipelines create \
-C "$PAPERCLIP_COMPANY_ID" \
--key "$FEATURE_PIPELINE" \
--name "Feature Content $RUN_KEY" \
--stages-file /tmp/feature-stages.json
```
Create Content Production. `Assets` and `Assembly` are `working` stages, not review stages. `Final Review` is the review stage and has all three exits: approve, request changes, and drop.
```sh
cat > /tmp/content-stages.json <<'JSON'
[
{
"key": "drafting",
"name": "Drafting",
"kind": "working",
"position": 100,
"config": { "autonomy": "suggest" }
},
{
"key": "assets",
"name": "Assets",
"kind": "working",
"position": 200
},
{
"key": "assembly",
"name": "Assembly",
"kind": "working",
"position": 300,
"config": { "autoAdvanceOnChildrenTerminal": "final_review" }
},
{
"key": "final_review",
"name": "Final Review",
"kind": "review",
"position": 400,
"config": {
"approveToStageKey": "publishing",
"rejectToStageKey": "dropped",
"requestChangesToStageKey": "drafting",
"requireRejectReason": true,
"reviewerKind": "human"
}
},
{ "key": "publishing", "name": "Publishing", "kind": "working", "position": 500 },
{ "key": "published", "name": "Published", "kind": "done", "position": 900 },
{ "key": "dropped", "name": "Dropped", "kind": "cancelled", "position": 1000 }
]
JSON
paperclipai pipelines create \
-C "$PAPERCLIP_COMPANY_ID" \
--key "$CONTENT_PIPELINE" \
--name "Content Production $RUN_KEY" \
--stages-file /tmp/content-stages.json
```
Show `enforceTransitions` on one pipeline. The release case can only auto-cover or cancel.
```sh
cat > /tmp/release-transitions.json <<'JSON'
{
"enforceTransitions": true,
"transitions": [
{ "fromStageKey": "intake", "toStageKey": "covered", "label": "all features terminal" },
{ "fromStageKey": "intake", "toStageKey": "cancelled", "label": "cancel release coverage" }
]
}
JSON
paperclipai pipelines set-transitions \
-C "$PAPERCLIP_COMPANY_ID" \
"$RELEASE_PIPELINE" \
--file /tmp/release-transitions.json
```
Add guidance and a drafting routine. The guidance document carries the rubric.
```sh
cat > /tmp/content-guidance.md <<'MD'
# Content Production guidance
Final Review has three exits:
- approve to Publishing when the pinned revisions are ready to ship
- request changes back to Drafting when the same work issue should continue
- drop to Dropped when the content should not ship
Convention: asset cases store `briefedFromVersion` in `fields` so assembly review can compare a pinned brief against the current upstream case `version`.
MD
paperclipai pipelines guidance put \
-C "$PAPERCLIP_COMPANY_ID" \
"$CONTENT_PIPELINE" \
--file /tmp/content-guidance.md
```
```sh
cat > /tmp/drafting-routine.json <<JSON
{
"title": "Draft content production case",
"description": "Template convention: draft the content case from the Pipeline Case Context, keep typed work references in case fields, and suggest Drafting -> Assets when ready.",
"priority": "medium",
"status": "active",
"concurrencyPolicy": "always_enqueue",
"catchUpPolicy": "skip_missed"
${DRAFTING_AGENT_ID:+, "assigneeAgentId": "$DRAFTING_AGENT_ID"}
}
JSON
export DRAFTING_ROUTINE_ID="$(
paperclipai routine create \
-C "$PAPERCLIP_COMPANY_ID" \
--payload-json "$(jq -c . /tmp/drafting-routine.json)" \
--json | jq -r '.id'
)"
paperclipai pipelines set-automation \
-C "$PAPERCLIP_COMPANY_ID" \
"$CONTENT_PIPELINE" \
--stage drafting \
--routine "$DRAFTING_ROUTINE_ID" \
--note "Template-versioned with the routine prompt."
```
**Convention:** v1 "templates" version with the routine prompt plus the batch file below, not with the pipeline. The pipeline `guidance` document carries the durable rubric. This is the accepted divergence from the long-term template-on-pipeline shape.
## Step 2: Trigger, Intake, And Gate
A real system would start with a release-cut routine. Today, the routine fires on a timer or API trigger and creates an intake issue. On that issue, the agent writes a proposal document and asks the board for a checkbox confirmation.
The accepted checkbox selection is represented here by the batch files. That batch file plus the routine prompt is the v1 template convention.
Create the release root:
```sh
export RELEASE_CASE_ID="$(
paperclipai pipelines ingest \
-C "$PAPERCLIP_COMPANY_ID" \
"$RELEASE_PIPELINE" \
--case-key "release-$RUN_KEY" \
--stage intake \
--title "Release $RUN_KEY: Pipeline primitives" \
--summary "Rollup root for release content coverage." \
--fields-json '{"release":"v0.pipeline-tutorial","templateVersionConvention":"routine-prompt"}' \
--json | jq -r '.case.id'
)"
```
Create two feature cases parented to the release. One is approved, one is dropped.
```sh
jq -n --arg parent "$RELEASE_CASE_ID" '{
items: [
{
caseKey: "feature-pipelines-ui",
title: "Feature: Pipelines UI",
summary: "Worth a content package.",
parentCaseId: $parent,
stageKey: "suggestion_review",
fields: { releaseTag: "v0.pipeline-tutorial", source: "release-notes" }
},
{
caseKey: "feature-routine-webhooks",
title: "Feature: Routine webhooks",
summary: "Rejected by the gate for this release.",
parentCaseId: $parent,
stageKey: "suggestion_review",
fields: { releaseTag: "v0.pipeline-tutorial", source: "release-notes" }
}
]
}' > /tmp/feature-cases.json
paperclipai pipelines ingest-batch \
-C "$PAPERCLIP_COMPANY_ID" \
"$FEATURE_PIPELINE" \
--file /tmp/feature-cases.json \
--json | tee /tmp/feature-cases-result.json
```
```sh
feature_case_id() {
jq -r --arg key "$1" '.[] | select(.case.caseKey == $key) | .case.id' /tmp/feature-cases-result.json
}
export FEATURE_MAIN="$(feature_case_id feature-pipelines-ui)"
export FEATURE_DROP="$(feature_case_id feature-routine-webhooks)"
jq -n --arg main "$FEATURE_MAIN" --arg drop "$FEATURE_DROP" '{
items: [
{ caseId: $main, decision: "approve", expectedVersion: 1 },
{ caseId: $drop, decision: "reject", reason: "Fold webhooks into the broader launch post.", expectedVersion: 1 }
]
}' > /tmp/feature-review.json
paperclipai pipelines review-bulk \
-C "$PAPERCLIP_COMPANY_ID" \
--file /tmp/feature-review.json
```
Create content cases under the approved feature. `launch-tweet` declares `blockedByCaseKeys: ["blog-post"]`; the CLI resolves that key to the blog case in the same batch.
```sh
jq -n --arg parent "$FEATURE_MAIN" '{
items: [
{
caseKey: "blog-post",
title: "Launch blog post",
summary: "Draft the release narrative.",
parentCaseId: $parent,
stageKey: "drafting",
fields: {
contentType: "blog",
typedWorkRefs: { draftPath: "workspaces/release/blog.md" },
briefedFromVersion: null
}
},
{
caseKey: "changelog-entry",
title: "Product changelog",
summary: "Compact changelog entry.",
parentCaseId: $parent,
stageKey: "drafting",
fields: {
contentType: "changelog",
typedWorkRefs: { draftPath: "workspaces/release/changelog.md" },
briefedFromVersion: null
}
},
{
caseKey: "launch-tweet",
title: "Launch tweet",
summary: "Tweet after the blog is approved.",
parentCaseId: $parent,
stageKey: "drafting",
blockedByCaseKeys: ["blog-post"],
fields: {
contentType: "social",
typedWorkRefs: { draftPath: "workspaces/release/tweet.md" },
briefedFromVersion: 1
}
}
]
}' > /tmp/content-cases.json
paperclipai pipelines ingest-batch \
-C "$PAPERCLIP_COMPANY_ID" \
"$CONTENT_PIPELINE" \
--file /tmp/content-cases.json \
--json | tee /tmp/content-cases-result.json
```
**Convention:** `typedWorkRefs` and `briefedFromVersion` are ordinary case `fields`, not new primitives. They document how this case type points at work and how downstream asset briefs pin an upstream version.
## Step 3: Readiness Suggestion
The drafting agent should not silently move the case. It suggests `Drafting -> Assets` with a rationale, and the human accepts it.
```sh
content_case_id() {
jq -r --arg key "$1" '.[] | select(.case.caseKey == $key) | .case.id' /tmp/content-cases-result.json
}
export BLOG_CASE="$(content_case_id blog-post)"
export CHANGELOG_CASE="$(content_case_id changelog-entry)"
export TWEET_CASE="$(content_case_id launch-tweet)"
export SUGGESTION_ID="$(
paperclipai pipelines case suggest \
-C "$PAPERCLIP_COMPANY_ID" \
"$BLOG_CASE" \
--to assets \
--rationale "Draft is stable enough to brief asset work." \
--confidence 0.9 \
--json | jq -r '.suggestion.id'
)"
paperclipai pipelines case resolve-suggestion \
-C "$PAPERCLIP_COMPANY_ID" \
"$BLOG_CASE" \
--suggestion "$SUGGESTION_ID" \
--accept \
--expected-version 1
```
## Step 4: Parallel Editing And Drift
The draft can still change while dependent work exists. A material update to the upstream case posts a drift comment on dependent linked work issues.
```sh
export TWEET_WORK_ISSUE="$(
paperclipai issue create \
-C "$PAPERCLIP_COMPANY_ID" \
--title "Work issue for launch tweet $RUN_KEY" \
--description "Receives drift comments from the upstream blog case." \
--status todo \
--priority low \
--json | jq -r '.id'
)"
curl -sS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
--data "$(jq -cn --arg issueId "$TWEET_WORK_ISSUE" '{ issueId: $issueId, role: "work" }')" \
"$PAPERCLIP_API_URL/api/cases/$TWEET_CASE/issue-links" >/dev/null
paperclipai pipelines case edit \
-C "$PAPERCLIP_COMPANY_ID" \
"$BLOG_CASE" \
--expected-version 2 \
--summary "Draft changed while dependent tweet work was already briefed." \
--fields-json '{"contentType":"blog","typedWorkRefs":{"draftPath":"workspaces/release/blog.md"},"briefedFromVersion":null,"materialChange":"new-positioning"}'
```
If a worker tries to patch with the stale version, the API returns `409` with `code=version_conflict`, the current version, and the current stage. Recovery is to re-read the case and retry against the current version.
```sh
paperclipai pipelines case edit \
-C "$PAPERCLIP_COMPANY_ID" \
"$BLOG_CASE" \
--expected-version 2 \
--title "Stale edit"
# Recovery:
paperclipai pipelines case get -C "$PAPERCLIP_COMPANY_ID" "$BLOG_CASE" --json
```
## Step 5: Assets
The Assets automation creates asset cases under the feature. In v1 the tutorial uses an explicit batch file; in the product, this is the stage-template convention.
```sh
export BLOG_VERSION="$(paperclipai pipelines case get -C "$PAPERCLIP_COMPANY_ID" "$BLOG_CASE" --json | jq -r '.case.version')"
jq -n --arg parent "$FEATURE_MAIN" --argjson briefVersion "$BLOG_VERSION" '{
items: [
{
caseKey: "blog-hero-image",
title: "Hero image",
parentCaseId: $parent,
stageKey: "assets",
fields: { assetType: "image", briefedFromVersion: $briefVersion }
},
{
caseKey: "blog-social-card",
title: "Social card",
parentCaseId: $parent,
stageKey: "assets",
fields: { assetType: "image", briefedFromVersion: $briefVersion }
}
]
}' > /tmp/asset-cases.json
paperclipai pipelines ingest-batch \
-C "$PAPERCLIP_COMPANY_ID" \
"$CONTENT_PIPELINE" \
--file /tmp/asset-cases.json \
--json | tee /tmp/asset-cases-result.json
```
```sh
asset_case_id() {
jq -r --arg key "$1" '.[] | select(.case.caseKey == $key) | .case.id' /tmp/asset-cases-result.json
}
export HERO_CASE="$(asset_case_id blog-hero-image)"
export CARD_CASE="$(asset_case_id blog-social-card)"
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$HERO_CASE" --to published --expected-version 1 --reason "Hero image done."
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CARD_CASE" --to dropped --expected-version 1 --reason "Social card not needed."
```
When both asset cases are terminal, move the blog case to `assembly`.
```sh
export BLOG_ASSETS_VERSION="$(paperclipai pipelines case get -C "$PAPERCLIP_COMPANY_ID" "$BLOG_CASE" --json | jq -r '.case.version')"
paperclipai pipelines case transition \
-C "$PAPERCLIP_COMPANY_ID" \
"$BLOG_CASE" \
--to assembly \
--expected-version "$BLOG_ASSETS_VERSION" \
--reason "Assets complete; assemble the package."
```
## Step 6: Assembly And Auto-Advance To Final Review
Assembly is also a `working` stage. This is the `autoAdvanceOnChildrenTerminal` gate: create a package child case, complete it, and the blog case auto-advances into `final_review`.
```sh
jq -n --arg parent "$BLOG_CASE" '{
items: [
{
caseKey: "blog-assembly-package",
title: "Assembled blog package",
parentCaseId: $parent,
stageKey: "assembly",
fields: { packageType: "blog", assembledFrom: ["blog-hero-image", "blog-social-card"] }
}
]
}' > /tmp/assembly-cases.json
paperclipai pipelines ingest-batch \
-C "$PAPERCLIP_COMPANY_ID" \
"$CONTENT_PIPELINE" \
--file /tmp/assembly-cases.json \
--json | tee /tmp/assembly-cases-result.json
export ASSEMBLY_CASE="$(jq -r '.[0].case.id' /tmp/assembly-cases-result.json)"
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$ASSEMBLY_CASE" --to published --expected-version 1 --reason "Assembly complete."
```
## Step 7: Blocker Guard
The tweet is blocked by the blog case through `blockedByCaseKeys`. This transition fails with `409 code=blocked` until the blog reaches a `done` terminal stage.
```sh
paperclipai pipelines case transition \
-C "$PAPERCLIP_COMPANY_ID" \
"$TWEET_CASE" \
--to assets \
--expected-version 1 \
--reason "Try before upstream blog is published."
```
## Step 8: Final Review Approve
Approve the blog in Final Review, then publish it.
```sh
export BLOG_REVIEW_VERSION="$(paperclipai pipelines case get -C "$PAPERCLIP_COMPANY_ID" "$BLOG_CASE" --json | jq -r '.case.version')"
paperclipai pipelines case review \
-C "$PAPERCLIP_COMPANY_ID" \
"$BLOG_CASE" \
--approve \
--expected-version "$BLOG_REVIEW_VERSION"
paperclipai pipelines case transition \
-C "$PAPERCLIP_COMPANY_ID" \
"$BLOG_CASE" \
--to published \
--expected-version "$((BLOG_REVIEW_VERSION + 1))" \
--reason "Approved package published."
```
## Step 9: Final Review Request Changes
The changelog demonstrates the edit loop: Final Review requests changes, the same case re-enters `drafting`, the same work references continue, and the case comes back to Final Review for approval.
```sh
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CHANGELOG_CASE" --to final_review --expected-version 1 --reason "Draft ready for final review."
paperclipai pipelines case review \
-C "$PAPERCLIP_COMPANY_ID" \
"$CHANGELOG_CASE" \
--request-changes \
--reason "Tighten the framing before publishing." \
--expected-version 2
paperclipai pipelines case edit \
-C "$PAPERCLIP_COMPANY_ID" \
"$CHANGELOG_CASE" \
--expected-version 3 \
--summary "Revised changelog entry after requested changes." \
--fields-json '{"contentType":"changelog","typedWorkRefs":{"draftPath":"workspaces/release/changelog.md"},"changeRequestAddressed":true}'
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CHANGELOG_CASE" --to final_review --expected-version 4 --reason "Revised draft ready."
paperclipai pipelines case review -C "$PAPERCLIP_COMPANY_ID" "$CHANGELOG_CASE" --approve --expected-version 5
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$CHANGELOG_CASE" --to published --expected-version 6 --reason "Published after request-changes loop."
```
## Step 10: Final Review Drop
Now that the blog blocker is done, the tweet can reach Final Review. The reviewer drops it, which is terminal and still counts toward rollup completion.
```sh
paperclipai pipelines case transition -C "$PAPERCLIP_COMPANY_ID" "$TWEET_CASE" --to final_review --expected-version 1 --reason "Blog blocker is now done."
paperclipai pipelines case review \
-C "$PAPERCLIP_COMPANY_ID" \
"$TWEET_CASE" \
--reject \
--reason "Drop this tweet; blog already covers the announcement." \
--expected-version 2
```
## Step 11: Rollup
At this point:
- content cases are `published` or `dropped`
- the approved feature case auto-advanced to `covered`
- the dropped feature case is terminal
- the release case auto-advanced to `covered`
Inspect the release rollup:
```sh
paperclipai pipelines case rollup \
-C "$PAPERCLIP_COMPANY_ID" \
"$RELEASE_CASE_ID" \
--json
```
Expected shape:
```json
{
"total": 8,
"done": 5,
"cancelled": 3,
"open": 0,
"complete": true
}
```
## Step 12: Reflection Feed
Reflection can pull provenance from case events:
```sh
paperclipai pipelines case events \
-C "$PAPERCLIP_COMPANY_ID" \
"$CHANGELOG_CASE" \
--json
```
Look for `review_decided` events where `payload.decision` is `request_changes`, `approve`, or `reject`. Rejection and change-request reasons are the feed for improving skills, routine prompts, and pipeline guidance.
For rollup provenance:
```sh
paperclipai pipelines case events \
-C "$PAPERCLIP_COMPANY_ID" \
"$RELEASE_CASE_ID" \
--json
```
Look for `children_terminal` followed by the auto `transitioned` event.
## Scripted Smoke
Run the same flow end to end:
```sh
PAPERCLIP_API_URL=http://localhost:3100 \
PAPERCLIP_COMPANY_ID=<company-id> \
PAPERCLIP_API_KEY=<token> \
pnpm smoke:pipelines-tutorial
```
The smoke asserts:
- the three pipelines are created with the expected stages
- feature review approves one feature and rejects one
- batch ingest wires `blockedByCaseKeys`
- readiness uses `suggest-transition` plus acceptance
- upstream drift posts a system comment to a linked work issue
- stale edits fail with `409 code=version_conflict`
- the Assembly child-terminal gate auto-advances the parent into Final Review
- Final Review approve, request-changes, and drop outcomes all work
- the release rollup is complete with the expected done/cancelled split