Agora
Tooling

Codegen

The build-time code durable generates and publishes. Assembler init hooks write lazy barrels of app/workflows and app/steps so the provider skips its runtime directory scan, and an OpenAPI 3.1 document of the dashboard API lets you generate a typed client for it.

Durable does two kinds of code generation. Neither is required to run a workflow:

  • Discovery barrels. Two AdonisJS Assembler init hooks write typed, lazy barrels of app/workflows and app/steps into .adonisjs/durable/. The provider imports them at boot instead of scanning those directories with readdir.
  • A dashboard API contract. The dashboard serves an OpenAPI 3.1 document of its JSON API, which you can feed to any OpenAPI client generator.

Workflow and step barrels

@adonis-agora/durable/hooks/workflows and @adonis-agora/durable/hooks/steps are Assembler init hooks. They work the same way @adonisjs/core generates its controllers/events/listeners barrels. The dev server, the test runner and node ace build run them, and the file watcher re-runs the generator whenever a file in the scanned directory is added or removed, so the barrels always reflect the directories.

Setup

node ace configure @adonis-agora/durable registers both hooks for you:

adonisrc.ts
export default defineConfig({
  hooks: {
    init: [
      () => import('@adonis-agora/durable/hooks/workflows'),
      () => import('@adonis-agora/durable/hooks/steps'),
    ],
  },
})

It also adds the subpath imports the generated barrels import through to your package.json:

package.json
{
  "imports": {
    "#workflows/*": "./app/workflows/*.js",
    "#steps/*": "./app/steps/*.js"
  }
}

configure never overwrites an imports entry you already define. If you wire the hooks by hand, add these entries too. Without them, the build succeeds but the app fails at boot with ERR_PACKAGE_IMPORT_NOT_DEFINED.

What gets generated

HookScansWritesExport
@adonis-agora/durable/hooks/workflowsapp/workflows.adonisjs/durable/workflows.tsworkflows
@adonis-agora/durable/hooks/stepsapp/steps.adonisjs/durable/steps.tssteps

Each barrel is a map of key to lazy import. Keys drop the workflow / step file suffix, the way controller barrels drop controller:

.adonisjs/durable/workflows.ts
export const workflows = {
  Charge: () => import('#workflows/charge_workflow'),
  // ...
}

How the provider uses them

At boot the provider imports .adonisjs/durable/workflows.js. It awaits each thunk and registers every BaseWorkflow subclass it finds, through the container so constructor injection works. Each class is registered once, even when it's re-exported from several modules. It does the same with .adonisjs/durable/steps.js, serving every @Step / defineStep export by name on the app's transport, and resolves each @Step class through the container as well, on every step execution.

When a barrel file doesn't exist (because the hook isn't registered), the provider falls back to scanning the configured directories at runtime (workflowsPath, default app/workflows; stepsPath, default app/steps). Registration is identical either way. Only a genuine "module not found" triggers the fallback: a barrel that exists but fails to import throws at boot instead of quietly degrading. Setting workflowsPath: false or stepsPath: false in config/durable.ts turns discovery off entirely, barrel included.

The barrels serve the app process's provider. A dedicated worker process started with node ace durable:worker registers its step handlers by scanning every stepsPath directory at runtime and doesn't read the steps barrel.

Options

The default export of each hook module uses the defaults. For a different directory or alias, export the factory (workflowsHook / stepsHook, exported from the same subpaths) with options from your own hook module and register that module instead:

hooks/durable_workflows.ts
import { workflowsHook } from '@adonis-agora/durable/hooks/workflows'

export default workflowsHook({ source: 'app/flows', importAlias: '#flows' })
OptionworkflowsHook defaultstepsHook defaultDescription
source'app/workflows''app/steps'Directory scanned, relative to the app root, or an array of directories (see Several directories).
importAlias'#workflows''#steps'Import alias each barrel entry uses. Needs a matching package.json imports entry. Single source only: passing it with several directories throws.
output'.adonisjs/durable/workflows.ts''.adonisjs/durable/steps.ts'Where the barrel is written.

Keep the default output path

The provider always imports the barrel from the fixed .adonisjs/durable/workflows.js / .adonisjs/durable/steps.js path. If you change output, the provider won't find the barrel and silently falls back to the runtime directory scan.

Several directories

A modular app with one folder per bounded context passes an array to source. Each hook still writes one barrel at the same output path, so the provider finds it as before:

hooks/durable_workflows.ts
import { workflowsHook } from '@adonis-agora/durable/hooks/workflows'

export default workflowsHook({ source: ['app/orders/flows', 'app/billing/flows'] })
hooks/durable_steps.ts
import { stepsHook } from '@adonis-agora/durable/hooks/steps'

export default stepsHook({ source: ['app/orders/steps', 'app/billing/steps', 'app/engine'] })

Register those modules in adonisrc.ts in place of the package defaults. With several directories the barrel is an array with one entry per root. Each module is keyed by its path from the app root and imported through a path relative to the barrel, so you don't need a package.json imports entry per directory:

.adonisjs/durable/workflows.ts
export const workflows = [
  {
    root: 'app/orders/flows',
    modules: {
      'app/orders/flows/place_workflow.ts': () => import('../../app/orders/flows/place_workflow.js'),
    },
  },
  {
    root: 'app/billing/flows',
    modules: {
      'app/billing/flows/charge_workflow.ts': () => import('../../app/billing/flows/charge_workflow.js'),
    },
  },
]

The file watcher regenerates the barrel when a file is added to or removed from any of the directories. A one-element array produces exactly the single-directory barrel, and an empty array throws: to turn a barrel off, remove its hook.

The duplicate rule from the runtime scan applies here too. If two roots define the same workflow name and version, or the same step name, boot fails with a DuplicateDiscoveryError that names both module paths. Keep workflowsPath / stepsPath in config/durable.ts set to the same directories, so the runtime fallback (and durable:worker) agrees with the barrel.

A typed client for the dashboard API

Durable doesn't ship a generated or prebuilt client for the dashboard's REST API. What it ships is the contract: the dashboard serves a hand-written OpenAPI 3.1 document of its JSON API, and you can generate a client from it with any OpenAPI tool. A spec in the durable repo asserts the document matches the registered routes, so a handler change can't silently drift from it.

Getting the document

The document is served at GET <path>/api/openapi.json, which is /durable/api/openapi.json with the default path:

curl -H "Authorization: Bearer $DURABLE_DASHBOARD_TOKEN" \
  http://localhost:3333/durable/api/openapi.json > durable-openapi.json

The route sits behind the same authorize gate as the rest of the API, so send the dashboard token (or a session) like any other API call. Its servers entry is the mounted API base (/durable/api, or <path>/api), so paths in the document are relative to it (/runs, /runs/{id}/retry, …).

The document isn't exported as a library function. The served route is the way to get it, and committing the downloaded durable-openapi.json keeps client generation reproducible offline and in CI. Diffing a freshly fetched copy against the committed one after an upgrade shows exactly what changed in the wire contract.

Generating types with openapi-typescript

npm i -D openapi-typescript
npm i openapi-fetch
npx openapi-typescript ./durable-openapi.json -o ./types/durable-api.d.ts

The document defines no operationIds, so a path-keyed client such as openapi-fetch fits better than a generator that names methods after operations:

types/durable.ts
import createClient from 'openapi-fetch'
import type { paths } from './durable-api'

export const durable = createClient<paths>({ baseUrl: '/durable/api' })
// List failed runs (typed RunSummary[])
const { data } = await durable.GET('/runs', { params: { query: { page: 1, size: 50 } } })

// Run detail: run + step timeline + children
const { data: detail } = await durable.GET('/runs/{id}', { params: { path: { id: runId } } })

// Retry it
if (detail?.run?.status === 'failed') {
  await durable.POST('/runs/{id}/retry', { params: { path: { id: runId } } })
}

What the generated types cover:

  • Fully shaped: the components RunStatus, RunSummary, Checkpoint, RunResult and Error, the GET /runs page (runs, meta, statuses), the GET /runs/{id} detail (run, timeline, children, fans, childCount) and its GET /runs/{id}/fans/{group} page, path and query parameters, and request bodies.
  • Conditional run detail: GET /runs/{id} declares an If-None-Match header parameter and a bodiless 304. The 200 carries an ETag with Cache-Control: private, no-cache, so a poller that sends the last ETag back pays nothing for an unchanged run; with openapi-fetch, check response.status === 304 before reading data.
  • Non-JSON routes: GET /metrics is Prometheus text (text/plain, format 0.0.4), not JSON — fetch it as text or point a scraper at it (see Metrics).
  • Other 0.51 routes: GET /runs/{id}/events/{key} returns { value } (the latest value the run published with ctx.setEvent, value absent when never published), and POST /webhooks/{token} delivers a ctx.webhook() callback (404 for an unknown or no-longer-waiting token, 413 for an oversized body).
  • Loosely typed: many action and fleet responses (retry, cancel, bulk, schedules, health, workers, topology, compat, …) are declared as a plain object, so you get the route and its parameters typed but should narrow the response yourself. The dashboard page documents what each returns.
  • Filters beyond page / size on GET /runs (the filter[...] envelope and flat status / workflow / tag / … params) are described in prose rather than as declared parameters. Pass them as extra query keys.

GET /runs/{id}/stream is a Server-Sent Events endpoint, so read it with EventSource (see Live-tailing one run) rather than through the generated client.

It's still a control surface

A generated client talks to the same routes the console does, including retry, cancel, bulk and the human-in-the-loop verbs. A browser client in your own frontend needs the dashboard to accept its requests: configure dashboardAuth or a custom authorize that recognises your app's session rather than shipping the bearer token to the browser. Mutating requests are also subject to the dashboard's cross-site rejection and session-role checks.

For server-side code in the same app, you don't need HTTP at all. The handlers behind the routes (listRuns, getRun, getRunEvent, retryRun, cancelRun, deliverWebhook, metricsText, …) are exported from @adonis-agora/durable/dashboard, and runGateway from @adonis-agora/durable/services/main is the role-agnostic read/control surface. See Calling the API directly.

On this page

Partner

Your product as the official integration for Durable, right here.

Become a partner →