Serve Sai plugin ABI over CultNet
This commit is contained in:
parent
a5c2a65b0e
commit
2e7be7e9f9
9 changed files with 219 additions and 44 deletions
|
|
@ -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
39
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
91
scripts/run-eve-cultnet-witness.mjs
Normal file
91
scripts/run-eve-cultnet-witness.mjs
Normal 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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
45
src/eve-cultnet-sidecar.cjs
Normal file
45
src/eve-cultnet-sidecar.cjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in a new issue