$workflow
$workflow
$workflow adds command boundaries, diagnostics, history, snapshots, restore,
retry, and repeat on top of $machine.
Use $machine when you only need legal modes and transitions. Use $workflow
when external work needs to be run, inspected, repaired, repeated, or handed to
another process as JSON.
Create a Workflow
Inject $workflow and assign the workflow to a controller or scope property:
app.controller('DocsCtrl', function ($workflow) {
this.build = $workflow({
id: 'docs-build',
initial: 'idle',
data: {
status: 'idle',
output: '',
},
transitions: {
idle: {
start(data) {
data.status = 'running';
return 'running';
},
},
running: {
complete(data, output) {
data.status = 'complete';
data.output = output;
return 'complete';
},
fail(data, reason) {
data.status = reason;
return 'failed';
},
},
},
commands: {
build({ workflow, data, input }) {
workflow.send('start');
data.output = String(input);
workflow.send('complete', data.output);
return {
ok: true,
output: {
file: data.output,
},
};
},
},
});
});
workflow.current is the current mode. workflow.data is reactive data for
templates. workflow.run(name, input) is the explicit boundary for work that
can succeed, fail, or be retried.
Commands may be synchronous or async. run(), retry(), and repeat() always
return a promise that resolves to a normalized WorkflowCommandResult.
Register a Named Workflow
Use module.workflow(name, config) when a workflow should be injectable by
name:
app.workflow('docsWorkflow', {
id: 'docs',
initial: 'idle',
data: {
runs: 0,
},
transitions: {
idle: {
start(data) {
data.runs += 1;
return 'running';
},
},
},
});
app.controller('DocsCtrl', function (docsWorkflow) {
this.workflow = docsWorkflow;
});
You can make workflow registration resumable and environment-driven by passing a config factory:
function docsWorkflowConfig(buildConfig) {
return {
id: buildConfig.workflowId,
initial: buildConfig.initialMode,
data: {
runs: 0,
status: buildConfig.initialMode,
},
transitions: {
idle: {
start(data) {
data.runs += 1;
data.status = 'running';
return 'running';
},
},
},
commands: {},
};
}
docsWorkflowConfig.$inject = ['buildConfig'];
app.workflow('docsWorkflow', docsWorkflowConfig);
Named workflows are DI singletons for an injector. Observing scopes can be destroyed without destroying the workflow instance.
Command Diagnostics
Commands return a WorkflowCommandResult, or they can throw. Thrown values are
converted to structured diagnostics and partial data mutations are preserved:
const result = await workflow.run('publish', 'index.html');
if (!result.ok) {
for (const diagnostic of result.diagnostics) {
console.warn(diagnostic.code, diagnostic.message);
}
}
Diagnostics are safe to serialize:
const diagnosticsJson = JSON.stringify(workflow.diagnostics);
Snapshot And Restore
Snapshots are the JSON handoff format:
const snapshot = workflow.snapshot();
localStorage.setItem('docsWorkflow', JSON.stringify(snapshot));
const restored = JSON.parse(localStorage.getItem('docsWorkflow'));
workflow.restore(restored);
A snapshot contains:
{
version: 1,
id: 'docs-build',
current: 'failed',
data: {},
diagnostics: [],
history: []
}
restore(snapshot) requires version 1 and a matching workflow id. Restored
diagnostics and history entries are normalized into the same JSON-safe shape
that live commands produce. Restored history IDs are normalized to unique
positive integers. Command inputs and outputs in workflow.history and
snapshots are stored as JSON-safe projections. Live workflows still retry or
repeat with the original input value; after restore(snapshot), retry and
repeat use the serialized input from the snapshot.
Use migrateSnapshot(snapshot) to restore older persisted shapes:
const workflow = $workflow({
id: 'docs-build',
initial: 'idle',
data: {
title: '',
},
transitions: {},
migrateSnapshot(snapshot) {
return {
version: 1,
id: 'docs-build',
current: snapshot.state,
data: {
title: snapshot.title,
},
diagnostics: [],
history: [],
};
},
});
Retry, Repeat, And Repair
retry(commandName?) reruns the latest failed command with its original input:
const retryResult = await workflow.retry('publish');
repeat(commandName?) reruns the latest completed command with its original
input:
const repeatResult = await workflow.repeat('publish');
Repair is intentionally configured as a normal command instead of a magic built-in policy:
app.controller('DocsCtrl', function ($workflow) {
this.workflow = $workflow({
id: 'repairable-docs',
initial: 'idle',
data: {
title: '',
},
transitions: {
idle: {
validate(data) {
return data.title ? 'complete' : 'failed';
},
},
failed: {
complete() {
return 'complete';
},
},
},
commands: {
validate({ workflow }) {
workflow.send('validate');
return workflow.matches('complete')
? { ok: true }
: {
ok: false,
diagnostics: [
{
code: 'docs.missingTitle',
message: 'Missing title.',
recoverable: true,
},
],
};
},
repair({ workflow, data, input }) {
data.title = String(input);
workflow.send('complete');
return {
ok: true,
};
},
},
});
});
await workflow.run('validate');
await workflow.run('repair', 'Guide');
Diagnostics and history are append-only in v1. Repair, retry, and repeat add evidence; they do not erase the commands that made recovery or replay necessary.
Production Policies
Concurrent command calls are allowed by default. Set concurrency to reject or
queue overlapping runs for the same command:
const workflow = $workflow({
id: 'publish',
concurrency: 'queue',
initial: 'idle',
data: {},
transitions: {},
commands: {
publish() {
return { ok: true };
},
},
});
Per-run options can override the workflow default:
await workflow.run('publish', payload, { concurrency: 'reject' });
Use commandTimeout or per-run timeout to fail commands that exceed a time
budget. Timeout and cancellation resolve the command result with diagnostics;
commands receive an AbortSignal so async work can stop early:
const workflow = $workflow({
id: 'publish',
commandTimeout: 5000,
initial: 'idle',
data: {},
transitions: {},
commands: {
async publish({ cleanup, signal }) {
const controller = new AbortController();
cleanup(() => controller.abort());
signal.addEventListener('abort', () => controller.abort(), {
once: true,
});
await fetch('/publish', { signal: controller.signal });
return { ok: true };
},
},
});
workflow.cancel('publish');
cleanup(callback) runs after the command resolves, fails, times out, or is
cancelled. Use it for timers, event listeners, observers, and request
controllers created by the command.
restore(snapshot) is a hard recovery boundary. It cancels running commands,
prevents queued commands from starting, and ignores late writes from cancelled
command continuations. Command code should still observe signal.aborted so it
can release external resources promptly.
Diagnostics and history are bounded to 1000 entries by default. Use
diagnosticLimit and historyLimit to choose different bounds:
const workflow = $workflow({
id: 'bounded',
diagnosticLimit: 100,
historyLimit: 100,
initial: 'idle',
data: {},
transitions: {},
});
Runtime API
workflow.current;
workflow.data;
workflow.diagnostics;
workflow.history;
workflow.matches('idle');
workflow.can('start');
workflow.send('start');
workflow.run('publish', payload, { timeout: 5000 });
workflow.retry('publish', { concurrency: 'reject' });
workflow.repeat('publish');
workflow.cancel('publish');
workflow.snapshot();
workflow.restore(snapshot);
TypeScript callers can specify the expected command output type at the command boundary:
const result = await workflow.run<{ file: string }>('publish', payload);
if (result.ok) {
result.output?.file;
}
Workflow definitions are strict by default in TypeScript. If no event or command
map is provided, send(), run(), retry(), and repeat() have no valid
names. Use defineWorkflow<Data, Events, Commands>() and defineCommand() for
checked event names, command names, inputs, and outputs:
import { defineCommand, defineWorkflow } from '@angular-wave/angular.ts';
type DocsData = {
output: string;
};
type DocsEvents = {
complete: { output: string };
};
type DocsCommands = {
publish: ng.WorkflowCommand<DocsData, string, { file: string }, DocsEvents>;
};
const config = defineWorkflow<DocsData, DocsEvents, DocsCommands>({
id: 'docs',
initial: 'idle',
data: {
output: '',
} satisfies DocsData,
transitions: {
idle: {
complete(data, payload) {
data.output = payload.output;
return 'complete';
},
},
},
commands: {
publish: defineCommand<DocsData, string, { file: string }, DocsEvents>(
({ workflow, input }) => {
workflow.send('complete', { output: input });
return {
ok: true,
output: {
file: input,
},
};
},
),
},
});
const workflow = $workflow(config);
const result = await workflow.run('publish', 'index.html');
When a command map is provided, commands is required and every declared
command key must be present and callable. This keeps workflow.run('name')
aligned with the runtime command table.
When a command calls another command, pass the full command map to
defineCommand() so context.workflow.run() is checked too:
type DocsCommands = {
build: ng.WorkflowCommand<
DocsData,
string,
{ file: string },
DocsEvents,
DocsCommands
>;
publish: ng.WorkflowCommand<
DocsData,
{ file: string },
{ url: string },
DocsEvents,
DocsCommands
>;
};
const build = defineCommand<
DocsData,
string,
{ file: string },
DocsEvents,
DocsCommands
>(({ workflow, input }) => {
workflow.run('publish', { file: input });
return {
ok: true,
output: {
file: input,
},
};
});
Use ng.WorkflowCommandMap only when you intentionally need dynamic command
names. Dynamic command inputs are typed as unknown; narrow the value inside
the command before using it.
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.