Composition
A run can start another run and wait for it. In a pipeline a
workflow: job starts a child run, and in a graph a workflow node
does. The child has its own run page, log and status; the parent sees
one step that succeeds or fails with it.
A script’s spool call starts a run and waits for it. That run is
listed as the caller’s child and ends with it, but goes through its own
admission, concurrency and throttle. See
From a script. Connecting pipelines,
graphs and scripts shows each combination with a
runnable example.
When to compose
Section titled “When to compose”Compose when the child should be inspectable and rerunnable on its own, or when it is owned and versioned separately. Two shapes where it pays off:
- A pipeline that ends in a long canary.
build → test → deployis YAML; “watch the metrics for 30 days” is a durable script withspool sleepin it, and becomes one job of the pipeline. - A pipeline of pipelines: a release that runs build, deploy-staging, soak and deploy-prod, each a pipeline or graph someone else owns and versions.
Don’t compose for:
- A sequence of shell commands. Use
steps:in one job. - Parallel work. Jobs with nothing ordering them already run together.
- A durable script as one part of a pipeline. A
script:job runs it inline in the same run, withspool step,spool sleepandspool wait signalavailable. Use aworkflow:child only when the script should be its own run.
A workflow: job
Section titled “A workflow: job”canary: depends_on: [build] outputs: verdict: verdict workflow: type: hyperspool/script/v1 script: | #!/usr/bin/env bash set -e spool step "smoke" -- bash -c 'echo "smoke OK"' spool step "metrics" -- bash -c 'echo "metrics within band"' spool output set verdict=oktype: selects the child’s workflow type. script: is rendered against
the parent’s ${{ steps.X.output.Y }} / ${{ input.Y }} scope, then
handed to the child. The child’s output is the job’s raw output, and
outputs: picks keys out of it for downstream
${{ steps.canary.output.verdict }}.
A job names its child in one of three ways, and a graph workflow step
has a fourth, from_repo:.
type: hyperspool/script/v1 - a polyglot script
Section titled “type: hyperspool/script/v1 - a polyglot script”script: is a script body, bash by default, run on an agent with the
polyglot capability. See Polyglot scripts. Its
output is one flat JSON object: exit_code, duration_ms, and every key
the script staged with spool output set.
type: hyperspool/pipeline/v1 - a nested pipeline
Section titled “type: hyperspool/pipeline/v1 - a nested pipeline”script: is a pipeline, the document spool submit accepts. The
parent’s scope is rendered into it first, so ${{ input.region }} below
is the parent’s input:
release: workflow: type: hyperspool/pipeline/v1 script: | jobs: build: steps: [{ name: x, run: 'echo build ${{ input.region }}' }] deploy: depends_on: [build] steps: [{ name: y, run: 'echo deploy' }]It returns what its top-level outputs: declares
(below). One that declares none hands back only
its per-job statuses, so gate on the job’s success. A child document
that doesn’t compile fails the job with the reason before any child run
exists.
graph: and node: - a stored graph or one node kind
Section titled “graph: and node: - a stored graph or one node kind”deploy: workflow: graph: { name: build-image } # the published version; `version: 3` pins one input: { ref: main } outputs: sha: shanode: runs a single node kind, a builtin or <connector>.<action>:
ping: workflow: node: kind: http config: { url: "https://example.com" }input: is passed as structured data, and ${{ }} holes in it are filled
from the parent’s run before the child starts (${{ steps.pick.output.service }},
say). A node: child returns that node’s output; a graph: child returns what
the graph declares. See the
YAML reference for every field.
The kinds mix. A stored graph, an inline script and a single node can be three parallel jobs of one run, joined by a fourth that reads all three:
decide: depends_on: [deploy, canary, ping] steps: - name: decide run: echo "${{ steps.deploy.output.sha }} ${{ steps.canary.output.verdict }}"from_repo: - a file in a git repo
Section titled “from_repo: - a file in a git repo”A graph workflow node can run a pipeline or graph file straight from a
repository, read when the node starts:
- id: deploy type: workflow config: from_repo: repo: acme/infra # owner/name or a clone URL path: .spool/deploy.yaml ref: main # branch, tag or commit; default HEAD credential: infra-read # a bearer or url-token credential that can read the repo input: { env: production }repo and credential are both required: nothing else says which repo
to read or with what token. The file’s version: line decides whether
it is a graph or a pipeline. A graph runs as a child with input as its
run input; a pipeline gets input as its document-level input:.
Either way the step’s output is what the file’s top-level outputs:
declares.
On Gitea or Forgejo, add forge: gitea (or forgejo) and give repo
as a URL, https://git.example.com/acme/infra, since that is where the
file is read from. The token only goes to a host the project already
trusts, one it connected a repository on with spool repo connect. Any
other host is refused before the credential is sent.
The fetch spends a project credential, so on a run started by an
untrusted event (a fork pull request, an outside contributor) the step
fails before reading anything, saying the credential is withheld. Add
if: "!run.untrusted" to skip it on those runs instead.
What a child inherits, and what it doesn’t
Section titled “What a child inherits, and what it doesn’t”A child’s event waits are live. A pipeline child’s wait_for_event: and
cancel_on:, and a graph child’s event nodes and top-level
cancel_on:, are subscribed when the child starts, correlated to the
same subject as the run it belongs to. A cancel_on: event cancels the
child, which fails the parent’s step. A matrix: over a workflow job
subscribes every expanded child separately, and one matching event
releases all of them.
A child skips run admission. A top-level concurrency: or throttle:
in a child pipeline or graph has no effect; declare it on the parent.
Naming a group the parent already holds deadlocks, and that includes a
job-level concurrency: in the child: the parent’s workflow: step
holds its lease while the child runs, so the child job waits for a
release only its own completion can produce.
A script’s spool spawn and spool call are the exceptions. The
run either one starts is a new top-level run and goes through admission
like a submit; see below.
A child keeps its parent’s trust. Under a run started by an untrusted
event, run.untrusted is true in every child too, and project
credentials are withheld there as well.
Saying what input a graph takes
Section titled “Saying what input a graph takes”A stored graph can declare the input it expects, so a misspelled key is caught at submit instead of several steps in:
version: hyperspool-graph/v1input_schema: description: Notify one customer about one order properties: customer_id: { type: string } order_id: { type: string } attempts: { type: integer, description: retries before giving up } required: [customer_id, order_id]nodes: - id: send type: shell config: run: { $template: "echo notify {{ input.customer_id }} about {{ input.order_id }}" }$ spool call -w notify --input '{"custmer_id": "c-1", "order_id": "o-9"}'error: call returned status 400 Bad Request: `input` is missing required field: customer_idTypes are string, number, integer, boolean, object, array and
null; nothing is coerced ("3" is not an integer). Only required
makes a field mandatory, and extra keys are fine. Other keywords,
including enum and pattern, are ignored without a warning.
The check runs where someone chose the input: spool call -w, a
script’s call_graph, a parent’s workflow: job, and a schedule when
you create it. It skips a schedule’s ticks (republishing with a new
required would otherwise kill every live schedule), event handlers
(the input is the event envelope), and reruns (they replay the recorded
input).
A child computed at run time
Section titled “A child computed at run time”When the work is only known once something has run, a graph
workflow node can take its definition: from an upstream output and
start a child run of whatever that output describes.
In a pipeline, a script: job writes the document and runs spool call <file|-> (dynamic pipelines).
That starts a separate run, admitted on its own like any submit, which
the job waits for and which ends when the calling run ends; from a
script covers it.
A definition is a graph document in JSON or YAML: a version, a list of
nodes, and optional edges (every field is in
graph documents). spool workflow check <file> validates one before you wire it into a run.
Here a step produces the document: generate decides what to build and
build_all runs it:
version: hyperspool-graph/v1nodes: - id: generate type: code config: interpreter: [node] script: | // Stand-in for the real decision: a diff, a manifest, a service. const targets = ["linux", "docs", "site"]; const graph = { version: "hyperspool-graph/v1", nodes: targets.map((t) => ({ id: `build_${t}`, type: "shell", config: { run: `echo building ${t}` }, })), }; require("child_process").execFileSync( "spool", ["output", "set", "--values", JSON.stringify({ graph })]); - id: build_all type: workflow config: definition: { $expr: "nodes.generate.output.graph" }edges: - { from: generate, to: build_all }This is also the way around matrix: refusing an expression: a run’s
node set is fixed when it starts, and the child run is a new one. The
sequencing is coarse. No edge reaches from the parent into one generated
step, so a parent step that needs one generated result waits for all of
them.
A graph that calls itself
Section titled “A graph that calls itself”graph: accepts any stored graph in the project, including the one the
node is in. That is how a graph loops: do one pass, and let a switch
decide whether to start the next. Each pass is a child run, and the
parent waits for it holding no agent. Nesting stops at 20 levels, so a
loop that needs more passes belongs in a script or a schedule. See
Repeating work.
One child per item
Section titled “One child per item”A fan_out with a workflow body starts a child run per item, which is
how you get N children from a list you only have at run time:
- id: release type: fan_out config: items: { $expr: nodes.plan.output.services } body: type: workflow config: graph: { name: service-release } input: { service: { $expr: item } }Each child can be inspected and rerun on its own, so rerunning the one service that failed leaves the nine that worked alone. The step’s output is the list of what each child returned, in item order. Repeating work covers when to choose this over an activity body.
What a child returns
Section titled “What a child returns”A run declares what it hands back. In a graph that is a top-level
outputs: block beside nodes and edges:
outputs: sha: { $expr: nodes.build.output.sha } tag: { $template: "v{{ nodes.build.output.sha }}" }Values take the same markers as a node config and resolve once, when the
run finishes, so an output can read any step’s output, the run’s
input, or vars. A value with no marker is a constant.
A pipeline declares the same block at the top of the document, with
${{ … }} holes over the scope a job’s if: reads:
outputs: sha: "${{ steps.build.output.sha }}" image: "registry/app:${{ steps.build.output.sha }}"
jobs: build: outputs: sha: sha steps: - name: build run: 'spool output set sha=$(git rev-parse HEAD)'A value that is one whole hole keeps its JSON type; a hole with text
around it produces a string. The block reads only what a job exposes,
so naming a key the job’s outputs: doesn’t list is rejected at submit.
Returning whichever branch ran
Section titled “Returning whichever branch ran”A child that routes has a branch that didn’t run, and a skipped step’s output is null. Reading straight through it fails the run at the end, after the work is paid for, so check the status first:
outputs: who: $expr: "nodes.fast.status == 'success' ? nodes.fast.output.who : nodes.slow.output.who"Reading them from the parent
Section titled “Reading them from the parent”Declared outputs land beside the per-node status map. The child’s output
is {"nodes": {…}, "sha": "abc123"}, so a graph workflow node reads
nodes.<child>.output.sha, and a workflow: job’s outputs: picks
sha: sha as in the deploy job above. Downstream jobs then read
${{ steps.deploy.output.sha }}.
nodesis reserved for the status map; a definition declaring an output by that name is refused when you save it.- Only a successful run produces outputs. A failed run ends with no output at all, so gate on the child’s success before reading them.
- An output whose expression can’t be computed fails the run, naming the output. A caller gets every declared output or a failure.
- Output expressions compile when the definition is saved, so
spool workflow checkcatches a typo before a run spends an hour getting to the end.
A hyperspool/script/v1 child has no outputs: block; its output is
the flat object described above.
From a script: call and spawn
Section titled “From a script: call and spawn”A script has two ways to start another run. Both are recorded in its history, so a replay doesn’t start a second one:
spool call -w <name>(orspool call <file|->for a graph, a pipeline or a script) waits for the run it starts and prints its output. The called run is listed with the caller as its parent, and ends when the caller ends.spool spawn <file>starts a run and prints its id without waiting. A file whoseversion:ishyperspool-graph/v1runs as a graph, a.yamlor.ymlfile as a pipeline, and anything else as a script;--as pipeline|graph|scriptoverrides the guess. The spawned run carries on by itself after the caller ends.
Both kinds of run go through admission as a submit would, so the
document’s own concurrency: and throttle: apply. A busy skip
group, a full queue or a drop throttle fails the spawn:
error: spawn returned status 422 Unprocessable Entity: spawn refused: the spawned pipeline was refused: concurrency group "deploy" is busy and its policy is skipDon’t use spool submit inside a job. A job has no server address or
token of its own, so the command refuses, and a run started that way
would have no link to the one that started it.
Replay
Section titled “Replay”A child is started once and its outcome recorded in the parent’s log. On the parent’s replay the start is matched to the existing child and the recorded outcome resolves the wait, so the parent survives a restart without starting the child again. The child’s own log replays on its own terms.
Script fan-out is a separate mechanism: spool activity submit enqueues
activities and spool activity wait gathers them, and the run page
lists them under Activities. Use that for hundreds of units of work
from one script, and a child run when each unit should be its own run.
See script commands.