Workflows
A DAG of steps, each one a job. Steps read their dependencies’ output, retry individually, and can fan out.
Overview
A workflow is a DAG of steps, where each step calls a backend route and can read the output of the steps it depends on. Use it when periodic work is more than one call: extract-then-load, fan-in aggregation, or anything where a later step needs an earlier step’s result.
Every step is an ordinary atomic job, so the whole jobs lifecycle applies per step: retries, timeouts, spky jobs visibility, kill, and crash recovery. A workflow adds the ordering, the data passing, and the run-level view.
That declaration describes this shape:
Parallelism is implicit in the graph. There is no parallel: keyword. Anything whose dependencies are satisfied runs as soon as they are, so both roots start together and notify/archive start together.
A workflow with a schedule: runs on a clock exactly like a schedule does, same cron/interval syntax, same spky schedules pause / trigger. Omit schedule: for a workflow you only ever trigger by hand.
Passing data between steps
A step receives the JSON body each of its direct dependencies responded with, under payload.steps. Only direct dependencies are injected, so a long chain never accumulates an ever-growing payload, if notify needs something extract-orders produced, pass it through transform’s response.
A step’s output is its HTTP response body. Keep it small (identifiers, counts, paths) rather than the data itself. Bodies over 64 KB are replaced with a marker and the step still succeeds, so a large response fails quietly as missing input to the next step rather than as an error.
The output is copied onto the step row when the step finalizes, and dependants read it from there rather than from the job row. That is what makes job retention safe: deleting finished job rows can never break a chain, however long the workflow runs.
Per-step retries, timeouts, and backends
retry and timeout work per step exactly as they do for a one-shot job, because a step is one: a step counts as failed only once its job row has exhausted its retries. While it is retrying, the workflow simply waits, dependents stay blocked, and nothing is marked failed.
Steps may target different backends, in which case each step’s job lands in its own backend’s outbox table.
When a step fails
onFailure decides the rest of the run:
| Prop | Type | Default | Description |
|---|---|---|---|
| halt | string | halt | Fail the run and skip every step that has not started yet. |
| continue-independent | string | - | Skip only the branch below the failure; branches that do not depend on it keep running. The run still ends failed. |
Either way, steps already in flight are left to finish rather than abandoned. The run terminalizes once nothing can move any more. That means a failed run may still be doing useful work for a moment, and its final status arrives when the last in-flight step lands.
Fanning out a whole workflow
forEach works at the workflow level too: one complete run per row, with the row available to every step as payload.input.
concurrency is then per row as well, so a tenant whose report is still generating doesn’t block anyone else’s. The Schedules page covers the three policies.
Definitions in their own files
A DAG is the kind of thing that outgrows a shared manifest quickly, so workflows: entries can live in their own files. Paths are relative to the manifest that names them.
Watching a run
show renders a run as a diagram and watch renders the same diagram live, refreshing as steps advance:
In watch, the selected step’s payload, output, and errors appear underneath the graph. Use ↑↓/jk to move, K to kill the run, r to refresh now, q to quit.
When something breaks, the same view tells you where and why:
Piped output switches to plain ASCII automatically, so it pastes cleanly into a ticket or a CI log; --ascii forces it. For scripting, --json gives the same state as data:
On a narrow terminal the renderer falls back to an indented dependency list instead of truncating the graph, so it stays readable at any width.
How it advances
A workflow run has no long-lived coordinator process. Every advancement reads the step rows, decides what changed, and writes the transitions back under guards, which is what makes it safe to run restarts and duplicate events through:
- Roots are not special. Every step starts
blockedand is promoted the same way, so spawning and advancing share one code path. - A step is dispatched exactly once. The
blocked → readypromotion is a compare-and-swap; whichever pass wins it is the one that creates the job. Two concurrent advancements can’t double-dispatch. - A join waits for all of its dependencies to succeed. Not “most”, and not “all finished”: a dependency that failed means the join can never become ready, so it is skipped instead.
- Completion is observed, then verified. The engine reacts to job-completion events, and every 5-second sweep also reconciles against the job rows directly, so a dropped event costs one sweep of latency rather than stalling the run.
- History is pruned, in-flight runs are not. Finished
_00_workflow_runrows (and their step rows, which are always removed together) are subject to the same asymmetric retention as everything else: 24h for a success, 30d for a failure or kill, by default. A run that is stillrunningis never pruned, whatever its age. See Jobs → Retention.
Reference
Workflow fields
| Prop | Type | Default | Description |
|---|---|---|---|
| schedule | object | - | When to run it: { cron } or { every }, plus optional timezone. Omit for a trigger-only workflow. |
| steps | map | (required) | Steps by name. Declaration order is irrelevant, dependsOn decides execution order. |
| onFailure | halt | continue-independent | halt | Whether a failed step stops the whole run or only its branch. |
| concurrency | skip | allow | replace | skip | What a fire does when the previous run (for that forEach key) is still going. |
| forEach.query | string | - | SurrealQL SELECT run each fire; one whole run per row, passed to every step as payload.input. |
| forEach.key | string | id | Row field whose value keys the per-row concurrency check. |
Step fields
| Prop | Type | Default | Description |
|---|---|---|---|
| backend | string | (required) | Name of an apps: entry with an outbox method. |
| route | string | (required) | POST route on that backend. |
| dependsOn | string[] | [] | Steps that must succeed first. Empty means a root; several means a fan-in join. |
| payload | object | {} | Static payload for this step, merged with input and steps. |
| retry.max | integer | 3 | Retries before this step counts as failed. |
| retry.strategy | linear | exponential | linear | Backoff shape between retries. |
| timeout | duration | (backend default) | Per-step HTTP timeout, e.g. 30s. |
Statuses
Run statuses are running, success, failed, and killed. Step statuses add the lifecycle:
| Prop | Type | Default | Description |
|---|---|---|---|
| blocked | step | - | Waiting on a dependency. |
| ready | step | - | Dependencies satisfied and claimed for dispatch. |
| dispatched | step | - | Its job is pending, retrying, or running. |
| success | step | - | Its job succeeded; its response body is the step output. |
| failed | step | - | Its job exhausted its retries. |
| skipped | step | - | An ancestor failed, or the run was killed, so it can never run. |
Validation
spky lint rejects a broken DAG before it can be deployed:
dependsOnnames a step that exists, and no step depends on itself- the graph is acyclic
- each step’s
backendis an app with an outbox method, and itsrouteexists in that backend’s OpenAPI spec - the cadence parses, if the workflow has one
Workflows run on VM and cluster deployments. The Cloudflare (free) plan has no job runner, so the steps would never execute.