Pipeline Graph
This is the graph view that scarn pipelines studio renders locally. Here it runs over static fixtures instead of a live directory. Each fixture is written in TypeScript with @scarn/sdk/pipelines, and scarn pipelines build compiles it to the JSON document that scarn pipelines publish sends (see Building Pipelines). Open “View source” under any example to compare the two. The steps the examples use are listed at the end of the page.
At build time, translateDocumentToGraph (the "." export of @scarn/pipeline-studio-ui) turns each document into {nodes, edges}. The graph has a synthetic Start node for the start transition and one edge for each next and catch entry. Edges are labelled with their condition, otherwise, an each or partial marker, or the errors they catch. The same PipelineGraphView component the studio uses (its "./view" export) renders it in the browser. Drag to pan, scroll to zoom, and use the controls in the bottom-left corner to fit or reset the view.
Linear chain
Section titled “Linear chain”The simplest shape: a sequence of steps with no branching, ending in a succeed node.
Triage a bug report
Reads the report, analyzes its severity, and writes a summary.
View source
import { definePipeline, succeed } from "@scarn/sdk/pipelines";
import * as steps from "./steps";
const intake = steps.intake.as("intake");
const analyze = steps.analyze.as("analyze");
const summarize = steps.summarize.as("summarize");
export default definePipeline({
key: "catalog-linear-chain",
title: "Triage a bug report",
start: intake,
flow: [
intake.settings({ title: "Read report" }).next(analyze),
analyze.settings({ title: "Analyze severity" }).next(summarize),
summarize.settings({ title: "Write summary" }).next(succeed("done")),
],
});{
"schemaVersion": 1,
"key": "catalog-linear-chain",
"title": "Triage a bug report",
"start": { "to": "intake" },
"nodes": {
"intake": {
"kind": "step",
"title": "Read report",
"step": "intake-step",
"next": {
"routes": [{ "to": "analyze" }]
}
},
"analyze": {
"kind": "step",
"title": "Analyze severity",
"step": "analyze-step",
"next": {
"routes": [{ "to": "summarize" }]
}
},
"summarize": {
"kind": "step",
"title": "Write summary",
"step": "summarize-step",
"next": {
"routes": [{ "to": "done" }]
}
},
"done": { "kind": "succeed" }
}
}Conditional next
Section titled “Conditional next”A step’s next list is checked in order. The first transition whose when condition matches is taken, and the last entry, which has no when, is the fallback.
Route an incident by severity
Classifies an incident, then pages on-call or files a ticket.
View source
import { definePipeline, equal, succeed } from "@scarn/sdk/pipelines";
import * as steps from "./steps";
const classify = steps.classify.as("classify");
const pageOncall = steps.pageOncall.as("pageOncall");
const fileTicket = steps.fileTicket.as("fileTicket");
export default definePipeline({
key: "catalog-choice",
title: "Route an incident by severity",
start: classify,
flow: [
classify.settings({ title: "Classify severity" }).next({
routes: [
{ when: (r) => equal(r.result.severity, "critical"), to: pageOncall },
],
otherwise: fileTicket,
}),
pageOncall.settings({ title: "Page on-call" }).next(succeed("paged")),
fileTicket.settings({ title: "File a ticket" }).next(succeed("ticketed")),
],
});{
"schemaVersion": 1,
"key": "catalog-choice",
"title": "Route an incident by severity",
"start": { "to": "classify" },
"nodes": {
"classify": {
"kind": "step",
"title": "Classify severity",
"step": "classify-step",
"next": {
"routes": [
{
"when": {
"path": "result.severity",
"op": "equal",
"value": "critical"
},
"to": "pageOncall"
}
],
"otherwise": { "to": "fileTicket" }
}
},
"pageOncall": {
"kind": "step",
"title": "Page on-call",
"step": "page-oncall-step",
"next": {
"routes": [{ "to": "paged" }]
}
},
"fileTicket": {
"kind": "step",
"title": "File a ticket",
"step": "file-ticket-step",
"next": {
"routes": [{ "to": "ticketed" }]
}
},
"paged": { "kind": "succeed" },
"ticketed": { "kind": "succeed" }
}
}Decision
Section titled “Decision”A wait node with "for": "decision" pauses until a person picks one of its actions, optionally with a written body. Its result { action, body } is branched on like a step’s. Here revise needs a body and loops back to proposeFix with it as feedback, and nodes.proposeFix.result reaches back to the step’s result once the fix is approved.
Review a fix before applying it
Proposes a fix, asks for a review when the step says it's needed, then applies it, sends it back with feedback, or fails.
View source
import {
decision,
definePipeline,
equal,
fail,
succeed,
workItem,
} from "@scarn/sdk/pipelines";
import * as steps from "./steps";
const proposeFix = steps.proposeFix.as("proposeFix");
const applyFix = steps.applyFix.as("applyFix");
const reviewFix = decision("reviewFix", {
actions: {
approve: { label: "Approve" },
revise: { label: "Request changes", body: "required" },
reject: { label: "Reject" },
},
});
const done = succeed("done");
const rejected = fail("rejected", { error: "FixRejected" });
export default definePipeline({
key: "catalog-decision",
title: "Review a fix before applying it",
start: proposeFix({ title: workItem.title }),
flow: [
proposeFix.settings({ title: "Propose a fix" }).next({
routes: [
{
when: (r) => equal(r.result.requiresHumanApproval, true),
to: (r) => reviewFix({ plan: r.result }),
},
],
otherwise: (r) => applyFix({ plan: r.result }),
}),
reviewFix.settings({ title: "Review the fix" }).next({
routes: [
{
when: (r) => equal(r.result.action, "approve"),
to: (r) => applyFix({ plan: r.node(proposeFix).result }),
},
{
when: (r) => equal(r.result.action, "revise"),
to: (r) =>
proposeFix({
title: r.node(proposeFix).input.title,
feedback: r.result.body,
}),
},
],
otherwise: rejected,
}),
applyFix.settings({ title: "Apply the fix" }).next(done),
],
});{
"schemaVersion": 1,
"key": "catalog-decision",
"title": "Review a fix before applying it",
"start": { "to": "proposeFix", "args": { "title": "workItem.title" } },
"nodes": {
"proposeFix": {
"kind": "step",
"title": "Propose a fix",
"step": "propose-fix",
"next": {
"routes": [
{
"when": {
"path": "result.requiresHumanApproval",
"op": "equal",
"value": true
},
"to": "reviewFix",
"args": { "plan": "result" }
}
],
"otherwise": { "to": "applyFix", "args": { "plan": "result" } }
}
},
"reviewFix": {
"kind": "wait",
"title": "Review the fix",
"for": "decision",
"actions": [
{ "key": "approve", "label": "Approve", "body": "optional" },
{ "key": "revise", "label": "Request changes", "body": "required" },
{ "key": "reject", "label": "Reject", "body": "optional" }
],
"next": {
"routes": [
{
"when": {
"path": "result.action",
"op": "equal",
"value": "approve"
},
"to": "applyFix",
"args": { "plan": "nodes.proposeFix.result" }
},
{
"when": {
"path": "result.action",
"op": "equal",
"value": "revise"
},
"to": "proposeFix",
"args": {
"title": "nodes.proposeFix.input.title",
"feedback": "result.body"
}
}
],
"otherwise": { "to": "rejected" }
}
},
"applyFix": {
"kind": "step",
"title": "Apply the fix",
"step": "apply-fix",
"next": {
"routes": [{ "to": "done" }]
}
},
"done": { "kind": "succeed" },
"rejected": { "kind": "fail", "error": "FixRejected" }
}
}Loop, retry and catch
Section titled “Loop, retry and catch”A transition can lead back to the node it leaves from. maxRuns bounds the loop by raising Scarn.MaxRunsExceeded, which the catch edge sends to a fail node. retry doesn’t appear on the graph, because a retry is a new attempt of the same node.
Fix until the tests pass
Retries a dead worker twice, loops until the tests pass, and gives up after five tries.
View source
import {
definePipeline,
equal,
fail,
succeed,
workItem,
} from "@scarn/sdk/pipelines";
import * as steps from "./steps";
const fix = steps.fix.as("fix");
const done = succeed("done");
const gaveUp = fail("gaveUp", { error: "TestsStillFailing" });
export default definePipeline({
key: "catalog-loop-and-catch",
title: "Fix until the tests pass",
start: fix({ title: workItem.title }),
flow: [
fix
.settings({
title: "Fix and run tests",
maxRuns: 5,
heartbeatSeconds: 300,
})
.retry({ on: ["Scarn.HeartbeatTimeout"], maxAttempts: 2 })
.catch(["Scarn.MaxRunsExceeded"], gaveUp)
.next({
routes: [{ when: (r) => equal(r.result.testsPass, true), to: done }],
otherwise: (r) =>
fix({ title: r.input.title, previousAttempt: r.result }),
}),
],
});{
"schemaVersion": 1,
"key": "catalog-loop-and-catch",
"title": "Fix until the tests pass",
"start": { "to": "fix", "args": { "title": "workItem.title" } },
"nodes": {
"fix": {
"kind": "step",
"title": "Fix and run tests",
"step": "fix",
"maxRuns": 5,
"heartbeatSeconds": 300,
"retry": [{ "on": ["Scarn.HeartbeatTimeout"], "maxAttempts": 2 }],
"catch": [
{
"on": ["Scarn.MaxRunsExceeded"],
"to": "gaveUp"
}
],
"next": {
"routes": [
{
"when": {
"path": "result.testsPass",
"op": "equal",
"value": true
},
"to": "done"
}
],
"otherwise": {
"to": "fix",
"args": { "title": "input.title", "previousAttempt": "result" }
}
}
},
"done": { "kind": "succeed" },
"gaveUp": { "kind": "fail", "error": "TestsStillFailing" }
}
}each starts one lane per file, with up to five running at once. Every lane runs editFile and then reviewEdit. Each review is a partial route into approveReviews, which merges them into one reviews list and runs once every lane has arrived. A person then approves or rejects the reviews, and the pipeline branches on action: open the PR, or fail.
Edit and review each file
Plans a change, edits and reviews each file in its own lane, then merges the reviews to open one pull request.
View source
import {
decision,
definePipeline,
each,
equal,
fail,
succeed,
workItem,
} from "@scarn/sdk/pipelines";
import { z } from "zod";
import * as steps from "./steps";
const planChanges = steps.planChanges.as("planChanges");
const editFile = steps.editFile.as("editFile");
const reviewEdit = steps.reviewEdit.as("reviewEdit");
const openPr = steps.openPr.as("openPr");
const approveReviews = decision("approveReviews", {
input: z.object({ reviews: z.array(z.object({ approved: z.boolean() })) }),
actions: {
approve: { label: "Approve" },
reject: { label: "Reject" },
},
});
const done = succeed("done");
const changesRejected = fail("changesRejected", { error: "ChangesRejected" });
export default definePipeline({
key: "catalog-lanes",
title: "Edit and review each file",
start: planChanges({ request: workItem.title }),
flow: [
planChanges
.settings({ title: "Plan changes" })
.next((r) =>
each(r.result.files, { maxConcurrency: 5 }, (file) =>
editFile({ file, planSummary: r.result.summary }),
),
),
editFile
.settings({ title: "Edit one file" })
.next((r) => reviewEdit({ path: r.result.path, diff: r.result.diff })),
reviewEdit
.settings({ title: "Review the edit" })
.next((r) => approveReviews.partial({ reviews: [r.result] })),
approveReviews.settings({ title: "Approve the reviews" }).next({
routes: [
{
when: (r) => equal(r.result.action, "approve"),
to: (r) =>
openPr({
request: r.node(planChanges).input.request,
reviews: r.input.reviews,
}),
},
],
otherwise: changesRejected,
}),
openPr.settings({ title: "Open a pull request" }).next(done),
],
});{
"schemaVersion": 1,
"key": "catalog-lanes",
"title": "Edit and review each file",
"start": { "to": "planChanges", "args": { "request": "workItem.title" } },
"nodes": {
"planChanges": {
"kind": "step",
"title": "Plan changes",
"step": "plan-changes",
"next": {
"routes": [
{
"to": "editFile",
"each": { "over": "result.files", "maxConcurrency": 5 },
"args": { "file": "item", "planSummary": "result.summary" }
}
]
}
},
"editFile": {
"kind": "step",
"title": "Edit one file",
"step": "edit-file",
"next": {
"routes": [
{
"to": "reviewEdit",
"args": { "path": "result.path", "diff": "result.diff" }
}
]
}
},
"reviewEdit": {
"kind": "step",
"title": "Review the edit",
"step": "review-edit",
"next": {
"routes": [
{
"to": "approveReviews",
"args": { "reviews": { "list": ["result"] } },
"partial": true
}
]
}
},
"approveReviews": {
"kind": "wait",
"title": "Approve the reviews",
"for": "decision",
"actions": [
{ "key": "approve", "label": "Approve", "body": "optional" },
{ "key": "reject", "label": "Reject", "body": "optional" }
],
"next": {
"routes": [
{
"when": {
"path": "result.action",
"op": "equal",
"value": "approve"
},
"to": "openPr",
"args": {
"request": "nodes.planChanges.input.request",
"reviews": "input.reviews"
}
}
],
"otherwise": { "to": "changesRejected" }
}
},
"openPr": {
"kind": "step",
"title": "Open a pull request",
"step": "open-pr",
"next": {
"routes": [{ "to": "done" }]
}
},
"done": { "kind": "succeed" },
"changesRejected": { "kind": "fail", "error": "ChangesRejected" }
}
}Every matching route
Section titled “Every matching route”With match: "all", every route that matches runs in its own lane. A route with no when always runs, and otherwise runs only when no conditional route matched. writeFailingTest is a partial merge of two of those lanes, and summariseFindings is the closing merge that waits for every lane that can reach it.
Triage an issue on every matching route
Classifies an issue, then reproduces it, notifies security and screenshots each affected page side by side, merging the results into one summary.
View source
import {
definePipeline,
each,
equal,
isEmpty,
not,
succeed,
workItem,
} from "@scarn/sdk/pipelines";
import * as steps from "./steps";
const classifyIssue = steps.classifyIssue.as("classifyIssue");
const reproduceBug = steps.reproduceBug.as("reproduceBug");
const notifySecurity = steps.notifySecurity.as("notifySecurity");
const writeFailingTest = steps.writeFailingTest.as("writeFailingTest");
const captureScreenshot = steps.captureScreenshot.as("captureScreenshot");
const addToBacklog = steps.addToBacklog.as("addToBacklog");
const summariseFindings = steps.summariseFindings.as("summariseFindings");
export default definePipeline({
key: "catalog-routes-all",
title: "Triage an issue on every matching route",
start: classifyIssue({ title: workItem.title }),
flow: [
classifyIssue.settings({ title: "Classify issue" }).next({
match: "all",
routes: [
{ to: (r) => reproduceBug({ appUrl: r.result.appUrl }) },
{
when: (r) => equal(r.result.category, "security"),
to: (r) => notifySecurity({ summary: r.result.summary }),
},
{
when: (r) => not(isEmpty(r.result.affectedPages)),
to: (r) =>
each(r.result.affectedPages, { maxConcurrency: 3 }, (page) =>
captureScreenshot({ url: page }),
),
},
],
otherwise: (r) => addToBacklog({ summary: r.result.summary }),
}),
reproduceBug.settings({ title: "Reproduce bug" }).next((r) =>
writeFailingTest.partial({
observedBehavior: r.result.observedBehavior,
}),
),
notifySecurity
.settings({ title: "Notify security" })
.next((r) => writeFailingTest.partial({ securityNotes: r.result.notes })),
writeFailingTest
.settings({ title: "Write failing test" })
.next((r) =>
summariseFindings.partial({ failingTest: r.result.testPath }),
),
captureScreenshot
.settings({ title: "Capture screenshot" })
.next((r) => summariseFindings.partial({ screenshots: [r.result.url] })),
summariseFindings
.settings({ title: "Summarise findings" })
.next(succeed("triaged")),
],
});{
"schemaVersion": 1,
"key": "catalog-routes-all",
"title": "Triage an issue on every matching route",
"start": {
"to": "classifyIssue",
"args": {
"title": "workItem.title"
}
},
"nodes": {
"classifyIssue": {
"kind": "step",
"title": "Classify issue",
"step": "classify-issue",
"next": {
"match": "all",
"routes": [
{
"to": "reproduceBug",
"args": {
"appUrl": "result.appUrl"
}
},
{
"when": {
"path": "result.category",
"op": "equal",
"value": "security"
},
"to": "notifySecurity",
"args": {
"summary": "result.summary"
}
},
{
"when": {
"not": {
"path": "result.affectedPages",
"op": "isEmpty"
}
},
"to": "captureScreenshot",
"each": {
"over": "result.affectedPages",
"maxConcurrency": 3
},
"args": {
"url": "item"
}
}
],
"otherwise": {
"to": "addToBacklog",
"args": {
"summary": "result.summary"
}
}
}
},
"reproduceBug": {
"kind": "step",
"title": "Reproduce bug",
"step": "reproduce-bug",
"next": {
"routes": [
{
"to": "writeFailingTest",
"args": {
"observedBehavior": "result.observedBehavior"
},
"partial": true
}
]
}
},
"notifySecurity": {
"kind": "step",
"title": "Notify security",
"step": "notify-security",
"next": {
"routes": [
{
"to": "writeFailingTest",
"args": {
"securityNotes": "result.notes"
},
"partial": true
}
]
}
},
"captureScreenshot": {
"kind": "step",
"title": "Capture screenshot",
"step": "capture-screenshot",
"next": {
"routes": [
{
"to": "summariseFindings",
"args": {
"screenshots": {
"list": ["result.url"]
}
},
"partial": true
}
]
}
},
"addToBacklog": {
"kind": "step",
"step": "add-to-backlog"
},
"writeFailingTest": {
"kind": "step",
"title": "Write failing test",
"step": "write-failing-test",
"next": {
"routes": [
{
"to": "summariseFindings",
"args": {
"failingTest": "result.testPath"
},
"partial": true
}
]
}
},
"summariseFindings": {
"kind": "step",
"title": "Summarise findings",
"step": "summarise-findings",
"next": {
"routes": [
{
"to": "triaged"
}
]
}
},
"triaged": {
"kind": "succeed"
}
}
}Steps used by these examples
Section titled “Steps used by these examples”The TypeScript sources above import their steps from one shared steps.ts. Each step’s zod schemas type the r.result and r.input references and the args at each call site.
import { z } from "zod";
import { codeStep } from "@scarn/sdk/definitions/steps";
const notRun = (): never => {
throw new Error("fixture steps never run");
};
const plan = z.object({
requiresHumanApproval: z.boolean(),
summary: z.string(),
});
export const proposeFix = codeStep({
key: "propose-fix",
name: "Propose fix",
inputSchema: z.object({
title: z.string(),
feedback: z.string().nullable().optional(),
}),
resultSchema: plan,
fn: notRun,
});
export const applyFix = codeStep({
key: "apply-fix",
name: "Apply fix",
inputSchema: z.object({ plan }),
resultSchema: z.object({ applied: z.boolean() }),
fn: notRun,
});
export const classify = codeStep({
key: "classify-step",
name: "Classify",
resultSchema: z.object({ severity: z.enum(["critical", "minor"]) }),
fn: notRun,
});
export const pageOncall = codeStep({
key: "page-oncall-step",
name: "Page",
fn: notRun,
});
export const fileTicket = codeStep({
key: "file-ticket-step",
name: "Ticket",
fn: notRun,
});
export const intake = codeStep({
key: "intake-step",
name: "Intake",
fn: notRun,
});
export const analyze = codeStep({
key: "analyze-step",
name: "Analyze",
fn: notRun,
});
export const summarize = codeStep({
key: "summarize-step",
name: "Summarize",
fn: notRun,
});
const fixResult = z.object({ testsPass: z.boolean() });
export const fix = codeStep({
key: "fix",
name: "Fix",
inputSchema: z.object({
title: z.string(),
previousAttempt: fixResult.optional(),
}),
resultSchema: fixResult,
fn: notRun,
});
const review = z.object({ approved: z.boolean() });
export const planChanges = codeStep({
key: "plan-changes",
name: "Plan changes",
inputSchema: z.object({ request: z.string() }),
resultSchema: z.object({ files: z.array(z.string()), summary: z.string() }),
fn: notRun,
});
export const editFile = codeStep({
key: "edit-file",
name: "Edit file",
inputSchema: z.object({ file: z.string(), planSummary: z.string() }),
resultSchema: z.object({ path: z.string(), diff: z.string() }),
fn: notRun,
});
export const reviewEdit = codeStep({
key: "review-edit",
name: "Review edit",
inputSchema: z.object({ path: z.string(), diff: z.string() }),
resultSchema: review,
fn: notRun,
});
export const openPr = codeStep({
key: "open-pr",
name: "Open PR",
inputSchema: z.object({ request: z.string(), reviews: z.array(review) }),
resultSchema: z.object({ url: z.string() }),
fn: notRun,
});
export const classifyIssue = codeStep({
key: "classify-issue",
name: "Classify issue",
inputSchema: z.object({ title: z.string() }),
resultSchema: z.object({
appUrl: z.string(),
category: z.string(),
summary: z.string(),
affectedPages: z.array(z.string()),
}),
fn: notRun,
});
export const reproduceBug = codeStep({
key: "reproduce-bug",
name: "Reproduce bug",
inputSchema: z.object({ appUrl: z.string() }),
resultSchema: z.object({ observedBehavior: z.string() }),
fn: notRun,
});
export const notifySecurity = codeStep({
key: "notify-security",
name: "Notify security",
inputSchema: z.object({ summary: z.string() }),
resultSchema: z.object({ notes: z.string() }),
fn: notRun,
});
export const writeFailingTest = codeStep({
key: "write-failing-test",
name: "Write failing test",
inputSchema: z.object({
observedBehavior: z.string(),
securityNotes: z.string().optional(),
}),
resultSchema: z.object({ testPath: z.string() }),
fn: notRun,
});
export const captureScreenshot = codeStep({
key: "capture-screenshot",
name: "Capture screenshot",
inputSchema: z.object({ url: z.string() }),
resultSchema: z.object({ url: z.string() }),
fn: notRun,
});
export const addToBacklog = codeStep({
key: "add-to-backlog",
name: "Add to backlog",
inputSchema: z.object({ summary: z.string() }),
resultSchema: z.object({ id: z.string() }),
fn: notRun,
});
export const summariseFindings = codeStep({
key: "summarise-findings",
name: "Summarise findings",
inputSchema: z.object({
failingTest: z.string().optional(),
screenshots: z.array(z.string()),
}),
resultSchema: z.object({ text: z.string() }),
fn: notRun,
});