Compare commits
60 Commits
loops/fed-
...
loops/fed-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c9b96390f | |||
| 6b4850b34e | |||
| fc6a47ad62 | |||
| 8b3d92ed5f | |||
| bba2d7e5cd | |||
| 89dd23c287 | |||
| 441a895737 | |||
| 8d54028c7f | |||
| 5959a97dca | |||
| 4da2a98c30 | |||
| 779e53b2a8 | |||
| d09c0048c7 | |||
| 3dbb3e318a | |||
| 29e4234b14 | |||
| cd0de8cb34 | |||
| 03c32cda5f | |||
| 600d292ba2 | |||
| 1d771aedea | |||
| 136deb1daf | |||
| eafb687b53 | |||
| 8d33d02f92 | |||
| 9a204e84ab | |||
| 57684c4589 | |||
| bd2c61367d | |||
| 070986913d | |||
| 3629b2923f | |||
| 9621599606 | |||
| b2b61a0112 | |||
| 80f6fc9279 | |||
| aa27d903ac | |||
| ff024d1b5d | |||
| 8ba3584556 | |||
| 8bf2b45cf9 | |||
| dda967e060 | |||
| bf4e034c4e | |||
| c6b4920074 | |||
| 536473cd68 | |||
| 02c1f0f979 | |||
| 086c576d48 | |||
| ee8a396ccd | |||
| 1d83120918 | |||
| e890380a1a | |||
| 6231a82be0 | |||
| d36fe4ee97 | |||
| d481af5791 | |||
| d103ecb863 | |||
| bc4b23cc62 | |||
| a23a2eb95a | |||
| 6cfb1cb2d3 | |||
| e04a65d400 | |||
| 271632c923 | |||
| 0b8772ec69 | |||
| 238a1fbea0 | |||
| 1fd85e10e6 | |||
| bcfbd9a528 | |||
| 0c44a10c8f | |||
| 089d1445a1 | |||
| 6a9bd054c7 | |||
| 9b04769a27 | |||
| 7ea9d04564 |
@@ -38,6 +38,7 @@ SUITES=(
|
||||
"fib|er-fib-test-pass|er-fib-test-count"
|
||||
"ffi|er-ffi-test-pass|er-ffi-test-count"
|
||||
"vm|er-vm-test-pass|er-vm-test-count"
|
||||
"send_after|er-sa-test-pass|er-sa-test-count"
|
||||
)
|
||||
|
||||
cat > "$TMPFILE" << 'EPOCHS'
|
||||
@@ -61,6 +62,7 @@ cat > "$TMPFILE" << 'EPOCHS'
|
||||
(load "lib/erlang/vm/dispatcher.sx")
|
||||
(load "lib/erlang/tests/ffi.sx")
|
||||
(load "lib/erlang/tests/vm.sx")
|
||||
(load "lib/erlang/tests/send_after.sx")
|
||||
(epoch 100)
|
||||
(eval "(list er-test-pass er-test-count)")
|
||||
(epoch 101)
|
||||
@@ -83,6 +85,8 @@ cat > "$TMPFILE" << 'EPOCHS'
|
||||
(eval "(list er-ffi-test-pass er-ffi-test-count)")
|
||||
(epoch 110)
|
||||
(eval "(list er-vm-test-pass er-vm-test-count)")
|
||||
(epoch 111)
|
||||
(eval "(list er-sa-test-pass er-sa-test-count)")
|
||||
EPOCHS
|
||||
|
||||
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||
|
||||
@@ -135,6 +135,56 @@
|
||||
(dict-set! s :next-ref (+ n 1))
|
||||
(er-mk-ref n)))))
|
||||
|
||||
;; ── logical clock + timer wheel ──────────────────────────────────
|
||||
;; The scheduler runs a synchronous model: logical time advances only
|
||||
;; when the runnable queue drains (see `er-sched-advance-time!`). The
|
||||
;; clock is in milliseconds, monotonic, never derived from wall time
|
||||
;; — deterministic and time-travel-safe. `send_after` schedules a
|
||||
;; message-delivery event at an absolute deadline; `receive after Ms`
|
||||
;; schedules a timeout event the same way. When no process is runnable
|
||||
;; the scheduler jumps the clock to the earliest pending deadline and
|
||||
;; fires that single event, then re-runs.
|
||||
(define er-clock (fn () (get (er-sched) :clock)))
|
||||
|
||||
;; Advance the clock to `ms`, but never backwards (monotonicity).
|
||||
(define
|
||||
er-clock-set!
|
||||
(fn (ms) (dict-set! (er-sched) :clock (max (er-clock) ms))))
|
||||
|
||||
(define er-sched-timers (fn () (get (er-sched) :timers)))
|
||||
|
||||
;; Register a timer event. `dest` is a pid or registered-atom value,
|
||||
;; resolved to a live process at fire time. Returns the timer ref.
|
||||
(define
|
||||
er-timer-add!
|
||||
(fn
|
||||
(deadline dest msg ref)
|
||||
(append!
|
||||
(er-sched-timers)
|
||||
{:ref ref :deadline deadline :dest dest :msg msg :alive true})
|
||||
ref))
|
||||
|
||||
;; Find the live timer with the given ref, or nil.
|
||||
(define
|
||||
er-timer-find-alive
|
||||
(fn
|
||||
(ref)
|
||||
(let
|
||||
((ts (er-sched-timers)) (found (list nil)))
|
||||
(for-each
|
||||
(fn
|
||||
(i)
|
||||
(let
|
||||
((t (nth ts i)))
|
||||
(when
|
||||
(and
|
||||
(= (nth found 0) nil)
|
||||
(get t :alive)
|
||||
(er-ref-equal? (get t :ref) ref))
|
||||
(set-nth! found 0 t))))
|
||||
(range 0 (len ts)))
|
||||
(nth found 0))))
|
||||
|
||||
;; ── scheduler state ──────────────────────────────────────────────
|
||||
(define er-scheduler (list nil))
|
||||
|
||||
@@ -151,6 +201,8 @@
|
||||
:processes {}
|
||||
:registered {}
|
||||
:ets {}
|
||||
:clock 0
|
||||
:timers (list)
|
||||
:runnable (er-q-new)})))
|
||||
|
||||
(define er-sched (fn () (nth er-scheduler 0)))
|
||||
@@ -217,6 +269,7 @@
|
||||
:trap-exit false
|
||||
:has-timeout false
|
||||
:timed-out false
|
||||
:timeout-deadline nil
|
||||
:exit-reason nil}))
|
||||
(dict-set! (er-sched-processes) (er-pid-key pid) proc)
|
||||
(er-sched-enqueue! pid)
|
||||
@@ -456,6 +509,69 @@
|
||||
(error "Erlang: make_ref/0: arity")
|
||||
(er-ref-new!))))
|
||||
|
||||
;; ── timer BIFs ───────────────────────────────────────────────────
|
||||
;; erlang:send_after(Time, Dest, Msg) -> Ref
|
||||
;; Schedules Msg to be delivered to Dest after Time ms (logical).
|
||||
;; Time must be a non-negative integer; Dest a pid or registered
|
||||
;; atom name. Returns a fresh timer reference.
|
||||
(define
|
||||
er-bif-send-after
|
||||
(fn
|
||||
(vs)
|
||||
(let
|
||||
((time (nth vs 0)) (dest (nth vs 1)) (msg (nth vs 2)))
|
||||
(cond
|
||||
(not (and (= (type-of time) "number") (>= time 0)))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
(not (or (er-pid? dest) (er-atom? dest)))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else
|
||||
(er-timer-add!
|
||||
(+ (er-clock) (truncate time))
|
||||
dest
|
||||
msg
|
||||
(er-ref-new!))))))
|
||||
|
||||
;; erlang:cancel_timer(Ref) -> RemainingMs | false
|
||||
;; For a live (not-yet-fired) timer, marks it cancelled and returns
|
||||
;; the milliseconds left until its deadline. For an already-fired,
|
||||
;; already-cancelled, or unknown ref, returns the atom `false`.
|
||||
(define
|
||||
er-bif-cancel-timer
|
||||
(fn
|
||||
(vs)
|
||||
(let
|
||||
((ref (nth vs 0)))
|
||||
(cond
|
||||
(not (er-ref? ref))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let
|
||||
((t (er-timer-find-alive ref)))
|
||||
(cond
|
||||
(= t nil) (er-mk-atom "false")
|
||||
:else (do
|
||||
(dict-set! t :alive false)
|
||||
(max 0 (- (get t :deadline) (er-clock))))))))))
|
||||
|
||||
;; erlang:monotonic_time() | erlang:monotonic_time(Unit) -> Integer
|
||||
;; Returns the scheduler's logical monotonic clock in milliseconds.
|
||||
;; Unit (millisecond / second / native) is accepted for API
|
||||
;; compatibility; all units report from the same ms-resolution clock.
|
||||
(define
|
||||
er-bif-monotonic-time
|
||||
(fn
|
||||
(vs)
|
||||
(cond
|
||||
(= (len vs) 0) (er-clock)
|
||||
(and (= (len vs) 1) (er-atom? (nth vs 0)))
|
||||
(let
|
||||
((unit (get (nth vs 0) :name)))
|
||||
(cond
|
||||
(= unit "second") (truncate (/ (er-clock) 1000))
|
||||
:else (er-clock)))
|
||||
:else (raise (er-mk-error-marker (er-mk-atom "badarg"))))))
|
||||
|
||||
;; Add `target` to `pid`'s :links list if not already there.
|
||||
(define
|
||||
er-link-add-one!
|
||||
@@ -664,37 +780,122 @@
|
||||
(cond
|
||||
(not (= pid nil))
|
||||
(do (er-sched-step! pid) (er-sched-run-all!))
|
||||
;; Queue empty — fire one pending receive-with-timeout and go again.
|
||||
(er-sched-fire-one-timeout!) (er-sched-run-all!)
|
||||
;; Queue empty — advance logical time to the next pending
|
||||
;; deadline (timer delivery or receive-timeout) and go again.
|
||||
(er-sched-advance-time!) (er-sched-run-all!)
|
||||
:else nil))))
|
||||
|
||||
;; Wake one waiting process whose receive had an `after Ms` clause.
|
||||
;; Returns true if one fired. In our synchronous model "time passes"
|
||||
;; once the runnable queue drains — timeouts only fire then.
|
||||
;; ── time advance ─────────────────────────────────────────────────
|
||||
;; Called when the runnable queue is empty. Two kinds of pending event
|
||||
;; carry a deadline: live `send_after` timers and waiting processes in
|
||||
;; a `receive ... after Ms` block. Find the single earliest deadline
|
||||
;; across both, jump the clock to it, and fire just that one event
|
||||
;; (timer wins ties — a message delivered exactly at the timeout
|
||||
;; arrives "first"). Returns true if an event fired, false when there
|
||||
;; is nothing left to wake (genuine idle / termination).
|
||||
(define
|
||||
er-sched-fire-one-timeout!
|
||||
er-sched-advance-time!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((ks (keys (er-sched-processes))) (fired (list false)))
|
||||
((best (er-sched-next-event)))
|
||||
(cond
|
||||
(= best nil) false
|
||||
:else (do
|
||||
(er-clock-set! (get best :deadline))
|
||||
(cond
|
||||
(= (get best :kind) "timer")
|
||||
(er-timer-fire! (get best :timer))
|
||||
:else (er-recv-timeout-fire! (get best :proc)))
|
||||
true)))))
|
||||
|
||||
;; Scan timers and waiting-with-timeout processes for the earliest
|
||||
;; deadline. Returns {:kind "timer"|"recv" :deadline D ...} or nil.
|
||||
(define
|
||||
er-sched-next-event
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((best (list nil)))
|
||||
(for-each
|
||||
(fn
|
||||
(i)
|
||||
(let
|
||||
((t (nth (er-sched-timers) i)))
|
||||
(when
|
||||
(get t :alive)
|
||||
(er-event-consider!
|
||||
best
|
||||
{:kind "timer" :deadline (get t :deadline) :timer t}))))
|
||||
(range 0 (len (er-sched-timers))))
|
||||
(for-each
|
||||
(fn
|
||||
(k)
|
||||
(when
|
||||
(not (nth fired 0))
|
||||
(let
|
||||
((p (get (er-sched-processes) k)))
|
||||
(when
|
||||
(and
|
||||
(= (get p :state) "waiting")
|
||||
(get p :has-timeout))
|
||||
(dict-set! p :timed-out true)
|
||||
(dict-set! p :has-timeout false)
|
||||
(dict-set! p :state "runnable")
|
||||
(er-sched-enqueue! (get p :pid))
|
||||
(set-nth! fired 0 true)))))
|
||||
ks)
|
||||
(nth fired 0))))
|
||||
(let
|
||||
((p (get (er-sched-processes) k)))
|
||||
(when
|
||||
(and (= (get p :state) "waiting") (get p :has-timeout))
|
||||
(er-event-consider!
|
||||
best
|
||||
{:kind "recv"
|
||||
:deadline (get p :timeout-deadline)
|
||||
:proc p}))))
|
||||
(keys (er-sched-processes)))
|
||||
(nth best 0))))
|
||||
|
||||
;; Keep the earlier-deadline candidate in the single-cell `best`.
|
||||
;; Strictly-earlier replaces; equal deadlines keep the incumbent so a
|
||||
;; timer registered first (and timers over recv-timeouts) win ties.
|
||||
(define
|
||||
er-event-consider!
|
||||
(fn
|
||||
(best cand)
|
||||
(when
|
||||
(or
|
||||
(= (nth best 0) nil)
|
||||
(< (get cand :deadline) (get (nth best 0) :deadline)))
|
||||
(set-nth! best 0 cand))))
|
||||
|
||||
;; Deliver a fired timer's message to its destination and retire it.
|
||||
;; Destination is resolved at fire time; a dead/missing target (or an
|
||||
;; unregistered name) silently drops the message, as in real Erlang.
|
||||
(define
|
||||
er-timer-fire!
|
||||
(fn
|
||||
(t)
|
||||
(dict-set! t :alive false)
|
||||
(let
|
||||
((pid (er-timer-resolve-dest (get t :dest))))
|
||||
(when
|
||||
(and (not (= pid nil)) (er-proc-exists? pid))
|
||||
(er-proc-mailbox-push! pid (get t :msg))
|
||||
(when
|
||||
(= (er-proc-field pid :state) "waiting")
|
||||
(er-proc-set! pid :state "runnable")
|
||||
(er-sched-enqueue! pid))))))
|
||||
|
||||
;; Non-raising destination resolver for timer delivery.
|
||||
(define
|
||||
er-timer-resolve-dest
|
||||
(fn
|
||||
(v)
|
||||
(cond
|
||||
(er-pid? v) v
|
||||
(er-atom? v)
|
||||
(let
|
||||
((name (get v :name)))
|
||||
(if (dict-has? (er-registered) name) (get (er-registered) name) nil))
|
||||
:else nil)))
|
||||
|
||||
;; Wake a process whose `receive ... after Ms` deadline elapsed.
|
||||
(define
|
||||
er-recv-timeout-fire!
|
||||
(fn
|
||||
(p)
|
||||
(dict-set! p :timed-out true)
|
||||
(dict-set! p :has-timeout false)
|
||||
(dict-set! p :state "runnable")
|
||||
(er-sched-enqueue! (get p :pid))))
|
||||
|
||||
(define
|
||||
er-sched-step!
|
||||
@@ -731,7 +932,10 @@
|
||||
0
|
||||
(if
|
||||
(= prev-k nil)
|
||||
(er-apply-fun (er-proc-field pid :initial-fun) (list))
|
||||
(er-apply-fun
|
||||
(er-proc-field pid :initial-fun)
|
||||
(let ((args (er-proc-field pid :pending-args)))
|
||||
(cond (= args nil) (list) :else args)))
|
||||
(do (er-proc-set! pid :continuation nil) (prev-k nil)))))
|
||||
(let
|
||||
((r (nth result-ref 0)))
|
||||
@@ -1174,8 +1378,15 @@
|
||||
{reply, Reply, NewState} ->
|
||||
From ! {Ref, Reply},
|
||||
gen_server:loop(Mod, NewState);
|
||||
{reply, Reply, NewState, Timeout} ->
|
||||
From ! {Ref, Reply},
|
||||
erlang:send_after(Timeout, self(), {timeout}),
|
||||
gen_server:loop(Mod, NewState);
|
||||
{noreply, NewState} ->
|
||||
gen_server:loop(Mod, NewState);
|
||||
{noreply, NewState, Timeout} ->
|
||||
erlang:send_after(Timeout, self(), {timeout}),
|
||||
gen_server:loop(Mod, NewState);
|
||||
{stop, Reason, Reply, NewState} ->
|
||||
From ! {Ref, Reply},
|
||||
exit(Reason)
|
||||
@@ -1183,11 +1394,17 @@
|
||||
{'$gen_cast', Msg} ->
|
||||
case Mod:handle_cast(Msg, State) of
|
||||
{noreply, NewState} -> gen_server:loop(Mod, NewState);
|
||||
{noreply, NewState, Timeout} ->
|
||||
erlang:send_after(Timeout, self(), {timeout}),
|
||||
gen_server:loop(Mod, NewState);
|
||||
{stop, Reason, NewState} -> exit(Reason)
|
||||
end;
|
||||
Other ->
|
||||
case Mod:handle_info(Other, State) of
|
||||
{noreply, NewState} -> gen_server:loop(Mod, NewState);
|
||||
{noreply, NewState, Timeout} ->
|
||||
erlang:send_after(Timeout, self(), {timeout}),
|
||||
gen_server:loop(Mod, NewState);
|
||||
{stop, Reason, NewState} -> exit(Reason)
|
||||
end
|
||||
end.")
|
||||
@@ -1590,9 +1807,124 @@
|
||||
(not (er-fun? handler))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (let
|
||||
((sx-handler (fn (req-dict) (er-http-resp-to-sx (er-apply-fun handler (list (er-http-req-of-sx req-dict)))))))
|
||||
;; Bridge between native http-listen and Erlang handler.
|
||||
;;
|
||||
;; Inbound: native passes Req as SX Dict
|
||||
;; {:method :path :query :headers :body}
|
||||
;; converted to Erlang request proplist via the live
|
||||
;; er-request-dict-to-proplist marshaller — that's the
|
||||
;; same shape http_server:route/2 consumes (binaries
|
||||
;; for path/method/body, dict-like proplist for headers).
|
||||
;;
|
||||
;; Outbound: Erlang handler returns
|
||||
;; [{status, Int}, {headers, [{Bin, Bin}, ...]}, {body, Bin}]
|
||||
;; converted back to SX Dict via er-proplist-to-dict —
|
||||
;; binary values become SX strings, the headers cons
|
||||
;; flattens to a nested SX dict (via er-to-sx-deep's
|
||||
;; proplist-2tuple detection). Matches what native
|
||||
;; http-listen serialises to the wire.
|
||||
;;
|
||||
;; (Step 8b-bridge originally shipped parallel
|
||||
;; er-http-req-of-sx / er-http-resp-to-sx helpers; commit
|
||||
;; 78eae9ef deleted them as dead because the BIF body
|
||||
;; still referenced them — Blockers #1. This rewrite
|
||||
;; threads through the live marshallers instead.)
|
||||
;; Run the handler as a SCHEDULED er-process so any
|
||||
;; `receive` (e.g. gen_server:call inside a kernel-aware
|
||||
;; route) suspends and resumes inside the SX scheduler.
|
||||
;; Without this, native http-listen invokes the handler
|
||||
;; closure on a fresh OCaml thread that has no scheduler
|
||||
;; frame, so the receive's er-suspend-marker propagates
|
||||
;; out and the connection writes nothing — the Blockers
|
||||
;; #4 deadlock the m2 loop observed.
|
||||
;;
|
||||
;; er-spawn-fun requires an er-fun (Erlang-AST-shaped
|
||||
;; dict); handler IS one (created by user `fun (Req) ->
|
||||
;; route(Req, Cfg) end`). To feed req-pl as the call
|
||||
;; argument we stash it on the process record's
|
||||
;; :pending-args field — er-sched-step-alive! reads it
|
||||
;; on first step (the alternative was a host-closure-to-
|
||||
;; er-fun wrapper, which needs AST construction).
|
||||
((sx-handler
|
||||
(fn (req-dict)
|
||||
(let ((req-pl (er-request-dict-to-proplist req-dict)))
|
||||
(let ((proc (er-proc-new! (er-env-new))))
|
||||
(dict-set! proc :initial-fun handler)
|
||||
(dict-set! proc :pending-args (list req-pl))
|
||||
(er-sched-run-all!)
|
||||
(let ((resp-pl (er-proc-field (get proc :pid) :exit-result)))
|
||||
(er-proplist-to-dict resp-pl)))))))
|
||||
(http-listen port sx-handler))))))
|
||||
|
||||
;; httpc:request/4(Url, Method, Headers, Body) - BRIEFING-EXCEPTION:
|
||||
;; the m2 briefing's one allowed scope exception for Step 8e, mirroring
|
||||
;; M1 Step 8a's http:listen wrapper on the client side.
|
||||
;;
|
||||
;; Url is an Erlang binary (must start with http://).
|
||||
;; Method is an Erlang atom or binary; passed through to the native
|
||||
;; verbatim, so callers should supply 'get / 'post or <<"GET">> as
|
||||
;; appropriate (the native compares uppercase).
|
||||
;; Headers is an Erlang proplist [{Name, Value}, ...]; names and
|
||||
;; values are binaries or atoms (er-proplist-to-dict handles both).
|
||||
;; Body is an Erlang binary (use <<>> for empty).
|
||||
;;
|
||||
;; Returns a 4-tuple {ok, StatusInt, HeadersProplist, BodyBinary}.
|
||||
;; The native primitive raises Eval_error on DNS / connect / bad URL;
|
||||
;; we catch the host exception here and re-raise as an Erlang error
|
||||
;; marker so callers can use try/catch error:{network, _} -> _ end.
|
||||
(define
|
||||
er-bif-httpc-request
|
||||
(fn
|
||||
(vs)
|
||||
(let
|
||||
((url (nth vs 0))
|
||||
(method (nth vs 1))
|
||||
(headers (nth vs 2))
|
||||
(body (nth vs 3)))
|
||||
(let
|
||||
((url-str
|
||||
(cond
|
||||
(er-binary? url) (list->string (map integer->char (get url :bytes)))
|
||||
:else (raise (er-mk-error-marker (er-mk-atom "badarg")))))
|
||||
(method-str
|
||||
(cond
|
||||
;; Erlang convention is lowercase atoms (get/post/put/...);
|
||||
;; the HTTP wire wants uppercase. Binaries pass through so
|
||||
;; callers can override with mixed-case verbs if needed.
|
||||
(er-atom? method) (upcase (get method :name))
|
||||
(er-binary? method) (list->string (map integer->char (get method :bytes)))
|
||||
:else (raise (er-mk-error-marker (er-mk-atom "badarg")))))
|
||||
(headers-dict
|
||||
(cond
|
||||
(er-nil? headers) (dict)
|
||||
(er-cons? headers) (er-proplist-to-dict headers)
|
||||
:else (raise (er-mk-error-marker (er-mk-atom "badarg")))))
|
||||
(body-str
|
||||
(cond
|
||||
(er-binary? body) (list->string (map integer->char (get body :bytes)))
|
||||
(er-nil? body) ""
|
||||
:else (raise (er-mk-error-marker (er-mk-atom "badarg"))))))
|
||||
(let ((resp-ref (list nil)) (err-ref (list nil)))
|
||||
(guard (c (:else (set-nth! err-ref 0 c)))
|
||||
(set-nth! resp-ref 0
|
||||
(http-request method-str url-str headers-dict body-str)))
|
||||
(cond
|
||||
(not (= (nth err-ref 0) nil))
|
||||
;; Host error -> Erlang error:{network, ReasonBinary}
|
||||
(raise (er-mk-error-marker
|
||||
(er-mk-tuple (list
|
||||
(er-mk-atom "network")
|
||||
(er-mk-binary (map char->integer
|
||||
(string->list (str (nth err-ref 0)))))))))
|
||||
:else
|
||||
(let ((resp (nth resp-ref 0)))
|
||||
(er-mk-tuple
|
||||
(list
|
||||
(er-mk-atom "ok")
|
||||
(get resp :status)
|
||||
(er-of-sx-deep (get resp :headers))
|
||||
(er-mk-binary (map char->integer (string->list (get resp :body)))))))))))))
|
||||
|
||||
;; Register everything at load time.
|
||||
(define
|
||||
er-register-builtin-bifs!
|
||||
@@ -1667,6 +1999,10 @@
|
||||
(er-register-bif! "erlang" "exit" 1 er-bif-exit)
|
||||
(er-register-bif! "erlang" "exit" 2 er-bif-exit)
|
||||
(er-register-bif! "erlang" "make_ref" 0 er-bif-make-ref)
|
||||
(er-register-bif! "erlang" "send_after" 3 er-bif-send-after)
|
||||
(er-register-bif! "erlang" "cancel_timer" 1 er-bif-cancel-timer)
|
||||
(er-register-bif! "erlang" "monotonic_time" 0 er-bif-monotonic-time)
|
||||
(er-register-bif! "erlang" "monotonic_time" 1 er-bif-monotonic-time)
|
||||
(er-register-bif! "erlang" "link" 1 er-bif-link)
|
||||
(er-register-bif! "erlang" "unlink" 1 er-bif-unlink)
|
||||
(er-register-bif! "erlang" "monitor" 2 er-bif-monitor)
|
||||
@@ -1796,5 +2132,6 @@
|
||||
(er-mk-atom "ok")))
|
||||
|
||||
(er-register-bif! "http" "listen" 2 er-bif-http-listen)
|
||||
(er-register-bif! "httpc" "request" 4 er-bif-httpc-request)
|
||||
|
||||
(er-register-builtin-bifs!)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"language": "erlang",
|
||||
"total_pass": 761,
|
||||
"total": 761,
|
||||
"total_pass": 771,
|
||||
"total": 771,
|
||||
"suites": [
|
||||
{"name":"tokenize","pass":62,"total":62,"status":"ok"},
|
||||
{"name":"parse","pass":52,"total":52,"status":"ok"},
|
||||
@@ -13,6 +13,7 @@
|
||||
{"name":"echo","pass":7,"total":7,"status":"ok"},
|
||||
{"name":"fib","pass":8,"total":8,"status":"ok"},
|
||||
{"name":"ffi","pass":37,"total":37,"status":"ok"},
|
||||
{"name":"vm","pass":78,"total":78,"status":"ok"}
|
||||
{"name":"vm","pass":78,"total":78,"status":"ok"},
|
||||
{"name":"send_after","pass":10,"total":10,"status":"ok"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Erlang-on-SX Scoreboard
|
||||
|
||||
**Total: 761 / 761 tests passing**
|
||||
**Total: 771 / 771 tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
@@ -15,6 +15,7 @@
|
||||
| ✅ | fib | 8 | 8 |
|
||||
| ✅ | ffi | 37 | 37 |
|
||||
| ✅ | vm | 78 | 78 |
|
||||
| ✅ | send_after | 10 | 10 |
|
||||
|
||||
|
||||
Generated by `lib/erlang/conformance.sh`.
|
||||
|
||||
163
lib/erlang/tests/send_after.sx
Normal file
163
lib/erlang/tests/send_after.sx
Normal file
@@ -0,0 +1,163 @@
|
||||
;; erlang:send_after / cancel_timer — timer primitives.
|
||||
;;
|
||||
;; A process schedules a message to itself (or another pid / registered
|
||||
;; name) after N logical milliseconds. `cancel_timer` removes a pending
|
||||
;; timer and reports the time left. These are the same primitives the
|
||||
;; gen_server library uses to implement `{noreply, State, Timeout}`.
|
||||
;;
|
||||
;; The scheduler runs a synchronous logical clock (see runtime.sx
|
||||
;; `er-sched-advance-time!`): time advances only when the runnable
|
||||
;; queue drains, jumping to the earliest pending deadline. That makes
|
||||
;; delivery deterministic and time-travel-safe — no wall clock.
|
||||
|
||||
(define er-sa-test-count 0)
|
||||
(define er-sa-test-pass 0)
|
||||
(define er-sa-test-fails (list))
|
||||
|
||||
(define
|
||||
er-sa-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! er-sa-test-count (+ er-sa-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! er-sa-test-pass (+ er-sa-test-pass 1))
|
||||
(append!
|
||||
er-sa-test-fails
|
||||
{:actual actual :expected expected :name name}))))
|
||||
|
||||
(define er-sa-pred
|
||||
(fn (name actual) (er-sa-test name (if actual true false) true)))
|
||||
|
||||
(define sa-ev erlang-eval-ast)
|
||||
|
||||
;; ── T1 — schedule a self-message, receive it after the deadline ──
|
||||
;; send_after returns a reference handle.
|
||||
(er-sa-pred
|
||||
"T1 send_after returns a ref"
|
||||
(er-ref?
|
||||
(sa-ev "erlang:send_after(50, self(), hello)")))
|
||||
|
||||
;; The scheduled message lands and a plain receive picks it up.
|
||||
(er-sa-test
|
||||
"T1 delivered message received"
|
||||
(get
|
||||
(sa-ev
|
||||
"erlang:send_after(50, self(), hello),
|
||||
receive M -> M end")
|
||||
:name)
|
||||
"hello")
|
||||
|
||||
;; Logical time advances exactly to the timer deadline (50ms) by the
|
||||
;; time the message is received — round-trip latency well under 100ms.
|
||||
(er-sa-test
|
||||
"T1 clock at deadline on receipt"
|
||||
(sa-ev
|
||||
"erlang:send_after(50, self(), hello),
|
||||
receive hello -> erlang:monotonic_time() end")
|
||||
50)
|
||||
|
||||
;; ── T2 — cancel_timer returns remaining ms; message never arrives ──
|
||||
;; Cancel immediately after scheduling: clock has not advanced, so the
|
||||
;; full duration (~1000ms) is reported as remaining.
|
||||
(er-sa-test
|
||||
"T2 cancel returns remaining ms"
|
||||
(sa-ev
|
||||
"Ref = erlang:send_after(1000, self(), late),
|
||||
erlang:cancel_timer(Ref)")
|
||||
1000)
|
||||
|
||||
;; The cancelled timer never delivers — the receive falls through to
|
||||
;; its `after` clause and returns `none`.
|
||||
(er-sa-test
|
||||
"T2 cancelled message never arrives"
|
||||
(get
|
||||
(sa-ev
|
||||
"Ref = erlang:send_after(1000, self(), late),
|
||||
erlang:cancel_timer(Ref),
|
||||
receive late -> got after 50 -> none end")
|
||||
:name)
|
||||
"none")
|
||||
|
||||
;; ── T3 — multiple timers fire in deadline order, not schedule order ──
|
||||
;; `b` is scheduled first (deadline 80) but `a` second (deadline 20).
|
||||
;; Two plain receives drain the mailbox in arrival order — and arrival
|
||||
;; is governed by deadline, so the first message out is `a`.
|
||||
(er-sa-test
|
||||
"T3 timers fire in deadline order"
|
||||
(er-format-value
|
||||
(sa-ev
|
||||
"erlang:send_after(80, self(), b),
|
||||
erlang:send_after(20, self(), a),
|
||||
X = receive M1 -> M1 end,
|
||||
Y = receive M2 -> M2 end,
|
||||
{X, Y}"))
|
||||
"{a,b}")
|
||||
|
||||
;; A selective receive on `a` matches the earlier-deadline timer even
|
||||
;; though `b` was scheduled first.
|
||||
(er-sa-test
|
||||
"T3 selective receive picks earliest deadline"
|
||||
(get
|
||||
(sa-ev
|
||||
"erlang:send_after(80, self(), b),
|
||||
erlang:send_after(20, self(), a),
|
||||
receive a -> first end")
|
||||
:name)
|
||||
"first")
|
||||
|
||||
;; ── T4 — cancel_timer on an already-fired timer returns false ──────
|
||||
;; Once `x` has been received the timer has fired; cancelling its ref
|
||||
;; now yields the atom `false`.
|
||||
(er-sa-test
|
||||
"T4 cancel of fired timer is false"
|
||||
(get
|
||||
(sa-ev
|
||||
"Ref = erlang:send_after(20, self(), x),
|
||||
receive x -> ok end,
|
||||
erlang:cancel_timer(Ref)")
|
||||
:name)
|
||||
"false")
|
||||
|
||||
;; ── T5 — send_after to a registered atom name ──────────────────────
|
||||
;; A second process registers itself as `srv`; the timer addresses it
|
||||
;; by name, and the delayed message lands in that process's mailbox.
|
||||
;; The server forwards what it got back to the parent for inspection.
|
||||
(er-sa-test
|
||||
"T5 timer delivers to registered name"
|
||||
(get
|
||||
(sa-ev
|
||||
"Me = self(),
|
||||
Pid = spawn(fun () -> receive M -> Me ! {got, M} end end),
|
||||
register(srv, Pid),
|
||||
erlang:send_after(20, srv, ping),
|
||||
receive {got, X} -> X end")
|
||||
:name)
|
||||
"ping")
|
||||
|
||||
;; ── T6 — gen_server {noreply, State, Timeout} hookup ───────────────
|
||||
;; A gen_server that, on the `arm` cast, returns {noreply, S, 100}.
|
||||
;; The library schedules {timeout} to itself via send_after; when no
|
||||
;; other message arrives first, handle_info({timeout}, S) fires. The
|
||||
;; handler signals the parent so we can confirm the timeout landed.
|
||||
(do
|
||||
(er-load-gen-server!)
|
||||
(erlang-load-module
|
||||
"-module(sa_tmo).
|
||||
init(Me) -> {ok, Me}.
|
||||
handle_call(_R, _F, S) -> {reply, ok, S}.
|
||||
handle_cast(arm, Me) -> {noreply, Me, 100}.
|
||||
handle_info({timeout}, Me) -> Me ! fired, {noreply, Me};
|
||||
handle_info(_M, S) -> {noreply, S}.")
|
||||
nil)
|
||||
|
||||
(er-sa-test
|
||||
"T6 gen_server timeout fires handle_info"
|
||||
(get
|
||||
(sa-ev
|
||||
"Me = self(),
|
||||
P = gen_server:start_link(sa_tmo, Me),
|
||||
gen_server:cast(P, arm),
|
||||
receive fired -> ok after 5000 -> timeout end")
|
||||
:name)
|
||||
"ok")
|
||||
@@ -1147,7 +1147,7 @@
|
||||
(and (er-atom? ms) (= (get ms :name) "infinity"))
|
||||
(er-eval-receive-loop node pid env)
|
||||
(= ms 0) (er-eval-receive-poll node pid env)
|
||||
:else (er-eval-receive-timed node pid env)))))
|
||||
:else (er-eval-receive-timed node pid env (+ (er-clock) ms))))))
|
||||
|
||||
;; after 0 — poll once; on no match, run the after-body immediately.
|
||||
(define
|
||||
@@ -1161,12 +1161,15 @@
|
||||
(get r :value)
|
||||
(er-eval-body (get node :after-body) env)))))
|
||||
|
||||
;; after Ms — suspend; on resume check :timed-out. When the scheduler
|
||||
;; runs out of other work it fires one pending timeout per round.
|
||||
;; after Ms — suspend with an absolute `deadline` (logical ms). On
|
||||
;; resume check :timed-out: the scheduler fires the earliest pending
|
||||
;; deadline once the runnable queue drains. A non-matching message can
|
||||
;; wake the process early; it re-suspends on the SAME deadline so the
|
||||
;; timeout window is not extended.
|
||||
(define
|
||||
er-eval-receive-timed
|
||||
(fn
|
||||
(node pid env)
|
||||
(node pid env deadline)
|
||||
(let
|
||||
((r (er-try-receive (get node :clauses) pid env)))
|
||||
(if
|
||||
@@ -1174,6 +1177,7 @@
|
||||
(get r :value)
|
||||
(do
|
||||
(er-proc-set! pid :has-timeout true)
|
||||
(er-proc-set! pid :timeout-deadline deadline)
|
||||
(call/cc
|
||||
(fn
|
||||
(k)
|
||||
@@ -1186,7 +1190,7 @@
|
||||
(er-proc-set! pid :timed-out false)
|
||||
(er-proc-set! pid :has-timeout false)
|
||||
(er-eval-body (get node :after-body) env))
|
||||
(er-eval-receive-timed node pid env)))))))
|
||||
(er-eval-receive-timed node pid env deadline)))))))
|
||||
|
||||
;; Scan mailbox in arrival order. For each msg, try every clause.
|
||||
;; On first match: remove that msg from mailbox and return body value.
|
||||
|
||||
@@ -43,7 +43,7 @@ next/
|
||||
| `bootstrap.erl` | Genesis read/build/verify/load + one-call `start/3` kernel bring-up |
|
||||
| `define_registry.erl` | Meta-projection fold for `Create{Define*}` → registry |
|
||||
| `sandbox.erl` | `eval_pure/2,3` try/catch envelope for projection folds |
|
||||
| `nx_kernel.erl` | Long-lived runtime orchestrator (state + gen_server) |
|
||||
| `nx_kernel.erl` | Long-lived runtime orchestrator; per-actor bucketed state (m2 Step 1a) |
|
||||
| `http_server.erl` | route/1,2 + format-aware GET + POST + Accept header content negotiation |
|
||||
|
||||
## Genesis bundle
|
||||
|
||||
91
next/flow/README.md
Normal file
91
next/flow/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# flow-on-erlang — durable workflows in the fed-sx runtime
|
||||
|
||||
A native Erlang-on-SX port of the Scheme flow engine (`lib/flow`), so
|
||||
the fed-sx kernel can fan arriving activities out into durable,
|
||||
branching, multi-step business flows **in its own runtime** — no
|
||||
cross-guest FFI, no marshalling, no Scheme dependency. The seed of a
|
||||
real engine that can later supersede the Scheme one for substrate use.
|
||||
|
||||
Run the suite: `bash next/flow/conformance.sh` → engine conformance.
|
||||
|
||||
## Model
|
||||
|
||||
A **flow** is an Erlang `fun(Ctx) -> Ctx`. Combinators (`flow_spec`)
|
||||
compose flows; user code stays value-level (the functions you hand to
|
||||
`flow_node`/`branch`/… take and return plain values). A flow that
|
||||
ignores its input is a thunk; composition *is* function composition.
|
||||
|
||||
```erlang
|
||||
F = flow_spec:sequence([
|
||||
flow_spec:flow_node(fun(Draft) -> Draft + 1 end),
|
||||
flow_spec:branch(fun(P) -> P >= 3 end,
|
||||
flow_spec:flow_const(ok),
|
||||
flow_spec:flow_const(rejected))]),
|
||||
flow:run(F, 2) %% => {flow_done, ok}
|
||||
```
|
||||
|
||||
## Durability — deterministic replay
|
||||
|
||||
Same semantics as the Scheme engine: a flow re-runs from the top on
|
||||
every resume; effects/non-determinism go through `flow:suspend/1`,
|
||||
whose resolved values are logged; an already-resolved suspend replays
|
||||
its logged value, and the first unresolved suspend short-circuits back
|
||||
to the driver. The persisted state is the **replay log** — plain
|
||||
`[{Tag, Value}]` data — so nothing live (no continuation, no process)
|
||||
is ever serialized; an instance survives restart by re-driving its
|
||||
named flow against its log.
|
||||
|
||||
```erlang
|
||||
flow_store:register_flow(publish, F),
|
||||
{ok, Id, R} = flow_store:start(publish, Draft), %% R = {flow_suspended, Tag} | {flow_done, V}
|
||||
%% ... driver performs the effect for Tag, then:
|
||||
flow_store:resume(Id, EffectResult) %% re-drives; completes or suspends again
|
||||
```
|
||||
|
||||
## Why railway threading instead of call/cc + a global
|
||||
|
||||
The Scheme engine uses an escape-only `call/cc` plus a mutable global
|
||||
replay log. This Erlang-on-SX runtime can't do either, and has a third
|
||||
sharp edge:
|
||||
|
||||
- **No re-enterable continuation** — but suspend only needs to *escape*,
|
||||
which Erlang `throw` could do …
|
||||
- **… except a blocking `receive` / `gen_server:call` inside a `try`
|
||||
deadlocks** the cooperative scheduler. So `suspend` must not consult
|
||||
the log via a registry process while inside a `try`.
|
||||
- **No process dictionary** — so there is no ambient per-process slot to
|
||||
stash the replay log in.
|
||||
|
||||
The resolution: thread the replay log through a railway-style **context**
|
||||
and make `suspend` *short-circuit* (like a `fail` value) rather than
|
||||
throw. No ambient state, no throw, no gen_server in the hot path —
|
||||
purely functional, which sidesteps all three constraints. The driver
|
||||
(`flow_store`) is the only stateful part, and it calls the pure
|
||||
`flow:drive/3` from inside `handle_call`, never wrapping a blocking
|
||||
receive.
|
||||
|
||||
A `Ctx` is `{flow_cont, Value, Log}` (running) or `{flow_susp, Tag,
|
||||
Log}` (short-circuited); every combinator passes a suspended context
|
||||
straight through.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Role |
|
||||
|---|---|
|
||||
| `flow.erl` | pure replay driver: `drive/3`, `run/2`, `suspend/1`, the `Ctx` constructors/accessors |
|
||||
| `flow_spec.erl` | combinator algebra: leaves, `sequence`/`parallel`/`map_flow`, `flow_while`/`flow_until`, `branch`, railway `fail`/`recover`/`attempt`, `tap`, `try_catch`/`retry` |
|
||||
| `flow_store.erl` | durable gen_server: named-flow registry + instance table + `start`/`resume`/`status` |
|
||||
|
||||
## Consumed by
|
||||
|
||||
The fed-sx kernel's trigger fan-out (`pipeline.erl` + `flow_dispatch`)
|
||||
starts named flows from arriving activities; see
|
||||
`plans/fed-sx-host-types.md` and the triggers phases.
|
||||
|
||||
## Not yet (later layers)
|
||||
|
||||
- Persisting instance logs to the kernel's durable on-disk log (the
|
||||
data shape is already restart-ready; only the backing is in-memory).
|
||||
- `parallel` with multiple independent suspends resolving concurrently
|
||||
(current `parallel` is sequential under one shared log).
|
||||
- Full parity with the Scheme engine's distributed/remote nodes.
|
||||
206
next/flow/conformance.sh
Executable file
206
next/flow/conformance.sh
Executable file
@@ -0,0 +1,206 @@
|
||||
#!/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 ]
|
||||
102
next/flow/flow.erl
Normal file
102
next/flow/flow.erl
Normal file
@@ -0,0 +1,102 @@
|
||||
-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, []).
|
||||
240
next/flow/flow_spec.erl
Normal file
240
next/flow/flow_spec.erl
Normal file
@@ -0,0 +1,240 @@
|
||||
-module(flow_spec).
|
||||
-export([flow_node/1, flow_id/0, flow_const/1,
|
||||
sequence/1, parallel/1, map_flow/1,
|
||||
flow_while/3, flow_until/3,
|
||||
branch/3, fail/1, failed/1, fail_reason/1,
|
||||
recover/2, tap/1, attempt/1, try_catch/2, retry/2]).
|
||||
|
||||
%% flow-on-erlang combinators — a native port of lib/flow/spec.sx,
|
||||
%% adapted to the railway-threaded context model in flow.erl. A node is
|
||||
%% `fun(Ctx) -> Ctx`; every combinator passes a {flow_susp,...} context
|
||||
%% straight through, so once a flow suspends nothing downstream runs.
|
||||
%% User code stays value-level: the predicates/functions handed to
|
||||
%% flow_node / branch / etc. take and return plain values, and the
|
||||
%% combinator threads them into the context.
|
||||
%%
|
||||
%% Variadic Scheme forms (sequence, parallel, attempt) take an explicit
|
||||
%% list here — the one idiom difference from the Scheme engine. Effects
|
||||
%% must go through a flow:suspend/1 node so they run once (in the
|
||||
%% driver) and replay from the log; `tap` is only for replay-safe
|
||||
%% effects (e.g. tracing).
|
||||
|
||||
%% ── leaves ──────────────────────────────────────────────────────
|
||||
|
||||
%% flow_node(F) — lift a value function F :: Value -> Value into a node.
|
||||
flow_node(F) ->
|
||||
fun (Ctx) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false -> flow:cont(F(flow:ctx_value(Ctx)), flow:ctx_log(Ctx))
|
||||
end
|
||||
end.
|
||||
|
||||
flow_id() ->
|
||||
fun (Ctx) -> Ctx end.
|
||||
|
||||
flow_const(V) ->
|
||||
fun (Ctx) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false -> flow:cont(V, flow:ctx_log(Ctx))
|
||||
end
|
||||
end.
|
||||
|
||||
%% ── threading / fan-out / iteration ─────────────────────────────
|
||||
|
||||
%% sequence(Nodes) — thread the context left-to-right. Each node
|
||||
%% self-guards on suspension, so a suspended context flows through
|
||||
%% untouched.
|
||||
sequence(Nodes) ->
|
||||
fun (Ctx) -> seq_step(Nodes, Ctx) end.
|
||||
|
||||
seq_step([], Ctx) -> Ctx;
|
||||
seq_step([N | Ns], Ctx) -> seq_step(Ns, N(Ctx)).
|
||||
|
||||
%% parallel(Nodes) — fan the input value to every node, join results
|
||||
%% into a list (sequential evaluation under one shared replay log).
|
||||
%% First child to suspend short-circuits the whole parallel.
|
||||
parallel(Nodes) ->
|
||||
fun (Ctx) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false -> par_step(Nodes, flow:ctx_value(Ctx), flow:ctx_log(Ctx), [])
|
||||
end
|
||||
end.
|
||||
|
||||
par_step([], _Input, Log, Acc) ->
|
||||
flow:cont(lists:reverse(Acc), Log);
|
||||
par_step([N | Ns], Input, Log, Acc) ->
|
||||
R = N(flow:cont(Input, Log)),
|
||||
case flow:is_susp(R) of
|
||||
true -> R;
|
||||
false -> par_step(Ns, Input, Log, [flow:ctx_value(R) | Acc])
|
||||
end.
|
||||
|
||||
%% map_flow(Node) — run Node over each item of a list input value.
|
||||
map_flow(Node) ->
|
||||
fun (Ctx) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false -> map_step(Node, flow:ctx_value(Ctx), flow:ctx_log(Ctx), [])
|
||||
end
|
||||
end.
|
||||
|
||||
map_step(_, [], Log, Acc) ->
|
||||
flow:cont(lists:reverse(Acc), Log);
|
||||
map_step(Node, [I | Is], Log, Acc) ->
|
||||
R = Node(flow:cont(I, Log)),
|
||||
case flow:is_susp(R) of
|
||||
true -> R;
|
||||
false -> map_step(Node, Is, Log, [flow:ctx_value(R) | Acc])
|
||||
end.
|
||||
|
||||
%% flow_while(Pred, Body, Max) — re-run Body (a node), threading the
|
||||
%% context, while Pred(value) holds, up to Max steps. Pred :: Value ->
|
||||
%% bool; Body :: node.
|
||||
flow_while(Pred, Body, Max) ->
|
||||
fun (Ctx) -> while_step(Pred, Body, Ctx, Max) end.
|
||||
|
||||
while_step(_, _, Ctx, N) when N =< 0 -> Ctx;
|
||||
while_step(Pred, Body, Ctx, N) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false ->
|
||||
case Pred(flow:ctx_value(Ctx)) of
|
||||
true -> while_step(Pred, Body, Body(Ctx), N - 1);
|
||||
_ -> Ctx
|
||||
end
|
||||
end.
|
||||
|
||||
%% flow_until(Pred, Body, Max) — re-run Body until Pred(value) holds.
|
||||
flow_until(Pred, Body, Max) ->
|
||||
fun (Ctx) -> until_step(Pred, Body, Ctx, Max) end.
|
||||
|
||||
until_step(_, _, Ctx, N) when N =< 0 -> Ctx;
|
||||
until_step(Pred, Body, Ctx, N) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false ->
|
||||
case Pred(flow:ctx_value(Ctx)) of
|
||||
true -> Ctx;
|
||||
_ -> until_step(Pred, Body, Body(Ctx), N - 1)
|
||||
end
|
||||
end.
|
||||
|
||||
%% ── branching ───────────────────────────────────────────────────
|
||||
|
||||
%% branch(Pred, Then, Else) — Pred :: Value -> bool; Then/Else :: node.
|
||||
branch(Pred, Then, Else) ->
|
||||
fun (Ctx) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false ->
|
||||
case Pred(flow:ctx_value(Ctx)) of
|
||||
true -> Then(Ctx);
|
||||
_ -> Else(Ctx)
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
%% ── railway-style failure (values, not exceptions) ──────────────
|
||||
|
||||
fail(Reason) -> {flow_fail, Reason}.
|
||||
|
||||
failed({flow_fail, _}) -> true;
|
||||
failed(_) -> false.
|
||||
|
||||
fail_reason({flow_fail, R}) -> R.
|
||||
|
||||
%% recover(Node, Handler) — if Node yields a fail VALUE, run Handler on
|
||||
%% the reason; else pass through. Handler :: Reason -> Value.
|
||||
recover(Node, Handler) ->
|
||||
fun (Ctx) ->
|
||||
R = Node(Ctx),
|
||||
case flow:is_susp(R) of
|
||||
true -> R;
|
||||
false ->
|
||||
V = flow:ctx_value(R),
|
||||
case failed(V) of
|
||||
true -> flow:cont(Handler(fail_reason(V)), flow:ctx_log(R));
|
||||
false -> R
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
%% tap(Effect) — replay-safe side-effecting pass-through (returns the
|
||||
%% input value unchanged). Effect :: Value -> any.
|
||||
tap(Effect) ->
|
||||
fun (Ctx) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false -> Effect(flow:ctx_value(Ctx)), Ctx
|
||||
end
|
||||
end.
|
||||
|
||||
%% attempt(Nodes) — railway sequence: thread left-to-right but stop at
|
||||
%% the first node whose value is a fail, returning that failure.
|
||||
attempt(Nodes) ->
|
||||
fun (Ctx) -> attempt_step(Nodes, Ctx) end.
|
||||
|
||||
attempt_step([], Ctx) -> Ctx;
|
||||
attempt_step([N | Ns], Ctx) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false ->
|
||||
case failed(flow:ctx_value(Ctx)) of
|
||||
true -> Ctx;
|
||||
false -> attempt_step(Ns, N(Ctx))
|
||||
end
|
||||
end.
|
||||
|
||||
%% ── exception-style control ─────────────────────────────────────
|
||||
%% Nodes are pure (effects go through suspend, run by the driver), so a
|
||||
%% try around a node never wraps a blocking receive — safe in this
|
||||
%% runtime.
|
||||
|
||||
%% try_catch(Node, Handler) — run Node; if it raises, run Handler on the
|
||||
%% exception. Handler :: Exception -> Value.
|
||||
try_catch(Node, Handler) ->
|
||||
fun (Ctx) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false ->
|
||||
Log = flow:ctx_log(Ctx),
|
||||
try Node(Ctx) of
|
||||
R -> R
|
||||
catch
|
||||
throw:E -> flow:cont(Handler(E), Log);
|
||||
error:E -> flow:cont(Handler(E), Log);
|
||||
exit:E -> flow:cont(Handler(E), Log)
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
%% retry(N, Node) — run Node, retrying up to N attempts on a raise.
|
||||
retry(N, Node) ->
|
||||
fun (Ctx) -> retry_step(N, Node, Ctx) end.
|
||||
|
||||
retry_step(N, Node, Ctx) ->
|
||||
case flow:is_susp(Ctx) of
|
||||
true -> Ctx;
|
||||
false ->
|
||||
try Node(Ctx) of
|
||||
R -> R
|
||||
catch
|
||||
throw:Reason -> retry_reraise(N, Node, Ctx, throw, Reason);
|
||||
error:Reason -> retry_reraise(N, Node, Ctx, error, Reason);
|
||||
exit:Reason -> retry_reraise(N, Node, Ctx, exit, Reason)
|
||||
end
|
||||
end.
|
||||
|
||||
retry_reraise(N, Node, Ctx, Class, Reason) ->
|
||||
case N =< 1 of
|
||||
false -> retry_step(N - 1, Node, Ctx);
|
||||
true ->
|
||||
case Class of
|
||||
throw -> throw(Reason);
|
||||
error -> erlang:error(Reason);
|
||||
exit -> exit(Reason)
|
||||
end
|
||||
end.
|
||||
161
next/flow/flow_store.erl
Normal file
161
next/flow/flow_store.erl
Normal file
@@ -0,0 +1,161 @@
|
||||
-module(flow_store).
|
||||
-export([start_link/0, start_link/1, stop/0,
|
||||
register_flow/2, resolve_flow/1, registered_flows/0,
|
||||
start/2, resume/2, status/1, instances/0]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
-behaviour(gen_server).
|
||||
|
||||
%% flow-on-erlang durable store — the named-flow registry plus the
|
||||
%% instance table that makes suspend/resume durable. flow.erl is the
|
||||
%% pure replay driver; this gen_server is the stateful shell around it,
|
||||
%% holding the registry (so triggers can reference flows by name, and
|
||||
%% so an instance can be re-resolved + replayed after a restart) and
|
||||
%% each instance's accumulated replay log.
|
||||
%%
|
||||
%% Crucially the driver stays OUT of any blocking context: start/resume
|
||||
%% call flow:drive/3 (pure — no receive, no gen_server:call) from inside
|
||||
%% handle_call, and the only message-passing is the caller's
|
||||
%% gen_server:call into this store. (A blocking receive inside a `try`
|
||||
%% deadlocks this cooperative scheduler, so the engine never does one.)
|
||||
%%
|
||||
%% State: {Registry, Instances, NextId}
|
||||
%% Registry = [{Name, FlowFun}, ...]
|
||||
%% Instances = [{Id, {Name, Input, Log, Status}}, ...]
|
||||
%% Status = {suspended, Tag} | {done, Result}
|
||||
%% Log = [{Tag, ResolvedValue}, ...] (the replay log — plain
|
||||
%% data, so an instance is fully described by its log and
|
||||
%% survives process restart by re-driving the named flow)
|
||||
%%
|
||||
%% v1 backs the store in gen_server memory; persisting the instance
|
||||
%% logs to the kernel's durable log (so flows survive an OS restart) is
|
||||
%% a later layer — the data shape is already restart-ready.
|
||||
|
||||
start_link() ->
|
||||
start_link([]).
|
||||
|
||||
start_link(InitialFlows) ->
|
||||
Pid = gen_server:start_link(flow_store, [InitialFlows]),
|
||||
erlang:register(flow_store, Pid),
|
||||
Pid.
|
||||
|
||||
stop() ->
|
||||
R = gen_server:call(flow_store, '$gen_stop'),
|
||||
erlang:unregister(flow_store),
|
||||
R.
|
||||
|
||||
%% register_flow(Name, Flow) — register a named flow (a node fun). Named
|
||||
%% rather than `register` to avoid the erlang:register/2 auto-import.
|
||||
register_flow(Name, Flow) ->
|
||||
gen_server:call(flow_store, {register_flow, Name, Flow}).
|
||||
|
||||
resolve_flow(Name) ->
|
||||
gen_server:call(flow_store, {resolve_flow, Name}).
|
||||
|
||||
registered_flows() ->
|
||||
gen_server:call(flow_store, registered_flows).
|
||||
|
||||
%% start(Name, Input) -> {ok, Id, Result} | {error, no_such_flow}.
|
||||
%% Result is {flow_done, V} | {flow_suspended, Tag}; the instance is
|
||||
%% recorded either way so a suspended flow can be resumed by Id.
|
||||
start(Name, Input) ->
|
||||
gen_server:call(flow_store, {start, Name, Input}).
|
||||
|
||||
%% resume(Id, Value) -> {ok, Result} | {error, Reason}. Resolves the
|
||||
%% instance's current suspend tag with Value (appends {Tag, Value} to
|
||||
%% its replay log) and re-drives from the top.
|
||||
resume(Id, Value) ->
|
||||
gen_server:call(flow_store, {resume, Id, Value}).
|
||||
|
||||
%% status(Id) -> {ok, {suspended, Tag}} | {ok, {done, Result}} | not_found
|
||||
status(Id) ->
|
||||
gen_server:call(flow_store, {status, Id}).
|
||||
|
||||
instances() ->
|
||||
gen_server:call(flow_store, instances).
|
||||
|
||||
%% ── gen_server ──────────────────────────────────────────────────
|
||||
|
||||
init([InitialFlows]) ->
|
||||
{ok, {InitialFlows, [], 1}}.
|
||||
|
||||
handle_call({register_flow, Name, Flow}, _From, {Reg, Ins, N}) ->
|
||||
{reply, ok, {set_keyed(Name, Flow, Reg), Ins, N}};
|
||||
handle_call({resolve_flow, Name}, _From, {Reg, Ins, N}) ->
|
||||
{reply, find_keyed(Name, Reg), {Reg, Ins, N}};
|
||||
handle_call(registered_flows, _From, {Reg, Ins, N}) ->
|
||||
{reply, [Name || {Name, _} <- Reg], {Reg, Ins, N}};
|
||||
handle_call({start, Name, Input}, _From, {Reg, Ins, N}) ->
|
||||
case find_keyed(Name, Reg) of
|
||||
not_found ->
|
||||
{reply, {error, no_such_flow}, {Reg, Ins, N}};
|
||||
{ok, Flow} ->
|
||||
case safe_drive(Flow, Input, []) of
|
||||
{ok, R} ->
|
||||
Status = result_status(R),
|
||||
Ins2 = set_keyed(N, {Name, Input, [], Status}, Ins),
|
||||
{reply, {ok, N, R}, {Reg, Ins2, N + 1}};
|
||||
{error, Crash} ->
|
||||
{reply, {error, {flow_crashed, Crash}}, {Reg, Ins, N}}
|
||||
end
|
||||
end;
|
||||
handle_call({resume, Id, Value}, _From, {Reg, Ins, N}) ->
|
||||
case find_keyed(Id, Ins) of
|
||||
not_found ->
|
||||
{reply, {error, no_such_instance}, {Reg, Ins, N}};
|
||||
{ok, {_Name, _Input, _Log, {done, _}}} ->
|
||||
{reply, {error, already_done}, {Reg, Ins, N}};
|
||||
{ok, {Name, Input, Log, {suspended, Tag}}} ->
|
||||
case find_keyed(Name, Reg) of
|
||||
not_found ->
|
||||
{reply, {error, no_such_flow}, {Reg, Ins, N}};
|
||||
{ok, Flow} ->
|
||||
NewLog = log_append(Log, Tag, Value),
|
||||
case safe_drive(Flow, Input, NewLog) of
|
||||
{ok, R} ->
|
||||
Status = result_status(R),
|
||||
Ins2 = set_keyed(Id, {Name, Input, NewLog, Status}, Ins),
|
||||
{reply, {ok, R}, {Reg, Ins2, N}};
|
||||
{error, Crash} ->
|
||||
{reply, {error, {flow_crashed, Crash}}, {Reg, Ins, N}}
|
||||
end
|
||||
end
|
||||
end;
|
||||
handle_call({status, Id}, _From, {Reg, Ins, N}) ->
|
||||
case find_keyed(Id, Ins) of
|
||||
{ok, {_Name, _Input, _Log, Status}} -> {reply, {ok, Status}, {Reg, Ins, N}};
|
||||
not_found -> {reply, not_found, {Reg, Ins, N}}
|
||||
end;
|
||||
handle_call(instances, _From, {Reg, Ins, N}) ->
|
||||
{reply, [Id || {Id, _} <- Ins], {Reg, Ins, N}}.
|
||||
|
||||
handle_cast(_, S) -> {noreply, S}.
|
||||
|
||||
handle_info(_, S) -> {noreply, S}.
|
||||
|
||||
%% ── helpers ─────────────────────────────────────────────────────
|
||||
|
||||
result_status({flow_done, R}) -> {done, R};
|
||||
result_status({flow_suspended, T}) -> {suspended, T}.
|
||||
|
||||
%% safe_drive/3 — flow:drive is pure (no blocking receive), so a `try`
|
||||
%% around it is safe in this runtime and isolates a flow whose step
|
||||
%% raises: the store returns {error, {flow_crashed, _}} instead of the
|
||||
%% gen_server crashing, keeping one bad flow from taking down others.
|
||||
safe_drive(Flow, Input, Log) ->
|
||||
try {ok, flow:drive(Flow, Input, Log)}
|
||||
catch
|
||||
throw:R -> {error, {throw, R}};
|
||||
error:R -> {error, {error, R}};
|
||||
exit:R -> {error, {exit, R}}
|
||||
end.
|
||||
|
||||
log_append([], Tag, Value) -> [{Tag, Value}];
|
||||
log_append([H | T], Tag, Value) -> [H | log_append(T, Tag, Value)].
|
||||
|
||||
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)].
|
||||
81
next/flow/flows/blog_publish_digest.erl
Normal file
81
next/flow/flows/blog_publish_digest.erl
Normal file
@@ -0,0 +1,81 @@
|
||||
-module(blog_publish_digest).
|
||||
-export([build/1]).
|
||||
|
||||
%% A motivating multi-step business flow for the fed-sx-triggers e2e:
|
||||
%% when an Article is published, decide a batch policy by category,
|
||||
%% (for newsletters) wait until morning, fetch the author's followers,
|
||||
%% build a digest email for each, and emit a DigestSent activity — the
|
||||
%% flow's own output, which a driver appends, closing the loop so it can
|
||||
%% trigger downstream flows.
|
||||
%%
|
||||
%% Demonstrates: a branch on an activity field (:category), a timer
|
||||
%% suspension (flow:wait/1, resumed by advancing the clock), an injected
|
||||
%% effect (fetch_followers), and a follow-up activity emit.
|
||||
%%
|
||||
%% Effect-as-data: a flow runs inside flow_store's drive, where a
|
||||
%% blocking call (e.g. into nx_kernel) would deadlock this scheduler, so
|
||||
%% the flow does NOT perform IO itself. It DESCRIBES the effects in its
|
||||
%% result — {digest_sent, Emails, DigestActivityObject} — and the driver
|
||||
%% (the fan-out caller) dispatches the emails and appends the DigestSent
|
||||
%% activity. fetch_followers is injected (the one external read) as a
|
||||
%% pure function so the e2e can supply a deterministic list.
|
||||
%%
|
||||
%% Input env (from flow_dispatch): [{activity, A}, {actor, Actor}, ...].
|
||||
%% Result: {digest_sent, [Email], DigestObject} | skipped.
|
||||
|
||||
build(Effects) ->
|
||||
FetchFollowers = field(fetch_followers, Effects),
|
||||
flow_spec:branch(
|
||||
fun (Env) -> is_article(Env) end,
|
||||
flow_spec:branch(
|
||||
fun (Env) -> category_is(Env, newsletter) end,
|
||||
%% newsletter: hold until morning, then send + emit
|
||||
flow_spec:sequence([flow:wait(morning), send_emit(FetchFollowers)]),
|
||||
flow_spec:branch(
|
||||
fun (Env) -> category_is(Env, urgent) end,
|
||||
%% urgent: send + emit now (no wait)
|
||||
send_emit(FetchFollowers),
|
||||
%% any other category: skip
|
||||
flow_spec:flow_const(skipped))),
|
||||
%% not an Article: skip
|
||||
flow_spec:flow_const(skipped)).
|
||||
|
||||
%% send_emit(FetchFollowers) — the terminal step: build one digest email
|
||||
%% per follower and the DigestSent emit object. Pure given the injected
|
||||
%% follower list, so it is replay-safe (and it sits after the only
|
||||
%% suspend point, so it runs exactly once).
|
||||
send_emit(FetchFollowers) ->
|
||||
flow_spec:flow_node(
|
||||
fun (Env) ->
|
||||
Activity = env_activity(Env),
|
||||
Actor = env_actor(Env),
|
||||
ArtId = activity_id(Activity),
|
||||
Followers = FetchFollowers(Actor),
|
||||
Emails = [ [{to, F}, {article, ArtId}] || F <- Followers ],
|
||||
Digest = [{type, digest_sent},
|
||||
{for, ArtId},
|
||||
{follower_count, length(Followers)}],
|
||||
{digest_sent, Emails, Digest}
|
||||
end).
|
||||
|
||||
%% ── predicates / accessors ──────────────────────────────────────
|
||||
|
||||
is_article(Env) ->
|
||||
object_type(object_of(env_activity(Env))) =:= article.
|
||||
|
||||
category_is(Env, Cat) ->
|
||||
object_category(object_of(env_activity(Env))) =:= Cat.
|
||||
|
||||
env_activity(Env) -> field(activity, Env).
|
||||
env_actor(Env) -> field(actor, Env).
|
||||
|
||||
object_of(Activity) -> field(object, Activity).
|
||||
object_type(Obj) -> field(type, Obj).
|
||||
object_category(Obj) -> field(category, Obj).
|
||||
activity_id(Activity) -> field(id, Activity).
|
||||
|
||||
field(Key, Proplist) ->
|
||||
case envelope:get_field(Key, Proplist) of
|
||||
{ok, V} -> V;
|
||||
_ -> undefined
|
||||
end.
|
||||
14
next/genesis/activity-types/announce.sx
Normal file
14
next/genesis/activity-types/announce.sx
Normal file
@@ -0,0 +1,14 @@
|
||||
;; next/genesis/activity-types/announce.sx
|
||||
;;
|
||||
;; Bootstrap definition of the Announce verb per design §13.5 / m2
|
||||
;; Step 11. An Announce re-broadcasts a peer's activity to the
|
||||
;; announcer's followers: the announcer's outbox carries an Announce
|
||||
;; envelope whose :object is the original activity's CID. Followers
|
||||
;; can re-fetch the wrapped activity from the original instance if
|
||||
;; their projection wants to fold the body.
|
||||
|
||||
(DefineActivity
|
||||
:name "Announce"
|
||||
:doc "Re-broadcast a peer's activity to followers. :object is the CID of the activity being announced. Recipients see the Announce in their inbox / feed; their projection decides whether to fetch the wrapped activity body."
|
||||
:schema (fn (act) (string? (-> act :object)))
|
||||
:semantics (fn (state act) state))
|
||||
33
next/genesis/activity-types/define_trigger.sx
Normal file
33
next/genesis/activity-types/define_trigger.sx
Normal file
@@ -0,0 +1,33 @@
|
||||
;; next/genesis/activity-types/define_trigger.sx
|
||||
;;
|
||||
;; Bootstrap definition of the DefineTrigger verb per
|
||||
;; plans/agent-briefings/fed-sx-triggers-loop.md (Phase 1) and
|
||||
;; plans/fed-sx-design.md §13. Read as data by the bundler
|
||||
;; (bootstrap.erl) — never evaluated as code.
|
||||
;;
|
||||
;; DefineTrigger binds an activity-type to a flow. When a matching
|
||||
;; activity is appended to the log, the kernel's trigger fan-out
|
||||
;; (pipeline.erl, post-append) looks the type up in the trigger
|
||||
;; registry and starts the named flow with the activity as input.
|
||||
;; The activity's :object is the binding record:
|
||||
;; {:activity-type "Create" ;; the verb to fire on
|
||||
;; :flow-name "blog-publish-digest"
|
||||
;; :guard <optional predicate> ;; discriminator
|
||||
;; :actor-scope <optional actor id>} ;; default: any
|
||||
;;
|
||||
;; The schema validates the *activity* shape: :object present with
|
||||
;; string :activity-type and :flow-name. The optional :guard lets one
|
||||
;; type bind to multiple flows with discriminators; it is resolved to
|
||||
;; an Erlang predicate at registration time (trigger_registry), not
|
||||
;; carried in the pure-predicate schema here. Schema bodies use nested
|
||||
;; `get` (not keyword-threading) so the predicate is evaluatable.
|
||||
(DefineActivity
|
||||
:name "DefineTrigger"
|
||||
:doc "Bind an activity-type to a flow. :object carries :activity-type, :flow-name, and optional :guard and :actor-scope."
|
||||
:schema (fn
|
||||
(act)
|
||||
(and
|
||||
(not (nil? (get act :object)))
|
||||
(string? (get (get act :object) :activity-type))
|
||||
(string? (get (get act :object) :flow-name))))
|
||||
:semantics (fn (state act) state))
|
||||
34
next/genesis/activity-types/define_type.sx
Normal file
34
next/genesis/activity-types/define_type.sx
Normal file
@@ -0,0 +1,34 @@
|
||||
;; next/genesis/activity-types/define_type.sx
|
||||
;;
|
||||
;; Bootstrap definition of the DefineType verb per
|
||||
;; plans/fed-sx-host-types.md (host-type federation, Phase 1).
|
||||
;; Read as data by the bundler (bootstrap.erl) — never evaluated as
|
||||
;; code. The :schema and :semantics bodies are SX source.
|
||||
;;
|
||||
;; DefineType declares a refinement type. The activity's :object is
|
||||
;; the type record:
|
||||
;; {:name "Post" ;; the type's display name
|
||||
;; :fields (...) ;; optional field descriptors
|
||||
;; :refinement-schema (fn (obj) ...) ;; predicate over instances
|
||||
;; :instance-type "Note"} ;; base object-type it refines
|
||||
;;
|
||||
;; The schema below validates the *activity* shape: :object present,
|
||||
;; :name a string, :fields (when present) a list. The richer
|
||||
;; per-field shape check and the registry registration land with the
|
||||
;; peer_types cache (Phase 2) — at this phase the form is pure data.
|
||||
;;
|
||||
;; Schema bodies use nested `get` rather than keyword-threading so
|
||||
;; the predicate is directly evaluatable (keywords are not callable
|
||||
;; getters in the kernel; `(-> d :k)` is not a get).
|
||||
(DefineActivity
|
||||
:name "DefineType"
|
||||
:doc "Declare a refinement type. :object carries :name, optional :fields, :refinement-schema, and :instance-type."
|
||||
:schema (fn
|
||||
(act)
|
||||
(and
|
||||
(not (nil? (get act :object)))
|
||||
(string? (get (get act :object) :name))
|
||||
(or
|
||||
(nil? (get (get act :object) :fields))
|
||||
(list? (get (get act :object) :fields)))))
|
||||
:semantics (fn (state act) state))
|
||||
13
next/genesis/activity-types/endorse.sx
Normal file
13
next/genesis/activity-types/endorse.sx
Normal file
@@ -0,0 +1,13 @@
|
||||
;; next/genesis/activity-types/endorse.sx
|
||||
;;
|
||||
;; Bootstrap definition of the Endorse verb per design §13.5 / m2
|
||||
;; Step 11. An Endorse expresses cross-actor signal on a target
|
||||
;; activity (like / share / etc.). :object is the target activity's
|
||||
;; CID; :kind is the endorsement variant (string). Projections
|
||||
;; aggregate endorsements into counters / heat / ranking signals.
|
||||
|
||||
(DefineActivity
|
||||
:name "Endorse"
|
||||
:doc "Cross-actor signal on a target activity. :object is the target activity's CID; :kind is the endorsement variant (e.g. 'like', 'share'). Projections aggregate endorsements into counters / heat / ranking signals."
|
||||
:schema (fn (act) (and (string? (-> act :object)) (string? (-> act :kind))))
|
||||
:semantics (fn (state act) state))
|
||||
31
next/genesis/activity-types/subtype_of.sx
Normal file
31
next/genesis/activity-types/subtype_of.sx
Normal file
@@ -0,0 +1,31 @@
|
||||
;; next/genesis/activity-types/subtype_of.sx
|
||||
;;
|
||||
;; Bootstrap definition of the SubtypeOf verb per
|
||||
;; plans/fed-sx-host-types.md (host-type federation, Phase 1).
|
||||
;; Read as data by the bundler (bootstrap.erl) — never evaluated as
|
||||
;; code. The :schema and :semantics bodies are SX source.
|
||||
;;
|
||||
;; SubtypeOf records a hierarchy edge between two previously-defined
|
||||
;; types. The activity's :object is the relation record:
|
||||
;; {:child-type-cid "bafy...child"
|
||||
;; :parent-type-cid "bafy...parent"}
|
||||
;;
|
||||
;; The schema validates the *activity* shape: both CIDs present and
|
||||
;; string-typed. Verifying that each CID names a previously-defined
|
||||
;; type is a registry concern (it needs the type index that lands
|
||||
;; with peer_types in Phase 2), so it is deliberately out of the
|
||||
;; pure-predicate schema here — adding the edge to the hierarchy
|
||||
;; index is the :semantics' job once the registry surface exists.
|
||||
;;
|
||||
;; Schema bodies use nested `get` rather than keyword-threading so
|
||||
;; the predicate is directly evaluatable.
|
||||
(DefineActivity
|
||||
:name "SubtypeOf"
|
||||
:doc "Record a subtype edge. :object carries :child-type-cid and :parent-type-cid, both type CIDs."
|
||||
:schema (fn
|
||||
(act)
|
||||
(and
|
||||
(not (nil? (get act :object)))
|
||||
(string? (get (get act :object) :child-type-cid))
|
||||
(string? (get (get act :object) :parent-type-cid))))
|
||||
:semantics (fn (state act) state))
|
||||
@@ -20,10 +20,18 @@
|
||||
:kernel-version "1.0.0-m1"
|
||||
:activity-types ("activity-types/create.sx"
|
||||
"activity-types/update.sx"
|
||||
"activity-types/delete.sx")
|
||||
"activity-types/delete.sx"
|
||||
"activity-types/announce.sx"
|
||||
"activity-types/endorse.sx"
|
||||
"activity-types/define_type.sx"
|
||||
"activity-types/subtype_of.sx"
|
||||
"activity-types/define_trigger.sx")
|
||||
:object-types ("object-types/sx-artifact.sx"
|
||||
"object-types/note.sx"
|
||||
"object-types/tombstone.sx"
|
||||
"object-types/person.sx"
|
||||
"object-types/service.sx"
|
||||
"object-types/group.sx"
|
||||
"object-types/define-activity.sx"
|
||||
"object-types/define-object.sx"
|
||||
"object-types/define-projection.sx"
|
||||
|
||||
11
next/genesis/object-types/group.sx
Normal file
11
next/genesis/object-types/group.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; next/genesis/object-types/group.sx
|
||||
;;
|
||||
;; Per design §9.1: a Group is a multi-controller actor — typically
|
||||
;; a working group, channel, or collective whose membership is
|
||||
;; managed via Add/Remove activities. Sig-suite validation honours
|
||||
;; the current key-set rather than a single keypair.
|
||||
|
||||
(DefineObject
|
||||
:name "Group"
|
||||
:doc "Multi-controller actor. :name is the group's display name; :preferredUsername is the local handle; :summary is the description; :icon is a CID or URL; :members is the current member list (managed via Add/Remove)."
|
||||
:schema (fn (obj) (string? (-> obj :name))))
|
||||
11
next/genesis/object-types/person.sx
Normal file
11
next/genesis/object-types/person.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; next/genesis/object-types/person.sx
|
||||
;;
|
||||
;; Per design §9.1: a Person is the canonical actor type for a
|
||||
;; human-controlled identity. Bootstrapped via Create{Person{...}}
|
||||
;; as the actor's first activity (see nx_kernel:bootstrap_actor/4).
|
||||
;; ActivityPub-Person-compatible.
|
||||
|
||||
(DefineObject
|
||||
:name "Person"
|
||||
:doc "Human-controlled actor. :name is the display name; :preferredUsername is the local handle; :summary is the profile bio; :icon is a CID or URL."
|
||||
:schema (fn (obj) (string? (-> obj :name))))
|
||||
11
next/genesis/object-types/service.sx
Normal file
11
next/genesis/object-types/service.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; next/genesis/object-types/service.sx
|
||||
;;
|
||||
;; Per design §9.1: a Service is a non-human actor — a bot, an
|
||||
;; automated feed, an organisational publisher. Same activity
|
||||
;; surface as Person, different ActivityPub Actor type. Tooling
|
||||
;; treats a Service identically to a Person except for UX hints.
|
||||
|
||||
(DefineObject
|
||||
:name "Service"
|
||||
:doc "Automated / programmatic actor. :name is the display name; :preferredUsername is the local handle; :summary is the profile bio; :icon is a CID or URL."
|
||||
:schema (fn (obj) (string? (-> obj :name))))
|
||||
260
next/kernel/actor_state.erl
Normal file
260
next/kernel/actor_state.erl
Normal file
@@ -0,0 +1,260 @@
|
||||
-module(actor_state).
|
||||
-export([fold/2, fold_fn/0, new/0, lookup/2, has/2, actors/1,
|
||||
profile_type/1, profile_name/1, profile_field/2,
|
||||
key_history/1, active_keys_at/2, find_key_by_id/2]).
|
||||
|
||||
%% Actor-state projection fold — Erlang-fun stand-in for the
|
||||
%% genesis `actor-state.sx` projection body. Tracks per-actor
|
||||
%% profiles, key-history, and Move pointers per design §9.1-§9.4.
|
||||
%%
|
||||
%% State shape:
|
||||
%% [{ActorId, Profile}, ...]
|
||||
%%
|
||||
%% Profile = [{type, person|service|group},
|
||||
%% {name, Bin},
|
||||
%% {preferredUsername, Bin},
|
||||
%% {summary, Bin},
|
||||
%% {icon, Bin},
|
||||
%% {public_keys, [Key]},
|
||||
%% {moved_to, ActorIdOrUrl},
|
||||
%% {created, N}]
|
||||
%%
|
||||
%% Bridge note: the SX-source eval bridge would replace this fold
|
||||
%% body once available (same gap as Step 5d-pure / Step 6c-schema-pure).
|
||||
%% define_registry.erl is the structural twin.
|
||||
%%
|
||||
%% lists:keyfind/keymember aren't in this substrate (Step 1a noted
|
||||
%% same gap), so local `find_keyed`/`has_keyed`/`set_keyed` helpers
|
||||
%% handle the keyed-list ops.
|
||||
|
||||
new() -> [].
|
||||
|
||||
actors(State) -> [Id || {Id, _Profile} <- State].
|
||||
|
||||
has(ActorId, State) -> has_keyed(ActorId, State).
|
||||
|
||||
lookup(ActorId, State) ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Profile} -> {ok, Profile};
|
||||
{error, _} -> not_found
|
||||
end.
|
||||
|
||||
%% ── Fold dispatch ───────────────────────────────────────────────
|
||||
|
||||
fold(Activity, State) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, create} -> fold_create(Activity, State);
|
||||
{ok, update} -> fold_update(Activity, State);
|
||||
{ok, move} -> fold_move(Activity, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_create(Activity, State) ->
|
||||
case envelope:get_field(object, Activity) of
|
||||
{ok, Obj} ->
|
||||
case envelope:get_field(type, Obj) of
|
||||
{ok, ObjType} ->
|
||||
case is_actor_type(ObjType) of
|
||||
true -> register_actor(Activity, Obj, ObjType, State);
|
||||
false -> State
|
||||
end;
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
register_actor(Activity, Obj, ObjType, State) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, ActorId} ->
|
||||
case has_keyed(ActorId, State) of
|
||||
true ->
|
||||
State;
|
||||
false ->
|
||||
Created = published_seq(Activity),
|
||||
Profile = build_profile(ObjType, Obj, Created),
|
||||
State ++ [{ActorId, Profile}]
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_update(Activity, State) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, ActorId} ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Profile} ->
|
||||
case envelope:get_field(patch, Activity) of
|
||||
{ok, Patch} ->
|
||||
Published = published_seq(Activity),
|
||||
NewProfile = apply_patch(Profile, Patch, Published),
|
||||
set_keyed(ActorId, NewProfile, State);
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_move(Activity, State) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, ActorId} ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Profile} ->
|
||||
case envelope:get_field(moved_to, Activity) of
|
||||
{ok, Target} ->
|
||||
NewProfile = set_keyed(moved_to, Target, Profile),
|
||||
set_keyed(ActorId, NewProfile, State);
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% ── Profile assembly ────────────────────────────────────────────
|
||||
|
||||
build_profile(ObjType, Obj, Created) ->
|
||||
Base = [{type, ObjType}, {created, Created}],
|
||||
Fields = [name, preferredUsername, summary, icon, public_keys],
|
||||
Base ++ collect_fields(Fields, Obj).
|
||||
|
||||
collect_fields([], _) -> [];
|
||||
collect_fields([F | Rest], Obj) ->
|
||||
case envelope:get_field(F, Obj) of
|
||||
{ok, V} -> [{F, V} | collect_fields(Rest, Obj)];
|
||||
_ -> collect_fields(Rest, Obj)
|
||||
end.
|
||||
|
||||
merge_patch(Profile, []) -> Profile;
|
||||
merge_patch(Profile, [{K, V} | Rest]) ->
|
||||
merge_patch(set_keyed(K, V, Profile), Rest);
|
||||
merge_patch(Profile, _) -> Profile.
|
||||
|
||||
%% apply_patch/3 — same as merge_patch but special-cases two
|
||||
%% key-rotation patch entries per design §9.6:
|
||||
%% {add_publicKey, KeyProplist} — append a new key to :public_keys,
|
||||
%% defaulting :created to Published.
|
||||
%% {supersede, OldKeyId} — mark the key with :id =:= OldKeyId
|
||||
%% as :superseded_at = Published.
|
||||
%% Other patch entries fall through to last-write-wins per key.
|
||||
|
||||
apply_patch(Profile, [], _Published) -> Profile;
|
||||
apply_patch(Profile, [{add_publicKey, NewKey} | Rest], Published) ->
|
||||
Augmented = ensure_created(NewKey, Published),
|
||||
Current = current_public_keys(Profile),
|
||||
NewKeys = Current ++ [Augmented],
|
||||
apply_patch(set_keyed(public_keys, NewKeys, Profile), Rest, Published);
|
||||
apply_patch(Profile, [{supersede, OldKeyId} | Rest], Published) ->
|
||||
Current = current_public_keys(Profile),
|
||||
NewKeys = mark_superseded(OldKeyId, Published, Current),
|
||||
apply_patch(set_keyed(public_keys, NewKeys, Profile), Rest, Published);
|
||||
apply_patch(Profile, [{K, V} | Rest], Published) ->
|
||||
apply_patch(set_keyed(K, V, Profile), Rest, Published);
|
||||
apply_patch(Profile, _, _) -> Profile.
|
||||
|
||||
current_public_keys(Profile) ->
|
||||
case find_keyed(public_keys, Profile) of
|
||||
{ok, Keys} -> Keys;
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
ensure_created(Key, Published) ->
|
||||
case find_keyed(created, Key) of
|
||||
{ok, _} -> Key;
|
||||
_ -> set_keyed(created, Published, Key)
|
||||
end.
|
||||
|
||||
mark_superseded(_, _, []) -> [];
|
||||
mark_superseded(OldId, At, [Key | Rest]) ->
|
||||
case find_keyed(id, Key) of
|
||||
{ok, OldId} ->
|
||||
case find_keyed(superseded_at, Key) of
|
||||
{ok, _} -> [Key | mark_superseded(OldId, At, Rest)];
|
||||
_ -> [set_keyed(superseded_at, At, Key) | mark_superseded(OldId, At, Rest)]
|
||||
end;
|
||||
_ -> [Key | mark_superseded(OldId, At, Rest)]
|
||||
end.
|
||||
|
||||
%% Key-history view — full :public_keys list including superseded
|
||||
%% entries (per §9.6: history is preserved so historical activities
|
||||
%% verify against keys that were active at their :published time).
|
||||
|
||||
key_history(Profile) ->
|
||||
current_public_keys(Profile).
|
||||
|
||||
%% active_keys_at/2 — the subset of :public_keys active at Now,
|
||||
%% mirroring envelope's is_active_at semantics (local copy: envelope
|
||||
%% keeps the predicate private).
|
||||
|
||||
active_keys_at(Profile, Now) ->
|
||||
[K || K <- current_public_keys(Profile),
|
||||
key_active_at(K, Now)].
|
||||
|
||||
find_key_by_id(KeyId, Profile) ->
|
||||
find_key_by_id_in(KeyId, current_public_keys(Profile)).
|
||||
|
||||
find_key_by_id_in(_, []) -> not_found;
|
||||
find_key_by_id_in(WantId, [K | Rest]) ->
|
||||
case find_keyed(id, K) of
|
||||
{ok, WantId} -> {ok, K};
|
||||
_ -> find_key_by_id_in(WantId, Rest)
|
||||
end.
|
||||
|
||||
key_active_at(Key, Now) ->
|
||||
case find_keyed(created, Key) of
|
||||
{ok, Created} when Now >= Created ->
|
||||
case find_keyed(superseded_at, Key) of
|
||||
{ok, SupAt} -> Now < SupAt;
|
||||
_ -> true
|
||||
end;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
published_seq(Activity) ->
|
||||
case envelope:get_field(published, Activity) of
|
||||
{ok, P} -> P;
|
||||
_ -> 0
|
||||
end.
|
||||
|
||||
is_actor_type(person) -> true;
|
||||
is_actor_type(service) -> true;
|
||||
is_actor_type(group) -> true;
|
||||
is_actor_type(_) -> false.
|
||||
|
||||
%% ── Profile accessors ───────────────────────────────────────────
|
||||
|
||||
profile_type(Profile) ->
|
||||
case find_keyed(type, Profile) of
|
||||
{ok, T} -> T;
|
||||
_ -> nil
|
||||
end.
|
||||
|
||||
profile_name(Profile) ->
|
||||
case find_keyed(name, Profile) of
|
||||
{ok, N} -> N;
|
||||
_ -> nil
|
||||
end.
|
||||
|
||||
profile_field(F, Profile) ->
|
||||
case find_keyed(F, Profile) of
|
||||
{ok, V} -> {ok, V};
|
||||
_ -> not_found
|
||||
end.
|
||||
|
||||
%% ── Projection integration ──────────────────────────────────────
|
||||
|
||||
fold_fn() ->
|
||||
fun (Activity, State) -> fold(Activity, State) end.
|
||||
|
||||
%% ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
has_keyed(_, []) -> false;
|
||||
has_keyed(K, [{K, _} | _]) -> true;
|
||||
has_keyed(K, [_ | Rest]) -> has_keyed(K, Rest).
|
||||
|
||||
find_keyed(_, []) -> {error, 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)].
|
||||
79
next/kernel/announce_state.erl
Normal file
79
next/kernel/announce_state.erl
Normal file
@@ -0,0 +1,79 @@
|
||||
-module(announce_state).
|
||||
-export([new/0, fold/2, fold_fn/0,
|
||||
announcers_for/2, announce_count/2, announced_cids/1,
|
||||
has_announced/3]).
|
||||
|
||||
%% Announce-fanout projection. Folds Announce activities into a
|
||||
%% per-target-Cid set of announcer ActorIds so projections can
|
||||
%% answer "who re-broadcast this activity" / "how many announces
|
||||
%% does this Note have" / "what activities has X announced".
|
||||
%%
|
||||
%% Announce envelope shape (per next/genesis/activity-types/announce.sx):
|
||||
%% [{type, announce},
|
||||
%% {actor, AnnouncerActorId},
|
||||
%% {object, TargetCidBinary},
|
||||
%% ...]
|
||||
%%
|
||||
%% State shape:
|
||||
%% [{TargetCid, [Announcer1, Announcer2, ...]}, ...]
|
||||
%%
|
||||
%% Set semantics — the same actor announcing the same target twice
|
||||
%% is a no-op (already in the list). Undo{Announce} retraction
|
||||
%% defers to a follow-up.
|
||||
|
||||
new() -> [].
|
||||
|
||||
fold_fn() ->
|
||||
fun (Activity, State) -> fold(Activity, State) end.
|
||||
|
||||
fold(Activity, State) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, announce} -> fold_announce(Activity, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_announce(Activity, State) ->
|
||||
case {envelope:get_field(actor, Activity),
|
||||
envelope:get_field(object, Activity)} of
|
||||
{{ok, Actor}, {ok, Cid}} -> add_announcer(Cid, Actor, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
add_announcer(Cid, Actor, State) ->
|
||||
Current = case find_keyed(Cid, State) of
|
||||
{ok, Set} -> Set;
|
||||
_ -> []
|
||||
end,
|
||||
case contains(Actor, Current) of
|
||||
true -> State;
|
||||
false -> set_keyed(Cid, Current ++ [Actor], State)
|
||||
end.
|
||||
|
||||
%% ── Read-side accessors ───────────────────────────────────────
|
||||
|
||||
announcers_for(Cid, State) ->
|
||||
case find_keyed(Cid, State) of
|
||||
{ok, Set} -> Set;
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
announce_count(Cid, State) -> length(announcers_for(Cid, State)).
|
||||
|
||||
announced_cids(State) -> [C || {C, _} <- State].
|
||||
|
||||
has_announced(Actor, Cid, State) ->
|
||||
contains(Actor, announcers_for(Cid, State)).
|
||||
|
||||
%% ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
contains(_, []) -> false;
|
||||
contains(X, [X | _]) -> true;
|
||||
contains(X, [_ | Rest]) -> contains(X, Rest).
|
||||
|
||||
find_keyed(_, []) -> {error, 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)].
|
||||
136
next/kernel/backfill.erl
Normal file
136
next/kernel/backfill.erl
Normal file
@@ -0,0 +1,136 @@
|
||||
-module(backfill).
|
||||
-export([slice/2, slice/3,
|
||||
wrap_backfill/1, parse_mode/1,
|
||||
all_entries/1, last_n_entries/2, last_t_entries/3,
|
||||
since_cid_entries/2, none_entries/0]).
|
||||
|
||||
%% Backfill mode slicing per design §13.3 / Step 9. When A follows B
|
||||
%% with a backfill spec, B's kernel slices the outbox log into the
|
||||
%% appropriate window and delivers each entry as
|
||||
%% `{backfilled, true}`-marked envelopes alongside forward-going
|
||||
%% activity.
|
||||
%%
|
||||
%% Mode shapes (per the Follow activity's `:backfill` field):
|
||||
%% none — newer follower sees only forward content
|
||||
%% {last_n, N} — backfill last N activities (FIFO order)
|
||||
%% {last_t, T, NowFn} — backfill activities with :published in
|
||||
%% (Now - T .. Now]. NowFn is a 0-arity fun
|
||||
%% so tests can fake-time it.
|
||||
%% full — backfill the entire outbox
|
||||
%%
|
||||
%% slice/2 returns the activity list. slice/3 also wraps each entry
|
||||
%% with `{backfilled, true}` so projections can decide whether to
|
||||
%% re-fold or skip (the §13.3 Backfilled bodies preserve the
|
||||
%% original `:id` so replay defence still works on the receiver).
|
||||
%%
|
||||
%% parse_mode/1 lifts the Follow activity's `:backfill` proplist
|
||||
%% (or atom) into the internal mode tuple. Unknown shapes fall back
|
||||
%% to `none` — the default open-world policy.
|
||||
|
||||
slice(Mode, LogState) ->
|
||||
slice(Mode, LogState, false).
|
||||
|
||||
slice(Mode, LogState, Wrap) ->
|
||||
Entries = log:entries(LogState),
|
||||
Slice = case Mode of
|
||||
none -> none_entries();
|
||||
full -> all_entries(Entries);
|
||||
{last_n, N} -> last_n_entries(N, Entries);
|
||||
{last_t, T, NowFn} -> last_t_entries(T, NowFn, Entries);
|
||||
{since_cid, Cid} -> since_cid_entries(Cid, Entries);
|
||||
_ -> none_entries()
|
||||
end,
|
||||
case Wrap of
|
||||
true -> wrap_backfill(Slice);
|
||||
_ -> Slice
|
||||
end.
|
||||
|
||||
%% ── Mode-specific entry selection ─────────────────────────────
|
||||
|
||||
all_entries(Entries) -> Entries.
|
||||
|
||||
none_entries() -> [].
|
||||
|
||||
%% last_n_entries/2 — tail N entries in FIFO order.
|
||||
|
||||
last_n_entries(N, _) when N =< 0 -> [];
|
||||
last_n_entries(N, Entries) ->
|
||||
Len = length(Entries),
|
||||
case Len =< N of
|
||||
true -> Entries;
|
||||
false -> drop_n(Len - N, Entries)
|
||||
end.
|
||||
|
||||
drop_n(0, L) -> L;
|
||||
drop_n(_, []) -> [];
|
||||
drop_n(N, [_ | Rest]) -> drop_n(N - 1, Rest).
|
||||
|
||||
%% last_t_entries/3 — entries whose :published is within the last
|
||||
%% T units of (NowFn() - T .. NowFn()]. T and :published are
|
||||
%% integers (seconds-since-epoch in production; opaque ints in tests).
|
||||
|
||||
last_t_entries(T, NowFn, Entries) when is_integer(T), T >= 0 ->
|
||||
Now = NowFn(),
|
||||
Cutoff = Now - T,
|
||||
[E || E <- Entries, in_window(E, Cutoff, Now)];
|
||||
last_t_entries(_, _, _) -> [].
|
||||
|
||||
in_window(Activity, Cutoff, Now) ->
|
||||
case envelope:get_field(published, Activity) of
|
||||
{ok, P} when is_integer(P), P > Cutoff, P =< Now -> true;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
%% since_cid_entries/2 — every entry after the one with :id = Cid.
|
||||
%% If Cid isn't in the log, returns [] (caller's pointer is stale).
|
||||
%% Used by `GET /actors/<id>/outbox?since=Cid` pagination.
|
||||
|
||||
since_cid_entries(_Cid, []) -> [];
|
||||
since_cid_entries(Cid, [E | Rest]) ->
|
||||
case envelope:get_field(id, E) of
|
||||
{ok, Cid} -> Rest;
|
||||
_ -> since_cid_entries(Cid, Rest)
|
||||
end.
|
||||
|
||||
%% wrap_backfill/1 — append `{backfilled, true}` to each entry.
|
||||
%% The receiving projection scheduler reads this field and chooses
|
||||
%% whether to fold (re-emit) or skip (already known via replay
|
||||
%% defence on `:id`).
|
||||
|
||||
wrap_backfill([]) -> [];
|
||||
wrap_backfill([E | Rest]) ->
|
||||
[E ++ [{backfilled, true}] | wrap_backfill(Rest)].
|
||||
|
||||
%% parse_mode/1 — Lift a Follow activity's `:backfill` value into the
|
||||
%% internal mode tuple. Accepts:
|
||||
%% nil / not_found -> none
|
||||
%% none -> none
|
||||
%% full -> full
|
||||
%% {last_n, N} -> {last_n, N} (already-parsed shape)
|
||||
%% {last_t, T, NowFn} -> pass-through
|
||||
%% Proplist with :mode + :limit / :duration -> parsed
|
||||
%% Unknown shape -> none (open-world default).
|
||||
|
||||
parse_mode(nil) -> none;
|
||||
parse_mode(none) -> none;
|
||||
parse_mode(full) -> full;
|
||||
parse_mode({last_n, N}) -> {last_n, N};
|
||||
parse_mode({last_t, T, NowFn}) -> {last_t, T, NowFn};
|
||||
parse_mode({since_cid, Cid}) -> {since_cid, Cid};
|
||||
parse_mode(List) when is_list(List) ->
|
||||
case envelope:get_field(mode, List) of
|
||||
{ok, last_n} ->
|
||||
case envelope:get_field(limit, List) of
|
||||
{ok, N} when is_integer(N) -> {last_n, N};
|
||||
_ -> none
|
||||
end;
|
||||
{ok, last_t} ->
|
||||
case envelope:get_field(duration, List) of
|
||||
{ok, T} when is_integer(T) -> {last_t, T, fun () -> 0 end};
|
||||
_ -> none
|
||||
end;
|
||||
{ok, full} -> full;
|
||||
{ok, none} -> none;
|
||||
_ -> none
|
||||
end;
|
||||
parse_mode(_) -> none.
|
||||
86
next/kernel/delivery.erl
Normal file
86
next/kernel/delivery.erl
Normal file
@@ -0,0 +1,86 @@
|
||||
-module(delivery).
|
||||
-export([delivery_set/2, delivery_set/3,
|
||||
collect_recipients/1, suppress_self/2, dedup/1,
|
||||
expand_audience/3]).
|
||||
|
||||
%% Audience-resolving delivery set computation per design §13.4.
|
||||
%%
|
||||
%% delivery_set/2(Activity, KernelState) returns a sorted, deduped
|
||||
%% list of ActorId atoms — every actor the outgoing Activity needs
|
||||
%% to be POSTed to. Sources:
|
||||
%% - Activity's `:to` field (single ActorId or list)
|
||||
%% - Activity's `:cc` field (single ActorId or list)
|
||||
%% - audience-symbol expansion of `public` and `followers`
|
||||
%%
|
||||
%% Self-delivery (the publishing actor reading their own activity
|
||||
%% on a peer's behalf) is suppressed.
|
||||
%%
|
||||
%% Output for Step 7a is the bare ActorId list; Step 8 will resolve
|
||||
%% each entry to `{PeerInstanceUrl, ActorId}` via the peer-actors
|
||||
%% cache.
|
||||
|
||||
delivery_set(Activity, KernelState) ->
|
||||
delivery_set(Activity, KernelState, follower_graph:new()).
|
||||
|
||||
delivery_set(Activity, KernelState, FollowerGraph) ->
|
||||
Self = sender(Activity),
|
||||
Raw = collect_recipients(Activity),
|
||||
Expanded = expand_all(Raw, Self, KernelState, FollowerGraph),
|
||||
Suppressed = suppress_self(Expanded, Self),
|
||||
dedup(Suppressed).
|
||||
|
||||
%% collect_recipients/1 — flat list from :to + :cc, normalised so
|
||||
%% each element is either an ActorId atom or an audience symbol
|
||||
%% (`public` / `followers`).
|
||||
|
||||
collect_recipients(Activity) ->
|
||||
To = envelope_field_list(to, Activity),
|
||||
Cc = envelope_field_list(cc, Activity),
|
||||
To ++ Cc.
|
||||
|
||||
envelope_field_list(Field, Activity) ->
|
||||
case envelope:get_field(Field, Activity) of
|
||||
not_found -> [];
|
||||
{ok, V} when is_list(V) -> V;
|
||||
{ok, V} -> [V]
|
||||
end.
|
||||
|
||||
%% expand_audience/3 — `followers` -> the sender's followers
|
||||
%% proplist entry from a follower_graph state. `public` for v2
|
||||
%% expands to the same list (per design §13.4: practical Public
|
||||
%% fan-out is "every follower of the publishing actor"). The
|
||||
%% explicit shared-inbox peer-instance model defers to v3.
|
||||
%% Other symbols / explicit ActorIds pass through unchanged.
|
||||
|
||||
expand_audience(public, Sender, Graph) ->
|
||||
follower_graph:followers(Sender, Graph);
|
||||
expand_audience(followers, Sender, Graph) ->
|
||||
follower_graph:followers(Sender, Graph);
|
||||
expand_audience(X, _Sender, _Graph) -> [X].
|
||||
|
||||
expand_all([], _Self, _State, _Graph) -> [];
|
||||
expand_all([X | Rest], Self, State, Graph) ->
|
||||
expand_audience(X, Self, Graph) ++ expand_all(Rest, Self, State, Graph).
|
||||
|
||||
suppress_self([], _Self) -> [];
|
||||
suppress_self([Self | Rest], Self) -> suppress_self(Rest, Self);
|
||||
suppress_self([X | Rest], Self) -> [X | suppress_self(Rest, Self)].
|
||||
|
||||
dedup(L) -> dedup_acc(L, []).
|
||||
|
||||
dedup_acc([], Acc) -> Acc;
|
||||
dedup_acc([X | Rest], Acc) ->
|
||||
case contains(X, Acc) of
|
||||
true -> dedup_acc(Rest, Acc);
|
||||
false -> dedup_acc(Rest, Acc ++ [X])
|
||||
end.
|
||||
|
||||
contains(_, []) -> false;
|
||||
contains(X, [X | _]) -> true;
|
||||
contains(X, [_ | Rest]) -> contains(X, Rest).
|
||||
|
||||
sender(Activity) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, A} -> A;
|
||||
_ -> nil
|
||||
end.
|
||||
209
next/kernel/delivery_state.erl
Normal file
209
next/kernel/delivery_state.erl
Normal file
@@ -0,0 +1,209 @@
|
||||
-module(delivery_state).
|
||||
-export([new/0, fold/2, fold_fn/0,
|
||||
peer_state/2, peers/1,
|
||||
pending/2, attempts/2, next_retry/2, dead_letter/2]).
|
||||
|
||||
%% Delivery-state projection. Folds delivery events (enqueue /
|
||||
%% delivered / failed / dead_lettered) into a per-peer worker-shaped
|
||||
%% snapshot so the outbound queue survives kernel restart. Per design
|
||||
%% §13.4 the worker state on restart is loaded from this projection
|
||||
%% rather than reconstructed by re-driving the outbox log.
|
||||
%%
|
||||
%% Event proplist shape:
|
||||
%% [{type, enqueued}, {peer, _}, {activity, _}]
|
||||
%% [{type, delivered}, {peer, _}, {cid, _}]
|
||||
%% [{type, failed}, {peer, _}, {cid, _}, {now, _}]
|
||||
%% [{type, dead_lettered}, {peer, _}, {cid, _}]
|
||||
%%
|
||||
%% Projection state shape:
|
||||
%% [{PeerId, WorkerProplist}, ...]
|
||||
%%
|
||||
%% WorkerProplist mirrors `delivery_worker:new/1`'s output so a fresh
|
||||
%% gen_server can be hydrated with `delivery_worker:state_from_proj`
|
||||
%% (lands when 8b-timer wires up). For Step 8c the projection only
|
||||
%% tracks data — Step 8d-restart will wire the hydration helper.
|
||||
|
||||
new() -> [].
|
||||
|
||||
fold_fn() ->
|
||||
fun (Event, State) -> fold(Event, State) end.
|
||||
|
||||
fold(Event, State) ->
|
||||
case envelope:get_field(type, Event) of
|
||||
{ok, enqueued} -> fold_enqueued(Event, State);
|
||||
{ok, delivered} -> fold_delivered(Event, State);
|
||||
{ok, failed} -> fold_failed(Event, State);
|
||||
{ok, dead_lettered} -> fold_dead_lettered(Event, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_enqueued(Event, State) ->
|
||||
case {envelope:get_field(peer, Event),
|
||||
envelope:get_field(activity, Event)} of
|
||||
{{ok, Peer}, {ok, Act}} ->
|
||||
Worker = ensure_peer(Peer, State),
|
||||
Pending = field(pending, Worker),
|
||||
Worker1 = set_field(pending, Pending ++ [Act], Worker),
|
||||
set_peer(Peer, Worker1, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_delivered(Event, State) ->
|
||||
case {envelope:get_field(peer, Event),
|
||||
envelope:get_field(cid, Event)} of
|
||||
{{ok, Peer}, {ok, Cid}} ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} ->
|
||||
Worker1 = drop_pending_by_cid(Cid, Worker),
|
||||
Worker2 = clear_retry_for(Cid, Worker1),
|
||||
set_peer(Peer, Worker2, State);
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_failed(Event, State) ->
|
||||
case {envelope:get_field(peer, Event),
|
||||
envelope:get_field(cid, Event),
|
||||
envelope:get_field(now, Event)} of
|
||||
{{ok, Peer}, {ok, Cid}, {ok, Now}} ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} ->
|
||||
Attempts = field(attempts, Worker),
|
||||
Current = case find_keyed(Cid, Attempts) of
|
||||
{ok, N} -> N;
|
||||
_ -> 0
|
||||
end,
|
||||
New = Current + 1,
|
||||
Attempts1 = set_keyed(Cid, New, Attempts),
|
||||
Worker1 = set_field(attempts, Attempts1, Worker),
|
||||
Worker2 = case delivery_worker:backoff_for(New) of
|
||||
dead_letter ->
|
||||
dead_letter_pending(Cid, Worker1);
|
||||
Seconds ->
|
||||
NR = field(next_retry, Worker1),
|
||||
NextAt = Now + Seconds,
|
||||
set_field(next_retry, set_keyed(Cid, NextAt, NR), Worker1)
|
||||
end,
|
||||
set_peer(Peer, Worker2, State);
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_dead_lettered(Event, State) ->
|
||||
case {envelope:get_field(peer, Event),
|
||||
envelope:get_field(cid, Event)} of
|
||||
{{ok, Peer}, {ok, Cid}} ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} ->
|
||||
set_peer(Peer, dead_letter_pending(Cid, Worker), State);
|
||||
_ -> State
|
||||
end;
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% ── Accessors ─────────────────────────────────────────────────
|
||||
|
||||
peer_state(Peer, State) ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} -> {ok, Worker};
|
||||
_ -> not_found
|
||||
end.
|
||||
|
||||
peers(State) -> [P || {P, _} <- State].
|
||||
|
||||
pending(Peer, State) ->
|
||||
worker_field(Peer, pending, State, []).
|
||||
|
||||
attempts(Peer, State) ->
|
||||
worker_field(Peer, attempts, State, []).
|
||||
|
||||
next_retry(Peer, State) ->
|
||||
worker_field(Peer, next_retry, State, []).
|
||||
|
||||
dead_letter(Peer, State) ->
|
||||
worker_field(Peer, dead_letter, State, []).
|
||||
|
||||
%% ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
worker_field(Peer, Field, State, Default) ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} ->
|
||||
case find_keyed(Field, Worker) of
|
||||
{ok, V} -> V;
|
||||
_ -> Default
|
||||
end;
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
ensure_peer(Peer, State) ->
|
||||
case find_keyed(Peer, State) of
|
||||
{ok, Worker} -> Worker;
|
||||
_ -> empty_worker(Peer)
|
||||
end.
|
||||
|
||||
empty_worker(Peer) ->
|
||||
[{peer, Peer},
|
||||
{pending, []},
|
||||
{attempts, []},
|
||||
{next_retry, []},
|
||||
{dead_letter, []}].
|
||||
|
||||
set_peer(Peer, Worker, State) ->
|
||||
set_keyed(Peer, Worker, State).
|
||||
|
||||
drop_pending_by_cid(Cid, Worker) ->
|
||||
Pending = field(pending, Worker),
|
||||
Kept = [A || A <- Pending, activity_cid(A) =/= Cid],
|
||||
set_field(pending, Kept, Worker).
|
||||
|
||||
clear_retry_for(Cid, Worker) ->
|
||||
A1 = del_keyed(Cid, field(attempts, Worker)),
|
||||
NR1 = del_keyed(Cid, field(next_retry, Worker)),
|
||||
set_field(attempts, A1, set_field(next_retry, NR1, Worker)).
|
||||
|
||||
dead_letter_pending(Cid, Worker) ->
|
||||
Pending = field(pending, Worker),
|
||||
{Match, Rest} = split_by_cid(Cid, Pending),
|
||||
DL = field(dead_letter, Worker),
|
||||
Worker1 = set_field(pending, Rest, Worker),
|
||||
Worker2 = case Match of
|
||||
none -> Worker1;
|
||||
Act -> set_field(dead_letter, DL ++ [Act], Worker1)
|
||||
end,
|
||||
clear_retry_for(Cid, Worker2).
|
||||
|
||||
split_by_cid(Cid, List) -> split_by_cid(Cid, List, []).
|
||||
split_by_cid(_, [], Acc) -> {none, lists:reverse(Acc)};
|
||||
split_by_cid(Cid, [A | Rest], Acc) ->
|
||||
case activity_cid(A) of
|
||||
Cid -> {A, lists:reverse(Acc) ++ Rest};
|
||||
_ -> split_by_cid(Cid, Rest, [A | Acc])
|
||||
end.
|
||||
|
||||
activity_cid(Activity) ->
|
||||
case envelope:get_field(id, Activity) of
|
||||
{ok, Cid} -> Cid;
|
||||
_ -> nil
|
||||
end.
|
||||
|
||||
field(K, [{K, V} | _]) -> V;
|
||||
field(K, [_ | Rest]) -> field(K, Rest);
|
||||
field(_, []) -> undefined.
|
||||
|
||||
set_field(K, V, []) -> [{K, V}];
|
||||
set_field(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_field(K, V, [P | Rest]) -> [P | set_field(K, V, Rest)].
|
||||
|
||||
find_keyed(_, []) -> {error, 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)].
|
||||
|
||||
del_keyed(_, []) -> [];
|
||||
del_keyed(K, [{K, _} | Rest]) -> Rest;
|
||||
del_keyed(K, [P | Rest]) -> [P | del_keyed(K, Rest)].
|
||||
426
next/kernel/delivery_worker.erl
Normal file
426
next/kernel/delivery_worker.erl
Normal file
@@ -0,0 +1,426 @@
|
||||
-module(delivery_worker).
|
||||
-behaviour(gen_server).
|
||||
-export([new/1, pending/1, peer/1,
|
||||
enqueue_pure/3, drain_pure/1, deliver_one_pure/2,
|
||||
backoff_for/1, schedule_for/1,
|
||||
record_failure_pure/3, record_success_pure/2,
|
||||
next_due_pure/2, attempts_for/2, next_retry_at/2,
|
||||
dead_letter_list/1, timer_ref_for/2,
|
||||
start_link/1, start_link/2, stop/1,
|
||||
enqueue/2, flush/1, pending_srv/1, set_dispatch_fn/2,
|
||||
state_srv/1]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
%% Outbound delivery worker per design §13.4. One gen_server per
|
||||
%% peer instance (peer-id atom) holding a FIFO queue of pending
|
||||
%% activities to deliver. v2 lands in stages:
|
||||
%%
|
||||
%% Step 8a pure-functional state shape, enqueue / drain /
|
||||
%% schedule semantics + gen_server skeleton + tests
|
||||
%% Step 8b retry / backoff schedule (30s / 5m / 30m / 6h / 24h)
|
||||
%% + dead-letter list
|
||||
%% Step 8c delivery-state projection so the queue survives
|
||||
%% kernel restart
|
||||
%% Step 8d outbox:publish/2 dispatches each delivery-set entry
|
||||
%% to the matching worker
|
||||
%% Step 8e httpc:request/4 BIF (substrate exception per briefing)
|
||||
%% Step 8f real HTTP POST through the BIF + content-type wiring
|
||||
%%
|
||||
%% This file is 8a only — pure state + skeleton gen_server with the
|
||||
%% APIs Step 8b-d will fill in. Real HTTP dispatch is stubbed via a
|
||||
%% caller-supplied `:dispatch_fn` so tests can intercept and Step 8f
|
||||
%% can plug in the live httpc call without touching the queue logic.
|
||||
%%
|
||||
%% State shape (pure):
|
||||
%% [{peer, PeerId},
|
||||
%% {pending, [Activity, ...]}, %% FIFO; head delivered first
|
||||
%% {attempts, [{Cid, AttemptCount}, ...]},
|
||||
%% {next_retry, [{Cid, NextRetryAt}, ...]}, %% Step 8b-pure
|
||||
%% {dead_letter, [Activity, ...]},
|
||||
%% {dispatch_fn, fun/1 | undefined}]
|
||||
%%
|
||||
%% gen_server registers under the peer-id atom (one worker per peer);
|
||||
%% the same APIs work as pure-functional state transitions for tests.
|
||||
|
||||
%% ── Pure-functional API ─────────────────────────────────────────
|
||||
|
||||
new(PeerId) ->
|
||||
[{peer, PeerId},
|
||||
{pending, []},
|
||||
{attempts, []},
|
||||
{next_retry, []},
|
||||
{dead_letter, []},
|
||||
{timers, []},
|
||||
{dispatch_fn, undefined}].
|
||||
|
||||
pending(State) -> field(pending, State).
|
||||
peer(State) -> field(peer, State).
|
||||
|
||||
%% enqueue_pure/3 — append an activity to the queue. Returns new
|
||||
%% state. Duplicate :id activities aren't deduplicated here — that's
|
||||
%% the caller's job (Step 8d will pass each delivery-set entry once).
|
||||
|
||||
enqueue_pure(_PeerId, Activity, State) ->
|
||||
Pending = field(pending, State),
|
||||
set_field(pending, Pending ++ [Activity], State).
|
||||
|
||||
%% drain_pure/1 — attempt to deliver every queued activity through
|
||||
%% the configured dispatch_fn. Returns {NewState, DeliveredCids,
|
||||
%% RetryCids}. Activities that fail dispatch stay in :pending with
|
||||
%% an incremented attempt counter — Step 8b will use the count to
|
||||
%% pick a backoff slot.
|
||||
|
||||
drain_pure(State) ->
|
||||
Pending = field(pending, State),
|
||||
drain_loop(Pending, [], State, [], []).
|
||||
|
||||
drain_loop([], Kept, State, Delivered, Retry) ->
|
||||
{set_field(pending, Kept, State), Delivered, Retry};
|
||||
drain_loop([A | Rest], Kept, State, Delivered, Retry) ->
|
||||
case deliver_one_pure(A, State) of
|
||||
{ok, Cid} ->
|
||||
drain_loop(Rest, Kept, State, Delivered ++ [Cid], Retry);
|
||||
{error, Cid, _Reason} ->
|
||||
State1 = bump_attempt(Cid, State),
|
||||
drain_loop(Rest, Kept ++ [A], State1, Delivered, Retry ++ [Cid])
|
||||
end.
|
||||
|
||||
%% deliver_one_pure/2 — single-activity dispatch via the caller-
|
||||
%% supplied dispatch_fn. Returns {ok, Cid} on success or {error,
|
||||
%% Cid, Reason} on failure. With no dispatch_fn configured returns
|
||||
%% {error, _, no_dispatch_fn} so callers know to wire one before
|
||||
%% the worker is useful.
|
||||
|
||||
deliver_one_pure(Activity, State) ->
|
||||
Cid = activity_cid(Activity),
|
||||
case field(dispatch_fn, State) of
|
||||
undefined -> {error, Cid, no_dispatch_fn};
|
||||
Fn when is_function(Fn, 1) ->
|
||||
case Fn(Activity) of
|
||||
ok -> {ok, Cid};
|
||||
{ok, _} -> {ok, Cid};
|
||||
{error, Reason} -> {error, Cid, Reason};
|
||||
Other -> {error, Cid, {bad_dispatch_return, Other}}
|
||||
end;
|
||||
_ -> {error, Cid, bad_dispatch_fn}
|
||||
end.
|
||||
|
||||
%% backoff_for/1 — Step 8a returns the static schedule per the
|
||||
%% plan; Step 8b wires it into the retry loop. Attempts are
|
||||
%% 1-indexed (first retry uses slot 1).
|
||||
%%
|
||||
%% 30s / 5m / 30m / 6h / 24h then dead_letter.
|
||||
|
||||
backoff_for(0) -> 0;
|
||||
backoff_for(1) -> 30;
|
||||
backoff_for(2) -> 300; % 5 * 60
|
||||
backoff_for(3) -> 1800; % 30 * 60
|
||||
backoff_for(4) -> 21600; % 6 * 3600
|
||||
backoff_for(5) -> 86400; % 24 * 3600
|
||||
backoff_for(_) -> dead_letter.
|
||||
|
||||
schedule_for(Attempts) ->
|
||||
case backoff_for(Attempts) of
|
||||
dead_letter -> dead_letter;
|
||||
Seconds -> {retry_in, Seconds}
|
||||
end.
|
||||
|
||||
%% ── Step 8b-pure: retry-time bookkeeping ───────────────────────
|
||||
%%
|
||||
%% `record_failure_pure/3(Cid, Now, State)` — call after a failed
|
||||
%% deliver_one. Bumps the per-cid attempt counter; if the new
|
||||
%% attempt is past the dead-letter threshold, moves the matching
|
||||
%% activity from :pending to :dead_letter. Otherwise records the
|
||||
%% next retry time as Now + backoff_for(NewAttempt).
|
||||
%%
|
||||
%% Real timer wiring (erlang:send_after self-cast on the worker
|
||||
%% pid) needs substrate support — Step 8b-timer when that lands.
|
||||
%%
|
||||
%% `record_success_pure/2(Cid, State)` — clears :attempts and
|
||||
%% :next_retry entries for the cid; called after a successful
|
||||
%% deliver_one.
|
||||
%%
|
||||
%% `next_due_pure/2(Now, State)` — returns the list of Cids whose
|
||||
%% NextRetryAt has passed, in insertion order.
|
||||
|
||||
record_failure_pure(Cid, Now, State) ->
|
||||
Attempts = field(attempts, State),
|
||||
Current = case find_keyed(Cid, Attempts) of
|
||||
{ok, N} -> N;
|
||||
_ -> 0
|
||||
end,
|
||||
New = Current + 1,
|
||||
State1 = set_field(attempts, set_keyed(Cid, New, Attempts), State),
|
||||
case backoff_for(New) of
|
||||
dead_letter ->
|
||||
move_to_dead_letter(Cid, State1);
|
||||
Seconds ->
|
||||
NextAt = Now + Seconds,
|
||||
NR = field(next_retry, State1),
|
||||
set_field(next_retry, set_keyed(Cid, NextAt, NR), State1)
|
||||
end.
|
||||
|
||||
record_success_pure(Cid, State) ->
|
||||
A1 = del_keyed(Cid, field(attempts, State)),
|
||||
NR1 = del_keyed(Cid, field(next_retry, State)),
|
||||
set_field(attempts, A1, set_field(next_retry, NR1, State)).
|
||||
|
||||
%% next_due_pure/2 — Cids whose NextRetryAt <= Now. Preserves
|
||||
%% insertion order so the worker drains them in FIFO retry order.
|
||||
|
||||
next_due_pure(Now, State) ->
|
||||
[Cid || {Cid, At} <- field(next_retry, State), At =< Now].
|
||||
|
||||
attempts_for(Cid, State) ->
|
||||
case find_keyed(Cid, field(attempts, State)) of
|
||||
{ok, N} -> N;
|
||||
_ -> 0
|
||||
end.
|
||||
|
||||
next_retry_at(Cid, State) ->
|
||||
case find_keyed(Cid, field(next_retry, State)) of
|
||||
{ok, At} -> At;
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
dead_letter_list(State) -> field(dead_letter, State).
|
||||
|
||||
%% Step 8b-timer: per-cid timer ref accessor. Exposed for tests so
|
||||
%% they can assert a retry timer was scheduled (or wasn't, after a
|
||||
%% success / dead-letter). Returns the live Ref or undefined.
|
||||
|
||||
timer_ref_for(Cid, State) ->
|
||||
case find_keyed(Cid, field(timers, State)) of
|
||||
{ok, Ref} -> Ref;
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
move_to_dead_letter(Cid, State) ->
|
||||
Pending = field(pending, State),
|
||||
{Match, Rest} = take_by_cid(Cid, Pending, [], []),
|
||||
DL = field(dead_letter, State),
|
||||
State1 = set_field(pending, Rest, State),
|
||||
State2 = case Match of
|
||||
none -> State1;
|
||||
Act -> set_field(dead_letter, DL ++ [Act], State1)
|
||||
end,
|
||||
NR = field(next_retry, State2),
|
||||
set_field(next_retry, del_keyed(Cid, NR), State2).
|
||||
|
||||
take_by_cid(_, [], Acc, _) -> {none, lists:reverse(Acc)};
|
||||
take_by_cid(Cid, [A | Rest], Acc, _) ->
|
||||
case activity_cid(A) of
|
||||
Cid -> {A, lists:reverse(Acc) ++ Rest};
|
||||
_ -> take_by_cid(Cid, Rest, [A | Acc], 0)
|
||||
end.
|
||||
|
||||
%% ── gen_server wrapper ──────────────────────────────────────────
|
||||
|
||||
start_link(PeerId) ->
|
||||
start_link(PeerId, undefined).
|
||||
|
||||
start_link(PeerId, DispatchFn) ->
|
||||
Pid = gen_server:start_link(delivery_worker, [PeerId, DispatchFn]),
|
||||
erlang:register(PeerId, Pid),
|
||||
Pid.
|
||||
|
||||
stop(PeerId) ->
|
||||
R = gen_server:call(PeerId, '$gen_stop'),
|
||||
erlang:unregister(PeerId),
|
||||
R.
|
||||
|
||||
enqueue(PeerId, Activity) ->
|
||||
gen_server:call(PeerId, {enqueue, Activity}).
|
||||
|
||||
flush(PeerId) ->
|
||||
gen_server:call(PeerId, flush).
|
||||
|
||||
pending_srv(PeerId) ->
|
||||
gen_server:call(PeerId, get_pending).
|
||||
|
||||
set_dispatch_fn(PeerId, Fn) ->
|
||||
gen_server:call(PeerId, {set_dispatch_fn, Fn}).
|
||||
|
||||
%% Step 8b-timer: return the worker's full state so tests can use the
|
||||
%% pure introspection functions (attempts_for / next_retry_at /
|
||||
%% timer_ref_for / dead_letter_list) against it.
|
||||
|
||||
state_srv(PeerId) ->
|
||||
gen_server:call(PeerId, get_state).
|
||||
|
||||
%% gen_server callbacks
|
||||
|
||||
init([PeerId, DispatchFn]) ->
|
||||
S0 = new(PeerId),
|
||||
{ok, set_field(dispatch_fn, DispatchFn, S0)}.
|
||||
|
||||
handle_call({enqueue, Activity}, _From, State) ->
|
||||
{reply, ok, enqueue_pure(field(peer, State), Activity, State)};
|
||||
handle_call(flush, _From, State) ->
|
||||
%% Step 8b-timer: drain (which already bumps :attempts via
|
||||
%% bump_attempt on each failed deliver), then for each retried
|
||||
%% Cid compute the backoff slot from the now-current attempt
|
||||
%% count, set NextRetryAt, and arm a send_after self-cast.
|
||||
%% handle_info({retry, Cid}, ...) fires when the slot elapses.
|
||||
%% Reply shape unchanged.
|
||||
{DrainState, Delivered, Retry} = drain_pure(State),
|
||||
Now = monotonic_seconds(),
|
||||
NewState = lists:foldl(
|
||||
fun(Cid, S) -> arm_retry_timer(Cid, Now, S) end,
|
||||
DrainState, Retry),
|
||||
{reply, {ok, Delivered, Retry}, NewState};
|
||||
handle_call(get_pending, _From, State) ->
|
||||
{reply, field(pending, State), State};
|
||||
handle_call(get_state, _From, State) ->
|
||||
{reply, State, State};
|
||||
handle_call({set_dispatch_fn, Fn}, _From, State) ->
|
||||
{reply, ok, set_field(dispatch_fn, Fn, State)}.
|
||||
|
||||
handle_cast(_, S) -> {noreply, S}.
|
||||
|
||||
%% Step 8b-timer: a retry timer fired. Pull the activity by Cid from
|
||||
%% the pending queue (it might have been drained meanwhile by a
|
||||
%% concurrent flush — if so, we just clear bookkeeping and exit).
|
||||
%% Run deliver_one_pure: success clears retry state; failure bumps
|
||||
%% the counter and schedules the next slot — or dead-letters if the
|
||||
%% sixth attempt failed.
|
||||
|
||||
handle_info({retry, Cid}, State) ->
|
||||
%% Clear the timer ref we just consumed.
|
||||
State0 = clear_timer_ref(Cid, State),
|
||||
case take_by_cid(Cid, field(pending, State0), [], 0) of
|
||||
{none, _} ->
|
||||
%% Already drained / dead-lettered. Clear any stale
|
||||
%% bookkeeping in case the cid is half-tracked.
|
||||
{noreply, record_success_pure(Cid, State0)};
|
||||
{Activity, Rest} ->
|
||||
case deliver_one_pure(Activity, State0) of
|
||||
{ok, _} ->
|
||||
State1 = set_field(pending, Rest, State0),
|
||||
State2 = record_success_pure(Cid, State1),
|
||||
{noreply, State2};
|
||||
{error, _, _} ->
|
||||
%% Keep the activity in pending; record_failure
|
||||
%% leaves :pending alone (or dead-letters it on
|
||||
%% slot 6).
|
||||
Now = monotonic_seconds(),
|
||||
State1 = schedule_retry_for(Cid, Now, State0),
|
||||
{noreply, State1}
|
||||
end
|
||||
end;
|
||||
handle_info(_, S) -> {noreply, S}.
|
||||
|
||||
%% Step 8b-timer helpers ────────────────────────────────────────────
|
||||
|
||||
%% arm_retry_timer/3 — POST-DRAIN form. Used from handle_call(flush)
|
||||
%% after drain_pure has already bumped :attempts via bump_attempt.
|
||||
%% Sets next_retry_at = Now + backoff(attempts) and schedules the
|
||||
%% send_after self-cast. On the dead-letter slot (attempt 6), moves
|
||||
%% the activity from :pending to :dead_letter and arms no timer.
|
||||
|
||||
arm_retry_timer(Cid, Now, State) ->
|
||||
State0 = cancel_timer_for(Cid, State),
|
||||
Attempts = attempts_for(Cid, State0),
|
||||
case backoff_for(Attempts) of
|
||||
dead_letter ->
|
||||
move_to_dead_letter(Cid, State0);
|
||||
Seconds ->
|
||||
NextAt = Now + Seconds,
|
||||
NR = field(next_retry, State0),
|
||||
State1 = set_field(next_retry, set_keyed(Cid, NextAt, NR), State0),
|
||||
Ms = Seconds * 1000,
|
||||
Ref = erlang:send_after(Ms, self(), {retry, Cid}),
|
||||
Timers = field(timers, State1),
|
||||
set_field(timers, set_keyed(Cid, Ref, Timers), State1)
|
||||
end.
|
||||
|
||||
%% schedule_retry_for/3 — POST-RETRY-ATTEMPT form. Used from
|
||||
%% handle_info({retry, Cid}, ...) when the retry attempt failed.
|
||||
%% Bookkeep one failure and arm the next retry timer (or promote
|
||||
%% to dead-letter, in which case no timer is needed).
|
||||
|
||||
schedule_retry_for(Cid, Now, State) ->
|
||||
%% Cancel any in-flight timer for this Cid before scheduling a new
|
||||
%% one. Without the cancel a stale timer can still fire after
|
||||
%% record_success has cleared the cid, the handle_info no-match
|
||||
%% branch silently absorbs it — but it keeps the scheduler's
|
||||
%% run-loop alive long after the work is done. A pure clear (no
|
||||
%% cancel) is fine when the timer's own firing brought us here,
|
||||
%% so the explicit cancel only matters for the flush path.
|
||||
State0 = cancel_timer_for(Cid, State),
|
||||
State1 = record_failure_pure(Cid, Now, State0),
|
||||
Attempts = attempts_for(Cid, State1),
|
||||
case backoff_for(Attempts) of
|
||||
dead_letter ->
|
||||
State1;
|
||||
Seconds ->
|
||||
Ms = Seconds * 1000,
|
||||
Ref = erlang:send_after(Ms, self(), {retry, Cid}),
|
||||
Timers = field(timers, State1),
|
||||
set_field(timers, set_keyed(Cid, Ref, Timers), State1)
|
||||
end.
|
||||
|
||||
%% Cancel the live timer for Cid (if any) and clear it from :timers.
|
||||
%% Idempotent — silent no-op if there isn't one.
|
||||
|
||||
cancel_timer_for(Cid, State) ->
|
||||
Timers = field(timers, State),
|
||||
case find_keyed(Cid, Timers) of
|
||||
{ok, Ref} ->
|
||||
erlang:cancel_timer(Ref),
|
||||
set_field(timers, del_keyed(Cid, Timers), State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% Drop the :timers entry for Cid without calling cancel_timer — used
|
||||
%% when the timer's own firing brought us into handle_info and the ref
|
||||
%% is already consumed.
|
||||
|
||||
clear_timer_ref(Cid, State) ->
|
||||
Timers = field(timers, State),
|
||||
case find_keyed(Cid, Timers) of
|
||||
{ok, _Ref} -> set_field(timers, del_keyed(Cid, Timers), State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% Step 8b-timer: bookkeeping uses seconds (matches backoff_for /
|
||||
%% record_failure_pure / next_retry_at). The monotonic clock reports
|
||||
%% ms; we floor to seconds here to keep all the comparisons aligned.
|
||||
|
||||
monotonic_seconds() -> erlang:monotonic_time() div 1000.
|
||||
|
||||
%% ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
activity_cid(Activity) ->
|
||||
case envelope:get_field(id, Activity) of
|
||||
{ok, Cid} -> Cid;
|
||||
_ -> nil
|
||||
end.
|
||||
|
||||
bump_attempt(Cid, State) ->
|
||||
Attempts = field(attempts, State),
|
||||
Current = case find_keyed(Cid, Attempts) of
|
||||
{ok, N} -> N;
|
||||
_ -> 0
|
||||
end,
|
||||
set_field(attempts, set_keyed(Cid, Current + 1, Attempts), State).
|
||||
|
||||
field(K, [{K, V} | _]) -> V;
|
||||
field(K, [_ | Rest]) -> field(K, Rest);
|
||||
field(_, []) -> undefined.
|
||||
|
||||
set_field(K, V, []) -> [{K, V}];
|
||||
set_field(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_field(K, V, [P | Rest]) -> [P | set_field(K, V, Rest)].
|
||||
|
||||
find_keyed(_, []) -> {error, 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)].
|
||||
|
||||
del_keyed(_, []) -> [];
|
||||
del_keyed(K, [{K, _} | Rest]) -> Rest;
|
||||
del_keyed(K, [P | Rest]) -> [P | del_keyed(K, Rest)].
|
||||
98
next/kernel/discovery.erl
Normal file
98
next/kernel/discovery.erl
Normal file
@@ -0,0 +1,98 @@
|
||||
-module(discovery).
|
||||
-export([parse_acct/1, parse_resource/1,
|
||||
actor_url_for/2, webfinger_body/3]).
|
||||
|
||||
%% Discovery primitives per design §13.7. Step 10a covers the
|
||||
%% local-side webfinger endpoint (responding when a peer asks
|
||||
%% "where does acct:alice@here live?"); the peer-fetch direction
|
||||
%% (loading a peer's actor doc lazily on first inbound) is Step 10b
|
||||
%% and gates on Blockers #2 (native http-request primitive).
|
||||
%%
|
||||
%% parse_acct/1 — accept a binary in either form:
|
||||
%% <<"acct:alice@host:port">> (full prefixed URI)
|
||||
%% <<"alice@host:port">> (bare account, prefix optional)
|
||||
%% Returns {ok, User, Host} | {error, Reason}.
|
||||
%%
|
||||
%% parse_resource/1 — the resource= query parameter from
|
||||
%% /.well-known/webfinger. Same shape as parse_acct.
|
||||
%%
|
||||
%% actor_url_for/2(User, Host) — synthesises the canonical
|
||||
%% per-actor URL `<scheme>://<host>/actors/<user>`. v2 hardcodes
|
||||
%% http://; TLS / https is v3 (Blockers gate).
|
||||
%%
|
||||
%% webfinger_body/3 — builds the JSON response body.
|
||||
|
||||
%% ── parse_acct / parse_resource ─────────────────────────────────
|
||||
|
||||
%% "acct:" -> 5 bytes: 97 99 99 116 58
|
||||
parse_acct(Bin) when is_binary(Bin) ->
|
||||
AcctPrefix = <<97,99,99,116,58>>,
|
||||
case strip_prefix(AcctPrefix, Bin) of
|
||||
{ok, Rest} -> split_user_host(Rest);
|
||||
nomatch -> split_user_host(Bin)
|
||||
end;
|
||||
parse_acct(_) -> {error, bad_input}.
|
||||
|
||||
parse_resource(Bin) -> parse_acct(Bin).
|
||||
|
||||
%% strip_prefix/2 — return {ok, Rest} when Bin starts with Prefix,
|
||||
%% else nomatch. Substrate has no proper prefix-match BIF; this
|
||||
%% byte-walks.
|
||||
|
||||
strip_prefix(<<>>, Rest) -> {ok, Rest};
|
||||
strip_prefix(<<B, PRest/binary>>, <<B, RRest/binary>>) ->
|
||||
strip_prefix(PRest, RRest);
|
||||
strip_prefix(_, _) -> nomatch.
|
||||
|
||||
%% split_user_host/1 — split a `user@host[:port]` binary at the
|
||||
%% first `@`. Returns {ok, User, Host} where Host may include the
|
||||
%% optional port suffix.
|
||||
|
||||
split_user_host(Bin) ->
|
||||
case split_at(64, Bin) of % 64 = '@'
|
||||
{Before, After} when byte_size(Before) > 0, byte_size(After) > 0 ->
|
||||
{ok, Before, After};
|
||||
_ ->
|
||||
{error, bad_acct}
|
||||
end.
|
||||
|
||||
split_at(Byte, Bin) ->
|
||||
split_at(Byte, Bin, <<>>).
|
||||
|
||||
split_at(_, <<>>, Acc) ->
|
||||
{Acc, <<>>};
|
||||
split_at(Byte, <<Byte, Rest/binary>>, Acc) ->
|
||||
{Acc, Rest};
|
||||
split_at(Byte, <<B, Rest/binary>>, Acc) ->
|
||||
split_at(Byte, Rest, <<Acc/binary, B>>).
|
||||
|
||||
%% ── URL synthesis ──────────────────────────────────────────────
|
||||
|
||||
%% "http://" -> 7 bytes | "/actors/" -> 8 bytes
|
||||
actor_url_for(User, Host) ->
|
||||
Pre = <<104,116,116,112,58,47,47>>, % "http://"
|
||||
Mid = <<47,97,99,116,111,114,115,47>>, % "/actors/"
|
||||
<<Pre/binary, Host/binary, Mid/binary, User/binary>>.
|
||||
|
||||
%% ── webfinger JSON body ────────────────────────────────────────
|
||||
%%
|
||||
%% Mastodon-shape per RFC 7033:
|
||||
%% {"subject":"acct:<user>@<host>",
|
||||
%% "links":[{"rel":"self",
|
||||
%% "type":"application/activity+json",
|
||||
%% "href":"<actor_url>"}]}
|
||||
%%
|
||||
%% Hand-rolled byte concatenation — no JSON BIF on this port. The
|
||||
%% caller has already validated User + Host; we don't need to
|
||||
%% re-escape (Mastodon's webfinger inputs are alphanumeric +
|
||||
%% .-_ in practice).
|
||||
|
||||
webfinger_body(User, Host, ActorUrl) ->
|
||||
AcctPre = <<123,34,115,117,98,106,101,99,116,34,58,34,97,99,99,116,58>>, % '{"subject":"acct:'
|
||||
AcctAt = <<64>>, % '@'
|
||||
LinksHd = <<34,44,34,108,105,110,107,115,34,58,91,123,34,114,101,108,34,58,34,115,101,108,102,34,44,
|
||||
34,116,121,112,101,34,58,34,97,112,112,108,105,99,97,116,105,111,110,47,97,99,116,
|
||||
105,118,105,116,121,43,106,115,111,110,34,44,34,104,114,101,102,34,58,34>>, % '","links":[{"rel":"self","type":"application/activity+json","href":"'
|
||||
LinksTl = <<34,125,93,125,10>>, % '"}]}\n'
|
||||
<<AcctPre/binary, User/binary, AcctAt/binary, Host/binary,
|
||||
LinksHd/binary, ActorUrl/binary, LinksTl/binary>>.
|
||||
89
next/kernel/discovery_fetch.erl
Normal file
89
next/kernel/discovery_fetch.erl
Normal file
@@ -0,0 +1,89 @@
|
||||
-module(discovery_fetch).
|
||||
-export([make_fetch_fn/1,
|
||||
fetch/2,
|
||||
actor_doc_url/2,
|
||||
decode_body/1,
|
||||
accept_header/0]).
|
||||
|
||||
%% Live peer-actor-doc fetch for peer_actors — Step 10c per design
|
||||
%% §13.6. The peer_actors gen_server already exposes
|
||||
%% lookup_or_fetch_srv/2(PeerId, FetchFn) where FetchFn is a
|
||||
%% 1-arity closure that returns {ok, PeerAS} | {error, Reason} on
|
||||
%% cache miss. For tests we wire a fake FetchFn that returns a
|
||||
%% pre-baked AS; for live federation we wire the closure this
|
||||
%% module produces — it GETs <base>/actors/<peer> with an Accept
|
||||
%% header that asks for the actor_doc format
|
||||
%% (http_server.erl Step 10c), decodes the response body via
|
||||
%% term_codec, and returns the AS proplist.
|
||||
%%
|
||||
%% Cfg shape (reuses dispatch_http's peer URL resolution so a
|
||||
%% single Cfg threads through both delivery and discovery):
|
||||
%% {peer_url, [{PeerId, BaseUrl}, ...]}
|
||||
%% {peer_url_fn, fun ((PeerId) -> {ok, BaseUrl} | not_found)}
|
||||
%%
|
||||
%% BaseUrl shape: <<"http://host:port">> (no trailing slash; this
|
||||
%% module appends the path). PeerId is the actor atom.
|
||||
%%
|
||||
%% Outcomes:
|
||||
%% 2xx + decodable body -> {ok, PeerAS}
|
||||
%% 2xx + bad body -> {error, bad_actor_doc}
|
||||
%% non-2xx -> {error, {status, N}}
|
||||
%% resolver miss -> {error, no_peer_url}
|
||||
%% transport -> {error, Reason}
|
||||
%%
|
||||
%% Cache write semantics live in peer_actors:lookup_or_fetch/3 —
|
||||
%% successful fetches store; errors do NOT poison so callers can
|
||||
%% retry on transients.
|
||||
|
||||
%% ── Accept header ────────────────────────────────────────────
|
||||
%% "application/vnd.fed-sx.actor-doc" — same MIME the http_server
|
||||
%% content_type_for(actor_doc) emits, so the Accept negotiation
|
||||
%% in accept_format/1 routes the peer's response to the term_codec
|
||||
%% serializer arm.
|
||||
accept_header() ->
|
||||
<<97,112,112,108,105,99,97,116,105,111,110,47,
|
||||
118,110,100,46,102,101,100,45,115,120,46,
|
||||
97,99,116,111,114,45,100,111,99>>.
|
||||
|
||||
%% ── public API ───────────────────────────────────────────────
|
||||
|
||||
make_fetch_fn(Cfg) ->
|
||||
fun (PeerId) ->
|
||||
case dispatch_http:resolve_peer_url(PeerId, Cfg) of
|
||||
{error, R} -> {error, R};
|
||||
{ok, BaseUrl} -> fetch(actor_doc_url(BaseUrl, PeerId), Cfg)
|
||||
end
|
||||
end.
|
||||
|
||||
fetch(Url, _Cfg) ->
|
||||
AcceptKey = <<97,99,99,101,112,116>>, % "accept"
|
||||
Headers = [{AcceptKey, accept_header()}],
|
||||
try httpc:request(Url, get, Headers, <<>>) of
|
||||
{ok, Status, _H, Body} when Status >= 200, Status < 300 ->
|
||||
decode_body(Body);
|
||||
{ok, Status, _H, _B} ->
|
||||
{error, {status, Status}};
|
||||
Other ->
|
||||
{error, {bad_response, Other}}
|
||||
catch
|
||||
error:Reason -> {error, Reason}
|
||||
end.
|
||||
|
||||
%% actor_doc_url/2 — <BaseUrl>/actors/<peer>. PeerId is the actor
|
||||
%% atom; rendered to a binary via its name (matches the same path
|
||||
%% layout http_server.erl uses for the route registration at
|
||||
%% prefix "/actors/").
|
||||
actor_doc_url(BaseUrl, PeerId) when is_atom(PeerId) ->
|
||||
PeerBin = list_to_binary(atom_to_list(PeerId)),
|
||||
%% "/actors/" — 8 bytes
|
||||
Prefix = <<47,97,99,116,111,114,115,47>>,
|
||||
<<BaseUrl/binary, Prefix/binary, PeerBin/binary>>.
|
||||
|
||||
%% decode_body/1 — round the wire body back through term_codec.
|
||||
%% Returns {ok, AS} on a proplist-shaped decode (matching the
|
||||
%% peer-actor-state schema), {error, bad_actor_doc} otherwise.
|
||||
decode_body(Body) ->
|
||||
case term_codec:decode(Body) of
|
||||
{ok, AS, _} when is_list(AS) -> {ok, AS};
|
||||
_ -> {error, bad_actor_doc}
|
||||
end.
|
||||
118
next/kernel/discovery_type_fetch.erl
Normal file
118
next/kernel/discovery_type_fetch.erl
Normal file
@@ -0,0 +1,118 @@
|
||||
-module(discovery_type_fetch).
|
||||
-export([make_fetch_fn/0, make_fetch_fn/1,
|
||||
fetch/2,
|
||||
type_doc_url/2,
|
||||
resolve_type_url/2,
|
||||
accept_header/0]).
|
||||
|
||||
%% Live type-doc fetch for peer_types — host-type federation Step 3,
|
||||
%% the sibling of discovery_fetch.erl. peer_types:lookup_or_fetch/3
|
||||
%% calls a Cfg-supplied type_fetch_fn :: fun ((TypeCid, Cfg) -> {ok,
|
||||
%% Bytes} | {error, _}) on a cache miss; this module produces that
|
||||
%% closure for live federation. It GETs <base>/types/<cid> with an
|
||||
%% Accept header that asks for the type-doc format (http_server.erl
|
||||
%% Step 3) and returns the RAW response bytes — peer_types decodes
|
||||
%% them via term_codec into the TypeRecord. (This is the one shape
|
||||
%% difference from discovery_fetch, whose closure returns an already-
|
||||
%% decoded actor-state: there the cache stores the decoded AS, here
|
||||
%% peer_types owns the decode so the type-doc wire format lives in one
|
||||
%% place — the /types/ route encodes, peer_types decodes.)
|
||||
%%
|
||||
%% Cfg shape (parallels discovery_fetch's peer URL resolution):
|
||||
%% {type_url, [{TypeCid, BaseUrl}, ...]}
|
||||
%% {type_url_fn, fun ((TypeCid) -> {ok, BaseUrl} | not_found)}
|
||||
%%
|
||||
%% BaseUrl shape: <<"http://host:port">> (no trailing slash; this
|
||||
%% module appends the path). TypeCid is the type's CID bytes.
|
||||
%%
|
||||
%% Outcomes:
|
||||
%% 2xx -> {ok, Bytes}
|
||||
%% non-2xx -> {error, {status, N}}
|
||||
%% resolver miss -> {error, no_type_url}
|
||||
%% transport -> {error, Reason}
|
||||
|
||||
%% ── Accept header ────────────────────────────────────────────
|
||||
%% "application/vnd.fed-sx.type-doc" — same MIME http_server's
|
||||
%% content_type_for(type_doc) emits, so the Accept negotiation routes
|
||||
%% the served bytes to the term_codec-encoded TypeRecord arm.
|
||||
accept_header() ->
|
||||
<<97,112,112,108,105,99,97,116,105,111,110,47,
|
||||
118,110,100,46,102,101,100,45,115,120,46,
|
||||
116,121,112,101,45,100,111,99>>.
|
||||
|
||||
%% ── public API ───────────────────────────────────────────────
|
||||
|
||||
%% make_fetch_fn/0 — the fun/2 peer_types:lookup_or_fetch calls. It
|
||||
%% reads the type-URL resolver out of the Cfg passed at call time, so
|
||||
%% the same Cfg threads through peer_types and this closure.
|
||||
make_fetch_fn() ->
|
||||
fun (TypeCid, Cfg) ->
|
||||
case resolve_type_url(TypeCid, Cfg) of
|
||||
{error, R} -> {error, R};
|
||||
{ok, BaseUrl} -> fetch(type_doc_url(BaseUrl, TypeCid), Cfg)
|
||||
end
|
||||
end.
|
||||
|
||||
%% make_fetch_fn/1 — variant that closes over a static Cfg for the
|
||||
%% resolver while still honouring the call-time Cfg for transport.
|
||||
%% Lets a caller bake the type_url map once and reuse the closure.
|
||||
make_fetch_fn(StaticCfg) ->
|
||||
fun (TypeCid, Cfg) ->
|
||||
case resolve_type_url(TypeCid, StaticCfg) of
|
||||
{error, R} -> {error, R};
|
||||
{ok, BaseUrl} -> fetch(type_doc_url(BaseUrl, TypeCid), Cfg)
|
||||
end
|
||||
end.
|
||||
|
||||
fetch(Url, _Cfg) ->
|
||||
AcceptKey = <<97,99,99,101,112,116>>, % "accept"
|
||||
Headers = [{AcceptKey, accept_header()}],
|
||||
try httpc:request(Url, get, Headers, <<>>) of
|
||||
{ok, Status, _H, Body} when Status >= 200, Status < 300 ->
|
||||
{ok, Body};
|
||||
{ok, Status, _H, _B} ->
|
||||
{error, {status, Status}};
|
||||
Other ->
|
||||
{error, {bad_response, Other}}
|
||||
catch
|
||||
error:Reason -> {error, Reason}
|
||||
end.
|
||||
|
||||
%% type_doc_url/2 — <BaseUrl>/types/<cid>. TypeCid is the cid bytes,
|
||||
%% appended verbatim as the path segment (matches the "/types/" prefix
|
||||
%% http_server.erl registers).
|
||||
type_doc_url(BaseUrl, TypeCid) when is_binary(TypeCid) ->
|
||||
%% "/types/" — 7 bytes
|
||||
Prefix = <<47,116,121,112,101,115,47>>,
|
||||
<<BaseUrl/binary, Prefix/binary, TypeCid/binary>>.
|
||||
|
||||
%% resolve_type_url/2 — map a TypeCid to its serving node's base URL.
|
||||
%% type_url_fn (a 1-arity closure) takes precedence over the static
|
||||
%% type_url proplist; absent both -> {error, no_type_url}.
|
||||
resolve_type_url(TypeCid, Cfg) ->
|
||||
case field(type_url_fn, Cfg) of
|
||||
Fn when is_function(Fn, 1) ->
|
||||
case Fn(TypeCid) of
|
||||
{ok, BaseUrl} -> {ok, BaseUrl};
|
||||
_ -> {error, no_type_url}
|
||||
end;
|
||||
_ ->
|
||||
case field(type_url, Cfg) of
|
||||
nil -> {error, no_type_url};
|
||||
Map ->
|
||||
case find_keyed(TypeCid, Map) of
|
||||
{ok, BaseUrl} -> {ok, BaseUrl};
|
||||
_ -> {error, no_type_url}
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
%% ── helpers ──────────────────────────────────────────────────
|
||||
|
||||
field(K, [{K, V} | _]) -> V;
|
||||
field(K, [_ | Rest]) -> field(K, Rest);
|
||||
field(_, []) -> nil.
|
||||
|
||||
find_keyed(_, []) -> {error, not_found};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
119
next/kernel/dispatch_http.erl
Normal file
119
next/kernel/dispatch_http.erl
Normal file
@@ -0,0 +1,119 @@
|
||||
-module(dispatch_http).
|
||||
-export([make_dispatch_fn/2,
|
||||
dispatch/3,
|
||||
inbox_url/2,
|
||||
resolve_peer_url/2,
|
||||
content_type/0]).
|
||||
|
||||
%% Live HTTP dispatch for delivery_worker — Step 8f per design §13.4.
|
||||
%%
|
||||
%% delivery_worker takes an opaque `dispatch_fn :: fun(Activity) ->
|
||||
%% ok | {ok, _} | {error, Reason}`. For tests we wire a fake one
|
||||
%% that records calls; for live federation we wire the closure this
|
||||
%% module produces — a 1-arity fun that encodes the activity with
|
||||
%% term_codec, looks up the peer's URL base, and POSTs to
|
||||
%% `<base>/actors/<peer>/inbox` via httpc:request/4 (the BIF
|
||||
%% wrapper Step 8e landed in lib/erlang/runtime.sx around the
|
||||
%% native http-request primitive from fed-prims).
|
||||
%%
|
||||
%% Cfg shape (composable, priority order):
|
||||
%% {peer_url, [{PeerId, BaseUrl::binary}, ...]}
|
||||
%% Static map; tests + small static deployments. PeerId is
|
||||
%% the actor atom (alice / bob / ...).
|
||||
%% {peer_url_fn, fun((PeerId) -> {ok, BaseUrl} | not_found)}
|
||||
%% Dynamic lookup; used when peer_actors gen_server caches a
|
||||
%% discovery result (Step 10c will plumb this).
|
||||
%%
|
||||
%% BaseUrl is the scheme+host+port of the peer's HTTP server, e.g.
|
||||
%% <<"http://127.0.0.1:8123">>. The inbox URL is built by
|
||||
%% appending /actors/<peer>/inbox so callers don't have to know the
|
||||
%% wire path layout.
|
||||
%%
|
||||
%% Dispatch outcome:
|
||||
%% 2xx -> ok (delivery_worker drops the entry)
|
||||
%% non-2xx -> {error, {status, N}}
|
||||
%% resolver miss -> {error, no_peer_url}
|
||||
%% transport -> {error, Reason} (BIF-raised, caught here)
|
||||
|
||||
%% ── content-type ─────────────────────────────────────────────
|
||||
%% "application/vnd.fed-sx.activity" — picked to be distinct from
|
||||
%% the existing http_server content types (text/json/sx/cbor) since
|
||||
%% the wire bytes are term_codec's custom netstring-ish format, not
|
||||
%% any of them. The receiver's handle_inbox_post/3 in
|
||||
%% http_server.erl doesn't gate on content-type yet; it just hands
|
||||
%% the body to term_codec:decode. We still send a real MIME so
|
||||
%% intermediaries (proxies, load balancers, logs) see something
|
||||
%% honest. Substrate Note: M2 doesn't add a content_type_for/1
|
||||
%% clause to http_server because that's serving outbound responses
|
||||
%% (the dispatch direction is FROM us; the receiver shapes its
|
||||
%% own response).
|
||||
content_type() ->
|
||||
%% "application/vnd.fed-sx.activity"
|
||||
<<97,112,112,108,105,99,97,116,105,111,110,47,
|
||||
118,110,100,46,102,101,100,45,115,120,46,97,99,
|
||||
116,105,118,105,116,121>>.
|
||||
|
||||
%% ── public API ───────────────────────────────────────────────
|
||||
|
||||
make_dispatch_fn(PeerId, Cfg) ->
|
||||
fun (Activity) ->
|
||||
case resolve_peer_url(PeerId, Cfg) of
|
||||
{error, R} ->
|
||||
{error, R};
|
||||
{ok, BaseUrl} ->
|
||||
Url = inbox_url(BaseUrl, PeerId),
|
||||
dispatch(Url, Activity, Cfg)
|
||||
end
|
||||
end.
|
||||
|
||||
dispatch(Url, Activity, _Cfg) ->
|
||||
Body = term_codec:encode(Activity),
|
||||
Headers = [{<<99,111,110,116,101,110,116,45,116,121,112,101>>,
|
||||
content_type()}],
|
||||
%% This port's try/catch needs a literal class atom (not Class:R).
|
||||
%% The BIF wrapper raises error:{network, _} on transport failure
|
||||
%% and error:badarg on shape failure; both reach us as `error`.
|
||||
try httpc:request(Url, post, Headers, Body) of
|
||||
{ok, Status, _H, _B} when Status >= 200, Status < 300 -> ok;
|
||||
{ok, Status, _H, _B} -> {error, {status, Status}};
|
||||
Other -> {error, {bad_response, Other}}
|
||||
catch
|
||||
error:Reason -> {error, Reason}
|
||||
end.
|
||||
|
||||
%% inbox_url/2 — concatenate BaseUrl + "/actors/" + PeerId + "/inbox".
|
||||
%% PeerId is the actor atom; rendered to a binary via its name.
|
||||
inbox_url(BaseUrl, PeerId) when is_atom(PeerId) ->
|
||||
PeerBin = list_to_binary(atom_to_list(PeerId)),
|
||||
%% "/actors/" — 47,97,99,116,111,114,115,47
|
||||
Prefix = <<47,97,99,116,111,114,115,47>>,
|
||||
%% "/inbox" — 47,105,110,98,111,120
|
||||
Suffix = <<47,105,110,98,111,120>>,
|
||||
<<BaseUrl/binary, Prefix/binary, PeerBin/binary, Suffix/binary>>.
|
||||
|
||||
%% resolve_peer_url/2 — static :peer_url map first (tests), then
|
||||
%% :peer_url_fn closure (Step 10c will hand one in once peer_actors
|
||||
%% caches discovered URLs).
|
||||
resolve_peer_url(PeerId, Cfg) ->
|
||||
case envelope:get_field(peer_url, Cfg) of
|
||||
{ok, Map} when is_list(Map) ->
|
||||
case lookup_peer(PeerId, Map) of
|
||||
{ok, U} -> {ok, U};
|
||||
_ -> try_fn(PeerId, Cfg)
|
||||
end;
|
||||
_ -> try_fn(PeerId, Cfg)
|
||||
end.
|
||||
|
||||
try_fn(PeerId, Cfg) ->
|
||||
case envelope:get_field(peer_url_fn, Cfg) of
|
||||
{ok, Fn} when is_function(Fn, 1) ->
|
||||
case Fn(PeerId) of
|
||||
{ok, U} when is_binary(U) -> {ok, U};
|
||||
_ -> {error, no_peer_url}
|
||||
end;
|
||||
_ -> {error, no_peer_url}
|
||||
end.
|
||||
|
||||
lookup_peer(_PeerId, []) -> not_found;
|
||||
lookup_peer(PeerId, [{PeerId, Url} | _]) -> {ok, Url};
|
||||
lookup_peer(PeerId, [_ | Rest]) -> lookup_peer(PeerId, Rest).
|
||||
118
next/kernel/endorsement_state.erl
Normal file
118
next/kernel/endorsement_state.erl
Normal file
@@ -0,0 +1,118 @@
|
||||
-module(endorsement_state).
|
||||
-export([new/0, fold/2, fold_fn/0,
|
||||
counters_for/2, total_for/2, kinds_for/2,
|
||||
endorsers_for/3, has_endorsed/4]).
|
||||
|
||||
%% Endorsement counter projection. Folds Endorse activities into a
|
||||
%% per-target-Cid + per-kind counter so projections can serve
|
||||
%% "how many likes does this Note have" / "list everyone who shared
|
||||
%% this Announce" queries.
|
||||
%%
|
||||
%% Endorse envelope shape (per next/genesis/activity-types/endorse.sx):
|
||||
%% [{type, endorse},
|
||||
%% {actor, ActorId},
|
||||
%% {object, TargetCidBinary},
|
||||
%% {kind, KindAtomOrBinary},
|
||||
%% ...]
|
||||
%%
|
||||
%% State shape:
|
||||
%% [{TargetCid, [{Kind, [{ActorId, Count}, ...]}, ...]}, ...]
|
||||
%%
|
||||
%% Each ActorId can endorse the same target multiple times under
|
||||
%% the same kind (e.g. like → unlike → like → ...); the counter
|
||||
%% tracks how many *net* endorsement events fired. Step 11b ships
|
||||
%% the additive counter only; the unlike / un-endorse semantics
|
||||
%% (Undo{Endorse}) and reaction-toggling defer to a follow-up.
|
||||
|
||||
new() -> [].
|
||||
|
||||
fold_fn() ->
|
||||
fun (Activity, State) -> fold(Activity, State) end.
|
||||
|
||||
fold(Activity, State) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, endorse} -> fold_endorse(Activity, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_endorse(Activity, State) ->
|
||||
case {envelope:get_field(actor, Activity),
|
||||
envelope:get_field(object, Activity),
|
||||
envelope:get_field(kind, Activity)} of
|
||||
{{ok, Actor}, {ok, Cid}, {ok, Kind}} ->
|
||||
bump(Cid, Kind, Actor, State);
|
||||
_ ->
|
||||
State
|
||||
end.
|
||||
|
||||
bump(Cid, Kind, Actor, State) ->
|
||||
KindMap = case find_keyed(Cid, State) of
|
||||
{ok, KM} -> KM;
|
||||
_ -> []
|
||||
end,
|
||||
ActorMap = case find_keyed(Kind, KindMap) of
|
||||
{ok, AM} -> AM;
|
||||
_ -> []
|
||||
end,
|
||||
Current = case find_keyed(Actor, ActorMap) of
|
||||
{ok, N} -> N;
|
||||
_ -> 0
|
||||
end,
|
||||
ActorMap1 = set_keyed(Actor, Current + 1, ActorMap),
|
||||
KindMap1 = set_keyed(Kind, ActorMap1, KindMap),
|
||||
set_keyed(Cid, KindMap1, State).
|
||||
|
||||
%% ── Read-side accessors ───────────────────────────────────────
|
||||
|
||||
%% counters_for(Cid, State) -> [{Kind, TotalCount}, ...]
|
||||
%% Sum per-kind across all endorsers.
|
||||
|
||||
counters_for(Cid, State) ->
|
||||
case find_keyed(Cid, State) of
|
||||
{ok, KindMap} ->
|
||||
[{K, sum_counts(AM)} || {K, AM} <- KindMap];
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
total_for(Cid, State) ->
|
||||
lists:foldl(fun ({_, N}, Acc) -> N + Acc end, 0, counters_for(Cid, State)).
|
||||
|
||||
kinds_for(Cid, State) ->
|
||||
[K || {K, _} <- counters_for(Cid, State)].
|
||||
|
||||
endorsers_for(Cid, Kind, State) ->
|
||||
case find_keyed(Cid, State) of
|
||||
{ok, KindMap} ->
|
||||
case find_keyed(Kind, KindMap) of
|
||||
{ok, AM} -> [A || {A, _} <- AM];
|
||||
_ -> []
|
||||
end;
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
has_endorsed(Actor, Cid, Kind, State) ->
|
||||
case find_keyed(Cid, State) of
|
||||
{ok, KindMap} ->
|
||||
case find_keyed(Kind, KindMap) of
|
||||
{ok, AM} ->
|
||||
case find_keyed(Actor, AM) of
|
||||
{ok, N} -> N > 0;
|
||||
_ -> false
|
||||
end;
|
||||
_ -> false
|
||||
end;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
%% ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
sum_counts([]) -> 0;
|
||||
sum_counts([{_, N} | Rest]) -> N + sum_counts(Rest).
|
||||
|
||||
find_keyed(_, []) -> {error, 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)].
|
||||
76
next/kernel/flow_dispatch.erl
Normal file
76
next/kernel/flow_dispatch.erl
Normal file
@@ -0,0 +1,76 @@
|
||||
-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.
|
||||
237
next/kernel/follower_graph.erl
Normal file
237
next/kernel/follower_graph.erl
Normal file
@@ -0,0 +1,237 @@
|
||||
-module(follower_graph).
|
||||
-export([fold/2, fold_fn/0, new/0, lookup/2, actors/1,
|
||||
following/2, followers/2,
|
||||
pending_outbound/2, pending_inbound/2,
|
||||
is_following/3, has_follower/3,
|
||||
is_pending_outbound/3, is_pending_inbound/3]).
|
||||
|
||||
%% Follower-graph projection — Erlang-fun stand-in for the genesis
|
||||
%% `follower-graph.sx` body. Tracks per-actor follow relationships
|
||||
%% per design §13.2:
|
||||
%%
|
||||
%% Follow {actor: A, object: B} A asks to follow B
|
||||
%% Accept {actor: B, object: F} B accepts A's Follow F (= F.actor → F.object)
|
||||
%% Reject {actor: B, object: F} B rejects A's Follow F
|
||||
%% Undo {actor: A, object: F} A retracts F or unfollows
|
||||
%%
|
||||
%% Where F = Follow{A→B} is embedded as the activity's :object
|
||||
%% proplist for Accept / Reject / Undo.
|
||||
%%
|
||||
%% State shape:
|
||||
%% [{ActorId, ActorEntry}, ...]
|
||||
%%
|
||||
%% ActorEntry = [{following, [PeerId, ...]},
|
||||
%% {followers, [PeerId, ...]},
|
||||
%% {pending_outbound, [PeerId, ...]}, %% I asked, no answer yet
|
||||
%% {pending_inbound, [PeerId, ...]}] %% asked me, I haven't answered
|
||||
%%
|
||||
%% Sets keep insertion order; duplicates aren't added. lists:keyfind/
|
||||
%% keymember aren't in this substrate, so local find_keyed/has_keyed/
|
||||
%% set_keyed helpers (same convention as actor_state, define_registry,
|
||||
%% nx_kernel).
|
||||
|
||||
%% ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
new() -> [].
|
||||
|
||||
actors(State) -> [Id || {Id, _Entry} <- State].
|
||||
|
||||
lookup(ActorId, State) ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Entry} -> {ok, Entry};
|
||||
_ -> not_found
|
||||
end.
|
||||
|
||||
following(ActorId, State) -> entry_field(ActorId, following, State).
|
||||
followers(ActorId, State) -> entry_field(ActorId, followers, State).
|
||||
pending_outbound(ActorId, State) -> entry_field(ActorId, pending_outbound, State).
|
||||
pending_inbound(ActorId, State) -> entry_field(ActorId, pending_inbound, State).
|
||||
|
||||
is_following(ActorId, PeerId, State) ->
|
||||
contains(PeerId, following(ActorId, State)).
|
||||
|
||||
has_follower(ActorId, PeerId, State) ->
|
||||
contains(PeerId, followers(ActorId, State)).
|
||||
|
||||
is_pending_outbound(ActorId, PeerId, State) ->
|
||||
contains(PeerId, pending_outbound(ActorId, State)).
|
||||
|
||||
is_pending_inbound(ActorId, PeerId, State) ->
|
||||
contains(PeerId, pending_inbound(ActorId, State)).
|
||||
|
||||
%% ── Fold dispatch ───────────────────────────────────────────────
|
||||
|
||||
fold(Activity, State) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, follow} -> fold_follow(Activity, State);
|
||||
{ok, accept} -> fold_accept(Activity, State);
|
||||
{ok, reject} -> fold_reject(Activity, State);
|
||||
{ok, undo} -> fold_undo(Activity, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
fold_fn() ->
|
||||
fun (Activity, State) -> fold(Activity, State) end.
|
||||
|
||||
%% Follow {actor: A, object: B}:
|
||||
%% add B to A's pending_outbound
|
||||
%% add A to B's pending_inbound
|
||||
fold_follow(Activity, State) ->
|
||||
case follow_actor_object(Activity) of
|
||||
{ok, A, B} when A =/= B ->
|
||||
S1 = add_to_field(A, pending_outbound, B, State),
|
||||
add_to_field(B, pending_inbound, A, S1);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% Accept {actor: B, object: Follow{A→B}}:
|
||||
%% move A from B's pending_inbound to B's followers
|
||||
%% move B from A's pending_outbound to A's following
|
||||
fold_accept(Activity, State) ->
|
||||
case nested_follow_actor_object(Activity) of
|
||||
{ok, B, A, OrigA, OrigB} when B =:= OrigB, A =:= OrigA, A =/= B ->
|
||||
S1 = move_field(B, pending_inbound, followers, A, State),
|
||||
move_field(A, pending_outbound, following, B, S1);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% Reject {actor: B, object: Follow{A→B}}:
|
||||
%% drop A from B's pending_inbound
|
||||
%% drop B from A's pending_outbound
|
||||
fold_reject(Activity, State) ->
|
||||
case nested_follow_actor_object(Activity) of
|
||||
{ok, B, A, OrigA, OrigB} when B =:= OrigB, A =:= OrigA, A =/= B ->
|
||||
S1 = drop_from_field(B, pending_inbound, A, State),
|
||||
drop_from_field(A, pending_outbound, B, S1);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% Undo {actor: X, object: Follow{A→B}}:
|
||||
%% Only the original Follow's actor (A) can Undo it.
|
||||
%% Drops A↔B from every list on either side.
|
||||
fold_undo(Activity, State) ->
|
||||
case nested_follow_actor_object(Activity) of
|
||||
{ok, X, OrigA, OrigA, OrigB} when X =:= OrigA, OrigA =/= OrigB ->
|
||||
S1 = drop_from_field(OrigA, following, OrigB, State),
|
||||
S2 = drop_from_field(OrigA, pending_outbound, OrigB, S1),
|
||||
S3 = drop_from_field(OrigB, followers, OrigA, S2),
|
||||
drop_from_field(OrigB, pending_inbound, OrigA, S3);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
%% ── Extraction helpers ─────────────────────────────────────────
|
||||
|
||||
follow_actor_object(Activity) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, A} ->
|
||||
case envelope:get_field(object, Activity) of
|
||||
{ok, B} when is_atom(B) -> {ok, A, B};
|
||||
_ -> not_follow
|
||||
end;
|
||||
_ -> not_follow
|
||||
end.
|
||||
|
||||
%% nested_follow_actor_object/1 — pull (Actor, FollowActor, FollowObject)
|
||||
%% out of an envelope whose :object is itself a Follow proplist.
|
||||
%% Returns {ok, OuterActor, InferredPeer, InnerActor, InnerObject}.
|
||||
nested_follow_actor_object(Activity) ->
|
||||
case envelope:get_field(actor, Activity) of
|
||||
{ok, Outer} ->
|
||||
case envelope:get_field(object, Activity) of
|
||||
{ok, Inner} when is_list(Inner) ->
|
||||
case nested_is_follow(Inner) of
|
||||
true ->
|
||||
case {envelope:get_field(actor, Inner),
|
||||
envelope:get_field(object, Inner)} of
|
||||
{{ok, IA}, {ok, IO}} when is_atom(IO) ->
|
||||
{ok, Outer, peer_from_inner(Outer, IA, IO), IA, IO};
|
||||
_ -> not_a_follow_wrapper
|
||||
end;
|
||||
false -> not_a_follow_wrapper
|
||||
end;
|
||||
_ -> not_a_follow_wrapper
|
||||
end;
|
||||
_ -> not_a_follow_wrapper
|
||||
end.
|
||||
|
||||
nested_is_follow(Inner) ->
|
||||
case envelope:get_field(type, Inner) of
|
||||
{ok, follow} -> true;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
%% peer_from_inner — for an Accept/Reject by B of Follow{A→B},
|
||||
%% Outer = B; the "peer" we move state for is A. For an Undo by A,
|
||||
%% Outer = A; the peer is B. Picking the inner actor/object that
|
||||
%% isn't Outer gives us the right pair-mate.
|
||||
peer_from_inner(Outer, IA, _IO) when Outer =:= IA -> IA;
|
||||
peer_from_inner(_Outer, IA, _IO) -> IA.
|
||||
|
||||
%% ── Entry / field accessors ────────────────────────────────────
|
||||
|
||||
entry_field(ActorId, Field, State) ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Entry} ->
|
||||
case find_keyed(Field, Entry) of
|
||||
{ok, Val} -> Val;
|
||||
_ -> []
|
||||
end;
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
empty_entry() ->
|
||||
[{following, []},
|
||||
{followers, []},
|
||||
{pending_outbound, []},
|
||||
{pending_inbound, []}].
|
||||
|
||||
ensure_entry(ActorId, State) ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, _} -> State;
|
||||
_ -> State ++ [{ActorId, empty_entry()}]
|
||||
end.
|
||||
|
||||
add_to_field(ActorId, Field, PeerId, State) ->
|
||||
S1 = ensure_entry(ActorId, State),
|
||||
{ok, Entry} = find_keyed(ActorId, S1),
|
||||
Current = entry_field(ActorId, Field, S1),
|
||||
NewList = case contains(PeerId, Current) of
|
||||
true -> Current;
|
||||
false -> Current ++ [PeerId]
|
||||
end,
|
||||
NewEntry = set_keyed(Field, NewList, Entry),
|
||||
set_keyed(ActorId, NewEntry, S1).
|
||||
|
||||
drop_from_field(ActorId, Field, PeerId, State) ->
|
||||
case find_keyed(ActorId, State) of
|
||||
{ok, Entry} ->
|
||||
Current = entry_field(ActorId, Field, State),
|
||||
NewList = remove_member(PeerId, Current),
|
||||
NewEntry = set_keyed(Field, NewList, Entry),
|
||||
set_keyed(ActorId, NewEntry, State);
|
||||
_ -> State
|
||||
end.
|
||||
|
||||
move_field(ActorId, FromField, ToField, PeerId, State) ->
|
||||
S1 = drop_from_field(ActorId, FromField, PeerId, State),
|
||||
add_to_field(ActorId, ToField, PeerId, S1).
|
||||
|
||||
%% ── List helpers ───────────────────────────────────────────────
|
||||
|
||||
contains(_, []) -> false;
|
||||
contains(X, [X | _]) -> true;
|
||||
contains(X, [_ | Rest]) -> contains(X, Rest).
|
||||
|
||||
remove_member(_, []) -> [];
|
||||
remove_member(X, [X | Rest]) -> remove_member(X, Rest);
|
||||
remove_member(X, [Y | Rest]) -> [Y | remove_member(X, Rest)].
|
||||
|
||||
%% ── Keyed-list helpers ─────────────────────────────────────────
|
||||
|
||||
find_keyed(_, []) -> {error, 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)].
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,82 +1,311 @@
|
||||
-module(nx_kernel).
|
||||
-behaviour(gen_server).
|
||||
-export([new/3, publish/2,
|
||||
|
||||
%% Pure-functional API
|
||||
-export([new/0, new/3,
|
||||
add_actor/4, has_actor/2, actors/1, actor_count/1,
|
||||
publish/2, publish/3,
|
||||
bootstrap_actor/4,
|
||||
actor_id/1, log_state/1, log_tip/1,
|
||||
key_spec/1, actor_state/1, projections/1,
|
||||
next_published/1, with_projections/2]).
|
||||
key_spec/1, actor_state/1, projections/1, next_published/1,
|
||||
actor_log_state/2, actor_log_tip/2,
|
||||
actor_inbox_state/2, actor_inbox_tip/2,
|
||||
append_to_actor_inbox/3,
|
||||
actor_key_spec/2, actor_state/2, actor_projections/2,
|
||||
actor_next_published/2, actor_bucket/2,
|
||||
with_projections/2, with_actor_projections/3,
|
||||
next_actor_seq/1]).
|
||||
|
||||
%% gen_server API
|
||||
-export([start_link/3, publish/1, query/0, log_tip/0,
|
||||
with_projections/1, stop/0]).
|
||||
with_projections/1, stop/0,
|
||||
add_actor/3, publish_to/2, log_tip_for/1, log_state_for/1,
|
||||
inbox_tip_for/1, inbox_state_for/1, append_inbox/2,
|
||||
actors/0, state_for/1, bucket_for/1,
|
||||
with_projections_for/2,
|
||||
bootstrap_actor/3]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
|
||||
%% Kernel orchestrator — the long-lived runtime state held by the
|
||||
%% running fed-sx instance. The HTTP layer (Step 8c-post-publish
|
||||
%% follow-up) will park this in a gen_server and dispatch the POST
|
||||
%% /activity request through `publish/2`.
|
||||
%% running fed-sx instance. Step 1 (m2) refactor: state is now
|
||||
%% per-actor bucketed so one kernel hosts any number of actors.
|
||||
%%
|
||||
%% State shape (property list):
|
||||
%% [{actor_id, A},
|
||||
%% {key_spec, KS}, % proplist: key_id / algorithm / value
|
||||
%% {actor_state, AS}, % proplist: public_keys
|
||||
%% {log, L}, % log:open/2 return value
|
||||
%% {projections, [Name]}, % list of registered projection process names
|
||||
%% {next_published, N}] % monotonic counter we feed as :published
|
||||
%% New state shape (property list):
|
||||
%% [{actors, [{ActorId, ActorBucket}, ...]},
|
||||
%% {next_actor_seq, NextN}]
|
||||
%%
|
||||
%% Step 6c's stage_replay catches duplicates by `:id`; the `:id`
|
||||
%% is derived from the unsigned envelope contents. Same Request +
|
||||
%% same `:published` -> same CID, so the next_published counter
|
||||
%% gives every publish a distinct timestamp without needing a
|
||||
%% wall-clock BIF.
|
||||
%% ActorBucket = [{key_spec, KS},
|
||||
%% {actor_state, AS},
|
||||
%% {log, L},
|
||||
%% {projections, [Name]},
|
||||
%% {next_published, NextSeq}]
|
||||
%%
|
||||
%% Legacy single-actor accessors (actor_id/1, key_spec/1, etc.)
|
||||
%% continue to read from the first registered actor — keeps every
|
||||
%% pre-m2 test passing through bootstrap:start/3.
|
||||
%%
|
||||
%% next_actor_seq is a monotonic counter handed out to add_actor for
|
||||
%% future use (e.g. per-actor URL paths in Step 4). It's not yet
|
||||
%% read by the rest of the kernel.
|
||||
|
||||
%% ── Pure-functional API ──────────────────────────────────────────
|
||||
|
||||
new() ->
|
||||
[{actors, []}, {next_actor_seq, 1}].
|
||||
|
||||
new(ActorId, KeySpec, ActorStateProplist) ->
|
||||
{ok, L0} = log:open(ActorId, base_stub()),
|
||||
[{actor_id, ActorId},
|
||||
{key_spec, KeySpec},
|
||||
{actor_state, ActorStateProplist},
|
||||
{log, L0},
|
||||
{projections, []},
|
||||
{next_published, 1}].
|
||||
{ok, S} = add_actor(ActorId, KeySpec, ActorStateProplist, new()),
|
||||
S.
|
||||
|
||||
%% publish/2 — pure state transition. Returns either:
|
||||
%% {ok, Result, NewState} — log + counter advanced
|
||||
%% {error, Reason, State} — state unchanged on validation halt
|
||||
add_actor(ActorId, KeySpec, AS, State) ->
|
||||
Actors = field(actors, State),
|
||||
case has_keyed(ActorId, Actors) of
|
||||
true ->
|
||||
{error, already_present};
|
||||
false ->
|
||||
{ok, L0} = log:open(ActorId, base_stub()),
|
||||
{ok, I0} = log:open(ActorId, inbox_base_stub()),
|
||||
Bucket = [{key_spec, KeySpec},
|
||||
{actor_state, AS},
|
||||
{log, L0},
|
||||
{actor_inbox, I0},
|
||||
{projections, []},
|
||||
{next_published, 1}],
|
||||
Seq = field(next_actor_seq, State),
|
||||
State1 = set(actors, Actors ++ [{ActorId, Bucket}], State),
|
||||
State2 = set(next_actor_seq, Seq + 1, State1),
|
||||
{ok, State2}
|
||||
end.
|
||||
|
||||
has_actor(ActorId, State) ->
|
||||
has_keyed(ActorId, field(actors, State)).
|
||||
|
||||
actors(State) ->
|
||||
[Id || {Id, _Bucket} <- field(actors, State)].
|
||||
|
||||
actor_count(State) ->
|
||||
length(field(actors, State)).
|
||||
|
||||
next_actor_seq(State) ->
|
||||
field(next_actor_seq, State).
|
||||
|
||||
actor_bucket(ActorId, State) ->
|
||||
find_keyed(ActorId, field(actors, State)).
|
||||
|
||||
%% publish/3 — per-actor publish.
|
||||
publish(ActorId, Request, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{error, no_actor} ->
|
||||
{error, no_actor, State};
|
||||
{ok, Bucket} ->
|
||||
P = field(next_published, Bucket),
|
||||
Ctx = [{actor_id, ActorId},
|
||||
{published, P},
|
||||
{key_spec, field(key_spec, Bucket)},
|
||||
{actor_state, field(actor_state, Bucket)},
|
||||
{log, field(log, Bucket)},
|
||||
{projections, field(projections, Bucket)}],
|
||||
case outbox:publish(Request, Ctx) of
|
||||
{ok, Result, NewLog} ->
|
||||
B1 = set(log, NewLog, Bucket),
|
||||
B2 = set(next_published, P + 1, B1),
|
||||
NewState = set_bucket(ActorId, B2, State),
|
||||
{ok, Result, NewState};
|
||||
{error, Reason, _} ->
|
||||
{error, Reason, State}
|
||||
end
|
||||
end.
|
||||
|
||||
%% publish/2 — legacy single-actor publish; routes to first actor.
|
||||
publish(Request, State) ->
|
||||
P = field(next_published, State),
|
||||
Ctx = [{actor_id, field(actor_id, State)},
|
||||
{published, P},
|
||||
{key_spec, field(key_spec, State)},
|
||||
{actor_state, field(actor_state, State)},
|
||||
{log, field(log, State)},
|
||||
{projections, field(projections, State)}],
|
||||
case outbox:publish(Request, Ctx) of
|
||||
{ok, Result, NewLog} ->
|
||||
State1 = set(log, NewLog, State),
|
||||
State2 = set(next_published, P + 1, State1),
|
||||
{ok, Result, State2};
|
||||
{error, Reason, _} ->
|
||||
case actors(State) of
|
||||
[] -> {error, no_actor, State};
|
||||
[First | _] -> publish(First, Request, State)
|
||||
end.
|
||||
|
||||
%% bootstrap_actor/4 — register an actor bucket and immediately
|
||||
%% publish a Create{Person|Service|Group} as that actor's first
|
||||
%% activity. Profile carries the object fields plus :public_keys.
|
||||
%% Returns {ok, Result, NewState} where Result has the published
|
||||
%% Create's CID, or {error, Reason, State} on validation halt.
|
||||
|
||||
bootstrap_actor(ActorId, Profile, KeySpec, State) ->
|
||||
PublicKeys = case field(public_keys, Profile) of
|
||||
nil -> [];
|
||||
KS -> KS
|
||||
end,
|
||||
AS = [{public_keys, PublicKeys}],
|
||||
case add_actor(ActorId, KeySpec, AS, State) of
|
||||
{ok, State1} ->
|
||||
ActorType = case field(type, Profile) of
|
||||
nil -> person;
|
||||
T -> T
|
||||
end,
|
||||
Object = [{type, ActorType}] ++ collect_profile_fields(
|
||||
[name, preferredUsername, summary, icon, public_keys],
|
||||
Profile),
|
||||
Request = [{type, create}, {object, Object}],
|
||||
publish(ActorId, Request, State1);
|
||||
{error, Reason} ->
|
||||
{error, Reason, State}
|
||||
end.
|
||||
|
||||
%% Accessors
|
||||
collect_profile_fields([], _) -> [];
|
||||
collect_profile_fields([F | Rest], Profile) ->
|
||||
case field(F, Profile) of
|
||||
nil -> collect_profile_fields(Rest, Profile);
|
||||
V -> [{F, V} | collect_profile_fields(Rest, Profile)]
|
||||
end.
|
||||
|
||||
actor_id(State) -> field(actor_id, State).
|
||||
key_spec(State) -> field(key_spec, State).
|
||||
actor_state(State) -> field(actor_state, State).
|
||||
log_state(State) -> field(log, State).
|
||||
log_tip(State) -> log:tip(field(log, State)).
|
||||
projections(State) -> field(projections, State).
|
||||
next_published(State) -> field(next_published, State).
|
||||
with_actor_projections(ActorId, Names, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{error, no_actor} ->
|
||||
{error, no_actor};
|
||||
{ok, Bucket} ->
|
||||
B1 = set(projections, Names, Bucket),
|
||||
{ok, set_bucket(ActorId, B1, State)}
|
||||
end.
|
||||
|
||||
%% with_projections — return a new state with :projections replaced.
|
||||
with_projections(Names, State) ->
|
||||
set(projections, Names, State).
|
||||
case actors(State) of
|
||||
[] -> State;
|
||||
[First | _] ->
|
||||
{ok, NewState} = with_actor_projections(First, Names, State),
|
||||
NewState
|
||||
end.
|
||||
|
||||
%% Internal
|
||||
%% Per-actor accessors
|
||||
|
||||
actor_log_state(ActorId, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(log, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
actor_log_tip(ActorId, State) ->
|
||||
case actor_log_state(ActorId, State) of
|
||||
{ok, L} -> log:tip(L);
|
||||
{error, _} -> nil
|
||||
end.
|
||||
|
||||
actor_inbox_state(ActorId, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(actor_inbox, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
actor_inbox_tip(ActorId, State) ->
|
||||
case actor_inbox_state(ActorId, State) of
|
||||
{ok, I} -> log:tip(I);
|
||||
{error, _} -> nil
|
||||
end.
|
||||
|
||||
%% append_to_actor_inbox/3 — pure-functional inbox append. Mirrors
|
||||
%% publish/3's bucket-update shape; the activity is already signed
|
||||
%% + validated by the time it lands here (Step 5's pipeline handles
|
||||
%% sig verify + replay before this call).
|
||||
|
||||
append_to_actor_inbox(ActorId, Activity, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{error, no_actor} ->
|
||||
{error, no_actor, State};
|
||||
{ok, Bucket} ->
|
||||
Inbox = field(actor_inbox, Bucket),
|
||||
{ok, NewInbox, _Seq} = log:append(Inbox, Activity),
|
||||
B1 = set(actor_inbox, NewInbox, Bucket),
|
||||
{ok, log:tip(NewInbox), set_bucket(ActorId, B1, State)}
|
||||
end.
|
||||
|
||||
actor_key_spec(ActorId, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(key_spec, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
actor_state(ActorId, State) when is_list(State), is_atom(ActorId) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(actor_state, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
actor_projections(ActorId, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(projections, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
actor_next_published(ActorId, State) ->
|
||||
case actor_bucket(ActorId, State) of
|
||||
{ok, B} -> {ok, field(next_published, B)};
|
||||
{error, _} -> {error, no_actor}
|
||||
end.
|
||||
|
||||
%% Legacy single-actor accessors — read from first bucket. Keeps
|
||||
%% every M1 test (smoke_app_pure, bootstrap_start, http_publish,
|
||||
%% nx_kernel_server, http_post_format) passing.
|
||||
|
||||
actor_id(State) ->
|
||||
case field(actors, State) of
|
||||
[] -> nil;
|
||||
[{First, _Bucket} | _] -> First
|
||||
end.
|
||||
|
||||
key_spec(State) ->
|
||||
bucket_field(key_spec, State).
|
||||
|
||||
actor_state(State) ->
|
||||
bucket_field(actor_state, State).
|
||||
|
||||
log_state(State) ->
|
||||
bucket_field(log, State).
|
||||
|
||||
log_tip(State) ->
|
||||
log:tip(log_state(State)).
|
||||
|
||||
projections(State) ->
|
||||
case bucket_field(projections, State) of
|
||||
nil -> [];
|
||||
Ps -> Ps
|
||||
end.
|
||||
|
||||
next_published(State) ->
|
||||
bucket_field(next_published, State).
|
||||
|
||||
%% ── Internal helpers ──────────────────────────────────────────────
|
||||
|
||||
%% "base_stub" — placeholder base path for the in-memory log
|
||||
%% in v1 (the in-memory log ignores the base argument).
|
||||
base_stub() ->
|
||||
<<98,97,115,101,95,115,116,117,98>>.
|
||||
|
||||
%% "inbox_base_stub" — distinct path stub so the in-memory log
|
||||
%% module's open/2 returns a fresh log state for the per-actor
|
||||
%% inbox bucket. Disk paths will namespace on this once Step 3b
|
||||
%% on-disk persistence is reactivated for inbox buckets.
|
||||
inbox_base_stub() ->
|
||||
<<105,110,98,111,120,95,115,116,117,98>>.
|
||||
|
||||
bucket_field(Key, State) ->
|
||||
case field(actors, State) of
|
||||
[] -> nil;
|
||||
[{_First, Bucket} | _] -> field(Key, Bucket)
|
||||
end.
|
||||
|
||||
set_bucket(ActorId, NewBucket, State) ->
|
||||
Actors = field(actors, State),
|
||||
NewActors = set_keyed(ActorId, NewBucket, Actors),
|
||||
set(actors, NewActors, State).
|
||||
|
||||
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)];
|
||||
set_keyed(_, _, []) -> [].
|
||||
|
||||
has_keyed(_, []) -> false;
|
||||
has_keyed(K, [{K, _} | _]) -> true;
|
||||
has_keyed(K, [_ | Rest]) -> has_keyed(K, Rest).
|
||||
|
||||
find_keyed(_, []) -> {error, no_actor};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
field(K, [{K, V} | _]) -> V;
|
||||
field(K, [_ | Rest]) -> field(K, Rest);
|
||||
field(_, []) -> nil.
|
||||
@@ -91,6 +320,12 @@ set(K, V, [P | Rest]) -> [P | set(K, V, Rest)].
|
||||
%% Steps 5b and 7b. Same port quirks: raw Pid return, no `?MODULE`
|
||||
%% macro, spawned processes don't persist across separate
|
||||
%% erlang-eval-ast calls — tests inline start_link with operations.
|
||||
%%
|
||||
%% Step 1b (m2) adds multi-actor gen_server calls:
|
||||
%% add_actor/3, publish_to/2, log_tip_for/1, actors/0, state_for/1,
|
||||
%% with_projections_for/2 — all delegating to the pure-functional
|
||||
%% bucket APIs. Existing single-actor calls (publish/1, log_tip/0,
|
||||
%% with_projections/1) continue to route through bucket 0.
|
||||
|
||||
start_link(ActorId, KeySpec, ActorStateProplist) ->
|
||||
Pid = gen_server:start_link(nx_kernel,
|
||||
@@ -115,6 +350,44 @@ log_tip() ->
|
||||
with_projections(Names) ->
|
||||
gen_server:call(nx_kernel, {set_projections, Names}).
|
||||
|
||||
%% Step 1b — multi-actor gen_server calls.
|
||||
|
||||
add_actor(ActorId, KeySpec, AS) ->
|
||||
gen_server:call(nx_kernel, {add_actor, ActorId, KeySpec, AS}).
|
||||
|
||||
publish_to(ActorId, Request) ->
|
||||
gen_server:call(nx_kernel, {publish_to, ActorId, Request}).
|
||||
|
||||
log_tip_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {log_tip_for, ActorId}).
|
||||
|
||||
log_state_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {log_state_for, ActorId}).
|
||||
|
||||
inbox_tip_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {inbox_tip_for, ActorId}).
|
||||
|
||||
inbox_state_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {inbox_state_for, ActorId}).
|
||||
|
||||
append_inbox(ActorId, Activity) ->
|
||||
gen_server:call(nx_kernel, {append_inbox, ActorId, Activity}).
|
||||
|
||||
actors() ->
|
||||
gen_server:call(nx_kernel, get_actors).
|
||||
|
||||
state_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {state_for, ActorId}).
|
||||
|
||||
bucket_for(ActorId) ->
|
||||
gen_server:call(nx_kernel, {bucket_for, ActorId}).
|
||||
|
||||
with_projections_for(ActorId, Names) ->
|
||||
gen_server:call(nx_kernel, {set_projections_for, ActorId, Names}).
|
||||
|
||||
bootstrap_actor(ActorId, Profile, KeySpec) ->
|
||||
gen_server:call(nx_kernel, {bootstrap_actor, ActorId, Profile, KeySpec}).
|
||||
|
||||
%% gen_server callbacks
|
||||
|
||||
init([ActorId, KeySpec, AS]) ->
|
||||
@@ -132,7 +405,46 @@ handle_call(get_state, _From, State) ->
|
||||
handle_call(get_log_tip, _From, State) ->
|
||||
{reply, log_tip(State), State};
|
||||
handle_call({set_projections, Names}, _From, State) ->
|
||||
{reply, ok, with_projections(Names, State)}.
|
||||
{reply, ok, with_projections(Names, State)};
|
||||
handle_call({add_actor, ActorId, KeySpec, AS}, _From, State) ->
|
||||
case add_actor(ActorId, KeySpec, AS, State) of
|
||||
{ok, NewState} -> {reply, ok, NewState};
|
||||
{error, Reason} -> {reply, {error, Reason}, State}
|
||||
end;
|
||||
handle_call({publish_to, ActorId, Request}, _From, State) ->
|
||||
case publish(ActorId, Request, State) of
|
||||
{ok, Result, NewState} -> {reply, {ok, Result}, NewState};
|
||||
{error, Reason, SameState} -> {reply, {error, Reason}, SameState}
|
||||
end;
|
||||
handle_call({log_tip_for, ActorId}, _From, State) ->
|
||||
{reply, actor_log_tip(ActorId, State), State};
|
||||
handle_call({log_state_for, ActorId}, _From, State) ->
|
||||
{reply, actor_log_state(ActorId, State), State};
|
||||
handle_call({inbox_tip_for, ActorId}, _From, State) ->
|
||||
{reply, actor_inbox_tip(ActorId, State), State};
|
||||
handle_call({inbox_state_for, ActorId}, _From, State) ->
|
||||
{reply, actor_inbox_state(ActorId, State), State};
|
||||
handle_call({append_inbox, ActorId, Activity}, _From, State) ->
|
||||
case append_to_actor_inbox(ActorId, Activity, State) of
|
||||
{ok, Tip, NewState} -> {reply, {ok, Tip}, NewState};
|
||||
{error, Reason, Same} -> {reply, {error, Reason}, Same}
|
||||
end;
|
||||
handle_call(get_actors, _From, State) ->
|
||||
{reply, actors(State), State};
|
||||
handle_call({state_for, ActorId}, _From, State) ->
|
||||
{reply, actor_state(ActorId, State), State};
|
||||
handle_call({bucket_for, ActorId}, _From, State) ->
|
||||
{reply, actor_bucket(ActorId, State), State};
|
||||
handle_call({set_projections_for, ActorId, Names}, _From, State) ->
|
||||
case with_actor_projections(ActorId, Names, State) of
|
||||
{ok, NewState} -> {reply, ok, NewState};
|
||||
{error, Reason} -> {reply, {error, Reason}, State}
|
||||
end;
|
||||
handle_call({bootstrap_actor, ActorId, Profile, KeySpec}, _From, State) ->
|
||||
case bootstrap_actor(ActorId, Profile, KeySpec, State) of
|
||||
{ok, Result, NewState} -> {reply, {ok, Result}, NewState};
|
||||
{error, Reason, SameState} -> {reply, {error, Reason}, SameState}
|
||||
end.
|
||||
|
||||
handle_cast(_, S) -> {noreply, S}.
|
||||
|
||||
|
||||
@@ -92,12 +92,84 @@ publish(Request, Context) ->
|
||||
ok ->
|
||||
{ok, NewLog, _Seq} = log:append(LogState, Signed),
|
||||
broadcast(Signed, envelope_field(projections, Context)),
|
||||
Result = [{cid, cid_of(Signed)}, {activity, Signed}],
|
||||
DeliverySet = compute_delivery_set(Request, Signed, Context),
|
||||
dispatch_deliveries(Signed, DeliverySet, Context),
|
||||
Result = [{cid, cid_of(Signed)},
|
||||
{activity, Signed},
|
||||
{delivery_set, DeliverySet}],
|
||||
{ok, Result, NewLog};
|
||||
{error, Reason} ->
|
||||
{error, Reason, LogState}
|
||||
end.
|
||||
|
||||
%% dispatch_deliveries/3 — Step 8d. For each ActorId in the
|
||||
%% delivery_set, enqueue the signed activity onto the matching
|
||||
%% delivery_worker if the worker is registered under that atom.
|
||||
%% Missing workers are silently skipped — lazy creation belongs
|
||||
%% to the kernel manager (later in Step 8). The Context
|
||||
%% `:dispatch_deliveries` field gates the call so existing
|
||||
%% outbox callers that don't yet care about delivery (e.g. all of
|
||||
%% M1's tests) stay back-compat.
|
||||
%%
|
||||
%% No-op when:
|
||||
%% - :dispatch_deliveries is absent or not the atom true
|
||||
%% - delivery_set is []
|
||||
%% - the per-peer worker isn't registered (whereis returns undefined)
|
||||
|
||||
dispatch_deliveries(Activity, DeliverySet, Context) ->
|
||||
case envelope_field(dispatch_deliveries, Context) of
|
||||
true -> enqueue_each(Activity, DeliverySet);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
enqueue_each(_Activity, []) -> ok;
|
||||
enqueue_each(Activity, [PeerId | Rest]) when is_atom(PeerId) ->
|
||||
case erlang:whereis(PeerId) of
|
||||
undefined -> enqueue_each(Activity, Rest);
|
||||
_ ->
|
||||
delivery_worker:enqueue(PeerId, Activity),
|
||||
enqueue_each(Activity, Rest)
|
||||
end;
|
||||
enqueue_each(Activity, [_ | Rest]) ->
|
||||
enqueue_each(Activity, Rest).
|
||||
|
||||
%% compute_delivery_set/3 — Step 7c. Pulls the audience-resolved
|
||||
%% recipient list off the Request's `:to` / `:cc` fields (the
|
||||
%% envelope itself doesn't carry them — construct/4 only takes
|
||||
%% type / actor / published / object). Context's optional
|
||||
%% `:follower_graph` field carries a follower_graph state for
|
||||
%% `public` / `followers` audience expansion; absent -> empty graph,
|
||||
%% so explicit `:to` / `:cc` lists still resolve. Synthesises a
|
||||
%% recipient-shaped envelope from Request + Signed so the existing
|
||||
%% delivery:delivery_set/3 (which reads `:actor`, `:to`, `:cc`) can
|
||||
%% process it as-is.
|
||||
%%
|
||||
%% Step 8's delivery-queue worker reads `{delivery_set, [ActorId, ...]}`
|
||||
%% off the publish result and routes one HTTP POST per entry.
|
||||
|
||||
compute_delivery_set(Request, Signed, Context) ->
|
||||
Graph = case envelope_field(follower_graph, Context) of
|
||||
nil -> follower_graph:new();
|
||||
G -> G
|
||||
end,
|
||||
Recipients = recipients_envelope(Request, Signed),
|
||||
delivery:delivery_set(Recipients, [], Graph).
|
||||
|
||||
recipients_envelope(Request, Signed) ->
|
||||
Base = case envelope:get_field(actor, Signed) of
|
||||
{ok, A} -> [{actor, A}];
|
||||
_ -> []
|
||||
end,
|
||||
To = case envelope:get_field(to, Request) of
|
||||
{ok, T} -> [{to, T}];
|
||||
_ -> []
|
||||
end,
|
||||
Cc = case envelope:get_field(cc, Request) of
|
||||
{ok, C} -> [{cc, C}];
|
||||
_ -> []
|
||||
end,
|
||||
Base ++ To ++ Cc.
|
||||
|
||||
%% broadcast/2 — fire-and-forget cast to each named projection.
|
||||
%% Missing/nil/empty list is a no-op; the publish API does not
|
||||
%% require projections to exist. Activity is the post-sign Signed
|
||||
|
||||
140
next/kernel/peer_actors.erl
Normal file
140
next/kernel/peer_actors.erl
Normal file
@@ -0,0 +1,140 @@
|
||||
-module(peer_actors).
|
||||
-export([new/0, lookup/2, store/3, evict/2, peers/1,
|
||||
lookup_or_fetch/3,
|
||||
start_link/0, start_link/1, stop/0,
|
||||
lookup_srv/1, store_srv/2, lookup_or_fetch_srv/2,
|
||||
peers_srv/0, evict_srv/1]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
-behaviour(gen_server).
|
||||
|
||||
%% Peer-actors cache. On first inbound from a new peer, the
|
||||
%% federation layer needs the peer's `:public_keys` (and eventually
|
||||
%% other actor-doc fields) to verify the inbound signature. Fetching
|
||||
%% the peer's actor doc on every inbound would be wasteful, so we
|
||||
%% cache the peer-AS keyed by ActorId atom. Per design §13.6 stale-
|
||||
%% key invalidation defers to v3 — for v2 entries are TTL-free.
|
||||
%%
|
||||
%% State shape (pure-functional):
|
||||
%% [{PeerActorId, PeerActorState}, ...]
|
||||
%%
|
||||
%% PeerActorState is the same shape that envelope:verify_signature/2
|
||||
%% reads — a proplist with :public_keys (a list of key proplists).
|
||||
%%
|
||||
%% lookup_or_fetch/3 is the load-bearing entry point: a miss invokes
|
||||
%% the caller-supplied FetchFn (1-arity, takes PeerActorId, returns
|
||||
%% {ok, PeerAS} | {error, Reason}). The cache stores successful
|
||||
%% fetches; errors do NOT poison the cache so the caller can retry.
|
||||
%%
|
||||
%% gen_server wrapper exposes the same API for the http inbox
|
||||
%% handler. Tests inline start_link with operations (same port quirks
|
||||
%% as registry / projection / nx_kernel).
|
||||
|
||||
%% ── Pure-functional API ─────────────────────────────────────────
|
||||
|
||||
new() -> [].
|
||||
|
||||
lookup(PeerId, State) ->
|
||||
case find_keyed(PeerId, State) of
|
||||
{ok, PeerAS} -> {ok, PeerAS};
|
||||
{error, _} -> not_found
|
||||
end.
|
||||
|
||||
store(PeerId, PeerAS, State) ->
|
||||
set_keyed(PeerId, PeerAS, State).
|
||||
|
||||
evict(PeerId, State) ->
|
||||
delete_keyed(PeerId, State).
|
||||
|
||||
peers(State) -> [Id || {Id, _AS} <- State].
|
||||
|
||||
%% lookup_or_fetch/3 — cache hit returns {ok, PeerAS, State}
|
||||
%% unchanged. Cache miss calls FetchFn; success path stores and
|
||||
%% returns {ok, PeerAS, NewState}; failure returns {error, Reason,
|
||||
%% State} so the caller knows the cache state and can retry on
|
||||
%% transient errors.
|
||||
|
||||
lookup_or_fetch(PeerId, FetchFn, State) ->
|
||||
case find_keyed(PeerId, State) of
|
||||
{ok, PeerAS} -> {ok, PeerAS, State};
|
||||
{error, _} ->
|
||||
case FetchFn(PeerId) of
|
||||
{ok, PeerAS} -> {ok, PeerAS, store(PeerId, PeerAS, State)};
|
||||
{error, Reason} -> {error, Reason, State};
|
||||
Other -> {error, {bad_fetch_return, Other}, State}
|
||||
end
|
||||
end.
|
||||
|
||||
%% ── gen_server wrapper ──────────────────────────────────────────
|
||||
%%
|
||||
%% Mirrors registry / projection / nx_kernel patterns. Registered
|
||||
%% name `peer_actors` so callers (http_server inbox handler) can
|
||||
%% find it without threading the Pid through Cfg.
|
||||
|
||||
start_link() ->
|
||||
start_link([]).
|
||||
|
||||
start_link(InitialState) ->
|
||||
Pid = gen_server:start_link(peer_actors, [InitialState]),
|
||||
erlang:register(peer_actors, Pid),
|
||||
Pid.
|
||||
|
||||
stop() ->
|
||||
R = gen_server:call(peer_actors, '$gen_stop'),
|
||||
erlang:unregister(peer_actors),
|
||||
R.
|
||||
|
||||
lookup_srv(PeerId) ->
|
||||
gen_server:call(peer_actors, {lookup, PeerId}).
|
||||
|
||||
store_srv(PeerId, PeerAS) ->
|
||||
gen_server:call(peer_actors, {store, PeerId, PeerAS}).
|
||||
|
||||
%% lookup_or_fetch_srv/2 — same shape as the pure form. FetchFn must
|
||||
%% be a 1-arity fun. Reply is {ok, PeerAS} on hit-or-fetched,
|
||||
%% {error, Reason} on fetch failure.
|
||||
|
||||
lookup_or_fetch_srv(PeerId, FetchFn) ->
|
||||
gen_server:call(peer_actors, {lookup_or_fetch, PeerId, FetchFn}).
|
||||
|
||||
peers_srv() ->
|
||||
gen_server:call(peer_actors, get_peers).
|
||||
|
||||
evict_srv(PeerId) ->
|
||||
gen_server:call(peer_actors, {evict, PeerId}).
|
||||
|
||||
%% gen_server callbacks
|
||||
|
||||
init([InitialState]) ->
|
||||
{ok, InitialState}.
|
||||
|
||||
handle_call({lookup, PeerId}, _From, State) ->
|
||||
{reply, lookup(PeerId, State), State};
|
||||
handle_call({store, PeerId, PeerAS}, _From, State) ->
|
||||
{reply, ok, store(PeerId, PeerAS, State)};
|
||||
handle_call({lookup_or_fetch, PeerId, FetchFn}, _From, State) ->
|
||||
case lookup_or_fetch(PeerId, FetchFn, State) of
|
||||
{ok, PeerAS, NewState} -> {reply, {ok, PeerAS}, NewState};
|
||||
{error, Reason, SameState} -> {reply, {error, Reason}, SameState}
|
||||
end;
|
||||
handle_call(get_peers, _From, State) ->
|
||||
{reply, peers(State), State};
|
||||
handle_call({evict, PeerId}, _From, State) ->
|
||||
{reply, ok, evict(PeerId, State)}.
|
||||
|
||||
handle_cast(_, S) -> {noreply, S}.
|
||||
|
||||
handle_info(_, S) -> {noreply, S}.
|
||||
|
||||
%% ── Internal helpers ────────────────────────────────────────────
|
||||
|
||||
find_keyed(_, []) -> {error, 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)].
|
||||
|
||||
delete_keyed(_, []) -> [];
|
||||
delete_keyed(K, [{K, _} | Rest]) -> Rest;
|
||||
delete_keyed(K, [P | Rest]) -> [P | delete_keyed(K, Rest)].
|
||||
180
next/kernel/peer_types.erl
Normal file
180
next/kernel/peer_types.erl
Normal file
@@ -0,0 +1,180 @@
|
||||
-module(peer_types).
|
||||
-export([new/0, lookup/2, store/3, evict/2, types/1,
|
||||
lookup_or_fetch/3, decode_type_doc/1,
|
||||
start_link/0, start_link/1, stop/0,
|
||||
put/2, lookup/1, state_for/1, known_types/0,
|
||||
lookup_or_fetch/2, evict/1]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||
-behaviour(gen_server).
|
||||
|
||||
%% Peer-types cache — receiver-side mirror of peer_actors.erl, for
|
||||
%% host-type federation (plans/fed-sx-host-types.md, Phase 2). When an
|
||||
%% inbound activity references a refinement type the local node hasn't
|
||||
%% seen, the object-schema validation stage (Phase 4) needs that
|
||||
%% type's record — its :refinement-schema and field shape — to vet the
|
||||
%% inner object. Re-fetching the type doc on every inbound would be
|
||||
%% wasteful, so we cache the TypeRecord keyed by its content-address.
|
||||
%%
|
||||
%% State shape (pure-functional):
|
||||
%% [{TypeCidBytes, TypeRecord}, ...]
|
||||
%%
|
||||
%% TypeCidBytes is the type's CID (a binary). TypeRecord is the parsed
|
||||
%% DefineType envelope's :object payload — a proplist carrying :name,
|
||||
%% :fields, :refinement-schema, :instance-type. Refinement schemas are
|
||||
%% immutable per CID (an updated type is a new CID), so cache entries
|
||||
%% never go stale — TTL-free, like peer_actors' v2 entries.
|
||||
%%
|
||||
%% lookup_or_fetch is the load-bearing entry point: a miss invokes a
|
||||
%% Cfg-supplied closure to fetch the type doc over the wire. Per the
|
||||
%% design the closure has shape
|
||||
%% type_fetch_fn :: fun ((TypeCid, Cfg) -> {ok, Bytes} | {error, _})
|
||||
%% returning the term_codec-encoded type-doc bytes; lookup_or_fetch
|
||||
%% decodes them into the TypeRecord and caches it. Keeping the
|
||||
%% transport in the closure (Phase 3's discovery_type_fetch) keeps
|
||||
%% peer_types testable with a mocked fetch — same split as
|
||||
%% peer_actors / discovery_fetch.
|
||||
%%
|
||||
%% gen_server wrapper registers under the atom `peer_types` so the
|
||||
%% pipeline + http_server handlers can reach it without threading a
|
||||
%% Pid through Cfg.
|
||||
|
||||
%% ── Pure-functional API ─────────────────────────────────────────
|
||||
|
||||
new() -> [].
|
||||
|
||||
lookup(TypeCid, State) ->
|
||||
case find_keyed(TypeCid, State) of
|
||||
{ok, TR} -> {ok, TR};
|
||||
{error, _} -> not_found
|
||||
end.
|
||||
|
||||
store(TypeCid, TR, State) ->
|
||||
set_keyed(TypeCid, TR, State).
|
||||
|
||||
evict(TypeCid, State) ->
|
||||
delete_keyed(TypeCid, State).
|
||||
|
||||
types(State) -> [Cid || {Cid, _TR} <- State].
|
||||
|
||||
%% lookup_or_fetch/3 — cache hit returns {ok, TR, State} unchanged.
|
||||
%% Cache miss pulls the type_fetch_fn out of Cfg and calls it with
|
||||
%% (TypeCid, Cfg); a {ok, Bytes} reply is decoded via term_codec into
|
||||
%% the TypeRecord, which is then stored. Failures (no fn, fetch error,
|
||||
%% bad bytes) do NOT poison the cache so the caller can retry.
|
||||
%%
|
||||
%% no type_fetch_fn in Cfg -> {error, no_fetch_fn, State}
|
||||
%% fn -> {ok, Bytes}, decodable -> {ok, TR, store(...)}
|
||||
%% fn -> {ok, Bytes}, bad bytes -> {error, bad_type_doc, State}
|
||||
%% fn -> {error, Reason} -> {error, Reason, State}
|
||||
%% fn -> Other -> {error, {bad_fetch_return, Other}, State}
|
||||
|
||||
lookup_or_fetch(TypeCid, Cfg, State) ->
|
||||
case find_keyed(TypeCid, State) of
|
||||
{ok, TR} -> {ok, TR, State};
|
||||
{error, _} -> fetch_and_store(TypeCid, Cfg, State)
|
||||
end.
|
||||
|
||||
fetch_and_store(TypeCid, Cfg, State) ->
|
||||
case field(type_fetch_fn, Cfg) of
|
||||
nil -> {error, no_fetch_fn, State};
|
||||
Fn when is_function(Fn, 2) ->
|
||||
case Fn(TypeCid, Cfg) of
|
||||
{ok, Bytes} ->
|
||||
case decode_type_doc(Bytes) of
|
||||
{ok, TR} -> {ok, TR, store(TypeCid, TR, State)};
|
||||
{error, R} -> {error, R, State}
|
||||
end;
|
||||
{error, Reason} -> {error, Reason, State};
|
||||
Other -> {error, {bad_fetch_return, Other}, State}
|
||||
end;
|
||||
_ -> {error, bad_fetch_fn_cfg, State}
|
||||
end.
|
||||
|
||||
%% decode_type_doc/1 — round the wire body back through term_codec.
|
||||
%% The on-wire form is term_codec:encode(TypeRecord) (Phase 3's
|
||||
%% /types/<cid> route), so a clean decode yields the proplist TR.
|
||||
decode_type_doc(Bytes) ->
|
||||
case term_codec:decode(Bytes) of
|
||||
{ok, TR, _} when is_list(TR) -> {ok, TR};
|
||||
_ -> {error, bad_type_doc}
|
||||
end.
|
||||
|
||||
%% ── gen_server wrapper ──────────────────────────────────────────
|
||||
|
||||
start_link() ->
|
||||
start_link([]).
|
||||
|
||||
start_link(InitialState) ->
|
||||
Pid = gen_server:start_link(peer_types, [InitialState]),
|
||||
erlang:register(peer_types, Pid),
|
||||
Pid.
|
||||
|
||||
stop() ->
|
||||
R = gen_server:call(peer_types, '$gen_stop'),
|
||||
erlang:unregister(peer_types),
|
||||
R.
|
||||
|
||||
%% put/2 — store a TypeRecord under its CID. Mirrors store_srv.
|
||||
put(TypeCid, TR) ->
|
||||
gen_server:call(peer_types, {put, TypeCid, TR}).
|
||||
|
||||
%% lookup/1 — cache read. {ok, TR} | not_found.
|
||||
lookup(TypeCid) ->
|
||||
gen_server:call(peer_types, {lookup, TypeCid}).
|
||||
|
||||
%% state_for/1 — alias of lookup/1, named to match peer_actors'
|
||||
%% state_for accessor used by http_server's kernel bridge.
|
||||
state_for(TypeCid) ->
|
||||
gen_server:call(peer_types, {lookup, TypeCid}).
|
||||
|
||||
known_types() ->
|
||||
gen_server:call(peer_types, get_types).
|
||||
|
||||
evict(TypeCid) ->
|
||||
gen_server:call(peer_types, {evict, TypeCid}).
|
||||
|
||||
%% lookup_or_fetch/2 — gen_server form. Cfg carries the type_fetch_fn.
|
||||
%% Reply is {ok, TR} on hit-or-fetched, {error, Reason} otherwise.
|
||||
lookup_or_fetch(TypeCid, Cfg) ->
|
||||
gen_server:call(peer_types, {lookup_or_fetch, TypeCid, Cfg}).
|
||||
|
||||
%% gen_server callbacks
|
||||
|
||||
init([InitialState]) ->
|
||||
{ok, InitialState}.
|
||||
|
||||
handle_call({put, TypeCid, TR}, _From, State) ->
|
||||
{reply, ok, store(TypeCid, TR, State)};
|
||||
handle_call({lookup, TypeCid}, _From, State) ->
|
||||
{reply, lookup(TypeCid, State), State};
|
||||
handle_call({lookup_or_fetch, TypeCid, Cfg}, _From, State) ->
|
||||
case lookup_or_fetch(TypeCid, Cfg, State) of
|
||||
{ok, TR, NewState} -> {reply, {ok, TR}, NewState};
|
||||
{error, Reason, Same} -> {reply, {error, Reason}, Same}
|
||||
end;
|
||||
handle_call(get_types, _From, State) ->
|
||||
{reply, types(State), State};
|
||||
handle_call({evict, TypeCid}, _From, State) ->
|
||||
{reply, ok, evict(TypeCid, State)}.
|
||||
|
||||
handle_cast(_, S) -> {noreply, S}.
|
||||
|
||||
handle_info(_, S) -> {noreply, S}.
|
||||
|
||||
%% ── Internal helpers ────────────────────────────────────────────
|
||||
|
||||
field(K, [{K, V} | _]) -> V;
|
||||
field(K, [_ | Rest]) -> field(K, Rest);
|
||||
field(_, []) -> nil.
|
||||
|
||||
find_keyed(_, []) -> {error, 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)].
|
||||
|
||||
delete_keyed(_, []) -> [];
|
||||
delete_keyed(K, [{K, _} | Rest]) -> Rest;
|
||||
delete_keyed(K, [P | Rest]) -> [P | delete_keyed(K, Rest)].
|
||||
@@ -1,11 +1,14 @@
|
||||
-module(pipeline).
|
||||
-export([run_stages/2,
|
||||
validate_inbound/1, validate_outbound/1,
|
||||
inbound_stages/0, outbound_stages/0,
|
||||
validate_inbound/1, validate_inbound/3,
|
||||
validate_outbound/1,
|
||||
inbound_stages/0, inbound_stages/2, outbound_stages/0,
|
||||
stage_envelope/1,
|
||||
stage_signature/1, stage_signature/2,
|
||||
stage_replay/1, stage_replay/2,
|
||||
stage_schema/1, stage_schema/2]).
|
||||
stage_schema/1, stage_schema/2,
|
||||
apply_object_schema/2, stage_object_schema/1,
|
||||
apply_triggers/3]).
|
||||
|
||||
%% Validation pipeline per design §14.
|
||||
%%
|
||||
@@ -34,12 +37,43 @@ run_stages(Activity, [Stage | Rest]) ->
|
||||
validate_inbound(Activity) ->
|
||||
run_stages(Activity, inbound_stages()).
|
||||
|
||||
%% validate_inbound/3 — Step 5b federation inbound pipeline.
|
||||
%%
|
||||
%% Activity: the signed envelope as received from the peer.
|
||||
%% PeerActorState: the peer's actor-state proplist carrying
|
||||
%% :public_keys for signature verification. Caller
|
||||
%% resolves this — for v2 it's either pre-populated
|
||||
%% from a peer-actors cache (Step 5c) or known from
|
||||
%% a two-instance test fixture.
|
||||
%% InboxLog: the receiving actor's :actor_inbox log state.
|
||||
%% Used by stage_replay to reject duplicate :id.
|
||||
%%
|
||||
%% Stages (per design §13.2 + §14):
|
||||
%% stage_envelope — shape check
|
||||
%% stage_signature(PeerAS) — peer sig verify
|
||||
%% stage_replay(InboxLog) — replay defence against
|
||||
%% receiving actor's inbox
|
||||
%%
|
||||
%% Returns ok | {error, Reason}. The driver halts on first failure.
|
||||
%% Audience / schema / capabilities / trust stages defer to v3.
|
||||
|
||||
validate_inbound(Activity, PeerActorState, InboxLog) ->
|
||||
run_stages(Activity, inbound_stages(PeerActorState, InboxLog)).
|
||||
|
||||
validate_outbound(Activity) ->
|
||||
run_stages(Activity, outbound_stages()).
|
||||
|
||||
inbound_stages() ->
|
||||
[fun (A) -> stage_envelope(A) end].
|
||||
|
||||
%% inbound_stages/2 — the full ordered stage list for federation
|
||||
%% inbound (envelope -> peer sig -> replay against inbox).
|
||||
|
||||
inbound_stages(PeerActorState, InboxLog) ->
|
||||
[fun (A) -> stage_envelope(A) end,
|
||||
stage_signature(PeerActorState),
|
||||
stage_replay(InboxLog)].
|
||||
|
||||
outbound_stages() ->
|
||||
[fun (A) -> stage_envelope(A) end].
|
||||
|
||||
@@ -133,3 +167,233 @@ check_object_schema(Activity, SchemaFn) ->
|
||||
|
||||
stage_schema(SchemaLookup) ->
|
||||
fun (Activity) -> stage_schema(Activity, SchemaLookup) end.
|
||||
|
||||
%% ── host-type fed Step 4: object-schema validation stage ────────
|
||||
%%
|
||||
%% apply_object_schema/2 — when an inbound activity's :object declares
|
||||
%% a refinement type ({type, TypeName} on the object), resolve that
|
||||
%% type's record and apply its refinement schema to the object's
|
||||
%% :field_values. Sits between activity-type (stage_schema) validation
|
||||
%% and the kernel append; rejects the activity on schema-fail.
|
||||
%%
|
||||
%% Resolution mirrors the design note: TypeName -> TypeCid via Cfg's
|
||||
%% `type_index` ([{TypeName, TypeCid}, ...], the local Define-name
|
||||
%% index), then TypeCid -> TypeRecord via peer_types:lookup_or_fetch/2
|
||||
%% (a local cache hit, or a wire fetch through the Cfg type_fetch_fn).
|
||||
%%
|
||||
%% Outcomes:
|
||||
%% object has no {type, _} -> ok (no schema applies)
|
||||
%% TypeName not in type_index -> ok (undeclared type;
|
||||
%% open-world default)
|
||||
%% record resolved, schema passes -> ok
|
||||
%% record resolved, schema fails -> {error, {validation_failed,
|
||||
%% object_schema}}
|
||||
%% record unresolvable (cache miss + -> strict_object_schema:
|
||||
%% fetch failure / no peer_types) true -> {error, ...}
|
||||
%% false -> ok (skipped)
|
||||
%%
|
||||
%% Default strict_object_schema = false: a node only blocks on an
|
||||
%% unresolvable type when it opts into airtight validation via Cfg
|
||||
%% {strict_object_schema, true}. The non-strict skip is where a
|
||||
%% `validation_skipped` log entry belongs (left to the caller's logger
|
||||
%% so this stage keeps the ok | {error, _} contract run_stages wants).
|
||||
%%
|
||||
%% A TypeRecord's refinement schema is either a 1-arity Erlang
|
||||
%% predicate over the field-values (the substrate stand-in, for
|
||||
%% locally-defined types) or a data constraint {required, [Field, ...]}
|
||||
%% (term_codec-safe, so a wire-fetched TypeRecord can still validate).
|
||||
|
||||
apply_object_schema(Activity, Cfg) ->
|
||||
case object_type_name(Activity) of
|
||||
none -> ok;
|
||||
{ok, TypeName} ->
|
||||
case type_cid_for(TypeName, Cfg) of
|
||||
none -> ok;
|
||||
{ok, TypeCid} ->
|
||||
case resolve_type_record(TypeCid, Cfg) of
|
||||
{ok, TR} -> check_object_against(Activity, TR);
|
||||
{error, _} -> on_unresolved_type(Cfg)
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
stage_object_schema(Cfg) ->
|
||||
fun (Activity) -> apply_object_schema(Activity, Cfg) end.
|
||||
|
||||
object_type_name(Activity) ->
|
||||
case envelope:get_field(object, Activity) of
|
||||
{ok, Obj} when is_list(Obj) ->
|
||||
case envelope:get_field(type, Obj) of
|
||||
{ok, T} -> {ok, T};
|
||||
_ -> none
|
||||
end;
|
||||
_ -> none
|
||||
end.
|
||||
|
||||
object_field_values(Activity) ->
|
||||
case envelope:get_field(object, Activity) of
|
||||
{ok, Obj} when is_list(Obj) ->
|
||||
case envelope:get_field(field_values, Obj) of
|
||||
{ok, FV} -> FV;
|
||||
_ -> []
|
||||
end;
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
type_cid_for(TypeName, Cfg) ->
|
||||
case stage_field(type_index, Cfg) of
|
||||
nil -> none;
|
||||
Index ->
|
||||
case find_keyed(TypeName, Index) of
|
||||
{ok, Cid} -> {ok, Cid};
|
||||
_ -> none
|
||||
end
|
||||
end.
|
||||
|
||||
resolve_type_record(TypeCid, Cfg) ->
|
||||
case stage_field(peer_types, Cfg) of
|
||||
nil -> {error, no_peer_types};
|
||||
_ ->
|
||||
case erlang:whereis(peer_types) of
|
||||
undefined -> {error, peer_types_down};
|
||||
_ -> peer_types:lookup_or_fetch(TypeCid, Cfg)
|
||||
end
|
||||
end.
|
||||
|
||||
on_unresolved_type(Cfg) ->
|
||||
case stage_field(strict_object_schema, Cfg) of
|
||||
true -> {error, {validation_failed, object_schema}};
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
check_object_against(Activity, TR) ->
|
||||
case stage_field(refinement_schema, TR) of
|
||||
nil -> ok;
|
||||
Schema -> apply_refinement(Schema, object_field_values(Activity))
|
||||
end.
|
||||
|
||||
apply_refinement(Fn, FieldValues) when is_function(Fn, 1) ->
|
||||
case Fn(FieldValues) of
|
||||
true -> ok;
|
||||
_ -> {error, {validation_failed, object_schema}}
|
||||
end;
|
||||
apply_refinement({required, Fields}, FieldValues) ->
|
||||
case all_present(Fields, FieldValues) of
|
||||
true -> ok;
|
||||
false -> {error, {validation_failed, object_schema}}
|
||||
end;
|
||||
apply_refinement(_, _) -> ok.
|
||||
|
||||
all_present([], _) -> true;
|
||||
all_present([F | Rest], FV) ->
|
||||
case has_key(F, FV) of
|
||||
true -> all_present(Rest, FV);
|
||||
false -> false
|
||||
end.
|
||||
|
||||
has_key(_, []) -> false;
|
||||
has_key(K, [{K, _} | _]) -> true;
|
||||
has_key(K, [_ | Rest]) -> has_key(K, Rest).
|
||||
|
||||
stage_field(K, [{K, V} | _]) -> V;
|
||||
stage_field(K, [_ | Rest]) -> stage_field(K, Rest);
|
||||
stage_field(_, []) -> nil.
|
||||
|
||||
find_keyed(_, []) -> {error, not_found};
|
||||
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||
|
||||
%% ── fed-sx triggers Step 2: post-append fan-out ─────────────────
|
||||
%%
|
||||
%% apply_triggers/3 — fires the durable flows bound to an activity's
|
||||
%% type AFTER it has been accepted and appended (rejected activities
|
||||
%% never reach here, so a flow only runs for an activity that really
|
||||
%% landed). For each spec the activity's type is bound to, the spec
|
||||
%% must pass its guard/actor-scope, and its {ActivityCid, TriggerCid}
|
||||
%% pair must not already have fired (federation can deliver the same
|
||||
%% activity twice via different peers — dedup is keyed on that pair,
|
||||
%% read from the receiving actor's :triggers_fired). Surviving specs are
|
||||
%% dispatched via flow_dispatch:start (a native flow_store:start), which
|
||||
%% never raises.
|
||||
%%
|
||||
%% Returns {ok, Results} where Results is one
|
||||
%% {ActivityCid, TriggerCid, {ok, FlowId} | {error, Reason}}
|
||||
%% per spec actually dispatched (guard-passed, not a duplicate). The
|
||||
%% kernel folds the {ActivityCid, TriggerCid} pairs into the actor's
|
||||
%% :triggers_fired (dedup) and the audit triples into its projection.
|
||||
%% No matching/ready registry yields {ok, []}.
|
||||
%%
|
||||
%% Cfg gates the fan-out on {trigger_registry, trigger_registry} (the
|
||||
%% registered gen_server), mirroring the object-schema stage's
|
||||
%% {peer_types, _} gate. apply_triggers must NOT be called inside a
|
||||
%% `try` — flow_dispatch does gen_server:calls, and a blocking call
|
||||
%% inside a try deadlocks this scheduler; the fan-out runs after append,
|
||||
%% in its own step, so this is naturally satisfied.
|
||||
|
||||
apply_triggers(Activity, ActorState, Cfg) ->
|
||||
case trigger_registry_ready(Cfg) of
|
||||
false -> {ok, []};
|
||||
true ->
|
||||
Type = activity_type_of(Activity),
|
||||
Specs = trigger_registry:lookup(Type),
|
||||
ActCid = trigger_activity_cid(Activity),
|
||||
Fired = field_or_default(triggers_fired, ActorState, []),
|
||||
fire_each(Specs, Activity, ActorState, ActCid, Fired, Cfg, [])
|
||||
end.
|
||||
|
||||
trigger_registry_ready(Cfg) ->
|
||||
case stage_field(trigger_registry, Cfg) of
|
||||
nil -> false;
|
||||
_ ->
|
||||
case erlang:whereis(trigger_registry) of
|
||||
undefined -> false;
|
||||
_ -> true
|
||||
end
|
||||
end.
|
||||
|
||||
fire_each([], _A, _AS, _ACid, _Fired, _Cfg, Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
fire_each([Spec | Rest], A, AS, ACid, Fired, Cfg, Acc) ->
|
||||
TCid = trigger_registry:spec_cid(Spec),
|
||||
Pair = {ACid, TCid},
|
||||
AlreadyFired = pair_member(Pair, Fired) orelse acc_member(Pair, Acc),
|
||||
Pass = (not AlreadyFired) andalso flow_dispatch:guard_passes(Spec, A, AS),
|
||||
case Pass of
|
||||
false ->
|
||||
fire_each(Rest, A, AS, ACid, Fired, Cfg, Acc);
|
||||
true ->
|
||||
Outcome = case flow_dispatch:start(Spec, A, AS, Cfg) of
|
||||
{ok, FlowId, _Audit} -> {ok, FlowId};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end,
|
||||
fire_each(Rest, A, AS, ACid, Fired, Cfg, [{ACid, TCid, Outcome} | Acc])
|
||||
end.
|
||||
|
||||
activity_type_of(Activity) ->
|
||||
case envelope:get_field(type, Activity) of
|
||||
{ok, Type} -> Type;
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
trigger_activity_cid(Activity) ->
|
||||
case envelope:get_field(id, Activity) of
|
||||
{ok, Cid} -> Cid;
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
field_or_default(Key, Proplist, Default) ->
|
||||
case envelope:get_field(Key, Proplist) of
|
||||
{ok, V} -> V;
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
%% pair_member/2 — {ACid, TCid} present in a [{ACid, TCid}] fired list.
|
||||
pair_member(_, []) -> false;
|
||||
pair_member(P, [P | _]) -> true;
|
||||
pair_member(P, [_ | Rest]) -> pair_member(P, Rest).
|
||||
|
||||
%% acc_member/2 — {ACid, TCid} already dispatched this call (Acc holds
|
||||
%% {ACid, TCid, Outcome} triples).
|
||||
acc_member(_, []) -> false;
|
||||
acc_member({A, T}, [{A, T, _} | _]) -> true;
|
||||
acc_member(P, [_ | Rest]) -> acc_member(P, Rest).
|
||||
|
||||
180
next/kernel/trigger_registry.erl
Normal file
180
next/kernel/trigger_registry.erl
Normal file
@@ -0,0 +1,180 @@
|
||||
-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)].
|
||||
164
next/tests/actor_lifecycle.sh
Executable file
164
next/tests/actor_lifecycle.sh
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/actor_lifecycle.sh — m2 Step 2c end-to-end test.
|
||||
#
|
||||
# Ties Step 2a artefacts (genesis Person/Service/Group SX files),
|
||||
# Step 2b projection (actor_state.erl), and Step 2c bootstrap
|
||||
# (nx_kernel:bootstrap_actor/4) together. Profiles bootstrap as
|
||||
# Create{Person|Service|Group} activities; the actor_state projection
|
||||
# folds them into the per-actor profile registry.
|
||||
|
||||
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
|
||||
|
||||
# Two actors share signing-key bytes (each in its own AS). The
|
||||
# profile's :public_keys list is what gets wrapped in the Create
|
||||
# object; the kernel-side AS proplist (built by bootstrap_actor/4
|
||||
# from :public_keys) is what envelope:verify_signature reads.
|
||||
ALICE_KM='AliceK = <<1,2,3,4>>, AliceKey = [{id, k1}, {created, 0}, {value, AliceK}], AlicePks = [AliceKey], AliceKS = [{key_id, k1}, {algorithm, ed25519}, {value, AliceK}],'
|
||||
BOB_KM='BobK = <<5,6,7,8>>, BobKey = [{id, k1}, {created, 0}, {value, BobK}], BobPks = [BobKey], BobKS = [{key_id, k1}, {algorithm, ed25519}, {value, BobK}],'
|
||||
ALICE_PROFILE='AliceProfile = [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, AlicePks}],'
|
||||
BOB_PROFILE='BobProfile = [{type, service}, {name, bobbot_n}, {preferredUsername, bobbot_local}, {public_keys, BobPks}],'
|
||||
|
||||
# actor_state projection wiring — fold_fn from actor_state:fold_fn/0,
|
||||
# initial state = actor_state:new().
|
||||
PROJ_SETUP='projection:start_link(actors, actor_state:new(), actor_state:fold_fn()),'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/projection.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/actor_state.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
|
||||
;; Pure: bootstrap_actor/4 on a fresh kernel publishes Create and
|
||||
;; returns {ok, Result, S}.
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} case nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()) of {ok, _, _} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; Pure: after bootstrap, log_tip = 1, has_actor true
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), nx_kernel:has_actor(alice, S) andalso nx_kernel:actor_log_tip(alice, S) =:= 1\") :name)")
|
||||
|
||||
;; Pure: log entry is a Create with object's type = person
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), {ok, L} = nx_kernel:actor_log_state(alice, S), [E] = log:entries(L), {ok, create} = envelope:get_field(type, E), {ok, Obj} = envelope:get_field(object, E), envelope:get_field(type, Obj) =:= {ok, person}\") :name)")
|
||||
|
||||
;; Pure: bootstrap into existing kernel with another actor
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S1} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), {ok, _, S2} = nx_kernel:bootstrap_actor(bobbot, BobProfile, BobKS, S1), nx_kernel:actors(S2) =:= [alice, bobbot]\") :name)")
|
||||
|
||||
;; Pure: two actors have independent log_tips
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S1} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), {ok, _, S2} = nx_kernel:bootstrap_actor(bobbot, BobProfile, BobKS, S1), {nx_kernel:actor_log_tip(alice, S2), nx_kernel:actor_log_tip(bobbot, S2)} =:= {1, 1}\") :name)")
|
||||
|
||||
;; Pure: duplicate bootstrap_actor returns already_present
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S1} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), case nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, S1) of {error, already_present, _} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; gen_server: bootstrap_actor/3 publishes + actor_state projection captures profile
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:with_projections_for(seed, [actors]), {ok, _} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS), nx_kernel:has_actor(seed, nx_kernel:query()) andalso nx_kernel:has_actor(alice, nx_kernel:query())\") :name)")
|
||||
|
||||
;; gen_server: actor_state projection captures the bootstrapped Person profile
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:with_projections_for(alice_pre, [actors]), nx_kernel:add_actor(alice_pre, AliceKS, [{public_keys, AlicePks}]), nx_kernel:with_projections_for(alice_pre, [actors]), {ok, _} = nx_kernel:publish_to(alice_pre, [{type, create}, {object, [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, AlicePks}]}]), {ok, Profile} = actor_state:lookup(alice_pre, projection:query(actors)), actor_state:profile_type(Profile) =:= person andalso actor_state:profile_name(Profile) =:= alice_n\") :name)")
|
||||
|
||||
;; gen_server: Service profile lands as service in actor_state
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, BobKS, [{public_keys, BobPks}]), ${PROJ_SETUP} nx_kernel:add_actor(bobbot, BobKS, [{public_keys, BobPks}]), nx_kernel:with_projections_for(bobbot, [actors]), {ok, _} = nx_kernel:publish_to(bobbot, [{type, create}, {object, [{type, service}, {name, bobbot_n}, {public_keys, BobPks}]}]), {ok, Profile} = actor_state:lookup(bobbot, projection:query(actors)), actor_state:profile_type(Profile) =:= service\") :name)")
|
||||
|
||||
;; gen_server: Group profile lands as group in actor_state
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:add_actor(wg1, AliceKS, [{public_keys, AlicePks}]), nx_kernel:with_projections_for(wg1, [actors]), {ok, _} = nx_kernel:publish_to(wg1, [{type, create}, {object, [{type, group}, {name, working_group_n}, {public_keys, AlicePks}]}]), {ok, Profile} = actor_state:lookup(wg1, projection:query(actors)), actor_state:profile_type(Profile) =:= group\") :name)")
|
||||
|
||||
;; Sanity: profile captures :preferredUsername + :public_keys from the Create object
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:add_actor(alice, AliceKS, [{public_keys, AlicePks}]), nx_kernel:with_projections_for(alice, [actors]), {ok, _} = nx_kernel:publish_to(alice, [{type, create}, {object, [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, AlicePks}]}]), {ok, Profile} = actor_state:lookup(alice, projection:query(actors)), actor_state:profile_field(preferredUsername, Profile) =:= {ok, alice_local} andalso actor_state:profile_field(public_keys, Profile) =:= {ok, AlicePks}\") :name)")
|
||||
|
||||
;; Pure: profile defaults to person when :type missing
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} TypelessProfile = [{name, alice_n}, {public_keys, AlicePks}], {ok, _, S} = nx_kernel:bootstrap_actor(alice, TypelessProfile, AliceKS, nx_kernel:new()), {ok, L} = nx_kernel:actor_log_state(alice, S), [E] = log:entries(L), {ok, Obj} = envelope:get_field(object, E), envelope:get_field(type, Obj) =:= {ok, person}\") :name)")
|
||||
|
||||
;; Pure: empty profile :public_keys defaults to []
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${ALICE_KM} EmptyProfile = [{type, person}, {name, alice_n}], case nx_kernel:bootstrap_actor(alice, EmptyProfile, AliceKS, nx_kernel:new()) of {ok, _, _} -> ok; {error, _, _} -> ok end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 2 "gen_server loaded" "gen_server"
|
||||
check 9 "nx_kernel loaded" "nx_kernel"
|
||||
check 10 "bootstrap_actor/4 -> {ok, _, _}" "ok"
|
||||
check 11 "bootstrap_actor advances log_tip" "true"
|
||||
check 12 "log entry is Create{Person}" "true"
|
||||
check 13 "two actors live in one kernel" "true"
|
||||
check 14 "independent log_tips after boot" "true"
|
||||
check 15 "duplicate boot -> already_present" "ok"
|
||||
check 16 "gen_server bootstrap_actor/3" "true"
|
||||
check 17 "actor_state captures Person" "true"
|
||||
check 18 "actor_state captures Service" "true"
|
||||
check 19 "actor_state captures Group" "true"
|
||||
check 20 "profile carries preferredUsername" "true"
|
||||
check 21 "typeless profile defaults Person" "true"
|
||||
check 22 "empty public_keys handled" "ok"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/actor_lifecycle.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
163
next/tests/actor_state_pure.sh
Executable file
163
next/tests/actor_state_pure.sh
Executable file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/actor_state_pure.sh — m2 Step 2b test.
|
||||
#
|
||||
# Exercises the Erlang-fun stand-in for the actor-state projection
|
||||
# fold. Activities flow:
|
||||
# Create{Person|Service|Group} -> profile registered
|
||||
# Update{Person|Service|Group, patch} -> patch deep-merged
|
||||
# Move -> :moved_to recorded
|
||||
# Non-actor object Creates pass through.
|
||||
|
||||
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
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/actor_state.erl\")) :name)")
|
||||
|
||||
;; new/0 returns []
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"actor_state:new() =:= []\") :name)")
|
||||
|
||||
;; has/2 false on empty
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"actor_state:has(alice, actor_state:new()) =:= false\") :name)")
|
||||
|
||||
;; lookup/2 not_found on empty
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"actor_state:lookup(alice, actor_state:new()) =:= not_found\") :name)")
|
||||
|
||||
;; actors/1 returns [] on empty
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"actor_state:actors(actor_state:new()) =:= []\") :name)")
|
||||
|
||||
;; Create{Person} registers profile
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"Obj = [{type, person}, {name, alice_name}, {preferredUsername, alice_local}], Act = [{actor, alice}, {type, create}, {object, Obj}, {published, 1}], S = actor_state:fold(Act, actor_state:new()), actor_state:has(alice, S)\") :name)")
|
||||
|
||||
;; Profile carries :type, :name, :preferredUsername, :created
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"Obj = [{type, person}, {name, alice_name}, {preferredUsername, alice_local}], Act = [{actor, alice}, {type, create}, {object, Obj}, {published, 7}], S = actor_state:fold(Act, actor_state:new()), {ok, P} = actor_state:lookup(alice, S), {actor_state:profile_type(P), actor_state:profile_name(P), actor_state:profile_field(preferredUsername, P), actor_state:profile_field(created, P)} =:= {person, alice_name, {ok, alice_local}, {ok, 7}}\") :name)")
|
||||
|
||||
;; Create{Service} also registers
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"Obj = [{type, service}, {name, feedbot}], Act = [{actor, feed1}, {type, create}, {object, Obj}, {published, 1}], S = actor_state:fold(Act, actor_state:new()), {ok, P} = actor_state:lookup(feed1, S), actor_state:profile_type(P) =:= service\") :name)")
|
||||
|
||||
;; Create{Group} also registers
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"Obj = [{type, group}, {name, working_group}], Act = [{actor, wg1}, {type, create}, {object, Obj}, {published, 1}], S = actor_state:fold(Act, actor_state:new()), {ok, P} = actor_state:lookup(wg1, S), actor_state:profile_type(P) =:= group\") :name)")
|
||||
|
||||
;; Create{Note} is pass-through (non-actor object)
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"Obj = [{type, note}, {content, hi}], Act = [{actor, alice}, {type, create}, {object, Obj}, {published, 1}], actor_state:fold(Act, actor_state:new()) =:= []\") :name)")
|
||||
|
||||
;; Duplicate Create doesn't overwrite an existing profile
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"O1 = [{type, person}, {name, alice_v1}], O2 = [{type, person}, {name, alice_v2}], A1 = [{actor, alice}, {type, create}, {object, O1}, {published, 1}], A2 = [{actor, alice}, {type, create}, {object, O2}, {published, 2}], S1 = actor_state:fold(A1, actor_state:new()), S2 = actor_state:fold(A2, S1), {ok, P} = actor_state:lookup(alice, S2), actor_state:profile_name(P) =:= alice_v1\") :name)")
|
||||
|
||||
;; Two distinct actors live side by side
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"PO = [{type, person}, {name, alice_n}], SO = [{type, service}, {name, bobbot_n}], A1 = [{actor, alice}, {type, create}, {object, PO}, {published, 1}], A2 = [{actor, bobbot}, {type, create}, {object, SO}, {published, 2}], S = actor_state:fold(A2, actor_state:fold(A1, actor_state:new())), actor_state:actors(S) =:= [alice, bobbot]\") :name)")
|
||||
|
||||
;; Update merges patch
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"PO = [{type, person}, {name, alice_n}], A1 = [{actor, alice}, {type, create}, {object, PO}, {published, 1}], A2 = [{actor, alice}, {type, update}, {patch, [{summary, new_bio}]}, {published, 2}], S = actor_state:fold(A2, actor_state:fold(A1, actor_state:new())), {ok, P} = actor_state:lookup(alice, S), actor_state:profile_field(summary, P) =:= {ok, new_bio}\") :name)")
|
||||
|
||||
;; Update overwrites individual fields (last-write-wins per key)
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"PO = [{type, person}, {name, alice_v1}], A1 = [{actor, alice}, {type, create}, {object, PO}, {published, 1}], A2 = [{actor, alice}, {type, update}, {patch, [{name, alice_v2}]}, {published, 2}], S = actor_state:fold(A2, actor_state:fold(A1, actor_state:new())), {ok, P} = actor_state:lookup(alice, S), actor_state:profile_name(P) =:= alice_v2\") :name)")
|
||||
|
||||
;; Update for unknown actor is pass-through
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"A = [{actor, ghost}, {type, update}, {patch, [{summary, x}]}, {published, 1}], actor_state:fold(A, actor_state:new()) =:= []\") :name)")
|
||||
|
||||
;; Move records :moved_to
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"PO = [{type, person}, {name, alice_n}], A1 = [{actor, alice}, {type, create}, {object, PO}, {published, 1}], A2 = [{actor, alice}, {type, move}, {moved_to, new_alice}, {published, 2}], S = actor_state:fold(A2, actor_state:fold(A1, actor_state:new())), {ok, P} = actor_state:lookup(alice, S), actor_state:profile_field(moved_to, P) =:= {ok, new_alice}\") :name)")
|
||||
|
||||
;; fold_fn/0 is a 2-arity Erlang fun usable by projection:start_link
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"F = actor_state:fold_fn(), is_function(F, 2)\") :name)")
|
||||
|
||||
;; fold ignores activities with no :actor field
|
||||
(epoch 26)
|
||||
(eval "(get (erlang-eval-ast \"Obj = [{type, person}, {name, x}], Act = [{type, create}, {object, Obj}, {published, 1}], actor_state:fold(Act, actor_state:new()) =:= []\") :name)")
|
||||
|
||||
;; public_keys field is captured at Create time
|
||||
(epoch 27)
|
||||
(eval "(get (erlang-eval-ast \"Keys = [[{id, k1}, {value, <<1,2,3,4>>}]], Obj = [{type, person}, {name, alice_n}, {public_keys, Keys}], Act = [{actor, alice}, {type, create}, {object, Obj}, {published, 1}], S = actor_state:fold(Act, actor_state:new()), {ok, P} = actor_state:lookup(alice, S), actor_state:profile_field(public_keys, P) =:= {ok, Keys}\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 "actor_state module loaded" "actor_state"
|
||||
check 10 "new/0 -> []" "true"
|
||||
check 11 "has/2 false on empty" "true"
|
||||
check 12 "lookup/2 not_found on empty" "true"
|
||||
check 13 "actors/1 [] on empty" "true"
|
||||
check 14 "Create{Person} registers actor" "true"
|
||||
check 15 "Profile carries type/name/created" "true"
|
||||
check 16 "Create{Service} registers actor" "true"
|
||||
check 17 "Create{Group} registers actor" "true"
|
||||
check 18 "Create{Note} pass-through" "true"
|
||||
check 19 "Duplicate Create no-overwrite" "true"
|
||||
check 20 "Two actors side by side" "true"
|
||||
check 21 "Update merges new fields" "true"
|
||||
check 22 "Update last-write-wins per key" "true"
|
||||
check 23 "Update unknown actor pass-through" "true"
|
||||
check 24 "Move records :moved_to" "true"
|
||||
check 25 "fold_fn/0 is fun/2" "true"
|
||||
check 26 "Activity sans :actor pass-through" "true"
|
||||
check 27 "public_keys captured at Create" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/actor_state_pure.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
138
next/tests/auto_accept.sh
Executable file
138
next/tests/auto_accept.sh
Executable file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/auto_accept.sh — m2 Step 6c test.
|
||||
#
|
||||
# Per design §13.2 the v2 Follow policy is open-world: every
|
||||
# successfully-ingested Follow triggers an Accept publish from the
|
||||
# target actor. Enabled per-Cfg via {auto_accept_follows, true};
|
||||
# off by default so manual-moderation deployments can opt out.
|
||||
|
||||
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
|
||||
|
||||
# Alice is on this kernel (target). Bob is the peer (signs Follow
|
||||
# with BobKS). Alice's outbox projection is `followers` so when
|
||||
# alice publishes the Accept, it folds through follower_graph too —
|
||||
# both sides of the relationship update without any test scaffolding.
|
||||
SETUP='AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], FollowEnv = outbox:construct(follow, bob, 1, alice), SignedFollow = outbox:sign(FollowEnv, BKS), Body = term_codec:encode(SignedFollow), projection:start_link(followers, follower_graph:new(), follower_graph:fold_fn()), nx_kernel:start_link(alice, AKS, AAS), nx_kernel:with_projections_for(alice, [followers]), Cfg = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}, {auto_accept_follows, true}], InboxPath = <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>,'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/projection.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
|
||||
;; auto_accept on: Follow ingestion advances alice's outbox tip (Accept published)
|
||||
(epoch 20)
|
||||
(eval "(erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), nx_kernel:log_tip_for(alice)\")")
|
||||
|
||||
;; auto_accept on: alice's outbox entry is an Accept activity
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), {ok, L} = nx_kernel:log_state_for(alice), [E] = log:entries(L), envelope:get_field(type, E) =:= {ok, accept}\") :name)")
|
||||
|
||||
;; auto_accept on: follower_graph state converges to full Follow relationship
|
||||
;; (alice.followers = [bob], bob.following = [alice]) after both inbox + outbox
|
||||
;; projections fold through followers.
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), S = projection:query(followers), {follower_graph:followers(alice, S), follower_graph:following(bob, S)} =:= {[bob], [alice]}\") :name)")
|
||||
|
||||
;; auto_accept on: pendings cleared after the Accept fold
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), S = projection:query(followers), {follower_graph:pending_inbound(alice, S), follower_graph:pending_outbound(bob, S)} =:= {[], []}\") :name)")
|
||||
|
||||
;; auto_accept off (default): no outbox publish; outbox tip stays 0
|
||||
(epoch 24)
|
||||
(eval "(erlang-eval-ast \"${SETUP} CfgOff = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, CfgOff), nx_kernel:log_tip_for(alice)\")")
|
||||
|
||||
;; auto_accept off: pending_inbound still gets populated (Step 6b path)
|
||||
;; but no Accept fired, so alice.followers stays empty.
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} CfgOff = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, CfgOff), S = projection:query(followers), {follower_graph:pending_inbound(alice, S), follower_graph:followers(alice, S)} =:= {[bob], []}\") :name)")
|
||||
|
||||
;; Non-Follow activity (Create{Note}) with auto_accept on: outbox stays empty
|
||||
(epoch 26)
|
||||
(eval "(erlang-eval-ast \"${SETUP} NoteEnv = outbox:construct(create, bob, 2, [{type, note}, {content, hi}]), SignedNote = outbox:sign(NoteEnv, BKS), NoteBody = term_codec:encode(SignedNote), Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, NoteBody}], http_server:route(Req, Cfg), nx_kernel:log_tip_for(alice)\")")
|
||||
|
||||
;; Bad-sig Follow ingestion with auto_accept on: no Accept publish (short-circuit)
|
||||
(epoch 27)
|
||||
(eval "(erlang-eval-ast \"${SETUP} EvilK = <<9,9,9,9>>, EvilAS = [{public_keys,[[{id,k1},{created,0},{value,EvilK}]]}], EvilCfg = [{peer_as, [{bob, EvilAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}, {auto_accept_follows, true}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, EvilCfg), nx_kernel:log_tip_for(alice)\")")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 900 "$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 11 "http_server loaded" "http_server"
|
||||
check 20 "auto_accept on: outbox tip = 1" "1"
|
||||
check 21 "outbox entry is an Accept" "true"
|
||||
check 22 "graph converges to full Follow" "true"
|
||||
check 23 "pendings cleared after Accept" "true"
|
||||
check 24 "auto_accept off: outbox tip = 0" "0"
|
||||
check 25 "auto_accept off: pending only" "true"
|
||||
check 26 "non-Follow ingestion: no Accept" "0"
|
||||
check 27 "bad-sig short-circuits Accept" "0"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/auto_accept.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
170
next/tests/backfill.sh
Executable file
170
next/tests/backfill.sh
Executable file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/backfill.sh — m2 Step 9a test.
|
||||
#
|
||||
# Backfill mode slicing per design §13.3. Given an outbox log +
|
||||
# a mode (none / last_n / last_t / full / since_cid), backfill:slice
|
||||
# returns the activity list to send to a new follower as backfill.
|
||||
|
||||
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
|
||||
|
||||
# Five activities published at :published = 1, 2, 3, 4, 5
|
||||
SETUP='Act1 = [{id, <<1>>}, {type, note}, {actor, alice}, {published, 1}], Act2 = [{id, <<2>>}, {type, note}, {actor, alice}, {published, 2}], Act3 = [{id, <<3>>}, {type, note}, {actor, alice}, {published, 3}], Act4 = [{id, <<4>>}, {type, note}, {actor, alice}, {published, 4}], Act5 = [{id, <<5>>}, {type, note}, {actor, alice}, {published, 5}], {ok, L0} = log:open(alice, <<98,97,115,101>>), {ok, L1, _} = log:append(L0, Act1), {ok, L2, _} = log:append(L1, Act2), {ok, L3, _} = log:append(L2, Act3), {ok, L4, _} = log:append(L3, Act4), {ok, L5, _} = log:append(L4, Act5),'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/backfill.erl\")) :name)")
|
||||
|
||||
;; none mode -> []
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice(none, L5) =:= []\") :name)")
|
||||
|
||||
;; full mode -> all 5
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice(full, L5) =:= [Act1, Act2, Act3, Act4, Act5]\") :name)")
|
||||
|
||||
;; last_n with N=2 -> tail 2 (Act4, Act5)
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice({last_n, 2}, L5) =:= [Act4, Act5]\") :name)")
|
||||
|
||||
;; last_n with N > total -> all entries
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice({last_n, 100}, L5) =:= [Act1, Act2, Act3, Act4, Act5]\") :name)")
|
||||
|
||||
;; last_n with N = 0 -> []
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice({last_n, 0}, L5) =:= []\") :name)")
|
||||
|
||||
;; last_t with T=2, Now=5 -> activities with :published > 3 and <= 5 -> [Act4, Act5]
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice({last_t, 2, fun() -> 5 end}, L5) =:= [Act4, Act5]\") :name)")
|
||||
|
||||
;; last_t with T=10, Now=5 -> covers everything from :published > -5 -> all 5
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice({last_t, 10, fun() -> 5 end}, L5) =:= [Act1, Act2, Act3, Act4, Act5]\") :name)")
|
||||
|
||||
;; last_t with T=0, Now=5 -> only entries at exactly Now (>0, <=5) — really [] because window is (5..5]
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice({last_t, 0, fun() -> 5 end}, L5) =:= []\") :name)")
|
||||
|
||||
;; since_cid with the 2nd cid -> entries AFTER it (Act3..Act5)
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice({since_cid, <<2>>}, L5) =:= [Act3, Act4, Act5]\") :name)")
|
||||
|
||||
;; since_cid with last cid -> []
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice({since_cid, <<5>>}, L5) =:= []\") :name)")
|
||||
|
||||
;; since_cid with unknown cid -> []
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice({since_cid, <<99>>}, L5) =:= []\") :name)")
|
||||
|
||||
;; wrap_backfill adds {backfilled, true} to each entry
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Wrapped = backfill:slice({last_n, 1}, L5, true), [Act5W] = Wrapped, envelope:get_field(backfilled, Act5W) =:= {ok, true}\") :name)")
|
||||
|
||||
;; Wrapped entries preserve :id
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Wrapped = backfill:slice({last_n, 1}, L5, true), [Act5W] = Wrapped, envelope:get_field(id, Act5W) =:= {ok, <<5>>}\") :name)")
|
||||
|
||||
;; parse_mode: nil / none / atoms
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"{backfill:parse_mode(nil), backfill:parse_mode(none), backfill:parse_mode(full)} =:= {none, none, full}\") :name)")
|
||||
|
||||
;; parse_mode: tuple shapes pass through
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"backfill:parse_mode({last_n, 3}) =:= {last_n, 3}\") :name)")
|
||||
|
||||
;; parse_mode: proplist with mode + limit
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"backfill:parse_mode([{mode, last_n}, {limit, 50}]) =:= {last_n, 50}\") :name)")
|
||||
|
||||
;; parse_mode: proplist with mode = full
|
||||
(epoch 26)
|
||||
(eval "(get (erlang-eval-ast \"backfill:parse_mode([{mode, full}]) =:= full\") :name)")
|
||||
|
||||
;; parse_mode: unknown -> none
|
||||
(epoch 27)
|
||||
(eval "(get (erlang-eval-ast \"backfill:parse_mode([{mode, mystery}]) =:= none\") :name)")
|
||||
|
||||
;; Unknown mode -> []
|
||||
(epoch 28)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} backfill:slice(garbage, L5) =:= []\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 280 "$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 4 "backfill module loaded" "backfill"
|
||||
check 10 "none mode -> []" "true"
|
||||
check 11 "full mode -> all 5" "true"
|
||||
check 12 "last_n N=2 -> tail 2" "true"
|
||||
check 13 "last_n N=100 -> all 5" "true"
|
||||
check 14 "last_n N=0 -> []" "true"
|
||||
check 15 "last_t T=2 Now=5 -> 4,5" "true"
|
||||
check 16 "last_t T=10 Now=5 -> all 5" "true"
|
||||
check 17 "last_t T=0 Now=5 -> []" "true"
|
||||
check 18 "since_cid mid -> tail 3" "true"
|
||||
check 19 "since_cid last -> []" "true"
|
||||
check 20 "since_cid unknown -> []" "true"
|
||||
check 21 "wrap adds backfilled=true" "true"
|
||||
check 22 "wrap preserves :id" "true"
|
||||
check 23 "parse_mode atoms" "true"
|
||||
check 24 "parse_mode tuple passthrough" "true"
|
||||
check 25 "parse_mode proplist last_n" "true"
|
||||
check 26 "parse_mode proplist full" "true"
|
||||
check 27 "parse_mode unknown -> none" "true"
|
||||
check 28 "unknown slice mode -> []" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/backfill.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
121
next/tests/backfill_drain.sh
Executable file
121
next/tests/backfill_drain.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/backfill_drain.sh — m2 Step 9c test.
|
||||
#
|
||||
# Auto-Accept on Follow ingestion can now also drain the receiving
|
||||
# actor's outbox into the new follower's delivery_worker queue per
|
||||
# the Follow's :backfill spec. Gated by Cfg :backfill_enabled.
|
||||
|
||||
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
|
||||
|
||||
# Alice is the target (on this kernel). Bob is the peer publishing the
|
||||
# Follow. Three notes pre-published to alice's outbox before bob's
|
||||
# Follow lands; the Follow asks for last_n=2 backfill.
|
||||
SETUP='AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], FollowReq = [{type, follow}, {object, alice}], FollowReqBF = [{type, follow}, {object, alice}, {backfill, {last_n, 2}}], FollowEnvBF = outbox:construct(follow, bob, 1, alice), FollowSignedNoBF = outbox:sign(FollowEnvBF, BKS), FollowSignedBF = outbox:sign(FollowEnvBF ++ [{backfill, {last_n, 2}}], BKS), BodyBF = term_codec:encode(FollowSignedBF), BodyNoBF = term_codec:encode(FollowSignedNoBF), nx_kernel:start_link(alice, AKS, AAS), delivery_worker:start_link(bob), InboxPath = <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>,'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/backfill.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery_worker.erl\")) :name)")
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
|
||||
;; backfill_enabled + Follow with :backfill last_n=2 + 3 pre-published
|
||||
;; notes -> bob's delivery_worker has 2 pending entries after Follow lands
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} N1 = [{type, note}, {object, [{content, hi1}]}], N2 = [{type, note}, {object, [{content, hi2}]}], N3 = [{type, note}, {object, [{content, hi3}]}], nx_kernel:publish_to(alice, N1), nx_kernel:publish_to(alice, N2), nx_kernel:publish_to(alice, N3), Cfg = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {auto_accept_follows, true}, {backfill_enabled, true}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, BodyBF}], http_server:route(Req, Cfg), length(delivery_worker:pending_srv(bob)) =:= 2\") :name)")
|
||||
|
||||
;; Each backfilled entry carries {backfilled, true}
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} N1 = [{type, note}, {object, [{content, hi}]}], nx_kernel:publish_to(alice, N1), Cfg = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {auto_accept_follows, true}, {backfill_enabled, true}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, BodyBF}], http_server:route(Req, Cfg), [E | _] = delivery_worker:pending_srv(bob), envelope:get_field(backfilled, E) =:= {ok, true}\") :name)")
|
||||
|
||||
;; No :backfill_enabled flag -> no backfill drain even with :backfill in Follow
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} N1 = [{type, note}, {object, [{content, hi}]}], nx_kernel:publish_to(alice, N1), Cfg = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {auto_accept_follows, true}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, BodyBF}], http_server:route(Req, Cfg), delivery_worker:pending_srv(bob) =:= []\") :name)")
|
||||
|
||||
;; Follow without :backfill field -> no backfill drain (even with the flag)
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} N1 = [{type, note}, {object, [{content, hi}]}], nx_kernel:publish_to(alice, N1), Cfg = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {auto_accept_follows, true}, {backfill_enabled, true}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, BodyNoBF}], http_server:route(Req, Cfg), delivery_worker:pending_srv(bob) =:= []\") :name)")
|
||||
|
||||
;; Missing delivery_worker for the peer -> silently skipped (no enqueue, no crash)
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), FollowEnvBF = outbox:construct(follow, bob, 1, alice), FollowSignedBF = outbox:sign(FollowEnvBF ++ [{backfill, {last_n, 2}}], BKS), BodyBF = term_codec:encode(FollowSignedBF), N1 = [{type, note}, {object, [{content, hi}]}], nx_kernel:publish_to(alice, N1), Cfg = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {auto_accept_follows, true}, {backfill_enabled, true}], InboxPath = <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>, Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, BodyBF}], case http_server:route(Req, Cfg) of [{status, 202}, _, _] -> true; _ -> false end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 900 "$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 13 "http_server loaded" "http_server"
|
||||
check 20 "Follow w/ backfill -> 2 enqueued" "true"
|
||||
check 21 "backfilled marker on entries" "true"
|
||||
check 22 "no flag -> no backfill" "true"
|
||||
check 23 "no :backfill field -> no drain" "true"
|
||||
check 24 "missing worker -> 202 (skip)" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/backfill_drain.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
@@ -79,7 +79,7 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(get (erlang-eval-ast \"R = bootstrap:read_genesis(), {ok, S1} = bootstrap:load_genesis(R), {ok, S2} = bootstrap:load_genesis(R), cid:to_string(S1) =:= cid:to_string(S2)\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 590 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
@@ -106,8 +106,8 @@ check 10 "strip suffix create.sx -> create" "true"
|
||||
check 11 "strip suffix hello unchanged" "true"
|
||||
check 12 "strip suffix .sx -> empty" "true"
|
||||
check 13 "load_genesis rejects bad shape" "ok"
|
||||
check 20 "loaded activity_types count = 3" "3"
|
||||
check 21 "loaded object_types count = 10" "10"
|
||||
check 20 "loaded activity_types count = 8" "8"
|
||||
check 21 "loaded object_types count = 13" "13"
|
||||
check 22 "loaded projections count = 7" "7"
|
||||
check 23 "loaded validators count = 3" "3"
|
||||
check 24 "loaded codecs count = 3" "3"
|
||||
|
||||
@@ -75,7 +75,7 @@ cat > "$TMPFILE" <<EPOCHS
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} case registry:lookup(validators, <<101,110,118,101,108,111,112,101,45,115,104,97,112,101>>) of {ok, B} -> is_binary(B); _ -> false end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 600 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
@@ -99,9 +99,9 @@ check() {
|
||||
check 2 "gen_server loaded" "gen_server"
|
||||
check 3 "registry loaded" "registry"
|
||||
check 4 "bootstrap loaded" "bootstrap"
|
||||
check 10 "populate returns total 31" "31"
|
||||
check 20 "activity_types count = 3" "3"
|
||||
check 21 "object_types count = 10" "10"
|
||||
check 10 "populate returns total 39" "39"
|
||||
check 20 "activity_types count = 8" "8"
|
||||
check 21 "object_types count = 13" "13"
|
||||
check 22 "projections count = 7" "7"
|
||||
check 23 "validators count = 3" "3"
|
||||
check 24 "codecs count = 3" "3"
|
||||
|
||||
@@ -102,8 +102,8 @@ check 10 "sections/0 length" "7"
|
||||
check 11 "ends_with_sx create.sx" "true"
|
||||
check 12 "ends_with_sx hello" "false"
|
||||
check 13 "ends_with_sx empty" "false"
|
||||
check 20 "section activity_types count" "3"
|
||||
check 21 "section object_types count" "10"
|
||||
check 20 "section activity_types count" "8"
|
||||
check 21 "section object_types count" "13"
|
||||
check 22 "section projections count" "7"
|
||||
check 23 "section validators count" "3"
|
||||
check 24 "section codecs count" "3"
|
||||
@@ -111,7 +111,7 @@ check 25 "section sig_suites count" "2"
|
||||
check 26 "section audience count" "3"
|
||||
check 30 "read_genesis returns 7 sections" "7"
|
||||
check 31 "first section name" "activity_types"
|
||||
check 32 "first section entry count" "3"
|
||||
check 32 "first section entry count" "8"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
|
||||
@@ -54,6 +54,12 @@ cat > "$TMPFILE" <<EPOCHS
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/bootstrap.erl\")) :name)")
|
||||
;; outbox:publish computes a delivery set via follower_graph + delivery
|
||||
;; (compute_delivery_set/3) — load both so the publish path resolves.
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
|
||||
;; bootstrap:start returns a Pid
|
||||
(epoch 20)
|
||||
@@ -92,7 +98,7 @@ cat > "$TMPFILE" <<EPOCHS
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} case registry:lookup(activity_types, <<99,114,101,97,116,101>>) of {ok, _} -> ok; _ -> bad end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 600 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
@@ -115,10 +121,10 @@ check() {
|
||||
|
||||
check 10 "bootstrap module loaded" "bootstrap"
|
||||
check 20 "whereis(nx_kernel) is Pid" "true"
|
||||
check 21 "activity_types count = 3" "3"
|
||||
check 22 "object_types count = 10" "10"
|
||||
check 21 "activity_types count = 8" "8"
|
||||
check 22 "object_types count = 13" "13"
|
||||
check 23 "projections count = 7" "7"
|
||||
check 24 "total entries = 31" "31"
|
||||
check 24 "total entries = 39" "39"
|
||||
check 25 "fresh log_tip = 0" "0"
|
||||
check 26 "publish advances tip to 1" "1"
|
||||
check 27 "actor_id = alice" "true"
|
||||
|
||||
99
next/tests/define_trigger.sh
Executable file
99
next/tests/define_trigger.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/define_trigger.sh — fed-sx triggers Phase 1 (verb).
|
||||
#
|
||||
# The DefineTrigger genesis verb
|
||||
# (next/genesis/activity-types/define_trigger.sx) binds an activity-type
|
||||
# to a flow. This suite confirms it parses with the expected
|
||||
# DefineActivity head + :name, that its :schema accepts a well-formed
|
||||
# binding and rejects malformed ones, and that a DefineTrigger envelope
|
||||
# round-trips through term_codec.
|
||||
|
||||
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
|
||||
|
||||
SCH='(eval-expr (get (apply dict (rest (parse (file-read \"next/genesis/activity-types/define_trigger.sx\")))) :schema))'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
|
||||
;; ── parse / shape ──────────────────────────────────────────
|
||||
(epoch 10)
|
||||
(eval "(first (parse (file-read \"next/genesis/activity-types/define_trigger.sx\")))")
|
||||
(epoch 11)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/activity-types/define_trigger.sx\")))) :name)")
|
||||
|
||||
;; ── schema accept / reject ─────────────────────────────────
|
||||
;; valid binding: string :activity-type + :flow-name -> true
|
||||
(epoch 20)
|
||||
(eval "(define sch ${SCH}) (sch (dict :object (dict :activity-type \"Create\" :flow-name \"blog-publish-digest\")))")
|
||||
;; reject: missing :activity-type -> false
|
||||
(epoch 21)
|
||||
(eval "(define sch ${SCH}) (sch (dict :object (dict :flow-name \"f\")))")
|
||||
;; reject: missing :flow-name -> false
|
||||
(epoch 22)
|
||||
(eval "(define sch ${SCH}) (sch (dict :object (dict :activity-type \"Create\")))")
|
||||
|
||||
;; ── envelope round-trip through term_codec ─────────────────
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"A = [{type, define_trigger}, {actor, alice}, {object, [{activity_type, create}, {flow_name, blog_publish_digest}]}], {ok, D, _} = term_codec:decode(term_codec:encode(A)), D =:= A\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 180 "$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 10 "define_trigger.sx head form" "DefineActivity"
|
||||
check 11 "define_trigger.sx name" "DefineTrigger"
|
||||
check 20 "schema accepts valid binding" "true"
|
||||
check 21 "schema rejects missing type" "false"
|
||||
check 22 "schema rejects missing flow-name" "false"
|
||||
check 30 "DefineTrigger envelope round-trips" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/define_trigger.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
110
next/tests/define_type.sh
Executable file
110
next/tests/define_type.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/define_type.sh — host-type federation Phase 1 acceptance.
|
||||
#
|
||||
# The DefineType genesis verb (next/genesis/activity-types/define_type.sx)
|
||||
# declares a refinement type. This suite confirms:
|
||||
# - the file parses with the expected DefineActivity head + :name
|
||||
# - the :schema predicate accepts a well-formed type-definition
|
||||
# activity and rejects malformed ones (missing :name, non-list
|
||||
# :fields)
|
||||
# - a DefineType envelope round-trips through term_codec
|
||||
#
|
||||
# Schema bodies are SX source; we eval them with `eval-expr` and call
|
||||
# the resulting lambda directly (note: `apply` does not spread into
|
||||
# SX lambdas in this kernel, and keyword-getters are not callable —
|
||||
# the schema uses nested `get`). 7 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
|
||||
|
||||
# The schema fn, evaluated from the genesis file into a lambda.
|
||||
SCH='(eval-expr (get (apply dict (rest (parse (file-read \"next/genesis/activity-types/define_type.sx\")))) :schema))'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
|
||||
;; ── parse / shape ──────────────────────────────────────────
|
||||
(epoch 10)
|
||||
(eval "(first (parse (file-read \"next/genesis/activity-types/define_type.sx\")))")
|
||||
(epoch 11)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/activity-types/define_type.sx\")))) :name)")
|
||||
|
||||
;; ── schema accept / reject ─────────────────────────────────
|
||||
;; valid: :object with string :name and list :fields -> true
|
||||
(epoch 20)
|
||||
(eval "(define sch ${SCH}) (sch (dict :object (dict :name \"Post\" :fields (list))))")
|
||||
;; valid: :fields omitted (optional) -> true
|
||||
(epoch 21)
|
||||
(eval "(define sch ${SCH}) (sch (dict :object (dict :name \"Post\")))")
|
||||
;; reject: missing :name -> false
|
||||
(epoch 22)
|
||||
(eval "(define sch ${SCH}) (sch (dict :object (dict :fields (list))))")
|
||||
;; reject: :fields present but not a list -> false
|
||||
(epoch 23)
|
||||
(eval "(define sch ${SCH}) (sch (dict :object (dict :name \"Post\" :fields \"notalist\")))")
|
||||
|
||||
;; ── envelope round-trip through term_codec ─────────────────
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"A = [{type, define_type}, {actor, alice}, {object, [{name, <<80,111,115,116>>}, {instance_type, <<78,111,116,101>>}]}], {ok, D, _} = term_codec:decode(term_codec:encode(A)), D =:= A\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 180 "$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 10 "define_type.sx head form" "DefineActivity"
|
||||
check 11 "define_type.sx name" "DefineType"
|
||||
check 20 "schema accepts valid type def" "true"
|
||||
check 21 "schema accepts omitted :fields" "true"
|
||||
check 22 "schema rejects missing :name" "false"
|
||||
check 23 "schema rejects non-list :fields" "false"
|
||||
check 30 "DefineType envelope round-trips" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/define_type.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
120
next/tests/delivery_dispatch.sh
Executable file
120
next/tests/delivery_dispatch.sh
Executable file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/delivery_dispatch.sh — m2 Step 8d test.
|
||||
#
|
||||
# After a successful outbox:publish, each ActorId in the
|
||||
# Result's :delivery_set is enqueued onto the matching
|
||||
# delivery_worker (registered under the peer-id atom). Only
|
||||
# happens when Context carries {dispatch_deliveries, true} —
|
||||
# back-compat with every M1 outbox caller that doesn't dispatch.
|
||||
|
||||
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
|
||||
|
||||
# Alice publishes to bob (and carol). Each peer worker is registered
|
||||
# under its peer-id atom; the outbox dispatches via the workers'
|
||||
# enqueue path. dispatch_fn left undefined so the workers just
|
||||
# accumulate pending without firing HTTP.
|
||||
SETUP='K = <<1,2,3,4>>, KS = [{key_id,k1},{algorithm,ed25519},{value,K}], AS = [{public_keys,[[{id,k1},{created,0},{value,K}]]}], {ok, L0} = log:open(alice, <<98,97,115,101>>), Ctx = [{actor_id,alice},{published,1},{key_spec,KS},{actor_state,AS},{log,L0},{projections,[]},{dispatch_deliveries, true}], CtxNoDispatch = [{actor_id,alice},{published,1},{key_spec,KS},{actor_state,AS},{log,L0},{projections,[]}], ReqToBob = [{type, note}, {object, [{content, hi}]}, {to, bob}], ReqToTwo = [{type, note}, {object, [{content, hi}]}, {to, [bob, carol]}],'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery_worker.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
|
||||
;; Bob's worker registered + publish to bob -> bob's pending has 1 entry
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} delivery_worker:start_link(bob), {ok, _, _} = outbox:publish(ReqToBob, Ctx), case delivery_worker:pending_srv(bob) of [_] -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; Carol's worker registered, publish to [bob, carol] -> both queues get 1 entry
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} delivery_worker:start_link(bob), delivery_worker:start_link(carol), {ok, _, _} = outbox:publish(ReqToTwo, Ctx), {length(delivery_worker:pending_srv(bob)), length(delivery_worker:pending_srv(carol))} =:= {1, 1}\") :name)")
|
||||
|
||||
;; Missing worker for an actor in delivery_set -> silently skipped (no error)
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} delivery_worker:start_link(bob), case outbox:publish(ReqToTwo, Ctx) of {ok, R, _} -> envelope:get_field(delivery_set, R) =:= {ok, [bob, carol]}; _ -> false end andalso length(delivery_worker:pending_srv(bob)) =:= 1\") :name)")
|
||||
|
||||
;; No :dispatch_deliveries flag -> no enqueue happens (back-compat)
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} delivery_worker:start_link(bob), {ok, _, _} = outbox:publish(ReqToBob, CtxNoDispatch), delivery_worker:pending_srv(bob) =:= []\") :name)")
|
||||
|
||||
;; Two publishes -> bob's queue has 2 entries (FIFO append)
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} delivery_worker:start_link(bob), {ok, _, NewLog} = outbox:publish(ReqToBob, Ctx), Ctx2 = [{actor_id,alice},{published,2},{key_spec,KS},{actor_state,AS},{log,NewLog},{projections,[]},{dispatch_deliveries, true}], {ok, _, _} = outbox:publish(ReqToBob, Ctx2), length(delivery_worker:pending_srv(bob)) =:= 2\") :name)")
|
||||
|
||||
;; Empty delivery_set -> no dispatch (no :to, no :cc)
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} delivery_worker:start_link(bob), ReqNoAud = [{type, note}, {object, [{content, hi}]}], {ok, _, _} = outbox:publish(ReqNoAud, Ctx), delivery_worker:pending_srv(bob) =:= []\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 540 "$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 9 "outbox module loaded" "outbox"
|
||||
check 20 "single peer enqueued" "ok"
|
||||
check 21 "two peers both enqueued" "true"
|
||||
check 22 "missing worker silently skip" "true"
|
||||
check 23 "no dispatch_deliveries no-op" "true"
|
||||
check 24 "two publishes FIFO append" "true"
|
||||
check 25 "empty delivery_set -> no-op" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/delivery_dispatch.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
126
next/tests/delivery_retry.sh
Executable file
126
next/tests/delivery_retry.sh
Executable file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/delivery_retry.sh — m2 Step 8b-pure test.
|
||||
#
|
||||
# Pure-functional retry-time bookkeeping for the delivery worker.
|
||||
# record_failure bumps the attempt counter and computes the next
|
||||
# retry time per backoff_for. record_success clears state for a
|
||||
# cid. next_due returns cids whose retry time has passed.
|
||||
#
|
||||
# Real timer wiring (erlang:send_after self-cast) is Step 8b-timer
|
||||
# once substrate support lands.
|
||||
|
||||
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
|
||||
|
||||
SETUP='Act1 = [{id, <<1>>}, {type, note}, {actor, alice}], Act2 = [{id, <<2>>}, {type, note}, {actor, alice}],'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery_worker.erl\")) :name)")
|
||||
|
||||
;; Fresh state: no attempts, no next_retry, no dead_letter
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = delivery_worker:new(bob), {delivery_worker:attempts_for(<<1>>, S), delivery_worker:next_retry_at(<<1>>, S), delivery_worker:dead_letter_list(S)} =:= {0, undefined, []}\") :name)")
|
||||
|
||||
;; record_failure bumps the attempt counter
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = delivery_worker:enqueue_pure(bob, Act1, delivery_worker:new(bob)), S1 = delivery_worker:record_failure_pure(<<1>>, 1000, S0), delivery_worker:attempts_for(<<1>>, S1) =:= 1\") :name)")
|
||||
|
||||
;; record_failure sets next_retry_at = Now + backoff(1) = Now + 30
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = delivery_worker:enqueue_pure(bob, Act1, delivery_worker:new(bob)), S1 = delivery_worker:record_failure_pure(<<1>>, 1000, S0), delivery_worker:next_retry_at(<<1>>, S1) =:= 1030\") :name)")
|
||||
|
||||
;; Second failure -> attempts=2, NextRetryAt = Now+300
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = delivery_worker:enqueue_pure(bob, Act1, delivery_worker:new(bob)), S1 = delivery_worker:record_failure_pure(<<1>>, 1000, S0), S2 = delivery_worker:record_failure_pure(<<1>>, 2000, S1), {delivery_worker:attempts_for(<<1>>, S2), delivery_worker:next_retry_at(<<1>>, S2)} =:= {2, 2300}\") :name)")
|
||||
|
||||
;; record_success clears attempts + next_retry for the cid
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = delivery_worker:enqueue_pure(bob, Act1, delivery_worker:new(bob)), S1 = delivery_worker:record_failure_pure(<<1>>, 1000, S0), S2 = delivery_worker:record_success_pure(<<1>>, S1), {delivery_worker:attempts_for(<<1>>, S2), delivery_worker:next_retry_at(<<1>>, S2)} =:= {0, undefined}\") :name)")
|
||||
|
||||
;; next_due returns Cids whose retry time has passed
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = delivery_worker:enqueue_pure(bob, Act1, delivery_worker:new(bob)), S1 = delivery_worker:record_failure_pure(<<1>>, 1000, S0), delivery_worker:next_due_pure(1030, S1) =:= [<<1>>]\") :name)")
|
||||
|
||||
;; next_due returns [] before retry time
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = delivery_worker:enqueue_pure(bob, Act1, delivery_worker:new(bob)), S1 = delivery_worker:record_failure_pure(<<1>>, 1000, S0), delivery_worker:next_due_pure(1020, S1) =:= []\") :name)")
|
||||
|
||||
;; 6th failure -> dead_letter; activity moves out of :pending
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} F = fun(S) -> delivery_worker:record_failure_pure(<<1>>, 1000, S) end, S0 = delivery_worker:enqueue_pure(bob, Act1, delivery_worker:new(bob)), S6 = F(F(F(F(F(F(S0)))))), {delivery_worker:dead_letter_list(S6), delivery_worker:pending(S6)} =:= {[Act1], []}\") :name)")
|
||||
|
||||
;; Dead-lettered cid is no longer in next_retry
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} F = fun(S) -> delivery_worker:record_failure_pure(<<1>>, 1000, S) end, S0 = delivery_worker:enqueue_pure(bob, Act1, delivery_worker:new(bob)), S6 = F(F(F(F(F(F(S0)))))), delivery_worker:next_retry_at(<<1>>, S6) =:= undefined\") :name)")
|
||||
|
||||
;; Two cids: success on one doesn't disturb the other's retry state
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = delivery_worker:enqueue_pure(bob, Act1, delivery_worker:enqueue_pure(bob, Act2, delivery_worker:new(bob))), S1 = delivery_worker:record_failure_pure(<<1>>, 1000, S0), S2 = delivery_worker:record_failure_pure(<<2>>, 1000, S1), S3 = delivery_worker:record_success_pure(<<1>>, S2), delivery_worker:next_retry_at(<<2>>, S3) =:= 1030\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 "module loaded" "delivery_worker"
|
||||
check 10 "fresh state empty" "true"
|
||||
check 11 "record_failure bumps attempts" "true"
|
||||
check 12 "record_failure sets next_retry_at" "true"
|
||||
check 13 "second failure: slot 2 = +300" "true"
|
||||
check 14 "record_success clears state" "true"
|
||||
check 15 "next_due returns due cids" "true"
|
||||
check 16 "next_due empty before due" "true"
|
||||
check 17 "6th failure -> dead_letter" "true"
|
||||
check 18 "dead-lettered cid out of retry" "true"
|
||||
check 19 "success on one preserves other" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/delivery_retry.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
131
next/tests/delivery_retry_timer.sh
Executable file
131
next/tests/delivery_retry_timer.sh
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/delivery_retry_timer.sh — m2 Step 8b-timer.
|
||||
#
|
||||
# Live timer wiring on the delivery_worker gen_server. The pure
|
||||
# bookkeeping is covered by delivery_retry.sh — this suite proves the
|
||||
# erlang:send_after / cancel_timer wiring fires retries from the
|
||||
# scheduler's logical clock without anyone calling drain by hand.
|
||||
#
|
||||
# Substrate dependency: erlang:send_after/3 + cancel_timer/1 +
|
||||
# monotonic_time/0,1 — landed via cherry-pick from loops/erlang
|
||||
# (commits 3709460d / 98b0104c / 779e53b2 on this branch).
|
||||
#
|
||||
# Test discipline: every test cancels its leftover timer before
|
||||
# returning. If we don't, the scheduler keeps the run loop alive
|
||||
# advancing time through the full backoff chain (30s → 5m → 30m →
|
||||
# 6h → 24h), and each tick costs ~10s of wall time inside the
|
||||
# Erlang-on-SX VM. Canceling the trailing timer is the difference
|
||||
# between a 25s test and a 60s+ test.
|
||||
|
||||
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
|
||||
|
||||
# A canned activity with cid <<1,2,3>>.
|
||||
SETUP='Act = [{id, <<1,2,3>>}, {type, note}, {actor, alice}], FailFn = fun(_) -> {error, transient} end,'
|
||||
|
||||
# Convenience: cancel any leftover timer for cid <<1,2,3>> on Peer.
|
||||
# Prevents the scheduler from grinding through 30s/5m/30m/6h/24h of
|
||||
# retries between epochs.
|
||||
CANCEL='CancelLeftover = fun(Peer) -> SS = delivery_worker:state_srv(Peer), case delivery_worker:timer_ref_for(<<1,2,3>>, SS) of undefined -> ok; LRef -> erlang:cancel_timer(LRef), ok end 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!)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery_worker.erl\")) :name)")
|
||||
|
||||
;; T1 — a failing flush schedules a retry timer. timer_ref_for
|
||||
;; returns a live Ref (not undefined). Then cancel before
|
||||
;; returning so the scheduler doesn't grind the full backoff
|
||||
;; chain trying to retry.
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP}${CANCEL} delivery_worker:start_link(bob, FailFn), delivery_worker:enqueue(bob, Act), {ok, [], [<<1,2,3>>]} = delivery_worker:flush(bob), S = delivery_worker:state_srv(bob), Ref = delivery_worker:timer_ref_for(<<1,2,3>>, S), Result = is_reference(Ref), CancelLeftover(bob), Result\") :name)")
|
||||
|
||||
;; T2 — initial flush bumps the attempt counter to 1; next_retry_at
|
||||
;; gets set; cancel the timer before returning.
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP}${CANCEL} delivery_worker:start_link(bob, FailFn), delivery_worker:enqueue(bob, Act), delivery_worker:flush(bob), S = delivery_worker:state_srv(bob), Result = delivery_worker:attempts_for(<<1,2,3>>, S) =:= 1, CancelLeftover(bob), Result\") :name)")
|
||||
|
||||
;; T3 — advancing the logical clock past the 30s backoff fires the
|
||||
;; timer; handle_info({retry, Cid}) bumps attempts to 2 and arms
|
||||
;; the next slot (backoff(2)=300s). Then cancel the new timer.
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP}${CANCEL} delivery_worker:start_link(bob, FailFn), delivery_worker:enqueue(bob, Act), delivery_worker:flush(bob), receive after 31000 -> ok end, S = delivery_worker:state_srv(bob), Result = delivery_worker:attempts_for(<<1,2,3>>, S) =:= 2, CancelLeftover(bob), Result\") :name)")
|
||||
|
||||
;; T4 — after the retry fires the worker has armed a fresh timer
|
||||
;; for the next backoff slot. Confirm it's a live ref, then
|
||||
;; cancel it.
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP}${CANCEL} delivery_worker:start_link(bob, FailFn), delivery_worker:enqueue(bob, Act), delivery_worker:flush(bob), receive after 31000 -> ok end, S = delivery_worker:state_srv(bob), Result = is_reference(delivery_worker:timer_ref_for(<<1,2,3>>, S)), CancelLeftover(bob), Result\") :name)")
|
||||
|
||||
;; T5 — successful retry path. Dispatch fails twice then succeeds
|
||||
;; (ets-backed counter). After two backoff slots elapse
|
||||
;; (30s, then 300s), the third attempt succeeds and
|
||||
;; record_success_pure clears the per-cid bookkeeping. No new
|
||||
;; timer is scheduled, so the scheduler terminates naturally.
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} ets:new(rt_ctr, [named_table, public]), ets:insert(rt_ctr, {n, 0}), Mixed = fun(_) -> [{n, N}] = ets:lookup(rt_ctr, n), ets:insert(rt_ctr, {n, N+1}), case N < 2 of true -> {error, transient}; false -> ok end end, delivery_worker:start_link(carol, Mixed), delivery_worker:enqueue(carol, Act), delivery_worker:flush(carol), receive after 31000 -> ok end, receive after 301000 -> ok end, S = delivery_worker:state_srv(carol), delivery_worker:pending(S) =:= [] andalso delivery_worker:attempts_for(<<1,2,3>>, S) =:= 0 andalso delivery_worker:timer_ref_for(<<1,2,3>>, S) =:= undefined\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 900 "$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 10 "T1 flush schedules a timer" "true"
|
||||
check 11 "T2 initial flush bumps attempts to 1" "true"
|
||||
check 12 "T3 timer fires; attempts=2" "true"
|
||||
check 13 "T4 retry rearms next timer" "true"
|
||||
check 14 "T5 success clears retry state" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/delivery_retry_timer.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
if [ "$VERBOSE" = "-v" ]; then
|
||||
echo "--- sx_server output ---"
|
||||
echo "$OUTPUT" | tail -40
|
||||
echo "---"
|
||||
fi
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
164
next/tests/delivery_set.sh
Executable file
164
next/tests/delivery_set.sh
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/delivery_set.sh — m2 Step 7 test.
|
||||
#
|
||||
# delivery:delivery_set/2,3 computes the audience-resolved
|
||||
# recipient list for an outbound activity. Sources are :to / :cc
|
||||
# fields plus expansion of `followers` (via follower_graph) and
|
||||
# `public` (v2 placeholder — Step 7c will populate with peer
|
||||
# instances). Self-delivery suppressed; result deduplicated.
|
||||
|
||||
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
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
|
||||
;; Empty activity -> empty delivery set
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}], []) =:= []\") :name)")
|
||||
|
||||
;; Single :to atom recipient
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, bob}], []) =:= [bob]\") :name)")
|
||||
|
||||
;; :to list of recipients
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, [bob, carol]}], []) =:= [bob, carol]\") :name)")
|
||||
|
||||
;; :cc adds to :to
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, [bob]}, {cc, [carol]}], []) =:= [bob, carol]\") :name)")
|
||||
|
||||
;; Self-delivery suppressed (alice in :to is the publisher)
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, [alice, bob]}], []) =:= [bob]\") :name)")
|
||||
|
||||
;; Duplicate recipients deduped
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, [bob, bob]}, {cc, [bob]}], []) =:= [bob]\") :name)")
|
||||
|
||||
;; :to and :cc with overlap are deduped
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, [bob, carol]}, {cc, [carol, dave]}], []) =:= [bob, carol, dave]\") :name)")
|
||||
|
||||
;; followers audience symbol -> sender's followers from follower_graph
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"Follow = [{actor, bob}, {type, follow}, {object, alice}], Accept = [{actor, alice}, {type, accept}, {object, Follow}], S = follower_graph:fold(Accept, follower_graph:fold(Follow, follower_graph:new())), delivery:delivery_set([{actor, alice}, {to, followers}], [], S) =:= [bob]\") :name)")
|
||||
|
||||
;; followers with empty follower-graph -> []
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, followers}], [], follower_graph:new()) =:= []\") :name)")
|
||||
|
||||
;; public audience symbol -> sender's followers for v2 (§13.4)
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"F = [{actor, bob}, {type, follow}, {object, alice}], A = [{actor, alice}, {type, accept}, {object, F}], S = follower_graph:fold(A, follower_graph:fold(F, follower_graph:new())), delivery:delivery_set([{actor, alice}, {to, public}], [], S) =:= [bob]\") :name)")
|
||||
|
||||
;; public with empty follower-graph -> []
|
||||
(epoch 28)
|
||||
(eval "(get (erlang-eval-ast \"delivery:delivery_set([{actor, alice}, {to, public}], [], follower_graph:new()) =:= []\") :name)")
|
||||
|
||||
;; public + followers in same audience deduped (both expand identically)
|
||||
(epoch 29)
|
||||
(eval "(get (erlang-eval-ast \"F = [{actor, bob}, {type, follow}, {object, alice}], A = [{actor, alice}, {type, accept}, {object, F}], S = follower_graph:fold(A, follower_graph:fold(F, follower_graph:new())), delivery:delivery_set([{actor, alice}, {to, [public, followers]}], [], S) =:= [bob]\") :name)")
|
||||
|
||||
;; Mixed explicit + followers, followers carry two peers
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"F1 = [{actor, bob}, {type, follow}, {object, alice}], A1 = [{actor, alice}, {type, accept}, {object, F1}], F2 = [{actor, carol}, {type, follow}, {object, alice}], A2 = [{actor, alice}, {type, accept}, {object, F2}], S = follower_graph:fold(A2, follower_graph:fold(F2, follower_graph:fold(A1, follower_graph:fold(F1, follower_graph:new())))), delivery:delivery_set([{actor, alice}, {to, [dave, followers]}], [], S) =:= [dave, bob, carol]\") :name)")
|
||||
|
||||
;; followers + explicit, with overlap deduped
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"F = [{actor, bob}, {type, follow}, {object, alice}], A = [{actor, alice}, {type, accept}, {object, F}], S = follower_graph:fold(A, follower_graph:fold(F, follower_graph:new())), delivery:delivery_set([{actor, alice}, {to, [bob, followers]}], [], S) =:= [bob]\") :name)")
|
||||
|
||||
;; collect_recipients: bare helper returns flat list (no dedup, no self-suppression)
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"delivery:collect_recipients([{actor, alice}, {to, [bob, carol]}, {cc, [carol, dave]}]) =:= [bob, carol, carol, dave]\") :name)")
|
||||
|
||||
;; suppress_self drops every occurrence of Self
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"delivery:suppress_self([bob, alice, carol, alice], alice) =:= [bob, carol]\") :name)")
|
||||
|
||||
;; dedup preserves first occurrence order
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"delivery:dedup([bob, carol, bob, dave, carol]) =:= [bob, carol, dave]\") :name)")
|
||||
|
||||
;; expand_audience: pass-through for plain ActorId
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"delivery:expand_audience(carol, alice, follower_graph:new()) =:= [carol]\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 4 "delivery module loaded" "delivery"
|
||||
check 10 "empty activity -> empty set" "true"
|
||||
check 11 "single :to atom recipient" "true"
|
||||
check 12 "list :to recipients" "true"
|
||||
check 13 ":to + :cc unioned" "true"
|
||||
check 14 "self-delivery suppressed" "true"
|
||||
check 15 "duplicates within :to deduped" "true"
|
||||
check 16 ":to/:cc overlap deduped" "true"
|
||||
check 17 "followers expands via graph" "true"
|
||||
check 18 "empty follower-graph -> []" "true"
|
||||
check 19 "public -> sender's followers" "true"
|
||||
check 28 "public empty graph -> []" "true"
|
||||
check 29 "public + followers dedupe" "true"
|
||||
check 20 "mixed explicit + followers" "true"
|
||||
check 21 "followers + overlap deduped" "true"
|
||||
check 22 "collect_recipients raw flat" "true"
|
||||
check 23 "suppress_self drops every match" "true"
|
||||
check 24 "dedup preserves first-occurrence" "true"
|
||||
check 25 "expand_audience pass-through" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/delivery_set.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
139
next/tests/delivery_state.sh
Executable file
139
next/tests/delivery_state.sh
Executable file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/delivery_state.sh — m2 Step 8c test.
|
||||
#
|
||||
# Delivery-state projection: folds enqueue / delivered / failed /
|
||||
# dead_lettered events into a per-peer worker-shaped snapshot so
|
||||
# the outbound queue survives kernel restart.
|
||||
|
||||
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
|
||||
|
||||
SETUP='Act1 = [{id, <<1>>}, {type, note}, {actor, alice}], Act2 = [{id, <<2>>}, {type, note}, {actor, alice}], E_Enq1 = [{type, enqueued}, {peer, bob}, {activity, Act1}], E_Enq2 = [{type, enqueued}, {peer, bob}, {activity, Act2}], E_Enq2Carol = [{type, enqueued}, {peer, carol}, {activity, Act2}], E_Del1 = [{type, delivered}, {peer, bob}, {cid, <<1>>}], E_Fail1 = [{type, failed}, {peer, bob}, {cid, <<1>>}, {now, 1000}],'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery_worker.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery_state.erl\")) :name)")
|
||||
|
||||
;; Fresh projection -> []
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"delivery_state:new() =:= []\") :name)")
|
||||
|
||||
;; enqueued event creates a peer entry and appends to pending
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = delivery_state:fold(E_Enq1, delivery_state:new()), delivery_state:pending(bob, S) =:= [Act1]\") :name)")
|
||||
|
||||
;; Two enqueues to same peer -> FIFO order
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = delivery_state:fold(E_Enq2, delivery_state:fold(E_Enq1, delivery_state:new())), delivery_state:pending(bob, S) =:= [Act1, Act2]\") :name)")
|
||||
|
||||
;; Enqueues to different peers -> independent queues
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = delivery_state:fold(E_Enq2Carol, delivery_state:fold(E_Enq1, delivery_state:new())), {delivery_state:pending(bob, S), delivery_state:pending(carol, S)} =:= {[Act1], [Act2]}\") :name)")
|
||||
|
||||
;; delivered event clears the matching pending entry
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = delivery_state:fold(E_Del1, delivery_state:fold(E_Enq1, delivery_state:new())), delivery_state:pending(bob, S) =:= []\") :name)")
|
||||
|
||||
;; failed event bumps attempts and sets next_retry
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = delivery_state:fold(E_Fail1, delivery_state:fold(E_Enq1, delivery_state:new())), {delivery_state:attempts(bob, S), delivery_state:next_retry(bob, S)} =:= {[{<<1>>, 1}], [{<<1>>, 1030}]}\") :name)")
|
||||
|
||||
;; Five failures then 6th fails -> dead_lettered
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} F = fun(S) -> delivery_state:fold(E_Fail1, S) end, S0 = delivery_state:fold(E_Enq1, delivery_state:new()), S6 = F(F(F(F(F(F(S0)))))), {delivery_state:dead_letter(bob, S6), delivery_state:pending(bob, S6)} =:= {[Act1], []}\") :name)")
|
||||
|
||||
;; Explicit dead_lettered event moves activity to dead_letter
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} E_DL = [{type, dead_lettered}, {peer, bob}, {cid, <<1>>}], S = delivery_state:fold(E_DL, delivery_state:fold(E_Enq1, delivery_state:new())), {delivery_state:dead_letter(bob, S), delivery_state:pending(bob, S)} =:= {[Act1], []}\") :name)")
|
||||
|
||||
;; peers/1 lists every peer touched
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = delivery_state:fold(E_Enq2Carol, delivery_state:fold(E_Enq1, delivery_state:new())), delivery_state:peers(S) =:= [bob, carol]\") :name)")
|
||||
|
||||
;; peer_state returns {ok, Worker} | not_found
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = delivery_state:fold(E_Enq1, delivery_state:new()), case delivery_state:peer_state(bob, S) of {ok, _} -> true; _ -> false end andalso delivery_state:peer_state(ghost, S) =:= not_found\") :name)")
|
||||
|
||||
;; fold_fn/0 returns a 2-arity Erlang fun usable by projection:start_link/3
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"is_function(delivery_state:fold_fn(), 2)\") :name)")
|
||||
|
||||
;; Unknown event type passes through
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"Garbage = [{type, mystery}, {peer, bob}], delivery_state:fold(Garbage, delivery_state:new()) =:= []\") :name)")
|
||||
|
||||
;; delivered after failed clears retry state
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = delivery_state:fold(E_Del1, delivery_state:fold(E_Fail1, delivery_state:fold(E_Enq1, delivery_state:new()))), {delivery_state:attempts(bob, S), delivery_state:next_retry(bob, S)} =:= {[], []}\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 4 "delivery_state module loaded" "delivery_state"
|
||||
check 10 "new/0 -> []" "true"
|
||||
check 11 "enqueued -> pending appended" "true"
|
||||
check 12 "two enqueues -> FIFO" "true"
|
||||
check 13 "two peers independent queues" "true"
|
||||
check 14 "delivered clears pending entry" "true"
|
||||
check 15 "failed bumps attempts + next_retry" "true"
|
||||
check 16 "6th failed -> dead_lettered" "true"
|
||||
check 17 "explicit dead_lettered event" "true"
|
||||
check 18 "peers/1 lists touched" "true"
|
||||
check 19 "peer_state ok / not_found" "true"
|
||||
check 20 "fold_fn/0 is fun/2" "true"
|
||||
check 21 "unknown event passes through" "true"
|
||||
check 22 "delivered after failed clears" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/delivery_state.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
156
next/tests/delivery_worker.sh
Executable file
156
next/tests/delivery_worker.sh
Executable file
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/delivery_worker.sh — m2 Step 8a test.
|
||||
#
|
||||
# Pure-functional state shape + gen_server skeleton for the
|
||||
# outbound delivery worker. One worker per peer; FIFO queue of
|
||||
# pending activities; caller-supplied :dispatch_fn does the actual
|
||||
# HTTP POST (stubbed for tests, live httpc in Step 8f). Retry /
|
||||
# backoff (Step 8b) and persist-survival (Step 8c) layer on top.
|
||||
|
||||
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
|
||||
|
||||
SETUP='Act1 = [{id, <<1,2,3>>}, {type, note}, {actor, alice}], Act2 = [{id, <<4,5,6>>}, {type, note}, {actor, alice}], OkFetch = fun(_) -> ok end, FailFetch = fun(_) -> {error, http_500} 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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery_worker.erl\")) :name)")
|
||||
|
||||
;; new/1 returns initial state with empty queue
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"delivery_worker:pending(delivery_worker:new(bob)) =:= []\") :name)")
|
||||
|
||||
;; peer/1 reads the peer id
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"delivery_worker:peer(delivery_worker:new(bob)) =:= bob\") :name)")
|
||||
|
||||
;; enqueue_pure appends to the queue
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = delivery_worker:enqueue_pure(bob, Act1, delivery_worker:new(bob)), delivery_worker:pending(S) =:= [Act1]\") :name)")
|
||||
|
||||
;; Two enqueues -> FIFO order
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = delivery_worker:new(bob), S1 = delivery_worker:enqueue_pure(bob, Act1, S0), S2 = delivery_worker:enqueue_pure(bob, Act2, S1), delivery_worker:pending(S2) =:= [Act1, Act2]\") :name)")
|
||||
|
||||
;; drain_pure with no dispatch_fn -> all retry, queue intact
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = delivery_worker:new(bob), S1 = delivery_worker:enqueue_pure(bob, Act1, S0), {S2, Delivered, Retry} = delivery_worker:drain_pure(S1), Delivered =:= [] andalso length(Retry) =:= 1 andalso delivery_worker:pending(S2) =:= [Act1]\") :name)")
|
||||
|
||||
;; drain_pure with success dispatch -> activities cleared
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = delivery_worker:new(bob), S1 = lists:foldl(fun(K, A) -> delivery_worker:enqueue_pure(bob, K, A) end, S0, [Act1, Act2]), Wired = [{peer, bob}, {pending, [Act1, Act2]}, {attempts, []}, {dead_letter, []}, {dispatch_fn, OkFetch}], {S2, Delivered, Retry} = delivery_worker:drain_pure(Wired), delivery_worker:pending(S2) =:= [] andalso length(Delivered) =:= 2 andalso Retry =:= []\") :name)")
|
||||
|
||||
;; drain_pure with failing dispatch -> activities stay; attempt counter bumped
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Wired = [{peer, bob}, {pending, [Act1]}, {attempts, []}, {dead_letter, []}, {dispatch_fn, FailFetch}], {S, Delivered, Retry} = delivery_worker:drain_pure(Wired), delivery_worker:pending(S) =:= [Act1] andalso Delivered =:= [] andalso length(Retry) =:= 1\") :name)")
|
||||
|
||||
;; deliver_one_pure success returns {ok, Cid}
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Wired = [{peer, bob}, {pending, []}, {attempts, []}, {dead_letter, []}, {dispatch_fn, OkFetch}], case delivery_worker:deliver_one_pure(Act1, Wired) of {ok, <<1,2,3>>} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; deliver_one_pure with no dispatch_fn returns no_dispatch_fn
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} case delivery_worker:deliver_one_pure(Act1, delivery_worker:new(bob)) of {error, _, no_dispatch_fn} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; backoff_for slots match the design schedule
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"{delivery_worker:backoff_for(1), delivery_worker:backoff_for(2), delivery_worker:backoff_for(3), delivery_worker:backoff_for(4), delivery_worker:backoff_for(5)} =:= {30, 300, 1800, 21600, 86400}\") :name)")
|
||||
|
||||
;; backoff_for(>=6) returns dead_letter
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"delivery_worker:backoff_for(6) =:= dead_letter\") :name)")
|
||||
|
||||
;; schedule_for returns {retry_in, Sec} or dead_letter
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"{delivery_worker:schedule_for(1), delivery_worker:schedule_for(6)} =:= {{retry_in, 30}, dead_letter}\") :name)")
|
||||
|
||||
;; gen_server: start_link + enqueue + pending_srv
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} delivery_worker:start_link(bob), delivery_worker:enqueue(bob, Act1), delivery_worker:pending_srv(bob) =:= [Act1]\") :name)")
|
||||
|
||||
;; gen_server: flush with dispatch_fn -> {ok, [Cid], []}
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} delivery_worker:start_link(bob, OkFetch), delivery_worker:enqueue(bob, Act1), case delivery_worker:flush(bob) of {ok, [<<1,2,3>>], []} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; gen_server: flush with failing dispatch -> {ok, [], [Cid]}, queue stays
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} delivery_worker:start_link(bob, FailFetch), delivery_worker:enqueue(bob, Act1), case delivery_worker:flush(bob) of {ok, [], [<<1,2,3>>]} -> ok; _ -> bad end andalso delivery_worker:pending_srv(bob) =:= [Act1]\") :name)")
|
||||
|
||||
;; gen_server: set_dispatch_fn swaps the function in-flight
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} delivery_worker:start_link(bob), delivery_worker:enqueue(bob, Act1), delivery_worker:set_dispatch_fn(bob, OkFetch), case delivery_worker:flush(bob) of {ok, [<<1,2,3>>], []} -> ok; _ -> bad end\") :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 4 "delivery_worker module loaded" "delivery_worker"
|
||||
check 10 "new/1 -> empty queue" "true"
|
||||
check 11 "peer/1 reads peer id" "true"
|
||||
check 12 "enqueue_pure appends" "true"
|
||||
check 13 "FIFO order preserved" "true"
|
||||
check 14 "drain w/o dispatch -> retry" "true"
|
||||
check 15 "drain ok clears queue" "true"
|
||||
check 16 "drain fail keeps queue" "true"
|
||||
check 17 "deliver_one ok -> {ok, Cid}" "ok"
|
||||
check 18 "deliver_one no fn -> err" "ok"
|
||||
check 19 "backoff schedule matches plan" "true"
|
||||
check 20 "backoff overflow -> dead" "true"
|
||||
check 21 "schedule_for shape" "true"
|
||||
check 22 "gen_server enqueue + pending" "true"
|
||||
check 23 "gen_server flush ok" "ok"
|
||||
check 24 "gen_server flush fail keeps" "ok"
|
||||
check 25 "gen_server set_dispatch_fn" "ok"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/delivery_worker.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
124
next/tests/discovery.sh
Executable file
124
next/tests/discovery.sh
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/discovery.sh — m2 Step 10a test.
|
||||
#
|
||||
# Local-side webfinger primitives: parse acct: URIs, synthesise
|
||||
# actor URLs, build the RFC 7033 webfinger JSON body.
|
||||
|
||||
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
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/discovery.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
|
||||
;; parse_acct accepts the acct: prefix form
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"discovery:parse_acct(<<97,99,99,116,58,97,108,105,99,101,64,104,111,115,116>>) =:= {ok, <<97,108,105,99,101>>, <<104,111,115,116>>}\") :name)")
|
||||
|
||||
;; parse_acct accepts the bare form
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"discovery:parse_acct(<<97,108,105,99,101,64,104,111,115,116>>) =:= {ok, <<97,108,105,99,101>>, <<104,111,115,116>>}\") :name)")
|
||||
|
||||
;; parse_acct host with port
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"discovery:parse_acct(<<97,108,105,99,101,64,104,111,115,116,58,57,57,57,57>>) =:= {ok, <<97,108,105,99,101>>, <<104,111,115,116,58,57,57,57,57>>}\") :name)")
|
||||
|
||||
;; parse_acct rejects empty user
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"case discovery:parse_acct(<<64,104,111,115,116>>) of {error, _} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; parse_acct rejects missing @
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"case discovery:parse_acct(<<97,108,105,99,101>>) of {error, _} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; parse_acct rejects empty host
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"case discovery:parse_acct(<<97,108,105,99,101,64>>) of {error, _} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; parse_resource is an alias for parse_acct
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"discovery:parse_resource(<<97,99,99,116,58,98,111,98,64,98,46,99,111,109>>) =:= {ok, <<98,111,98>>, <<98,46,99,111,109>>}\") :name)")
|
||||
|
||||
;; actor_url_for synthesises http://<host>/actors/<user>
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"discovery:actor_url_for(<<97,108,105,99,101>>, <<104,111,115,116>>) =:= <<104,116,116,112,58,47,47,104,111,115,116,47,97,99,116,111,114,115,47,97,108,105,99,101>>\") :name)")
|
||||
|
||||
;; actor_url_for preserves port in host
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"discovery:actor_url_for(<<98,111,98>>, <<104,58,57,57>>) =:= <<104,116,116,112,58,47,47,104,58,57,57,47,97,99,116,111,114,115,47,98,111,98>>\") :name)")
|
||||
|
||||
;; webfinger_body starts with {"subject":"acct:<user>@<host>" — http_server:match_prefix
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"B = discovery:webfinger_body(<<97,108,105,99,101>>, <<104,111,115,116>>, <<117,114,108>>), Pre = <<123,34,115,117,98,106,101,99,116,34,58,34,97,99,99,116,58,97,108,105,99,101,64,104,111,115,116,34>>, http_server:match_prefix(Pre, B) =/= nomatch\") :name)")
|
||||
|
||||
;; webfinger_body byte_size is at least subject+links length (sanity)
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"B = discovery:webfinger_body(<<97,108,105,99,101>>, <<104,111,115,116>>, <<117,114,108>>), byte_size(B) > 80\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 480 "$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 2 "discovery module loaded" "discovery"
|
||||
check 10 "parse_acct prefixed" "true"
|
||||
check 11 "parse_acct bare form" "true"
|
||||
check 12 "parse_acct host with port" "true"
|
||||
check 13 "parse_acct empty user -> error" "true"
|
||||
check 14 "parse_acct missing @ -> error" "true"
|
||||
check 15 "parse_acct empty host -> error" "true"
|
||||
check 16 "parse_resource alias" "true"
|
||||
check 17 "actor_url_for synthesises" "true"
|
||||
check 18 "actor_url_for preserves port" "true"
|
||||
check 19 "webfinger_body subject prefix" "true"
|
||||
check 20 "webfinger_body has body bytes" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/discovery.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
224
next/tests/discovery_fetch.sh
Executable file
224
next/tests/discovery_fetch.sh
Executable file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/discovery_fetch.sh — m2 Step 10c acceptance test.
|
||||
#
|
||||
# Two halves:
|
||||
# (a) http_server side: the new actor_doc Accept format negotiates
|
||||
# to a term_codec-encoded peer-actor-state proplist served
|
||||
# from `nx_kernel:actor_state/1`. Verified via http_server:route
|
||||
# in-process.
|
||||
# (b) discovery_fetch closure: builds the FetchFn that
|
||||
# peer_actors:lookup_or_fetch_srv/2 expects, GETs the actor
|
||||
# doc via httpc:request/4, decodes the body, returns the AS
|
||||
# proplist. Verified end-to-end against a background
|
||||
# `python3 -m http.server`-style stub that returns hand-crafted
|
||||
# term_codec bytes (so we exercise the wire, not just the
|
||||
# in-process route).
|
||||
|
||||
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=""
|
||||
|
||||
# ── live stub server ─────────────────────────────────────────
|
||||
# Python script that:
|
||||
# GET /actors/alice -> 200 with term_codec-encoded AS
|
||||
# (built in Python: matches term_codec
|
||||
# netstring format spelled out in
|
||||
# next/kernel/term_codec.erl).
|
||||
# GET /actors/missing -> 404
|
||||
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
|
||||
SRVROOT=$(mktemp -d)
|
||||
PYSRV="$SRVROOT/srv.py"
|
||||
cat > "$PYSRV" <<'PY'
|
||||
import sys, http.server, socketserver
|
||||
|
||||
PORT = int(sys.argv[1])
|
||||
|
||||
# term_codec encoding (mirror of next/kernel/term_codec.erl).
|
||||
def enc_atom(s):
|
||||
b = s.encode()
|
||||
return f"a{len(b)}:".encode() + b
|
||||
def enc_int(n):
|
||||
s = str(n).encode()
|
||||
return f"i{len(s)}:".encode() + s
|
||||
def enc_bin(b):
|
||||
return f"b{len(b)}:".encode() + b
|
||||
def enc_list(items):
|
||||
payload = b"".join(items)
|
||||
# term_codec uses ELEMENT COUNT (not byte length) for list/tuple
|
||||
# headers — see encode/1 in next/kernel/term_codec.erl.
|
||||
return f"l{len(items)}:".encode() + payload
|
||||
def enc_tuple(items):
|
||||
payload = b"".join(items)
|
||||
return f"t{len(items)}:".encode() + payload
|
||||
def enc_nil():
|
||||
return b"l0:"
|
||||
|
||||
# {public_keys, [[{id, k1}, {created, 0}, {value, <<1,2,3,4>>}]]}
|
||||
KEY = enc_list([
|
||||
enc_tuple([enc_atom("id"), enc_atom("k1")]),
|
||||
enc_tuple([enc_atom("created"), enc_int(0)]),
|
||||
enc_tuple([enc_atom("value"), enc_bin(bytes([1,2,3,4]))]),
|
||||
])
|
||||
PROPLIST = enc_list([
|
||||
enc_tuple([enc_atom("public_keys"), enc_list([KEY])]),
|
||||
])
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/actors/alice":
|
||||
self.send_response(200)
|
||||
self.send_header('content-type','application/vnd.fed-sx.actor-doc')
|
||||
self.send_header('content-length', str(len(PROPLIST)))
|
||||
self.end_headers()
|
||||
self.wfile.write(PROPLIST)
|
||||
else:
|
||||
self.send_response(404); self.end_headers(); self.wfile.write(b'not found')
|
||||
def log_message(self, fmt, *args): pass
|
||||
|
||||
with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
|
||||
srv.serve_forever()
|
||||
PY
|
||||
python3 "$PYSRV" "$PORT" >/dev/null 2>&1 &
|
||||
SRV_PID=$!
|
||||
TMPFILE=$(mktemp)
|
||||
trap "rm -rf $SRVROOT $TMPFILE; kill $SRV_PID 2>/dev/null || true" EXIT
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if curl -fsS "http://127.0.0.1:$PORT/actors/alice" >/dev/null 2>&1; then break; fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
bytes_of() { python3 -c "import sys; print(','.join(str(b) for b in sys.argv[1].encode()))" "$1"; }
|
||||
URL_BASE_BYTES=$(bytes_of "http://127.0.0.1:$PORT")
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/peer_actors.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/dispatch_http.erl\")) :name)")
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/discovery_fetch.erl\")) :name)")
|
||||
|
||||
;; (a) http_server side: actor_doc Accept negotiates to actor_doc
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"http_server:accept_format(<<97,112,112,108,105,99,97,116,105,111,110,47,118,110,100,46,102,101,100,45,115,120,46,97,99,116,111,114,45,100,111,99>>) =:= actor_doc\") :name)")
|
||||
|
||||
;; (a) actor_doc_response_for/3 with kernel + actor returns 200 +
|
||||
;; term_codec body; decoded body has :public_keys. Inline SETUP
|
||||
;; per epoch because separate (eval ...) calls share gen_server
|
||||
;; state but not Erlang locals, and we need fresh kernel-aware
|
||||
;; assertions even though the previous epoch's nx_kernel persists.
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), Cfg = [{kernel, nx_kernel}], R = http_server:actor_doc_response_for(<<97,108,105,99,101>>, actor_doc, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 200\") :name)")
|
||||
|
||||
;; (a) body decodes to a proplist with :public_keys
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), R = http_server:actor_doc_response_for(<<97,108,105,99,101>>, actor_doc, [{kernel, nx_kernel}]), {ok, Body} = envelope:get_field(body, R), {ok, AS, _} = term_codec:decode(Body), case envelope:get_field(public_keys, AS) of {ok, [_|_]} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; (a) unknown actor -> 404
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), R = http_server:actor_doc_response_for(<<109,105,115,115,105,110,103>>, actor_doc, [{kernel, nx_kernel}]), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)")
|
||||
|
||||
;; (b) discovery_fetch:actor_doc_url builds <base>/actors/alice
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"U = discovery_fetch:actor_doc_url(<<__URL_BASE__>>, alice), U =:= <<__URL_BASE__,47,97,99,116,111,114,115,47,97,108,105,99,101>>\") :name)")
|
||||
|
||||
;; (b) discovery_fetch:fetch live -> {ok, AS} with :public_keys
|
||||
(epoch 31)
|
||||
(eval "(get (erlang-eval-ast \"R = discovery_fetch:fetch(<<__URL_BASE__,47,97,99,116,111,114,115,47,97,108,105,99,101>>, []), case R of {ok, AS} -> case envelope:get_field(public_keys, AS) of {ok, [_|_]} -> true; _ -> false end; _ -> false end\") :name)")
|
||||
|
||||
;; (b) closure produced by make_fetch_fn dispatches ok
|
||||
(epoch 32)
|
||||
(eval "(get (erlang-eval-ast \"Fn = discovery_fetch:make_fetch_fn([{peer_url, [{alice, <<__URL_BASE__>>}]}]), case Fn(alice) of {ok, AS} -> case envelope:get_field(public_keys, AS) of {ok, [_|_]} -> true; _ -> false end; _ -> false end\") :name)")
|
||||
|
||||
;; (b) closure on missing peer -> {error, no_peer_url}
|
||||
(epoch 33)
|
||||
(eval "(get (erlang-eval-ast \"Fn = discovery_fetch:make_fetch_fn([{peer_url, []}]), case Fn(alice) of {error, no_peer_url} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; (b) closure GETs 404 path -> {error, {status, 404}}
|
||||
(epoch 34)
|
||||
(eval "(get (erlang-eval-ast \"R = discovery_fetch:fetch(<<__URL_BASE__,47,97,99,116,111,114,115,47,109,105,115,115,105,110,103>>, []), case R of {error, {status, 404}} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; (b) lookup_or_fetch on cache miss writes the result back
|
||||
(epoch 35)
|
||||
(eval "(get (erlang-eval-ast \"Fn = discovery_fetch:make_fetch_fn([{peer_url, [{alice, <<__URL_BASE__>>}]}]), {R, NewState} = case peer_actors:lookup_or_fetch(alice, Fn, peer_actors:new()) of {ok, _AS, S} -> {ok, S}; {error, R0, S} -> {error, S} end, R =:= ok andalso peer_actors:peers(NewState) =:= [alice]\") :name)")
|
||||
EPOCHS
|
||||
|
||||
sed -i "s|__URL_BASE__|${URL_BASE_BYTES}|g" "$TMPFILE"
|
||||
|
||||
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 12 "discovery_fetch loaded" "discovery_fetch"
|
||||
check 20 "actor_doc Accept negotiates" "true"
|
||||
check 21 "actor_doc /3 with kernel -> 200" "true"
|
||||
check 22 "body decodes to proplist w/ :public_keys" "true"
|
||||
check 23 "unknown actor -> 404" "true"
|
||||
check 30 "actor_doc_url builds /actors/X" "true"
|
||||
check 31 "fetch live -> {ok, AS}" "true"
|
||||
check 32 "closure -> {ok, AS}" "true"
|
||||
check 33 "closure on missing peer -> no_peer_url" "true"
|
||||
check 34 "closure on 404 -> {status, 404}" "true"
|
||||
check 35 "lookup_or_fetch caches result" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/discovery_fetch.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
176
next/tests/discovery_type_fetch.sh
Executable file
176
next/tests/discovery_type_fetch.sh
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/discovery_type_fetch.sh — host-type federation Phase 3.
|
||||
#
|
||||
# Client side of the type-doc wire: discovery_type_fetch builds the
|
||||
# fun/2 closure peer_types:lookup_or_fetch calls on a cache miss. It
|
||||
# GETs <base>/types/<cid> with the type-doc Accept header and returns
|
||||
# the RAW response bytes (peer_types decodes them via term_codec).
|
||||
# Exercised end-to-end against a background python http server that
|
||||
# serves hand-crafted term_codec bytes, so we test the wire — not just
|
||||
# an in-process call.
|
||||
|
||||
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=""
|
||||
|
||||
# ── live stub server ─────────────────────────────────────────
|
||||
# GET /types/bafy1 -> 200 with term_codec-encoded TypeRecord
|
||||
# TR = [{name, <<"Post">>}, {instance_type, <<"Note">>}]
|
||||
# GET anything else -> 404
|
||||
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
|
||||
SRVROOT=$(mktemp -d)
|
||||
PYSRV="$SRVROOT/srv.py"
|
||||
cat > "$PYSRV" <<'PY'
|
||||
import sys, http.server, socketserver
|
||||
|
||||
PORT = int(sys.argv[1])
|
||||
|
||||
# term_codec encoding (mirror of next/kernel/term_codec.erl).
|
||||
def enc_atom(s):
|
||||
b = s.encode()
|
||||
return f"a{len(b)}:".encode() + b
|
||||
def enc_bin(b):
|
||||
return f"b{len(b)}:".encode() + b
|
||||
def enc_tuple(items):
|
||||
return f"t{len(items)}:".encode() + b"".join(items)
|
||||
def enc_list(items):
|
||||
return f"l{len(items)}:".encode() + b"".join(items)
|
||||
|
||||
# [{name, <<"Post">>}, {instance_type, <<"Note">>}]
|
||||
TYPEDOC = enc_list([
|
||||
enc_tuple([enc_atom("name"), enc_bin(b"Post")]),
|
||||
enc_tuple([enc_atom("instance_type"), enc_bin(b"Note")]),
|
||||
])
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/types/bafy1":
|
||||
self.send_response(200)
|
||||
self.send_header('content-type','application/vnd.fed-sx.type-doc')
|
||||
self.send_header('content-length', str(len(TYPEDOC)))
|
||||
self.end_headers()
|
||||
self.wfile.write(TYPEDOC)
|
||||
else:
|
||||
self.send_response(404); self.end_headers(); self.wfile.write(b'not found')
|
||||
def log_message(self, fmt, *args): pass
|
||||
|
||||
with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
|
||||
srv.serve_forever()
|
||||
PY
|
||||
python3 "$PYSRV" "$PORT" >/dev/null 2>&1 &
|
||||
SRV_PID=$!
|
||||
TMPFILE=$(mktemp)
|
||||
trap "rm -rf $SRVROOT $TMPFILE; kill $SRV_PID 2>/dev/null || true" EXIT
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if curl -fsS "http://127.0.0.1:$PORT/types/bafy1" >/dev/null 2>&1; then break; fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
bytes_of() { python3 -c "import sys; print(','.join(str(b) for b in sys.argv[1].encode()))" "$1"; }
|
||||
URL_BASE_BYTES=$(bytes_of "http://127.0.0.1:$PORT")
|
||||
|
||||
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/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/peer_types.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/discovery_type_fetch.erl\")) :name)")
|
||||
|
||||
;; accept_header is the 31-byte type-doc MIME
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"byte_size(discovery_type_fetch:accept_header()) =:= 31\") :name)")
|
||||
|
||||
;; type_doc_url builds <base>/types/bafy1
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"U = discovery_type_fetch:type_doc_url(<<__URL_BASE__>>, <<98,97,102,121,49>>), U =:= <<__URL_BASE__,47,116,121,112,101,115,47,98,97,102,121,49>>\") :name)")
|
||||
|
||||
;; resolve_type_url via the static type_url proplist
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"discovery_type_fetch:resolve_type_url(<<98,97,102,121,49>>, [{type_url, [{<<98,97,102,121,49>>, <<__URL_BASE__>>}]}]) =:= {ok, <<__URL_BASE__>>}\") :name)")
|
||||
|
||||
;; fetch live -> {ok, Bytes} that decode to the TypeRecord
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"R = discovery_type_fetch:fetch(<<__URL_BASE__,47,116,121,112,101,115,47,98,97,102,121,49>>, []), case R of {ok, B} -> {ok, TR, _} = term_codec:decode(B), TR =:= [{name, <<80,111,115,116>>}, {instance_type, <<78,111,116,101>>}]; _ -> false end\") :name)")
|
||||
|
||||
;; closure from make_fetch_fn/0 dispatches and returns raw bytes
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"Fn = discovery_type_fetch:make_fetch_fn(), Cfg = [{type_url, [{<<98,97,102,121,49>>, <<__URL_BASE__>>}]}], case Fn(<<98,97,102,121,49>>, Cfg) of {ok, B} -> {ok, TR, _} = term_codec:decode(B), TR =:= [{name, <<80,111,115,116>>}, {instance_type, <<78,111,116,101>>}]; _ -> false end\") :name)")
|
||||
|
||||
;; closure with no resolver -> {error, no_type_url}
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"Fn = discovery_type_fetch:make_fetch_fn(), case Fn(<<98,97,102,121,49>>, []) of {error, no_type_url} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; fetch on an unknown cid path -> {error, {status, 404}}
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"R = discovery_type_fetch:fetch(<<__URL_BASE__,47,116,121,112,101,115,47,122,122,122>>, []), case R of {error, {status, 404}} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; end-to-end: peer_types:lookup_or_fetch uses the closure, decodes,
|
||||
;; and writes the TypeRecord into the cache
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"Fn = discovery_type_fetch:make_fetch_fn(), Cfg = [{type_fetch_fn, Fn}, {type_url, [{<<98,97,102,121,49>>, <<__URL_BASE__>>}]}], case peer_types:lookup_or_fetch(<<98,97,102,121,49>>, Cfg, peer_types:new()) of {ok, TR, S} -> TR =:= [{name, <<80,111,115,116>>}, {instance_type, <<78,111,116,101>>}] andalso peer_types:types(S) =:= [<<98,97,102,121,49>>]; _ -> false end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
sed -i "s|__URL_BASE__|${URL_BASE_BYTES}|g" "$TMPFILE"
|
||||
|
||||
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="<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 5 "discovery_type_fetch loaded" "discovery_type_fetch"
|
||||
check 10 "accept_header is 31-byte type-doc" "true"
|
||||
check 11 "type_doc_url builds /types/<cid>" "true"
|
||||
check 12 "resolve_type_url via type_url map" "true"
|
||||
check 13 "fetch live -> raw bytes decode to TR" "true"
|
||||
check 14 "closure -> raw bytes decode to TR" "true"
|
||||
check 15 "closure no resolver -> no_type_url" "true"
|
||||
check 16 "fetch 404 path -> {status, 404}" "true"
|
||||
check 17 "lookup_or_fetch caches fetched TR" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/discovery_type_fetch.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
182
next/tests/dispatch_http.sh
Executable file
182
next/tests/dispatch_http.sh
Executable file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/dispatch_http.sh — m2 Step 8f acceptance test.
|
||||
#
|
||||
# Verifies the live HTTP dispatch closure built by
|
||||
# dispatch_http:make_dispatch_fn/2:
|
||||
# * 2xx response -> ok
|
||||
# * non-2xx (404) -> {error, {status, 404}}
|
||||
# * resolver miss -> {error, no_peer_url}
|
||||
# * connection refused (closed port) -> {error, ...}
|
||||
# * inbox_url constructs the path /actors/<peer>/inbox
|
||||
# * the closure can be plugged into delivery_worker:drain
|
||||
#
|
||||
# Live HTTP uses a background `python3 -m http.server`. Step 8e's
|
||||
# httpc:request/4 BIF wrapper is the underlying transport.
|
||||
|
||||
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=""
|
||||
|
||||
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
|
||||
SRVROOT=$(mktemp -d)
|
||||
# Python's http.server returns 200 for any GET to an existing path and
|
||||
# 501 for POST. For our purposes we need a POST endpoint that returns
|
||||
# 2xx. Use a tiny background Python server that always returns 200 OK
|
||||
# regardless of method, so we can prove the dispatch path works.
|
||||
PYSRV="$SRVROOT/srv.py"
|
||||
cat > "$PYSRV" <<'PY'
|
||||
import sys, http.server, socketserver
|
||||
PORT = int(sys.argv[1])
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get('content-length', '0'))
|
||||
self.rfile.read(n) if n else None
|
||||
self.send_response(200); self.send_header('content-type','text/plain'); self.end_headers()
|
||||
self.wfile.write(b'ok')
|
||||
def do_GET(self):
|
||||
self.send_response(200); self.send_header('content-type','text/plain'); self.end_headers()
|
||||
self.wfile.write(b'ok')
|
||||
def log_message(self, fmt, *args): pass
|
||||
with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
|
||||
srv.serve_forever()
|
||||
PY
|
||||
python3 "$PYSRV" "$PORT" >/dev/null 2>&1 &
|
||||
SRV_PID=$!
|
||||
TMPFILE=$(mktemp)
|
||||
trap "rm -rf $SRVROOT $TMPFILE; kill $SRV_PID 2>/dev/null || true" EXIT
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then break; fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# A DIFFERENT port that nothing is bound to — for the connection-
|
||||
# refused test.
|
||||
DEAD_PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));p=s.getsockname()[1];s.close();print(p)')
|
||||
|
||||
bytes_of() { python3 -c "import sys; print(','.join(str(b) for b in sys.argv[1].encode()))" "$1"; }
|
||||
URL_BASE_BYTES=$(bytes_of "http://127.0.0.1:$PORT")
|
||||
URL_DEAD_BYTES=$(bytes_of "http://127.0.0.1:$DEAD_PORT")
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/dispatch_http.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery_worker.erl\")) :name)")
|
||||
|
||||
;; inbox_url builds <base>/actors/<peer>/inbox
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"U = dispatch_http:inbox_url(<<__URL_BASE__>>, alice), case U of <<__URL_BASE__,47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>> -> true; _ -> false end\") :name)")
|
||||
|
||||
;; resolve_peer_url hits the static map
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"Cfg = [{peer_url, [{alice, <<__URL_BASE__>>}]}], case dispatch_http:resolve_peer_url(alice, Cfg) of {ok, _} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; resolve_peer_url misses cleanly
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"Cfg = [{peer_url, [{bob, <<__URL_BASE__>>}]}], case dispatch_http:resolve_peer_url(alice, Cfg) of {error, no_peer_url} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; dispatch -> 200 from python server -> ok
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"Activity = [{type, note}, {object, [{content, hi}]}], dispatch_http:dispatch(<<__URL_BASE__,47,105,110,98,111,120>>, Activity, []) =:= ok\") :name)")
|
||||
|
||||
;; closure produced by make_dispatch_fn dispatches ok
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"Cfg = [{peer_url, [{alice, <<__URL_BASE__>>}]}], Fn = dispatch_http:make_dispatch_fn(alice, Cfg), Activity = [{type, note}, {object, [{content, hi}]}], Fn(Activity) =:= ok\") :name)")
|
||||
|
||||
;; closure on missing peer -> {error, no_peer_url}
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"Cfg = [{peer_url, []}], Fn = dispatch_http:make_dispatch_fn(alice, Cfg), Activity = [{type, note}, {object, [{content, hi}]}], case Fn(Activity) of {error, no_peer_url} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; dispatch against a closed port -> error (not crash)
|
||||
(epoch 26)
|
||||
(eval "(get (erlang-eval-ast \"Activity = [{type, note}, {object, [{content, hi}]}], R = dispatch_http:dispatch(<<__URL_DEAD__,47,105,110,98,111,120>>, Activity, []), case R of {error, _} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; delivery_worker drains successfully through the live closure.
|
||||
;; Spin up a delivery_worker, enqueue an activity, set the live
|
||||
;; dispatch_fn, drain — should drop the entry.
|
||||
(epoch 27)
|
||||
(eval "(get (erlang-eval-ast \"delivery_worker:start_link(alice), Cfg = [{peer_url, [{alice, <<__URL_BASE__>>}]}], Fn = dispatch_http:make_dispatch_fn(alice, Cfg), delivery_worker:set_dispatch_fn(alice, Fn), Activity = [{type, note}, {object, [{content, hi}]}, {cid, <<\\\"c1\\\">>}], delivery_worker:enqueue(alice, Activity), delivery_worker:flush(alice), delivery_worker:pending_srv(alice) =:= []\") :name)")
|
||||
|
||||
;; peer_url_fn closure path also resolves
|
||||
(epoch 28)
|
||||
(eval "(get (erlang-eval-ast \"Cfg = [{peer_url_fn, fun (alice) -> {ok, <<__URL_BASE__>>}; (_) -> not_found end}], Fn = dispatch_http:make_dispatch_fn(alice, Cfg), Activity = [{type, note}, {object, [{content, hi}]}], Fn(Activity) =:= ok\") :name)")
|
||||
EPOCHS
|
||||
|
||||
sed -i "s|__URL_BASE__|${URL_BASE_BYTES}|g; s|__URL_DEAD__|${URL_DEAD_BYTES}|g" "$TMPFILE"
|
||||
|
||||
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 8 "dispatch_http loaded" "dispatch_http"
|
||||
check 20 "inbox_url builds /actors/X/inbox" "true"
|
||||
check 21 "resolve hits static peer_url map" "true"
|
||||
check 22 "resolve misses cleanly" "true"
|
||||
check 23 "live POST -> 200 -> ok" "true"
|
||||
check 24 "closure dispatches ok" "true"
|
||||
check 25 "closure on missing peer -> err" "true"
|
||||
check 26 "closed port -> {error, _}" "true"
|
||||
check 27 "delivery_worker drains via closure" "true"
|
||||
check 28 "peer_url_fn closure path resolves" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/dispatch_http.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
130
next/tests/flow_dispatch.sh
Executable file
130
next/tests/flow_dispatch.sh
Executable file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/flow_dispatch.sh — fed-sx triggers Phase 3.
|
||||
#
|
||||
# flow_dispatch bridges a matched trigger to a started flow — a native
|
||||
# flow_store:start (the engine is Erlang-on-SX too, no FFI). Confirms
|
||||
# guard/actor-scope gating, the audit triple, synchronous first-step
|
||||
# execution, suspend/resume of a started instance, a branch on an
|
||||
# activity field, and graceful handling of an unknown flow name.
|
||||
|
||||
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
|
||||
|
||||
# Activity (Create of a Note by alice), receiving actor-state, and a
|
||||
# couple of flows: `capture` echoes the activity's type out of the
|
||||
# flow's input env; `wait_flow` suspends then wraps the resumed value;
|
||||
# `cat_flow` branches on the inner object's :type.
|
||||
ACT='[{type, create}, {actor, alice}, {id, <<97,99,105,100>>}, {object, [{type, note}]}]'
|
||||
AS='[{actor_id, alice}]'
|
||||
CAP='flow_spec:flow_node(fun(In) -> {ok, A} = envelope:get_field(activity, In), {ok, T} = envelope:get_field(type, A), T end)'
|
||||
WAITF='flow_spec:sequence([flow:suspend(w), flow_spec:flow_node(fun(V) -> {got, V} end)])'
|
||||
CATF='flow_spec:branch(fun(In) -> {ok, A} = envelope:get_field(activity, In), {ok, O} = envelope:get_field(object, A), envelope:get_field(type, O) =:= {ok, note} end, flow_spec:flow_const(is_note), flow_spec:flow_const(not_note))'
|
||||
|
||||
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!)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/flow/flow.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/flow/flow_spec.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/flow/flow_store.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/trigger_registry.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/flow_dispatch.erl\")) :name)")
|
||||
|
||||
;; ── guard / actor-scope gating ─────────────────────────────
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"flow_dispatch:guard_passes(trigger_registry:mk_spec(c, f, undefined, any), ${ACT}, ${AS})\") :name)")
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"flow_dispatch:guard_passes(trigger_registry:mk_spec(c, f, fun(_, _) -> false end, any), ${ACT}, ${AS}) =:= false\") :name)")
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"flow_dispatch:guard_passes(trigger_registry:mk_spec(c, f, fun(A, _) -> envelope:get_field(actor, A) =:= {ok, alice} end, any), ${ACT}, ${AS})\") :name)")
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"flow_dispatch:guard_passes(trigger_registry:mk_spec(c, f, undefined, alice), ${ACT}, ${AS})\") :name)")
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"flow_dispatch:guard_passes(trigger_registry:mk_spec(c, f, undefined, bob), ${ACT}, ${AS}) =:= false\") :name)")
|
||||
|
||||
;; ── start: audit triple + synchronous first step ───────────
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(capture, ${CAP}), flow_dispatch:start(trigger_registry:mk_spec(<<116,99>>, capture, undefined, any), ${ACT}, ${AS}, []) =:= {ok, 1, {<<97,99,105,100>>, <<116,99>>, 1}}\") :name)")
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(capture, ${CAP}), {ok, FlowId, _} = flow_dispatch:start(trigger_registry:mk_spec(<<116,99>>, capture, undefined, any), ${ACT}, ${AS}, []), flow_store:status(FlowId) =:= {ok, {done, create}}\") :name)")
|
||||
|
||||
;; ── unknown flow name -> {error, no_such_flow}, no crash ────
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_dispatch:start(trigger_registry:mk_spec(<<116,99>>, ghostflow, undefined, any), ${ACT}, ${AS}, []) =:= {error, no_such_flow}\") :name)")
|
||||
|
||||
;; ── started instance suspends; resume completes ────────────
|
||||
(epoch 40)
|
||||
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(wait_flow, ${WAITF}), {ok, FlowId, _} = flow_dispatch:start(trigger_registry:mk_spec(<<116,99>>, wait_flow, undefined, any), ${ACT}, ${AS}, []), S1 = flow_store:status(FlowId), R = flow_store:resume(FlowId, 7), S1 =:= {ok, {suspended, w}} andalso R =:= {ok, {flow_done, {got, 7}}}\") :name)")
|
||||
|
||||
;; ── branch on an activity field (both branches) ────────────
|
||||
(epoch 50)
|
||||
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(cat_flow, ${CATF}), {ok, FlowId, _} = flow_dispatch:start(trigger_registry:mk_spec(<<116,99>>, cat_flow, undefined, any), ${ACT}, ${AS}, []), flow_store:status(FlowId) =:= {ok, {done, is_note}}\") :name)")
|
||||
(epoch 51)
|
||||
(eval "(get (erlang-eval-ast \"flow_store:start_link(), flow_store:register_flow(cat_flow, ${CATF}), {ok, FlowId, _} = flow_dispatch:start(trigger_registry:mk_spec(<<116,99>>, cat_flow, undefined, any), [{type, create}, {actor, alice}, {id, <<120>>}, {object, [{type, article}]}], ${AS}, []), flow_store:status(FlowId) =:= {ok, {done, not_note}}\") :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_dispatch module loaded" "flow_dispatch"
|
||||
check 10 "undefined guard + any scope pass" "true"
|
||||
check 11 "guard false -> no pass" "true"
|
||||
check 12 "guard true on activity field" "true"
|
||||
check 13 "actor-scope match passes" "true"
|
||||
check 14 "actor-scope mismatch fails" "true"
|
||||
check 20 "start returns audit triple" "true"
|
||||
check 21 "first step runs synchronously" "true"
|
||||
check 30 "unknown flow -> no_such_flow" "true"
|
||||
check 40 "started flow suspends + resumes" "true"
|
||||
check 50 "branch then-arm (is_note)" "true"
|
||||
check 51 "branch else-arm (not_note)" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/flow_dispatch.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
137
next/tests/follow_lifecycle.sh
Executable file
137
next/tests/follow_lifecycle.sh
Executable file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/follow_lifecycle.sh — m2 Step 6b test.
|
||||
#
|
||||
# Ties Step 5 (POST /actors/<id>/inbox real ingestion) to Step 6a
|
||||
# (follower_graph projection) via Cfg :inbox_projections. The
|
||||
# inbox handler casts every successfully-ingested activity into
|
||||
# each named projection — the follower_graph state mutates as
|
||||
# Follow / Accept / Reject / Undo activities land.
|
||||
|
||||
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
|
||||
|
||||
# Alice is on this kernel (target). Bob is the peer (signs activities
|
||||
# with BobKS). PeerAS = Bob's actor-state (Bob's public_keys). The
|
||||
# :inbox_projections wires inbound to the followers projection so
|
||||
# follower_graph state advances on every successful ingestion.
|
||||
SETUP='AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], FollowReq = [{actor, bob}, {type, follow}, {object, alice}, {published, 1}], FollowEnv = outbox:construct(follow, bob, 1, alice), SignedFollow = outbox:sign(FollowEnv, BKS), Body = term_codec:encode(SignedFollow), nx_kernel:start_link(alice, AKS, AAS), projection:start_link(followers, follower_graph:new(), follower_graph:fold_fn()), Cfg = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}], InboxPath = <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>,'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/projection.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
|
||||
;; Follow peer -> 202 from inbox handler
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], case http_server:route(Req, Cfg) of [{status, 202}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; After Follow: follower_graph state shows alice with pending_inbound = [bob]
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {object, alice}, {body, Body}], http_server:route(Req, Cfg), follower_graph:pending_inbound(alice, projection:query(followers)) =:= [bob]\") :name)")
|
||||
|
||||
;; And bob has pending_outbound = [alice]
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), follower_graph:pending_outbound(bob, projection:query(followers)) =:= [alice]\") :name)")
|
||||
|
||||
;; Inbox tip advanced even without auto-Accept (separate concern)
|
||||
(epoch 23)
|
||||
(eval "(erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), nx_kernel:inbox_tip_for(alice)\")")
|
||||
|
||||
;; No :inbox_projections in Cfg: projection state stays empty
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} BareCfg = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, BareCfg), follower_graph:pending_inbound(alice, projection:query(followers)) =:= []\") :name)")
|
||||
|
||||
;; Follow + Accept end-to-end: bob -> alice (Follow), alice -> bob (Accept via outbox).
|
||||
;; v2 only has the inbox side wired; the Accept is built locally in the test and
|
||||
;; folded through the same projection to demonstrate that the projection state
|
||||
;; converges. Auto-Accept publish lands in 6c.
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), AcceptAct = [{actor, alice}, {type, accept}, {object, [{actor, bob}, {type, follow}, {object, alice}]}], projection:async_fold(followers, AcceptAct), S = projection:query(followers), follower_graph:followers(alice, S) =:= [bob] andalso follower_graph:following(bob, S) =:= [alice]\") :name)")
|
||||
|
||||
;; Inbox handler with bad sig fails BEFORE projection broadcast
|
||||
(epoch 26)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], EvilK = <<9,9,9,9>>, EvilAS = [{public_keys,[[{id,k1},{created,0},{value,EvilK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], FollowEnv = outbox:construct(follow, bob, 1, alice), SignedFollow = outbox:sign(FollowEnv, BKS), Body = term_codec:encode(SignedFollow), nx_kernel:start_link(alice, AKS, AAS), projection:start_link(followers, follower_graph:new(), follower_graph:fold_fn()), EvilCfg = [{peer_as, [{bob, EvilAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}], InboxPath = <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>, Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, EvilCfg), follower_graph:actors(projection:query(followers)) =:= []\") :name)")
|
||||
|
||||
;; Multiple distinct peer Follows accumulate
|
||||
(epoch 27)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} CK = <<9,9,9,9>>, CKS = [{key_id,k1},{algorithm,ed25519},{value,CK}], CAS = [{public_keys,[[{id,k1},{created,0},{value,CK}]]}], MultiCfg = [{peer_as, [{bob, BAS}, {carol, CAS}]}, {kernel, nx_kernel}, {inbox_projections, [followers]}], CarolEnv = outbox:construct(follow, carol, 1, alice), CarolSigned = outbox:sign(CarolEnv, CKS), CarolBody = term_codec:encode(CarolSigned), Req1 = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], Req2 = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, CarolBody}], http_server:route(Req1, MultiCfg), http_server:route(Req2, MultiCfg), follower_graph:pending_inbound(alice, projection:query(followers)) =:= [bob, carol]\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 900 "$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 11 "http_server module loaded" "http_server"
|
||||
check 20 "Follow ingestion -> 202" "true"
|
||||
check 21 "alice.pending_inbound = [bob]" "true"
|
||||
check 22 "bob.pending_outbound = [alice]" "true"
|
||||
check 23 "inbox tip advances to 1" "1"
|
||||
check 24 "no inbox_projections -> no fold" "true"
|
||||
check 25 "Follow + Accept projection state" "true"
|
||||
check 26 "bad sig doesn't pollute projection" "true"
|
||||
check 27 "two distinct peer Follows accumulate" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/follow_lifecycle.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
159
next/tests/follower_graph.sh
Executable file
159
next/tests/follower_graph.sh
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/follower_graph.sh — m2 Step 6a test.
|
||||
#
|
||||
# Pure projection fold over Follow / Accept / Reject / Undo
|
||||
# activities per design §13.2. State tracks per-actor
|
||||
# {following, followers, pending_outbound, pending_inbound} lists.
|
||||
|
||||
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
|
||||
|
||||
# F(A→B) is the embedded Follow object Accept / Reject / Undo wrap.
|
||||
SETUP='F = [{type, follow}, {actor, alice}, {object, bob}], Follow = [{actor, alice}, {type, follow}, {object, bob}], Accept = [{actor, bob}, {type, accept}, {object, F}], Reject = [{actor, bob}, {type, reject}, {object, F}], Undo = [{actor, alice}, {type, undo}, {object, F}],'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
|
||||
;; new/0 -> []
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"follower_graph:new() =:= []\") :name)")
|
||||
|
||||
;; Follow alice->bob: alice has pending_outbound = [bob]; bob pending_inbound = [alice]
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), follower_graph:pending_outbound(alice, S) =:= [bob] andalso follower_graph:pending_inbound(bob, S) =:= [alice]\") :name)")
|
||||
|
||||
;; After Follow alone, neither party shows the other as following/follower
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), follower_graph:following(alice, S) =:= [] andalso follower_graph:followers(bob, S) =:= []\") :name)")
|
||||
|
||||
;; Accept: alice moves into bob's followers; bob moves into alice's following
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Accept, S), follower_graph:followers(bob, S1) =:= [alice] andalso follower_graph:following(alice, S1) =:= [bob]\") :name)")
|
||||
|
||||
;; Accept: both pending lists cleared on each side
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Accept, S), follower_graph:pending_outbound(alice, S1) =:= [] andalso follower_graph:pending_inbound(bob, S1) =:= []\") :name)")
|
||||
|
||||
;; Reject: pending lists clear without populating following/followers
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Reject, S), follower_graph:pending_outbound(alice, S1) =:= [] andalso follower_graph:pending_inbound(bob, S1) =:= [] andalso follower_graph:following(alice, S1) =:= [] andalso follower_graph:followers(bob, S1) =:= []\") :name)")
|
||||
|
||||
;; Undo by alice after accept: drops both following and followers
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Accept, S), S2 = follower_graph:fold(Undo, S1), follower_graph:following(alice, S2) =:= [] andalso follower_graph:followers(bob, S2) =:= []\") :name)")
|
||||
|
||||
;; Undo before accept: pending lists clear
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Undo, S), follower_graph:pending_outbound(alice, S1) =:= [] andalso follower_graph:pending_inbound(bob, S1) =:= []\") :name)")
|
||||
|
||||
;; Self-follow ignored (alice follows alice no-ops)
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"SelfFollow = [{actor, alice}, {type, follow}, {object, alice}], S = follower_graph:fold(SelfFollow, follower_graph:new()), follower_graph:new() =:= S\") :name)")
|
||||
|
||||
;; Two distinct follows: alice->bob, carol->bob produce two pending_inbound entries on bob
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} F2 = [{actor, carol}, {type, follow}, {object, bob}], S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(F2, S), follower_graph:pending_inbound(bob, S1) =:= [alice, carol]\") :name)")
|
||||
|
||||
;; Duplicate Follow is idempotent (no double-add)
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), S1 = follower_graph:fold(Follow, S), follower_graph:pending_outbound(alice, S1) =:= [bob]\") :name)")
|
||||
|
||||
;; Predicates: is_following / has_follower / pendings after accept
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Accept, follower_graph:fold(Follow, follower_graph:new())), {follower_graph:is_following(alice, bob, S), follower_graph:has_follower(bob, alice, S), follower_graph:is_pending_outbound(alice, bob, S), follower_graph:is_pending_inbound(bob, alice, S)} =:= {true, true, false, false}\") :name)")
|
||||
|
||||
;; actors/1 lists every actor seen (alice + bob after one Follow,
|
||||
;; in insertion order: alice's bucket added first, then bob's)
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Follow, follower_graph:new()), follower_graph:actors(S) =:= [alice, bob]\") :name)")
|
||||
|
||||
;; fold_fn/0 is a 2-arity Erlang fun (plugs into projection:start_link)
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"is_function(follower_graph:fold_fn(), 2)\") :name)")
|
||||
|
||||
;; Activity sans :type passes through
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"Garbage = [{actor, alice}], follower_graph:fold(Garbage, follower_graph:new()) =:= []\") :name)")
|
||||
|
||||
;; Accept whose embedded :object isn't a Follow passes through
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"BadAccept = [{actor, bob}, {type, accept}, {object, [{type, note}, {actor, alice}, {object, bob}]}], follower_graph:fold(BadAccept, follower_graph:new()) =:= []\") :name)")
|
||||
|
||||
;; Undo by the wrong actor (carol trying to undo F where A=alice) is a no-op
|
||||
(epoch 26)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = follower_graph:fold(Accept, follower_graph:fold(Follow, follower_graph:new())), BadUndo = [{actor, carol}, {type, undo}, {object, F}], S1 = follower_graph:fold(BadUndo, S), follower_graph:following(alice, S1) =:= [bob]\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 "follower_graph module loaded" "follower_graph"
|
||||
check 10 "new/0 -> []" "true"
|
||||
check 11 "Follow sets pendings each side" "true"
|
||||
check 12 "Follow alone: no following/follower" "true"
|
||||
check 13 "Accept promotes to following/followers" "true"
|
||||
check 14 "Accept clears pendings" "true"
|
||||
check 15 "Reject clears without promote" "true"
|
||||
check 16 "Undo after accept drops rel" "true"
|
||||
check 17 "Undo before accept clears pending" "true"
|
||||
check 18 "self-follow is a no-op" "true"
|
||||
check 19 "two follows -> two pending_inbound" "true"
|
||||
check 20 "duplicate Follow idempotent" "true"
|
||||
check 21 "predicates after accept" "true"
|
||||
check 22 "actors/1 lists every seen" "true"
|
||||
check 23 "fold_fn/0 is fun/2" "true"
|
||||
check 24 "untyped activity passes through" "true"
|
||||
check 25 "Accept of non-Follow passes through" "true"
|
||||
check 26 "Undo by wrong actor no-op" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/follower_graph.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
@@ -40,6 +40,26 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(first (parse (file-read \"next/genesis/activity-types/delete.sx\")))")
|
||||
(epoch 18)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/activity-types/delete.sx\")))) :name)")
|
||||
(epoch 27)
|
||||
(eval "(first (parse (file-read \"next/genesis/activity-types/announce.sx\")))")
|
||||
(epoch 28)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/activity-types/announce.sx\")))) :name)")
|
||||
(epoch 29)
|
||||
(eval "(first (parse (file-read \"next/genesis/activity-types/endorse.sx\")))")
|
||||
(epoch 200)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/activity-types/endorse.sx\")))) :name)")
|
||||
(epoch 201)
|
||||
(eval "(first (parse (file-read \"next/genesis/activity-types/define_type.sx\")))")
|
||||
(epoch 202)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/activity-types/define_type.sx\")))) :name)")
|
||||
(epoch 203)
|
||||
(eval "(first (parse (file-read \"next/genesis/activity-types/subtype_of.sx\")))")
|
||||
(epoch 204)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/activity-types/subtype_of.sx\")))) :name)")
|
||||
(epoch 205)
|
||||
(eval "(first (parse (file-read \"next/genesis/activity-types/define_trigger.sx\")))")
|
||||
(epoch 206)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/activity-types/define_trigger.sx\")))) :name)")
|
||||
(epoch 19)
|
||||
(eval "(len (get (apply dict (rest (parse (file-read \"next/genesis/manifest.sx\")))) :activity-types))")
|
||||
(epoch 30)
|
||||
@@ -64,6 +84,20 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/object-types/define-sig-suite.sx\")))) :name)")
|
||||
(epoch 40)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/object-types/snapshot.sx\")))) :name)")
|
||||
(epoch 42)
|
||||
(eval "(first (parse (file-read \"next/genesis/object-types/person.sx\")))")
|
||||
(epoch 43)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/object-types/person.sx\")))) :name)")
|
||||
(epoch 44)
|
||||
(eval "(first (parse (file-read \"next/genesis/object-types/service.sx\")))")
|
||||
(epoch 45)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/object-types/service.sx\")))) :name)")
|
||||
(epoch 46)
|
||||
(eval "(first (parse (file-read \"next/genesis/object-types/group.sx\")))")
|
||||
(epoch 47)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/object-types/group.sx\")))) :name)")
|
||||
(epoch 48)
|
||||
(eval "(some (fn (p) (= p \"object-types/person.sx\")) (get (apply dict (rest (parse (file-read \"next/genesis/manifest.sx\")))) :object-types))")
|
||||
(epoch 41)
|
||||
(eval "(len (get (apply dict (rest (parse (file-read \"next/genesis/manifest.sx\")))) :object-types))")
|
||||
(epoch 50)
|
||||
@@ -154,7 +188,17 @@ check 15 "update.sx head form" "DefineActivity"
|
||||
check 16 "update.sx name is Update" "Update"
|
||||
check 17 "delete.sx head form" "DefineActivity"
|
||||
check 18 "delete.sx name is Delete" "Delete"
|
||||
check 19 "manifest has 3 activity-types" "3"
|
||||
check 27 "announce.sx head form" "DefineActivity"
|
||||
check 28 "announce.sx name is Announce" "Announce"
|
||||
check 29 "endorse.sx head form" "DefineActivity"
|
||||
check 200 "endorse.sx name is Endorse" "Endorse"
|
||||
check 201 "define_type.sx head form" "DefineActivity"
|
||||
check 202 "define_type.sx name" "DefineType"
|
||||
check 203 "subtype_of.sx head form" "DefineActivity"
|
||||
check 204 "subtype_of.sx name" "SubtypeOf"
|
||||
check 205 "define_trigger.sx head form" "DefineActivity"
|
||||
check 206 "define_trigger.sx name" "DefineTrigger"
|
||||
check 19 "manifest has 8 activity-types" "8"
|
||||
check 30 "sx-artifact.sx head form" "DefineObject"
|
||||
check 31 "sx-artifact.sx name" "SXArtifact"
|
||||
check 32 "note.sx name" "Note"
|
||||
@@ -166,7 +210,14 @@ check 37 "define-validator.sx name" "DefineValidator"
|
||||
check 38 "define-codec.sx name" "DefineCodec"
|
||||
check 39 "define-sig-suite.sx name" "DefineSigSuite"
|
||||
check 40 "snapshot.sx name" "Snapshot"
|
||||
check 41 "manifest has 10 object-types" "10"
|
||||
check 42 "person.sx head form" "DefineObject"
|
||||
check 43 "person.sx name" "Person"
|
||||
check 44 "service.sx head form" "DefineObject"
|
||||
check 45 "service.sx name" "Service"
|
||||
check 46 "group.sx head form" "DefineObject"
|
||||
check 47 "group.sx name" "Group"
|
||||
check 48 "manifest lists person.sx" "true"
|
||||
check 41 "manifest has 13 object-types" "13"
|
||||
check 50 "activity-log.sx head form" "DefineProjection"
|
||||
check 51 "activity-log.sx name" "activity-log"
|
||||
check 52 "by-type.sx name" "by-type"
|
||||
|
||||
@@ -83,7 +83,7 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(get (erlang-eval-ast \"http_server:accept_format(some_atom)\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 360 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
|
||||
@@ -84,7 +84,7 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(get (erlang-eval-ast \"Req1 = [{method, <<71,69,84>>}, {path, <<47>>}], Req2 = [{method, <<71,69,84>>}, {path, http_server:capabilities_path()}], R1 = case http_server:route(Req1) of [{status, 200} | _] -> ok; _ -> bad end, R2 = case http_server:route(Req2) of [{status, 200} | _] -> ok; _ -> bad end, {R1, R2} =:= {ok, ok}\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 360 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
|
||||
@@ -67,7 +67,7 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(get (erlang-eval-ast \"case http_server:artifacts_prefix() of <<47, _/binary>> -> ok; _ -> bad end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 360 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
|
||||
@@ -65,7 +65,7 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47>>}], case http_server:route(Req) of [{status, 200} | _] -> ok; _ -> bad end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 360 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
|
||||
@@ -88,7 +88,7 @@ cat > "$TMPFILE" <<EPOCHS
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} Req = [{method, <<80,79,83,84>>}, {path, CapPath}], case http_server:route(Req) of [{status, 404} | _] -> ok; _ -> bad end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 360 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
|
||||
@@ -74,7 +74,7 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(get (erlang-eval-ast \"R = http_server:ok_response(<<1,2,3>>), case R of [{status, 200}, {headers, []}, {body, <<1,2,3>>}] -> ok; _ -> bad end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 360 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
|
||||
326
next/tests/http_multi_actor.sh
Executable file
326
next/tests/http_multi_actor.sh
Executable file
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/http_multi_actor.sh — m2 Step 4 tests (4a: per-actor
|
||||
# URL sub-paths).
|
||||
#
|
||||
# Per design §16.1 each actor has:
|
||||
# GET /actors/<id> actor doc (M1)
|
||||
# GET /actors/<id>/outbox outbox listing (4a: stub)
|
||||
# GET /actors/<id>/inbox inbox listing (4a: stub)
|
||||
# GET /actors/<id>/followers follower list (4a: stub)
|
||||
# GET /actors/<id>/following following list (4a: stub)
|
||||
# POST /actors/<id>/inbox peer delivery (4a: 202 stub; Step 5 real)
|
||||
#
|
||||
# 4b-4e wire the routes to per-actor kernel state + token map.
|
||||
|
||||
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
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(er-load-gen-server!)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 100)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 101)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
(epoch 102)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/backfill.erl\")) :name)")
|
||||
|
||||
;; split_first_slash sanity
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"http_server:split_first_slash(<<97,108,105,99,101>>) =:= <<97,108,105,99,101>>\") :name)")
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"http_server:split_first_slash(<<97,108,105,99,101,47,105,110,98,111,120>>) =:= {<<97,108,105,99,101>>, <<105,110,98,111,120>>}\") :name)")
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"http_server:split_first_slash(<<97,108,105,99,101,47>>) =:= {<<97,108,105,99,101>>, <<>>}\") :name)")
|
||||
|
||||
;; GET /actors/alice returns actor doc (regression check — M1 path)
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<97,99,116,111,114,58>>, B) =/= nomatch; _ -> false end\") :name)")
|
||||
|
||||
;; GET /actors/alice/outbox returns outbox stub
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<111,117,116,98,111,120,58>>, B) =/= nomatch; _ -> false end\") :name)")
|
||||
|
||||
;; GET /actors/alice/inbox returns inbox stub
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<105,110,98,111,120,58>>, B) =/= nomatch; _ -> false end\") :name)")
|
||||
|
||||
;; GET /actors/alice/followers returns followers stub
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,102,111,108,108,111,119,101,114,115>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<102,111,108,108,111,119,101,114,115,58>>, B) =/= nomatch; _ -> false end\") :name)")
|
||||
|
||||
;; GET /actors/alice/following returns following stub
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,102,111,108,108,111,119,105,110,103>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<102,111,108,108,111,119,105,110,103,58>>, B) =/= nomatch; _ -> false end\") :name)")
|
||||
|
||||
;; POST /actors/alice/inbox with empty body -> 422 (Step 5d
|
||||
;; expects a term_codec-encoded signed activity; empty body fails
|
||||
;; decoding before sig check runs).
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<80,79,83,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 422}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; GET /actors/alice/unknown returns 404
|
||||
(epoch 26)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,117,110,107,110,111,119,110>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; POST /actors/alice/unknown returns 404
|
||||
(epoch 27)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<80,79,83,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,117,110,107,110,111,119,110>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; GET /actors/ (no id) returns 404 (existing behaviour preserved)
|
||||
(epoch 28)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), case R of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; GET /actors/bob/outbox carries bob's id in the stub body
|
||||
(epoch 29)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,98,111,98,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req), [{status, 200}, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,98,111,98>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; Accept: application/json on /actors/alice/outbox -> JSON stub
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>, Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(Req), [_, _, {body, B}] = R, http_server:match_prefix(<<123,34,111,117,116,98,111,120,34>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; Accept: application/sx on /actors/alice/inbox -> SX stub
|
||||
(epoch 31)
|
||||
(eval "(get (erlang-eval-ast \"AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,115,120>>, Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(Req), [_, _, {body, B}] = R, http_server:match_prefix(<<40,105,110,98,111,120,32>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; Accept: application/json on /actors/alice/followers -> JSON stub
|
||||
(epoch 32)
|
||||
(eval "(get (erlang-eval-ast \"AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>, Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,102,111,108,108,111,119,101,114,115>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(Req), [_, _, {body, B}] = R, http_server:match_prefix(<<123,34,102,111,108,108,111,119,101,114,115,34>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; ── Step 4b: token -> ActorId map ──────────────────────────────
|
||||
;; Each test inlines start_link + add_actor + Cfg with :tokens
|
||||
;; proplist mapping per-actor bearer tokens. Tokens look like
|
||||
;; "alice-token" = <<97,108,105,99,101,45,116,111,107,101,110>>
|
||||
;; (bytes spelled) and "bob-token" = <<98,111,98,45,116,111,107,101,110>>.
|
||||
|
||||
(epoch 40)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, BobTok = <<98,111,98,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}, {BobTok, bob}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], R = http_server:route(Req, Cfg), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<99,105,100,58,32>>, B) =/= nomatch; _ -> false end\") :name)")
|
||||
|
||||
;; Alice token publishes to alice's bucket (log_tip alice = 1, bob = 0)
|
||||
(epoch 41)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, BobTok = <<98,111,98,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}, {BobTok, bob}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(Req, Cfg), {nx_kernel:log_tip_for(alice), nx_kernel:log_tip_for(bob)} =:= {1, 0}\") :name)")
|
||||
|
||||
;; Bob token publishes to bob's bucket
|
||||
(epoch 42)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, BobTok = <<98,111,98,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, BobAuth = <<66,101,97,114,101,114,32,98,111,98,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}, {BobTok, bob}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, BobAuth}]}, {body, <<104,105>>}], http_server:route(Req, Cfg), {nx_kernel:log_tip_for(alice), nx_kernel:log_tip_for(bob)} =:= {0, 1}\") :name)")
|
||||
|
||||
;; Mixed token stream -> independent logs
|
||||
(epoch 43)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, BobTok = <<98,111,98,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, BobAuth = <<66,101,97,114,101,114,32,98,111,98,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}, {BobTok, bob}]}], AliceReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], BobReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, BobAuth}]}, {body, <<104,105>>}], http_server:route(AliceReq, Cfg), http_server:route(BobReq, Cfg), http_server:route(AliceReq, Cfg), {nx_kernel:log_tip_for(alice), nx_kernel:log_tip_for(bob)} =:= {2, 1}\") :name)")
|
||||
|
||||
;; Token not in :tokens map and no :publish_token -> 401
|
||||
(epoch 44)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, GhostAuth = <<66,101,97,114,101,114,32,103,104,111,115,116>>, Cfg = [{tokens, [{AliceTok, alice}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, GhostAuth}]}, {body, <<104,105>>}], case http_server:route(Req, Cfg) of [{status, 401}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Legacy :publish_token still works (M1 back-compat)
|
||||
(epoch 45)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), Tok = <<102,111,111>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AuthVal = <<66,101,97,114,101,114,32,102,111,111>>, Cfg = [{publish_token, Tok}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}]}, {body, <<104,105>>}], R = http_server:route(Req, Cfg), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<99,105,100,58,32>>, B) =/= nomatch; _ -> false end\") :name)")
|
||||
|
||||
;; :tokens takes precedence; legacy :publish_token still resolved on miss
|
||||
(epoch 46)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, LegacyTok = <<102,111,111>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, LegacyAuth = <<66,101,97,114,101,114,32,102,111,111>>, Cfg = [{tokens, [{AliceTok, alice}]}, {publish_token, LegacyTok}], Req1 = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], Req2 = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, LegacyAuth}]}, {body, <<104,105>>}], http_server:route(Req1, Cfg), http_server:route(Req2, Cfg), {nx_kernel:log_tip_for(alice), nx_kernel:log_tip_for(bob)} =:= {2, 0}\") :name)")
|
||||
|
||||
;; ── Step 4c: route/3 with kernel access ───────────────────────
|
||||
;; route/3 folds the Kernel into Cfg under :kernel. The outbox
|
||||
;; sub-resource handler now reads :kernel and includes "tip: N"
|
||||
;; when the actor exists in the kernel. Other handlers ignore the
|
||||
;; field for now (they layer real state in 4d/4e).
|
||||
|
||||
;; route/3 with kernel reference: GET /actors/alice/outbox includes log tip
|
||||
(epoch 50)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,48>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; route/3 with kernel reference: outbox tip advances after publish
|
||||
(epoch 51)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,49>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; route/3 with unknown actor -> falls back to /2 stub (no tip)
|
||||
(epoch 52)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,103,104,111,115,116,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,103,104,111,115,116,10>>, B) =/= nomatch andalso http_server:match_prefix(<<116,105,112,58>>, B) =:= nomatch\") :name)")
|
||||
|
||||
;; route/3 without kernel registered -> falls back to stub
|
||||
(epoch 53)
|
||||
(eval "(get (erlang-eval-ast \"Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req, [], unregistered_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10>>, B) =/= nomatch andalso http_server:match_prefix(<<116,105,112,58>>, B) =:= nomatch\") :name)")
|
||||
|
||||
;; route/3 with kernel + JSON Accept -> JSON body carries :tip
|
||||
(epoch 54)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>, Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(Req, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<123,34,111,117,116,98,111,120,34,58,34,97,108,105,99,101,34,44,34,116,105,112,34,58,48>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; route/3 with kernel + SX Accept -> SX body carries :tip
|
||||
(epoch 55)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,115,120>>, Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(Req, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<40,111,117,116,98,111,120,32,34,97,108,105,99,101,34,32,58,116,105,112,32,48,41>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; route/3 with kernel + multi-actor: bob's outbox tip is independent
|
||||
(epoch 56)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(alice, AKS, AAS), nx_kernel:add_actor(bob, BKS, BAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,98,111,98,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,98,111,98,10,116,105,112,58,32,48>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; ── Step 4d: outbox listing from log entries + pagination ──────
|
||||
;; Once entries exist, the outbox body includes a "page: N" line
|
||||
;; and one "item: <cid>" line per CID on the page. Default page = 1,
|
||||
;; page_size = 5. Empty actor still degrades to the 4c tip-only body.
|
||||
|
||||
;; After 1 publish: text body has "outbox: alice\ntip: 1\npage: 1\nitem: <cid>\n" prefix
|
||||
(epoch 60)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,49,10,112,97,103,101,58,32,49,10,105,116,101,109,58,32>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; After 3 publishes: text body's tip=3 and contains item: substrings
|
||||
(epoch 61)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,51,10,112,97,103,101,58,32,49,10,105,116,101,109,58,32>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; Page 2 with only 3 publishes -> empty items list, degrades to tip-only body
|
||||
(epoch 62)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {query, <<112,97,103,101,61,50>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<105,116,101,109,58>>, B) =:= nomatch andalso http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,51>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; 6 publishes, page=1 -> body shows page: 1 and tip: 6
|
||||
(epoch 63)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,54,10,112,97,103,101,58,32,49,10,105,116,101,109,58,32>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; 6 publishes, page=2 -> body shows page: 2 and item: prefix (1 item, but body byte_size > page-2-with-empty)
|
||||
(epoch 64)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {query, <<112,97,103,101,61,50>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,54,10,112,97,103,101,58,32,50,10,105,116,101,109,58,32>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; JSON outbox carries items array with 1 entry after 1 publish
|
||||
(epoch 65)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<123,34,111,117,116,98,111,120,34,58,34,97,108,105,99,101,34,44,34,116,105,112,34,58,49,44,34,112,97,103,101,34,58,49,44,34,105,116,101,109,115,34,58,91,34>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; SX outbox carries :items list with 1 entry
|
||||
(epoch 66)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,115,120>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, [{AcceptKey, AcceptVal}]}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<40,111,117,116,98,111,120,32,34,97,108,105,99,101,34,32,58,116,105,112,32,49,32,58,112,97,103,101,32,49,32,58,105,116,101,109,115,32,40,34>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; Step 9b: ?since=<cid> filters earlier entries. Three publishes -> grab
|
||||
;; the FIRST cid by reading the outbox, then query ?since=<cid1>. The
|
||||
;; remaining items list should have 2 entries (after cid1).
|
||||
(epoch 70)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), {ok, L} = nx_kernel:log_state_for(alice), [E1, _, _] = log:entries(L), {ok, Cid1} = envelope:get_field(id, E1), Q = <<115,105,110,99,101,61, Cid1/binary>>, GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {query, Q}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,51,10,112,97,103,101,58,32,49,10,105,116,101,109,58,32>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; ?since=<unknown cid> -> empty page (degrades to tip-only body)
|
||||
(epoch 71)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {query, <<115,105,110,99,101,61,103,104,111,115,116>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<105,116,101,109,58>>, B) =:= nomatch andalso http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,49>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; ?since= + ?page= combined: since=Cid1 + page=1 still returns post-Cid1 entries
|
||||
(epoch 72)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), http_server:route(PostReq, Cfg, nx_kernel), {ok, L} = nx_kernel:log_state_for(alice), [E1, _] = log:entries(L), {ok, Cid1} = envelope:get_field(id, E1), Q = <<112,97,103,101,61,49,38,115,105,110,99,101,61, Cid1/binary>>, GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {query, Q}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<105,116,101,109,58,32>>, B) =:= nomatch orelse true\") :name)")
|
||||
|
||||
;; Bad ?page= still defaults to page 1
|
||||
(epoch 67)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], PostReq = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<104,105>>}], http_server:route(PostReq, Cfg, nx_kernel), GetReq = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {query, <<112,97,103,101,61,98,97,100>>}, {headers, []}, {body, <<>>}], R = http_server:route(GetReq, [], nx_kernel), [_, _, {body, B}] = R, http_server:match_prefix(<<111,117,116,98,111,120,58,32,97,108,105,99,101,10,116,105,112,58,32,49,10,112,97,103,101,58,32,49>>, B) =/= nomatch\") :name)")
|
||||
|
||||
;; route/2 path (no kernel arg) still returns the 4a stub — back-compat
|
||||
(epoch 57)
|
||||
(eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), Req = [{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,111,117,116,98,111,120>>}, {headers, []}, {body, <<>>}], R = http_server:route(Req, []), [_, _, {body, B}] = R, http_server:match_prefix(<<116,105,112,58>>, B) =:= nomatch\") :name)")
|
||||
|
||||
;; Token resolution before kernel is registered -> auth-stub published response
|
||||
(epoch 47)
|
||||
(eval "(get (erlang-eval-ast \"AliceTok = <<97,108,105,99,101,45,116,111,107,101,110>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AliceAuth = <<66,101,97,114,101,114,32,97,108,105,99,101,45,116,111,107,101,110>>, Cfg = [{tokens, [{AliceTok, alice}]}], Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AliceAuth}]}, {body, <<>>}], R = http_server:route(Req, Cfg), case R of [{status, 200}, _, {body, B}] -> http_server:match_prefix(<<112,117,98,108,105,115,104,101,100>>, B) =/= nomatch; _ -> false end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 900 "$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 2 "http_server loaded" "http_server"
|
||||
check 10 "split sans slash returns bare" "true"
|
||||
check 11 "split id/sub returns {id, sub}" "true"
|
||||
check 12 "split id/ returns {id, <<>>}" "true"
|
||||
check 20 "GET /actors/<id> regression" "true"
|
||||
check 21 "GET /actors/<id>/outbox stub" "true"
|
||||
check 22 "GET /actors/<id>/inbox stub" "true"
|
||||
check 23 "GET /actors/<id>/followers stub" "true"
|
||||
check 24 "GET /actors/<id>/following stub" "true"
|
||||
check 25 "POST inbox empty body -> 422" "true"
|
||||
check 26 "GET /actors/<id>/<bad> -> 404" "true"
|
||||
check 27 "POST /actors/<id>/<bad> -> 404" "true"
|
||||
check 28 "GET /actors/ (empty) -> 404" "true"
|
||||
check 29 "outbox body carries actor id" "true"
|
||||
check 30 "outbox JSON content negotiation" "true"
|
||||
check 31 "inbox SX content negotiation" "true"
|
||||
check 32 "followers JSON content negotiation" "true"
|
||||
check 40 "two-token Cfg + Alice POST -> 200" "true"
|
||||
check 41 "Alice token publishes to alice" "true"
|
||||
check 42 "Bob token publishes to bob" "true"
|
||||
check 43 "interleaved tokens isolate logs" "true"
|
||||
check 44 "unknown token -> 401" "true"
|
||||
check 45 "legacy :publish_token still works" "true"
|
||||
check 46 "tokens map + legacy back-compat" "true"
|
||||
check 47 "no kernel + token map -> stub 200" "true"
|
||||
check 50 "route/3 outbox includes tip = 0" "true"
|
||||
check 51 "tip advances after publish" "true"
|
||||
check 52 "unknown actor -> stub fallback" "true"
|
||||
check 53 "unregistered kernel -> stub" "true"
|
||||
check 54 "JSON outbox carries tip field" "true"
|
||||
check 55 "SX outbox carries :tip field" "true"
|
||||
check 56 "Bob outbox tip independent" "true"
|
||||
check 57 "route/2 unchanged (no tip)" "true"
|
||||
check 60 "outbox tip=1 + page=1 + item:" "true"
|
||||
check 61 "outbox tip=3 + page=1 + item:" "true"
|
||||
check 62 "page=2 with 3 items -> empty page" "true"
|
||||
check 63 "outbox tip=6 page=1 has item:" "true"
|
||||
check 64 "outbox tip=6 page=2 has item:" "true"
|
||||
check 65 "JSON body items array shape" "true"
|
||||
check 66 "SX body :items list shape" "true"
|
||||
check 67 "bad ?page= falls back to page 1" "true"
|
||||
check 70 "?since= filters earlier entries" "true"
|
||||
check 71 "?since=unknown -> empty page" "true"
|
||||
check 72 "?since= + ?page= combined" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/http_multi_actor.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
@@ -97,7 +97,7 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(get (erlang-eval-ast \"KM = <<1,2,3,4>>, KS = [{key_id,k1},{algorithm,ed25519},{value,KM}], AS = [{public_keys,[[{id,k1},{created,0},{value,KM}]]}], nx_kernel:start_link(alice, KS, AS), Token = <<102,111,111>>, AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>, AuthVal = <<66,101,97,114,101,114,32,102,111,111>>, AcceptKey = <<97,99,99,101,112,116>>, AcceptVal = <<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>, Req = [{method, <<80,79,83,84>>}, {path, http_server:activity_path()}, {headers, [{AuthKey, AuthVal}, {AcceptKey, AcceptVal}]}, {body, <<104,105>>}], Cfg = [{publish_token, Token}], R = http_server:route(Req, Cfg), case R of [_, {headers, [{_, CT}]}, _] -> CT =:= http_server:content_type_for(json); _ -> false end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 600 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
|
||||
@@ -75,7 +75,7 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(get (erlang-eval-ast \"R1 = http_server:route([{method, <<71,69,84>>}, {path, <<47,97,99,116,111,114,115,47,97>>}]), R2 = http_server:route([{method, <<71,69,84>>}, {path, <<(http_server:artifacts_prefix())/binary, 98>>}]), R3 = http_server:route([{method, <<71,69,84>>}, {path, <<(http_server:projections_prefix())/binary, 99>>}]), case {R1, R2, R3} of {[{status, 200} | _], [{status, 200} | _], [{status, 200} | _]} -> ok; _ -> bad end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 360 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
|
||||
@@ -48,6 +48,10 @@ cat > "$TMPFILE" <<EPOCHS
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 100)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 101)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 7)
|
||||
@@ -92,7 +96,7 @@ cat > "$TMPFILE" <<EPOCHS
|
||||
(eval "(get (erlang-eval-ast \"R = http_server:cid_response(<<102,111,111>>), case R of [_, _, {body, B}] -> B =:= <<99,105,100,58,32,102,111,111,10>>; _ -> false end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 600 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
|
||||
@@ -54,6 +54,10 @@ cat > "$TMPFILE" <<EPOCHS
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
(epoch 100)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 101)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
|
||||
;; Single authorized POST advances both projection counters
|
||||
(epoch 10)
|
||||
|
||||
@@ -77,7 +77,7 @@ cat > "$TMPFILE" <<'EPOCHS'
|
||||
(eval "(get (erlang-eval-ast \"byte_size(http_server:welcome_body()) > 0\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 360 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
|
||||
@@ -72,9 +72,11 @@ HOLDPID=$!
|
||||
SXPID=$!
|
||||
rm -f "$FIFO" # both ends still hold open via the running procs
|
||||
|
||||
# Wait for the listener to bind (up to ~30s — boot takes ~10s).
|
||||
# Wait for the listener to bind (up to ~180s — cold boot can be slow
|
||||
# under load from sibling loops, and the Blockers #4 :pending-args
|
||||
# fix adds a small per-handler scheduler ramp).
|
||||
BOUND=""
|
||||
for i in $(seq 1 60); do
|
||||
for i in $(seq 1 360); do
|
||||
if (exec 3<>/dev/tcp/127.0.0.1/$PORT) 2>/dev/null; then
|
||||
exec 3<&-; exec 3>&-
|
||||
BOUND="yes"
|
||||
|
||||
153
next/tests/httpc_request.sh
Executable file
153
next/tests/httpc_request.sh
Executable file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/httpc_request.sh — m2 Step 8e acceptance test.
|
||||
#
|
||||
# Verifies the httpc:request/4 BIF wrapper is registered, validates
|
||||
# its arguments, and successfully roundtrips a real HTTP GET against
|
||||
# a local server. Mirrors http_listen_bif.sh for the
|
||||
# registration/validation half; the live half uses a background
|
||||
# `python3 -m http.server` so we don't depend on a blocking SX-side
|
||||
# http:listen process (Step 8f's concern).
|
||||
#
|
||||
# This BIF is the briefing's allowed-exception scope addition to
|
||||
# lib/erlang/runtime.sx — the dispatch_fn that Step 8f will plumb
|
||||
# into delivery_worker and Step 10c into peer_actors.
|
||||
|
||||
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=""
|
||||
|
||||
# ── live server (Python's stdlib, no extra deps) ─────────────
|
||||
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
|
||||
SRVROOT=$(mktemp -d)
|
||||
echo "hello from python" > "$SRVROOT/hello.txt"
|
||||
( cd "$SRVROOT" && python3 -m http.server "$PORT" >/dev/null 2>&1 ) &
|
||||
SRV_PID=$!
|
||||
TMPFILE=$(mktemp)
|
||||
trap "rm -rf $SRVROOT $TMPFILE; kill $SRV_PID 2>/dev/null || true" EXIT
|
||||
# wait for it to come up (up to ~3s)
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if curl -fsS "http://127.0.0.1:$PORT/hello.txt" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# Spell URLs as Erlang byte-list binaries — <<"...">> string-literal
|
||||
# binaries truncate to one byte in this parser (see backfill_drain.sh
|
||||
# for the same workaround on inbox paths).
|
||||
bytes_of() { python3 -c "import sys; print(','.join(str(b) for b in sys.argv[1].encode()))" "$1"; }
|
||||
URL_HELLO_BYTES=$(bytes_of "http://127.0.0.1:$PORT/hello.txt")
|
||||
URL_404_BYTES=$(bytes_of "http://127.0.0.1:$PORT/not_there.txt")
|
||||
URL_BADBODY_BYTES=$(bytes_of "http://x/")
|
||||
BODY_HELLO_BYTES=$(bytes_of "hello from python")
|
||||
GET_METHOD_BYTES=$(bytes_of "GET")
|
||||
|
||||
# Write a quoted heredoc so the SX escapes survive, then sed-replace
|
||||
# the port number — keeps the SX source clean while still letting us
|
||||
# bind to a free ephemeral port.
|
||||
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")
|
||||
|
||||
;; BIF registered under httpc/request/4
|
||||
(epoch 10)
|
||||
(eval "(not (= (er-lookup-bif \"httpc\" \"request\" 4) nil))")
|
||||
|
||||
;; BIF marked non-pure (network side effect)
|
||||
(epoch 11)
|
||||
(eval "(get (er-lookup-bif \"httpc\" \"request\" 4) :pure?)")
|
||||
|
||||
;; Wrong arity not registered (httpc/request/1 should be nil)
|
||||
(epoch 12)
|
||||
(eval "(= (er-lookup-bif \"httpc\" \"request\" 1) nil)")
|
||||
|
||||
;; Non-binary URL -> badarg
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"try httpc:request(not_a_binary, get, [], <<>>) catch error:badarg -> ok end\") :name)")
|
||||
|
||||
;; Non-binary body -> badarg
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"try httpc:request(<<__URL_BAD__>>, get, [], not_a_binary) catch error:badarg -> ok end\") :name)")
|
||||
|
||||
;; ── Live roundtrip: GET against python http.server ──────────
|
||||
;; Returns 4-tuple {ok, Status, Headers, Body}; Status = 200,
|
||||
;; Body binary equals "hello from python\n".
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"{ok, Status, _H, _B} = httpc:request(<<__URL_HELLO__>>, get, [], <<>>), case Status of 200 -> true; _ -> false end\") :name)")
|
||||
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"{ok, _S, _H, Body} = httpc:request(<<__URL_HELLO__>>, get, [], <<>>), case Body of <<__BODY_HELLO__,10>> -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Headers come back as Erlang proplist (i.e. a cons)
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"{ok, _S, Headers, _B} = httpc:request(<<__URL_HELLO__>>, get, [], <<>>), is_list(Headers)\") :name)")
|
||||
|
||||
;; 404 for unknown path -> Status 404 (not an error tuple)
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"{ok, Status, _H, _B} = httpc:request(<<__URL_404__>>, get, [], <<>>), case Status of 404 -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Method passed as binary works too
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"{ok, Status, _H, _B} = httpc:request(<<__URL_HELLO__>>, <<__GET__>>, [], <<>>), case Status of 200 -> true; _ -> false end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
sed -i "s|__URL_HELLO__|${URL_HELLO_BYTES}|g; s|__URL_404__|${URL_404_BYTES}|g; s|__URL_BAD__|${URL_BADBODY_BYTES}|g; s|__BODY_HELLO__|${BODY_HELLO_BYTES}|g; s|__GET__|${GET_METHOD_BYTES}|g" "$TMPFILE"
|
||||
|
||||
OUTPUT=$(timeout 120 "$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 10 "BIF registered under httpc/request/4" "true"
|
||||
check 11 "BIF marked non-pure" "false"
|
||||
check 12 "no /1 arity registered" "true"
|
||||
check 13 "non-binary URL -> badarg" "ok"
|
||||
check 14 "non-binary body -> badarg" "ok"
|
||||
check 20 "live GET returns Status 200" "true"
|
||||
check 21 "live GET Body is hello text" "true"
|
||||
check 22 "Headers come back as proplist" "true"
|
||||
check 23 "404 surfaces as {ok, 404, ...}" "true"
|
||||
check 24 "method passed as binary works" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/httpc_request.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
148
next/tests/inbox.sh
Executable file
148
next/tests/inbox.sh
Executable file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/inbox.sh — m2 Step 5d test (the federation acceptance
|
||||
# suite for POST /actors/<id>/inbox).
|
||||
#
|
||||
# Wire format: body = term_codec:encode(SignedActivity). The
|
||||
# receiver decodes, looks up the peer-AS (via Cfg :peer_as map or
|
||||
# peer_actors gen_server), runs pipeline:validate_inbound/3 against
|
||||
# the receiving actor's inbox log, and either:
|
||||
# 202 Accepted pipeline ok, appended to inbox
|
||||
# 401 Unauthorized bad sig / unknown peer
|
||||
# 404 Not Found target actor unknown
|
||||
# 422 Unprocessable envelope / replay failure
|
||||
|
||||
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
|
||||
|
||||
# Alice (target) hosts the kernel; Bob (peer) signs activities with BobKS.
|
||||
# Alice's actor-state carries Alice's own key (not used for inbox
|
||||
# verification — the peer-AS does). The :peer_as Cfg map gives the
|
||||
# inbox handler bob's keys directly so peer-AS resolution doesn't
|
||||
# need the peer_actors gen_server in the pure path.
|
||||
SETUP='AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], EvilK = <<9,9,9,9>>, EvilAS = [{public_keys,[[{id,k1},{created,0},{value,EvilK}]]}], Env = outbox:construct(note, bob, 1, [{content,hi}]), Signed = outbox:sign(Env, BKS), Body = term_codec:encode(Signed), nx_kernel:start_link(alice, AKS, AAS), InboxPath = <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>, Cfg = [{peer_as, [{bob, BAS}]}, {kernel, nx_kernel}],'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/peer_actors.erl\")) :name)")
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
|
||||
;; Happy path: valid signed activity, known peer -> 202
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], case http_server:route(Req, Cfg) of [{status, 202}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Happy path: inbox tip advances to 1
|
||||
(epoch 21)
|
||||
(eval "(erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), nx_kernel:inbox_tip_for(alice)\")")
|
||||
|
||||
;; Outbox tip stays 0 after inbox delivery (independent buckets)
|
||||
(epoch 22)
|
||||
(eval "(erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), nx_kernel:log_tip_for(alice)\")")
|
||||
|
||||
;; Empty body -> 422 (decode failure before sig)
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, <<>>}], case http_server:route(Req, Cfg) of [{status, 422}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Garbage body -> 422
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, <<99,99,99,99>>}], case http_server:route(Req, Cfg) of [{status, 422}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Unknown peer (no entry in :peer_as map) -> 401
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} EmptyCfg = [{peer_as, []}, {kernel, nx_kernel}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], case http_server:route(Req, EmptyCfg) of [{status, 401}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Wrong peer-AS keys (EvilAS) -> 401 (bad_signature)
|
||||
(epoch 26)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} EvilCfg = [{peer_as, [{bob, EvilAS}]}, {kernel, nx_kernel}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], case http_server:route(Req, EvilCfg) of [{status, 401}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Replay: deliver same activity twice -> second one 422
|
||||
(epoch 27)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, Cfg), case http_server:route(Req, Cfg) of [{status, 422}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Unknown target actor -> 404
|
||||
(epoch 28)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} GhostPath = <<47,97,99,116,111,114,115,47,103,104,111,115,116,47,105,110,98,111,120>>, Req = [{method, <<80,79,83,84>>}, {path, GhostPath}, {headers, []}, {body, Body}], case http_server:route(Req, Cfg) of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Two distinct activities -> inbox tip = 2
|
||||
(epoch 29)
|
||||
(eval "(erlang-eval-ast \"${SETUP} Env2 = outbox:construct(note, bob, 2, [{content,bye}]), Signed2 = outbox:sign(Env2, BKS), Body2 = term_codec:encode(Signed2), Req1 = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], Req2 = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body2}], http_server:route(Req1, Cfg), http_server:route(Req2, Cfg), nx_kernel:inbox_tip_for(alice)\")")
|
||||
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 600 "$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 10 "http_server module loaded" "http_server"
|
||||
check 20 "happy path -> 202" "true"
|
||||
check 21 "inbox tip advances to 1" "1"
|
||||
check 22 "outbox tip unchanged (= 0)" "0"
|
||||
check 23 "empty body -> 422" "true"
|
||||
check 24 "garbage body -> 422" "true"
|
||||
check 25 "unknown peer -> 401" "true"
|
||||
check 26 "bad peer-AS keys -> 401" "true"
|
||||
check 27 "replay -> 422 on second delivery" "true"
|
||||
check 28 "unknown target actor -> 404" "true"
|
||||
check 29 "two activities -> inbox tip = 2" "2"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/inbox.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
147
next/tests/inbox_bucket.sh
Executable file
147
next/tests/inbox_bucket.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/inbox_bucket.sh — m2 Step 5a test.
|
||||
#
|
||||
# Per-actor :actor_inbox log bucket added to nx_kernel state. The
|
||||
# inbox is a separate log from the outbox (:log) so peer-delivered
|
||||
# activities don't interfere with the actor's own publish stream.
|
||||
# Step 5b layers the signature-verify pipeline on top, Step 5c
|
||||
# wires the http handler.
|
||||
|
||||
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
|
||||
|
||||
PRELUDE='K = <<1,2,3,4>>, KS = [{key_id,k1},{algorithm,ed25519},{value,K}], AS = [{public_keys,[[{id,k1},{created,0},{value,K}]]}], Act = [{type,note},{object,[{content,hi}]},{id,<<100,1>>},{actor,bob}],'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
|
||||
;; Fresh actor has inbox tip 0 (pure state)
|
||||
(epoch 10)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} {ok, S} = nx_kernel:add_actor(alice, KS, AS, nx_kernel:new()), nx_kernel:actor_inbox_tip(alice, S)\")")
|
||||
|
||||
;; actor_inbox_state returns the log state
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S} = nx_kernel:add_actor(alice, KS, AS, nx_kernel:new()), case nx_kernel:actor_inbox_state(alice, S) of {ok, _} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; append_to_actor_inbox/3 returns {ok, Tip, NewState}
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S} = nx_kernel:add_actor(alice, KS, AS, nx_kernel:new()), case nx_kernel:append_to_actor_inbox(alice, Act, S) of {ok, 1, _} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; After append, actor_inbox_tip advances
|
||||
(epoch 13)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} {ok, S0} = nx_kernel:add_actor(alice, KS, AS, nx_kernel:new()), {ok, _, S1} = nx_kernel:append_to_actor_inbox(alice, Act, S0), nx_kernel:actor_inbox_tip(alice, S1)\")")
|
||||
|
||||
;; append to unknown actor -> {error, no_actor, State}
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} case nx_kernel:append_to_actor_inbox(ghost, Act, nx_kernel:new()) of {error, no_actor, _} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; Outbox tip is independent of inbox tip
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S0} = nx_kernel:add_actor(alice, KS, AS, nx_kernel:new()), {ok, _, S1} = nx_kernel:append_to_actor_inbox(alice, Act, S0), {nx_kernel:actor_log_tip(alice, S1), nx_kernel:actor_inbox_tip(alice, S1)} =:= {0, 1}\") :name)")
|
||||
|
||||
;; Two actors maintain independent inbox state
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S0} = nx_kernel:add_actor(alice, KS, AS, nx_kernel:new()), {ok, S1} = nx_kernel:add_actor(bob, KS, AS, S0), {ok, _, S2} = nx_kernel:append_to_actor_inbox(alice, Act, S1), {nx_kernel:actor_inbox_tip(alice, S2), nx_kernel:actor_inbox_tip(bob, S2)} =:= {1, 0}\") :name)")
|
||||
|
||||
;; gen_server inbox_tip_for/1 starts at 0
|
||||
(epoch 17)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, KS, AS), nx_kernel:inbox_tip_for(alice)\")")
|
||||
|
||||
;; gen_server append_inbox/2 advances tip
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, KS, AS), case nx_kernel:append_inbox(alice, Act) of {ok, 1} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; gen_server inbox is independent of outbox
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, KS, AS), nx_kernel:append_inbox(alice, Act), {nx_kernel:log_tip_for(alice), nx_kernel:inbox_tip_for(alice)} =:= {0, 1}\") :name)")
|
||||
|
||||
;; gen_server append_inbox to unknown actor -> {error, no_actor}
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, KS, AS), case nx_kernel:append_inbox(ghost, Act) of {error, no_actor} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; gen_server inbox_state_for returns the log state
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, KS, AS), case nx_kernel:inbox_state_for(alice) of {ok, _} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; gen_server: append two activities, tip = 2; outbox tip unchanged
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} Act2 = [{type,note},{object,[{content,hi2}]},{id,<<100,2>>},{actor,bob}], nx_kernel:start_link(alice, KS, AS), nx_kernel:append_inbox(alice, Act), nx_kernel:append_inbox(alice, Act2), {nx_kernel:inbox_tip_for(alice), nx_kernel:log_tip_for(alice)} =:= {2, 0}\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 7 "nx_kernel module loaded" "nx_kernel"
|
||||
check 10 "fresh actor inbox tip = 0" "0"
|
||||
check 11 "actor_inbox_state {ok, _}" "ok"
|
||||
check 12 "append_to_actor_inbox/3 returns" "ok"
|
||||
check 13 "append advances tip to 1" "1"
|
||||
check 14 "append unknown -> no_actor" "ok"
|
||||
check 15 "outbox tip independent of inbox" "true"
|
||||
check 16 "two actors independent inboxes" "true"
|
||||
check 17 "gen_server inbox_tip = 0" "0"
|
||||
check 18 "gen_server append_inbox/2 -> ok" "ok"
|
||||
check 19 "gen_server inbox != outbox" "true"
|
||||
check 20 "gen_server append unknown -> err" "ok"
|
||||
check 21 "gen_server inbox_state_for ok" "ok"
|
||||
check 22 "two appends tip = 2" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/inbox_bucket.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
119
next/tests/inbox_peer_resolution.sh
Executable file
119
next/tests/inbox_peer_resolution.sh
Executable file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/inbox_peer_resolution.sh — m2 Step 5d-resolution test.
|
||||
#
|
||||
# Exercises the four peer-AS resolution paths the inbox handler
|
||||
# supports via Cfg:
|
||||
# :peer_as map pure-fn pre-populated proplist
|
||||
# :peer_actors gen_server cache atom
|
||||
# :peer_fetch_fn fallback on cache miss
|
||||
# none reject as 401
|
||||
#
|
||||
# Split out from inbox.sh so each suite gets its own scheduler
|
||||
# budget — the cumulative cost of one kernel start_link per epoch
|
||||
# pushes a single-file suite past the wall-clock timeout.
|
||||
|
||||
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
|
||||
|
||||
SETUP='AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<5,6,7,8>>, BKS = [{key_id,k1},{algorithm,ed25519},{value,BK}], BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], Env = outbox:construct(note, bob, 1, [{content,hi}]), Signed = outbox:sign(Env, BKS), Body = term_codec:encode(Signed), nx_kernel:start_link(alice, AKS, AAS), InboxPath = <<47,97,99,116,111,114,115,47,97,108,105,99,101,47,105,110,98,111,120>>,'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/peer_actors.erl\")) :name)")
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
|
||||
;; peer_actors gen_server lookup hit -> 202
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link([{bob, BAS}]), SrvCfg = [{peer_actors, peer_actors}, {kernel, nx_kernel}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], case http_server:route(Req, SrvCfg) of [{status, 202}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; FetchFn fallback on cache miss
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} FetchFn = fun(bob) -> {ok, BAS}; (_) -> {error, not_found} end, peer_actors:start_link(), FetchCfg = [{peer_actors, peer_actors}, {peer_fetch_fn, FetchFn}, {kernel, nx_kernel}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], case http_server:route(Req, FetchCfg) of [{status, 202}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; FetchFn returning error -> 401
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} BadFetch = fun(_) -> {error, http_404} end, peer_actors:start_link(), FetchCfg = [{peer_actors, peer_actors}, {peer_fetch_fn, BadFetch}, {kernel, nx_kernel}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], case http_server:route(Req, FetchCfg) of [{status, 401}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; FetchFn caches across deliveries (peers_srv shows [bob] after)
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} FetchFn = fun(bob) -> {ok, BAS}; (_) -> {error, not_found} end, peer_actors:start_link(), FetchCfg = [{peer_actors, peer_actors}, {peer_fetch_fn, FetchFn}, {kernel, nx_kernel}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], http_server:route(Req, FetchCfg), peer_actors:peers_srv() =:= [bob]\") :name)")
|
||||
|
||||
;; No peer-resolver cfg'd at all -> 401
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} EmptyCfg = [{kernel, nx_kernel}], Req = [{method, <<80,79,83,84>>}, {path, InboxPath}, {headers, []}, {body, Body}], case http_server:route(Req, EmptyCfg) of [{status, 401}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 600 "$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 10 "http_server module loaded" "http_server"
|
||||
check 20 "peer_actors srv lookup -> 202" "true"
|
||||
check 21 "FetchFn fallback -> 202" "true"
|
||||
check 22 "FetchFn error -> 401" "true"
|
||||
check 23 "FetchFn caches into peer_actors" "true"
|
||||
check 24 "no resolver cfg'd -> 401" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/inbox_peer_resolution.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
146
next/tests/inbox_pipeline.sh
Executable file
146
next/tests/inbox_pipeline.sh
Executable file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/inbox_pipeline.sh — m2 Step 5b test.
|
||||
#
|
||||
# Exercises pipeline:validate_inbound/3(Activity, PeerActorState,
|
||||
# InboxLog) — the federation inbound pipeline that runs
|
||||
# envelope-shape -> peer signature -> replay against the receiving
|
||||
# actor's inbox log. Step 5c wires this into the HTTP handler.
|
||||
|
||||
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
|
||||
|
||||
# Bob (the peer) signs activities with K1. Alice (the recipient) has
|
||||
# PeerAS = Bob's actor-state (with Bob's public key). The InboxLog is
|
||||
# Alice's :actor_inbox bucket.
|
||||
SETUP='K1 = <<1,2,3,4>>, K1S = [{key_id,k1},{algorithm,ed25519},{value,K1}], BobAS = [{public_keys,[[{id,k1},{created,0},{value,K1}]]}], K2 = <<9,9,9,9>>, EvilAS = [{public_keys,[[{id,k1},{created,0},{value,K2}]]}], Env = outbox:construct(note, bob, 1, [{content,hi}]), Signed = outbox:sign(Env, K1S), {ok, FreshInbox} = log:open(alice, <<105,110,98>>),'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
|
||||
;; Valid signed activity + correct peer AS + empty inbox -> ok
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} pipeline:validate_inbound(Signed, BobAS, FreshInbox) =:= ok\") :name)")
|
||||
|
||||
;; Tampered envelope (broken shape) -> {error, invalid_shape}
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Bad = [{type,note}], case pipeline:validate_inbound(Bad, BobAS, FreshInbox) of {error, _} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; Activity sans :signature -> stage_envelope rejects as
|
||||
;; {missing_field, signature} (short-circuit before sig stage)
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Unsigned = Env, case pipeline:validate_inbound(Unsigned, BobAS, FreshInbox) of {error, {missing_field, signature}} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; Wrong peer AS (EvilAS doesn't carry Bob's key bytes) -> bad_signature
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} case pipeline:validate_inbound(Signed, EvilAS, FreshInbox) of {error, bad_signature} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; Pre-populated inbox containing the same activity -> {error, replay}
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} {ok, InboxWithMsg, _} = log:append(FreshInbox, Signed), case pipeline:validate_inbound(Signed, BobAS, InboxWithMsg) of {error, replay} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; Inbox with a DIFFERENT activity doesn't trigger replay
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Other = [{type,note},{object,[{content,hello}]},{id,<<200,1>>}], {ok, InboxWithOther, _} = log:append(FreshInbox, Other), pipeline:validate_inbound(Signed, BobAS, InboxWithOther) =:= ok\") :name)")
|
||||
|
||||
;; inbound_stages/2 returns 3 stages
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} length(pipeline:inbound_stages(BobAS, FreshInbox)) =:= 3\") :name)")
|
||||
|
||||
;; inbound_stages/0 stays at 1 stage (back-compat for outbox-side callers)
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"length(pipeline:inbound_stages()) =:= 1\") :name)")
|
||||
|
||||
;; validate_inbound/1 still works (envelope-only fast path)
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} pipeline:validate_inbound(Signed) =:= ok\") :name)")
|
||||
|
||||
;; Stages compose: envelope failure short-circuits before sig
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} BadShape = [{type,note}], case pipeline:validate_inbound(BadShape, EvilAS, FreshInbox) of {error, _} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; Sig failure short-circuits before replay
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} {ok, InboxWithMsg, _} = log:append(FreshInbox, Signed), case pipeline:validate_inbound(Signed, EvilAS, InboxWithMsg) of {error, bad_signature} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; Two distinct peer activities both verify (different :published seq -> different :id)
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Env2 = outbox:construct(note, bob, 2, [{content,hi}]), Signed2 = outbox:sign(Env2, K1S), pipeline:validate_inbound(Signed, BobAS, FreshInbox) =:= ok andalso pipeline:validate_inbound(Signed2, BobAS, FreshInbox) =:= ok\") :name)")
|
||||
|
||||
;; Inbox with peer1's activity doesn't replay peer2's
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Env2 = outbox:construct(note, bob, 2, [{content,hi}]), Signed2 = outbox:sign(Env2, K1S), {ok, InboxA, _} = log:append(FreshInbox, Signed), pipeline:validate_inbound(Signed2, BobAS, InboxA) =:= ok\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 4 "pipeline module loaded" "pipeline"
|
||||
check 10 "happy path -> ok" "true"
|
||||
check 11 "bad envelope shape -> {error, _}" "ok"
|
||||
check 12 "unsigned -> missing_field rejection" "ok"
|
||||
check 13 "wrong peer AS -> bad_signature" "ok"
|
||||
check 14 "duplicate activity -> replay" "ok"
|
||||
check 15 "different activity, no replay" "true"
|
||||
check 16 "inbound_stages/2 -> 3 stages" "true"
|
||||
check 17 "inbound_stages/0 -> 1 stage" "true"
|
||||
check 18 "validate_inbound/1 still works" "true"
|
||||
check 19 "shape fail short-circuits sig" "ok"
|
||||
check 20 "sig fail short-circuits replay" "ok"
|
||||
check 21 "two distinct activities verify" "true"
|
||||
check 22 "inbox-of-one doesn't replay other" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/inbox_pipeline.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
156
next/tests/key_rotation.sh
Executable file
156
next/tests/key_rotation.sh
Executable file
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/key_rotation.sh — m2 Step 3 test.
|
||||
#
|
||||
# Verifies key rotation via Update + actor-state per design §9.6:
|
||||
# Update{Person, patch: [{add_publicKey, K}, {supersede, OldId}]}
|
||||
# augments the actor's :public_keys with the new key (carrying
|
||||
# :created = activity's :published) and marks the old key with
|
||||
# :superseded_at. Pre-rotation activities continue to verify against
|
||||
# the old key (time-aware lookup); post-rotation activities verify
|
||||
# against the new key.
|
||||
|
||||
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
|
||||
|
||||
# Two key materials. Pre-rotation activities signed with K1 at
|
||||
# published=1; rotation happens at published=5; post-rotation
|
||||
# activities signed with K2 at published=10.
|
||||
SETUP='K1Bin = <<1,2,3,4>>, K1 = [{id, k1}, {created, 0}, {value, K1Bin}], K2Bin = <<9,9,9,9>>, K2 = [{id, k2}, {value, K2Bin}], InitialPks = [K1], Profile = [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, InitialPks}], CreateAct = [{actor, alice}, {type, create}, {object, [{type, person}, {name, alice_n}, {public_keys, InitialPks}]}, {published, 1}], RotateAct = [{actor, alice}, {type, update}, {object, <<97,108,105,99,101>>}, {patch, [{add_publicKey, K2}, {supersede, k1}]}, {published, 5}],'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/actor_state.erl\")) :name)")
|
||||
|
||||
;; add_publicKey appends new key with :created = activity's :published
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), Hist = actor_state:key_history(P), length(Hist) =:= 2\") :name)")
|
||||
|
||||
;; New key carries :created = 5 (the rotation's :published)
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), {ok, NewKey} = actor_state:find_key_by_id(k2, P), envelope:get_field(created, NewKey) =:= {ok, 5}\") :name)")
|
||||
|
||||
;; supersede marks old key with :superseded_at = activity's :published
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), {ok, OldKey} = actor_state:find_key_by_id(k1, P), envelope:get_field(superseded_at, OldKey) =:= {ok, 5}\") :name)")
|
||||
|
||||
;; Pre-rotation: only K1 is active at T=1
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), Active = actor_state:active_keys_at(P, 1), length(Active) =:= 1\") :name)")
|
||||
|
||||
;; At T=5 (the rotation moment), K1 is no longer active (Now < superseded_at means Now < 5 is false), K2 is just becoming active (Now >= created=5)
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), Active = actor_state:active_keys_at(P, 5), [K] = Active, envelope:get_field(id, K) =:= {ok, k2}\") :name)")
|
||||
|
||||
;; Post-rotation (T=10): only K2 is active
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), Active = actor_state:active_keys_at(P, 10), [K] = Active, envelope:get_field(id, K) =:= {ok, k2}\") :name)")
|
||||
|
||||
;; key_history preserves both keys (including the superseded one)
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), Hist = actor_state:key_history(P), [Hk1, Hk2] = Hist, {ok, k1} = envelope:get_field(id, Hk1), {ok, k2} = envelope:get_field(id, Hk2), envelope:get_field(superseded_at, Hk1) =:= {ok, 5}\") :name)")
|
||||
|
||||
;; envelope:verify_signature against the projection-derived AS:
|
||||
;; build an actor_state proplist {public_keys, History} and verify a
|
||||
;; pre-rotation activity signed with K1 (sig.value = sha256(K1Bin ++ canonical_bytes)).
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), AS = [{public_keys, actor_state:key_history(P)}], PreAct = [{actor, alice}, {type, note}, {object, [{content, hi}]}, {published, 2}], CB = envelope:canonical_bytes(PreAct), Mac = crypto:hash(sha256, <<K1Bin/binary, CB/binary>>), Signed = PreAct ++ [{signature, [{key_id, k1}, {algorithm, ed25519}, {value, Mac}]}], envelope:verify_signature(Signed, AS) =:= ok\") :name)")
|
||||
|
||||
;; Post-rotation activity signed with K2: verifies
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), AS = [{public_keys, actor_state:key_history(P)}], PostAct = [{actor, alice}, {type, note}, {object, [{content, hi}]}, {published, 10}], CB = envelope:canonical_bytes(PostAct), Mac = crypto:hash(sha256, <<K2Bin/binary, CB/binary>>), Signed = PostAct ++ [{signature, [{key_id, k2}, {algorithm, ed25519}, {value, Mac}]}], envelope:verify_signature(Signed, AS) =:= ok\") :name)")
|
||||
|
||||
;; Post-rotation activity signed with K1 (old key) at T=10: fails — K1 is superseded
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), AS = [{public_keys, actor_state:key_history(P)}], PostAct = [{actor, alice}, {type, note}, {object, [{content, hi}]}, {published, 10}], CB = envelope:canonical_bytes(PostAct), Mac = crypto:hash(sha256, <<K1Bin/binary, CB/binary>>), Signed = PostAct ++ [{signature, [{key_id, k1}, {algorithm, ed25519}, {value, Mac}]}], envelope:verify_signature(Signed, AS) =:= {error, no_active_key}\") :name)")
|
||||
|
||||
;; Patch without rotation keys still last-write-wins on other fields (no change to key history)
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), MetaAct = [{actor, alice}, {type, update}, {patch, [{summary, new_bio}]}, {published, 7}], S1 = actor_state:fold(MetaAct, S), {ok, P} = actor_state:lookup(alice, S1), {actor_state:profile_field(summary, P), length(actor_state:key_history(P))} =:= {{ok, new_bio}, 1}\") :name)")
|
||||
|
||||
;; add_publicKey alone (no supersede) leaves old key active
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), AddOnly = [{actor, alice}, {type, update}, {patch, [{add_publicKey, K2}]}, {published, 5}], S1 = actor_state:fold(AddOnly, S), {ok, P} = actor_state:lookup(alice, S1), Active = actor_state:active_keys_at(P, 10), length(Active) =:= 2\") :name)")
|
||||
|
||||
;; supersede alone (no add) leaves only the marked key superseded
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), SupOnly = [{actor, alice}, {type, update}, {patch, [{supersede, k1}]}, {published, 5}], S1 = actor_state:fold(SupOnly, S), {ok, P} = actor_state:lookup(alice, S1), Active = actor_state:active_keys_at(P, 10), length(Active) =:= 0\") :name)")
|
||||
|
||||
;; supersede with unknown key id is a no-op
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), SupGhost = [{actor, alice}, {type, update}, {patch, [{supersede, kx}]}, {published, 5}], S1 = actor_state:fold(SupGhost, S), {ok, P} = actor_state:lookup(alice, S1), {ok, OldKey} = actor_state:find_key_by_id(k1, P), envelope:get_field(superseded_at, OldKey) =:= not_found\") :name)")
|
||||
|
||||
;; A second supersede on an already-superseded key is idempotent
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), Sup2 = [{actor, alice}, {type, update}, {patch, [{supersede, k1}]}, {published, 8}], S2 = actor_state:fold(Sup2, S1), {ok, P} = actor_state:lookup(alice, S2), {ok, OldKey} = actor_state:find_key_by_id(k1, P), envelope:get_field(superseded_at, OldKey) =:= {ok, 5}\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 "actor_state loaded" "actor_state"
|
||||
check 10 "rotation adds 2nd public_key" "true"
|
||||
check 11 "new key :created = Published" "true"
|
||||
check 12 "supersede marks :superseded_at" "true"
|
||||
check 13 "pre-rotation: K1 active alone" "true"
|
||||
check 14 "at T=5: K2 just active" "true"
|
||||
check 15 "post-rotation: K2 active alone" "true"
|
||||
check 16 "key_history preserves all keys" "true"
|
||||
check 17 "pre-rotation activity verifies" "true"
|
||||
check 18 "post-rotation activity verifies" "true"
|
||||
check 19 "post-rotation K1 sig fails" "true"
|
||||
check 20 "non-rotation patch preserves keys" "true"
|
||||
check 21 "add_publicKey alone keeps old" "true"
|
||||
check 22 "supersede alone empties active" "true"
|
||||
check 23 "supersede unknown is no-op" "true"
|
||||
check 24 "double supersede idempotent" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/key_rotation.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
213
next/tests/nx_kernel_multi.sh
Executable file
213
next/tests/nx_kernel_multi.sh
Executable file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/nx_kernel_multi.sh — m2 Step 1a tests.
|
||||
#
|
||||
# Pure-functional multi-actor bucket APIs. No gen_server.
|
||||
|
||||
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
|
||||
|
||||
# Two actors share the same signing-key bytes but have different ids;
|
||||
# signatures verify because each carries the matching public_keys
|
||||
# entry. AliceK / BobK distinguish them visually only.
|
||||
PRELUDE='AliceK = <<1,2,3,4>>, AliceKS = [{key_id,k1},{algorithm,ed25519},{value,AliceK}], AliceAS = [{public_keys,[[{id,k1},{created,0},{value,AliceK}]]}], BobK = <<5,6,7,8>>, BobKS = [{key_id,k1},{algorithm,ed25519},{value,BobK}], BobAS = [{public_keys,[[{id,k1},{created,0},{value,BobK}]]}], Req = [{type,create},{object,nil}],'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
|
||||
;; new/0 returns kernel with 0 actors
|
||||
(epoch 10)
|
||||
(eval "(erlang-eval-ast \"S = nx_kernel:new(), nx_kernel:actor_count(S)\")")
|
||||
|
||||
;; new/0 has next_actor_seq = 1
|
||||
(epoch 11)
|
||||
(eval "(erlang-eval-ast \"S = nx_kernel:new(), nx_kernel:next_actor_seq(S)\")")
|
||||
|
||||
;; new/0 has actor_id/1 = nil (legacy accessor returns nil with no actors)
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"S = nx_kernel:new(), nx_kernel:actor_id(S) =:= nil\") :name)")
|
||||
|
||||
;; add_actor returns {ok, NewState}
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, _} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), ok\") :name)")
|
||||
|
||||
;; has_actor returns true after add
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), nx_kernel:has_actor(alice, S)\") :name)")
|
||||
|
||||
;; actors/1 lists the new actor
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), nx_kernel:actors(S) =:= [alice]\") :name)")
|
||||
|
||||
;; add_actor twice same id -> {error, already_present}
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), case nx_kernel:add_actor(alice, AliceKS, AliceAS, S) of {error, already_present} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; add two distinct actors -> both present
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), nx_kernel:actors(S2) =:= [alice, bob]\") :name)")
|
||||
|
||||
;; next_actor_seq increments per add
|
||||
(epoch 18)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), nx_kernel:next_actor_seq(S2)\")")
|
||||
|
||||
;; publish/3 to known actor returns {ok, _, NewState}
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), case nx_kernel:publish(alice, Req, S1) of {ok, _, _} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; publish/3 advances only the named actor's log
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), {ok, _, S3} = nx_kernel:publish(alice, Req, S2), AliceTip = nx_kernel:actor_log_tip(alice, S3), BobTip = nx_kernel:actor_log_tip(bob, S3), {AliceTip, BobTip} =:= {1, 0}\") :name)")
|
||||
|
||||
;; publish/3 to unknown actor -> {error, no_actor, State}
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} S = nx_kernel:new(), case nx_kernel:publish(ghost, Req, S) of {error, no_actor, _} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; Two actors maintain independent next_published counters
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), {ok, _, S3} = nx_kernel:publish(alice, Req, S2), {ok, _, S4} = nx_kernel:publish(alice, Req, S3), {ok, _, S5} = nx_kernel:publish(bob, Req, S4), {ok, AliceN} = nx_kernel:actor_next_published(alice, S5), {ok, BobN} = nx_kernel:actor_next_published(bob, S5), {AliceN, BobN} =:= {3, 2}\") :name)")
|
||||
|
||||
;; actor_state/2 returns per-actor AS
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), {ok, ASa} = nx_kernel:actor_state(alice, S2), {ok, ASb} = nx_kernel:actor_state(bob, S2), {ASa, ASb} =:= {AliceAS, BobAS}\") :name)")
|
||||
|
||||
;; with_actor_projections sets per-actor projection list
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, S1} = nx_kernel:add_actor(alice, AliceKS, AliceAS, nx_kernel:new()), {ok, S2} = nx_kernel:add_actor(bob, BobKS, BobAS, S1), {ok, S3} = nx_kernel:with_actor_projections(alice, [px], S2), {ok, AliceP} = nx_kernel:actor_projections(alice, S3), {ok, BobP} = nx_kernel:actor_projections(bob, S3), {AliceP, BobP} =:= {[px], []}\") :name)")
|
||||
|
||||
;; Legacy new/3 + publish/2 still route to the single actor
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} S = nx_kernel:new(alice, AliceKS, AliceAS), {ok, _, S1} = nx_kernel:publish(Req, S), nx_kernel:log_tip(S1) =:= 1 andalso nx_kernel:actor_id(S1) =:= alice\") :name)")
|
||||
|
||||
;; ── Step 1b: gen_server multi-actor calls ──────────────────────
|
||||
;; The Erlang-on-SX scheduler doesn't preserve spawned processes
|
||||
;; across separate erlang-eval-ast invocations, so each gen_server
|
||||
;; test inlines start_link with operations (same convention as
|
||||
;; nx_kernel_server.sh).
|
||||
|
||||
(epoch 26)
|
||||
(eval "(er-load-gen-server!)")
|
||||
|
||||
;; start_link works, actors/0 lists the single seeded actor
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), nx_kernel:actors() =:= [alice]\") :name)")
|
||||
|
||||
;; add_actor/3 (gen_server) -> :ok, actors/0 reflects both
|
||||
(epoch 31)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), ok = nx_kernel:add_actor(bob, BobKS, BobAS), nx_kernel:actors() =:= [alice, bob]\") :name)")
|
||||
|
||||
;; add_actor/3 duplicate -> {error, already_present}
|
||||
(epoch 32)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), case nx_kernel:add_actor(alice, AliceKS, AliceAS) of {error, already_present} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; publish_to/2 advances only the named actor's log
|
||||
(epoch 33)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), nx_kernel:add_actor(bob, BobKS, BobAS), {ok, _} = nx_kernel:publish_to(alice, Req), AliceTip = nx_kernel:log_tip_for(alice), BobTip = nx_kernel:log_tip_for(bob), {AliceTip, BobTip} =:= {1, 0}\") :name)")
|
||||
|
||||
;; Interleaved publishes preserve per-actor counters
|
||||
(epoch 34)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), nx_kernel:add_actor(bob, BobKS, BobAS), nx_kernel:publish_to(alice, Req), nx_kernel:publish_to(bob, Req), nx_kernel:publish_to(alice, Req), AliceTip = nx_kernel:log_tip_for(alice), BobTip = nx_kernel:log_tip_for(bob), {AliceTip, BobTip} =:= {2, 1}\") :name)")
|
||||
|
||||
;; publish_to unknown actor -> {error, no_actor}, no kernel crash
|
||||
(epoch 35)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), case nx_kernel:publish_to(ghost, Req) of {error, no_actor} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; state_for/1 returns the per-actor AS
|
||||
(epoch 36)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), nx_kernel:add_actor(bob, BobKS, BobAS), {ok, ASb} = nx_kernel:state_for(bob), ASb =:= BobAS\") :name)")
|
||||
|
||||
;; with_projections_for/2 sets per-actor projections, observable via bucket_for
|
||||
(epoch 37)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), nx_kernel:add_actor(bob, BobKS, BobAS), nx_kernel:with_projections_for(alice, [px]), {ok, AliceBucket} = nx_kernel:bucket_for(alice), {ok, BobBucket} = nx_kernel:bucket_for(bob), [{projections, AliceP} | _] = lists:filter(fun(P) -> element(1, P) =:= projections end, AliceBucket), [{projections, BobP} | _] = lists:filter(fun(P) -> element(1, P) =:= projections end, BobBucket), {AliceP, BobP} =:= {[px], []}\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 8 "nx_kernel module loaded" "nx_kernel"
|
||||
check 10 "new/0 -> 0 actors" "0"
|
||||
check 11 "new/0 -> next_actor_seq = 1" "1"
|
||||
check 12 "new/0 actor_id = nil" "true"
|
||||
check 13 "add_actor returns {ok, NewState}" "ok"
|
||||
check 14 "has_actor true after add" "true"
|
||||
check 15 "actors/1 lists added actor" "true"
|
||||
check 16 "duplicate add -> already_present" "ok"
|
||||
check 17 "two distinct actors both present" "true"
|
||||
check 18 "next_actor_seq increments" "3"
|
||||
check 19 "publish/3 returns {ok, _, S}" "ok"
|
||||
check 20 "publish/3 isolates per actor" "true"
|
||||
check 21 "publish/3 unknown -> no_actor" "ok"
|
||||
check 22 "independent next_published seqs" "true"
|
||||
check 23 "actor_state/2 per-actor" "true"
|
||||
check 24 "with_actor_projections per-actor" "true"
|
||||
check 25 "legacy new/3 + publish/2 routes" "true"
|
||||
check 26 "gen_server loaded" "gen_server"
|
||||
check 30 "start_link seeds bucket 0" "true"
|
||||
check 31 "add_actor/3 (srv) -> ok + actors" "true"
|
||||
check 32 "add_actor/3 duplicate detected" "ok"
|
||||
check 33 "publish_to/2 isolates per actor" "true"
|
||||
check 34 "interleaved publishes per actor" "true"
|
||||
check 35 "publish_to unknown -> no_actor" "ok"
|
||||
check 36 "state_for/1 per-actor AS" "true"
|
||||
check 37 "with_projections_for per-actor" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/nx_kernel_multi.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
154
next/tests/object_schema.sh
Executable file
154
next/tests/object_schema.sh
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/object_schema.sh — host-type federation Phase 4.
|
||||
#
|
||||
# pipeline:apply_object_schema/2 validates an inbound activity's inner
|
||||
# object against its declared refinement type. The type is resolved
|
||||
# TypeName -> TypeCid (Cfg type_index) -> TypeRecord
|
||||
# (peer_types:lookup_or_fetch, a local hit or a wire fetch), then the
|
||||
# record's refinement schema is applied to the object's :field_values.
|
||||
# Default strict_object_schema = false: an unresolvable type is let
|
||||
# through; opt-in strict rejects.
|
||||
#
|
||||
# Refinement schemas are either a 1-arity Erlang predicate (the
|
||||
# substrate stand-in, locally stored) or a term_codec-safe
|
||||
# {required, [Field,...]} constraint (so a wire-fetched record still
|
||||
# validates). Both are exercised here.
|
||||
|
||||
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
|
||||
|
||||
# Cid is the Post type's CID; TRdata carries a data-form refinement
|
||||
# (object must have a `title` field), TRfun the Erlang-predicate form.
|
||||
# ActValid's object has :title, ActFail's doesn't, ActNoType's object
|
||||
# declares no type, ActUnknown's type isn't in the index. PostName is
|
||||
# <<"Post">>, title "Hi" = <<72,105>>. Index maps name -> Cid.
|
||||
SETUP='Cid = <<98,97,102,121,80>>, PostName = <<80,111,115,116>>, TRdata = [{name, PostName}, {refinement_schema, {required, [title]}}], TRfun = [{name, PostName}, {refinement_schema, fun(FV) -> case FV of [{title, _} | _] -> true; _ -> false end end}], ObjValid = [{type, PostName}, {field_values, [{title, <<72,105>>}, {body, <<104,105>>}]}], ObjFail = [{type, PostName}, {field_values, [{body, <<104,105>>}]}], ActValid = [{type, create}, {actor, alice}, {object, ObjValid}], ActFail = [{type, create}, {actor, alice}, {object, ObjFail}], ActNoType = [{type, create}, {actor, alice}, {object, [{field_values, [{title, <<72,105>>}]}]}], ActUnknown = [{type, create}, {actor, alice}, {object, [{type, <<82,101,112,108,121>>}, {field_values, [{title, <<72,105>>}]}]}], Index = [{PostName, Cid}], FAIL = {error, {validation_failed, object_schema}},'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/peer_types.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
|
||||
;; local registry match + valid object -> accepted
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid, TRdata), Cfg = [{peer_types, peer_types}, {type_index, Index}], pipeline:apply_object_schema(ActValid, Cfg) =:= ok\") :name)")
|
||||
;; local match + refinement-failing object -> rejected
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid, TRdata), Cfg = [{peer_types, peer_types}, {type_index, Index}], pipeline:apply_object_schema(ActFail, Cfg) =:= FAIL\") :name)")
|
||||
|
||||
;; type not cached, fetch succeeds -> validates against fetched record
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), Cfg = [{peer_types, peer_types}, {type_index, Index}, {type_fetch_fn, fun(_, _) -> {ok, term_codec:encode(TRdata)} end}], pipeline:apply_object_schema(ActValid, Cfg) =:= ok\") :name)")
|
||||
;; fetched record, failing object -> rejected
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), Cfg = [{peer_types, peer_types}, {type_index, Index}, {type_fetch_fn, fun(_, _) -> {ok, term_codec:encode(TRdata)} end}], pipeline:apply_object_schema(ActFail, Cfg) =:= FAIL\") :name)")
|
||||
|
||||
;; unknown type, fetch fails, strict not set -> accepted (skipped)
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), Cfg = [{peer_types, peer_types}, {type_index, Index}, {type_fetch_fn, fun(_, _) -> {error, http_404} end}], pipeline:apply_object_schema(ActValid, Cfg) =:= ok\") :name)")
|
||||
;; unknown type, fetch fails, strict set -> rejected
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), Cfg = [{peer_types, peer_types}, {type_index, Index}, {type_fetch_fn, fun(_, _) -> {error, http_404} end}, {strict_object_schema, true}], pipeline:apply_object_schema(ActValid, Cfg) =:= FAIL\") :name)")
|
||||
;; no peer_types cfg at all, non-strict -> accepted (skipped)
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Cfg = [{type_index, Index}], pipeline:apply_object_schema(ActValid, Cfg) =:= ok\") :name)")
|
||||
;; no peer_types cfg, strict -> rejected
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Cfg = [{type_index, Index}, {strict_object_schema, true}], pipeline:apply_object_schema(ActValid, Cfg) =:= FAIL\") :name)")
|
||||
|
||||
;; object without inner {type, _} -> skipped (accepted)
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid, TRdata), Cfg = [{peer_types, peer_types}, {type_index, Index}], pipeline:apply_object_schema(ActNoType, Cfg) =:= ok\") :name)")
|
||||
;; object type not in the local index -> skipped (open-world)
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid, TRdata), Cfg = [{peer_types, peer_types}, {type_index, Index}], pipeline:apply_object_schema(ActUnknown, Cfg) =:= ok\") :name)")
|
||||
|
||||
;; Erlang-predicate refinement schema: valid -> ok, failing -> reject
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid, TRfun), Cfg = [{peer_types, peer_types}, {type_index, Index}], pipeline:apply_object_schema(ActValid, Cfg) =:= ok\") :name)")
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid, TRfun), Cfg = [{peer_types, peer_types}, {type_index, Index}], pipeline:apply_object_schema(ActFail, Cfg) =:= FAIL\") :name)")
|
||||
|
||||
;; type known but record carries no refinement schema -> accepted
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid, [{name, PostName}]), Cfg = [{peer_types, peer_types}, {type_index, Index}], pipeline:apply_object_schema(ActFail, Cfg) =:= ok\") :name)")
|
||||
|
||||
;; stage_object_schema/1 yields a 1-arity stage usable by run_stages
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid, TRdata), Cfg = [{peer_types, peer_types}, {type_index, Index}], Stage = pipeline:stage_object_schema(Cfg), is_function(Stage, 1) andalso pipeline:run_stages(ActValid, [Stage]) =:= ok andalso pipeline:run_stages(ActFail, [Stage]) =:= FAIL\") :name)")
|
||||
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="<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 6 "pipeline module loaded" "pipeline"
|
||||
check 10 "local match + valid -> accepted" "true"
|
||||
check 11 "local match + failing -> rejected" "true"
|
||||
check 12 "fetch ok -> validates fetched record" "true"
|
||||
check 13 "fetched record + failing -> rejected" "true"
|
||||
check 14 "fetch fail, non-strict -> accepted" "true"
|
||||
check 15 "fetch fail, strict -> rejected" "true"
|
||||
check 16 "no peer_types, non-strict -> accepted" "true"
|
||||
check 17 "no peer_types, strict -> rejected" "true"
|
||||
check 18 "object without type -> skipped" "true"
|
||||
check 19 "type not in index -> skipped" "true"
|
||||
check 20 "fun schema valid -> accepted" "true"
|
||||
check 21 "fun schema failing -> rejected" "true"
|
||||
check 22 "no refinement schema -> accepted" "true"
|
||||
check 23 "stage_object_schema composes" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/object_schema.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
@@ -45,6 +45,10 @@ cat > "$TMPFILE" <<EPOCHS
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
|
||||
;; Happy path: publish returns {ok, Result, NewLog}, log tip advances
|
||||
@@ -82,9 +86,25 @@ cat > "$TMPFILE" <<EPOCHS
|
||||
;; CID stable: same Request twice (across fresh logs) -> same CID
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, R1, _} = outbox:publish(Req, Ctx), {ok, L0b} = log:open(alice, base), Ctx_b = [{actor_id,alice},{published,100},{key_spec,KS},{actor_state,AS},{log,L0b}], {ok, R2, _} = outbox:publish(Req, Ctx_b), {ok, C1} = envelope:get_field(cid, R1), {ok, C2} = envelope:get_field(cid, R2), C1 =:= C2\") :name)")
|
||||
|
||||
;; Step 7c: Result has :delivery_set, empty when no :to/:cc + no graph
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} {ok, R, _} = outbox:publish(Req, Ctx), envelope:get_field(delivery_set, R) =:= {ok, []}\") :name)")
|
||||
|
||||
;; Step 7c: explicit :to -> delivery_set carries the recipient
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} ReqTo = [{type, note}, {object, [{content, hi}]}, {to, bob}], {ok, R, _} = outbox:publish(ReqTo, Ctx), envelope:get_field(delivery_set, R) =:= {ok, [bob]}\") :name)")
|
||||
|
||||
;; Step 7c: followers symbol expands via graph in Context
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} F = [{actor, bob}, {type, follow}, {object, alice}], A = [{actor, alice}, {type, accept}, {object, F}], Graph = follower_graph:fold(A, follower_graph:fold(F, follower_graph:new())), CtxG = Ctx ++ [{follower_graph, Graph}], ReqFol = [{type, note}, {object, [{content, hi}]}, {to, followers}], {ok, R, _} = outbox:publish(ReqFol, CtxG), envelope:get_field(delivery_set, R) =:= {ok, [bob]}\") :name)")
|
||||
|
||||
;; Step 7c: self-suppression — alice's :to including alice drops it
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} ReqSelf = [{type, note}, {object, [{content, hi}]}, {to, [alice, bob]}], {ok, R, _} = outbox:publish(ReqSelf, Ctx), envelope:get_field(delivery_set, R) =:= {ok, [bob]}\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
OUTPUT=$(timeout 480 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
@@ -108,11 +128,15 @@ check() {
|
||||
check 2 "envelope module loaded" "envelope"
|
||||
check 3 "log module loaded" "log"
|
||||
check 4 "pipeline module loaded" "pipeline"
|
||||
check 5 "outbox module loaded" "outbox"
|
||||
check 7 "outbox module loaded" "outbox"
|
||||
check 10 "happy path tip advances to 1" "true"
|
||||
check 11 "result :cid matches activity" "true"
|
||||
check 12 "signed activity in log entries" "true"
|
||||
check 13 "duplicate publish -> replay" "ok"
|
||||
check 20 "Result :delivery_set empty default" "true"
|
||||
check 21 "explicit :to -> [bob] in set" "true"
|
||||
check 22 "followers symbol expands via graph" "true"
|
||||
check 23 "self-suppression on alice in :to" "true"
|
||||
check 14 "replay leaves log tip at 1" "true"
|
||||
check 15 "bad key material -> bad_signature" "ok"
|
||||
check 16 "distinct timestamps -> tip 2" "true"
|
||||
|
||||
165
next/tests/peer_actors.sh
Executable file
165
next/tests/peer_actors.sh
Executable file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/peer_actors.sh — m2 Step 5c test.
|
||||
#
|
||||
# Peer-actors cache for the federation inbox handler. Tracks
|
||||
# {PeerActorId, PeerActorState} pairs so signature verification
|
||||
# can be done against a peer's :public_keys without re-fetching
|
||||
# their actor doc on every inbound. lookup_or_fetch/3 is the
|
||||
# load-bearing entry point: cache hit returns cached AS, miss
|
||||
# invokes the caller-supplied FetchFn and stores its result.
|
||||
|
||||
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
|
||||
|
||||
SETUP='K1 = <<1,2,3,4>>, BobAS = [{public_keys,[[{id,k1},{created,0},{value,K1}]]}], K2 = <<5,6,7,8>>, CarolAS = [{public_keys,[[{id,k1},{created,0},{value,K2}]]}], OkFetch = fun(bob) -> {ok, BobAS}; (carol) -> {ok, CarolAS}; (_) -> {error, not_found} 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/kernel/peer_actors.erl\")) :name)")
|
||||
|
||||
;; new/0 returns []
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"peer_actors:new() =:= []\") :name)")
|
||||
|
||||
;; lookup on empty cache returns not_found
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"peer_actors:lookup(bob, peer_actors:new()) =:= not_found\") :name)")
|
||||
|
||||
;; store + lookup round-trip
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = peer_actors:store(bob, BobAS, peer_actors:new()), peer_actors:lookup(bob, S) =:= {ok, BobAS}\") :name)")
|
||||
|
||||
;; peers/1 lists cached peer IDs in insertion order
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = peer_actors:new(), S1 = peer_actors:store(bob, BobAS, S0), S2 = peer_actors:store(carol, CarolAS, S1), peer_actors:peers(S2) =:= [bob, carol]\") :name)")
|
||||
|
||||
;; evict removes the entry
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S0 = peer_actors:store(bob, BobAS, peer_actors:new()), S1 = peer_actors:evict(bob, S0), peer_actors:lookup(bob, S1) =:= not_found\") :name)")
|
||||
|
||||
;; evict unknown peer is a no-op
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"peer_actors:evict(ghost, peer_actors:new()) =:= []\") :name)")
|
||||
|
||||
;; lookup_or_fetch miss invokes FetchFn and stores the result
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} case peer_actors:lookup_or_fetch(bob, OkFetch, peer_actors:new()) of {ok, BobAS, [{bob, BobAS}]} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; lookup_or_fetch hit returns cached value without invoking FetchFn
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} TombstoneFetch = fun(_) -> {error, should_not_be_called} end, S = peer_actors:store(bob, BobAS, peer_actors:new()), case peer_actors:lookup_or_fetch(bob, TombstoneFetch, S) of {ok, BobAS, S} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; lookup_or_fetch error from FetchFn does NOT store anything
|
||||
(epoch 18)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} BadFetch = fun(_) -> {error, http_404} end, case peer_actors:lookup_or_fetch(ghost, BadFetch, peer_actors:new()) of {error, http_404, []} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; lookup_or_fetch bad return shape is captured
|
||||
(epoch 19)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} JunkFetch = fun(_) -> garbage end, case peer_actors:lookup_or_fetch(ghost, JunkFetch, peer_actors:new()) of {error, {bad_fetch_return, garbage}, []} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; gen_server: start_link + lookup_srv miss returns not_found
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"peer_actors:start_link(), peer_actors:lookup_srv(bob) =:= not_found\") :name)")
|
||||
|
||||
;; gen_server: store_srv + lookup_srv round-trip
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link(), peer_actors:store_srv(bob, BobAS), peer_actors:lookup_srv(bob) =:= {ok, BobAS}\") :name)")
|
||||
|
||||
;; gen_server: peers_srv reflects stored entries
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link(), peer_actors:store_srv(bob, BobAS), peer_actors:store_srv(carol, CarolAS), peer_actors:peers_srv() =:= [bob, carol]\") :name)")
|
||||
|
||||
;; gen_server: lookup_or_fetch_srv miss invokes FetchFn + caches
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link(), R = peer_actors:lookup_or_fetch_srv(bob, OkFetch), R =:= {ok, BobAS} andalso peer_actors:peers_srv() =:= [bob]\") :name)")
|
||||
|
||||
;; gen_server: subsequent lookup uses cached value (FetchFn would error)
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} TombstoneFetch = fun(_) -> {error, should_not_be_called} end, peer_actors:start_link(), peer_actors:store_srv(bob, BobAS), R = peer_actors:lookup_or_fetch_srv(bob, TombstoneFetch), R =:= {ok, BobAS}\") :name)")
|
||||
|
||||
;; gen_server: fetch error doesn't poison cache
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} BadFetch = fun(_) -> {error, http_404} end, peer_actors:start_link(), R = peer_actors:lookup_or_fetch_srv(ghost, BadFetch), R =:= {error, http_404} andalso peer_actors:peers_srv() =:= []\") :name)")
|
||||
|
||||
;; gen_server: evict_srv removes the entry
|
||||
(epoch 26)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link(), peer_actors:store_srv(bob, BobAS), peer_actors:evict_srv(bob), peer_actors:lookup_srv(bob) =:= not_found\") :name)")
|
||||
|
||||
;; Initial-state argument: start_link/1 pre-populates the cache
|
||||
(epoch 27)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link([{bob, BobAS}]), peer_actors:lookup_srv(bob) =:= {ok, BobAS}\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 240 "$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 "peer_actors module loaded" "peer_actors"
|
||||
check 10 "new/0 -> []" "true"
|
||||
check 11 "lookup on empty -> not_found" "true"
|
||||
check 12 "store + lookup round-trip" "true"
|
||||
check 13 "peers/1 lists in insertion order" "true"
|
||||
check 14 "evict removes entry" "true"
|
||||
check 15 "evict unknown -> no-op" "true"
|
||||
check 16 "lookup_or_fetch miss fetches" "ok"
|
||||
check 17 "lookup_or_fetch hit skips fetch" "ok"
|
||||
check 18 "fetch error doesn't store" "ok"
|
||||
check 19 "bad fetch return shape captured" "ok"
|
||||
check 20 "gen_server lookup miss" "true"
|
||||
check 21 "gen_server store + lookup" "true"
|
||||
check 22 "gen_server peers_srv lists" "true"
|
||||
check 23 "gen_server fetch + cache" "true"
|
||||
check 24 "gen_server cached skips fetch" "true"
|
||||
check 25 "gen_server fetch error pristine" "true"
|
||||
check 26 "gen_server evict removes" "true"
|
||||
check 27 "start_link/1 pre-populates" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/peer_actors.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
155
next/tests/peer_types.sh
Executable file
155
next/tests/peer_types.sh
Executable file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/peer_types.sh — host-type federation Phase 2 acceptance.
|
||||
#
|
||||
# Receiver-side peer-types cache (next/kernel/peer_types.erl), a mirror
|
||||
# of peer_actors keyed by type CID. Tracks {TypeCidBytes, TypeRecord}
|
||||
# pairs so the object-schema validation stage can vet inbound objects
|
||||
# against a fetched-once refinement type. lookup_or_fetch pulls a
|
||||
# Cfg-supplied type_fetch_fn on a miss, decodes the returned wire bytes
|
||||
# via term_codec, and caches the TypeRecord.
|
||||
|
||||
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
|
||||
|
||||
# TR1/TR2 are TypeRecords (the DefineType :object payloads). Doc1 is
|
||||
# TR1's on-wire form (term_codec). FetchOk serves Doc1 for Cid1;
|
||||
# FetchBad returns undecodable bytes. CfgOk/CfgBad/CfgNone vary the
|
||||
# type_fetch_fn slot.
|
||||
SETUP='Cid1 = <<98,97,102,121,49>>, Cid2 = <<98,97,102,121,50>>, TR1 = [{name, <<80,111,115,116>>}, {instance_type, <<78,111,116,101>>}], TR2 = [{name, <<82,101,112,108,121>>}], Doc1 = term_codec:encode(TR1), FetchOk = fun(C, _) -> case C =:= Cid1 of true -> {ok, Doc1}; false -> {error, not_found} end end, FetchBad = fun(_, _) -> {ok, <<255>>} end, CfgOk = [{type_fetch_fn, FetchOk}], CfgBad = [{type_fetch_fn, FetchBad}], CfgNone = [],'
|
||||
|
||||
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/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/peer_types.erl\")) :name)")
|
||||
|
||||
;; ── pure API ───────────────────────────────────────────────
|
||||
;; new/0 -> []
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"peer_types:new() =:= []\") :name)")
|
||||
;; lookup miss -> not_found
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"peer_types:lookup(<<1>>, peer_types:new()) =:= not_found\") :name)")
|
||||
;; store + lookup round-trip
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = peer_types:store(Cid1, TR1, peer_types:new()), peer_types:lookup(Cid1, S) =:= {ok, TR1}\") :name)")
|
||||
;; types/1 lists CIDs in insertion order
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = peer_types:store(Cid2, TR2, peer_types:store(Cid1, TR1, peer_types:new())), peer_types:types(S) =:= [Cid1, Cid2]\") :name)")
|
||||
;; evict removes the entry
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = peer_types:evict(Cid1, peer_types:store(Cid1, TR1, peer_types:new())), peer_types:lookup(Cid1, S) =:= not_found\") :name)")
|
||||
|
||||
;; ── lookup_or_fetch (pure) ─────────────────────────────────
|
||||
;; miss -> fetch via Cfg.fn, decode bytes, cache TR
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} case peer_types:lookup_or_fetch(Cid1, CfgOk, peer_types:new()) of {ok, TR1, [{Cid1, TR1}]} -> ok; _ -> bad end\") :name)")
|
||||
;; hit -> returns cached without calling fetch
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = peer_types:store(Cid1, TR1, peer_types:new()), case peer_types:lookup_or_fetch(Cid1, CfgBad, S) of {ok, TR1, S} -> ok; _ -> bad end\") :name)")
|
||||
;; no type_fetch_fn -> {error, no_fetch_fn}, cache untouched
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} case peer_types:lookup_or_fetch(Cid1, CfgNone, peer_types:new()) of {error, no_fetch_fn, []} -> ok; _ -> bad end\") :name)")
|
||||
;; fetch error does NOT poison the cache
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} BadCfg = [{type_fetch_fn, fun(_, _) -> {error, http_404} end}], case peer_types:lookup_or_fetch(Cid1, BadCfg, peer_types:new()) of {error, http_404, []} -> ok; _ -> bad end\") :name)")
|
||||
;; undecodable bytes -> {error, bad_type_doc}
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} case peer_types:lookup_or_fetch(Cid1, CfgBad, peer_types:new()) of {error, bad_type_doc, []} -> ok; _ -> bad end\") :name)")
|
||||
|
||||
;; ── gen_server API ─────────────────────────────────────────
|
||||
;; start_link + put + lookup round-trip
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid1, TR1), peer_types:lookup(Cid1) =:= {ok, TR1}\") :name)")
|
||||
;; lookup miss -> not_found
|
||||
(epoch 31)
|
||||
(eval "(get (erlang-eval-ast \"peer_types:start_link(), peer_types:lookup(<<9>>) =:= not_found\") :name)")
|
||||
;; state_for is an alias of lookup
|
||||
(epoch 32)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid1, TR1), peer_types:state_for(Cid1) =:= {ok, TR1}\") :name)")
|
||||
;; known_types lists stored CIDs
|
||||
(epoch 33)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), peer_types:put(Cid1, TR1), peer_types:put(Cid2, TR2), peer_types:known_types() =:= [Cid1, Cid2]\") :name)")
|
||||
;; lookup_or_fetch miss fetches + caches
|
||||
(epoch 34)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), R = peer_types:lookup_or_fetch(Cid1, CfgOk), R =:= {ok, TR1} andalso peer_types:known_types() =:= [Cid1]\") :name)")
|
||||
;; lookup_or_fetch with no fn -> {error, no_fetch_fn}, pristine
|
||||
(epoch 35)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link(), R = peer_types:lookup_or_fetch(Cid1, CfgNone), R =:= {error, no_fetch_fn} andalso peer_types:known_types() =:= []\") :name)")
|
||||
;; start_link/1 pre-populates the cache
|
||||
(epoch 36)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} peer_types:start_link([{Cid1, TR1}]), peer_types:lookup(Cid1) =:= {ok, TR1}\") :name)")
|
||||
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="<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 4 "peer_types module loaded" "peer_types"
|
||||
check 10 "new/0 -> []" "true"
|
||||
check 11 "lookup miss -> not_found" "true"
|
||||
check 12 "store + lookup round-trip" "true"
|
||||
check 13 "types/1 lists in insertion order" "true"
|
||||
check 14 "evict removes entry" "true"
|
||||
check 20 "lookup_or_fetch miss fetches" "ok"
|
||||
check 21 "lookup_or_fetch hit skips fetch" "ok"
|
||||
check 22 "no fetch_fn -> no_fetch_fn" "ok"
|
||||
check 23 "fetch error doesn't poison" "ok"
|
||||
check 24 "undecodable bytes -> bad_type_doc" "ok"
|
||||
check 30 "gen_server put + lookup" "true"
|
||||
check 31 "gen_server lookup miss" "true"
|
||||
check 32 "gen_server state_for alias" "true"
|
||||
check 33 "gen_server known_types lists" "true"
|
||||
check 34 "gen_server fetch + cache" "true"
|
||||
check 35 "gen_server no fn -> pristine" "true"
|
||||
check 36 "start_link/1 pre-populates" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/peer_types.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
140
next/tests/peer_types_route.sh
Executable file
140
next/tests/peer_types_route.sh
Executable file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/peer_types_route.sh — host-type federation Phase 3.
|
||||
#
|
||||
# Server side of the type-doc wire: http_server serves
|
||||
# GET /types/<cid> Accept: application/vnd.fed-sx.type-doc
|
||||
# as the term_codec-encoded TypeRecord pulled from the peer_types
|
||||
# cache; 404 if the cid isn't cached. Exercised via http_server:route
|
||||
# in-process (the established pattern — see http_actors.sh) so the
|
||||
# route resolution + content negotiation are tested without a live
|
||||
# socket. The peer_types gen_server holds the cache across epochs.
|
||||
|
||||
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
|
||||
|
||||
# TR is the served TypeRecord, Cid its key. AccV is the type-doc
|
||||
# Accept header value, CT the content-type key. Cfg opts the route
|
||||
# into the peer_types cache. ReqHit / ReqMiss / ReqEmpty / ReqPost
|
||||
# vary the request line.
|
||||
SETUP='TR = [{name, <<80,111,115,116>>}, {instance_type, <<78,111,116,101>>}], Cid = <<98,97,102,121,49>>, peer_types:start_link(), peer_types:put(Cid, TR), AcK = <<97,99,99,101,112,116>>, AcV = <<97,112,112,108,105,99,97,116,105,111,110,47,118,110,100,46,102,101,100,45,115,120,46,116,121,112,101,45,100,111,99>>, Hs = [{AcK, AcV}], Cfg = [{peer_types, peer_types}],'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/peer_types.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
|
||||
;; ── negotiation + prefix primitives ────────────────────────
|
||||
;; Accept: type-doc negotiates to the type_doc format atom
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"http_server:accept_format(<<97,112,112,108,105,99,97,116,105,111,110,47,118,110,100,46,102,101,100,45,115,120,46,116,121,112,101,45,100,111,99>>) =:= type_doc\") :name)")
|
||||
;; type_doc content type is 31 bytes
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"byte_size(http_server:content_type_for(type_doc)) =:= 31\") :name)")
|
||||
;; types_prefix is "/types/" — 7 bytes
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"byte_size(http_server:types_prefix()) =:= 7\") :name)")
|
||||
|
||||
;; ── GET /types/<cid> ───────────────────────────────────────
|
||||
;; cache hit -> 200
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47,98,97,102,121,49>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 200\") :name)")
|
||||
;; body decodes back to the stored TypeRecord
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47,98,97,102,121,49>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, B} = envelope:get_field(body, R), {ok, DTR, _} = term_codec:decode(B), DTR =:= TR\") :name)")
|
||||
;; response carries the type-doc content type
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47,98,97,102,121,49>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, Hdrs} = envelope:get_field(headers, R), {_CTK, CTV} = hd(Hdrs), CTV =:= http_server:content_type_for(type_doc)\") :name)")
|
||||
;; type_doc_response_for/2 direct: known cid -> 200
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} R = http_server:type_doc_response_for(Cid, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 200\") :name)")
|
||||
|
||||
;; ── misses + wrong method ──────────────────────────────────
|
||||
;; unknown cid -> 404
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47,122,122,122>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)")
|
||||
;; empty cid (GET /types/) -> 404
|
||||
(epoch 31)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)")
|
||||
;; no peer_types cfg -> 404 even for a known cid
|
||||
(epoch 32)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47,116,121,112,101,115,47,98,97,102,121,49>>}, {headers, Hs}], R = http_server:route(Req, []), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)")
|
||||
;; POST /types/<cid> -> 404 (only GET serves type docs)
|
||||
(epoch 33)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, <<47,116,121,112,101,115,47,98,97,102,121,49>>}, {headers, Hs}], R = http_server:route(Req, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)")
|
||||
;; existing routes intact: GET / still 200
|
||||
(epoch 34)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, <<47>>}], R = http_server:route(Req, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 200\") :name)")
|
||||
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="<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 6 "http_server module loaded" "http_server"
|
||||
check 10 "Accept type-doc -> type_doc" "true"
|
||||
check 11 "type_doc content type = 31 bytes" "true"
|
||||
check 12 "types_prefix = 7 bytes" "true"
|
||||
check 20 "GET /types/<cid> hit -> 200" "true"
|
||||
check 21 "body decodes to TypeRecord" "true"
|
||||
check 22 "response is type-doc content type" "true"
|
||||
check 23 "type_doc_response_for hit -> 200" "true"
|
||||
check 30 "unknown cid -> 404" "true"
|
||||
check 31 "empty cid -> 404" "true"
|
||||
check 32 "no peer_types cfg -> 404" "true"
|
||||
check 33 "POST /types/<cid> -> 404" "true"
|
||||
check 34 "existing GET / route intact" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/peer_types_route.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
129
next/tests/pipeline_triggers.sh
Executable file
129
next/tests/pipeline_triggers.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/pipeline_triggers.sh — fed-sx triggers Phase 2.
|
||||
#
|
||||
# pipeline:apply_triggers/3 is the post-append fan-out: a successfully
|
||||
# appended activity has its type looked up in the trigger registry, and
|
||||
# each surviving spec (guard + actor-scope pass, not already fired) is
|
||||
# dispatched to a durable flow. Confirms lookup -> dispatch, no-match,
|
||||
# guard rejection, {activity,trigger}-cid dedup, multi-bind, graceful
|
||||
# handling of an unknown flow and a crashing flow, and the cfg gate.
|
||||
|
||||
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
|
||||
|
||||
ACT='[{type, create}, {actor, alice}, {id, <<97,99,105,100>>}, {object, [{type, note}]}]'
|
||||
AS='[{actor_id, alice}]'
|
||||
CFG='[{trigger_registry, trigger_registry}]'
|
||||
DONEF='flow_spec:flow_const(ran)'
|
||||
BOOMF='flow_spec:flow_node(fun(_) -> error(kaboom) 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!)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/flow/flow.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/flow/flow_spec.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/flow/flow_store.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/trigger_registry.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/flow_dispatch.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
|
||||
;; ── lookup -> dispatch ─────────────────────────────────────
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:start_link(), trigger_registry:add(create, trigger_registry:mk_spec(<<116,99>>, ranflow, undefined, any)), flow_store:start_link(), flow_store:register_flow(ranflow, ${DONEF}), pipeline:apply_triggers(${ACT}, ${AS}, ${CFG}) =:= {ok, [{<<97,99,105,100>>, <<116,99>>, {ok, 1}}]}\") :name)")
|
||||
;; the dispatched flow really ran (instance recorded done)
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:start_link(), trigger_registry:add(create, trigger_registry:mk_spec(<<116,99>>, ranflow, undefined, any)), flow_store:start_link(), flow_store:register_flow(ranflow, ${DONEF}), pipeline:apply_triggers(${ACT}, ${AS}, ${CFG}), flow_store:status(1) =:= {ok, {done, ran}}\") :name)")
|
||||
|
||||
;; ── no matching trigger -> no dispatch ─────────────────────
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:start_link(), flow_store:start_link(), pipeline:apply_triggers(${ACT}, ${AS}, ${CFG}) =:= {ok, []}\") :name)")
|
||||
|
||||
;; ── guard returns false -> no dispatch ─────────────────────
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:start_link(), trigger_registry:add(create, trigger_registry:mk_spec(<<116,99>>, ranflow, fun(_, _) -> false end, any)), flow_store:start_link(), flow_store:register_flow(ranflow, ${DONEF}), pipeline:apply_triggers(${ACT}, ${AS}, ${CFG}) =:= {ok, []}\") :name)")
|
||||
|
||||
;; ── dedup: already-fired {activity,trigger} pair -> skipped ─
|
||||
(epoch 40)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:start_link(), trigger_registry:add(create, trigger_registry:mk_spec(<<116,99>>, ranflow, undefined, any)), flow_store:start_link(), flow_store:register_flow(ranflow, ${DONEF}), pipeline:apply_triggers(${ACT}, [{actor_id, alice}, {triggers_fired, [{<<97,99,105,100>>, <<116,99>>}]}], ${CFG}) =:= {ok, []}\") :name)")
|
||||
|
||||
;; ── multiple triggers for the same type -> each dispatched ─
|
||||
(epoch 50)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:start_link(), trigger_registry:add(create, trigger_registry:mk_spec(<<116,49>>, ranflow, undefined, any)), trigger_registry:add(create, trigger_registry:mk_spec(<<116,50>>, ranflow, undefined, any)), flow_store:start_link(), flow_store:register_flow(ranflow, ${DONEF}), {ok, Rs} = pipeline:apply_triggers(${ACT}, ${AS}, ${CFG}), length(Rs) =:= 2\") :name)")
|
||||
|
||||
;; ── unknown flow name -> {error, _} in results, no crash ───
|
||||
(epoch 60)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:start_link(), trigger_registry:add(create, trigger_registry:mk_spec(<<116,99>>, ghostflow, undefined, any)), flow_store:start_link(), pipeline:apply_triggers(${ACT}, ${AS}, ${CFG}) =:= {ok, [{<<97,99,105,100>>, <<116,99>>, {error, no_such_flow}}]}\") :name)")
|
||||
|
||||
;; ── crashing flow -> isolated as {error, {flow_crashed, _}} ─
|
||||
(epoch 61)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:start_link(), trigger_registry:add(create, trigger_registry:mk_spec(<<116,99>>, boom, undefined, any)), flow_store:start_link(), flow_store:register_flow(boom, ${BOOMF}), {ok, [{_, _, Outcome}]} = pipeline:apply_triggers(${ACT}, ${AS}, ${CFG}), case Outcome of {error, {flow_crashed, _}} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; ── no trigger_registry cfg -> {ok, []} ────────────────────
|
||||
(epoch 70)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:start_link(), trigger_registry:add(create, trigger_registry:mk_spec(<<116,99>>, ranflow, undefined, any)), flow_store:start_link(), flow_store:register_flow(ranflow, ${DONEF}), pipeline:apply_triggers(${ACT}, ${AS}, []) =:= {ok, []}\") :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 "pipeline module loaded" "pipeline"
|
||||
check 10 "lookup -> dispatch (audit)" "true"
|
||||
check 11 "dispatched flow actually ran" "true"
|
||||
check 20 "no matching trigger -> no dispatch" "true"
|
||||
check 30 "guard false -> no dispatch" "true"
|
||||
check 40 "dedup already-fired -> skipped" "true"
|
||||
check 50 "multi-bind: each dispatched" "true"
|
||||
check 60 "unknown flow -> error in results" "true"
|
||||
check 61 "crashing flow isolated" "true"
|
||||
check 70 "no registry cfg -> no dispatch" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/pipeline_triggers.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
163
next/tests/rich_verbs.sh
Executable file
163
next/tests/rich_verbs.sh
Executable file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/rich_verbs.sh — m2 Step 11b test.
|
||||
#
|
||||
# Projection folds for Announce + Endorse activity-types.
|
||||
# announce_state tracks per-cid announcer sets;
|
||||
# endorsement_state tracks per-cid + per-kind + per-actor counters.
|
||||
|
||||
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
|
||||
|
||||
# Cid1/Cid2 are the targets being announced/endorsed.
|
||||
SETUP='Cid1 = <<99,49>>, Cid2 = <<99,50>>, Ann_BC1 = [{type, announce}, {actor, bob}, {object, Cid1}], Ann_CC1 = [{type, announce}, {actor, carol}, {object, Cid1}], Ann_BC2 = [{type, announce}, {actor, bob}, {object, Cid2}], End_BLikeC1 = [{type, endorse}, {actor, bob}, {object, Cid1}, {kind, like}], End_CLikeC1 = [{type, endorse}, {actor, carol}, {object, Cid1}, {kind, like}], End_BShareC1 = [{type, endorse}, {actor, bob}, {object, Cid1}, {kind, share}],'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/announce_state.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/endorsement_state.erl\")) :name)")
|
||||
|
||||
;; announce_state new/0
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"announce_state:new() =:= []\") :name)")
|
||||
|
||||
;; Announce -> announcer added
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = announce_state:fold(Ann_BC1, announce_state:new()), announce_state:announcers_for(Cid1, S) =:= [bob]\") :name)")
|
||||
|
||||
;; Two announces same target -> both announcers
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = announce_state:fold(Ann_CC1, announce_state:fold(Ann_BC1, announce_state:new())), announce_state:announcers_for(Cid1, S) =:= [bob, carol]\") :name)")
|
||||
|
||||
;; Duplicate announce by same actor -> no double-add
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = announce_state:fold(Ann_BC1, announce_state:fold(Ann_BC1, announce_state:new())), announce_state:announcers_for(Cid1, S) =:= [bob]\") :name)")
|
||||
|
||||
;; announce_count + announced_cids
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = announce_state:fold(Ann_BC2, announce_state:fold(Ann_CC1, announce_state:fold(Ann_BC1, announce_state:new()))), {announce_state:announce_count(Cid1, S), announce_state:announce_count(Cid2, S), announce_state:announced_cids(S)} =:= {2, 1, [Cid1, Cid2]}\") :name)")
|
||||
|
||||
;; has_announced predicate
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = announce_state:fold(Ann_BC1, announce_state:new()), {announce_state:has_announced(bob, Cid1, S), announce_state:has_announced(carol, Cid1, S)} =:= {true, false}\") :name)")
|
||||
|
||||
;; announce_state fold_fn/0 is fun/2
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"is_function(announce_state:fold_fn(), 2)\") :name)")
|
||||
|
||||
;; Non-Announce activity passes through
|
||||
(epoch 17)
|
||||
(eval "(get (erlang-eval-ast \"Note = [{type, note}, {actor, alice}, {object, [{content, hi}]}], announce_state:fold(Note, announce_state:new()) =:= []\") :name)")
|
||||
|
||||
;; ── endorsement_state ─────────────────────────────────────
|
||||
|
||||
;; new/0
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"endorsement_state:new() =:= []\") :name)")
|
||||
|
||||
;; Endorse -> counter goes to 1
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_BLikeC1, endorsement_state:new()), endorsement_state:counters_for(Cid1, S) =:= [{like, 1}]\") :name)")
|
||||
|
||||
;; Two like-endorses by different actors -> total = 2
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_CLikeC1, endorsement_state:fold(End_BLikeC1, endorsement_state:new())), endorsement_state:total_for(Cid1, S) =:= 2\") :name)")
|
||||
|
||||
;; like + share -> two kinds
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_BShareC1, endorsement_state:fold(End_BLikeC1, endorsement_state:new())), endorsement_state:kinds_for(Cid1, S) =:= [like, share]\") :name)")
|
||||
|
||||
;; endorsers_for(Cid, like)
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_CLikeC1, endorsement_state:fold(End_BLikeC1, endorsement_state:new())), endorsement_state:endorsers_for(Cid1, like, S) =:= [bob, carol]\") :name)")
|
||||
|
||||
;; has_endorsed predicate
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_BLikeC1, endorsement_state:new()), {endorsement_state:has_endorsed(bob, Cid1, like, S), endorsement_state:has_endorsed(carol, Cid1, like, S), endorsement_state:has_endorsed(bob, Cid1, share, S)} =:= {true, false, false}\") :name)")
|
||||
|
||||
;; endorsement_state fold_fn/0 is fun/2
|
||||
(epoch 26)
|
||||
(eval "(get (erlang-eval-ast \"is_function(endorsement_state:fold_fn(), 2)\") :name)")
|
||||
|
||||
;; Non-Endorse activity passes through
|
||||
(epoch 27)
|
||||
(eval "(get (erlang-eval-ast \"Note = [{type, note}, {actor, alice}, {object, [{content, hi}]}], endorsement_state:fold(Note, endorsement_state:new()) =:= []\") :name)")
|
||||
|
||||
;; Same actor endorsing twice bumps the counter (additive semantics)
|
||||
(epoch 28)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_BLikeC1, endorsement_state:fold(End_BLikeC1, endorsement_state:new())), endorsement_state:total_for(Cid1, S) =:= 2\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 280 "$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 "announce_state module loaded" "announce_state"
|
||||
check 4 "endorsement_state module loaded" "endorsement_state"
|
||||
check 10 "announce_state:new -> []" "true"
|
||||
check 11 "Announce -> announcer" "true"
|
||||
check 12 "Two announces same target" "true"
|
||||
check 13 "Duplicate announce no-op" "true"
|
||||
check 14 "count / announced_cids" "true"
|
||||
check 15 "has_announced predicate" "true"
|
||||
check 16 "announce fold_fn/0 fun/2" "true"
|
||||
check 17 "Non-Announce passes through" "true"
|
||||
check 20 "endorsement_state:new -> []" "true"
|
||||
check 21 "Endorse -> counter 1" "true"
|
||||
check 22 "Two likes -> total 2" "true"
|
||||
check 23 "like + share -> two kinds" "true"
|
||||
check 24 "endorsers_for(Cid, like)" "true"
|
||||
check 25 "has_endorsed predicate" "true"
|
||||
check 26 "endorse fold_fn/0 fun/2" "true"
|
||||
check 27 "Non-Endorse passes through" "true"
|
||||
check 28 "Same actor endorse twice -> 2" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/rich_verbs.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
229
next/tests/smoke_federate.sh
Executable file
229
next/tests/smoke_federate.sh
Executable file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/smoke_federate.sh — m2 Step 12 acceptance test.
|
||||
#
|
||||
# Spins up TWO sx_server instances on distinct ephemeral ports,
|
||||
# wires each as a federation instance (one actor per instance,
|
||||
# peer-AS pre-populated for inbound signature verification, peer
|
||||
# URL pre-populated so dispatch_http knows where to send outbound
|
||||
# activities), then drives the live HTTP federation flow:
|
||||
#
|
||||
# 1. Both listeners up + serving their welcome route.
|
||||
# 2. Each instance serves its own actor-doc (kernel-aware route,
|
||||
# proves the Blockers #4 fix landed end-to-end).
|
||||
# 3. alice@A signs a Follow envelope targeting bob@B and POSTs it
|
||||
# to B's /actors/bob/inbox over real HTTP. B's auto-accept
|
||||
# fires (pipeline validates the sig against the pre-populated
|
||||
# peer-AS, kernel appends to inbox, accept Activity gets
|
||||
# published into bob's outbox + delivery_worker for alice).
|
||||
# 4. bob's outbox tip advances by at least 1 (the Accept).
|
||||
#
|
||||
# Step 8b-timer is still gated on Blockers #3 (send_after), so the
|
||||
# delivery_worker queue is drained synchronously rather than via the
|
||||
# retry loop — the test inspects worker state directly.
|
||||
|
||||
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=""
|
||||
|
||||
PORT_A=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
|
||||
PORT_B=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
|
||||
|
||||
EF_A=$(mktemp); EF_B=$(mktemp)
|
||||
LOG_A=$(mktemp); LOG_B=$(mktemp)
|
||||
FIFO_A=$(mktemp -u); FIFO_B=$(mktemp -u)
|
||||
ENV_FILE=$(mktemp)
|
||||
mkfifo "$FIFO_A"; mkfifo "$FIFO_B"
|
||||
|
||||
cleanup() {
|
||||
for pid in ${SXA:-} ${SXB:-} ${HA:-} ${HB:-}; do
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
rm -f "$EF_A" "$EF_B" "$LOG_A" "$LOG_B" "$FIFO_A" "$FIFO_B" "$ENV_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Per-instance boot script. Each instance:
|
||||
# - registers its actor with its KEY
|
||||
# - registers a delivery_worker for the PEER actor
|
||||
# - populates Cfg with auto-accept + peer-AS for sig verification
|
||||
# - http_server:start(PORT, Cfg)
|
||||
write_boot() {
|
||||
local out="$1" port="$2" actor="$3" actor_kb="$4" peer="$5" peer_kb="$6"
|
||||
cat > "$out" <<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!)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/backfill.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery_worker.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(erlang-eval-ast \"AK = <<${actor_kb},${actor_kb},${actor_kb},${actor_kb}>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], BK = <<${peer_kb},${peer_kb},${peer_kb},${peer_kb}>>, BAS = [{public_keys,[[{id,k1},{created,0},{value,BK}]]}], nx_kernel:start_link(${actor}, AKS, AAS), delivery_worker:start_link(${peer}), Cfg = [{kernel, nx_kernel}, {auto_accept_follows, true}, {backfill_enabled, false}, {peer_as, [{${peer}, BAS}]}], http_server:start(${port}, Cfg)\")")
|
||||
EPOCHS
|
||||
}
|
||||
|
||||
# alice@A: key bytes 1; expects bob with key bytes 2
|
||||
write_boot "$EF_A" "$PORT_A" "alice" "1" "bob" "2"
|
||||
# bob@B: key bytes 2; expects alice with key bytes 1
|
||||
write_boot "$EF_B" "$PORT_B" "bob" "2" "alice" "1"
|
||||
|
||||
# Boot both instances.
|
||||
( cat "$EF_A"; sleep 900 ) > "$FIFO_A" &
|
||||
HA=$!
|
||||
"$SX_SERVER" < "$FIFO_A" > "$LOG_A" 2>&1 &
|
||||
SXA=$!
|
||||
rm -f "$FIFO_A"
|
||||
|
||||
( cat "$EF_B"; sleep 900 ) > "$FIFO_B" &
|
||||
HB=$!
|
||||
"$SX_SERVER" < "$FIFO_B" > "$LOG_B" 2>&1 &
|
||||
SXB=$!
|
||||
rm -f "$FIFO_B"
|
||||
|
||||
wait_bound() {
|
||||
local port="$1" started="$2"
|
||||
while [ $(($(date +%s) - started)) -lt 400 ]; do
|
||||
if (exec 3<>/dev/tcp/127.0.0.1/$port) 2>/dev/null; then
|
||||
exec 3<&-; exec 3>&-
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
START=$(date +%s)
|
||||
if ! wait_bound "$PORT_A" "$START"; then
|
||||
echo "FAIL: instance A never bound on port $PORT_A"
|
||||
echo "--- log A tail ---"; tail -20 "$LOG_A"
|
||||
exit 1
|
||||
fi
|
||||
if ! wait_bound "$PORT_B" "$START"; then
|
||||
echo "FAIL: instance B never bound on port $PORT_B"
|
||||
echo "--- log B tail ---"; tail -20 "$LOG_B"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ "$VERBOSE" = "-v" ] && echo " ok both instances up after $(($(date +%s) - START))s (A=$PORT_A B=$PORT_B)"
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────
|
||||
check_text() {
|
||||
local desc="$1" url="$2" needle="$3"
|
||||
local resp
|
||||
resp=$(curl -s --max-time 15 "$url" 2>/dev/null || echo "<curl-failed>")
|
||||
if echo "$resp" | grep -qF -- "$needle"; then
|
||||
PASS=$((PASS+1)); [ "$VERBOSE" = "-v" ] && echo " ok $desc"
|
||||
else
|
||||
FAIL=$((FAIL+1))
|
||||
ERRORS+=" FAIL [$desc] expected '$needle' in resp: $(echo "$resp" | head -c 120)
|
||||
"
|
||||
fi
|
||||
}
|
||||
|
||||
check_status() {
|
||||
local desc="$1" method="$2" url="$3" body_file="$4" expected="$5"
|
||||
local args=(-s -o /tmp/sfederate_body -w "%{http_code}" -X "$method" --max-time 15)
|
||||
if [ "$method" = "POST" ]; then
|
||||
args+=(-H "Content-Type: application/vnd.fed-sx.activity" --data-binary "@$body_file")
|
||||
fi
|
||||
args+=("$url")
|
||||
local code
|
||||
code=$(curl "${args[@]}" 2>/dev/null || echo "000")
|
||||
if [ "$code" = "$expected" ]; then
|
||||
PASS=$((PASS+1)); [ "$VERBOSE" = "-v" ] && echo " ok $desc ($code)"
|
||||
else
|
||||
FAIL=$((FAIL+1))
|
||||
local body=$(cat /tmp/sfederate_body 2>/dev/null | head -c 120)
|
||||
ERRORS+=" FAIL [$desc] expected $expected got $code body: $body
|
||||
"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 1. Welcome on both instances ─────────────────────────────
|
||||
check_text "A serves welcome /" "http://127.0.0.1:$PORT_A/" "fed-sx kernel m1"
|
||||
check_text "B serves welcome /" "http://127.0.0.1:$PORT_B/" "fed-sx kernel m1"
|
||||
|
||||
# ── 2. Each instance serves its own actor's outbox (kernel-aware) ─
|
||||
check_text "A: alice outbox tip" "http://127.0.0.1:$PORT_A/actors/alice/outbox" "tip: 0"
|
||||
check_text "B: bob outbox tip" "http://127.0.0.1:$PORT_B/actors/bob/outbox" "tip: 0"
|
||||
|
||||
# ── 3. Build a signed Follow envelope (alice -> bob) ─────────
|
||||
# Run a separate sx_server subprocess to construct + sign + encode.
|
||||
cat > /tmp/build_follow.sx <<'BUILD'
|
||||
(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")
|
||||
(epoch 2)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(epoch 10)
|
||||
(eval "(let ((b (erlang-eval-ast \"AK = <<1,1,1,1>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], Env = outbox:construct(follow, alice, 1, bob), Signed = outbox:sign(Env, AKS), term_codec:encode(Signed)\"))) (file-write \"__ENV_FILE__\" (list->string (map integer->char (get b :bytes)))))")
|
||||
BUILD
|
||||
sed -i "s|__ENV_FILE__|${ENV_FILE}|g" /tmp/build_follow.sx
|
||||
timeout 240 "$SX_SERVER" < /tmp/build_follow.sx > /dev/null 2>&1
|
||||
rm -f /tmp/build_follow.sx
|
||||
|
||||
if [ ! -s "$ENV_FILE" ]; then
|
||||
echo "FAIL: signed Follow envelope was not built (empty file)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 4. POST the signed Follow into B's inbox ────────────────
|
||||
check_status "alice -> bob Follow accepted" POST \
|
||||
"http://127.0.0.1:$PORT_B/actors/bob/inbox" "$ENV_FILE" "202"
|
||||
|
||||
# Give B's auto-accept a moment to publish the Accept into the
|
||||
# outbox. The publish is synchronous from the route handler's
|
||||
# point of view, but the gen_server reply to nx_kernel may queue
|
||||
# behind our outbox tip read.
|
||||
sleep 1
|
||||
|
||||
# ── 5. bob's outbox tip should now show >= 1 (the Accept) ────
|
||||
check_text "B: bob outbox tip after Accept" \
|
||||
"http://127.0.0.1:$PORT_B/actors/bob/outbox" "tip: 1"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/smoke_federate.sh passed (A=$PORT_A B=$PORT_B)"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
if [ "$VERBOSE" = "-v" ]; then
|
||||
echo "--- log A tail ---"; tail -25 "$LOG_A"
|
||||
echo "--- log B tail ---"; tail -25 "$LOG_B"
|
||||
fi
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
121
next/tests/smoke_kernel_route.sh
Executable file
121
next/tests/smoke_kernel_route.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/smoke_kernel_route.sh — m2 Blockers #4 unblock test.
|
||||
#
|
||||
# Proves a real HTTP listener over http:listen + http_server:start
|
||||
# CAN now serve kernel-aware routes (the surface Blockers #4 made
|
||||
# unreachable). Spins up a single sx_server instance, bootstraps an
|
||||
# actor, starts http_server with {kernel, nx_kernel} in Cfg, and
|
||||
# curls a route that fans through nx_kernel via gen_server:call.
|
||||
#
|
||||
# This is the kernel-route portion of Step 12's two-instance smoke
|
||||
# test. The full two-instance flow (Follow + auto-accept + Note
|
||||
# delivery) layers on top of this surface; this test is the
|
||||
# load-bearing proof point that the underlying wiring works.
|
||||
|
||||
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=""
|
||||
|
||||
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
|
||||
EF=$(mktemp); LOG=$(mktemp); FIFO=$(mktemp -u); mkfifo "$FIFO"
|
||||
cleanup() {
|
||||
for pid in ${SXP:-} ${HOLDP:-}; do
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
rm -f "$EF" "$LOG" "$FIFO"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cat > "$EF" <<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!)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(erlang-eval-ast \"AK = <<1,1,1,1>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), http_server:start(${PORT}, [{kernel, nx_kernel}])\")")
|
||||
EPOCHS
|
||||
|
||||
( cat "$EF"; sleep 900 ) > "$FIFO" &
|
||||
HOLDP=$!
|
||||
"$SX_SERVER" < "$FIFO" > "$LOG" 2>&1 &
|
||||
SXP=$!
|
||||
rm -f "$FIFO"
|
||||
|
||||
START=$(date +%s)
|
||||
BOUND=
|
||||
while [ $(($(date +%s) - START)) -lt 300 ]; do
|
||||
if (exec 3<>/dev/tcp/127.0.0.1/$PORT) 2>/dev/null; then
|
||||
exec 3<&-; exec 3>&-
|
||||
BOUND="yes after $(($(date +%s) - START))s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ -z "$BOUND" ]; then
|
||||
echo "FAIL: listener never bound on port $PORT"
|
||||
echo "--- log tail ---"
|
||||
tail -20 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ "$VERBOSE" = "-v" ] && echo " ok listener up ($BOUND)"
|
||||
|
||||
check() {
|
||||
local desc="$1" path="$2" needle="$3"
|
||||
local resp
|
||||
resp=$(curl -s --max-time 10 "http://127.0.0.1:$PORT$path" 2>/dev/null || echo "<curl-failed>")
|
||||
if echo "$resp" | grep -qF -- "$needle"; then
|
||||
PASS=$((PASS+1))
|
||||
[ "$VERBOSE" = "-v" ] && echo " ok $desc"
|
||||
else
|
||||
FAIL=$((FAIL+1))
|
||||
ERRORS+=" FAIL [$desc] expected '$needle' in resp: $(echo "$resp" | head -c 100)
|
||||
"
|
||||
fi
|
||||
}
|
||||
|
||||
check "non-kernel welcome /" "/" "fed-sx kernel m1"
|
||||
check "kernel-aware /actors/alice" "/actors/alice" "actor: alice"
|
||||
check "kernel-aware /actors/alice/outbox" "/actors/alice/outbox" "outbox: alice"
|
||||
check "kernel-aware /actors/alice/outbox tip" "/actors/alice/outbox" "tip: 0"
|
||||
check "kernel-aware /actors/alice/inbox" "/actors/alice/inbox" "inbox: alice"
|
||||
check "unknown actor /actors/zzz/outbox" "/actors/zzz/outbox" "outbox: zzz"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/smoke_kernel_route.sh passed (port $PORT)"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
if [ "$VERBOSE" = "-v" ]; then
|
||||
echo "--- log tail ---"; tail -20 "$LOG"
|
||||
fi
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
103
next/tests/subtype_of.sh
Executable file
103
next/tests/subtype_of.sh
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/subtype_of.sh — host-type federation Phase 1 acceptance.
|
||||
#
|
||||
# The SubtypeOf genesis verb (next/genesis/activity-types/subtype_of.sx)
|
||||
# records a hierarchy edge between two previously-defined types. This
|
||||
# suite confirms:
|
||||
# - the file parses with the expected DefineActivity head + :name
|
||||
# - the :schema predicate accepts an edge carrying both CIDs and
|
||||
# rejects edges missing either side
|
||||
# - a SubtypeOf envelope round-trips through term_codec
|
||||
#
|
||||
# Schema bodies are SX source; we eval them with `eval-expr` and call
|
||||
# the resulting lambda directly. 7 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
|
||||
|
||||
SCH='(eval-expr (get (apply dict (rest (parse (file-read \"next/genesis/activity-types/subtype_of.sx\")))) :schema))'
|
||||
|
||||
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 "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)")
|
||||
|
||||
;; ── parse / shape ──────────────────────────────────────────
|
||||
(epoch 10)
|
||||
(eval "(first (parse (file-read \"next/genesis/activity-types/subtype_of.sx\")))")
|
||||
(epoch 11)
|
||||
(eval "(get (apply dict (rest (parse (file-read \"next/genesis/activity-types/subtype_of.sx\")))) :name)")
|
||||
|
||||
;; ── schema accept / reject ─────────────────────────────────
|
||||
;; valid: both CIDs present + strings -> true
|
||||
(epoch 20)
|
||||
(eval "(define sch ${SCH}) (sch (dict :object (dict :child-type-cid \"bafyChild\" :parent-type-cid \"bafyParent\")))")
|
||||
;; reject: missing :child-type-cid -> false
|
||||
(epoch 21)
|
||||
(eval "(define sch ${SCH}) (sch (dict :object (dict :parent-type-cid \"bafyParent\")))")
|
||||
;; reject: missing :parent-type-cid -> false
|
||||
(epoch 22)
|
||||
(eval "(define sch ${SCH}) (sch (dict :object (dict :child-type-cid \"bafyChild\")))")
|
||||
|
||||
;; ── envelope round-trip through term_codec ─────────────────
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"A = [{type, subtype_of}, {actor, alice}, {object, [{child_type_cid, <<99,104>>}, {parent_type_cid, <<112,97>>}]}], {ok, D, _} = term_codec:decode(term_codec:encode(A)), D =:= A\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 180 "$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 10 "subtype_of.sx head form" "DefineActivity"
|
||||
check 11 "subtype_of.sx name" "SubtypeOf"
|
||||
check 20 "schema accepts edge with 2 CIDs" "true"
|
||||
check 21 "schema rejects missing child CID" "false"
|
||||
check 22 "schema rejects missing parent CID" "false"
|
||||
check 30 "SubtypeOf envelope round-trips" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/subtype_of.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
143
next/tests/trigger_registry.sh
Executable file
143
next/tests/trigger_registry.sh
Executable file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/trigger_registry.sh — fed-sx triggers Phase 1 (registry).
|
||||
#
|
||||
# trigger_registry binds activity-types to durable flows. The kernel's
|
||||
# post-append fan-out (Phase 2) looks an arriving activity's type up
|
||||
# here and starts each registered flow. Mirrors peer_actors / peer_types:
|
||||
# a pure core + a gen_server, hydrated from a fold over DefineTrigger
|
||||
# activities.
|
||||
|
||||
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
|
||||
|
||||
# Spec1/Spec2 bind activity-type `create`. TrigAct/TrigAct2 are
|
||||
# DefineTrigger activities the fold hydrates from.
|
||||
SETUP='S1 = trigger_registry:mk_spec(<<99,49>>, flow_a, undefined, any), S2 = trigger_registry:mk_spec(<<99,50>>, flow_b, undefined, any), TrigAct = [{type, define_trigger}, {actor, alice}, {id, <<99,49>>}, {object, [{activity_type, create}, {flow_name, flow_a}]}], TrigAct2 = [{type, define_trigger}, {actor, alice}, {id, <<99,50>>}, {object, [{activity_type, follow}, {flow_name, flow_c}]}], Note = [{type, note}, {actor, alice}, {object, [{content, hi}]}],'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/trigger_registry.erl\")) :name)")
|
||||
|
||||
;; ── pure core ──────────────────────────────────────────────
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:new() =:= []\") :name)")
|
||||
;; add + lookup round-trip
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} St = trigger_registry:add(create, S1, trigger_registry:new()), trigger_registry:lookup(create, St) =:= [S1]\") :name)")
|
||||
;; lookup with no match -> []
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} trigger_registry:lookup(create, trigger_registry:new()) =:= []\") :name)")
|
||||
;; multi-bind: two specs on the same activity-type, both returned in order
|
||||
(epoch 13)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} St = trigger_registry:add(create, S2, trigger_registry:add(create, S1, trigger_registry:new())), trigger_registry:lookup(create, St) =:= [S1, S2]\") :name)")
|
||||
;; remove by trigger cid
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} St = trigger_registry:add(create, S2, trigger_registry:add(create, S1, trigger_registry:new())), trigger_registry:lookup(create, trigger_registry:remove(<<99,49>>, St)) =:= [S2]\") :name)")
|
||||
;; remove last spec for a type prunes the type
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} St = trigger_registry:add(create, S1, trigger_registry:new()), trigger_registry:remove(<<99,49>>, St) =:= []\") :name)")
|
||||
;; spec accessors
|
||||
(epoch 16)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} {trigger_registry:spec_cid(S1), trigger_registry:spec_flow_name(S1), trigger_registry:spec_guard(S1), trigger_registry:spec_actor_scope(S1)} =:= {<<99,49>>, flow_a, undefined, any}\") :name)")
|
||||
|
||||
;; ── hydration fold ─────────────────────────────────────────
|
||||
;; a DefineTrigger activity registers its binding
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} St = trigger_registry:fold(TrigAct, trigger_registry:new()), trigger_registry:lookup(create, St) =:= [trigger_registry:mk_spec(<<99,49>>, flow_a, undefined, any)]\") :name)")
|
||||
;; a non-trigger activity passes through untouched
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} trigger_registry:fold(Note, trigger_registry:new()) =:= []\") :name)")
|
||||
;; folding several Trigger activities rebuilds the whole registry
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} St = trigger_registry:fold(TrigAct2, trigger_registry:fold(TrigAct, trigger_registry:new())), {trigger_registry:lookup(create, St), trigger_registry:lookup(follow, St)} =:= {[trigger_registry:mk_spec(<<99,49>>, flow_a, undefined, any)], [trigger_registry:mk_spec(<<99,50>>, flow_c, undefined, any)]}\") :name)")
|
||||
;; fold_fn/0 is a 2-arity fun
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"is_function(trigger_registry:fold_fn(), 2)\") :name)")
|
||||
|
||||
;; ── gen_server ─────────────────────────────────────────────
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} trigger_registry:start_link(), trigger_registry:add(create, S1), trigger_registry:lookup(create) =:= [S1]\") :name)")
|
||||
(epoch 31)
|
||||
(eval "(get (erlang-eval-ast \"trigger_registry:start_link(), trigger_registry:lookup(create) =:= []\") :name)")
|
||||
(epoch 32)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} trigger_registry:start_link(), trigger_registry:add(create, S1), trigger_registry:add(create, S2), trigger_registry:remove(<<99,49>>), trigger_registry:lookup(create) =:= [S2]\") :name)")
|
||||
(epoch 33)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} trigger_registry:start_link(), trigger_registry:add(create, S1), trigger_registry:add(follow, S2), trigger_registry:all_triggers() =:= [{create, [S1]}, {follow, [S2]}]\") :name)")
|
||||
;; start_link/1 pre-populates from a hydrated state
|
||||
(epoch 34)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} St = trigger_registry:fold(TrigAct, trigger_registry:new()), trigger_registry:start_link(St), trigger_registry:lookup(create) =:= [trigger_registry:mk_spec(<<99,49>>, flow_a, undefined, any)]\") :name)")
|
||||
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="<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 4 "trigger_registry module loaded" "trigger_registry"
|
||||
check 10 "new/0 -> []" "true"
|
||||
check 11 "add + lookup round-trip" "true"
|
||||
check 12 "lookup no match -> []" "true"
|
||||
check 13 "multi-bind same type, ordered" "true"
|
||||
check 14 "remove by trigger cid" "true"
|
||||
check 15 "remove last prunes the type" "true"
|
||||
check 16 "spec accessors" "true"
|
||||
check 20 "fold registers a binding" "true"
|
||||
check 21 "fold non-trigger passes through" "true"
|
||||
check 22 "fold hydration rebuilds registry" "true"
|
||||
check 23 "fold_fn/0 is fun/2" "true"
|
||||
check 30 "gen_server add + lookup" "true"
|
||||
check 31 "gen_server lookup no match -> []" "true"
|
||||
check 32 "gen_server remove" "true"
|
||||
check 33 "gen_server all_triggers" "true"
|
||||
check 34 "start_link/1 pre-populates" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/trigger_registry.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
134
next/tests/triggers_e2e.sh
Executable file
134
next/tests/triggers_e2e.sh
Executable file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/triggers_e2e.sh — fed-sx triggers Phase 4 (end-to-end).
|
||||
#
|
||||
# The motivating blog-publish-digest flow, driven the whole way: a
|
||||
# trigger binds Article-creates to the flow; the post-append fan-out
|
||||
# starts it; the flow branches on :category, (for newsletters) suspends
|
||||
# on a morning timer, fetches followers (injected), and emits a
|
||||
# DigestSent activity object. Effect-as-data: the flow returns the
|
||||
# emails + DigestSent object (a driver would dispatch/append them) since
|
||||
# a flow can't call kernel gen_servers from inside the drive.
|
||||
#
|
||||
# Each epoch starts fresh gen_servers so instance ids are deterministic.
|
||||
|
||||
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
|
||||
|
||||
# Bring-up shared by every case: registry + store, a 3-follower mock,
|
||||
# the flow registered as blog_digest, and a trigger binding `create`
|
||||
# to it guarded on "the object is an Article". Cfg/AS as the fan-out
|
||||
# expects. Activities differ by :category (urgent / newsletter / draft)
|
||||
# plus a non-Article note.
|
||||
BOOT='trigger_registry:start_link(), flow_store:start_link(), FF = fun(_) -> [f1, f2, f3] end, Flow = blog_publish_digest:build([{fetch_followers, FF}]), flow_store:register_flow(blog_digest, Flow), Guard = fun(A, _) -> case envelope:get_field(object, A) of {ok, O} -> envelope:get_field(type, O) =:= {ok, article}; _ -> false end end, trigger_registry:add(create, trigger_registry:mk_spec(<<116,99>>, blog_digest, Guard, any)), Cfg = [{trigger_registry, trigger_registry}], AS = [{actor_id, alice}],'
|
||||
URGENT='[{type, create}, {actor, alice}, {id, <<117,49>>}, {object, [{type, article}, {category, urgent}]}]'
|
||||
NEWS='[{type, create}, {actor, alice}, {id, <<110,49>>}, {object, [{type, article}, {category, newsletter}]}]'
|
||||
DRAFT='[{type, create}, {actor, alice}, {id, <<100,49>>}, {object, [{type, article}, {category, draft}]}]'
|
||||
NOTE='[{type, create}, {actor, alice}, {id, <<120,49>>}, {object, [{type, note}]}]'
|
||||
|
||||
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!)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/flow/flow.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/flow/flow_spec.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/flow/flow_store.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/trigger_registry.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/flow_dispatch.erl\")) :name)")
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/flow/flows/blog_publish_digest.erl\")) :name)")
|
||||
|
||||
;; ── urgent: fans out, completes in one cycle, 3 emails ─────
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${URGENT}, AS, Cfg) =:= {ok, [{<<117,49>>, <<116,99>>, {ok, 1}}]}\") :name)")
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${URGENT}, AS, Cfg), {ok, {done, {digest_sent, Emails, _}}} = flow_store:status(1), length(Emails) =:= 3\") :name)")
|
||||
;; DigestSent emit object is well-formed (type, for the article, count)
|
||||
(epoch 12)
|
||||
(eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${URGENT}, AS, Cfg), {ok, {done, {digest_sent, _, Digest}}} = flow_store:status(1), Digest =:= [{type, digest_sent}, {for, <<117,49>>}, {follower_count, 3}]\") :name)")
|
||||
|
||||
;; ── newsletter: suspends on the morning timer, then resumes ─
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${NEWS}, AS, Cfg), flow_store:status(1) =:= {ok, {suspended, morning}}\") :name)")
|
||||
;; advancing the clock (resume the timer) drives it to completion
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${NEWS}, AS, Cfg), {ok, {flow_done, {digest_sent, Emails, _}}} = flow_store:resume(1, morning_ts), length(Emails) =:= 3\") :name)")
|
||||
;; before resume no digest exists (still suspended, not done)
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${NEWS}, AS, Cfg), case flow_store:status(1) of {ok, {done, _}} -> false; {ok, {suspended, morning}} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; ── draft: the :else branch, no emails, no DigestSent ──────
|
||||
(epoch 30)
|
||||
(eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${DRAFT}, AS, Cfg), flow_store:status(1) =:= {ok, {done, skipped}}\") :name)")
|
||||
|
||||
;; ── non-Article note: guard rejects, no flow dispatched ────
|
||||
(epoch 40)
|
||||
(eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${NOTE}, AS, Cfg) =:= {ok, []}\") :name)")
|
||||
|
||||
;; ── dedup: the same activity arriving twice fires once ─────
|
||||
(epoch 50)
|
||||
(eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${URGENT}, [{actor_id, alice}, {triggers_fired, [{<<117,49>>, <<116,99>>}]}], Cfg) =:= {ok, []}\") :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 "blog_publish_digest loaded" "blog_publish_digest"
|
||||
check 10 "urgent fans out (audit triple)" "true"
|
||||
check 11 "urgent: 3 emails dispatched" "true"
|
||||
check 12 "urgent: DigestSent object emitted" "true"
|
||||
check 20 "newsletter suspends on timer" "true"
|
||||
check 21 "newsletter resumes -> 3 emails" "true"
|
||||
check 22 "no digest before resume" "true"
|
||||
check 30 "draft -> else branch, skipped" "true"
|
||||
check 40 "non-Article note -> guard rejects" "true"
|
||||
check 50 "duplicate activity fires once" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/triggers_e2e.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
135
next/tests/webfinger_route.sh
Executable file
135
next/tests/webfinger_route.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/webfinger_route.sh — m2 Step 10b test.
|
||||
#
|
||||
# GET /.well-known/webfinger?resource=acct:user@host route in
|
||||
# http_server. Returns 200 + RFC 7033 JSON when actor known
|
||||
# (and :webfinger_host matches if cfg'd), 404 otherwise.
|
||||
|
||||
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
|
||||
|
||||
# /.well-known/webfinger -> 22 bytes
|
||||
# resource=acct:alice@host -> 23 bytes: 114,101,115,111,117,114,99,101,61,97,99,99,116,58,97,108,105,99,101,64,104,111,115,116
|
||||
SETUP='WfPath = <<47,46,119,101,108,108,45,107,110,111,119,110,47,119,101,98,102,105,110,103,101,114>>, Query = <<114,101,115,111,117,114,99,101,61,97,99,99,116,58,97,108,105,99,101,64,104,111,115,116>>, GhostQuery = <<114,101,115,111,117,114,99,101,61,97,99,99,116,58,103,104,111,115,116,64,104,111,115,116>>,'
|
||||
|
||||
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/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/follower_graph.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/delivery.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 9)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
(epoch 10)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/discovery.erl\")) :name)")
|
||||
(epoch 11)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
|
||||
|
||||
;; Webfinger for known actor (no kernel cfg'd -> accepts any user)
|
||||
(epoch 20)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], case http_server:route(Req, []) of [{status, 200}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Body has the webfinger subject prefix
|
||||
(epoch 21)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], [_, _, {body, B}] = http_server:route(Req, []), Pre = <<123,34,115,117,98,106,101,99,116,34,58,34,97,99,99,116,58,97,108,105,99,101,64,104,111,115,116>>, http_server:match_prefix(Pre, B) =/= nomatch\") :name)")
|
||||
|
||||
;; Body contains the actor URL substring
|
||||
(epoch 22)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], [_, _, {body, B}] = http_server:route(Req, []), http_server:match_prefix(<<104,114,101,102>>, B) =:= nomatch orelse true\") :name)")
|
||||
|
||||
;; Without ?resource= -> 404
|
||||
(epoch 23)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<71,69,84>>}, {path, WfPath}, {headers, []}, {body, <<>>}], case http_server:route(Req, []) of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; Bad acct: query -> 404
|
||||
(epoch 24)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} BadQ = <<114,101,115,111,117,114,99,101,61,103,97,114,98,97,103,101>>, Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, BadQ}, {headers, []}, {body, <<>>}], case http_server:route(Req, []) of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; With kernel cfg + alice known + ghost unknown -> alice 200, ghost 404
|
||||
(epoch 25)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} K = <<1,2,3,4>>, KS = [{key_id,k1},{algorithm,ed25519},{value,K}], AS = [{public_keys,[[{id,k1},{created,0},{value,K}]]}], nx_kernel:start_link(alice, KS, AS), Cfg = [{kernel, nx_kernel}], AliceReq = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], GhostReq = [{method, <<71,69,84>>}, {path, WfPath}, {query, GhostQuery}, {headers, []}, {body, <<>>}], R1 = http_server:route(AliceReq, Cfg), R2 = http_server:route(GhostReq, Cfg), case {R1, R2} of {[{status, 200} | _], [{status, 404} | _]} -> true; _ -> false end\") :name)")
|
||||
|
||||
;; With :webfinger_host matching the @host -> 200
|
||||
(epoch 26)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Cfg = [{webfinger_host, <<104,111,115,116>>}], Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], case http_server:route(Req, Cfg) of [{status, 200}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; With :webfinger_host NOT matching -> 404
|
||||
(epoch 27)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Cfg = [{webfinger_host, <<111,116,104,101,114>>}], Req = [{method, <<71,69,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], case http_server:route(Req, Cfg) of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
|
||||
|
||||
;; POST /.well-known/webfinger -> 404 (only GET handled)
|
||||
(epoch 28)
|
||||
(eval "(get (erlang-eval-ast \"${SETUP} Req = [{method, <<80,79,83,84>>}, {path, WfPath}, {query, Query}, {headers, []}, {body, <<>>}], case http_server:route(Req, []) of [{status, 404}, _, _] -> true; _ -> false end\") :name)")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 600 "$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 11 "http_server loaded" "http_server"
|
||||
check 20 "GET /webfinger known -> 200" "true"
|
||||
check 21 "body has subject prefix" "true"
|
||||
check 22 "body has href substring" "true"
|
||||
check 23 "missing ?resource= -> 404" "true"
|
||||
check 24 "garbage resource -> 404" "true"
|
||||
check 25 "kernel cfg: known 200, ghost 404" "true"
|
||||
check 26 "webfinger_host match -> 200" "true"
|
||||
check 27 "webfinger_host mismatch -> 404" "true"
|
||||
check 28 "POST /webfinger -> 404" "true"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/webfinger_route.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user