diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index f44636e..95152eb 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -287,8 +287,8 @@ Exit criteria: 1. Graduate the generic Electron host/preload contract into EveElectron so Aetheria retains only daemon lifecycle and provider transport configuration. -2. Replace incubating Sai/Norn projection declarations with one live sidecar - deployment witness per plugin, preserving their independent ownership. +2. Build Sai's owner-side playback daemon and live sidecar witness without + absorbing Norn or TeX semantics into the Sai process. Recently cut: @@ -353,6 +353,16 @@ Recently cut: renderer bundle, while the live Electron witness still proves the resolved field surface. Eve core documents this as the host/plugin decoding boundary, not as an Aetheria-specific exception. +- Norn now has live runtime-independent sidecar proof, not only projection + declarations. The owner-repo `norn-eve-plugin` stays alive across correlated + `describe`, `validate`, `project`, and solver-backed `measure` requests over + advertised `stdio-ndjson`; all four operations satisfy Norn's ABI fixture. + Eve's generic sidecar witness launcher validates advertisement/fixture + agreement and response correlation, emits `gamecult.eve.plugin_witness.v1`, + and the conformance attachment path exports that witness without Norn source + paths. The first live run also removed a UTF-8 BOM framing fault at Norn's + NDJSON boundary. This proves Norn independently; it does not count as Sai + playback proof. - The clean EveUnity PlayMode client now consumes Aetheria's independently running daemon without importing Aetheria code. Aetheria builds its authored diff --git a/schemas/gamecult.eve.conformance_export.v1.schema.json b/schemas/gamecult.eve.conformance_export.v1.schema.json index e6ee523..fc3397c 100644 --- a/schemas/gamecult.eve.conformance_export.v1.schema.json +++ b/schemas/gamecult.eve.conformance_export.v1.schema.json @@ -66,6 +66,23 @@ "additionalProperties": true } }, + "pluginWitnesses": { + "type": "array", + "items": { + "type": "object", + "required": ["witnessId", "pluginId", "ownerRepo", "status", "transport", "operationCount", "witnessExportPath"], + "properties": { + "witnessId": { "type": "string", "minLength": 1 }, + "pluginId": { "type": "string", "minLength": 1 }, + "ownerRepo": { "type": "string", "minLength": 1 }, + "status": { "enum": ["pass", "fail"] }, + "transport": { "type": "string", "minLength": 1 }, + "operationCount": { "type": "integer", "minimum": 1 }, + "witnessExportPath": { "type": "string", "minLength": 1 } + }, + "additionalProperties": true + } + }, "packs": { "type": "array", "items": { diff --git a/schemas/gamecult.eve.plugin_witness.v1.schema.json b/schemas/gamecult.eve.plugin_witness.v1.schema.json new file mode 100644 index 0000000..4c6aa36 --- /dev/null +++ b/schemas/gamecult.eve.plugin_witness.v1.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.plugin_witness.v1", + "title": "Eve Plugin Sidecar Witness", + "type": "object", + "required": ["schema", "witnessId", "pluginId", "ownerRepo", "status", "transport", "generatedAtUtc", "durationMs", "operations", "authority"], + "properties": { + "schema": { "const": "gamecult.eve.plugin_witness.v1" }, + "witnessId": { "type": "string", "minLength": 1 }, + "pluginId": { "type": "string", "minLength": 1 }, + "ownerRepo": { "type": "string", "minLength": 1 }, + "status": { "enum": ["pass", "fail"] }, + "transport": { "type": "string", "minLength": 1 }, + "generatedAtUtc": { "type": "string", "minLength": 1 }, + "durationMs": { "type": "number", "minimum": 0 }, + "operations": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["operation", "requestId", "status", "durationMs", "expectationCount"], + "properties": { + "operation": { "type": "string", "minLength": 1 }, + "requestId": { "type": "string", "minLength": 1 }, + "status": { "type": "string", "minLength": 1 }, + "durationMs": { "type": "number", "minimum": 0 }, + "expectationCount": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true + } + }, + "authority": { "type": "string", "minLength": 1 } + }, + "additionalProperties": true +} diff --git a/tools/conformance/attach-plugin-witness.mjs b/tools/conformance/attach-plugin-witness.mjs new file mode 100644 index 0000000..58faec0 --- /dev/null +++ b/tools/conformance/attach-plugin-witness.mjs @@ -0,0 +1,44 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateSchemaSubset } from "../../runtimes/incubating/test-support/schema-subset.mjs"; + +const [witnessArgument, exportArgument] = process.argv.slice(2); +if (!witnessArgument || !exportArgument) { + console.error("Usage: node tools/conformance/attach-plugin-witness.mjs "); + process.exit(2); +} +const witnessPath = path.resolve(witnessArgument); +const exportDirectory = path.resolve(exportArgument); +const indexPath = path.join(exportDirectory, "index.json"); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const witness = parseJson(await readFile(witnessPath, "utf8")); +const schema = parseJson(await readFile(path.join(repoRoot, "schemas/gamecult.eve.plugin_witness.v1.schema.json"), "utf8")); +const schemaErrors = validateSchemaSubset(schema, witness); +if (schemaErrors.length) throw new Error(`Plugin witness schema errors: ${schemaErrors.join("; ")}`); + +const attachmentRoot = path.join(exportDirectory, "attachments", "plugin", safeSegment(witness.witnessId)); +await mkdir(attachmentRoot, { recursive: true }); +const exportedWitnessPath = path.join(attachmentRoot, "witness.json"); +await writeFile(exportedWitnessPath, `${JSON.stringify(witness, null, 2)}\n`); + +const index = parseJson(await readFile(indexPath, "utf8")); +const record = { + witnessId: witness.witnessId, + pluginId: witness.pluginId, + ownerRepo: witness.ownerRepo, + status: witness.status, + transport: witness.transport, + operationCount: witness.operations.length, + witnessExportPath: slash(path.relative(exportDirectory, exportedWitnessPath)), +}; +index.pluginWitnesses = [ + ...(Array.isArray(index.pluginWitnesses) ? index.pluginWitnesses : []).filter(candidate => candidate.witnessId !== record.witnessId), + record, +].sort((left, right) => left.witnessId.localeCompare(right.witnessId)); +await writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`); +console.log(exportedWitnessPath); + +function safeSegment(value) { return String(value).replace(/[^a-zA-Z0-9._-]+/g, "-"); } +function slash(value) { return value.replaceAll("\\", "/"); } +function parseJson(value) { return JSON.parse(value.replace(/^\uFEFF/, "")); } diff --git a/tools/conformance/consume-export.mjs b/tools/conformance/consume-export.mjs index 71e820e..140c97b 100644 --- a/tools/conformance/consume-export.mjs +++ b/tools/conformance/consume-export.mjs @@ -40,6 +40,7 @@ if (!exportDirectory) { " --expect-plugin-abi-field ", " --expect-plugin-abi-operation-coverage ", " --expect-plugin-handoff ", + " --expect-plugin-witness ", " --expect-plugin-handoff-move ", " --expect-provider-handoff-move ", " --expect-provider-surface ", @@ -140,6 +141,7 @@ function validateIndex(index, directory, expectations, errors) { } const plugins = Array.isArray(index.plugins) ? index.plugins : []; + const pluginWitnesses = Array.isArray(index.pluginWitnesses) ? index.pluginWitnesses : []; const providers = Array.isArray(index.providers) ? index.providers : []; const runtimes = mergeRuntimeRecords(Array.isArray(index.runtimes) ? index.runtimes : [], exportedRuntimeTargets); const splitTargets = Array.isArray(index.splitTargets) ? index.splitTargets : []; @@ -163,6 +165,7 @@ function validateIndex(index, directory, expectations, errors) { const worldSurfaceLoweringGaps = Array.isArray(index.worldSurfaceLoweringGaps) ? index.worldSurfaceLoweringGaps : []; validatePluginRecords(plugins, errors); + validatePluginWitnesses(pluginWitnesses, directory, errors); validatePluginAbiOperationCoverage(index.pluginAbiOperationCoverage, errors); validateProviderRecords(providers, errors); validateProviderPluginRequirementCoverage(index.providerPluginRequirementCoverage, errors); @@ -228,6 +231,15 @@ function validateIndex(index, directory, expectations, errors) { errors.push(`runtimeWitnesses:${witness.witnessId}:status:expected ${expectation.status} got ${witness.status || ""}`); } } + for (const expectation of expectations.pluginWitnesses) { + const witness = pluginWitnesses.find(candidate => + candidate.pluginId === expectation.pluginId && candidate.transport === expectation.transport); + if (!witness) { + errors.push(`pluginWitnesses:${expectation.pluginId}:${expectation.transport}:missing`); + continue; + } + if (witness.status !== expectation.status) errors.push(`pluginWitnesses:${expectation.pluginId}:status:expected ${expectation.status} got ${witness.status || ""}`); + } for (const expectation of expectations.providerRuntimePluginProjectionCoverage) { const record = providerRuntimePluginProjectionCoverage.find(candidate => candidate.providerId === expectation.providerId && @@ -1168,6 +1180,23 @@ function validatePluginRecords(plugins, errors) { } } +function validatePluginWitnesses(witnesses, directory, errors) { + if (!Array.isArray(witnesses)) return; + for (const [index, witness] of witnesses.entries()) { + for (const field of ["witnessId", "pluginId", "ownerRepo", "status", "transport", "operationCount", "witnessExportPath"]) { + if (witness?.[field] === undefined || witness?.[field] === "") errors.push(`pluginWitnesses:${index}:${field}:missing`); + } + const witnessPath = witness?.witnessExportPath ? path.join(directory, witness.witnessExportPath) : ""; + if (!witnessPath || !existsSync(witnessPath)) { + errors.push(`pluginWitnesses:${index}:witnessExportPath:missing`); + continue; + } + const document = JSON.parse(readFileSyncUtf8(witnessPath)); + if (document.schema !== "gamecult.eve.plugin_witness.v1") errors.push(`pluginWitnesses:${index}:schema:unexpected`); + if ((document.operations || []).length !== witness.operationCount) errors.push(`pluginWitnesses:${index}:operationCount:mismatch`); + } +} + function validatePluginAbiOperationCoverage(records, errors) { if (!Array.isArray(records)) { errors.push("pluginAbiOperationCoverage:missing"); @@ -1343,6 +1372,7 @@ function parseArguments(args) { pluginAbiFields: [], pluginAbiOperationCoverage: [], pluginHandoffs: [], + pluginWitnesses: [], pluginHandoffMoves: [], providers: [], providerSurfaces: [], @@ -1408,6 +1438,7 @@ function parseArguments(args) { ["--expect-plugin-abi-field", expectations.pluginAbiFields], ["--expect-plugin-abi-operation-coverage", expectations.pluginAbiOperationCoverage], ["--expect-plugin-handoff", expectations.pluginHandoffs], + ["--expect-plugin-witness", expectations.pluginWitnesses], ["--expect-plugin-handoff-move", expectations.pluginHandoffMoves], ["--expect-provider-surface", expectations.providerSurfaces], ["--expect-provider-surface-kind", expectations.providerSurfaceKinds], @@ -1488,6 +1519,8 @@ function parseArguments(args) { target.push(parsePluginAbiOperationCoverageExpectation(value)); } else if (option === "--expect-plugin-handoff") { target.push(value); + } else if (option === "--expect-plugin-witness") { + target.push(parsePluginWitnessExpectation(value)); } else if (option === "--expect-plugin-handoff-move") { target.push(parsePluginHandoffMoveExpectation(value)); } else if (option === "--expect-provider-surface") { @@ -1928,6 +1961,15 @@ function parseProviderExpectation(value, field) { }; } +function parsePluginWitnessExpectation(value) { + const parts = value.split(":"); + if (parts.length !== 3 || parts.some(part => !part)) { + console.error(`Expected plugin witness in form, got: ${value}`); + process.exit(2); + } + return { pluginId: parts[0], transport: parts[1], status: parts[2] }; +} + function parseProviderSurfaceKindExpectation(value) { const parts = value.split(":"); if (parts.length !== 3 || parts.some(part => !part)) { diff --git a/tools/conformance/eveconformance-handoff.json b/tools/conformance/eveconformance-handoff.json index b4b4189..f104184 100644 --- a/tools/conformance/eveconformance-handoff.json +++ b/tools/conformance/eveconformance-handoff.json @@ -39,6 +39,7 @@ "schemas/gamecult.eve.plugin_receipt.v1.schema.json", "schemas/gamecult.eve.plugin_advertisement.v1.schema.json", "schemas/gamecult.eve.plugin_handoff.v1.schema.json", + "schemas/gamecult.eve.plugin_witness.v1.schema.json", "schemas/gamecult.eve.plugin_abi_fixture.v1.schema.json", "schemas/gamecult.eve.plugin_abi.request.v1.schema.json", "schemas/gamecult.eve.plugin_abi.response.v1.schema.json", @@ -88,6 +89,7 @@ "gamecult.eve.plugin_receipt.v1", "gamecult.eve.plugin_advertisement.v1", "gamecult.eve.plugin_handoff.v1", + "gamecult.eve.plugin_witness.v1", "gamecult.eve.plugin_abi_fixture.v1", "gamecult.eve.plugin_abi.request.v1", "gamecult.eve.plugin_abi.response.v1", diff --git a/tools/parity/parity-manifest.json b/tools/parity/parity-manifest.json index 5bdd972..7111a7a 100644 --- a/tools/parity/parity-manifest.json +++ b/tools/parity/parity-manifest.json @@ -20,6 +20,7 @@ "gamecult.eve.plugin_abi_fixture.v1": "schemas/gamecult.eve.plugin_abi_fixture.v1.schema.json", "gamecult.eve.plugin_advertisement.v1": "schemas/gamecult.eve.plugin_advertisement.v1.schema.json", "gamecult.eve.plugin_handoff.v1": "schemas/gamecult.eve.plugin_handoff.v1.schema.json", + "gamecult.eve.plugin_witness.v1": "schemas/gamecult.eve.plugin_witness.v1.schema.json", "gamecult.eve.runtime_capability.v1": "schemas/gamecult.eve.runtime_capability.v1.schema.json", "gamecult.eve.runtime_lifecycle.v1": "schemas/gamecult.eve.runtime_lifecycle.v1.schema.json", "gamecult.eve.runtime_split_handoff.v1": "schemas/gamecult.eve.runtime_split_handoff.v1.schema.json", diff --git a/tools/plugins/run-sidecar-witness.mjs b/tools/plugins/run-sidecar-witness.mjs new file mode 100644 index 0000000..712af77 --- /dev/null +++ b/tools/plugins/run-sidecar-witness.mjs @@ -0,0 +1,159 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline"; +import { performance } from "node:perf_hooks"; + +const options = parseArguments(process.argv.slice(2)); +const advertisement = parseJson(await readFile(options.advertisement, "utf8")); +const fixture = parseJson(await readFile(options.fixture, "utf8")); +validateInputs(advertisement, fixture, options); + +const started = performance.now(); +const child = spawn(options.command, options.commandArgs, { + cwd: options.cwd, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, +}); +const lines = readline.createInterface({ input: child.stdout }); +const responses = []; +const waiters = []; +lines.on("line", line => { + const waiter = waiters.shift(); + if (!waiter) return; + try { waiter.resolve(parseJson(line)); } catch (error) { waiter.reject(error); } +}); +let stderr = ""; +child.stderr.on("data", chunk => { stderr += chunk.toString(); }); + +try { + for (const [index, operation] of fixture.operations.entries()) { + const requestId = `${fixture.fixtureId}:${operation.operation}:${index}`; + const request = { + schema: fixture.requestSchema, + pluginId: fixture.pluginId, + operation: operation.operation, + requestId, + input: operation.input || {}, + context: { witnessId: options.witnessId }, + targetRuntime: "eve-plugin-witness", + }; + const operationStarted = performance.now(); + const responsePromise = nextResponse(options.timeoutMs); + child.stdin.write(`${JSON.stringify(request)}\n`); + const response = await responsePromise; + validateResponse(response, request, operation.expect || {}); + responses.push({ + operation: operation.operation, + requestId, + status: response.status, + durationMs: round(performance.now() - operationStarted), + expectationCount: Object.keys(operation.expect || {}).length, + }); + } +} finally { + child.stdin.end(); + await Promise.race([new Promise(resolve => child.once("exit", resolve)), delay(1000)]); + if (child.exitCode === null) child.kill(); + lines.close(); +} + +const witness = { + schema: "gamecult.eve.plugin_witness.v1", + witnessId: options.witnessId, + pluginId: fixture.pluginId, + ownerRepo: fixture.ownerRepo, + status: "pass", + transport: advertisement.runtime?.sidecar?.protocol || advertisement.runtime?.transports?.[0] || "", + generatedAtUtc: new Date().toISOString(), + durationMs: round(performance.now() - started), + operations: responses, + advertisementPath: slash(path.relative(options.cwd, options.advertisement)), + fixturePath: slash(path.relative(options.cwd, options.fixture)), + executable: { + artifact: advertisement.runtime?.sidecar?.launch?.artifact || "", + command: advertisement.runtime?.sidecar?.launch?.command || path.basename(options.command), + }, + diagnostics: stderr.trim() ? [stderr.trim()] : [], + authority: "plugin-sidecar-owns-semantic-projection-and-proposes-state-provider-retains-acceptance", +}; +await mkdir(path.dirname(options.output), { recursive: true }); +await writeFile(options.output, `${JSON.stringify(witness, null, 2)}\n`); +console.log(options.output); + +function nextResponse(timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Plugin sidecar response timed out after ${timeoutMs}ms.`)), timeoutMs); + waiters.push({ + resolve: value => { clearTimeout(timer); resolve(value); }, + reject: error => { clearTimeout(timer); reject(error); }, + }); + }); +} + +function validateInputs(advertisement, fixture, options) { + if (advertisement.schema !== "gamecult.eve.plugin_advertisement.v1") throw new Error("Plugin advertisement schema is invalid."); + if (fixture.schema !== "gamecult.eve.plugin_abi_fixture.v1") throw new Error("Plugin ABI fixture schema is invalid."); + if (advertisement.pluginId !== fixture.pluginId) throw new Error("Plugin advertisement and fixture ids differ."); + if (!advertisement.runtime?.sidecar?.operations?.length) throw new Error("Plugin advertisement has no sidecar operations."); + for (const operation of fixture.operations || []) { + if (!advertisement.runtime.sidecar.operations.includes(operation.operation)) throw new Error(`Operation ${operation.operation} is not advertised.`); + } + if (!options.command) throw new Error("Plugin sidecar command is required."); +} + +function validateResponse(response, request, expect) { + for (const [field, value] of Object.entries({ + schema: "gamecult.eve.plugin_abi.response.v1", + pluginId: request.pluginId, + operation: request.operation, + requestId: request.requestId, + status: "accepted", + })) { + if (response?.[field] !== value) throw new Error(`${request.operation} response ${field}: expected ${value}, got ${response?.[field] || ""}`); + } + assertSubset(response.output, expect, request.operation); +} + +function assertSubset(actual, expected, label) { + for (const [key, expectedValue] of Object.entries(expected)) { + if (key === "measurementOutputs") { + for (const outputKey of expectedValue) if (!(outputKey in (actual || {}))) throw new Error(`${label} output missing ${outputKey}.`); + continue; + } + const actualValue = actual?.[key]; + if (Array.isArray(expectedValue)) { + if (!Array.isArray(actualValue) || expectedValue.some(value => !actualValue.includes(value))) throw new Error(`${label} output ${key} does not include expected values.`); + } else if (expectedValue && typeof expectedValue === "object") { + assertSubset(actualValue, expectedValue, `${label}.${key}`); + } else if (actualValue !== expectedValue) { + throw new Error(`${label} output ${key}: expected ${expectedValue}, got ${actualValue}.`); + } + } +} + +function parseArguments(args) { + const values = { commandArgs: [], timeoutMs: 5000 }; + for (let index = 0; index < args.length; index += 1) { + const key = args[index]; + if (key === "--") { values.commandArgs = args.slice(index + 1); break; } + const value = args[++index]; + if (!value) throw new Error(`Missing value for ${key}.`); + if (key === "--advertisement") values.advertisement = path.resolve(value); + else if (key === "--fixture") values.fixture = path.resolve(value); + else if (key === "--output") values.output = path.resolve(value); + else if (key === "--witness-id") values.witnessId = value; + else if (key === "--cwd") values.cwd = path.resolve(value); + else if (key === "--command") values.command = value; + else if (key === "--timeout-ms") values.timeoutMs = Number(value); + else throw new Error(`Unknown option ${key}.`); + } + values.cwd ||= process.cwd(); + for (const field of ["advertisement", "fixture", "output", "witnessId", "command"]) if (!values[field]) throw new Error(`Missing --${field.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}.`); + return values; +} + +function parseJson(value) { return JSON.parse(value.replace(/^\uFEFF/, "")); } +function round(value) { return Math.round(value * 1000) / 1000; } +function slash(value) { return value.replaceAll("\\", "/"); } +function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }