You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
## Summary
Replaces the per-enactment telemetry-subscriber boilerplate with a
structured
`ColouredFlow.Runner.Enactment.LifecycleHooks` behaviour, and wires the
DSL to
inject the host workflow module as the hooks implementation. After this
lands,
a workflow declares its side effects inline:
```elixir
defmodule TrafficLight do
use ColouredFlow.DSL, task_supervisor: TrafficLight.TaskSup
name "TrafficLight"
# ... colour sets / vars / places / transitions ...
transition :turn_green_ew do
input :red_ew, bind({1, s})
input :safe_ew, bind({1, s})
output :green_ew, {1, s}
action do
send(TrafficLight.EWLight, {:turn_green, s, options[:tenant]})
end
end
on_enactment_start do
Logger.info("started: " <> event.enactment_id)
end
end
# Caller wires storage primitives + lifecycle hooks options
flow =
%ColouredFlow.Runner.Storage.Schemas.Flow{}
|> Ecto.Changeset.cast(%{name: "TrafficLight", definition: TrafficLight.cpnet()}, [:name, :definition])
|> ColouredFlow.Runner.Storage.Repo.insert!([])
{:ok, enactment} = TrafficLight.insert_enactment(flow.id)
{:ok, _pid} =
TrafficLight.start_enactment(enactment.id,
lifecycle_hooks: {TrafficLight, [tenant: "acme"]}
)
```
## What's in the PR
### `ColouredFlow.Runner.Enactment.LifecycleHooks` behaviour
7 optional callbacks, each `(event_map, options) :: :ok`:
| Callback | Event map keys |
|---|---|
| `on_enactment_start/2` | `:enactment_id`, `:markings` |
| `on_enactment_terminate/2` | `:enactment_id`, `:markings`, `:reason` |
| `on_enactment_exception/2` | `:enactment_id`, `:markings`, `:reason` |
| `on_workitem_enabled/2` | `:enactment_id`, `:markings`, `:workitem`,
`:binding` |
| `on_workitem_started/2` | `:enactment_id`, `:markings`, `:workitem`,
`:binding` |
| `on_workitem_completed/2` | `:enactment_id`, `:markings`, `:workitem`,
`:occurrence`, `:binding` |
| `on_workitem_withdrawn/2` | `:enactment_id`, `:markings`, `:workitem`,
`:binding` |
Hook value is `module() | {module(), keyword()} | nil`; bare module is
normalised to `{module, []}` in `Enactment.start_link/1` via
`LifecycleHooks.validate!/1`. The `options` keyword is appended to every
callback as the second argument and exposed inside DSL bodies as the
magic binding `options`.
`exception_reason()` is a true SSOT union of
`Storage.ensure_runnable_error()` (extracted to a named type) and
`Runner.Exception.reason()` — and `terminate(reason, state)` now also
dispatches `:on_enactment_exception` with `:abnormal_exit` so every
reason atom recorded in `enactment_logs` is observable by hooks.
### Runner wiring
`Runner.Enactment` GenServer carries a `:lifecycle_hooks` field;
dispatch logic lives in a sibling `LifecycleHooks.Dispatcher` module so
the GenServer file stays focused. Telemetry events keep emitting
unchanged; each callback fires *after* its corresponding telemetry
event.
### DSL injection
`use ColouredFlow.DSL` accepts:
- `:task_supervisor` — wraps every `action`/`on_enactment_*` body in a
supervised `Task.Supervisor.start_child/2`. Falls back to unsupervised
`Task.start/1` when omitted.
Auto-generated module surface:
```elixir
MyWorkflow.cpnet() :: %ColouredPetriNet{}
MyWorkflow.__cpn__(:name) :: String.t() | nil
MyWorkflow.__cpn__(:version) :: String.t() | nil
MyWorkflow.__cpn__(:initial_markings) :: [%Marking{}]
MyWorkflow.insert_enactment(flow_id, initial_markings \\ ..., options \\ [])
:: {:ok, %Schemas.Enactment{}}
MyWorkflow.start_enactment(enactment_id, opts \\ [])
:: DynamicSupervisor.on_start_child()
# LifecycleHooks callbacks
MyWorkflow.on_workitem_completed(event, options) :: :ok # always emitted
MyWorkflow.on_enactment_start(event, options) :: :ok # if declared
MyWorkflow.on_enactment_terminate(event, options) :: :ok # if declared
MyWorkflow.on_enactment_exception(event, options) :: :ok # if declared
```
Every `transition do action do ... end end` body compiles into a
`__action_for__/3` clause that the auto-generated
`on_workitem_completed/2` dispatches on transition name. Body sees magic
bindings `event`, `options`, plus each declared CPN variable extracted
from `event.binding`.
`ColouredFlow.DSL.Lifecycle` exposes `on_enactment_start/1`,
`on_enactment_terminate/1`, `on_enactment_exception/1` macros (each at
most once per workflow; duplicate raises a CompileError).
### Out of scope
The DSL no longer ships flow-insertion helpers — callers go through
storage primitives directly (`Storage.InMemory.insert_flow!/1` or
`Schemas.Flow` cast/insert for Default). This keeps the DSL decoupled
from any specific storage backend.
## Test plan
- [x] `mix test` — 63 doctests, 436 tests, 0 failures (3 stable
consecutive runs)
- [x] `mix credo --strict` — `found no issues`
- [x] `mix dialyzer` — baseline `Total errors: 6, Skipped: 6,
Unnecessary Skips: 0` (no new errors)
- [x] New tests:
- `LifecycleHooks` unit tests — `validate!/1` normalises shapes, raises
on bad inputs; `safe_invoke/3` swallows raises/throws, skips missing
callbacks
- `LifecycleHooksDispatchTest` — runner dispatches every lifecycle event
to the hooks; bare module + `{module, options}` tuple paths both
verified; abnormal-exit dispatch covered
- `LifecycleHooksE2ETest` — DSL workflow's `start_enactment` auto-binds
the module as hooks, threads `options` through to magic bindings
- `LifecycleTest` — duplicate `on_enactment_*` raises CompileError
- [ ] Manual: rebase `docs-dsl-example-readme` (PR #88) on top to
demonstrate the new API removing the bespoke `WorkitemPubSub` GenServer
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
0 commit comments