From 559ed68907da6f255312bbddf3a5337fc0cac93d Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 28 May 2026 18:50:21 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m1:=20Step=209b-pure=20=E2=80=94=20react?= =?UTF-8?q?ive=20smoke=20test=20in-process=20(trigger=20match+derive=20end?= =?UTF-8?q?-to-end)=20+=2012=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next/tests/smoke_app_pure.sh | 148 +++++++++++++++++++++++++++++++++++ plans/fed-sx-milestone-1.md | 4 +- 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100755 next/tests/smoke_app_pure.sh diff --git a/next/tests/smoke_app_pure.sh b/next/tests/smoke_app_pure.sh new file mode 100755 index 00000000..3dfd3ebb --- /dev/null +++ b/next/tests/smoke_app_pure.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# next/tests/smoke_app_pure.sh — Step 9b-pure smoke test. +# +# Mirrors §Step 9b structurally without TCP/curl/JSON. A trigger +# projection (Erlang fun) matches Note activities tagged +# "smoketest", constructs a derived TestEcho activity carrying +# the Note's CID via :echoes, and captures it into projection +# state. Proves the reactive-application mechanism — match-then- +# derive — works end-to-end through nx_kernel's broadcast. +# +# Cascade publication (the trigger actually publishing the +# derived activity back through outbox) is sidestepped to avoid +# gen_server reentrancy; the projection state is the proof point. +# 12 cases. + +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 + +# Shared prelude — KM/KS/AS, the Match function (Note + +# smoketest tag), the trigger fold body, and various activity +# proplists. +PRELUDE='KM = <<1,2,3,4>>, KS = [{key_id,k1},{algorithm,ed25519},{value,KM}], AS = [{public_keys,[[{id,k1},{created,0},{value,KM}]]}], Match = fun (Act) -> case envelope:get_field(type, Act) of {ok, note} -> case envelope:get_field(object, Act) of {ok, Obj} -> case envelope:get_field(tags, Obj) of {ok, Tags} -> lists:member(smoketest, Tags); _ -> false end; _ -> false end; _ -> false end end, TrigFold = fun (Act, {Captured, Count}) -> case Match(Act) of true -> {ok, Id} = envelope:get_field(id, Act), Derived = [{type, test_echo}, {object, [{echoes, Id}]}], {[Derived | Captured], Count + 1}; false -> {Captured, Count} end end, projection:start_link(trig, {[], 0}, TrigFold), nx_kernel:start_link(alice, KS, AS), nx_kernel:with_projections([trig]), MatchNote = [{type, note}, {object, [{content, hi}, {tags, [smoketest]}]}], NoMatchNote = [{type, note}, {object, [{content, plain}, {tags, [other]}]}],' + +cat > "$TMPFILE" < trigger fires exactly once +(epoch 13) +(eval "(erlang-eval-ast \"${PRELUDE} nx_kernel:publish(MatchNote), nx_kernel:publish(NoMatchNote), {_, Count} = projection:query(trig), Count\")") + +;; Trigger captures the derived TestEcho +(epoch 14) +(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:publish(MatchNote), {[Derived], _} = projection:query(trig), envelope:get_field(type, Derived) =:= {ok, test_echo}\") :name)") + +;; Derived TestEcho :echoes points at the Note's :id (CID) +(epoch 15) +(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:publish(MatchNote), {[Derived], _} = projection:query(trig), {ok, Obj} = envelope:get_field(object, Derived), {ok, EchoesId} = envelope:get_field(echoes, Obj), [Logged] = log:entries(nx_kernel:log_state(nx_kernel:query())), {ok, LoggedId} = envelope:get_field(id, Logged), EchoesId =:= LoggedId\") :name)") + +;; Two matching Notes -> trigger fires twice, captures both derived +(epoch 16) +(eval "(erlang-eval-ast \"${PRELUDE} nx_kernel:publish(MatchNote), MatchNote2 = [{type, note}, {object, [{content, hello}, {tags, [smoketest]}]}], nx_kernel:publish(MatchNote2), {Captured, Count} = projection:query(trig), {length(Captured), Count}\")") + +;; Trigger ignores non-Note activities even if they have :tags +(epoch 17) +(eval "(erlang-eval-ast \"${PRELUDE} OtherType = [{type, pin}, {object, [{tags, [smoketest]}, {path, p}, {cid, c}]}], nx_kernel:publish(OtherType), {_, Count} = projection:query(trig), Count\")") + +;; Trigger ignores Note without :tags +(epoch 18) +(eval "(erlang-eval-ast \"${PRELUDE} NoTag = [{type, note}, {object, [{content, hi}]}], nx_kernel:publish(NoTag), {_, Count} = projection:query(trig), Count\")") + +;; Multiple tags including smoketest -> matches +(epoch 19) +(eval "(erlang-eval-ast \"${PRELUDE} Many = [{type, note}, {object, [{content, hi}, {tags, [smoketest, foo, bar]}]}], nx_kernel:publish(Many), {_, Count} = projection:query(trig), Count\")") + +;; Sig-failed publish doesn't reach the trigger +(epoch 20) +(eval "(erlang-eval-ast \"OtherKM = <<9,9,9,9>>, BadKS = [{key_id,k1},{algorithm,ed25519},{value,OtherKM}], AS = [{public_keys,[[{id,k1},{created,0},{value,<<1,2,3,4>>}]]}], Match = fun (Act) -> case envelope:get_field(type, Act) of {ok, note} -> case envelope:get_field(object, Act) of {ok, Obj} -> case envelope:get_field(tags, Obj) of {ok, Tags} -> lists:member(smoketest, Tags); _ -> false end; _ -> false end; _ -> false end end, TrigFold = fun (Act, {Captured, Count}) -> case Match(Act) of true -> {ok, Id} = envelope:get_field(id, Act), Derived = [{type, test_echo}, {object, [{echoes, Id}]}], {[Derived | Captured], Count + 1}; false -> {Captured, Count} end end, projection:start_link(trig, {[], 0}, TrigFold), nx_kernel:start_link(alice, BadKS, AS), nx_kernel:with_projections([trig]), MatchNote = [{type, note}, {object, [{content, hi}, {tags, [smoketest]}]}], nx_kernel:publish(MatchNote), {_, Count} = projection:query(trig), Count\")") +EPOCHS + +OUTPUT=$(timeout 300 "$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="" + 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 8 "nx_kernel module loaded" "nx_kernel" +check 10 "initial Count = 0" "0" +check 11 "Match fires once" "1" +check 12 "Non-match does NOT fire" "0" +check 13 "Mix: only match fires" "1" +check 14 "Derived type = test_echo" "true" +check 15 "Derived :echoes = Note's :id" "true" +check 16 "Two matches -> 2 derived, count 2" "(2 2)" +check 17 "Non-Note ignored" "0" +check 18 "Note without tags ignored" "0" +check 19 "Multi-tag includes smoketest" "1" +check 20 "Sig failure -> no trigger" "0" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/smoke_app_pure.sh passed" +else + echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" + echo "$ERRORS" +fi +[ $FAIL -eq 0 ] diff --git a/plans/fed-sx-milestone-1.md b/plans/fed-sx-milestone-1.md index f771035c..565fedb9 100644 --- a/plans/fed-sx-milestone-1.md +++ b/plans/fed-sx-milestone-1.md @@ -579,7 +579,8 @@ Auth on `POST /activity`: bearer token from env var `NEXT_PUBLISH_TOKEN`. - [x] **9-pre-fold** — In-process end-to-end test of the HTTP → publish → broadcast → projection-fold chain. Proves the full vertical works without a real TCP socket. `next/tests/http_publish_fold.sh` (10 cases). Step 9a/b proper need TCP (Step 8b-start). - [x] **9a-pure** — In-process Pin smoke test mirroring the §Step 9a flow. Wires `define_registry:fold_fn/0` + an Erlang-fun pin-state fold into nx_kernel via `with_projections/1`. Publishes Create{DefineActivity{name: pin}} → registry update; publishes Pin{path: ..., cid: ...} → pin_state update. Order-independent; ignores Note + other types. `next/tests/smoke_pin_pure.sh` (13 cases). - [ ] **9a-tcp** — Same flow under curl over Step 8b-start once TCP listening lands. -- [ ] **9b** — Reactive smoke test (TCP-driven, curl) — needs DefineSubscription / DefineTrigger eval. +- [x] **9b-pure** — In-process reactive smoke test. A trigger projection (Erlang-fun fold) matches Note activities tagged `smoketest`, constructs a derived `TestEcho{echoes: }`, and captures it into projection state. Order-independent; non-Note + non-smoketest + sig-failed all suppressed correctly. `next/tests/smoke_app_pure.sh` (12 cases). Cascade publish via outbox sidestepped — reentrancy proof is a v2 concern. +- [ ] **9b-tcp** — Same flow under curl over Step 8b-start + cascade publish through outbox. **The proof points.** Two end-to-end smoke tests demonstrate, between them, that fed-sx is genuinely a substrate for distributed reactive applications expressed @@ -1001,6 +1002,7 @@ A few things still under-specified; resolve as work begins. Newest first. One line per sub-deliverable commit. Erlang conformance gate (`bash lib/erlang/conformance.sh`) must remain 729/729 on every entry. +- **2026-05-28** — Step 9b-pure: **reactive application extensibility, proven end-to-end.** Mirrors §Step 9b structurally without TCP/curl/JSON. A trigger projection (Erlang-fun fold over `{Captured, Count}` state) matches Note activities whose `:object :tags` contains `smoketest`, constructs a derived `TestEcho` activity with `:object :echoes` pointing at the Note's `:id`, and captures it into projection state. Order-independent; non-Note + non-smoketest + Note-without-tags + sig-failed publishes all suppressed correctly. Multi-tag (e.g. `[smoketest, foo, bar]`) still matches. Cascade publish (the trigger actually publishing the derived activity back through outbox) is deferred — the gen_server reentrancy that introduces is a v2 concern; the projection-state capture is sufficient proof of the match-then-derive mechanism. `next/tests/smoke_app_pure.sh` 12/12. Erlang conformance 729/729. - **2026-05-28** — Step 9a-pure: **the first verb-extensibility smoke test, proven end-to-end.** Mirrors §Step 9a structurally without TCP/curl/JSON. Two projections wired into `nx_kernel:with_projections([define_reg, pin_state])` — `define_reg` uses `define_registry:fold_fn/0` (Step 5d-pure), `pin_state` uses an Erlang-fun fold that records `{Path, Cid}` from Pin activities. Publish `Create{DefineActivity{name: pin}}` → registry update visible via `registry:lookup(activity_types, pin, projection:query(define_reg))`; publish `Pin{path: docs_intro, cid: qm_cid_1}` → `projection:query(pin_state) =:= [{docs_intro, qm_cid_1}]`. Order-independent (DefineActivity-then-Pin and Pin-then-DefineActivity both succeed); Note + non-Define types are pass-throughs in both projections. The TCP/curl variant (Step 9a-tcp) layers on Step 8b-start. `next/tests/smoke_pin_pure.sh` 13/13. Erlang conformance 729/729. - **2026-05-28** — Step 5d-pure: `next/kernel/define_registry.erl` — the meta-projection fold body, in pure Erlang. State shape mirrors `registry:new()` exactly; `fold/2` dispatches Create{Define*} to `registry:register/4` keyed by `define_kind/1` (define_activity → activity_types, define_object → object_types, …). Non-Create + Create{non-Define} + Define{no :name} are all pass-throughs. Override re-registration preserves a single entry per name. `fold_fn/0` plugs the fold into `projection:start_link/3` — verified end-to-end: activity → projection async_fold → query state → registry:lookup returns the registered Object. The SX `define-registry.sx` body will replace this once an SX-source eval bridge exists; the Erlang shape proves the wiring is correct. `next/tests/define_registry_pure.sh` 16/16. Erlang conformance 729/729. - **2026-05-28** — Step 6c-schema-pure: `pipeline:stage_schema/2` accepts (Activity, SchemaLookup) where SchemaLookup is a caller-supplied callback `fun(Type) -> {ok, SchemaFn} | not_found`. Open-world default — unregistered types resolve to ok so the pipeline doesn't block activities the kernel hasn't yet learned about (tightened to strict-world in milestone 2). Activities without `:object` skip the schema check. `stage_schema/1` returns a 1-arity stage fun closed over SchemaLookup for composition with run_stages. Halt order verified end-to-end: envelope-shape errors precede schema; envelope-ok + schema-fail surfaces `schema_mismatch`. The Erlang-fun shape is the substrate-friendly stand-in for the SX `:schema` bodies in genesis; same stage shape will dispatch through an SX-source eval bridge once it exists. `next/tests/pipeline_schema.sh` 14/14. Erlang conformance 729/729.