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>
207 lines
11 KiB
Bash
Executable File
207 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# next/flow/conformance.sh — flow-on-erlang engine conformance.
|
|
#
|
|
# Exercises the native Erlang-on-SX durable workflow engine
|
|
# (next/flow/{flow,flow_spec,flow_store}.erl): the combinator algebra,
|
|
# the deterministic-replay suspend/resume core, and the durable store.
|
|
# This is the gate for the engine, replacing lib/flow/conformance.sh
|
|
# (the Scheme engine) for the fed-sx substrate — the kernel's trigger
|
|
# fan-out drives flows in its own runtime, with no cross-guest FFI.
|
|
|
|
set -uo pipefail
|
|
cd "$(git rev-parse --show-toplevel)"
|
|
|
|
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
|
if [ ! -x "$SX_SERVER" ]; then
|
|
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
|
fi
|
|
if [ ! -x "$SX_SERVER" ]; then
|
|
echo "ERROR: sx_server.exe not found." >&2
|
|
exit 1
|
|
fi
|
|
|
|
VERBOSE="${1:-}"
|
|
PASS=0; FAIL=0; ERRORS=""
|
|
TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT
|
|
|
|
# Common combinator shorthands built per-epoch (Erlang locals don't
|
|
# survive across erlang-eval-ast calls; the gen_server state does).
|
|
N1='flow_spec:flow_node(fun(X) -> X + 1 end)'
|
|
N2='flow_spec:flow_node(fun(X) -> X * 2 end)'
|
|
SUSP_FLOW='flow_spec:sequence([flow_spec:flow_node(fun(X) -> X + 1 end), flow:suspend(wait1), flow_spec:flow_node(fun(V) -> {resumed, V} end)])'
|
|
TWO_SUSP='flow_spec:sequence([flow:suspend(a), flow_spec:flow_node(fun(V) -> V * 10 end), flow:suspend(b), flow_spec:flow_node(fun(V) -> V + 1 end)])'
|
|
|
|
cat > "$TMPFILE" <<EPOCHS
|
|
(epoch 1)
|
|
(load "lib/erlang/tokenizer.sx")
|
|
(load "lib/erlang/parser.sx")
|
|
(load "lib/erlang/parser-core.sx")
|
|
(load "lib/erlang/parser-expr.sx")
|
|
(load "lib/erlang/parser-module.sx")
|
|
(load "lib/erlang/transpile.sx")
|
|
(load "lib/erlang/runtime.sx")
|
|
(load "lib/erlang/vm/dispatcher.sx")
|
|
(epoch 2)
|
|
(eval "(er-load-gen-server!)")
|
|
(epoch 3)
|
|
(eval "(get (erlang-load-module (file-read \"next/flow/flow.erl\")) :name)")
|
|
(epoch 4)
|
|
(eval "(get (erlang-load-module (file-read \"next/flow/flow_spec.erl\")) :name)")
|
|
(epoch 5)
|
|
(eval "(get (erlang-load-module (file-read \"next/flow/flow_store.erl\")) :name)")
|
|
|
|
;; ── leaves ─────────────────────────────────────────────────
|
|
(epoch 10)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:flow_id(), 7) =:= {flow_done, 7}\") :name)")
|
|
(epoch 11)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:flow_const(k), 7) =:= {flow_done, k}\") :name)")
|
|
(epoch 12)
|
|
(eval "(get (erlang-eval-ast \"flow:run(${N1}, 41) =:= {flow_done, 42}\") :name)")
|
|
|
|
;; ── threading / fan-out / iteration ────────────────────────
|
|
(epoch 20)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:sequence([${N1}, ${N2}]), 3) =:= {flow_done, 8}\") :name)")
|
|
(epoch 21)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:parallel([flow_spec:flow_const(a), flow_spec:flow_const(b)]), 0) =:= {flow_done, [a, b]}\") :name)")
|
|
(epoch 22)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:map_flow(${N1}), [1, 2, 3]) =:= {flow_done, [2, 3, 4]}\") :name)")
|
|
(epoch 23)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:flow_while(fun(X) -> X < 10 end, ${N1}, 100), 0) =:= {flow_done, 10}\") :name)")
|
|
(epoch 24)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:flow_until(fun(X) -> X >= 5 end, ${N1}, 100), 0) =:= {flow_done, 5}\") :name)")
|
|
(epoch 25)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:flow_while(fun(_) -> true end, ${N1}, 3), 0) =:= {flow_done, 3}\") :name)")
|
|
|
|
;; ── branching ──────────────────────────────────────────────
|
|
(epoch 30)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:branch(fun(X) -> X > 0 end, flow_spec:flow_const(pos), flow_spec:flow_const(neg)), 5) =:= {flow_done, pos}\") :name)")
|
|
(epoch 31)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:branch(fun(X) -> X > 0 end, flow_spec:flow_const(pos), flow_spec:flow_const(neg)), -5) =:= {flow_done, neg}\") :name)")
|
|
|
|
;; ── railway failure ────────────────────────────────────────
|
|
(epoch 40)
|
|
(eval "(get (erlang-eval-ast \"flow_spec:failed(flow_spec:fail(x)) andalso (flow_spec:failed(42) =:= false)\") :name)")
|
|
(epoch 41)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:attempt([flow_spec:flow_node(fun(_) -> flow_spec:fail(boom) end), flow_spec:flow_node(fun(_) -> 999 end)]), 0) =:= {flow_done, {flow_fail, boom}}\") :name)")
|
|
(epoch 42)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:attempt([${N1}, ${N2}]), 3) =:= {flow_done, 8}\") :name)")
|
|
(epoch 43)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:recover(flow_spec:flow_node(fun(_) -> flow_spec:fail(bad) end), fun(R) -> {ok, R} end), 0) =:= {flow_done, {ok, bad}}\") :name)")
|
|
|
|
;; ── effects / exceptions ───────────────────────────────────
|
|
(epoch 50)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:tap(fun(_) -> ok end), 7) =:= {flow_done, 7}\") :name)")
|
|
(epoch 51)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:try_catch(flow_spec:flow_node(fun(_) -> throw(oops) end), fun(E) -> {caught, E} end), 0) =:= {flow_done, {caught, oops}}\") :name)")
|
|
(epoch 52)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:retry(5, flow_spec:flow_node(fun(X) -> X + 1 end)), 1) =:= {flow_done, 2}\") :name)")
|
|
|
|
;; ── suspend / replay (deterministic-replay core) ───────────
|
|
(epoch 60)
|
|
(eval "(get (erlang-eval-ast \"flow:run(${SUSP_FLOW}, 0) =:= {flow_suspended, wait1}\") :name)")
|
|
(epoch 61)
|
|
(eval "(get (erlang-eval-ast \"flow:drive(${SUSP_FLOW}, 0, [{wait1, 99}]) =:= {flow_done, {resumed, 99}}\") :name)")
|
|
(epoch 62)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:sequence([flow:suspend(a), flow:suspend(b)]), 0) =:= {flow_suspended, a}\") :name)")
|
|
;; wait/1 — timer-style suspend that PRESERVES the value on resume
|
|
(epoch 63)
|
|
(eval "(get (erlang-eval-ast \"flow:run(flow_spec:sequence([flow:wait(t), flow_spec:flow_node(fun(X) -> X + 1 end)]), 5) =:= {flow_suspended, t}\") :name)")
|
|
(epoch 64)
|
|
(eval "(get (erlang-eval-ast \"flow:drive(flow_spec:sequence([flow:wait(t), flow_spec:flow_node(fun(X) -> X + 1 end)]), 5, [{t, ignored}]) =:= {flow_done, 6}\") :name)")
|
|
|
|
;; ── durable store: registry ────────────────────────────────
|
|
(epoch 70)
|
|
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(f1, ${N1}), flow_store:resolve_flow(f1) =/= not_found andalso flow_store:registered_flows() =:= [f1]\") :name)")
|
|
(epoch 71)
|
|
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:resolve_flow(ghost) =:= not_found\") :name)")
|
|
|
|
;; ── durable store: start / resume ──────────────────────────
|
|
;; one-shot flow runs to completion on start
|
|
(epoch 80)
|
|
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(done1, ${N1}), flow_store:start(done1, 41) =:= {ok, 1, {flow_done, 42}}\") :name)")
|
|
;; suspending flow: start suspends, resume completes
|
|
(epoch 81)
|
|
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(s1, ${SUSP_FLOW}), {ok, Id, R} = flow_store:start(s1, 10), R =:= {flow_suspended, wait1} andalso flow_store:status(Id) =:= {ok, {suspended, wait1}}\") :name)")
|
|
(epoch 82)
|
|
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(s1, ${SUSP_FLOW}), {ok, Id, _} = flow_store:start(s1, 10), flow_store:resume(Id, 99) =:= {ok, {flow_done, {resumed, 99}}}\") :name)")
|
|
(epoch 83)
|
|
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(s1, ${SUSP_FLOW}), {ok, Id, _} = flow_store:start(s1, 10), flow_store:resume(Id, 99), flow_store:status(Id) =:= {ok, {done, {resumed, 99}}}\") :name)")
|
|
;; two-suspend flow: resume chain accumulates the replay log
|
|
(epoch 84)
|
|
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(s2, ${TWO_SUSP}), {ok, Id, _} = flow_store:start(s2, 0), {ok, R1} = flow_store:resume(Id, 5), R2 = flow_store:resume(Id, 7), R1 =:= {flow_suspended, b} andalso R2 =:= {ok, {flow_done, 8}}\") :name)")
|
|
;; error paths
|
|
(epoch 85)
|
|
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:start(ghost, 0) =:= {error, no_such_flow}\") :name)")
|
|
(epoch 86)
|
|
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:resume(999, x) =:= {error, no_such_instance}\") :name)")
|
|
(epoch 87)
|
|
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(done1, ${N1}), {ok, Id, _} = flow_store:start(done1, 0), flow_store:resume(Id, x) =:= {error, already_done}\") :name)")
|
|
EPOCHS
|
|
|
|
OUTPUT=$(timeout 360 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
|
|
|
check() {
|
|
local epoch="$1" desc="$2" expected="$3"
|
|
local actual
|
|
actual=$(echo "$OUTPUT" | awk -v e="$epoch" '
|
|
$0 ~ "^\\(ok-len " e " " { getline; print; exit }
|
|
$0 ~ "^\\(ok " e " " { print; exit }
|
|
$0 ~ "^\\(error " e " " { print; exit }
|
|
')
|
|
[ -z "$actual" ] && actual="<no output for epoch $epoch>"
|
|
if echo "$actual" | grep -qF -- "$expected"; then
|
|
PASS=$((PASS+1))
|
|
[ "$VERBOSE" = "-v" ] && echo " ok $desc"
|
|
else
|
|
FAIL=$((FAIL+1))
|
|
ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual
|
|
"
|
|
fi
|
|
}
|
|
|
|
check 3 "flow module loaded" "flow"
|
|
check 4 "flow_spec module loaded" "flow_spec"
|
|
check 5 "flow_store module loaded" "flow_store"
|
|
check 10 "flow_id" "true"
|
|
check 11 "flow_const" "true"
|
|
check 12 "flow_node" "true"
|
|
check 20 "sequence threads left-to-right" "true"
|
|
check 21 "parallel fans out" "true"
|
|
check 22 "map_flow over a list" "true"
|
|
check 23 "flow_while bounded by pred" "true"
|
|
check 24 "flow_until bounded by pred" "true"
|
|
check 25 "flow_while bounded by max" "true"
|
|
check 30 "branch then-arm" "true"
|
|
check 31 "branch else-arm" "true"
|
|
check 40 "failed? predicate" "true"
|
|
check 41 "attempt stops at first fail" "true"
|
|
check 42 "attempt threads on success" "true"
|
|
check 43 "recover handles fail value" "true"
|
|
check 50 "tap pass-through" "true"
|
|
check 51 "try_catch catches a raise" "true"
|
|
check 52 "retry runs node" "true"
|
|
check 60 "suspend miss short-circuits" "true"
|
|
check 61 "suspend replay completes" "true"
|
|
check 62 "first of two suspends wins" "true"
|
|
check 63 "wait short-circuits on miss" "true"
|
|
check 64 "wait preserves value on resume" "true"
|
|
check 70 "register + resolve + list" "true"
|
|
check 71 "resolve unknown -> not_found" "true"
|
|
check 80 "start one-shot -> done" "true"
|
|
check 81 "start suspends + status" "true"
|
|
check 82 "resume completes" "true"
|
|
check 83 "status after resume = done" "true"
|
|
check 84 "two-suspend resume chain" "true"
|
|
check 85 "start unknown -> no_such_flow" "true"
|
|
check 86 "resume unknown -> no_such_instance" "true"
|
|
check 87 "resume a done flow -> already_done" "true"
|
|
|
|
TOTAL=$((PASS+FAIL))
|
|
if [ $FAIL -eq 0 ]; then
|
|
echo "ok $PASS/$TOTAL flow-on-erlang engine tests passed"
|
|
else
|
|
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
|
echo "$ERRORS"
|
|
fi
|
|
[ $FAIL -eq 0 ]
|