A launched bundle subscribes and asks through its runtime, and its code may do so while being imported (hq ADR 0198)

The stdio channel is the bundle's bus: subscribe asks the runtime (mesh/subscribe), which binds the
module's durable consumer and hands each event over as mesh/event — answered once every matching
handler has taken it, as an error when one failed so the runtime does not acknowledge it; request
asks the runtime (mesh/ask). The session starts as the stdio module loads when the runtime launched
the process, because a handler module subscribes at import, before the launcher says what it
serves; initialize and tools/list are answered once it has. topicMatches as the runtime's. 0.1.6.
This commit is contained in:
jochen
2026-10-03 22:23:16 +02:00
parent 16370de2be
commit 1f936dc4a6
4 changed files with 230 additions and 49 deletions
+15
View File
@@ -0,0 +1,15 @@
// A launched bundle whose module code runs long (novox/hq ADR 0198): it subscribes as it is imported,
// before it says what it serves — exactly as a handler module's index does — and one tool asks the bus.
import { on } from "../../dist/events/index.js";
import { broker } from "../../dist/messaging/index.js";
import { registerModuleTools } from "../../dist/tools/index.js";
import { serveRegisteredOverStdio } from "../../dist/stdio/index.js";
await on("alpha.happened", async (e) => {
process.stderr.write(`handled ${e.type} ${JSON.stringify(e.body)}\n`);
if (e.body && e.body.fail) throw new Error("not this one");
});
registerModuleTools("watcher", () => [
{ name: "relay", description: "asks beta", input: {}, run: async () => broker().request("beta.one", { x: 1 }) },
]);
await serveRegisteredOverStdio();
+65
View File
@@ -104,3 +104,68 @@ test("a launched tool's emit is asked of the runtime, and the call answers once
child.kill();
}
});
test("a launched bundle subscribes as it is imported, handles events the runtime hands it, and asks through it (ADR 0198)", async () => {
const child = spawn(process.execPath, [fileURLToPath(new URL("./fixtures/stdio-long-running.mjs", import.meta.url))],
{ stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, MESH_SERVED_MODULE: "watcher" } });
const seen: any[] = [];
let buffered = "";
let said = "";
child.stderr.on("data", (d) => (said += d.toString()));
child.stdout.on("data", (d) => {
buffered += d.toString();
let at: number;
while ((at = buffered.indexOf("\n")) >= 0) {
seen.push(JSON.parse(buffered.slice(0, at)));
buffered = buffered.slice(at + 1);
}
});
const until = async (match: (m: any) => boolean) => {
for (let i = 0; i < 150 && !seen.some(match); i++) await new Promise((r) => setTimeout(r, 20));
return seen.find(match);
};
const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n");
const event = (id: number, key: string, body: unknown) =>
send({ jsonrpc: "2.0", id, method: "mesh/event", params: { envelope: { key, node: "anchor", body, headers: { "x-event-id": `e${id}`, "x-source": "alpha" } } } });
try {
// The subscription comes first, from the import, before the bundle has said what it serves.
const sub = await until((m) => m.method === "mesh/subscribe");
assert.deepEqual(sub.params, { pattern: "alpha.happened" });
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {} } });
assert.equal(await until((m) => m.id === 1), undefined, "initialize was answered while the import still waited on its subscription");
send({ jsonrpc: "2.0", id: sub.id, result: {} });
assert.equal((await until((m) => m.id === 1)).result.serverInfo.name, "watcher");
send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
assert.deepEqual((await until((m) => m.id === 2)).result.tools.map((t: any) => t.name), ["relay"]);
// Handled: answered with a result, so the runtime acknowledges it.
event(10, "alpha.happened", { n: 1 });
assert.deepEqual((await until((m) => m.id === 10)).result, {});
assert.match(said, /handled alpha\.happened \{"n":1\}/);
// The handler failed: answered with an error, so the runtime does not acknowledge it.
event(11, "alpha.happened", { fail: true });
assert.match((await until((m) => m.id === 11)).error.message, /not this one/);
// No handler is for it: answered at once, so it is acknowledged rather than redelivered.
event(12, "beta.other", {});
assert.deepEqual((await until((m) => m.id === 12)).result, {});
// A tool asks the bus: the runtime is asked, and its answer is the tool's.
send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "relay", arguments: {} } });
const asked = await until((m) => m.method === "mesh/ask");
assert.deepEqual(asked.params, { key: "beta.one", body: { x: 1 } });
send({ jsonrpc: "2.0", id: asked.id, result: { beta: 1 } });
assert.deepEqual(JSON.parse((await until((m) => m.id === 3)).result.content[0].text), { beta: 1 });
} finally {
child.kill();
}
});
test("topics match as the runtime's do: * one segment, # and ** any number", async () => {
const { topicMatches } = await import("../dist/stdio/index.js");
assert.ok(topicMatches("alpha.happened", "alpha.happened"));
assert.ok(topicMatches("alpha.*", "alpha.happened"));
assert.ok(!topicMatches("alpha.*", "alpha.happened.twice"));
assert.ok(topicMatches("#", "anything.at.all"));
assert.ok(topicMatches("alpha.**", "alpha.a.b"));
assert.ok(!topicMatches("beta.*", "alpha.happened"));
});