Defining workflows
The *.workflow.tsx convention, workflow schemas, execute factories, checkpointed steps, child workflows, and replay rules.
A workflow file is discovered by suffix: *.workflow.tsx. It exports one workflow({...}) definition and a default factory that receives AppContext and returns the workflow executor.
Anatomy
// apps/api/workflows/notes.summarise.workflow.tsx
import { workflow, step } from '@voltro/workflow'
import { Effect, Schema } from 'effect'
import type { AppContext } from '@voltro/runtime'
export const SummariseNote = workflow({
name: 'notes.summarise',
payload: { noteId: Schema.String },
success: Schema.Struct({ summary: Schema.String }),
idempotencyKey: ({ noteId }) => `notes.summarise:${noteId}`,
})
const buildExecute = (ctx: AppContext) =>
({ noteId }: { noteId: string }, _executionId: string) =>
Effect.gen(function* () {
const note = yield* step({
name: 'load-note',
input: { noteId },
success: Schema.Struct({ id: Schema.String, body: Schema.String }),
execute: Effect.tryPromise(() => loadNote(ctx.store, noteId)),
})
const summary = yield* step({
name: 'summarise-with-llm',
input: { noteId },
success: Schema.String,
execute: summariseWithLlm(note.body),
})
yield* step({
name: 'save-summary',
input: { noteId },
success: Schema.Void,
execute: Effect.tryPromise(() => saveSummary(ctx.store, noteId, summary)),
})
return { summary }
})
export default buildExecuteTwo exports matter:
workflow({...})— the durable definition.payload,success, optionalerror, and optionalidempotencyKeyare read by codegen and the runtime.default— a build-execute factory,(ctx: AppContext) => (payload, executionId) => Effect. The CLI callsdefinition.toLayer(execute)during registration.
If the default export is not a function, discovery skips the file with a "missing default-export build-execute factory" warning.
Schema fields
export const ImportCustomers = workflow({
name: 'customers.import',
payload: {
uploadId: Schema.String,
dryRun: Schema.optional(Schema.Boolean),
},
success: Schema.Struct({
imported: Schema.Number,
skipped: Schema.Number,
}),
error: Schema.Union(InvalidCsv, ImportProviderDown),
idempotencyKey: ({ uploadId }) => `customers.import:${uploadId}`,
messages: {
signals: {
approval: Schema.Struct({ approved: Schema.Boolean }),
},
updates: {
approve: {
payload: Schema.Struct({ decision: Schema.Boolean }),
success: Schema.Struct({ accepted: Schema.Boolean }),
},
},
},
})payload is the start input. success is the resolved output. error is the typed failure channel. idempotencyKey deduplicates concurrent or repeated starts with the same logical input. messages is optional codegen metadata; it emits WorkflowSignals, WorkflowUpdates, WorkflowQueries, and WorkflowMessages type maps, while runtime validation still happens at awaitSignal(...) / awaitUpdate(...).
Step boundaries
step({...}) wraps @effect/workflow's Activity.make. It is the checkpointed unit that is journaled by the workflow engine and recorded into _voltro_workflow_run_steps.
const customer = yield* step({
name: 'fetch-customer',
input: { customerId },
success: Customer,
execute: Effect.tryPromise(() => crm.fetchCustomer(customerId)),
})The optional input field is not passed to the executor. It is persisted for inspection, truncated to a dashboard-safe size, and shown beside output/errors in the run timeline.
Plain Effect composition is still useful:
const normalised = normaliseCustomer(customer)
const enriched = yield* Effect.succeed(addDerivedFields(normalised))But it is not a durable activity boundary by itself. If it does external I/O, writes to storage, charges a card, sends an email, or calls an LLM, put that work inside step.
Deterministic replay
The workflow body can be replayed by the engine. The structure of checkpointed steps must be stable for the same payload.
OK:
const note = yield* step({ name: 'load-note', success: Note, execute: loadNote(noteId) })
if (note.archived) {
yield* step({ name: 'notify-archived', execute: notifyArchived(note.id) })
} else {
yield* step({ name: 'summarise', execute: summarise(note.body) })
}The branch depends on a checkpointed value.
Not OK:
if (Math.random() > 0.5) {
yield* step({ name: 'a', execute: doA })
} else {
yield* step({ name: 'b', execute: doB })
}If time, randomness, or external state influences structure, capture it in a step first:
const choice = yield* step({
name: 'choose-branch',
success: Schema.Boolean,
execute: Effect.sync(() => Math.random() > 0.5),
})Starting from the client
Codegen synthesises an RPC for each workflow. To start it from React, use useWorkflow(...); the call returns a run handle immediately and the durable work continues in the workflow engine:
import { useWorkflow, useWorkflowRun } from '@voltro/client'
const startImport = useWorkflow<{ uploadId: string }>('app', 'customers.import')
const run = await startImport.start({ uploadId })
const { run: liveRun } = useWorkflowRun('app', run.id)Use liveRun.status and liveRun.output to render progress/result state. Waiting for the success payload is explicit on the server with ctx.workflows.wait(...); UI code should usually subscribe to the run row instead of blocking the interaction.
Child workflows
Inside a workflow body, start child runs through ctx.workflows.child(...). The child gets its own durable run, step history, events, cancellation controls, and dashboard detail page. Voltro persists the parent execution id and parent-close policy before submitting the child start, so the relationship survives cross-runner execution in a cluster.
const child = yield* Effect.promise(() =>
ctx.workflows.child('documents.embed', { documentId }, {
parentClosePolicy: 'cancel',
}),
)
const result = yield* Effect.promise(() => ctx.workflows.wait(child))Fan out with normal Effect concurrency:
const children = yield* Effect.all(
documents.map((doc) =>
Effect.promise(() =>
ctx.workflows.child('documents.embed', { documentId: doc.id }, {
parentClosePolicy: 'abandon',
}),
),
),
{ concurrency: 8 },
)Parent-close policies:
| Policy | Behavior |
|---|---|
cancel |
Default. Interrupt open children when the parent closes. |
terminate |
Interrupt open children and record a hard parent-close action. |
abandon |
Leave the child running when the parent closes. |
The dashboard shows child runs, their parent execution id, and the selected policy. ctx.workflows.wait(child) accepts the run handle directly when the parent needs the child's success/failure snapshot.
Tenant and subject
Workflow starts persist the starter subject, trace id, source, parent execution id, and parent-close policy in _voltro_workflow_start_contexts. Whichever runner first executes the workflow loads that context before building the executor AppContext.
Still include tenant/user ids that the business process must enforce in payload, validate them in the first step, and scope store reads/writes deliberately. Payload data is replay-safe and makes authorization decisions auditable across retries and deploys.
Anti-patterns
- Using
inputinworkflow({...}). The current API ispayload. - Assuming every
yield*is a persisted step. Usestep({...})for checkpoints. - Starting workflows through
useMutation(...). UseuseWorkflow(...); workflows are durable runs, not optimistic writes. - Branching step structure on randomness or live external state. Capture the value in a step first.
- Long-lived workflows without payload versioning. A run resumed after a deploy uses the current workflow code.