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.

Read the docs

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.

pid 41823dagr.db · wal0/5 steps journaled
order.yaml
name: order
steps:
  load-order:
    kind: task
    runtime: http
  charge:
    needs: [load-order]
    runtime: bun
    uses: ./charge.ts:handler
    retries: { max: 3, backoff: exponential }
  reserve-stock:
    needs: [load-order]
    runtime: bun
  ship:
    needs: [charge, reserve-stock]
    runtime: shell
    with: { command: shipctl, args: [${{ steps.charge.output.id }}] }
  notify:
    needs: [ship]
    runtime: http
load-orderHTTPchargeBUNreserve-stockBUNshipSHELLnotifyHTTP
journalappend-only

waiting for the first claim…

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.
Fan out over what a step produced
each:
  kind: task
  runtime: bun
  uses: ./handle.ts:one
  needs: [list]
  foreach: ${{ steps.list.output.ids }}
  with:
    id: ${{ item }}
Arm a schedule explicitly — register never arms one
trigger:
  kind: cron
  cron: "0 2 * * *"
  timezone: America/Los_Angeles
  overlap: skip
  catchup: false

# then: dagr activate nightly-report

runtimes

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.

runtimeentrypointruns asnote
bunuses: ./file.ts:exportChild process via bun runHandler gets ctx.input, ctx.idempotencyKey, ctx.attempt, ctx.runId.
pythonuses: module:functionChild process via uv runDeclared deps must be on the executor's allowlist.
shellwith.command + with.argsChild process, no shellOne argv slot per argument. Commands and env are allowlisted.
httpwith.url + with.methodfetch against allowlisted hostsThe one runtime safe to hand a tenant who authors their own definitions.
A worker only claims steps whose runtime it lists
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.

fly.io
fly launch --copy-config --no-deploy
fly volumes create dagr_data --size 1
fly secrets set DAGR_UI_PASSWORD=…
fly deploy

Write the graph. Let it survive the restart.

Source on GitHub