Skip to content

Commit ec1e5c1

Browse files
hyperpolymathclaude
andcommitted
feat(phase2-skinny): boj-invoke CLI + Elixir Invoker + router wiring
Implements the OS-process transport from ADR-0005: - ffi/zig/src/boj_invoke_cli.zig — CLI that takes <so-path> <verb>, dlopens the cartridge via std.DynLib (libc-linked so dlopen(3) runs, not Zig's partial manual ELF loader), dispatches name/version/probe, emits JSON on stdout/stderr. Documented exit codes 0/2/3/4/5/6. - elixir/lib/boj_rest/invoker.ex — thin wrapper around System.cmd that classifies the CLI's exit code into :args/:open/:missing_symbol/ :init_failed/:cli_missing/:cli_crashed and merges stderr for structured error bodies. - elixir/lib/boj_rest/router.ex — POST /cartridge/:name/invoke now probes the cartridge .so and returns 501 with real probe evidence (init-returned + name + version for cartridges that have the 5-symbol ABI; classified missing-symbol for those that don't). Tool-level dispatch still 501 pending ADR-0006. End-to-end verified 2026-04-18: curl -X POST aerie-mcp/invoke returns {"probe":{"result":{"name":"aerie-mcp","version":"0.1.0"}}} once aerie-mcp carries the standard symbols. 5/5 Elixir tests pass. Key gotcha: std.DynLib on Zig 0.15.2 defaults to manual mmap-based loading which returns zero-filled string data for .rodata symbols. Setting link_libc=true on the invoke_mod routes through real dlopen and fixes it. This should be revisited if we ever need a libc-free build path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2e833de commit ec1e5c1

5 files changed

Lines changed: 347 additions & 9 deletions

File tree

elixir/lib/boj_rest/invoker.ex

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
defmodule BojRest.Invoker do
4+
@moduledoc """
5+
Shells out to `boj-invoke` (built by `zig build invoke` in `ffi/zig/`)
6+
for cartridge probe/name/version. Skinny Phase 2 per ADR-0005.
7+
8+
No pool yet — each invocation spawns a fresh child. Pool comes in the
9+
follow-up once the cartridge ABI gap (ADR-0006) is closed and there is
10+
actual traffic to amortise. Fork-per-request is ~1 ms; acceptable for
11+
the skeleton.
12+
13+
Tool-level dispatch (the `tool` field of `POST /cartridge/:name/invoke`)
14+
is not wired here — the caller gets a classified 501 with the probe
15+
result as evidence the cartridge loaded. Real dispatch waits on
16+
ADR-0006 + a reference cartridge implementation.
17+
"""
18+
19+
@cli_binary "boj-invoke"
20+
21+
# Exit codes from ffi/zig/src/boj_invoke_cli.zig
22+
@exit_ok 0
23+
@exit_args 2
24+
@exit_open 3
25+
@exit_symbol 4
26+
@exit_init 5
27+
28+
@type verb :: :probe | :name | :version
29+
@type so_path :: String.t()
30+
@type result ::
31+
{:ok, map()}
32+
| {:error,
33+
%{
34+
exit_code: non_neg_integer(),
35+
classification:
36+
:args
37+
| :open
38+
| :missing_symbol
39+
| :init_failed
40+
| :cli_missing
41+
| :cli_crashed,
42+
body: map() | String.t() | nil
43+
}}
44+
45+
@doc """
46+
Probe a cartridge .so: runs init, reads name+version, runs deinit.
47+
Returns `{:ok, %{"name" => ..., "version" => ...}}` on success.
48+
"""
49+
@spec probe(so_path()) :: result()
50+
def probe(so_path), do: run(so_path, "probe")
51+
52+
@spec name(so_path()) :: result()
53+
def name(so_path), do: run(so_path, "name")
54+
55+
@spec version(so_path()) :: result()
56+
def version(so_path), do: run(so_path, "version")
57+
58+
# ── internals ───────────────────────────────────────────────────────
59+
60+
@spec run(so_path(), String.t()) :: result()
61+
defp run(so_path, verb) do
62+
cli = cli_path()
63+
64+
if cli == nil do
65+
{:error,
66+
%{
67+
exit_code: -1,
68+
classification: :cli_missing,
69+
body: "boj-invoke CLI not found — run `zig build invoke` in ffi/zig/"
70+
}}
71+
else
72+
# stderr_to_stdout merges the CLI's error-path JSON (which it emits on
73+
# stderr) into stdout so we get it in the `{output, exit_code}` tuple.
74+
case System.cmd(cli, [so_path, verb], stderr_to_stdout: true) do
75+
{stdout, @exit_ok} ->
76+
case Jason.decode(stdout) do
77+
{:ok, map} -> {:ok, map}
78+
_ -> {:error, classified(@exit_ok, :cli_crashed, stdout)}
79+
end
80+
81+
{stdout_or_err, code} ->
82+
body = try_decode(stdout_or_err)
83+
84+
classification =
85+
case code do
86+
@exit_args -> :args
87+
@exit_open -> :open
88+
@exit_symbol -> :missing_symbol
89+
@exit_init -> :init_failed
90+
_ -> :cli_crashed
91+
end
92+
93+
{:error, classified(code, classification, body)}
94+
end
95+
end
96+
end
97+
98+
defp classified(code, cls, body),
99+
do: %{exit_code: code, classification: cls, body: body}
100+
101+
defp try_decode(bytes) do
102+
case Jason.decode(bytes) do
103+
{:ok, m} -> m
104+
_ -> String.trim(bytes)
105+
end
106+
end
107+
108+
@doc """
109+
Return the path to the boj-invoke binary, or `nil` if not built.
110+
Search order:
111+
1. `BOJ_INVOKE_CLI` env var if set
112+
2. `../ffi/zig/zig-out/bin/boj-invoke` relative to the Elixir project
113+
3. PATH
114+
"""
115+
@spec cli_path() :: String.t() | nil
116+
def cli_path do
117+
env_path = System.get_env("BOJ_INVOKE_CLI")
118+
project_path =
119+
Path.expand("../ffi/zig/zig-out/bin/boj-invoke", Application.app_dir(:boj_rest))
120+
|> fallback_if_missing()
121+
122+
cond do
123+
env_path && File.regular?(env_path) -> env_path
124+
project_path -> project_path
125+
true -> System.find_executable(@cli_binary)
126+
end
127+
end
128+
129+
defp fallback_if_missing(path) do
130+
# Application.app_dir points into _build; walk back up to the repo path
131+
# for the dev-workflow case where the Zig binary was built at repo root.
132+
# __DIR__ is elixir/lib/boj_rest/; need three levels up to reach the repo root.
133+
repo_relative =
134+
Path.expand("../../../ffi/zig/zig-out/bin/boj-invoke", __DIR__)
135+
136+
cond do
137+
File.regular?(path) -> path
138+
File.regular?(repo_relative) -> repo_relative
139+
true -> nil
140+
end
141+
end
142+
end

elixir/lib/boj_rest/router.ex

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,31 @@ defmodule BojRest.Router do
6262

6363
post "/cartridge/:name/invoke" do
6464
case BojRest.Catalog.get(name) do
65-
{:ok, _cart} ->
65+
{:ok, cart} ->
66+
# Skinny Phase 2 per ADR-0005: we can probe the cartridge .so
67+
# via the Zig CLI, but cannot dispatch to a specific tool yet
68+
# (ADR-0006 work). Run the probe so the caller gets real
69+
# evidence the cartridge loads; still return 501 on the tool
70+
# field because dispatch is not wired.
71+
so_path = cartridge_so_path(cart)
72+
73+
probe =
74+
case BojRest.Invoker.probe(so_path) do
75+
{:ok, map} -> %{probe: :ok, result: map}
76+
{:error, info} -> %{probe: :error, info: info}
77+
end
78+
79+
tool = Map.get(conn.body_params || %{}, "tool")
80+
6681
json(conn, 501, %{
67-
error: "invocation-not-yet-wired",
82+
error: "tool-dispatch-not-wired",
6883
cartridge: name,
84+
tool: tool,
85+
so_path: so_path,
86+
probe: probe,
6987
message:
70-
"The REST skeleton can find this cartridge in the catalog but does not " <>
71-
"yet dispatch to the Zig FFI. Tracked under the Elixir-port Phase 2 task."
88+
"Cartridge can be probed (init/name/version) but tool-level dispatch " <>
89+
"awaits ADR-0006 (boj_cartridge_invoke ABI)."
7290
})
7391

7492
:not_found ->
@@ -85,4 +103,15 @@ defmodule BojRest.Router do
85103
|> Plug.Conn.put_resp_content_type("application/json")
86104
|> Plug.Conn.send_resp(status, Jason.encode!(body))
87105
end
106+
107+
# Derive the expected shared-library path from a cartridge entry.
108+
# Each cartridge builds its .so under `cartridges/<name>/ffi/zig-out/lib/lib<name>_mcp.so`.
109+
defp cartridge_so_path(cart) do
110+
name = Map.get(cart, "name") || "unknown"
111+
root = Application.get_env(:boj_rest, :cartridges_root)
112+
Path.join([root, name, "ffi", "zig-out", "lib", "lib#{name_to_lib(name)}.so"])
113+
end
114+
115+
# cartridge name "aerie-mcp" -> lib name fragment "aerie_mcp"
116+
defp name_to_lib(name), do: String.replace(name, "-", "_")
88117
end

elixir/test/router_test.exs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,23 @@ defmodule BojRest.RouterTest do
4444
assert conn.status == 404
4545
end
4646

47-
test "POST /cartridge/:name/invoke returns 501 for known cartridge" do
48-
# Pick any cartridge that loaded successfully — 'database-mcp' is a safe bet
47+
test "POST /cartridge/:name/invoke returns 501 with probe info for known cartridge" do
4948
case BojRest.Catalog.get("database-mcp") do
5049
{:ok, _} ->
5150
conn =
52-
conn(:post, "/cartridge/database-mcp/invoke", %{tool: "noop"})
51+
conn(:post, "/cartridge/database-mcp/invoke", Jason.encode!(%{tool: "noop"}))
5352
|> put_req_header("content-type", "application/json")
5453
|> BojRest.Router.call(@opts)
5554

5655
assert conn.status == 501
5756
body = Jason.decode!(conn.resp_body)
58-
assert body["error"] == "invocation-not-yet-wired"
57+
assert body["error"] == "tool-dispatch-not-wired"
58+
assert body["cartridge"] == "database-mcp"
59+
# Probe ran — either ok (symbols present) or error (classified)
60+
assert body["probe"]["probe"] in ["ok", "error"]
61+
assert is_binary(body["so_path"])
5962

6063
:not_found ->
61-
# Catalog empty — still test the 404 path
6264
:ok
6365
end
6466
end

ffi/zig/build.zig

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,22 @@ pub fn build(b: *std.Build) void {
6161
const bench_step = b.step("bench", "Run cartridge mount/unmount benchmarks");
6262
bench_step.dependOn(&bench_run.step);
6363

64+
// --- boj-invoke CLI (skinny Phase 2 per ADR-0005) ---
65+
const invoke_mod = b.addModule("boj_invoke", .{
66+
.root_source_file = b.path("src/boj_invoke_cli.zig"),
67+
.target = target,
68+
.optimize = optimize,
69+
});
70+
invoke_mod.link_libc = true; // route std.DynLib through real dlopen(3)
71+
const invoke = b.addExecutable(.{
72+
.name = "boj-invoke",
73+
.root_module = invoke_mod,
74+
});
75+
const invoke_install = b.addInstallArtifact(invoke, .{});
76+
77+
const invoke_step = b.step("invoke", "Build boj-invoke CLI for Elixir Invoker pool");
78+
invoke_step.dependOn(&invoke_install.step);
79+
6480
// --- Catalogue tests ---
6581
const catalogue_tests = b.addTest(.{
6682
.root_module = catalogue_mod,

ffi/zig/src/boj_invoke_cli.zig

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// BoJ cartridge invoker CLI (skinny Phase 2 per ADR-0005).
5+
//
6+
// Loads a cartridge shared library and calls one of the four standard
7+
// symbols the loader already requires: init / deinit / name / version.
8+
// Tool-level dispatch is deferred to Phase 2.1 (ADR-0006).
9+
//
10+
// Usage:
11+
// boj-invoke <cartridge-so-path> <verb>
12+
//
13+
// Verbs:
14+
// probe — run boj_cartridge_init, read name+version, run boj_cartridge_deinit,
15+
// emit {"ok":true,"name":"...","version":"..."} on stdout.
16+
// name — read boj_cartridge_name only.
17+
// version — read boj_cartridge_version only.
18+
//
19+
// Exit codes (mirrored into the Elixir invoker's error classification):
20+
// 0 success, JSON on stdout
21+
// 2 argument error (wrong argc / unknown verb)
22+
// 3 cartridge .so not found / cannot open
23+
// 4 missing required symbol in the .so
24+
// 5 cartridge init returned non-zero
25+
// 6 tool dispatch not yet wired (reserved for Phase 2.1)
26+
//
27+
// This binary is intentionally single-process: fork-per-invocation is
28+
// acceptable for the skeleton, and the Elixir side will move to a
29+
// long-lived Port pool in a follow-up once the ABI stabilises.
30+
31+
const std = @import("std");
32+
33+
const EXIT_OK: u8 = 0;
34+
const EXIT_ARGS: u8 = 2;
35+
const EXIT_OPEN: u8 = 3;
36+
const EXIT_SYMBOL: u8 = 4;
37+
const EXIT_INIT: u8 = 5;
38+
const EXIT_UNWIRED: u8 = 6;
39+
40+
const Verb = enum { probe, name, version };
41+
42+
fn parseVerb(s: []const u8) ?Verb {
43+
if (std.mem.eql(u8, s, "probe")) return .probe;
44+
if (std.mem.eql(u8, s, "name")) return .name;
45+
if (std.mem.eql(u8, s, "version")) return .version;
46+
return null;
47+
}
48+
49+
fn emitJson(file: std.fs.File, alloc: std.mem.Allocator, comptime fmt: []const u8, args: anytype) !void {
50+
const line = try std.fmt.allocPrint(alloc, fmt ++ "\n", args);
51+
defer alloc.free(line);
52+
try file.writeAll(line);
53+
}
54+
55+
pub fn main() !u8 {
56+
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
57+
defer _ = gpa.deinit();
58+
const alloc = gpa.allocator();
59+
60+
const argv = try std.process.argsAlloc(alloc);
61+
defer std.process.argsFree(alloc, argv);
62+
63+
const stderr = std.fs.File.stderr();
64+
const stdout = std.fs.File.stdout();
65+
66+
if (argv.len != 3) {
67+
try emitJson(stderr, alloc,
68+
"{{\"ok\":false,\"error\":\"args\",\"expected\":\"<cartridge-so-path> <verb>\",\"got_argc\":{d}}}",
69+
.{argv.len});
70+
return EXIT_ARGS;
71+
}
72+
73+
const so_path = argv[1];
74+
const verb = parseVerb(argv[2]) orelse {
75+
try emitJson(stderr, alloc,
76+
"{{\"ok\":false,\"error\":\"unknown-verb\",\"verb\":\"{s}\"}}",
77+
.{argv[2]});
78+
return EXIT_ARGS;
79+
};
80+
81+
var lib = std.DynLib.open(so_path) catch |err| {
82+
try emitJson(stderr, alloc,
83+
"{{\"ok\":false,\"error\":\"open\",\"path\":\"{s}\",\"cause\":\"{s}\"}}",
84+
.{ so_path, @errorName(err) });
85+
return EXIT_OPEN;
86+
};
87+
defer lib.close();
88+
89+
const NameFn = *const fn () callconv(.c) [*:0]const u8;
90+
const VersionFn = *const fn () callconv(.c) [*:0]const u8;
91+
const InitFn = *const fn () callconv(.c) c_int;
92+
const DeinitFn = *const fn () callconv(.c) void;
93+
94+
switch (verb) {
95+
.name => {
96+
const name_fn = lib.lookup(NameFn, "boj_cartridge_name") orelse {
97+
try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_name\"}}", .{});
98+
return EXIT_SYMBOL;
99+
};
100+
const n = std.mem.span(name_fn());
101+
try emitJson(stdout, alloc, "{{\"ok\":true,\"name\":\"{s}\"}}", .{n});
102+
return EXIT_OK;
103+
},
104+
.version => {
105+
const version_fn = lib.lookup(VersionFn, "boj_cartridge_version") orelse {
106+
try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_version\"}}", .{});
107+
return EXIT_SYMBOL;
108+
};
109+
const v = std.mem.span(version_fn());
110+
try emitJson(stdout, alloc, "{{\"ok\":true,\"version\":\"{s}\"}}", .{v});
111+
return EXIT_OK;
112+
},
113+
.probe => {
114+
const init_fn = lib.lookup(InitFn, "boj_cartridge_init") orelse {
115+
try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_init\"}}", .{});
116+
return EXIT_SYMBOL;
117+
};
118+
const deinit_fn = lib.lookup(DeinitFn, "boj_cartridge_deinit") orelse {
119+
try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_deinit\"}}", .{});
120+
return EXIT_SYMBOL;
121+
};
122+
const name_fn = lib.lookup(NameFn, "boj_cartridge_name") orelse {
123+
try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_name\"}}", .{});
124+
return EXIT_SYMBOL;
125+
};
126+
const version_fn = lib.lookup(VersionFn, "boj_cartridge_version") orelse {
127+
try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_version\"}}", .{});
128+
return EXIT_SYMBOL;
129+
};
130+
131+
const rc = init_fn();
132+
if (rc != 0) {
133+
try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"init-returned\",\"rc\":{d}}}", .{rc});
134+
return EXIT_INIT;
135+
}
136+
defer deinit_fn();
137+
138+
const n = std.mem.span(name_fn());
139+
const v = std.mem.span(version_fn());
140+
try emitJson(stdout, alloc, "{{\"ok\":true,\"name\":\"{s}\",\"version\":\"{s}\"}}", .{ n, v });
141+
return EXIT_OK;
142+
},
143+
}
144+
}
145+
146+
// Suppress "unused" warning for EXIT_UNWIRED — reserved for Phase 2.1.
147+
comptime {
148+
_ = EXIT_UNWIRED;
149+
}

0 commit comments

Comments
 (0)