Skip to content

Commit b87db27

Browse files
hyperpolymathclaude
andcommitted
feat(dispatch): wire JS cartridge dispatch via Deno + fix so_path derivation
BojRest.JsInvoker: forks `deno run priv/js_runner.js <mod_js_path> <tool> <args_json>` per invocation (Phase 1 fork-per-call), 30s timeout via Task.yield/shutdown, structured error classification. elixir/priv/js_runner.js: thin Deno harness — dynamic-imports mod.js, calls handleTool(toolName, args), writes {status, data} JSON to stdout. Permissions: --allow-net --allow-env --allow-read. Router.dispatch/3: branches on cart["ffi"] presence. - "ffi" key present → BojRest.Invoker (Zig .so via boj-invoke) - "ffi" key absent → BojRest.JsInvoker (Deno mod.js) cartridge_so_path/1: now reads cart["ffi"]["so_path"] from the manifest instead of hardcoding the derivation. Removes name_to_lib/1 helper. Verified: js_runner + model-router-mcp (pure local), local-memory-mcp (correct 503 when backend absent), deno 2.6.10. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9c174c8 commit b87db27

3 files changed

Lines changed: 262 additions & 18 deletions

File tree

elixir/lib/boj_rest/js_invoker.ex

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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.JsInvoker do
4+
@moduledoc """
5+
Invokes a JS cartridge by shelling out to Deno with `priv/js_runner.js`.
6+
7+
Each call forks a fresh Deno process (Phase 1 — fork-per-call). The ~200 ms
8+
cold-start overhead is acceptable until there is enough traffic to justify a
9+
persistent worker pool (Phase 2).
10+
11+
Dispatch path:
12+
BojRest.Router
13+
→ BojRest.JsInvoker.invoke/3
14+
→ deno run priv/js_runner.js <mod_js_path> <tool_name> <args_json>
15+
→ cartridge/*/mod.js handleTool(toolName, args)
16+
← { status, data } JSON on stdout
17+
18+
Deno permissions granted per invocation:
19+
--allow-net cartridge fetch() calls to upstream backends
20+
--allow-env cartridge Deno.env.get() for API keys / URLs
21+
--allow-read dynamic import resolution of mod.js by the runner
22+
23+
Resolution order for the Deno binary:
24+
1. DENO_PATH env var (absolute path)
25+
2. System PATH (System.find_executable/1)
26+
"""
27+
28+
@timeout_ms 30_000
29+
30+
@type result ::
31+
{:ok, map()}
32+
| {:error,
33+
%{
34+
classification:
35+
:deno_missing | :runner_missing | :js_error | :bad_output | :timeout,
36+
body: String.t() | map() | nil
37+
}}
38+
39+
@doc """
40+
Invoke `tool_name` on the cartridge at `mod_js_path` with `args`.
41+
42+
Returns `{:ok, data_map}` on success or `{:error, info_map}` on failure.
43+
"""
44+
@spec invoke(String.t(), String.t() | nil, map()) :: result()
45+
def invoke(mod_js_path, tool_name, args) do
46+
with {:deno, deno} when deno != nil <- {:deno, deno_path()},
47+
{:runner, runner} when runner != nil <- {:runner, runner_path()},
48+
{:mod, true} <- {:mod, File.regular?(mod_js_path)} do
49+
args_json = Jason.encode!(args)
50+
51+
cmd_args = [
52+
"run",
53+
"--allow-net",
54+
"--allow-env",
55+
"--allow-read",
56+
runner,
57+
mod_js_path,
58+
tool_name || "",
59+
args_json
60+
]
61+
62+
task = Task.async(fn -> System.cmd(deno, cmd_args, stderr_to_stdout: true) end)
63+
64+
case Task.yield(task, @timeout_ms) || Task.shutdown(task, :brutal_kill) do
65+
{:ok, {stdout, 0}} ->
66+
parse_output(stdout)
67+
68+
{:ok, {stderr, _code}} ->
69+
body = try_decode(String.trim(stderr))
70+
{:error, %{classification: :js_error, body: body}}
71+
72+
nil ->
73+
{:error, %{classification: :timeout, body: "JS invocation timed out after #{@timeout_ms}ms"}}
74+
end
75+
else
76+
{:deno, nil} ->
77+
{:error,
78+
%{
79+
classification: :deno_missing,
80+
body: "deno binary not found — install Deno or set DENO_PATH env var"
81+
}}
82+
83+
{:runner, nil} ->
84+
{:error,
85+
%{
86+
classification: :runner_missing,
87+
body: "priv/js_runner.js not found — check the Elixir release priv directory"
88+
}}
89+
90+
{:mod, false} ->
91+
{:error,
92+
%{
93+
classification: :mod_missing,
94+
body: "mod.js not found at #{mod_js_path}"
95+
}}
96+
end
97+
end
98+
99+
# ── internals ─────────────────────────────────────────────────────────────
100+
101+
defp parse_output(stdout) do
102+
case Jason.decode(String.trim(stdout)) do
103+
{:ok, %{"status" => status, "data" => data}} when status in 200..299 ->
104+
{:ok, data}
105+
106+
{:ok, %{"status" => status, "data" => data}} ->
107+
# Application-level error returned by handleTool (4xx, 5xx)
108+
{:error, %{classification: :js_error, body: data, status: status}}
109+
110+
{:ok, other} ->
111+
{:ok, other}
112+
113+
{:error, _} ->
114+
{:error, %{classification: :bad_output, body: String.trim(stdout)}}
115+
end
116+
end
117+
118+
defp try_decode(text) do
119+
case Jason.decode(text) do
120+
{:ok, m} -> m
121+
_ -> text
122+
end
123+
end
124+
125+
@spec deno_path() :: String.t() | nil
126+
defp deno_path do
127+
env = System.get_env("DENO_PATH")
128+
129+
cond do
130+
is_binary(env) and File.regular?(env) -> env
131+
true -> System.find_executable("deno")
132+
end
133+
end
134+
135+
@spec runner_path() :: String.t() | nil
136+
defp runner_path do
137+
priv =
138+
case :code.priv_dir(:boj_rest) do
139+
{:error, _} -> nil
140+
dir -> Path.join(to_string(dir), "js_runner.js")
141+
end
142+
143+
# Dev fallback: walk up from this file to the elixir/ project root.
144+
dev_fallback =
145+
__DIR__
146+
|> Path.join("../../../priv/js_runner.js")
147+
|> Path.expand()
148+
149+
cond do
150+
is_binary(priv) and File.regular?(priv) -> priv
151+
File.regular?(dev_fallback) -> dev_fallback
152+
true -> nil
153+
end
154+
end
155+
end

elixir/lib/boj_rest/router.ex

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,26 @@ defmodule BojRest.Router do
6363
post "/cartridge/:name/invoke" do
6464
case BojRest.Catalog.get(name) do
6565
{:ok, cart} ->
66-
so_path = cartridge_so_path(cart)
6766
tool = Map.get(conn.body_params || %{}, "tool")
6867
args = Map.get(conn.body_params || %{}, "arguments") || %{}
6968

70-
case BojRest.Invoker.invoke(so_path, tool, args) do
71-
{:ok, result} ->
72-
json(conn, 200, result)
73-
74-
{:error, info} ->
75-
json(conn, 500, %{
76-
error: "invocation-failed",
77-
cartridge: name,
78-
tool: tool,
79-
info: info
80-
})
69+
if is_nil(tool) do
70+
json(conn, 400, %{error: "missing-tool-field", cartridge: name})
71+
else
72+
result = dispatch(cart, tool, args)
73+
74+
case result do
75+
{:ok, data} ->
76+
json(conn, 200, data)
77+
78+
{:error, info} ->
79+
json(conn, 500, %{
80+
error: "invocation-failed",
81+
cartridge: name,
82+
tool: tool,
83+
info: info
84+
})
85+
end
8186
end
8287

8388
:not_found ->
@@ -95,14 +100,31 @@ defmodule BojRest.Router do
95100
|> Plug.Conn.send_resp(status, Jason.encode!(body))
96101
end
97102

98-
# Derive the expected shared-library path from a cartridge entry.
99-
# Each cartridge builds its .so under `cartridges/<name>/ffi/zig-out/lib/lib<name>_mcp.so`.
103+
# Dispatch to the correct invoker based on whether the cartridge has an FFI
104+
# (Zig .so) or a JS (mod.js) implementation.
105+
#
106+
# cartridge.json with "ffi" key → Zig .so via boj-invoke CLI
107+
# cartridge.json without "ffi" → JS mod.js via Deno (JsInvoker)
108+
defp dispatch(cart, tool, args) do
109+
if Map.has_key?(cart, "ffi") do
110+
BojRest.Invoker.invoke(cartridge_so_path(cart), tool, args)
111+
else
112+
BojRest.JsInvoker.invoke(cartridge_mod_path(cart), tool, args)
113+
end
114+
end
115+
116+
# Read .so path directly from the manifest's ffi.so_path field.
100117
defp cartridge_so_path(cart) do
101-
name = Map.get(cart, "name") || "unknown"
102118
root = Application.get_env(:boj_rest, :cartridges_root)
103-
Path.join([root, name, "ffi", "zig-out", "lib", "lib#{name_to_lib(name)}.so"])
119+
name = Map.get(cart, "name")
120+
so_rel = get_in(cart, ["ffi", "so_path"])
121+
Path.join([root, name, so_rel])
104122
end
105123

106-
# cartridge name "aerie-mcp" -> lib name fragment "aerie_mcp"
107-
defp name_to_lib(name), do: String.replace(name, "-", "_")
124+
# mod.js lives at <cartridges_root>/<name>/mod.js by convention.
125+
defp cartridge_mod_path(cart) do
126+
root = Application.get_env(:boj_rest, :cartridges_root)
127+
name = Map.get(cart, "name")
128+
Path.join([root, name, "mod.js"])
129+
end
108130
end

elixir/priv/js_runner.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
// js_runner.js — thin Deno harness that imports a cartridge mod.js and calls
5+
// handleTool(toolName, args), writing { status, data } JSON to stdout.
6+
//
7+
// Invoked by BojRest.JsInvoker as:
8+
// deno run --allow-net --allow-env --allow-read js_runner.js <abs_mod_path> <tool_name> <args_json>
9+
//
10+
// Exit codes:
11+
// 0 — handleTool returned (success or application-level error in data)
12+
// 1 — runner itself failed (bad args, import error, unhandled throw)
13+
14+
const [modPath, toolName, argsJson] = Deno.args;
15+
16+
if (!modPath || !toolName) {
17+
console.error(JSON.stringify({
18+
status: 400,
19+
data: { error: "js_runner requires: <mod_path> <tool_name> [args_json]" },
20+
}));
21+
Deno.exit(1);
22+
}
23+
24+
let args = {};
25+
if (argsJson) {
26+
try {
27+
args = JSON.parse(argsJson);
28+
} catch (_) {
29+
console.error(JSON.stringify({
30+
status: 400,
31+
data: { error: `args_json is not valid JSON: ${argsJson}` },
32+
}));
33+
Deno.exit(1);
34+
}
35+
}
36+
37+
// Dynamic import: convert absolute path to file URL so Deno resolves it
38+
// correctly on all platforms without needing --allow-import-host.
39+
const modUrl = modPath.startsWith("/") ? `file://${modPath}` : modPath;
40+
41+
try {
42+
const mod = await import(modUrl);
43+
44+
if (typeof mod.handleTool !== "function") {
45+
console.error(JSON.stringify({
46+
status: 500,
47+
data: { error: `${modPath} does not export handleTool` },
48+
}));
49+
Deno.exit(1);
50+
}
51+
52+
const result = await mod.handleTool(toolName, args);
53+
54+
// Ensure result always has the expected shape { status, data }
55+
if (result && typeof result.status === "number" && "data" in result) {
56+
console.log(JSON.stringify(result));
57+
} else {
58+
// handleTool returned something unusual — wrap it
59+
console.log(JSON.stringify({ status: 200, data: result }));
60+
}
61+
} catch (e) {
62+
console.error(JSON.stringify({
63+
status: 500,
64+
data: { error: `handleTool threw: ${e.message}`, stack: e.stack },
65+
}));
66+
Deno.exit(1);
67+
}

0 commit comments

Comments
 (0)