bun · sqlite · one node
Kill the process.
The run survives.
dagr defines DAG workflows in YAML and executes them durably on a single SQLite file. Every state transition is written down. Completed steps are memoized and never run a second time, and a step in flight when the process dies holds a lease that expires — the reclaim sweep re-queues it on restart.
The kill switch
A run in flight, a process you can end, and a journal that outlives it. Kill it mid-step, restart, and watch which steps decline to run twice.
Kill it while a step is in flight.
A simulation of the engine's own sequence: claim, lease, heartbeat, reclaim, memoize.
definition language
Four kinds, eleven step keys, no escape hatch
The vocabulary is small on purpose, and it is specified rather than implied — the normative grammar is locked by golden fixtures. ${{ … }} is a small owned expression language read by a hand-written parser. Nothing in a definition reaches eval.
step kinds
- task
- Run a handler on a named runtime. The only kind Core requires.
- sleep
- A durable timer. The run parks; the process may exit and come back.
- waitForEvent
- Park until a matching event or signal arrives, then continue.
- call
- Run another registered definition as one step of this one.
what a step may carry
- needs
- Status-aware dependencies — succeeded by default, or name the statuses.
- foreach
- Fan out one step over a list produced upstream.
- if / when
- Gate a step once its needs are met; false marks it skipped.
- retries
- max, backoff, initial, cap, jitter. max counts retries after the first attempt.
- fatal_errors
- Error classes that skip the remaining attempts outright.
- continue_on
- Excuse a step's failure from failing the run.
- on_exit
- Always runs at run end, whatever the outcome. The cleanup hook.
- secrets
- Declare the names a step may read. An undeclared reference fails validation.
each:
kind: task
runtime: bun
uses: ./handle.ts:one
needs: [list]
foreach: ${{ steps.list.output.ids }}
with:
id: ${{ item }}trigger:
kind: cron
cron: "0 2 * * *"
timezone: America/Los_Angeles
overlap: skip
catchup: false
# then: dagr activate nightly-reportruntimes
Four runtimes, none of them in-process
Handler code never runs inside the engine, so a handler that crashes, spins, or overruns its timeout cannot take the control plane down with it. Nothing is registered by default: a host serves exactly the runtimes it passes to createEngine, because each one widens what a definition can reach.
const engine = createEngine({
store,
executors: [
bunExecutor({ cwd: './jobs' }),
pythonExecutor({ cwd: './jobs', depsAllowlist: ['httpx'] }),
shellExecutor({ commandAllowlist: ['git'], envAllowlist: ['PATH'] }),
httpExecutor({ allowlist: ['api.stripe.com'] }),
],
})
const worker = engine.worker({ runtimes: ['bun', 'http'] })
await worker.start()scope
What it will not do
dagr is single-node and single-process by design. Concurrency inside the process is real — max_concurrent_steps governs what is in flight, and I/O-bound steps overlap freely — but writes serialize through one connection. The limits are worth knowing before you adopt it.
One process per database file
The serializing queue is in-process. A second process against the same file contends for SQLite's write lock and surfaces as SQLITE_BUSY. There is no leader election and no failover.
A child process is a crash boundary, not a sandbox
shell, bun and python run code on the worker host. A shared install where tenants author their own definitions should register http only, or run one worker per tenant inside real isolation.
No fair-share scheduler
concurrency bounds a workflow against itself inside its workspace. Nothing bounds a workspace against the install.
A workspace is the tenancy boundary, and scope is a mandatory filter on reads rather than a convention: two workspaces that both name a workflow deploy cannot queue, wake, or read each other.
surfaces
Three ways in, one engine
CLI
Everything the engine can do, from a terminal.
- dagr validate order.yaml --mermaid
- dagr start order --input '{"id":1}'
- dagr ls / get / logs
- dagr rerun <runId> --from failed
- dagr serve --port 14311
HTTP API
The same operations over the wire, workspace-scoped on every route.
- POST /definitions
- POST /runs
- GET /runs/:id/tree
- GET /runs/:id/events (SSE)
- POST /signals · POST /events
Dashboard
A builder and a control room for the runs the engine is holding.
- YAML editor with live validation
- Run canvas, step timeline, logs
- Pause, resume, cancel, force a step
- Schedules and event triggers
- Basic auth in the shipped container
deploy
One machine, one volume
The repo ships a single container: engine, worker and HTTP API bound to loopback, with the dashboard in front of them. Set DAGR_UI_PASSWORD and the dashboard is behind basic auth; leave it unset and it is open, which is fine on a laptop and not on the internet.