Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 58s
The motivating end-to-end demonstration (fed-sx-triggers-loop.md Phase 4): one trigger arriving in the pipeline drives a multi-step business flow with a branch, a timer suspension, an injected effect, and a follow-up activity emit — all in the kernel's own runtime. - flow.erl: flow:wait/1 — a timer-style suspend that PRESERVES the value on resume (vs flow:suspend/1, which returns the logged result), so a "wait until morning" step lets the env flow through to later steps. - next/flow/flows/blog_publish_digest.erl: the flow. Branches on the article :category (newsletter -> wait-until-morning -> send + emit; urgent -> send + emit now; else -> skip), fetches followers (injected), builds a digest email per follower, and emits a DigestSent activity OBJECT. Effect-as-data: a flow can't call kernel gen_servers from inside the drive (a blocking call there deadlocks the scheduler), so it returns the emails + DigestSent object for a driver to dispatch and append — which can then trigger downstream flows, closing the loop. Test: triggers_e2e.sh (10) — urgent completes in one cycle with 3 emails + a DigestSent object; newsletter suspends on the morning timer, then resumes to the same on "advancing the clock"; draft takes the else branch (no emails); a non-Article note is rejected by the guard; a duplicate activity fires once. flow:wait covered in next/flow (36/36). plans/fed-sx-design.md §13.10 documents the trigger fan-out as a kernel convention. lib/erlang 771/771. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
103 lines
4.4 KiB
Erlang
103 lines
4.4 KiB
Erlang
-module(flow).
|
|
-export([drive/3, run/2,
|
|
cont/2, susp/2, is_susp/1, ctx_value/1, ctx_log/1,
|
|
suspend/1, wait/1, log_lookup/2]).
|
|
|
|
%% flow-on-erlang — the deterministic-replay core. A native Erlang port
|
|
%% of the Scheme flow engine (lib/flow), so the fed-sx kernel can fan
|
|
%% activities out into durable business flows in its own runtime (no
|
|
%% cross-guest FFI).
|
|
%%
|
|
%% Durability model — identical semantics to the Scheme engine, but
|
|
%% adapted to this Erlang-on-SX runtime, which has three hard
|
|
%% constraints the Scheme host doesn't: no escape continuation that can
|
|
%% be re-entered, no process dictionary, and (critically) a blocking
|
|
%% `receive` / `gen_server:call` inside a `try` deadlocks the
|
|
%% cooperative scheduler. So instead of the Scheme engine's
|
|
%% mutable-global + call/cc-escape, the replay log is THREADED through a
|
|
%% railway-style context and `suspend` SHORT-CIRCUITS (like a fail
|
|
%% value) rather than throwing. No ambient state, no throw, no
|
|
%% gen_server — purely functional, which sidesteps every constraint.
|
|
%%
|
|
%% A node is `fun(Ctx) -> Ctx`. A Ctx is one of:
|
|
%% {flow_cont, Value, Log} — running; Value is the current value
|
|
%% {flow_susp, Tag, Log} — short-circuited at suspend Tag
|
|
%% Log is the replay log: [{Tag, ResolvedValue}, ...]. Combinators
|
|
%% (flow_spec) thread Ctx and pass {flow_susp,...} straight through, so
|
|
%% once a flow suspends nothing downstream runs.
|
|
%%
|
|
%% suspend/1 is the load-bearing primitive: a node that, given the
|
|
%% running Ctx, looks Tag up in the replay log. A hit replaces the
|
|
%% current value with the logged value and continues; a miss
|
|
%% short-circuits to {flow_susp, Tag, Log}. ALL effects/non-determinism
|
|
%% go through a suspend node so they run once — in the driver, between
|
|
%% drives — and their results are logged, never re-run on replay. Tags
|
|
%% must be unique and deterministic across replays.
|
|
|
|
%% ── context constructors / accessors ────────────────────────────
|
|
|
|
cont(Value, Log) -> {flow_cont, Value, Log}.
|
|
susp(Tag, Log) -> {flow_susp, Tag, Log}.
|
|
|
|
is_susp({flow_susp, _, _}) -> true;
|
|
is_susp(_) -> false.
|
|
|
|
ctx_value({flow_cont, Value, _}) -> Value;
|
|
ctx_value({flow_susp, _, _}) -> undefined.
|
|
|
|
ctx_log({flow_cont, _, Log}) -> Log;
|
|
ctx_log({flow_susp, _, Log}) -> Log.
|
|
|
|
%% ── suspend node ────────────────────────────────────────────────
|
|
|
|
suspend(Tag) ->
|
|
fun (Ctx) ->
|
|
case Ctx of
|
|
{flow_susp, _, _} -> Ctx;
|
|
{flow_cont, _Value, Log} ->
|
|
case log_lookup(Tag, Log) of
|
|
{ok, Resolved} -> {flow_cont, Resolved, Log};
|
|
miss -> {flow_susp, Tag, Log}
|
|
end
|
|
end
|
|
end.
|
|
|
|
%% wait(Tag) — a timer-style suspend that PRESERVES the current value
|
|
%% instead of replacing it with the resolved one. Use it for pure
|
|
%% waits ("resume in the morning") where the resume is just a signal,
|
|
%% not a result: on the first pass it short-circuits like suspend; once
|
|
%% Tag is in the log the value flows through unchanged, so downstream
|
|
%% steps still see the value (e.g. the env) they had before the wait.
|
|
wait(Tag) ->
|
|
fun (Ctx) ->
|
|
case Ctx of
|
|
{flow_susp, _, _} -> Ctx;
|
|
{flow_cont, Value, Log} ->
|
|
case log_lookup(Tag, Log) of
|
|
{ok, _} -> {flow_cont, Value, Log};
|
|
miss -> {flow_susp, Tag, Log}
|
|
end
|
|
end
|
|
end.
|
|
|
|
log_lookup(_, []) -> miss;
|
|
log_lookup(Tag, [{Tag, Value} | _]) -> {ok, Value};
|
|
log_lookup(Tag, [_ | Rest]) -> log_lookup(Tag, Rest).
|
|
|
|
%% ── driver ──────────────────────────────────────────────────────
|
|
|
|
%% drive(Flow, Input, Log) — run Flow under the replay Log.
|
|
%% {flow_done, Result} — flow completed
|
|
%% {flow_suspended, Tag} — flow short-circuited at an unresolved
|
|
%% suspend; the driver resolves Tag, appends
|
|
%% {Tag, Value} to Log, and re-drives.
|
|
drive(Flow, Input, Log) ->
|
|
case Flow({flow_cont, Input, Log}) of
|
|
{flow_cont, Result, _} -> {flow_done, Result};
|
|
{flow_susp, Tag, _} -> {flow_suspended, Tag}
|
|
end.
|
|
|
|
%% run(Flow, Input) — drive with an empty replay log.
|
|
run(Flow, Input) ->
|
|
drive(Flow, Input, []).
|