Current section

Files

Jump to
mobus_stepwise ARCHITECTURE.md
Raw

ARCHITECTURE.md

# Architecture
MobusStepwise is an ALF-backed workflow execution engine with two explicit profiles:
- `:stepwise` for ordered, wizard-like progression
- `:flow` for graph execution with explicit forks, joins, waits, and checkpointable parallel state
## Core Model
The engine separates **specification** (what the workflow looks like) from **runtime** (where the workflow is right now).
### `:stepwise` Spec (static definition)
```
%{
profile: :stepwise,
initial_state: :step_one,
steps: [:step_one, :step_two, :step_three],
states: %{
step_one: %{step_number: 1, ui: %{key: :step_one}, action: %{...}},
step_two: %{step_number: 2, ui: %{key: :step_two}},
step_three: %{step_number: 3, ui: %{key: :step_three}}
},
transitions: %{}, # optional explicit transitions
breakpoints: [], # optional debug breakpoints
subscriptions: [] # optional PubSub topics
}
```
Key fields:
| Field | Purpose |
|-----------------|---------------------------------------------------------------|
| `profile` | Always `:stepwise` — identifies this as a stepwise workflow |
| `initial_state` | The step the engine starts on |
| `steps` | Ordered list of step identifiers (atoms or strings) |
| `states` | Per-step metadata: UI descriptor, actions, step number |
| `transitions` | Optional explicit event→state mappings for custom events |
| `breakpoints` | Optional debug breakpoints on events or state entries |
| `subscriptions` | PubSub topic templates interpolated with runtime context |
### `:stepwise` Runtime (live state)
```
%{
execution_id: "exec-001",
tenant_id: "tenant-123",
spec: %{...}, # normalized IR
current_state: :step_two,
context: %{name: "Alice"}, # accumulated user input
artifacts: %{}, # durable workflow-scoped data
history: [%{event: :next, from: :step_one, to: :step_two, at: ~U[...]}],
trace: [%{kind: :step, ...}],
blocked_reasons: %{},
breakpoint_hits: [],
projection: %Projection{...} # canonical UI contract
}
```
## Engine Lifecycle
```
┌─────────────────────────────────────────────┐
│ SPEC │
│ profile, initial_state, steps, states │
└─────────────────┬───────────────────────────┘
┌─────────────────┐
│ Engine.init/2 │
│ normalize → IR │
│ start pipeline │
│ entry action │
│ → projection │
└────────┬────────┘
┌─────────────────┐
│ RUNTIME │◄──────────────┐
│ current_state │ │
│ context │ │
│ projection │ │
└────────┬────────┘ │
│ │
event(:next, payload) │
│ │
▼ │
┌─────────────────────────────────┐ │
│ ALF Pipeline │ │
│ │ │
│ 1. StepwiseContextMerge │ │
│ merge payload → context │ │
│ │ │
│ 2. StepwiseAction │ │
│ run capability (if defined) │ │
│ │ │
│ 3. StepwiseAdvance │ │
│ move to next/prev step │ │
│ │ │
│ 4. StepwiseEntryAction │ │
│ run entry action on new │ │
│ state (if state changed) │ │
│ │ │
│ 5. FsmBreakpoint │ │
│ record breakpoint hits │ │
│ │ │
│ 6. StepwiseProjection │ │
│ build UI projection │ │
└────────────────┬────────────────┘ │
│ │
▼ │
┌─────────────────┐ │
│ updated RUNTIME │───────────────┘
│ new state │
│ new projection │
└─────────────────┘
```
### init/2
1. Extracts `tenant_id` and `execution_id` from runtime context
2. Starts the ALF pipeline (if not already running)
3. Normalizes the spec into internal representation (IR)
4. Builds initial runtime with `current_state` set to `initial_state`
5. Fires entry action for the initial state (capabilities with `:enter` trigger)
6. Computes initial projection
### handle_event/3
Sends an event (`:next`, `:back`, or custom) through the ALF pipeline with a payload. The pipeline stages execute in order:
1. **StepwiseContextMerge** — merges the event payload into `runtime.context`
2. **StepwiseAction** — if the current step defines an action (e.g. capability), executes it
3. **StepwiseAdvance** — advances or reverses `current_state` based on step ordering
4. **StepwiseEntryAction** — if the state changed, runs entry-triggered actions on the new state
5. **FsmBreakpoint** — records any breakpoint hits for debugging
6. **StepwiseProjection** — computes the canonical `Projection` struct
Returns `{:ok, runtime}`, `{:wait, runtime, wait_cfg}`, or `{:error, reason, runtime}`.
### get_state/1
Returns the current `Projection` struct from the runtime. If the projection is stale or missing, recomputes it by running the pipeline in projection-only mode.
### checkpoint/1 and restore/3
`checkpoint/1` strips the runtime down to a serializable map (no projection, no process references). `restore/3` re-hydrates a runtime from a checkpoint, re-normalizing the spec and recomputing the projection.
## Profiles
The engine uses `spec.profile` to select profile-specific behavior.
### `:stepwise`
- Ordered step progression from `steps` or `step_number`
- Single `current_state`
- ALF pipeline-backed step/action/projection flow
- Existing compatibility profile for all current consumers
### `:flow`
- Explicit `nodes` + `edges` graph model
- Token-based execution with multiple active branches
- Fork and join semantics with branch result aggregation
- Shared `%Projection{}` wrapper with graph data in `projection.extensions.flow`
- Versioned checkpoints that preserve active tokens, waits, join buffers, and branch results
## Events
Events drive state changes through the engine:
| Event | Behavior |
|-------------------|-------------------------------------------------------------|
| `:next` / `"next"`| Advance to the next step in order |
| `:back` / `"back"`| Return to the previous step in order |
| Custom atoms | Looked up in `spec.transitions` for explicit target state |
| `:__enter__` | Internal — fired on state entry to trigger entry actions |
Events carry a `payload` map that is merged into `runtime.context` by `StepwiseContextMerge`, accumulating user input across steps.
For `:flow`, events are interpreted by the graph runtime. `:next` advances ready tokens, `:resume` resumes waiting tokens, and `:cancel` / `:timeout` can target specific tokens or branches through the payload.
## Projection
The `Mobus.Stepwise.Projection` struct is the canonical contract between the engine and the UI layer. It contains everything the UI needs to render the current step:
- `current_state` — which step we're on
- `available_events` — what the user can do (`:next`, `:back`)
- `ui``%{key: atom, assigns: map}` descriptor for component rendering
- `artifacts` — durable workflow-scoped data
- `blocked_reasons` — why certain events are blocked
- `errors` / `trace` — debugging information
For `:flow`, `current_state` is the focus node and graph-wide execution state lives under `projection.extensions.flow`.
## Capabilities
Steps can define **actions** that execute via a pluggable capability runner adapter:
```elixir
action: %{type: :capability, handle: "myapp.validate", triggers: [:next]}
```
- The adapter is configured via `config :mobus_stepwise, :capability_runner_adapter, Module`
- When no adapter is set, capability execution is a no-op (`{:ok, %{context: %{}}}`)
- Capabilities receive the full execution context and return context updates and/or artifacts
- Failed capabilities block the event and populate `blocked_reasons`
## Artifacts
Artifacts are durable, workflow-scoped data that survive refreshes, retries, and external callbacks. They are normalized into a canonical format with `kind`, `version`, `inserted_at`, and `data` fields. Capabilities can produce artifacts that are merged into the runtime.
## IR (Internal Representation)
Before the engine operates on a spec, it normalizes it via `Mobus.Stepwise.IR.normalize/1`. This:
- Coerces known profile strings to atoms
- Ensures `states`, `transitions`, and `nodes` exist in normalized shapes
- Normalizes the profile identifier
## Integration
A typical host application integrates MobusStepwise by:
1. Building a stepwise spec from its own domain model (steps, states, UI keys)
2. Calling `Engine.init/2` with the spec and a runtime context containing `tenant_id` and `execution_id`
3. On user interaction, calling `Engine.handle_event/3` with `:next` and the form payload
4. Reading `Engine.get_state/1` to get the `Projection` for rendering
5. Using `checkpoint/1` / `restore/3` for persistence across page refreshes
The flow:
```
Groove Definition
Build stepwise spec (steps, states, UI keys)
Engine.init(spec, %{tenant_id: t, execution_id: e, sync: true})
Render projection.ui.key as LiveComponent
User submits form → Engine.handle_event(runtime, :next, form_data)
Re-render with updated projection
Repeat until final step
```