From 2e7be7e9f90e1eebce09538aff34c207820f41aa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 11:31:15 +0100 Subject: [PATCH] Serve Sai plugin ABI over CultNet --- docs/eve-sidecar.md | 17 +++-- package-lock.json | 39 +++++++++- package.json | 7 +- plugins/sai-vn.plugin-advertisement.json | 7 +- plugins/sai-vn.plugin.json | 5 +- scripts/run-eve-cultnet-witness.mjs | 91 ++++++++++++++++++++++++ scripts/run-eve-plugin-witness.ps1 | 20 +----- src/eve-cultnet-sidecar.cjs | 45 ++++++++++++ src/eve-sidecar.cjs | 32 +++++---- 9 files changed, 219 insertions(+), 44 deletions(-) create mode 100644 scripts/run-eve-cultnet-witness.mjs create mode 100644 src/eve-cultnet-sidecar.cjs diff --git a/docs/eve-sidecar.md b/docs/eve-sidecar.md index ea154ea..fca5236 100644 --- a/docs/eve-sidecar.md +++ b/docs/eve-sidecar.md @@ -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`. diff --git a/package-lock.json b/package-lock.json index 5ffc51d..46c88b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 64f6b71..b468632 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/plugins/sai-vn.plugin-advertisement.json b/plugins/sai-vn.plugin-advertisement.json index d82d818..be1bfe7 100644 --- a/plugins/sai-vn.plugin-advertisement.json +++ b/plugins/sai-vn.plugin-advertisement.json @@ -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" } }, diff --git a/plugins/sai-vn.plugin.json b/plugins/sai-vn.plugin.json index 7ed7fd4..70d46ca 100644 --- a/plugins/sai-vn.plugin.json +++ b/plugins/sai-vn.plugin.json @@ -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"], diff --git a/scripts/run-eve-cultnet-witness.mjs b/scripts/run-eve-cultnet-witness.mjs new file mode 100644 index 0000000..01f41f5 --- /dev/null +++ b/scripts/run-eve-cultnet-witness.mjs @@ -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); +} diff --git a/scripts/run-eve-plugin-witness.ps1 b/scripts/run-eve-plugin-witness.ps1 index 3a3d623..43ef2be 100644 --- a/scripts/run-eve-plugin-witness.ps1 +++ b/scripts/run-eve-plugin-witness.ps1 @@ -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" diff --git a/src/eve-cultnet-sidecar.cjs b/src/eve-cultnet-sidecar.cjs new file mode 100644 index 0000000..43b0534 --- /dev/null +++ b/src/eve-cultnet-sidecar.cjs @@ -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); +}); diff --git a/src/eve-sidecar.cjs b/src/eve-sidecar.cjs index 1c8386f..955f63c 100644 --- a/src/eve-sidecar.cjs +++ b/src/eve-sidecar.cjs @@ -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 };