-module(trigger_registry). -export([new/0, add/3, remove/2, lookup/2, all/1, fold/2, fold_fn/0, mk_spec/4, spec_cid/1, spec_flow_name/1, spec_guard/1, spec_actor_scope/1, start_link/0, start_link/1, stop/0, add/2, remove/1, lookup/1, all_triggers/0]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2]). -behaviour(gen_server). %% Trigger registry — binds activity-types to durable flows %% (plans/agent-briefings/fed-sx-triggers-loop.md, Phase 1). When an %% activity is appended, the kernel's post-append fan-out %% (pipeline.erl, Phase 2) looks the activity's type up here and starts %% each registered flow. Mirrors the peer_actors / peer_types shape: a %% pure-functional core plus a registered gen_server, hydrated on start %% from a fold over DefineTrigger activities. %% %% State shape (pure-functional): %% [{ActivityType, [Spec, ...]}, ...] %% Multiple triggers may bind the same activity-type; they fire %% independently. A Spec is a 4-tuple: %% {TriggerCid, FlowName, Guard, ActorScope} %% TriggerCid — content-address of the DefineTrigger activity %% (dedup + audit); `undefined` if not yet addressed. %% FlowName — the flow_store-registered flow to start. %% Guard — fun ((Activity, ActorState) -> bool) | undefined. %% Lets one type bind multiple flows with %% discriminators ("only Articles in :newsletter"). %% Resolved to a fun at registration; not carried over %% the wire (term_codec can't encode funs). %% ActorScope — an actor id the trigger is scoped to, or `any`. %% ── Spec constructor / accessors ──────────────────────────────── mk_spec(TriggerCid, FlowName, Guard, ActorScope) -> {TriggerCid, FlowName, Guard, ActorScope}. spec_cid({Cid, _, _, _}) -> Cid. spec_flow_name({_, FlowName, _, _}) -> FlowName. spec_guard({_, _, Guard, _}) -> Guard. spec_actor_scope({_, _, _, Scope}) -> Scope. %% ── Pure-functional API ───────────────────────────────────────── new() -> []. %% add(ActivityType, Spec, State) — append Spec to ActivityType's list. add(ActivityType, Spec, State) -> Existing = lookup(ActivityType, State), set_keyed(ActivityType, append1(Existing, Spec), State). %% remove(TriggerCid, State) — drop every spec carrying TriggerCid, %% across all activity-types; empties are pruned. remove(TriggerCid, State) -> prune([{T, drop_cid(TriggerCid, Specs)} || {T, Specs} <- State]). %% lookup(ActivityType, State) — the specs bound to ActivityType ([] if %% none). lookup(ActivityType, State) -> case find_keyed(ActivityType, State) of {ok, Specs} -> Specs; not_found -> [] end. all(State) -> State. %% ── Hydration fold ────────────────────────────────────────────── %% %% fold(Activity, State) — register the binding carried by a %% DefineTrigger activity. Replaying the actor log through this fold %% rebuilds the registry after a restart (same content-addressing %% discipline as define_registry). A non-DefineTrigger activity passes %% through untouched. fold(Activity, State) -> case envelope:get_field(type, Activity) of {ok, define_trigger} -> fold_trigger(Activity, State); _ -> State end. fold_trigger(Activity, State) -> case envelope:get_field(object, Activity) of {ok, Obj} -> case binding_of(Activity, Obj) of {ok, AType, Spec} -> add(AType, Spec, State); not_a_binding -> State end; _ -> State end. binding_of(Activity, Obj) -> case envelope:get_field(activity_type, Obj) of {ok, AType} -> case envelope:get_field(flow_name, Obj) of {ok, FlowName} -> Guard = field_or(guard, Obj, undefined), Scope = field_or(actor_scope, Obj, any), Cid = field_or(id, Activity, undefined), {ok, AType, mk_spec(Cid, FlowName, Guard, Scope)}; _ -> not_a_binding end; _ -> not_a_binding end. %% fold_fn/0 — a 2-arity fun the projection scheduler can plant. fold_fn() -> fun (Activity, State) -> fold(Activity, State) end. %% ── gen_server wrapper ────────────────────────────────────────── start_link() -> start_link([]). start_link(InitialState) -> Pid = gen_server:start_link(trigger_registry, [InitialState]), erlang:register(trigger_registry, Pid), Pid. stop() -> R = gen_server:call(trigger_registry, '$gen_stop'), erlang:unregister(trigger_registry), R. add(ActivityType, Spec) -> gen_server:call(trigger_registry, {add, ActivityType, Spec}). remove(TriggerCid) -> gen_server:call(trigger_registry, {remove, TriggerCid}). lookup(ActivityType) -> gen_server:call(trigger_registry, {lookup, ActivityType}). all_triggers() -> gen_server:call(trigger_registry, all_triggers). init([InitialState]) -> {ok, InitialState}. handle_call({add, ActivityType, Spec}, _From, State) -> {reply, ok, add(ActivityType, Spec, State)}; handle_call({remove, TriggerCid}, _From, State) -> {reply, ok, remove(TriggerCid, State)}; handle_call({lookup, ActivityType}, _From, State) -> {reply, lookup(ActivityType, State), State}; handle_call(all_triggers, _From, State) -> {reply, State, State}. handle_cast(_, S) -> {noreply, S}. handle_info(_, S) -> {noreply, S}. %% ── helpers ───────────────────────────────────────────────────── field_or(Key, Proplist, Default) -> case envelope:get_field(Key, Proplist) of {ok, V} -> V; _ -> Default end. drop_cid(_, []) -> []; drop_cid(Cid, [Spec | Rest]) -> case spec_cid(Spec) of Cid -> drop_cid(Cid, Rest); _ -> [Spec | drop_cid(Cid, Rest)] end. prune([]) -> []; prune([{_, []} | Rest]) -> prune(Rest); prune([P | Rest]) -> [P | prune(Rest)]. append1([], X) -> [X]; append1([H | T], X) -> [H | append1(T, X)]. find_keyed(_, []) -> not_found; find_keyed(K, [{K, V} | _]) -> {ok, V}; find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest). set_keyed(K, V, []) -> [{K, V}]; set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest]; set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].