Serve Sai plugin ABI over CultNet

This commit is contained in:
Meta Vens 2026-07-10 11:31:15 +01:00
commit 2e7be7e9f9
9 changed files with 219 additions and 44 deletions

View file

@ -10,9 +10,13 @@ behalf, or execute Norn or TeX semantics. It advertises `norn.graph` and
## Transport
`src/eve-sidecar.cjs` is a long-running NDJSON process. Each input line is a
`gamecult.eve.plugin_abi.request.v1`; each output line is the correlated
`gamecult.eve.plugin_abi.response.v1`.
`src/eve-cultnet-sidecar.cjs` is the runtime-independent service entry point.
It advertises a CultNet RUDP endpoint and accepts `cultnet.operation_request.v0`
envelopes whose MessagePack payload is a `gamecult.eve.plugin_abi.request.v1`.
Responses preserve both the CultNet message correlation and Eve ABI request ID.
`src/eve-sidecar.cjs` exposes the same handler through newline-delimited JSON
for local debugging. It is not the deployment transport.
The retained operations are:
@ -26,6 +30,9 @@ The retained operations are:
Run the owner-side conformance witness with:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/run-eve-plugin-witness.ps1
```sh
npm run check:eve-plugin
```
The witness starts the real CultNet service, invokes every fixture operation
through RUDP, and writes `artifacts/eve-plugin/runtime-witness.json`.

39
package-lock.json generated
View file

@ -8,16 +8,51 @@
"name": "@gamecult/sai",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"cultnet-ts": "file:../CultLib/packages/cultnet-ts",
"inkjs": "^2.4.0"
},
"bin": {
"sai-eve-sidecar": "src/eve-cultnet-sidecar.cjs",
"sai-eve-stdio": "src/eve-sidecar.cjs"
},
"devDependencies": {
"inkjs": "^2.4.0",
"prettier": "^3.5.3"
}
},
"../CultLib/packages/cultnet-ts": {
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"ajv": "^8.17.1",
"cultcache-ts": "file:../cultcache-ts"
},
"devDependencies": {
"@types/node": "^24.0.0",
"json-schema-to-typescript": "^15.0.4",
"typescript": "^5.9.0",
"zod": "^3.25.76"
}
},
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
"integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
"license": "ISC",
"engines": {
"node": ">= 18"
}
},
"node_modules/cultnet-ts": {
"resolved": "../CultLib/packages/cultnet-ts",
"link": true
},
"node_modules/inkjs": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/inkjs/-/inkjs-2.4.0.tgz",
"integrity": "sha512-EoPCYESIbMtfI8SqEDZCJwn+A5is0QozMLw250iic1ReJCgZpRKIezWj0VqgRUzAx0f3MmEbsUjY/ILe2815JQ==",
"dev": true,
"license": "MIT",
"bin": {
"inkjs-compiler": "bin/inkjs-compiler.js"

View file

@ -22,15 +22,18 @@
"./ink.js": "./dist/ink.js"
},
"bin": {
"sai-eve-sidecar": "./src/eve-sidecar.cjs"
"sai-eve-sidecar": "./src/eve-cultnet-sidecar.cjs",
"sai-eve-stdio": "./src/eve-sidecar.cjs"
},
"scripts": {
"build": "node scripts/build.mjs",
"check": "node scripts/check.mjs",
"check:eve-plugin": "powershell -ExecutionPolicy Bypass -File scripts/run-eve-plugin-witness.ps1",
"check:eve-plugin": "node scripts/run-eve-cultnet-witness.mjs",
"compile:example": "inkjs-compiler examples/static-site/story.ink -o examples/static-site/story.ink.json"
},
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"cultnet-ts": "file:../CultLib/packages/cultnet-ts",
"inkjs": "^2.4.0"
},
"devDependencies": {

View file

@ -7,17 +7,18 @@
"runtime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["stdio"],
"transports": ["cultnet-rudp", "stdio"],
"authority": ["renderer-independent", "no-provider-state-mutation", "provider-accepts-or-denies-commands"],
"sidecar": {
"processKind": "long-running-daemon",
"protocol": "stdio-ndjson",
"protocol": "cultnet-operation-v0",
"debugProtocol": "stdio-ndjson",
"requestSchema": "gamecult.eve.plugin_abi.request.v1",
"responseSchema": "gamecult.eve.plugin_abi.response.v1",
"operations": ["describe", "validate", "open", "apply", "project", "jump"],
"commandEnvelope": "gamecult.eve.command_invocation.v1",
"receiptSchema": "gamecult.eve.command_receipt.v1",
"launch": { "command": "node", "artifact": "src/eve-sidecar.cjs" },
"launch": { "command": "node", "artifact": "src/eve-cultnet-sidecar.cjs" },
"stateAuthority": "sai-sidecar-owns-story-session-provider-owns-command-acceptance"
}
},

View file

@ -12,11 +12,12 @@
"runtime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["stdio"],
"transports": ["cultnet-rudp", "stdio"],
"authority": ["renderer-independent", "no-provider-state-mutation", "provider-accepts-or-denies-commands"],
"sidecar": {
"processKind": "long-running-daemon",
"protocol": "stdio-ndjson",
"protocol": "cultnet-operation-v0",
"debugProtocol": "stdio-ndjson",
"requestSchema": "gamecult.eve.plugin_abi.request.v1",
"responseSchema": "gamecult.eve.plugin_abi.response.v1",
"operations": ["describe", "validate", "open", "apply", "project", "jump"],

View file

@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { Buffer } from "node:buffer";
import { decode, encode } from "@msgpack/msgpack";
import { invokeCultNetOperation } from "cultnet-ts";
const root = path.resolve(import.meta.dirname, "..");
const fixturePath = path.join(root, "plugins/sai-vn.plugin-abi-fixture.json");
const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
const sidecarPath = path.join(root, "src/eve-cultnet-sidecar.cjs");
const child = spawn(process.execPath, [sidecarPath], { cwd: root, stdio: ["ignore", "pipe", "inherit"] });
const lines = readline.createInterface({ input: child.stdout })[Symbol.asyncIterator]();
const endpointLine = await lines.next();
assert.equal(endpointLine.done, false);
const endpointAdvertisement = JSON.parse(endpointLine.value);
assert.equal(endpointAdvertisement.pluginId, fixture.pluginId);
const startedAt = performance.now();
const operations = [];
try {
for (let index = 0; index < fixture.operations.length; index += 1) {
const fixtureOperation = fixture.operations[index];
const requestId = `sai-witness-${index}`;
const operationStartedAt = performance.now();
const abiRequest = {
schema: fixture.requestSchema,
pluginId: fixture.pluginId,
operation: fixtureOperation.operation,
requestId,
input: fixtureOperation.input,
};
const envelope = await invokeCultNetOperation(endpointAdvertisement.endpoint, {
schemaVersion: "cultnet.operation_request.v0",
messageId: requestId,
serviceId: fixture.pluginId,
operation: fixtureOperation.operation,
payloadSchema: fixture.requestSchema,
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode(abiRequest)).toString("base64"),
sourceRuntimeId: "eve-conformance",
}, { runtimeId: "eve-conformance" });
const response = decode(Buffer.from(envelope.payload, "base64"));
assert.equal(response.schema, fixture.responseSchema);
assert.equal(response.pluginId, fixture.pluginId);
assert.equal(response.operation, fixtureOperation.operation);
assert.equal(response.requestId, requestId);
assert.equal(response.status, "accepted");
assertSubset(response.output, fixtureOperation.expect, fixtureOperation.operation);
operations.push({
operation: fixtureOperation.operation,
requestId,
status: response.status,
durationMs: Number((performance.now() - operationStartedAt).toFixed(3)),
expectationCount: Object.keys(fixtureOperation.expect).length,
});
}
} finally {
child.kill();
}
const outputPath = path.join(root, "artifacts/eve-plugin/runtime-witness.json");
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify({
schema: "gamecult.eve.plugin_witness.v1",
witnessId: "sai.vn.owner-sidecar",
pluginId: fixture.pluginId,
ownerRepo: "Sai",
status: "pass",
transport: "cultnet-operation-v0+rudp",
generatedAtUtc: new Date().toISOString(),
durationMs: Number((performance.now() - startedAt).toFixed(3)),
operations,
advertisementPath: "plugins/sai-vn.plugin-advertisement.json",
fixturePath: "plugins/sai-vn.plugin-abi-fixture.json",
executable: { artifact: "src/eve-cultnet-sidecar.cjs", command: "node" },
diagnostics: [],
authority: "sai-sidecar-owns-story-session-provider-retains-command-acceptance",
}, null, 2)}\n`);
console.log(outputPath);
function assertSubset(actual, expected, label) {
if (Array.isArray(expected)) return assert.deepEqual(actual, expected, label);
if (expected && typeof expected === "object") {
for (const [key, value] of Object.entries(expected)) assertSubset(actual?.[key], value, `${label}.${key}`);
return;
}
assert.equal(actual, expected, label);
}

View file

@ -1,22 +1,6 @@
param(
[string] $EveRoot = "E:\Projects\Eve",
[string] $OutputPath = "artifacts\eve-plugin\runtime-witness.json",
[string] $NodePath = "node"
)
param([string] $NodePath = "node")
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$output = if ([IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path $repoRoot $OutputPath }
$launcher = Join-Path $EveRoot "tools\plugins\run-sidecar-witness.mjs"
& $NodePath $launcher `
--advertisement (Join-Path $repoRoot "plugins\sai-vn.plugin-advertisement.json") `
--fixture (Join-Path $repoRoot "plugins\sai-vn.plugin-abi-fixture.json") `
--output $output `
--witness-id "sai.vn.owner-sidecar" `
--cwd $repoRoot `
--command $NodePath `
-- "src/eve-sidecar.cjs"
& $NodePath (Join-Path $PSScriptRoot "run-eve-cultnet-witness.mjs")
if ($LASTEXITCODE -ne 0) { throw "Sai Eve plugin witness failed with exit code $LASTEXITCODE" }
Write-Host "Sai Eve plugin witness: $output"

View file

@ -0,0 +1,45 @@
#!/usr/bin/env node
const { Buffer } = require("node:buffer");
const { decode, encode } = require("@msgpack/msgpack");
const { createResponse, dispatch, pluginId } = require("./eve-sidecar.cjs");
(async () => {
const { startCultNetOperationServer } = await import("cultnet-ts");
const server = await startCultNetOperationServer({
runtimeId: "sai-vn-plugin",
host: process.env.EVE_PLUGIN_HOST || "127.0.0.1",
port: Number(process.env.EVE_PLUGIN_PORT || 0),
handler: request => {
if (request.serviceId !== pluginId) throw new Error(`Expected service ${pluginId}.`);
if (request.payloadSchema !== "gamecult.eve.plugin_abi.request.v1") throw new Error("Unsupported plugin payload schema.");
const abiRequest = decode(Buffer.from(request.payload, "base64"));
let abiResponse;
try {
abiResponse = createResponse(abiRequest, dispatch(abiRequest));
} catch (error) {
abiResponse = createResponse(abiRequest, {}, "rejected", error instanceof Error ? error.message : String(error));
}
return {
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: pluginId,
operation: request.operation,
status: abiResponse.status,
payloadSchema: abiResponse.schema,
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode(abiResponse)).toString("base64"),
diagnostics: (abiResponse.diagnostics || []).map(item => item.message),
sourceRuntimeId: "sai-vn-plugin",
};
},
});
process.stdout.write(`${JSON.stringify({ schema: "gamecult.eve.plugin_endpoint.v1", pluginId, endpoint: server.endpoint })}\n`);
const stop = async () => { await server.close(); process.exit(0); };
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
await new Promise(() => {});
})().catch(error => {
process.stderr.write(`${error.stack || error}\n`);
process.exit(1);
});

View file

@ -10,16 +10,18 @@ const sessions = new Map();
const pluginId = "sai.vn";
const operations = ["describe", "validate", "open", "apply", "project", "jump"];
readline.createInterface({ input: process.stdin }).on("line", (line) => {
if (!line.trim()) return;
let request;
try {
request = JSON.parse(line.replace(/^\uFEFF/, ""));
respond(request, dispatch(request));
} catch (error) {
respond(request, {}, "rejected", error.message);
}
});
if (require.main === module) {
readline.createInterface({ input: process.stdin }).on("line", (line) => {
if (!line.trim()) return;
let request;
try {
request = JSON.parse(line.replace(/^\uFEFF/, ""));
respond(request, dispatch(request));
} catch (error) {
respond(request, {}, "rejected", error.message);
}
});
}
function dispatch(request) {
if (request?.schema !== "gamecult.eve.plugin_abi.request.v1") throw new Error("Unsupported request schema.");
@ -156,7 +158,11 @@ function requiredString(value, field) {
function parseJson(value) { return JSON.parse(value.replace(/^\uFEFF/, "")); }
function respond(request, output, status = "accepted", message) {
process.stdout.write(`${JSON.stringify({
process.stdout.write(`${JSON.stringify(createResponse(request, output, status, message))}\n`);
}
function createResponse(request, output, status = "accepted", message) {
return {
schema: "gamecult.eve.plugin_abi.response.v1",
pluginId,
operation: request?.operation || "unknown",
@ -164,5 +170,7 @@ function respond(request, output, status = "accepted", message) {
status,
output,
...(message ? { diagnostics: [{ message }] } : {}),
})}\n`);
};
}
module.exports = { createResponse, dispatch, operations, pluginId };