You don’t need this to build workflows - the SDK handles all of it. Read it to
understand what your runner is doing.
How Duraton drives a workflow
Duraton never holds your workflow in memory. It makes progress by sending your runner an invoke over its socket, once per step, sending along the results of every step that has already completed. Your handler runs from the top each time: completed steps return their saved result, and the first unfinished step does real work and reports back. Duraton saves that result and calls again, until the handler returns. This is why a runner is stateless and a run survives a Duraton restart: all progress lives in Duraton’s store and is replayed to the runner on each call. A pass ends in one of two ways. The handler returns, and Duraton marks the run complete; or the handler reaches work it has not done yet, and Duraton records what it discovered, does or schedules that work, and invokes again. A step that is already running - a sleep, a wait, a child run - comes back reported as in progress, and your handler waits on it rather than starting it twice. Steps are matched across passes by their id, which is why step ids must be stable and the handler must be replay-deterministic. Steps covers the rules that follow from that, including how a reused id inside a loop is disambiguated.hashStepId in the SDK
(reference) computes the key a given step id is stored under, if you ever
need to correlate one yourself.
Endpoints
Logs
A pass carries back the structured lines your handler emitted viactx.log, on every status
(200, 206 and 500 - lines written before a throw still ship), so a log is never lost to the
path a pass took.
Because the handler body re-runs on every pass, a handler-level log would re-emit each time. Duraton
deduplicates it so it persists once, and keys an in-step log on that step’s attempt, so a retried
step’s logs stay distinct per attempt. Read them back via
GET /runs/{id}/logs.
Routing ({app, runner?})
A run is owned by an app and executed by one of that app’s registered runners (an app may have many).
The runner id is the routing handle:
- Anycast (no
runner): each invoke goes to any one registered runner of the app. Runners are stateless and the full step memo is resent every invoke, so different passes may safely hit different replicas. - Pinned (
runnerset on the event): routed only to that runner id, invoke after invoke.
ctx.runner carries the pin to your handler (empty for anycast).
Connect transport (WebSocket)
Your runner dials Duraton over a WebSocket (GET /connect) and receives invokes on that socket, so it
needs no inbound address - it works for an agent on a node behind NAT. In the SDK this is
connect({ url, app, runner?, workflows }).
The stable runner id is part of the handshake, so a connected runner is pinnable by
{app, runner}. A dropped socket is detected by heartbeat and the runner is evicted from routing until
it reconnects; the SDK reconnects automatically. See connect().
Status codes
A response body is capped at 1 MiB; a larger one fails the pass rather than truncating silently.
When a run fails terminally and its workflow registered
onFailure: true, Duraton marks the run
failed and invokes the onFailure handler as a separate follow-on run. The failed run is retained:
queryable via GET /runs?status=failed and redrivable via POST /runs/{id}/replay. See
Retries.
Protocol version
Duraton and runner share a single integer wire version (currently1). Each side advertises it and
checks the peer’s in the Connect handshake, so a breaking wire change fails loudly instead of
misparsing.
The rule is lenient on absence, strict on a present mismatch: a peer that sends no version is
assumed compatible, so the field is additive and never breaks an older peer, but a version that is
present and differs is rejected: the socket closes. The SDK sets
this for you - you only encounter it if a runner and Duraton are on incompatible releases.