diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index 77e31b0..f1ad25e 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -430,6 +430,13 @@ Recently cut: demand those contracts explicitly, so Sai, Norn, EvePlugins, Aetheria, and EveConformance can consume handoff shape without depending on Eve's local smoke script folklore. +- Runtime lifecycle shape is now schema-backed as + `gamecult.eve.runtime_lifecycle.v1`. Flutter's standalone + `eveflutter-lifecycle.json` validates against that contract, embedded + release/test/capture lifecycle blocks in runtime capability manifests are + wrapped and validated through the same schema, and the conformance export + carries `lifecycleManifestPath` plus `lifecycleErrors` so runtime owners can + consume lifecycle status without guessing from prose. - EveElectron now has a pending split target and handoff at `runtimes/incubating/eve-electron/eveelectron-split-handoff.json`. It does claim a provider-agnostic Electron shell surface-tree projection and command diff --git a/docs/parity-testing-harness.md b/docs/parity-testing-harness.md index 99c230f..dc7fd79 100644 --- a/docs/parity-testing-harness.md +++ b/docs/parity-testing-harness.md @@ -224,6 +224,10 @@ Plugin, provider, and EveConformance handoff documents are also schema-backed: `gamecult.eve.plugin_handoff.v1`, `gamecult.eve.provider_handoff.v1`, and `gamecult.eve.conformance_handoff.v1` are exported through the schema catalog. Owner consumer smokes assert those schemas before trusting handoff paths. +Runtime lifecycle records use `gamecult.eve.runtime_lifecycle.v1`. Standalone +lifecycle documents such as `flutter/eve_parity/eveflutter-lifecycle.json` and +embedded runtime capability lifecycle blocks validate against the same stage +shape, and exported runtime records carry `lifecycleErrors`. After generating the parity report, `run-parity-harness.ps1` copies `artifacts/conformance/latest` into diff --git a/schemas/gamecult.eve.conformance_export.v1.schema.json b/schemas/gamecult.eve.conformance_export.v1.schema.json index 9382873..e92e64f 100644 --- a/schemas/gamecult.eve.conformance_export.v1.schema.json +++ b/schemas/gamecult.eve.conformance_export.v1.schema.json @@ -1020,6 +1020,12 @@ "capabilityManifestErrors": { "type": "array" }, + "lifecycleManifestPath": { + "type": "string" + }, + "lifecycleErrors": { + "type": "array" + }, "splitHandoffPath": { "type": "string" }, diff --git a/schemas/gamecult.eve.runtime_lifecycle.v1.schema.json b/schemas/gamecult.eve.runtime_lifecycle.v1.schema.json new file mode 100644 index 0000000..3fe6f5f --- /dev/null +++ b/schemas/gamecult.eve.runtime_lifecycle.v1.schema.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.runtime_lifecycle.v1", + "title": "Eve Runtime Lifecycle", + "type": "object", + "required": [ + "schema", + "splitTarget", + "ownerRepo", + "currentHostRepo", + "runtimes", + "release", + "test", + "capture" + ], + "properties": { + "schema": { + "const": "gamecult.eve.runtime_lifecycle.v1" + }, + "splitTarget": { + "type": "string", + "minLength": 1 + }, + "ownerRepo": { + "type": "string", + "minLength": 1 + }, + "currentHostRepo": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string" + }, + "runtimes": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "release": { + "type": "object", + "required": [ + "ownerRepo", + "status", + "evidencePaths" + ], + "properties": { + "ownerRepo": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "minLength": 1 + }, + "command": { + "type": "string" + }, + "commands": { + "type": "array" + }, + "releaseContract": { + "type": "object" + }, + "evidencePaths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "pendingProofs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": true + }, + "test": { + "type": "object", + "required": [ + "ownerRepo", + "status", + "evidencePaths" + ], + "properties": { + "ownerRepo": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "minLength": 1 + }, + "command": { + "type": "string" + }, + "commands": { + "type": "array" + }, + "testContract": { + "type": "object" + }, + "evidencePaths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "pendingProofs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": true + }, + "capture": { + "type": "object", + "required": [ + "ownerRepo", + "status", + "evidencePaths" + ], + "properties": { + "ownerRepo": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "minLength": 1 + }, + "command": { + "type": "string" + }, + "commands": { + "type": "array" + }, + "captureContract": { + "type": "object" + }, + "evidencePaths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "pendingProofs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": true + }, + "remainingSplitBlockers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": true +} diff --git a/scripts/run-conformance-consumer-smoke.ps1 b/scripts/run-conformance-consumer-smoke.ps1 index 688aed0..35d249b 100644 --- a/scripts/run-conformance-consumer-smoke.ps1 +++ b/scripts/run-conformance-consumer-smoke.ps1 @@ -41,6 +41,7 @@ try { --expect-schema gamecult.eve.plugin_abi.response.v1 ` --expect-schema gamecult.eve.runtime_release_request.v1 ` --expect-schema gamecult.eve.runtime_capture_request.v1 ` + --expect-schema gamecult.eve.runtime_lifecycle.v1 ` --expect-schema gamecult.eve.runtime_split_handoff.v1 ` --expect-schema gamecult.eve.plugin_handoff.v1 ` --expect-schema gamecult.eve.provider_handoff.v1 ` diff --git a/scripts/run-eveconformance-handoff-smoke.ps1 b/scripts/run-eveconformance-handoff-smoke.ps1 index 04a6263..d238873 100644 --- a/scripts/run-eveconformance-handoff-smoke.ps1 +++ b/scripts/run-eveconformance-handoff-smoke.ps1 @@ -91,6 +91,7 @@ foreach ($contract in @( "gamecult.eve.provider_advertisement.v1", "gamecult.eve.provider_handoff.v1", "gamecult.eve.runtime_capability.v1", + "gamecult.eve.runtime_lifecycle.v1", "gamecult.eve.runtime_split_handoff.v1" )) { if (-not (@($handoff.contractInputs) -contains $contract)) { @@ -124,6 +125,7 @@ if (Test-Path -LiteralPath (Join-Path $absoluteExportDirectory "index.json")) { "gamecult.eve.provider_advertisement.v1", "gamecult.eve.provider_handoff.v1", "gamecult.eve.runtime_capability.v1", + "gamecult.eve.runtime_lifecycle.v1", "gamecult.eve.runtime_split_handoff.v1", "gamecult.eve.command_receipt.v1", "gamecult.eve.plugin.v1", diff --git a/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 b/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 index 13ac2a8..b815908 100644 --- a/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 +++ b/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 @@ -43,6 +43,7 @@ node $consumerScript $consumerExport ` --expect-runtime direct2d ` --expect-schema gamecult.eve.runtime_release_request.v1 ` --expect-schema gamecult.eve.runtime_capture_request.v1 ` + --expect-schema gamecult.eve.runtime_lifecycle.v1 ` --expect-schema gamecult.eve.runtime_split_handoff.v1 ` --expect-runtime-status web:active ` --expect-runtime-status windows-flutter:active ` diff --git a/tools/conformance/consume-export.mjs b/tools/conformance/consume-export.mjs index b2d9d23..27469b4 100644 --- a/tools/conformance/consume-export.mjs +++ b/tools/conformance/consume-export.mjs @@ -974,7 +974,7 @@ function validateRuntimeRecords(runtimes, errors) { for (const field of ["runtimeId", "status", "ownerRepo", "splitTarget", "captureStatus"]) { if (!runtime?.[field]) errors.push(`${label}:${field}:missing`); } - for (const field of ["supportedFeatures", "supportedPlugins", "unsupportedPlugins", "capabilityManifestErrors", "worldSurfaceLowering"]) { + for (const field of ["supportedFeatures", "supportedPlugins", "unsupportedPlugins", "capabilityManifestErrors", "lifecycleErrors", "worldSurfaceLowering"]) { if (!Array.isArray(runtime?.[field])) errors.push(`${label}:${field}:expected array`); } } diff --git a/tools/conformance/eveconformance-handoff.json b/tools/conformance/eveconformance-handoff.json index 4785ef1..681a514 100644 --- a/tools/conformance/eveconformance-handoff.json +++ b/tools/conformance/eveconformance-handoff.json @@ -27,6 +27,7 @@ "schemas/gamecult.eve.provider_advertisement.v1.schema.json", "schemas/gamecult.eve.provider_handoff.v1.schema.json", "schemas/gamecult.eve.runtime_capability.v1.schema.json", + "schemas/gamecult.eve.runtime_lifecycle.v1.schema.json", "schemas/gamecult.eve.runtime_split_handoff.v1.schema.json", "schemas/gamecult.eve.command_receipt.v1.schema.json", "schemas/gamecult.eve.plugin.v1.schema.json", @@ -88,6 +89,7 @@ "gamecult.eve.provider_advertisement.v1", "gamecult.eve.provider_handoff.v1", "gamecult.eve.runtime_capability.v1", + "gamecult.eve.runtime_lifecycle.v1", "gamecult.eve.runtime_split_handoff.v1" ], "forbiddenImports": [ diff --git a/tools/parity/parity-manifest.json b/tools/parity/parity-manifest.json index d17ce2c..e2cec32 100644 --- a/tools/parity/parity-manifest.json +++ b/tools/parity/parity-manifest.json @@ -20,6 +20,7 @@ "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.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", "gamecult.eve.runtime_release_request.v1": "schemas/gamecult.eve.runtime_release_request.v1.schema.json", "gamecult.eve.runtime_capture_request.v1": "schemas/gamecult.eve.runtime_capture_request.v1.schema.json", @@ -1120,6 +1121,10 @@ "repoRole": "runtime-incubating", "splitTarget": "EveFlutter", "graduationTrigger": "Split when Flutter has provider picker, command transport, plugin projection adapters, and its own build/release lifecycle.", + "lifecycleManifest": { + "schemaPath": "schemas/gamecult.eve.runtime_lifecycle.v1.schema.json", + "manifestPath": "flutter/eve_parity/eveflutter-lifecycle.json" + }, "requiredFixtures": ["embedded-surface"], "pluginFixtures": ["sai-vn"], "supportedFeatures": ["embeddedDocuments"], @@ -1171,6 +1176,10 @@ "repoRole": "runtime-incubating", "splitTarget": "EveFlutter", "graduationTrigger": "Split with EveFlutter when Linux capture and runner health become runtime-owned.", + "lifecycleManifest": { + "schemaPath": "schemas/gamecult.eve.runtime_lifecycle.v1.schema.json", + "manifestPath": "flutter/eve_parity/eveflutter-lifecycle.json" + }, "requiredFixtures": ["embedded-surface"], "pluginFixtures": ["sai-vn"], "supportedFeatures": ["embeddedDocuments"], @@ -1259,6 +1268,10 @@ "repoRole": "runtime-incubating", "splitTarget": "EveFlutter", "graduationTrigger": "Split with EveFlutter when Android APK smoke, provider discovery, command transport, and plugin projection adapters are runtime-owned.", + "lifecycleManifest": { + "schemaPath": "schemas/gamecult.eve.runtime_lifecycle.v1.schema.json", + "manifestPath": "flutter/eve_parity/eveflutter-lifecycle.json" + }, "requiredFixtures": ["embedded-surface"], "pluginFixtures": ["sai-vn"], "supportedFeatures": ["embeddedDocuments"], diff --git a/tools/parity/run-parity.mjs b/tools/parity/run-parity.mjs index 8f1fe18..f00ee54 100644 --- a/tools/parity/run-parity.mjs +++ b/tools/parity/run-parity.mjs @@ -310,6 +310,9 @@ async function evaluateRuntime(runtime, fixtureResults) { const commandTransportSmokeErrors = await validateRuntimeCommandTransportSmoke(runtime); const capabilityManifestErrors = await validateRuntimeCapabilityManifest(runtime); const capabilityManifestDocument = await readRuntimeCapabilityManifestDocument(runtime); + const lifecycleManifestDocument = await readRuntimeLifecycleManifestDocument(runtime); + const lifecycle = runtime.lifecycle || capabilityManifestDocument?.lifecycle || readLifecycleStages(lifecycleManifestDocument); + const lifecycleErrors = await validateRuntimeLifecycle(runtime, lifecycle, lifecycleManifestDocument); const splitHandoffPath = await readRuntimeSplitHandoffPath(runtime); const splitHandoffErrors = await validateRuntimeSplitHandoff(runtime, splitHandoffPath); const localProviderCatalogErrors = await validateRuntimeLocalProviderCatalog(runtime); @@ -323,6 +326,7 @@ async function evaluateRuntime(runtime, fixtureResults) { if (runtime.kind === "active" && missingRequiredFeatures.length) status = "missing-required-feature"; if (runtime.kind === "active" && commandTransportSmokeErrors.length) status = "missing-command-transport-smoke"; if (runtime.kind === "active" && capabilityManifestErrors.length) status = "invalid-runtime-capability"; + if (runtime.kind === "active" && lifecycleErrors.length) status = "invalid-runtime-lifecycle"; if (runtime.kind === "active" && splitHandoffErrors.length) status = "invalid-runtime-split-handoff"; if (runtime.kind === "active" && localProviderCatalogErrors.length) status = "invalid-local-provider-catalog"; if (runtime.kind === "active" && missingIncubationFields.length) status = "missing-incubation-metadata"; @@ -361,11 +365,13 @@ async function evaluateRuntime(runtime, fixtureResults) { commandTransportSmokeErrors, capabilityManifest: runtime.capabilityManifest || null, capabilityManifestErrors, + lifecycleManifest: runtime.lifecycleManifest || null, + lifecycleErrors, splitHandoffPath, splitHandoffErrors, localProviderCatalog: runtime.localProviderCatalog || null, localProviderCatalogErrors, - lifecycle: runtime.lifecycle || capabilityManifestDocument?.lifecycle || null, + lifecycle, ownerRepo: runtime.ownerRepo || "", repoRole: runtime.repoRole || "", graduationTrigger: runtime.graduationTrigger || "", @@ -393,6 +399,82 @@ async function readRuntimeCapabilityManifestDocument(runtime) { } } +async function readRuntimeLifecycleManifestDocument(runtime) { + const lifecyclePath = runtime.lifecycleManifest?.manifestPath; + if (!lifecyclePath) return null; + try { + return await readJsonDocument(lifecyclePath); + } catch { + return null; + } +} + +function readLifecycleStages(lifecycleDocument) { + if (!lifecycleDocument) return null; + return { + release: lifecycleDocument.release, + test: lifecycleDocument.test, + capture: lifecycleDocument.capture, + }; +} + +async function validateRuntimeLifecycle(runtime, lifecycle, lifecycleDocument) { + if (!lifecycle && !runtime.lifecycleManifest) return []; + + const schemaPath = runtime.lifecycleManifest?.schemaPath || manifest.schemas?.["gamecult.eve.runtime_lifecycle.v1"]; + const errors = []; + if (runtime.lifecycleManifest?.manifestPath) { + errors.push(...await validateJsonDocument(schemaPath, runtime.lifecycleManifest.manifestPath, { + schema: "gamecult.eve.runtime_lifecycle.v1", + })); + if (!lifecycleDocument) { + errors.push(`${runtime.lifecycleManifest.manifestPath}:unreadable`); + } else if (!Array.isArray(lifecycleDocument.runtimes) || !lifecycleDocument.runtimes.includes(runtime.id)) { + errors.push(`${runtime.lifecycleManifest.manifestPath}:runtimes:${runtime.id}:missing`); + } + } + + if (!lifecycle) { + errors.push("lifecycle:missing"); + return errors; + } + + const ownerRepo = lifecycleDocument?.ownerRepo + || lifecycle.release?.ownerRepo + || lifecycle.test?.ownerRepo + || lifecycle.capture?.ownerRepo + || runtime.splitTarget + || runtime.ownerRepo + || ""; + const currentHostRepo = lifecycleDocument?.currentHostRepo || runtime.ownerRepo || "Eve"; + const lifecycleEnvelope = { + schema: "gamecult.eve.runtime_lifecycle.v1", + splitTarget: lifecycleDocument?.splitTarget || runtime.splitTarget || runtime.id, + ownerRepo, + currentHostRepo, + status: lifecycleDocument?.status || runtime.status || "", + runtimes: lifecycleDocument?.runtimes || [runtime.id], + release: lifecycle.release, + test: lifecycle.test, + capture: lifecycle.capture, + remainingSplitBlockers: lifecycleDocument?.remainingSplitBlockers || [], + }; + + const schemaErrors = await validateJsonDocumentAgainstSchemaPath(schemaPath, lifecycleEnvelope, `runtimeLifecycle:${runtime.id}`); + errors.push(...schemaErrors); + + for (const stage of ["release", "test", "capture"]) { + const stageDocument = lifecycle[stage]; + for (const evidencePath of stageDocument?.evidencePaths || []) { + if (!existsSync(path.join(repoRoot, evidencePath))) { + errors.push(`lifecycle.${stage}.evidence:${evidencePath}:missing`); + } + } + } + + return errors; +} + async function validateRuntimeLocalProviderCatalog(runtime) { const catalogConfig = runtime.localProviderCatalog; if (!catalogConfig) return []; @@ -1116,6 +1198,23 @@ async function validateJsonDocument(schemaPath, documentPath, expected = {}) { return errors; } +async function validateJsonDocumentAgainstSchemaPath(schemaPath, document, label) { + const errors = []; + if (!schemaPath) return ["schemaPath:missing"]; + + const absoluteSchemaPath = path.join(repoRoot, schemaPath); + if (!existsSync(absoluteSchemaPath)) return [`${schemaPath}:missing`]; + + try { + const schema = JSON.parse(await readFile(absoluteSchemaPath, "utf8")); + errors.push(...validateSchemaSubset(schema, document, label)); + } catch (error) { + errors.push(`${label}:invalid-json:${error instanceof Error ? error.message : String(error)}`); + } + + return errors; +} + function extractProviderSchemaIds(schemas) { return schemas.map(schema => { if (typeof schema === "string") return schema; @@ -1783,15 +1882,18 @@ function buildConformanceExport(report) { requiredFixtures: runtime.requiredFixtures, pluginFixtures: runtime.pluginFixtures, capabilityManifestPath: runtime.capabilityManifest?.manifestPath || "", + lifecycleManifestPath: runtime.lifecycleManifest?.manifestPath || "", commandTransportSchema: runtimeCommandTransportSchema(runtime), captureStatus: runtime.capture?.status || "", - lifecycle: runtime.lifecycle, - missingEvidence: [ + lifecycle: runtime.lifecycle, + lifecycleErrors: runtime.lifecycleErrors || [], + missingEvidence: [ ...runtime.missingPaths, ...runtime.missingRequiredFixtures.map(id => `fixture:${id}`), ...runtime.missingRequiredFeatures.map(id => `feature:${id}`), ...runtime.commandTransportSmokeErrors.map(id => `command-smoke:${id}`), ...runtime.capabilityManifestErrors.map(id => `runtime-capability:${id}`), + ...runtime.lifecycleErrors.map(id => `runtime-lifecycle:${id}`), ...runtime.localProviderCatalogErrors.map(id => `local-provider-catalog:${id}`), ], })) @@ -1874,6 +1976,8 @@ function buildConformanceExport(report) { unsupportedPlugins: runtime.unsupportedPlugins, capabilityManifestPath: runtime.capabilityManifest?.manifestPath || "", capabilityManifestErrors: runtime.capabilityManifestErrors || [], + lifecycleManifestPath: runtime.lifecycleManifest?.manifestPath || "", + lifecycleErrors: runtime.lifecycleErrors || [], splitHandoffPath: runtime.splitHandoffPath || "", splitHandoffErrors: runtime.splitHandoffErrors || [], splitHandoffExportPath: makeHandoffExportPath("runtime", runtime.id, runtime.splitHandoffPath || ""), @@ -2049,6 +2153,7 @@ function collectCapabilityGaps(report) { ...(runtime.missingRequiredFeatures || []).map(id => `feature:${id}`), ...(runtime.commandTransportSmokeErrors || []).map(id => `command-smoke:${id}`), ...(runtime.capabilityManifestErrors || []).map(id => `runtime-capability:${id}`), + ...(runtime.lifecycleErrors || []).map(id => `runtime-lifecycle:${id}`), ...(runtime.splitHandoffErrors || []).map(id => `runtime-split-handoff:${id}`), ...(runtime.localProviderCatalogErrors || []).map(id => `local-provider-catalog:${id}`), ...(runtime.pluginCapabilityGaps || []).map(id => `plugin-capability:${id}`),