Files
rose-ash/next/kernel/flow_dispatch.erl
giles 6b4850b34e
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
fed-sx-types Phase 7: pipeline trigger fan-out + flow_dispatch
The post-append fan-out that fires durable flows from arriving
activities (fed-sx-triggers-loop.md Phases 2+3), native into next/flow
— no cross-guest FFI.

- pipeline.erl: apply_triggers/3 runs AFTER the kernel append (rejected
  activities never reach it). It looks the activity's type up in the
  trigger registry, drops specs whose guard/actor-scope fails or whose
  {activity_cid, trigger_cid} pair already fired (federation can deliver
  the same activity twice — dedup is keyed on that pair, read from the
  actor's :triggers_fired), and dispatches the rest. Returns the audit
  triples for the kernel to fold into :triggers_fired + its projection.
  Must not be called inside a `try` (it does gen_server:calls, which
  deadlock the scheduler inside a try); running post-append in its own
  step satisfies that.
- flow_dispatch.erl: bridges a matched trigger to flow_store:start, with
  the activity bound into the flow's input env. guard_passes/3 gates on
  actor-scope + guard. Failures (unknown flow, crashing first step) come
  back as {error, _}, never raised — one flow can't take down the rest.
- flow_store.erl: drive wrapped in try (the drive is pure, so the try is
  safe) so a flow whose step raises yields {error, {flow_crashed, _}}
  instead of crashing the store.

Tests: flow_dispatch.sh (12), pipeline_triggers.sh (10). lib/erlang
771/771, next/flow 34/34.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:22:50 +00:00

77 lines
3.1 KiB
Erlang

-module(flow_dispatch).
-export([start/4, guard_passes/3]).
%% Bridge from "an activity matched a trigger" to "a flow started with
%% that activity as input" (fed-sx-triggers Phase 3). A NATIVE call into
%% next/flow (flow_store) — the engine is Erlang-on-SX too, so there is
%% no cross-guest FFI: the kernel and the workflow engine share one
%% runtime.
%%
%% start(Spec, Activity, ActorState, Cfg)
%% -> {ok, FlowId, {ActivityCid, TriggerCid, FlowId}} (audit triple)
%% | {error, Reason}
%%
%% The flow named in Spec is started with the activity bound into its
%% input environment, so flow steps can read the activity, the actor id,
%% and the trigger cid (the audit chain). Flow-start failures — an
%% unknown flow name, or a crashing first step (flow_store isolates the
%% raise) — come back as {error, Reason}, never raised, so the fan-out
%% caller is insulated from one flow's failure.
start(Spec, Activity, ActorState, _Cfg) ->
FlowName = trigger_registry:spec_flow_name(Spec),
TriggerCid = trigger_registry:spec_cid(Spec),
ActivityCid = activity_cid(Activity),
Input = [{activity, Activity},
{actor, actor_id_of(ActorState, Activity)},
{trigger_cid, TriggerCid}],
case flow_store:start(FlowName, Input) of
{ok, FlowId, _Result} ->
{ok, FlowId, {ActivityCid, TriggerCid, FlowId}};
{error, Reason} ->
{error, Reason}
end.
%% guard_passes(Spec, Activity, ActorState) — a spec fires when its
%% actor-scope admits the activity's actor AND its guard (if any)
%% returns true. An `any` scope and an `undefined` guard always pass;
%% the guard lets one activity-type bind multiple flows with
%% discriminators.
guard_passes(Spec, Activity, ActorState) ->
scope_ok(trigger_registry:spec_actor_scope(Spec), Activity) andalso
guard_ok(trigger_registry:spec_guard(Spec), Activity, ActorState).
scope_ok(any, _Activity) -> true;
scope_ok(Scope, Activity) ->
case envelope:get_field(actor, Activity) of
{ok, Scope} -> true;
_ -> false
end.
guard_ok(undefined, _Activity, _ActorState) -> true;
guard_ok(Guard, Activity, ActorState) when is_function(Guard, 2) ->
Guard(Activity, ActorState);
guard_ok(_, _, _) -> false.
%% ── helpers ─────────────────────────────────────────────────────
activity_cid(Activity) ->
case envelope:get_field(id, Activity) of
{ok, Cid} -> Cid;
_ -> undefined
end.
%% actor_id_of/2 — prefer the receiving actor's id (ActorState carries
%% {actor_id, _}); fall back to the activity's :actor. Reading
%% ActorState as a proplist keeps this decoupled from actor_state's
%% internal shape and testable with a plain [{actor_id, _}] stand-in.
actor_id_of(ActorState, Activity) ->
case envelope:get_field(actor_id, ActorState) of
{ok, Id} -> Id;
_ ->
case envelope:get_field(actor, Activity) of
{ok, A} -> A;
_ -> undefined
end
end.