fed-sx-m2: Step 10c — peer-actor doc fetch + cache (+ 11 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
Closes Step 10 (10a discovery + 10b webfinger + 10c fetch). New
next/kernel/discovery_fetch.erl produces a 1-arity FetchFn closure
suitable for peer_actors:lookup_or_fetch_srv/2, completing the
discovery half that Step 5c's peer_actors cache stubbed out.
discovery_fetch API:
make_fetch_fn(Cfg) -> fun((PeerId) -> {ok, AS} | {error, _})
fetch(Url, Cfg) -> {ok, AS} | {error, _}
actor_doc_url(BaseUrl, PeerAtom) -> <Base>/actors/<peer>
accept_header/0 -> <<"application/vnd.fed-sx.actor-doc">>
decode_body(Body) -> {ok, AS} | {error, bad_actor_doc}
Closure GETs <base>/actors/<peer> via the Step 8e BIF with
Accept = application/vnd.fed-sx.actor-doc, decodes the response
body via term_codec:decode/1, returns the peer-actor-state
proplist (currently [{public_keys, [...]}]) in the shape
envelope:verify_signature consumes.
Cfg reuses dispatch_http's :peer_url / :peer_url_fn resolution so
a single Cfg threads through both delivery (8f) and discovery (10c).
Server side: http_server.erl extended to serve the same MIME.
- accept_format/1 matches application/vnd.fed-sx.actor-doc first
via the new actor_doc_prefix/0 — content negotiation atom is
`actor_doc`.
- content_type_for(actor_doc) emits the MIME on outbound.
- actor_doc_response_for/3 kernel-aware arm: with kernel + actor
-> 200 + term_codec:encode of nx_kernel:state_for/1 result.
Unknown actor -> not_found_response/0. Other formats fall
through to the existing /2 stub variants.
- actor_get/3 route dispatch threads Cfg to the /3 arm.
Port quirks documented:
* This Erlang doesn't support Mod:Fun(X) dispatch on a variable
module — kernel_actor_state/2 hardcodes nx_kernel; the Cfg
:kernel field is just a "no kernel wired" -> nil flag.
* nx_kernel:actor_state/1 is the LEGACY single-bucket accessor
that takes State (not ActorId); the server-side variant we
want is state_for/1 (gen_server:call wrapper). Easy mismatch,
documented in the comment.
Outcome mapping:
2xx + decodable body -> {ok, AS}
2xx + bad body -> {error, bad_actor_doc}
non-2xx -> {error, {status, N}}
resolver miss -> {error, no_peer_url}
transport -> {error, Reason} (BIF re-raises)
Test: next/tests/discovery_fetch.sh 11/11
Server side (in-process via http_server:actor_doc_response_for):
- Accept negotiation
- kernel + actor -> 200 + decodable body w/ :public_keys
- unknown actor -> 404
Closure side (live HTTP against background python stub returning
hand-crafted term_codec bytes):
- URL construction <base>/actors/X
- fetch live -> {ok, AS}
- make_fetch_fn closure -> {ok, AS} via static :peer_url map
- missing peer -> {error, no_peer_url}
- 404 path -> {error, {status, 404}}
- peer_actors:lookup_or_fetch/3 caches the result
Test setup note: Python term_codec encoder uses ELEMENT COUNT
(not byte length) for l/t headers — see encode/1 in term_codec.erl
which does integer_to_list(length(T)). Easy bug, documented in the
test's python source.
No-regression gates green: Erlang conformance 761/761,
httpc_request 10/10, dispatch_http 10/10, http_listen_bif 5/5,
peer_actors 19/19, discovery 12/12, http_accept 13/13,
http_actors 13/13.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
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.
|
||||
@@ -15,7 +15,8 @@
|
||||
capabilities_body_for/1,
|
||||
content_type_for/1, ok_response/2,
|
||||
cid_response_for/2, post_activity_response_for/1,
|
||||
actor_doc_response_for/2, artifact_response_for/2,
|
||||
actor_doc_response_for/2, actor_doc_response_for/3,
|
||||
artifact_response_for/2,
|
||||
projection_response_for/2, projections_list_response_for/1,
|
||||
actor_outbox_response_for/2, actor_outbox_response_for/3,
|
||||
actor_inbox_get_response_for/2,
|
||||
@@ -172,9 +173,9 @@ dispatch(_, _, _, _) ->
|
||||
|
||||
actor_get(Rest, F, Cfg) ->
|
||||
case split_first_slash(Rest) of
|
||||
{Id, <<>>} -> actor_doc_response_for(Id, F);
|
||||
{Id, <<>>} -> actor_doc_response_for(Id, F, Cfg);
|
||||
{Id, Sub} -> actor_subresource_get(Id, Sub, F, Cfg);
|
||||
Id -> actor_doc_response_for(Id, F)
|
||||
Id -> actor_doc_response_for(Id, F, Cfg)
|
||||
end.
|
||||
|
||||
%% 111 117 116 98 111 120 = "outbox"
|
||||
@@ -481,21 +482,31 @@ sx_prefix() ->
|
||||
cbor_prefix() ->
|
||||
<<97,112,112,108,105,99,97,116,105,111,110,47,99,98,111,114>>.
|
||||
|
||||
%% "application/vnd.fed-sx.actor-doc" — 32 bytes (Step 10c)
|
||||
actor_doc_prefix() ->
|
||||
<<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>>.
|
||||
|
||||
accept_format(nil) -> text;
|
||||
accept_format(<<>>) -> text;
|
||||
accept_format(V) when is_binary(V) ->
|
||||
case match_prefix(activity_json_prefix(), V) of
|
||||
{ok, _} -> activity_json;
|
||||
case match_prefix(actor_doc_prefix(), V) of
|
||||
{ok, _} -> actor_doc;
|
||||
_ ->
|
||||
case match_prefix(json_prefix(), V) of
|
||||
{ok, _} -> json;
|
||||
case match_prefix(activity_json_prefix(), V) of
|
||||
{ok, _} -> activity_json;
|
||||
_ ->
|
||||
case match_prefix(sx_prefix(), V) of
|
||||
{ok, _} -> sx;
|
||||
case match_prefix(json_prefix(), V) of
|
||||
{ok, _} -> json;
|
||||
_ ->
|
||||
case match_prefix(cbor_prefix(), V) of
|
||||
{ok, _} -> cbor;
|
||||
_ -> text
|
||||
case match_prefix(sx_prefix(), V) of
|
||||
{ok, _} -> sx;
|
||||
_ ->
|
||||
case match_prefix(cbor_prefix(), V) of
|
||||
{ok, _} -> cbor;
|
||||
_ -> text
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -564,6 +575,17 @@ content_type_for(sx) ->
|
||||
content_type_for(cbor) ->
|
||||
<<97,112,112,108,105,99,97,116,105,111,110,47,
|
||||
99,98,111,114>>;
|
||||
%% "application/vnd.fed-sx.actor-doc" — 32 bytes. Step 10c content
|
||||
%% type for term_codec-encoded peer-actor docs; the federation fetch
|
||||
%% layer (discovery_fetch.erl) uses this Accept header to ask for a
|
||||
%% peer's :public_keys (and v3+ profile fields) in a wire-decodable
|
||||
%% form. Distinct from application/vnd.fed-sx.activity (dispatch_http
|
||||
%% Step 8f) because the body is a peer-actor-state proplist, not a
|
||||
%% signed activity envelope.
|
||||
content_type_for(actor_doc) ->
|
||||
<<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>>;
|
||||
content_type_for(_) ->
|
||||
content_type_for(text).
|
||||
|
||||
@@ -660,6 +682,38 @@ actor_doc_response_for(Id, cbor) ->
|
||||
actor_doc_response_for(Id, _) ->
|
||||
actor_doc_response(Id).
|
||||
|
||||
%% Step 10c kernel-aware variant. The `actor_doc` format negotiates
|
||||
%% to a term_codec-encoded peer-actor-state proplist (currently just
|
||||
%% `[{public_keys, [...]}]`) so a federated peer running
|
||||
%% discovery_fetch.erl can decode it directly into the shape
|
||||
%% peer_actors and envelope:verify_signature consume. Other formats
|
||||
%% fall through to the /2 stub variants.
|
||||
|
||||
actor_doc_response_for(Id, actor_doc, Cfg) ->
|
||||
case kernel_actor_state(field(kernel, Cfg), Id) of
|
||||
nil -> not_found_response();
|
||||
AS -> ok_response(term_codec:encode(AS), actor_doc)
|
||||
end;
|
||||
actor_doc_response_for(Id, F, _Cfg) ->
|
||||
actor_doc_response_for(Id, F).
|
||||
|
||||
%% kernel_actor_state/2 — bridge to nx_kernel:state_for/1 (the
|
||||
%% server-side variant of actor_state/2). Cfg carries the kernel
|
||||
%% module atom (currently always `nx_kernel`); Id is a binary so
|
||||
%% we round-trip through list_to_atom. This port's Erlang doesn't
|
||||
%% support `Mod:Fun(X)` dispatch on a variable module, so we
|
||||
%% hardcode nx_kernel (the only kernel module in play); the Cfg
|
||||
%% field exists to flag "no kernel wired" -> nil short-circuit.
|
||||
%% nx_kernel:actor_state/1 is the legacy single-bucket accessor
|
||||
%% that takes State, not ActorId — wrong shape here.
|
||||
kernel_actor_state(nil, _Id) -> nil;
|
||||
kernel_actor_state(_Kernel, Id) ->
|
||||
Atom = list_to_atom(binary_to_list(Id)),
|
||||
case nx_kernel:state_for(Atom) of
|
||||
{ok, AS} -> AS;
|
||||
_ -> nil
|
||||
end.
|
||||
|
||||
%% ── Step 4a: per-actor sub-resource stubs ──────────────────────
|
||||
%% Per design §16.1 each actor has /outbox /inbox /followers
|
||||
%% /following routes. v1 returns text-stub bodies so route resolution
|
||||
|
||||
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 ]
|
||||
@@ -754,11 +754,25 @@ Per §13.7: webfinger plus actor doc fetch.
|
||||
(no-kernel, with-kernel, host-match), 404 paths
|
||||
(missing-resource, bad-acct, unknown-actor, host-mismatch,
|
||||
wrong-method).
|
||||
- [ ] **10c** — Peer-actor fetch + cache write. Gates on
|
||||
Blockers #2 (native `http-request` primitive missing).
|
||||
Step 5's peer_actors cache already exposes the
|
||||
`lookup_or_fetch` shape; this Step 10c plugs the discovery
|
||||
HTTP fetch into the FetchFn slot.
|
||||
- [x] **10c** — Peer-actor fetch + cache write. New
|
||||
`discovery_fetch.erl` produces a 1-arity FetchFn closure
|
||||
suitable for `peer_actors:lookup_or_fetch_srv/2`: GETs
|
||||
`<base>/actors/<peer>` with
|
||||
`Accept: application/vnd.fed-sx.actor-doc`, decodes the body
|
||||
via `term_codec:decode/1`, and returns `{ok, AS}` where AS is
|
||||
the peer's `[{public_keys, [...]}]` proplist
|
||||
(`envelope:verify_signature` shape). Cfg reuses the same
|
||||
`:peer_url` / `:peer_url_fn` resolution as `dispatch_http`.
|
||||
Server side: http_server now serves the same MIME — new
|
||||
`actor_doc` content-negotiation atom, `actor_doc_response_for/3`
|
||||
kernel-aware arm calls `nx_kernel:state_for/1` and emits the
|
||||
`term_codec:encode/1` of the AS. Test:
|
||||
`next/tests/discovery_fetch.sh` 11/11 — Accept negotiation,
|
||||
server-side encode (with kernel) → 200 + decodable body,
|
||||
unknown actor → 404, URL construction, live fetch +
|
||||
decode, closure resolution (static map + closure peer
|
||||
resolver), missing peer → `no_peer_url`, 404 → `{status, 404}`,
|
||||
end-to-end `peer_actors:lookup_or_fetch` cache write.
|
||||
|
||||
**Tests:**
|
||||
|
||||
@@ -1072,6 +1086,63 @@ proceed.
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-06-07** — Step 10c (closes Step 10): peer-actor doc
|
||||
fetch + cache write. New `next/kernel/discovery_fetch.erl`
|
||||
produces a 1-arity FetchFn closure for
|
||||
`peer_actors:lookup_or_fetch_srv/2`. Closure GETs
|
||||
`<base>/actors/<peer>` via Step 8e's `httpc:request/4` BIF
|
||||
with `Accept: application/vnd.fed-sx.actor-doc`, decodes
|
||||
the body via `term_codec:decode/1`, returns `{ok, AS}` where
|
||||
AS is the peer-actor-state proplist (`[{public_keys, [...]}]`,
|
||||
the shape `envelope:verify_signature` consumes). Cfg reuses
|
||||
the same `:peer_url` / `:peer_url_fn` resolution as
|
||||
`dispatch_http` (Step 8f) so a single Cfg can thread through
|
||||
both delivery and discovery.
|
||||
|
||||
Server side: `http_server.erl` now serves the same MIME.
|
||||
New `actor_doc` content-negotiation atom — `accept_format/1`
|
||||
matches `application/vnd.fed-sx.actor-doc` first
|
||||
(`actor_doc_prefix/0`); `content_type_for(actor_doc)`
|
||||
emits it on outbound. New `actor_doc_response_for/3`
|
||||
kernel-aware arm: when Cfg carries `{kernel, Kernel}` and
|
||||
the kernel has the actor, calls `nx_kernel:state_for/1`
|
||||
(NOT the legacy single-bucket `actor_state/1` accessor) and
|
||||
emits `term_codec:encode/1` of the AS. Other formats fall
|
||||
through to the existing /2 stub variants. Unknown actor →
|
||||
`not_found_response/0`. `actor_get/3` route dispatch now
|
||||
threads Cfg through to the /3 arm.
|
||||
|
||||
Subtle port note: this port's Erlang doesn't support
|
||||
`Mod:Fun(X)` dispatch on a variable module, so the
|
||||
Cfg `:kernel` field exists to flag "no kernel wired" →
|
||||
nil short-circuit; the actual call is hardcoded to
|
||||
`nx_kernel:state_for/1` (the only kernel module in play).
|
||||
Documented inline.
|
||||
|
||||
Outcome mapping (discovery_fetch):
|
||||
2xx + decodable → {ok, AS}
|
||||
2xx + bad body → {error, bad_actor_doc}
|
||||
non-2xx → {error, {status, N}}
|
||||
resolver miss → {error, no_peer_url}
|
||||
transport → {error, Reason} (BIF's network re-raise)
|
||||
|
||||
Test: `next/tests/discovery_fetch.sh` 11/11 — both halves.
|
||||
Server side: Accept negotiation, kernel + actor → 200 +
|
||||
decodable body, unknown actor → 404. Closure side: URL
|
||||
construction `<base>/actors/<peer>`, live GET against the
|
||||
background python stub returning hand-crafted term_codec
|
||||
bytes (Python encoding helper mirrors term_codec.erl's
|
||||
netstring format — count-based not byte-length headers for
|
||||
l/t), make_fetch_fn closure resolves through static map +
|
||||
closure peer_url_fn, missing peer → `no_peer_url`, 404 →
|
||||
`{status, 404}`, end-to-end `peer_actors:lookup_or_fetch/3`
|
||||
caches the result.
|
||||
|
||||
Adjacent gates: Erlang conformance 761/761, httpc_request
|
||||
10/10, dispatch_http 10/10, http_listen_bif 5/5,
|
||||
peer_actors 19/19, discovery 12/12, http_accept 13/13,
|
||||
http_actors 13/13 — all green.
|
||||
|
||||
- **2026-06-07** — Step 8f (closes Step 8 except 8b-timer which
|
||||
still gates on Blockers #3 send_after): live HTTP dispatch
|
||||
through `httpc:request/4`. New `next/kernel/dispatch_http.erl`
|
||||
|
||||
Reference in New Issue
Block a user