GNL
Docs menu
Core · Free@gnldev/durable

Durable Workflows

Runs multi-step workflows durably under the same runId; suspend/resume is safe, retry() provides declarative retries (with a journal-backed counter), and it's exposed as REST via the /workflows/:name/run endpoint.

What it's for / when to use it#

Used to model multi-step workflows that go beyond a single agent call (e.g. first create a record, then check payment, then wait for approval and send a notification). Each step is recorded in the journal separately; if the process crashes mid-flight or a step (sleep/waitFor) suspends, calling again with the same runId does NOT re-run the completed steps (replay) — it simply continues from where it left off.

Concrete scenario: in an order flow, a "wait for payment approval" step might take hours. Even if the process shuts down during that time, calling it again with the same runId returns suspended if the payment step is still pending; once the condition is met, the step actually progresses and moves on to the next steps.

Setup / import#

The workflow() builder from the @gnldev/workflow package is used to define a workflow; the @gnldev/durable createGnl registry is needed to run/register it (the Workflow class already satisfies the WorkflowLike structural interface that createGnl expects — no separate adapter is needed).

import
import { workflow, step, retry } from '@gnldev/workflow';
import { createGnl } from '@gnldev/durable';

Step-by-step usage#

1) Define the workflow as a sequence of steps using workflow() and step(id, run):

workflow definition
const myWorkflow = workflow<{ email: string }>()
  .then(step('onboard:create-account', async (input) => {
    // ... create the account
    return { ...input, accountId: 'acc_1' };
  }))
  .then(step('onboard:send-welcome', async (input) => {
    // ... send the welcome email
    return { ...input, welcomed: true };
  }));

2) Register the workflow by name under createGnl's workflows field:

registration
const config: CreateGnlConfig = {
  storage,
  agents: { /* ... */ },
  workflows: { onboard: myWorkflow },
};
const gnl = createGnl(config);

3) Run it with runWorkflow, passing the same runId — even if the process is interrupted, calling again with the same runId skips completed steps and continues from where it left off:

running it
const res = await gnl.runWorkflow('onboard', input, { runId: 'wf-1' });
// POST /workflows/onboard/run with the same runId -> resumes where it stopped

On the REST side, this is exposed with the same contract via the POST /workflows/:name/run endpoint automatically generated by @gnldev/server — the body expects { runId?, input }; if the given runId already has a trace in the journal, the request is automatically treated as a resume (the budget gate is skipped in that case — only genuinely new work is gated). The step list of registered workflows can be introspected via GET /workflows.

4) Use retry(step, policy) to retry a step declaratively — it takes attempts (total attempts including the first run), an optional backoffMs (a fixed ms value or a function computed from the attempt index), and an optional fallback step to run once all attempts are exhausted. The wrapped step is a drop-in — it carries the same id and is used like an unwrapped step inside then/branch/parallel:

declarative retry with retry()
import { retry, step } from '@gnldev/workflow';

const chargeStep = retry(
  step('order:charge', async (input) => chargeCard(input)),
  {
    attempts: 3,
    backoffMs: (attempt) => attempt * 500, // 500ms, 1000ms, ...
    fallback: step('order:charge-fallback', async (input) => ({ ...input, charged: false })),
  },
);

const orderWorkflow = workflow<{ orderId: string }>().then(chargeStep);

The attempt counter is written to the journal under the key `${runId}:wf:${id}:attempts` — on crash-resume the counter does NOT restart from zero, so the "total N attempts" guarantee holds regardless of process deaths. A step that was already exhausted in a previous run goes straight to fallback (if any) on resume — it does not retry N more times. A suspension (WorkflowSuspended, e.g. sleep/waitFor) doesn't count as a failure and propagates outward exactly as-is without consuming an attempt. fallback is journaled separately under the step's own key — if fallback runs and then crashes, it does not re-run on resume.

API reference#

fnworkflow

workflow<Input>() — starts a workflow definition; .then(step) chains the ordered steps.

fnstep

step(id, run) — one journaled step. The id is what a resume matches on, so it is part of the contract, not a label.

fnwaitForResume

Typed human-in-the-loop suspension: the workflow stops and continues from runResumable({ resume }) with the value the operator supplied.

fncancelWorkflowRun

Durable cancel — the decision is journaled, so a worker that comes back later still sees it.

fnforkWorkflowRun

Copies a workflow run up to a step into a new runId, the workflow-level counterpart of forkRun.

fncreateGnl

Sets up the agent + workflow registry; the returned object's runWorkflow/listWorkflows methods run/introspect workflows.

typeWorkflowLike

The structural interface that can be given to createGnl's workflows field — build()/run()/optional runResumable(). @gnldev/workflow's Workflow class already satisfies this.

typeWorkflowMeta

The introspection shape returned by listWorkflows(): { name, steps: { id, kind }[] }.

typeWorkflowRunResult

The result of runWorkflow(): runId, output, suspended/paused flags, stepId/reason, and per-step outputs.

typeRunOptions

Call options for gnl.run()/stream() (runId, prompt/messages, context, etc.) — runWorkflow additionally uses its own { runId?, maxSteps? } option.

fnretry

(step, policy: RetryPolicy) → Step. Wraps a step with a declarative retry policy — drop-in (same id); the attempt counter is journaled (does not reset on crash-resume), suspension doesn't consume an attempt, and fallback is journaled separately.

typeRetryPolicy

{ attempts, backoffMs?: number | ((attempt) => number), fallback?: Step } — attempts is the total number of attempts including the first run.

classRetryExhaustedError

All attempts were exhausted and no fallback was given — carries { stepId, attempts, cause }.

retry + crash-resume
Having the attempt counter in the journal means the "total N attempts" guarantee holds even if the process crashes in the middle of a long-running workflow: resume continues from the attempt count it was at, it doesn't start over. If the retried step itself is side-effecting (e.g. an external API call), it should be idempotent or use a durable tool internally — the INSIDE of the attempts is not journaled, only the counter and the final output are.
Tip
Only rely on the input and ctx parameters inside step functions; base logic on the journaled output rather than external (closure) state — otherwise inconsistencies can arise between steps replayed during resume and steps that genuinely re-run live.
Suspend steps
Workflows containing sleep/waitFor need runResumable() instead of run() — plain run() throws the suspend signal (WorkflowSuspended) as an error. createGnl.runWorkflow automatically prefers runResumable when the workflow has one.

Related pages#