Skip to content

Commit 85b344d

Browse files
fahchenclaude
andauthored
feat: per-enactment LifecycleHooks + DSL lifecycle macros (#89)
## 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>
1 parent 668ada1 commit 85b344d

14 files changed

Lines changed: 1284 additions & 58 deletions

File tree

lib/coloured_flow/dsl.ex

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ defmodule ColouredFlow.DSL do
88
alias ColouredFlow.Definition.Variable
99

1010
@doc false
11-
defmacro __using__(_opts) do
11+
defmacro __using__(opts) do
12+
task_supervisor = Keyword.get(opts, :task_supervisor)
13+
1214
quote do
1315
import ColouredFlow.DSL,
1416
only: [
@@ -52,6 +54,13 @@ defmodule ColouredFlow.DSL do
5254
on_markings: 1
5355
]
5456

57+
import ColouredFlow.DSL.Lifecycle,
58+
only: [
59+
on_enactment_start: 1,
60+
on_enactment_terminate: 1,
61+
on_enactment_exception: 1
62+
]
63+
5564
import ColouredFlow.MultiSet, only: [sigil_MS: 2, multi_set_coefficient: 2]
5665

5766
Module.register_attribute(__MODULE__, :cf_colour_sets, accumulate: true)
@@ -65,6 +74,7 @@ defmodule ColouredFlow.DSL do
6574
Module.register_attribute(__MODULE__, :cf_termination_criteria, accumulate: true)
6675
Module.register_attribute(__MODULE__, :cf_name, accumulate: false)
6776
Module.register_attribute(__MODULE__, :cf_version, accumulate: false)
77+
Module.register_attribute(__MODULE__, :cf_task_supervisor, accumulate: false)
6878

6979
# Per-declaration metadata: accumulates `{name, file, line}` triples so
7080
# `Builder` can map validator-driven errors back to the offending callsite.
@@ -75,6 +85,15 @@ defmodule ColouredFlow.DSL do
7585
Module.register_attribute(__MODULE__, :cf_places_meta, accumulate: true)
7686
Module.register_attribute(__MODULE__, :cf_transitions_meta, accumulate: true)
7787

88+
# Per-transition action body AST collected by `transition do ... end` —
89+
# each entry is `{transition_name, body_ast, free_vars}`.
90+
Module.register_attribute(__MODULE__, :cf_transition_actions, accumulate: true)
91+
92+
# Enactment-level lifecycle hook bodies (`{kind, body_ast}`).
93+
Module.register_attribute(__MODULE__, :cf_lifecycle_hooks, accumulate: true)
94+
95+
@cf_task_supervisor unquote(task_supervisor)
96+
7897
@before_compile ColouredFlow.DSL.Builder
7998
end
8099
end

lib/coloured_flow/dsl/builder.ex

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ defmodule ColouredFlow.DSL.Builder do
1616
alias ColouredFlow.Validators.Exceptions.UniqueNameViolationError
1717

1818
@doc false
19+
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
1920
defmacro __before_compile__(env) do
2021
cpnet = build_cpnet(env.module)
2122
cpnet = resolve_function_results(cpnet)
@@ -30,8 +31,19 @@ defmodule ColouredFlow.DSL.Builder do
3031
{:ok, validated} ->
3132
name = Module.get_attribute(env.module, :cf_name)
3233
version = Module.get_attribute(env.module, :cf_version)
34+
task_supervisor = Module.get_attribute(env.module, :cf_task_supervisor)
35+
transition_actions = source_order_meta(env.module, :cf_transition_actions)
36+
lifecycle_hooks = source_order_meta(env.module, :cf_lifecycle_hooks)
37+
38+
action_clauses =
39+
Enum.map(transition_actions, &compile_transition_action(&1, task_supervisor))
40+
41+
lifecycle_clauses =
42+
Enum.map(lifecycle_hooks, &compile_lifecycle_hook(&1, task_supervisor))
3343

3444
quote do
45+
@behaviour ColouredFlow.Runner.Enactment.LifecycleHooks
46+
3547
@doc """
3648
The `%ColouredFlow.Definition.ColouredPetriNet{}` materialised from the DSL
3749
declarations in this module.
@@ -54,6 +66,63 @@ defmodule ColouredFlow.DSL.Builder do
5466
def __cpn__(:name), do: unquote(name)
5567
def __cpn__(:version), do: unquote(version)
5668
def __cpn__(:initial_markings), do: unquote(Macro.escape(initial_markings))
69+
70+
@doc """
71+
Insert an enactment row using `ColouredFlow.Runner.Storage.insert_enactment/1`.
72+
The `flow_id` and `initial_markings` keys are filled in by this helper; pass
73+
extra keys via `options` to merge into the storage params (e.g., `:id`).
74+
75+
Not idempotent — every invocation inserts a fresh row. Callers needing dedup
76+
must enforce it at application level.
77+
78+
The caller is responsible for inserting the underlying `Schemas.Flow` row (or
79+
`InMemory` flow record) — this helper does *not* set up the flow.
80+
"""
81+
@spec insert_enactment(binary(), [Marking.t()], keyword()) ::
82+
{:ok, ColouredFlow.Runner.Storage.Schemas.Enactment.t()}
83+
def insert_enactment(
84+
flow_id,
85+
initial_markings \\ __cpn__(:initial_markings),
86+
options \\ []
87+
)
88+
when is_binary(flow_id) and is_list(initial_markings) and is_list(options) do
89+
ColouredFlow.Runner.Storage.insert_enactment(
90+
Map.merge(
91+
%{flow_id: flow_id, initial_markings: initial_markings},
92+
Map.new(options)
93+
)
94+
)
95+
end
96+
97+
@doc """
98+
Start the enactment under `ColouredFlow.Runner.Enactment.Supervisor`, binding
99+
this module as the per-instance `ColouredFlow.Runner.Enactment.LifecycleHooks`
100+
unless the caller overrides it via `opts[:lifecycle_hooks]`.
101+
102+
The override accepts the same shape as the runtime hooks field — a bare module,
103+
a `{module, keyword}` tuple (so callbacks receive per-instance options as the
104+
second argument), or `nil` to disable hooks entirely for this enactment.
105+
"""
106+
@spec start_enactment(binary(), keyword()) :: DynamicSupervisor.on_start_child()
107+
def start_enactment(enactment_id, opts \\ [])
108+
when is_binary(enactment_id) and is_list(opts) do
109+
opts = Keyword.put_new(opts, :lifecycle_hooks, __MODULE__)
110+
ColouredFlow.Runner.Enactment.Supervisor.start_enactment(enactment_id, opts)
111+
end
112+
113+
# credo:disable-for-next-line JetCredo.Checks.ExplicitAnyType
114+
@spec __action_for__(String.t(), map(), keyword()) :: any()
115+
unquote_splicing(action_clauses)
116+
defp __action_for__(_transition, _event, _options), do: :ok
117+
118+
@doc false
119+
@impl ColouredFlow.Runner.Enactment.LifecycleHooks
120+
def on_workitem_completed(event, options) do
121+
__action_for__(event.workitem.binding_element.transition, event, options)
122+
:ok
123+
end
124+
125+
unquote_splicing(lifecycle_clauses)
57126
end
58127

59128
{:error, exception} ->
@@ -307,4 +376,115 @@ defmodule ColouredFlow.DSL.Builder do
307376
end
308377

309378
defp resolve_step(:unknown, descr, _colour_sets), do: descr
379+
380+
# Compile a single transition's `action do ... end` body into a
381+
# `__action_for__/3` clause. The body has access to:
382+
#
383+
# * `event` — `LifecycleHooks.workitem_completed_event()` map carrying
384+
# `:enactment_id`, `:markings`, `:workitem`, `:occurrence`, `:binding`.
385+
# * `options` — keyword list registered alongside the hook module.
386+
# * each CPN variable bound by the transition's incoming arcs (`:s`,
387+
# `:x`, …) — plucked from `event.binding`. Body-local pattern-match
388+
# names are NOT injected, so writing `pid = options[:pid]` (or any
389+
# local) inside `action do ... end` is safe.
390+
#
391+
# The wrapper unpacks `event.binding` via `Keyword.fetch!/2` for each
392+
# transition-bound var (using a `_`-prefixed alias when the body does not
393+
# reference the var, to suppress unused-variable warnings) and runs the
394+
# body inside a `Task.Supervisor.start_child/2` call (or unsupervised
395+
# `Task.start/1` when no `:task_supervisor` was provided to
396+
# `use ColouredFlow.DSL`) so the runner never blocks on user side effects.
397+
defp compile_transition_action(
398+
{transition_name, body, incoming_vars},
399+
task_supervisor
400+
) do
401+
# `incoming_vars` is the ground-truth set of CPN variables this
402+
# transition's incoming arcs bind — we always fetch each one from
403+
# `event.binding` and immediately discard the binding via `_ = var`
404+
# so a body that only references a subset of the bound vars does
405+
# not trip `--warnings-as-errors`. Body-local pattern-match names
406+
# (`pid = options[:pid]`, etc.) are NOT in this list and are never
407+
# touched.
408+
var_assignments =
409+
Enum.flat_map(incoming_vars, fn var ->
410+
var_ast = Macro.var(var, nil)
411+
412+
[
413+
quote do
414+
unquote(var_ast) = Keyword.fetch!(var!(event).binding, unquote(var))
415+
end,
416+
quote do
417+
# Discard expression so a body that doesn't reference the var
418+
# doesn't trip `--warnings-as-errors`. The underscore prefix
419+
# satisfies the project's unused-variable naming convention.
420+
_used = unquote(var_ast)
421+
end
422+
]
423+
end)
424+
425+
task_call = wrap_in_task(body, task_supervisor)
426+
427+
quote do
428+
defp __action_for__(unquote(transition_name), var!(event), var!(options)) do
429+
_event = var!(event)
430+
_options = var!(options)
431+
unquote_splicing(var_assignments)
432+
unquote(task_call)
433+
end
434+
end
435+
end
436+
437+
defp compile_lifecycle_hook({:on_enactment_start, body}, task_supervisor) do
438+
task_call = wrap_in_task(body, task_supervisor)
439+
440+
quote do
441+
@impl ColouredFlow.Runner.Enactment.LifecycleHooks
442+
def on_enactment_start(var!(event), var!(options)) do
443+
_event = var!(event)
444+
_options = var!(options)
445+
unquote(task_call)
446+
:ok
447+
end
448+
end
449+
end
450+
451+
defp compile_lifecycle_hook({:on_enactment_terminate, body}, task_supervisor) do
452+
task_call = wrap_in_task(body, task_supervisor)
453+
454+
quote do
455+
@impl ColouredFlow.Runner.Enactment.LifecycleHooks
456+
def on_enactment_terminate(var!(event), var!(options)) do
457+
_event = var!(event)
458+
_options = var!(options)
459+
unquote(task_call)
460+
:ok
461+
end
462+
end
463+
end
464+
465+
defp compile_lifecycle_hook({:on_enactment_exception, body}, task_supervisor) do
466+
task_call = wrap_in_task(body, task_supervisor)
467+
468+
quote do
469+
@impl ColouredFlow.Runner.Enactment.LifecycleHooks
470+
def on_enactment_exception(var!(event), var!(options)) do
471+
_event = var!(event)
472+
_options = var!(options)
473+
unquote(task_call)
474+
:ok
475+
end
476+
end
477+
end
478+
479+
defp wrap_in_task(body, nil) do
480+
quote do
481+
Task.start(fn -> unquote(body) end)
482+
end
483+
end
484+
485+
defp wrap_in_task(body, task_supervisor) do
486+
quote do
487+
Task.Supervisor.start_child(unquote(task_supervisor), fn -> unquote(body) end)
488+
end
489+
end
310490
end

lib/coloured_flow/dsl/function.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
defmodule ColouredFlow.DSL.Function do
22
@moduledoc """
3-
`function/2` and `function/3` macros. See `ColouredFlow.DSL` for context.
3+
`function/1` and `function/2` macros. See `ColouredFlow.DSL` for context.
44
"""
55

66
alias ColouredFlow.DSL.ExpressionHelper

lib/coloured_flow/dsl/lifecycle.ex

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
defmodule ColouredFlow.DSL.Lifecycle do
2+
@moduledoc """
3+
`on_enactment_start/1`, `on_enactment_terminate/1`, and
4+
`on_enactment_exception/1` macros.
5+
6+
Each macro registers a clause that the workflow module's
7+
`ColouredFlow.Runner.Enactment.LifecycleHooks` callback will execute. Bodies are
8+
wrapped in a task by `ColouredFlow.DSL.Builder` (using the `:task_supervisor`
9+
option passed to `use ColouredFlow.DSL`, falling back to an unsupervised
10+
`Task.start/1`) so the runner never blocks on user-defined side effects.
11+
12+
Each hook may appear at most once per workflow. A second declaration raises a
13+
`CompileError` pointing to the offending macro call.
14+
15+
Inside every hook body, the magic bindings `event` and `options` are available.
16+
`event` is the typed event map documented on each
17+
`ColouredFlow.Runner.Enactment.LifecycleHooks` callback (e.g.,
18+
`event.enactment_id`, `event.markings`, `event.reason`); `options` is the
19+
keyword list registered alongside the hook module via the `{module, options}`
20+
tuple form (or `[]` when registered as a bare module).
21+
"""
22+
23+
@doc """
24+
Runs once when the enactment GenServer finishes booting (after snapshot recovery
25+
and initial calibration). Inside the body, `event.enactment_id` and
26+
`event.markings` are available.
27+
28+
## Examples
29+
30+
on_enactment_start do
31+
Logger.info("enactment started: " <> event.enactment_id)
32+
end
33+
"""
34+
defmacro on_enactment_start(do: body) do
35+
push_hook!(:on_enactment_start, body, __CALLER__)
36+
end
37+
38+
@doc """
39+
Runs when the enactment terminates normally. Inside the body, `event.reason` is
40+
one of `:implicit`, `:explicit`, `:force`.
41+
42+
## Examples
43+
44+
on_enactment_terminate do
45+
Logger.info("done: " <> inspect(event.reason))
46+
end
47+
"""
48+
defmacro on_enactment_terminate(do: body) do
49+
push_hook!(:on_enactment_terminate, body, __CALLER__)
50+
end
51+
52+
@doc """
53+
Runs when the runner records an exception against the enactment. Inside the
54+
body, `event.reason` carries the failure mode (e.g., `:abnormal_exit`,
55+
`:snapshot_corrupt`, `:invalid_termination_criteria`,
56+
`:crash_threshold_exceeded`, `:terminated`, `:already_in_exception`).
57+
58+
## Examples
59+
60+
on_enactment_exception do
61+
Logger.error("workflow blew up: " <> inspect(event.reason))
62+
end
63+
"""
64+
defmacro on_enactment_exception(do: body) do
65+
push_hook!(:on_enactment_exception, body, __CALLER__)
66+
end
67+
68+
defp push_hook!(kind, body, caller) do
69+
file = caller.file
70+
line = caller.line
71+
escaped_body = Macro.escape(body)
72+
73+
quote do
74+
ColouredFlow.DSL.Lifecycle.__push_hook__!(
75+
__MODULE__,
76+
unquote(kind),
77+
unquote(escaped_body),
78+
unquote(file),
79+
unquote(line)
80+
)
81+
end
82+
end
83+
84+
@doc false
85+
@spec __push_hook__!(module(), atom(), Macro.t(), String.t(), non_neg_integer()) :: :ok
86+
def __push_hook__!(module, kind, body, file, line) do
87+
existing = Module.get_attribute(module, :cf_lifecycle_hooks) || []
88+
89+
if Enum.any?(existing, fn {existing_kind, _body} -> existing_kind == kind end) do
90+
raise CompileError,
91+
description: "#{kind} already declared in this workflow",
92+
file: file,
93+
line: line
94+
end
95+
96+
Module.put_attribute(module, :cf_lifecycle_hooks, {kind, body})
97+
:ok
98+
end
99+
end

0 commit comments

Comments
 (0)