Skip to content

Commit 804e9ff

Browse files
fahchenclaude
andcommitted
feat(dsl): action handler injection, lifecycle hooks, idempotent setup_flow
Wires `ColouredFlow.DSL` to the per-instance ActionHandler introduced in the previous commit. A workflow module now owns the handler contract end to end: - `use ColouredFlow.DSL, storage: ..., task_supervisor: ...` accepts the storage and task-supervisor pair to use; both are surfaced through auto-generated wrappers (`setup_flow!/0`, `insert_enactment!/{1,2}`, `start_enactment/{1,2}`). `start_enactment` registers the workflow module itself as the action handler. - Each `transition do action do ... end end` body compiles to a `__action_for__/4` clause dispatched from an auto-generated `on_workitem_completed/3`. The body sees the transition's bound CPN variables, plus magic bindings `ctx` and `workitem`. Bodies run inside the configured `Task.Supervisor` (or `Task.start/1` when none was provided), so the runner never blocks on user side effects. - New `ColouredFlow.DSL.Lifecycle` macros — `on_enactment_start`, `on_enactment_terminate`, `on_enactment_exception` — compile to the matching ActionHandler callbacks. Each may appear at most once per workflow; a duplicate declaration is a compile-time error. - Storage gains `setup_flow!/2` and `insert_enactment!/2` callbacks. The InMemory and Default backends both back the new convenience surface, with `setup_flow!` returning the existing flow when one is already stored under the same name. The runtime/runner contract is unchanged: telemetry events keep emitting in addition, and any cpnet that does not declare an `action` or `on_enactment_*` block compiles to a noop handler with zero overhead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 33b5e34 commit 804e9ff

13 files changed

Lines changed: 703 additions & 14 deletions

File tree

lib/coloured_flow/dsl.ex

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

1010
@doc false
11-
defmacro __using__(_opts) do
11+
defmacro __using__(opts) do
12+
storage = Keyword.get(opts, :storage)
13+
task_supervisor = Keyword.get(opts, :task_supervisor)
14+
1215
quote do
1316
import ColouredFlow.DSL,
1417
only: [
@@ -52,6 +55,15 @@ defmodule ColouredFlow.DSL do
5255
on_markings: 1
5356
]
5457

58+
import ColouredFlow.DSL.Lifecycle,
59+
only: [
60+
on_enactment_start: 1,
61+
on_enactment_terminate: 1,
62+
on_enactment_terminate: 2,
63+
on_enactment_exception: 1,
64+
on_enactment_exception: 2
65+
]
66+
5567
import ColouredFlow.MultiSet, only: [sigil_MS: 2, multi_set_coefficient: 2]
5668

5769
Module.register_attribute(__MODULE__, :cf_colour_sets, accumulate: true)
@@ -65,6 +77,8 @@ defmodule ColouredFlow.DSL do
6577
Module.register_attribute(__MODULE__, :cf_termination_criteria, accumulate: true)
6678
Module.register_attribute(__MODULE__, :cf_name, accumulate: false)
6779
Module.register_attribute(__MODULE__, :cf_version, accumulate: false)
80+
Module.register_attribute(__MODULE__, :cf_storage, accumulate: false)
81+
Module.register_attribute(__MODULE__, :cf_task_supervisor, accumulate: false)
6882

6983
# Per-declaration metadata: accumulates `{name, file, line}` triples so
7084
# `Builder` can map validator-driven errors back to the offending callsite.
@@ -75,6 +89,16 @@ defmodule ColouredFlow.DSL do
7589
Module.register_attribute(__MODULE__, :cf_places_meta, accumulate: true)
7690
Module.register_attribute(__MODULE__, :cf_transitions_meta, accumulate: true)
7791

92+
# Per-transition action body AST collected by `transition do ... end` —
93+
# each entry is `{transition_name, body_ast, free_vars}`.
94+
Module.register_attribute(__MODULE__, :cf_transition_actions, accumulate: true)
95+
96+
# Enactment-level lifecycle hook bodies (`{kind, body_ast}`).
97+
Module.register_attribute(__MODULE__, :cf_lifecycle_hooks, accumulate: true)
98+
99+
@cf_storage unquote(storage)
100+
@cf_task_supervisor unquote(task_supervisor)
101+
78102
@before_compile ColouredFlow.DSL.Builder
79103
end
80104
end

lib/coloured_flow/dsl/builder.ex

Lines changed: 202 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,20 @@ 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+
storage = Module.get_attribute(env.module, :cf_storage)
35+
task_supervisor = Module.get_attribute(env.module, :cf_task_supervisor)
36+
transition_actions = source_order_meta(env.module, :cf_transition_actions)
37+
lifecycle_hooks = source_order_meta(env.module, :cf_lifecycle_hooks)
38+
39+
action_clauses =
40+
Enum.map(transition_actions, &compile_transition_action(&1, task_supervisor))
41+
42+
lifecycle_clauses =
43+
Enum.map(lifecycle_hooks, &compile_lifecycle_hook(&1, task_supervisor))
3344

3445
quote do
46+
@behaviour ColouredFlow.Runner.ActionHandler
47+
3548
@doc """
3649
The `%ColouredFlow.Definition.ColouredPetriNet{}` materialised from the DSL
3750
declarations in this module.
@@ -54,6 +67,70 @@ defmodule ColouredFlow.DSL.Builder do
5467
def __cpn__(:name), do: unquote(name)
5568
def __cpn__(:version), do: unquote(version)
5669
def __cpn__(:initial_markings), do: unquote(Macro.escape(initial_markings))
70+
71+
@doc """
72+
Idempotent flow setup. Inserts the materialised CPN under the workflow name (or
73+
raises if no `name` was declared) using the storage configured via
74+
`use ColouredFlow.DSL, storage: ...`. Re-invocations return the previously
75+
stored row.
76+
"""
77+
# credo:disable-for-next-line JetCredo.Checks.ExplicitAnyType
78+
@spec setup_flow!() :: term()
79+
def setup_flow! do
80+
ColouredFlow.DSL.Builder.__setup_flow__!(
81+
__MODULE__,
82+
unquote(storage),
83+
unquote(name)
84+
)
85+
end
86+
87+
@doc """
88+
Insert an enactment for the given flow with the workflow's declared initial
89+
markings (overridable via the second argument).
90+
"""
91+
# credo:disable-for-next-line JetCredo.Checks.ExplicitAnyType
92+
@spec insert_enactment!(term()) :: term()
93+
# credo:disable-for-next-line JetCredo.Checks.ExplicitAnyType
94+
@spec insert_enactment!(term(), [Marking.t()]) :: term()
95+
def insert_enactment!(flow, initial_markings \\ __cpn__(:initial_markings)) do
96+
ColouredFlow.DSL.Builder.__insert_enactment__!(
97+
unquote(storage),
98+
flow,
99+
initial_markings
100+
)
101+
end
102+
103+
@doc """
104+
Start the enactment under `ColouredFlow.Runner.Enactment.Supervisor`, binding
105+
this module as the per-instance `ColouredFlow.Runner.ActionHandler` unless the
106+
caller overrides it.
107+
"""
108+
@spec start_enactment(binary(), keyword()) ::
109+
DynamicSupervisor.on_start_child()
110+
def start_enactment(enactment_id, opts \\ []) when is_binary(enactment_id) do
111+
opts = Keyword.put_new(opts, :action_handler, __MODULE__)
112+
ColouredFlow.Runner.Enactment.Supervisor.start_enactment(enactment_id, opts)
113+
end
114+
115+
# credo:disable-for-next-line JetCredo.Checks.ExplicitAnyType
116+
@spec __action_for__(String.t(), Keyword.t(), map(), term()) :: any()
117+
unquote_splicing(action_clauses)
118+
defp __action_for__(_transition_name, _binding, _ctx, _workitem), do: :ok
119+
120+
@doc false
121+
@impl ColouredFlow.Runner.ActionHandler
122+
def on_workitem_completed(ctx, workitem, _occurrence) do
123+
__action_for__(
124+
workitem.binding_element.transition,
125+
workitem.binding_element.binding,
126+
ctx,
127+
workitem
128+
)
129+
130+
:ok
131+
end
132+
133+
unquote_splicing(lifecycle_clauses)
57134
end
58135

59136
{:error, exception} ->
@@ -307,4 +384,129 @@ defmodule ColouredFlow.DSL.Builder do
307384
end
308385

309386
defp resolve_step(:unknown, descr, _colour_sets), do: descr
387+
388+
# Compile a single transition's `action do ... end` body into a
389+
# `__action_for__/4` clause. The body has access to:
390+
#
391+
# * `ctx` — `ColouredFlow.Runner.ActionHandler.ctx()`
392+
# * `workitem` — the just-completed `%Workitem{}`
393+
# * each free CPN variable (`:s`, `:x`, …) — plucked from the binding
394+
#
395+
# The wrapper unpacks the binding via `Keyword.fetch!/2` for each declared
396+
# free var and runs the body inside a `Task.Supervisor.start_child/2` call
397+
# (or unsupervised `Task.start/1` when no `:task_supervisor` was provided to
398+
# `use ColouredFlow.DSL`) so the runner never blocks on user side effects.
399+
defp compile_transition_action({transition_name, body, cpn_vars}, task_supervisor) do
400+
var_assignments =
401+
Enum.map(cpn_vars, fn var ->
402+
var_ast = Macro.var(var, nil)
403+
404+
quote do
405+
unquote(var_ast) = Keyword.fetch!(var!(binding), unquote(var))
406+
end
407+
end)
408+
409+
task_call = wrap_in_task(body, task_supervisor)
410+
411+
quote do
412+
defp __action_for__(unquote(transition_name), var!(binding), var!(ctx), var!(workitem)) do
413+
_binding = var!(binding)
414+
_ctx = var!(ctx)
415+
_workitem = var!(workitem)
416+
unquote_splicing(var_assignments)
417+
unquote(task_call)
418+
end
419+
end
420+
end
421+
422+
defp compile_lifecycle_hook({:on_enactment_start, body}, task_supervisor) do
423+
task_call = wrap_in_task(body, task_supervisor)
424+
425+
quote do
426+
@impl ColouredFlow.Runner.ActionHandler
427+
def on_enactment_start(var!(ctx)) do
428+
_ctx = var!(ctx)
429+
unquote(task_call)
430+
:ok
431+
end
432+
end
433+
end
434+
435+
defp compile_lifecycle_hook({:on_enactment_terminate, body}, task_supervisor) do
436+
task_call = wrap_in_task(body, task_supervisor)
437+
438+
quote do
439+
@impl ColouredFlow.Runner.ActionHandler
440+
def on_enactment_terminate(var!(ctx), var!(reason)) do
441+
_ctx = var!(ctx)
442+
_reason = var!(reason)
443+
unquote(task_call)
444+
:ok
445+
end
446+
end
447+
end
448+
449+
defp compile_lifecycle_hook({:on_enactment_exception, body}, task_supervisor) do
450+
task_call = wrap_in_task(body, task_supervisor)
451+
452+
quote do
453+
@impl ColouredFlow.Runner.ActionHandler
454+
def on_enactment_exception(var!(ctx), var!(reason)) do
455+
_ctx = var!(ctx)
456+
_reason = var!(reason)
457+
unquote(task_call)
458+
:ok
459+
end
460+
end
461+
end
462+
463+
defp wrap_in_task(body, nil) do
464+
quote do
465+
Task.start(fn -> unquote(body) end)
466+
end
467+
end
468+
469+
defp wrap_in_task(body, task_supervisor) do
470+
quote do
471+
Task.Supervisor.start_child(unquote(task_supervisor), fn -> unquote(body) end)
472+
end
473+
end
474+
475+
@doc false
476+
# credo:disable-for-next-line JetCredo.Checks.ExplicitAnyType
477+
@spec __setup_flow__!(module(), module() | nil, String.t() | nil) :: term()
478+
def __setup_flow__!(module, nil, _name) do
479+
raise ArgumentError, """
480+
#{inspect(module)}.setup_flow!/0 requires a `:storage` option on `use ColouredFlow.DSL`.
481+
482+
Example:
483+
484+
use ColouredFlow.DSL,
485+
storage: ColouredFlow.Runner.Storage.InMemory
486+
487+
"""
488+
end
489+
490+
def __setup_flow__!(module, _storage, nil) do
491+
raise ArgumentError, """
492+
#{inspect(module)}.setup_flow!/0 requires a `name "..."` declaration.
493+
"""
494+
end
495+
496+
def __setup_flow__!(module, storage, name) when is_atom(storage) and is_binary(name) do
497+
storage.setup_flow!(name, module.cpnet())
498+
end
499+
500+
@doc false
501+
# credo:disable-for-next-line JetCredo.Checks.ExplicitAnyType
502+
@spec __insert_enactment__!(module() | nil, term(), [Marking.t()]) :: term()
503+
def __insert_enactment__!(nil, _flow, _initial_markings) do
504+
raise ArgumentError,
505+
"insert_enactment!/2 requires a `:storage` option on `use ColouredFlow.DSL`."
506+
end
507+
508+
def __insert_enactment__!(storage, flow, initial_markings)
509+
when is_atom(storage) and is_list(initial_markings) do
510+
storage.insert_enactment!(flow, initial_markings)
511+
end
310512
end

0 commit comments

Comments
 (0)