Merge branch 'flow/distribution/P4.1' into distribution-plan

This commit is contained in:
agra
2026-06-12 01:32:46 +03:00
4 changed files with 608 additions and 1 deletions

View File

@@ -7,6 +7,7 @@
// dist ci publish local publish pipeline (P3.4a/b) — see publish.sx
// dist release promote point a channel at a release (P3.5) — see release/ops.sx
// dist release rollback channel pointer to the previous release (P3.5)
// dist server run read-only HTTP API over the store (P4.1) — see server/distd.sx
//
// EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with
// `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing
@@ -29,6 +30,7 @@
jout :: #import "json_out.sx";
pl :: #import "publish/publish.sx";
ops :: #import "release/ops.sx";
srv :: #import "server/distd.sx";
// Direct stderr writer (fd 2), so human help/usage/progress never lands on
// stdout's data stream. `out` (std builtin) targets stdout (fd 1).
@@ -43,7 +45,7 @@ emit_human :: (s: string, json_mode: bool) {
if json_mode { eputs(s); } else { out(s); }
}
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted; JSON error under --json)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store read-only over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n routes: /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
// True if `name` appears as a token in `args`.
has_flag :: (args: []string, name: string) -> bool {
@@ -180,12 +182,55 @@ handle_release_rollback :: (p: *Parsed, json_mode: bool) {
}
}
// `dist server run` — bind 0.0.0.0:<port> and serve the store read-only
// (P4.1). Loops forever on success; a bind/listen failure reports through
// `report_failure` like every other command.
handle_server_run :: (p: *Parsed, json_mode: bool) {
store_dir := p.value_of("local-store");
port : s64 = 8787;
if p.is_set("port") {
pq := parse_port(p.value_of("port"));
if pq == null {
eputs(concat(concat("dist: --port must be 1..65535, got: ", p.value_of("port")), "\n"));
exit_usage();
}
port = pq!;
}
failed := false;
srv.run_server(store_dir, port) catch { failed = true; };
if failed {
fail : jout.CliFailure = .{
code = "server.bind",
message = concat("could not bind/listen on 0.0.0.0:", int_to_string(port)),
};
report_failure("server run", fail, json_mode);
}
}
// Decimal port in 1..65535; anything else (empty, non-digits, out of
// range) is null — a usage error at the call site.
parse_port :: (s: string) -> ?s64 {
if s.len == 0 or s.len > 5 { return null; }
v : s64 = 0;
i := 0;
while i < s.len {
c := s[i];
if c < 48 or c > 57 { return null; } // '0'..'9'
v = v * 10 + (c - 48);
i += 1;
}
if v < 1 or v > 65535 { return null; }
return v;
}
// Route a parsed (group, command) to its handler. `parse` only returns a
// (group, command) present in the table, so one arm always matches.
dispatch :: (p: *Parsed, json_mode: bool) {
if p.group == "ci" and p.command == "publish" { handle_ci_publish(p, json_mode); return; }
if p.group == "release" and p.command == "promote" { handle_release_promote(p, json_mode); return; }
if p.group == "release" and p.command == "rollback" { handle_release_rollback(p, json_mode); return; }
if p.group == "server" and p.command == "run" { handle_server_run(p, json_mode); return; }
eputs("dist: internal error: unrouted command\n");
exit_usage();
}
@@ -231,10 +276,15 @@ main :: () -> ! {
FlagSpec.{ name = "channel", takes_value = true, required = true },
FlagSpec.{ name = "local-store", takes_value = true, required = true },
];
server_flags : []FlagSpec = .[
FlagSpec.{ name = "local-store", takes_value = true, required = true },
FlagSpec.{ name = "port", takes_value = true, required = false },
];
cmds : []Command = .[
Command.{ group = "ci", command = "publish", flags = publish_flags },
Command.{ group = "release", command = "promote", flags = promote_flags },
Command.{ group = "release", command = "rollback", flags = rollback_flags },
Command.{ group = "server", command = "run", flags = server_flags },
];
diag : Diag = .{};

303
src/server/distd.sx Normal file
View File

@@ -0,0 +1,303 @@
// =====================================================================
// distd.sx — the read-only distribution server over the local store
// (subplan 04, Slices 1 + the read half of 3/4), run as `dist server run`.
//
// Serves the state the CLI publishes — db.json metadata and the
// content-addressed objects — over HTTP (src/server/http.sx):
//
// GET /healthz {"status":"ok"} — no store access
// GET /api/apps {"apps":[...]} every app in the store
// GET /api/apps/<slug> {"app":..,"releases":[..],"channels":[..]}
// GET /download/<sha256> the object's bytes (application/octet-stream,
// X-Checksum-SHA256 header)
//
// Anything else is a JSON error in the CLI's error shape
// (`{"status":"error","error":{code,message}}`) with the matching HTTP
// status — the API and the CLI report failures identically.
//
// FRESHNESS: db.json is RELOADED on every /api request, so a `dist ci
// publish` / `release promote` between requests is visible immediately —
// the store on disk stays the single source of truth (no cache to
// invalidate, LAN-scale traffic).
//
// MUTATION is the CLI's job for now: every route is GET; writes arrive
// with token auth (subplan 04 Slice 2, deferred with the rest of the
// upload/auth surface).
//
// RESPONSE BUFFERS are heap slices from the per-request arena, never big
// stack arrays: a stack array of 64K+ in one frame crashes the sx LLVM
// backend (DAGCombiner segfault). Small fixed buffers (4K) are fine.
// =====================================================================
#import "modules/std.sx";
#import "modules/std/json.sx";
#import "modules/std/fs.sx";
#import "../domain/platform.sx";
#import "../domain/app.sx";
#import "../domain/release.sx";
#import "../domain/artifact.sx";
#import "../domain/channel.sx";
#import "../domain/audit.sx";
#import "../repo/repo.sx";
sock :: #import "modules/std/socket.sx";
http :: #import "http.sx";
db :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";
// Response-body capacity for the /api JSON renders (heap, per-request).
RENDER_CAP :: 262144;
ServeErr :: error {
Bind,
}
// Server-side stderr log line (fd 2 via the socket module's write; stdout
// stays clean for whoever launched the process).
slog :: (s: string) {
if s.len > 0 { sock.write(2, s.ptr, xx s.len); }
}
// True iff `s` is exactly 64 lowercase-hex chars (a storage key).
is_hex64 :: (s: string) -> bool {
if s.len != 64 { return false; }
i := 0;
while i < s.len {
c := s[i];
digit := c >= 48 and c <= 57; // '0'..'9'
lower := c >= 97 and c <= 102; // 'a'..'f'
if !digit and !lower { return false; }
i += 1;
}
return true;
}
starts_with :: (s: string, prefix: string) -> bool {
if prefix.len > s.len { return false; }
i := 0;
while i < prefix.len {
if s[i] != prefix[i] { return false; }
i += 1;
}
return true;
}
// The path remainder after `prefix` (caller has checked starts_with).
tail_after :: (s: string, prefix: string) -> string {
return string.{ ptr = @s[prefix.len], len = s.len - prefix.len };
}
// A `RENDER_CAP` heap slice from the per-request context allocator.
render_buf :: () -> string {
raw : [*]u8 = xx context.allocator.alloc_bytes(RENDER_CAP);
return string.{ ptr = raw, len = RENDER_CAP };
}
// ── responses ─────────────────────────────────────────────────────────
// JSON error body in the CLI's error shape, sent with the HTTP status.
respond_error :: (client: s32, code: s64, fail_code: string, fail_message: string) {
f : jout.CliFailure = .{ code = fail_code, message = fail_message };
raw : [4096]u8 = ---;
werr := false;
n := jout.write_error(f, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
body := "{\"status\":\"error\"}";
if !werr { body = string.{ ptr = @raw[0], len = n }; }
http.respond(client, code, "application/json", "", body);
}
// ── /api renders (builders own the `try`, callers catch) ─────────────
// Reload the persisted model. Null means the store has no readable
// db.json — the 503 error response has already been sent.
load_or_503 :: (client: s32, store_dir: string) -> ?Repo {
if !exists(path_join(store_dir, "db.json")) {
respond_error(client, 503, "store.load",
concat("no db.json under the store (nothing published yet): ", store_dir));
return null;
}
loaded, le := db.load(store_dir);
if le {
respond_error(client, 503, "store.load",
concat("db.json under the store could not be loaded: ", store_dir));
return null;
}
return loaded;
}
// `{"apps":[...]}` into `dst`, returning the bytes written.
render_apps_json :: (repo: *Repo, dst: []u8) -> (s64, !JsonError) {
alloc := context.allocator;
root : Object = .{};
arr : Array = .{};
i := 0;
while i < repo.apps.len {
arr.add(db.app_to_json(repo.apps.items[i], alloc), alloc);
i += 1;
}
root.put("apps", .array(arr), alloc);
rootv : Value = .object(root);
n := try write_to_buffer(rootv, dst);
return n;
}
// `{"app":..,"releases":[..],"channels":[..]}` for `app` into `dst`.
render_app_detail_json :: (repo: *Repo, app: App, dst: []u8) -> (s64, !JsonError) {
alloc := context.allocator;
root : Object = .{};
root.put("app", db.app_to_json(app, alloc), alloc);
rels : Array = .{};
i := 0;
while i < repo.releases.len {
r := repo.releases.items[i];
if r.app_id == app.id { rels.add(db.release_to_json(r, alloc), alloc); }
i += 1;
}
root.put("releases", .array(rels), alloc);
chans : Array = .{};
i = 0;
while i < repo.channels.len {
c := repo.channels.items[i];
if c.app_id == app.id { chans.add(db.channel_to_json(c, alloc), alloc); }
i += 1;
}
root.put("channels", .array(chans), alloc);
rootv : Value = .object(root);
n := try write_to_buffer(rootv, dst);
return n;
}
// Send `n` rendered bytes of `buf` as 200 JSON — or the overflow error
// when the render didn't fit RENDER_CAP.
respond_render :: (client: s32, buf: string, n: s64, overflowed: bool) {
if overflowed {
respond_error(client, 500, "http.response_overflow",
"response exceeded the server's render buffer");
return;
}
http.respond(client, 200, "application/json", "", string.{ ptr = buf.ptr, len = n });
}
// ── routes ────────────────────────────────────────────────────────────
handle_apps_index :: (client: s32, store_dir: string) {
rq := load_or_503(client, store_dir);
if rq == null { return; }
repo := rq!;
buf := render_buf();
werr := false;
n := render_apps_json(@repo, buf) catch { werr = true; 0 };
respond_render(client, buf, n, werr);
}
handle_app_detail :: (client: s32, store_dir: string, slug: string) {
rq := load_or_503(client, store_dir);
if rq == null { return; }
repo := rq!;
aq := repo.find_app_by_slug(slug);
if aq == null {
respond_error(client, 404, "api.unknown_app",
concat("no app with that slug in the store: ", slug));
return;
}
buf := render_buf();
werr := false;
n := render_app_detail_json(@repo, aq!, buf) catch { werr = true; 0 };
respond_render(client, buf, n, werr);
}
handle_download :: (client: s32, store_dir: string, sha: string) {
if !is_hex64(sha) {
respond_error(client, 404, "download.bad_key",
"download key must be a 64-char lowercase-hex sha256");
return;
}
opath := path_join(store_dir, concat("objects/", sha));
bq := read_file(opath);
if bq == null {
respond_error(client, 404, "download.unknown_object",
concat("no object with that digest in the store: ", sha));
return;
}
extra := concat("X-Checksum-SHA256: ", concat(sha, "\r\n"));
http.respond(client, 200, "application/octet-stream", extra, bq!);
}
// Route one parsed request. GET only; the path decides the handler.
route :: (client: s32, store_dir: string, method: string, path: string) -> s64 {
if method != "GET" {
respond_error(client, 405, "http.method_not_allowed",
"every distd route is GET for now (writes go through the dist CLI)");
return 405;
}
if path == "/healthz" {
http.respond(client, 200, "application/json", "", "{\"status\":\"ok\"}");
return 200;
}
if path == "/api/apps" {
handle_apps_index(client, store_dir);
return 200;
}
if starts_with(path, "/api/apps/") {
handle_app_detail(client, store_dir, tail_after(path, "/api/apps/"));
return 200;
}
if starts_with(path, "/download/") {
handle_download(client, store_dir, tail_after(path, "/download/"));
return 200;
}
respond_error(client, 404, "http.not_found",
concat("no route for ", path));
return 404;
}
// ── server loop ───────────────────────────────────────────────────────
// Read one request off `client`, route it, log the result line. All
// allocations land in the pushed per-request context allocator.
serve_one :: (client: s32, store_dir: string) {
buf : [8192]u8 = ---;
n := sock.read(client, @buf[0], 8192);
if n <= 0 { return; }
raw := string.{ ptr = @buf[0], len = xx n };
req : http.Request = .{};
if !http.parse_request(raw, @req) {
respond_error(client, 400, "http.bad_request",
"request line did not parse as HTTP");
return;
}
code := route(client, store_dir, req.method, req.path);
line := concat("distd: ", concat(req.method, concat(" ", concat(req.path, concat(" -> ", concat(int_to_string(code), "\n"))))));
slog(line);
}
// Bind 0.0.0.0:<port> and serve forever (sequential, one connection at a
// time — the deployment story is LAN/NAS-scale). Returns only when the
// socket can't be opened. Per-request allocations live in an arena that
// dies with the request.
run_server :: (store_dir: string, port: s64) -> !ServeErr {
fd, fe := http.listen_on(port);
if fe { raise error.Bind; }
slog(concat("distd: serving store ", concat(store_dir, concat(" on http://0.0.0.0:", concat(int_to_string(port), "\n")))));
while true {
client := sock.accept(fd, null, null);
if client < 0 { continue; }
gpa := GPA.init();
arena := Arena.init(xx gpa, 65536);
push Context.{ allocator = xx arena } {
serve_one(client, store_dir);
}
arena.deinit();
sock.close(client);
}
return;
}

88
src/server/http.sx Normal file
View File

@@ -0,0 +1,88 @@
// =====================================================================
// http.sx — minimal HTTP/1.1 over `std.socket` (subplan 04, Slice 1).
//
// The temporary in-repo boundary for the missing `std.http`, written so it
// can be lifted into the sx stdlib later. Deliberately minimal — exactly
// what a read-only JSON API + artifact download server needs:
//
// * `listen_on(port)` — a listening TCP socket on 0.0.0.0:<port>
// (INADDR_ANY, so the server is reachable from the LAN);
// * `parse_request` — method + path views off the request line;
// * `respond` — one full response: status line, Content-Type/-Length,
// `Connection: close`, optional extra header lines, body.
//
// NOT handled (v0, documented): request headers and bodies (the routes are
// GET-only and need neither), keep-alive (every response closes), chunked
// transfer, TLS (the deployment plan terminates TLS at a reverse proxy).
// A request is read in ONE `read` — request lines of interest fit the
// first segment; anything that doesn't parse is a 400.
// =====================================================================
#import "modules/std.sx";
sock :: #import "modules/std/socket.sx";
HttpError :: error {
Socket,
Bind,
Listen,
}
// Method + path of one request, as VIEWS into the caller's read buffer.
Request :: struct {
method: string = "";
path: string = "";
}
// Open a listening socket on 0.0.0.0:<port>. SO_REUSEADDR so a restarted
// server can re-bind without waiting out TIME_WAIT.
listen_on :: (port: s64) -> (s32, !HttpError) {
fd := sock.socket(sock.AF_INET, sock.SOCK_STREAM, 0);
if fd < 0 { raise error.Socket; }
opt : s32 = 1;
sock.setsockopt(fd, sock.SOL_SOCKET, sock.SO_REUSEADDR, @opt, 4);
addr : sock.SockAddr = .{ sin_len = 16, sin_family = 2, sin_port = sock.htons(port) };
if sock.bind(fd, @addr, 16) < 0 { sock.close(fd); raise error.Bind; }
if sock.listen(fd, 16) < 0 { sock.close(fd); raise error.Listen; }
return fd;
}
// Parse the request line `METHOD SP PATH SP HTTP/x.y` off the raw bytes.
// False when the bytes don't look like an HTTP request line.
parse_request :: (raw: string, req: *Request) -> bool {
i := 0;
while i < raw.len and raw[i] != 32 { i += 1; } // 32 = ' '
if i == 0 or i >= raw.len { return false; }
req.method = string.{ ptr = raw.ptr, len = i };
j := i + 1;
k := j;
while k < raw.len and raw[k] != 32 and raw[k] != 13 { k += 1; } // 13 = '\r'
if k == j or k >= raw.len { return false; }
if raw[k] != 32 { return false; } // no HTTP version after path
req.path = string.{ ptr = @raw[j], len = k - j };
return true;
}
status_text :: (code: s64) -> string {
if code == 200 { return "OK"; }
if code == 400 { return "Bad Request"; }
if code == 404 { return "Not Found"; }
if code == 405 { return "Method Not Allowed"; }
if code == 503 { return "Service Unavailable"; }
return "Internal Server Error";
}
// Write one complete response and leave the connection for the caller to
// close. `extra` is zero or more pre-formatted header lines, each ending
// in `\r\n` ("" for none).
respond :: (client: s32, code: s64, content_type: string, extra: string, body: string) {
h := concat("HTTP/1.1 ", concat(int_to_string(code), concat(" ", status_text(code))));
h = concat(h, "\r\n");
h = concat(h, concat("Content-Type: ", concat(content_type, "\r\n")));
h = concat(h, concat("Content-Length: ", concat(int_to_string(body.len), "\r\n")));
h = concat(h, "Connection: close\r\n");
h = concat(h, extra);
h = concat(h, "\r\n");
sock.write(client, h.ptr, xx h.len);
if body.len > 0 { sock.write(client, body.ptr, xx body.len); }
}

166
tests/server_http.sx Normal file
View File

@@ -0,0 +1,166 @@
// Pinned acceptance for P4.1 — `dist server run` serves the local store
// read-only over HTTP.
//
// Publishes the fixture manifest into a fresh store, starts the BUILT
// `build/dist server run` on a test port in the background, waits for
// /healthz, and asserts every route against curl:
//
// * /healthz → {"status":"ok"}
// * /api/apps → the published app is listed
// * /api/apps/<slug> → app + its releases + channels
// * /api/apps/<unknown> → JSON error api.unknown_app
// * /download/<sha256> → bytes identical to the source fixture
// * /download/<unknown sha> → JSON error download.unknown_object
// * any other path → JSON error http.not_found
//
// FRESHNESS is asserted by publishing a SECOND version while the server
// is running: the next /api/apps/<slug> must list both releases (db.json
// is reloaded per request — no stale cache).
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
STORE :: ".sx-tmp/server_http";
MDIR :: ".sx-tmp/server_http_m";
PORT :: "18792";
BASE :: "http://127.0.0.1:18792";
MANIFEST_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
MANIFEST_B :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
get :: (o: Object, key: string) -> Value {
i := 0;
while i < o.len {
if o.items[i].key == key { return o.items[i].val; }
i += 1;
}
process.assert(false, concat("missing json key: ", key));
dummy : Value = .null_;
return dummy;
}
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
write_file :: (path: string, body: string) {
cmd := concat(concat(concat("printf '%s' '", body), "' > "), path);
process.run(cmd);
}
// GET `path` and return the body (curl; 2s timeout so a dead server fails
// the test instead of hanging it).
fetch :: (path: string) -> string {
r := process.run(concat(concat("curl -s -m 2 ", BASE), path));
process.assert(r != null, concat("curl spawn failed: ", path));
return r!.stdout;
}
// GET `path` and return the HTTP status code as text.
fetch_code :: (path: string) -> string {
r := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", BASE), path));
process.assert(r != null, concat("curl spawn failed: ", path));
return r!.stdout;
}
// Parse a fetched JSON body (hard assert on parse failure).
parse_body :: (body: string, what: string, scratch: Allocator) -> Object {
v, e := parse(body, scratch);
if e {
process.assert(false, concat("response must be valid JSON: ", what));
dummy : Object = .{};
return dummy;
}
return v.object;
}
publish_cmd :: (mpath: string) -> string {
c := concat("build/dist ci publish --manifest ", mpath);
c = concat(c, concat(" --local-store ", STORE));
return concat(c, " --json 2>/dev/null >/dev/null");
}
main :: () -> s32 {
gpa := GPA.init();
arena := Arena.init(xx gpa, 1 << 20);
defer arena.deinit();
// Fresh state; clear any stale server from a crashed prior run.
process.run("pkill -f 'dist server run --local-store .sx-tmp/server_http' 2>/dev/null");
process.run(concat("rm -rf ", STORE));
process.run(concat("rm -rf ", MDIR));
process.run(concat("mkdir -p ", MDIR));
write_file(path_join(MDIR, "a.json"), MANIFEST_A);
write_file(path_join(MDIR, "b.json"), MANIFEST_B);
ra := process.run(publish_cmd(path_join(MDIR, "a.json")));
process.assert(ra != null and ra!.exit_code == 0, "publish A must exit 0");
// ── start the server, hold its pid, poll /healthz until ready ─────
sp := process.run(concat(concat(concat("sh -c 'build/dist server run --local-store ", STORE), concat(concat(" --port ", PORT), " >/dev/null 2>&1 & echo $!'")), ""));
process.assert(sp != null, "server spawn failed");
pid := sp!.stdout;
ready := false;
tries := 0;
while tries < 50 {
c := fetch_code("/healthz");
if c == "200" { ready = true; break; }
process.run("sleep 0.2");
tries += 1;
}
process.assert(ready, "server must answer /healthz within 10s");
print(" server up\n");
// ── routes ────────────────────────────────────────────────────────
hz := parse_body(fetch("/healthz"), "/healthz", xx arena);
process.assert(get_str(hz, "status") == "ok", "/healthz status ok");
apps := parse_body(fetch("/api/apps"), "/api/apps", xx arena);
apps_arr := get_arr(apps, "apps");
process.assert(apps_arr.len == 1, "/api/apps lists one app");
process.assert(get_str(apps_arr.items[0].object, "slug") == "acme-app", "/api/apps lists acme-app");
det := parse_body(fetch("/api/apps/acme-app"), "/api/apps/acme-app", xx arena);
process.assert(get_str(get_obj(det, "app"), "slug") == "acme-app", "detail names the app");
process.assert(get_arr(det, "releases").len == 1, "detail lists one release");
process.assert(get_arr(det, "channels").len == 1, "detail lists one channel");
process.assert(fetch_code("/api/apps/nope") == "404", "unknown slug is 404");
nf := parse_body(fetch("/api/apps/nope"), "unknown slug body", xx arena);
process.assert(get_str(get_obj(nf, "error"), "code") == "api.unknown_app", "unknown slug names api.unknown_app");
process.assert(fetch_code("/bogus") == "404", "unknown route is 404");
nr := parse_body(fetch("/bogus"), "unknown route body", xx arena);
process.assert(get_str(get_obj(nr, "error"), "code") == "http.not_found", "unknown route names http.not_found");
print(" api routes ok\n");
// ── download: bytes identical to the source fixture ───────────────
db_bytes := fs.read_file(path_join(STORE, "db.json"));
process.assert(db_bytes != null, "db.json must exist");
dbo := parse_body(db_bytes!, "db.json", xx arena);
sha := get_str(get_arr(dbo, "artifacts").items[0].object, "sha256");
dl := process.run(concat(concat(concat(concat("curl -s -m 2 -o .sx-tmp/server_http_dl.bin ", BASE), "/download/"), sha), " && cmp -s .sx-tmp/server_http_dl.bin examples/fixtures/acme-1.2.3-android.apk && echo SAME"));
process.assert(dl != null, "download curl spawn failed");
process.assert(dl!.stdout == "SAME\n", "downloaded bytes must equal the source fixture");
bad := parse_body(fetch("/download/0000000000000000000000000000000000000000000000000000000000000000"), "unknown object body", xx arena);
process.assert(get_str(get_obj(bad, "error"), "code") == "download.unknown_object", "unknown digest names download.unknown_object");
print(" download ok\n");
// ── freshness: publish B while the server runs ────────────────────
rb := process.run(publish_cmd(path_join(MDIR, "b.json")));
process.assert(rb != null and rb!.exit_code == 0, "publish B must exit 0");
det2 := parse_body(fetch("/api/apps/acme-app"), "detail after B", xx arena);
process.assert(get_arr(det2, "releases").len == 2, "server reflects the new release without restart");
print(" per-request reload ok\n");
// ── teardown ──────────────────────────────────────────────────────
process.run(concat("kill ", pid));
process.run("rm -f .sx-tmp/server_http_dl.bin");
process.run(concat("rm -rf ", STORE));
process.run(concat("rm -rf ", MDIR));
print("server_http: ALL CASES PASS\n");
return 0;
}