From 583acd86c6d34d880d778d238b3592102dfa2013 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 07:13:49 +0000 Subject: [PATCH 1/2] test: harden M3 identity wiring guards --- package.json | 2 +- scripts/dev-m3-hardware-loop-smoke.mjs | 47 ++++++++--- scripts/dev-m3-hardware-loop-smoke.test.mjs | 87 +++++++++++++++++++- scripts/validate-dev-gate-report.mjs | 14 ++++ scripts/validate-dev-m3-cardinality.mjs | 72 ++++++++++------ scripts/validate-dev-m3-cardinality.test.mjs | 79 ++++++++++++++++++ 6 files changed, 259 insertions(+), 42 deletions(-) create mode 100644 scripts/validate-dev-m3-cardinality.test.mjs diff --git a/package.json b/package.json index bbcf5414..e5fab95a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/dev-m3-hardware-loop-smoke.mjs b/scripts/dev-m3-hardware-loop-smoke.mjs index 27f7ba96..adafd196 100644 --- a/scripts/dev-m3-hardware-loop-smoke.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -37,6 +37,7 @@ const m3FailureClassifications = Object.freeze({ targetMissing: "target_missing", identityNotDistinct: "identity_not_distinct", patchPanelWiringMissing: "patch_panel_wiring_missing", + patchPanelRouteOwnerInvalid: "patch_panel_route_owner_invalid", operationFailed: "operation_failed", evidenceMissing: "evidence_missing" }); @@ -380,7 +381,8 @@ function serviceByName(services) { } async function collectReadOnlySupplementalEvidence() { - const [workloads, services] = await Promise.all([ + const [deploy, workloads, services] = await Promise.all([ + readFile(path.join(repoRoot, "deploy/deploy.json"), "utf8").then(JSON.parse), readFile(path.join(repoRoot, deployWorkloadsPath), "utf8").then(JSON.parse), readFile(path.join(repoRoot, deployServicesPath), "utf8").then(JSON.parse) ]); @@ -403,6 +405,7 @@ async function collectReadOnlySupplementalEvidence() { const box = observed.find((item) => item.name === "hwlab-box-simu"); const gateway = observed.find((item) => item.name === "hwlab-gateway-simu"); const patchPanel = observed.find((item) => item.name === "hwlab-patch-panel"); + const patchPanelManifest = (deploy.services ?? []).find((service) => service.serviceId === "hwlab-patch-panel"); const boxIds = splitList(box?.env.HWLAB_BOX_ID); const boxResourceIds = splitList(box?.env.HWLAB_BOX_RESOURCE_ID); const boxGatewaySessionIds = splitList(box?.env.HWLAB_GATEWAY_SESSION_ID); @@ -451,11 +454,11 @@ async function collectReadOnlySupplementalEvidence() { ]); const patchPanelReadyForM3 = patchPanel?.replicas === 1 && - patchPanel?.m3Route?.fromResourceId === requiredM3Connection.fromResourceId && - patchPanel?.m3Route?.fromPort === requiredM3Connection.fromPort && - patchPanel?.m3Route?.patchPanelServiceId === "hwlab-patch-panel" && - patchPanel?.m3Route?.toResourceId === requiredM3Connection.toResourceId && - patchPanel?.m3Route?.toPort === requiredM3Connection.toPort && + patchPanelManifest?.m3Route?.fromResourceId === requiredM3Connection.fromResourceId && + patchPanelManifest?.m3Route?.fromPort === requiredM3Connection.fromPort && + patchPanelManifest?.m3Route?.patchPanelServiceId === "hwlab-patch-panel" && + patchPanelManifest?.m3Route?.toResourceId === requiredM3Connection.toResourceId && + patchPanelManifest?.m3Route?.toPort === requiredM3Connection.toPort && patchPanelEndpointMap.res_boxsimu_2 === "http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201" && patchPanelHasManifestRoute; const manifestReadyForM3 = boxReadyForM3 && gatewayReadyForM3 && patchPanelReadyForM3 && hasInstanceServices; @@ -465,7 +468,7 @@ async function collectReadOnlySupplementalEvidence() { id: "deploy-skeleton-m3-cardinality", status: manifestReadyForM3 ? "manifest-ready" : "gap", blockerClass: manifestReadyForM3 ? null : "runtime_blocker", - source: [deployWorkloadsPath, deployServicesPath], + source: ["deploy/deploy.json", deployWorkloadsPath, deployServicesPath], summary: manifestReadyForM3 ? "Checked-in DEV deploy skeleton declares distinct indexed M3 identities for two box simulators, two gateway simulators, one patch panel, and the M3 DO1 -> DI1 endpoint map." : "Read-only static comparison found the checked-in DEV deploy skeleton does not declare distinct indexed M3 simulator identities and patch-panel M3 wiring; proving live DEV still requires a separate deploy/runtime task or authorized runtime observation.", @@ -487,6 +490,7 @@ async function collectReadOnlySupplementalEvidence() { }, "hwlab-patch-panel": { replicas: patchPanel?.replicas ?? null, + firstClassRoute: patchPanelManifest?.m3Route ?? null, env: patchPanel?.env ?? null, firstClassWiringConfig: patchPanelWiringConfig }, @@ -991,6 +995,12 @@ function formatConnection(connection) { function hasRequiredM3Connection({ status, wiring }) { const activeConnections = status?.activeConnections ?? []; const wiringConnections = wiring?.connections ?? []; + const owner = Boolean( + status?.serviceId === "hwlab-patch-panel" && + status?.metadata?.propagation === "patch-panel-only" && + wiring?.constraints?.propagation === "patch-panel-only" && + wiring?.constraints?.localLoopbackSubstituteAllowed === false + ); const active = activeConnections.find( (connection) => connection.fromResourceId === requiredM3Connection.fromResourceId && @@ -1006,7 +1016,7 @@ function hasRequiredM3Connection({ status, wiring }) { connection.to?.port === requiredM3Connection.toPort && connection.mode === "exclusive" ); - return { active, configured }; + return { active, configured, owner }; } async function probeDirectCandidate(candidate) { @@ -1105,16 +1115,21 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery }); } - if (patch && (!m3Connection.active || !m3Connection.configured)) { + if (patch && (!m3Connection.active || !m3Connection.configured || !m3Connection.owner)) { const observedActive = (patch.status.json?.activeConnections ?? []).map(formatConnection).join(", ") || "none"; const observedConfigured = (patchWiring?.json?.connections ?? []).map(formatConnection).join(", ") || "none"; + const ownerSummary = `serviceId=${patch.status.json?.serviceId ?? "unknown"} propagation=${patch.status.json?.metadata?.propagation ?? "unknown"} wiringPropagation=${patchWiring?.json?.constraints?.propagation ?? "unknown"} loopbackAllowed=${String(patchWiring?.json?.constraints?.localLoopbackSubstituteAllowed ?? "unknown")}`; blockers.push({ type: "runtime_blocker", scope: "m3-patch-panel-wiring", status: "open", - classification: classifyDirectTargetBlockerScope("m3-patch-panel-wiring"), + classification: m3Connection.owner + ? classifyDirectTargetBlockerScope("m3-patch-panel-wiring") + : m3FailureClassifications.patchPanelRouteOwnerInvalid, sourceIssue: "pikasTech/HWLAB#64", - summary: `DEV patch-panel is callable, but live wiring does not contain ${requiredM3Connection.fromResourceId}:${requiredM3Connection.fromPort} -> ${requiredM3Connection.toResourceId}:${requiredM3Connection.toPort}; active=${observedActive}; configured=${observedConfigured}.` + summary: m3Connection.owner + ? `DEV patch-panel is callable, but live wiring does not contain ${requiredM3Connection.fromResourceId}:${requiredM3Connection.fromPort} -> ${requiredM3Connection.toResourceId}:${requiredM3Connection.toPort}; active=${observedActive}; configured=${observedConfigured}.` + : `DEV patch-panel route owner is not the required hwlab-patch-panel patch-panel-only owner; ${ownerSummary}; active=${observedActive}; configured=${observedConfigured}.` }); } @@ -1125,7 +1140,8 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery selectedGateways.length >= 2 && patch && m3Connection.active && - m3Connection.configured; + m3Connection.configured && + m3Connection.owner; const targets = new Map(); if (ok) { @@ -1163,6 +1179,13 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery configuredObserved: (patchWiring?.json?.connections ?? []).map(formatConnection), hasRequiredActiveConnection: Boolean(m3Connection.active), hasRequiredConfiguredConnection: Boolean(m3Connection.configured), + hasRequiredRouteOwner: Boolean(m3Connection.owner), + routeOwnerObserved: { + serviceId: patch.status.json?.serviceId ?? null, + propagation: patch.status.json?.metadata?.propagation ?? null, + wiringPropagation: patchWiring?.json?.constraints?.propagation ?? null, + localLoopbackSubstituteAllowed: patchWiring?.json?.constraints?.localLoopbackSubstituteAllowed ?? null + }, configSource: patch.status.json?.metadata?.configSource ?? "unknown", syncableConnectionCount: patch.status.json?.metadata?.syncableConnectionCount ?? null } diff --git a/scripts/dev-m3-hardware-loop-smoke.test.mjs b/scripts/dev-m3-hardware-loop-smoke.test.mjs index 27e07c22..133cf2db 100644 --- a/scripts/dev-m3-hardware-loop-smoke.test.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdir, readFile, rm } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -68,6 +68,44 @@ test("--dry-run aliases the same source/read-only no-write path", async () => { } }); +test("M3 report validator rejects DRY-RUN evidence promoted to live pass", async () => { + await mkdir(path.join(repoRoot, testOutputDir), { recursive: true }); + const output = path.join(testOutputDir, "dry-run-promoted-live.json"); + const promoted = path.join(testOutputDir, "dry-run-promoted-live-tampered.json"); + try { + runSmoke(["--dry-run", "--output", output]); + const report = JSON.parse(await readFile(path.join(repoRoot, output), "utf8")); + report.liveOperation = { + status: "pass", + operationId: "op_fake_dry_run", + traceId: "trc_fake_dry_run", + auditId: "aud_fake_dry_run", + evidenceId: "evd_fake_dry_run", + summary: "Incorrectly promoted DRY-RUN output to DEV-LIVE." + }; + await writeFile(path.join(repoRoot, promoted), JSON.stringify(report, null, 2)); + + assert.throws( + () => + execFileSync("node", ["scripts/validate-dev-gate-report.mjs", promoted], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }), + (error) => { + assert.match( + `${error.message}\n${error.stderr ?? ""}`, + /liveOperation\.status cannot be pass when only a DRY-RUN plan is present/u + ); + return true; + } + ); + } finally { + await rm(path.join(repoRoot, output), { force: true }); + await rm(path.join(repoRoot, promoted), { force: true }); + } +}); + test("M3 safety gates keep incomplete live commands source/read-only", () => { assert.deepEqual(requireSafetyGates(parseArgs([])), { mode: "dry-run", @@ -103,12 +141,17 @@ test("M3 blocker scopes classify missing identities and patch-panel route gaps", assert.equal(plan.liveWriteWillRun, false); assert.ok(plan.failureClassifications.includes("identity_not_distinct")); assert.ok(plan.failureClassifications.includes("patch_panel_wiring_missing")); + assert.ok(plan.failureClassifications.includes("patch_panel_route_owner_invalid")); assert.match(plan.refusalPolicy.join("\n"), /source\/read-only/u); }); -test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsimu_2 DI1 wiring", () => { +test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsimu_2 DI1 wiring or wrong owner", () => { const wrongRoute = hasRequiredM3Connection({ status: { + serviceId: "hwlab-patch-panel", + metadata: { + propagation: "patch-panel-only" + }, activeConnections: [ { fromResourceId: "res_boxsimu_1", @@ -119,6 +162,10 @@ test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsi ] }, wiring: { + constraints: { + propagation: "patch-panel-only", + localLoopbackSubstituteAllowed: false + }, connections: [ { from: { resourceId: "res_boxsimu_1", port: "DO1" }, @@ -130,9 +177,14 @@ test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsi }); assert.equal(Boolean(wrongRoute.active), false); assert.equal(Boolean(wrongRoute.configured), false); + assert.equal(Boolean(wrongRoute.owner), true); const requiredRoute = hasRequiredM3Connection({ status: { + serviceId: "hwlab-patch-panel", + metadata: { + propagation: "patch-panel-only" + }, activeConnections: [ { fromResourceId: "res_boxsimu_1", @@ -143,6 +195,10 @@ test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsi ] }, wiring: { + constraints: { + propagation: "patch-panel-only", + localLoopbackSubstituteAllowed: false + }, connections: [ { from: { resourceId: "res_boxsimu_1", port: "DO1" }, @@ -154,6 +210,33 @@ test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsi }); assert.equal(Boolean(requiredRoute.active), true); assert.equal(Boolean(requiredRoute.configured), true); + assert.equal(Boolean(requiredRoute.owner), true); + + const wrongOwner = hasRequiredM3Connection({ + status: { + serviceId: "hwlab-box-simu", + metadata: { + propagation: "box-loopback" + }, + activeConnections: requiredRoute.active ? [requiredRoute.active] : [] + }, + wiring: { + constraints: { + propagation: "patch-panel-only", + localLoopbackSubstituteAllowed: false + }, + connections: [ + { + from: { resourceId: "res_boxsimu_1", port: "DO1" }, + to: { resourceId: "res_boxsimu_2", port: "DI1" }, + mode: "exclusive" + } + ] + } + }); + assert.equal(Boolean(wrongOwner.active), true); + assert.equal(Boolean(wrongOwner.configured), true); + assert.equal(Boolean(wrongOwner.owner), false); }); test("Kubernetes discovery preserves current indexed M3 service identities", () => { diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index e65dd943..9e57d10c 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -2644,6 +2644,20 @@ async function validateDevM3Report(report, label) { for (const field of ["operationId", "traceId", "auditId", "evidenceId", "summary"]) { assertString(report.liveOperation[field], `${label}.liveOperation.${field}`); } + if (Object.hasOwn(report, "dryRunPlan")) { + assert.notEqual( + report.liveOperation.status, + "pass", + `${label}.liveOperation.status cannot be pass when only a DRY-RUN plan is present` + ); + for (const field of ["operationId", "traceId", "auditId", "evidenceId"]) { + assert.equal( + report.liveOperation[field], + "not_observed", + `${label}.liveOperation.${field} must remain not_observed for DRY-RUN evidence` + ); + } + } assertArray(report.blockers, `${label}.blockers`); assertUnique( diff --git a/scripts/validate-dev-m3-cardinality.mjs b/scripts/validate-dev-m3-cardinality.mjs index 6f670314..16e6959d 100644 --- a/scripts/validate-dev-m3-cardinality.mjs +++ b/scripts/validate-dev-m3-cardinality.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const namespace = "hwlab-dev"; @@ -301,32 +301,50 @@ function assertM3RouteConfig(value, label) { assert.equal(value.constraints.localLoopbackSubstituteAllowed, false, `${label} loopback substitute`); } -const [deploy, workloads, services, topology] = await Promise.all([ - readJson("deploy/deploy.json"), - readJson("deploy/k8s/base/workloads.yaml"), - readJson("deploy/k8s/base/services.yaml"), - readJson("fixtures/mvp/m3-hardware-loop/topology.json") -]); +export async function readDevM3CardinalityInputs() { + const [deploy, workloads, services, topology] = await Promise.all([ + readJson("deploy/deploy.json"), + readJson("deploy/k8s/base/workloads.yaml"), + readJson("deploy/k8s/base/services.yaml"), + readJson("fixtures/mvp/m3-hardware-loop/topology.json") + ]); -const patchPanelWorkloadEnv = envMap( - workloadByName(workloads) - .get("hwlab-patch-panel") - ?.spec?.template?.spec?.containers?.find((item) => item.name === "hwlab-patch-panel") - ?.env -); + return { deploy, workloads, services, topology }; +} -assertDeployManifestCardinality(deploy); -assertDeployManifestInstanceMappings(deploy); -assertDevK8sCardinality(workloads); -assertDevK8sInstanceServices(services); -assertM3FixtureWiring(topology); -assertM3RouteConfig( - parseEnvValue(patchPanelWorkloadEnv.HWLAB_PATCH_PANEL_WIRING_CONFIG), - "DEV workload first-class M3 patch-panel wiring config" -); -assertM3RouteConfig( - parseEnvValue(deploy.services.find((service) => service.serviceId === "hwlab-patch-panel")?.env?.HWLAB_PATCH_PANEL_WIRING_CONFIG), - "deploy manifest first-class M3 patch-panel wiring config" -); +export function validateDevM3Cardinality({ deploy, workloads, services, topology }) { + const patchPanelWorkloadEnv = envMap( + workloadByName(workloads) + .get("hwlab-patch-panel") + ?.spec?.template?.spec?.containers?.find((item) => item.name === "hwlab-patch-panel") + ?.env + ); -console.log("validated DEV M3 manifest cardinality: 2 distinct box-simu, 2 distinct gateway-simu, 1 patch-panel, M3 DO1->DI1 wiring"); + assertDeployManifestCardinality(deploy); + assertDeployManifestInstanceMappings(deploy); + assertDevK8sCardinality(workloads); + assertDevK8sInstanceServices(services); + assertM3FixtureWiring(topology); + assertM3RouteConfig( + parseEnvValue(patchPanelWorkloadEnv.HWLAB_PATCH_PANEL_WIRING_CONFIG), + "DEV workload first-class M3 patch-panel wiring config" + ); + assertM3RouteConfig( + parseEnvValue(deploy.services.find((service) => service.serviceId === "hwlab-patch-panel")?.env?.HWLAB_PATCH_PANEL_WIRING_CONFIG), + "deploy manifest first-class M3 patch-panel wiring config" + ); +} + +export async function main() { + validateDevM3Cardinality(await readDevM3CardinalityInputs()); + console.log("validated DEV M3 manifest cardinality: 2 distinct box-simu, 2 distinct gateway-simu, 1 patch-panel, M3 DO1->DI1 wiring"); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/validate-dev-m3-cardinality.test.mjs b/scripts/validate-dev-m3-cardinality.test.mjs new file mode 100644 index 00000000..00cae084 --- /dev/null +++ b/scripts/validate-dev-m3-cardinality.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + readDevM3CardinalityInputs, + validateDevM3Cardinality +} from "./validate-dev-m3-cardinality.mjs"; + +const baseline = await readDevM3CardinalityInputs(); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function cloneInputs() { + return clone(baseline); +} + +function deployService(inputs, serviceId) { + return inputs.deploy.services.find((service) => service.serviceId === serviceId); +} + +function workloadContainer(inputs, workloadName) { + return inputs.workloads.items + .find((item) => item.metadata?.name === workloadName) + ?.spec?.template?.spec?.containers?.find((container) => container.name === workloadName); +} + +function setWorkloadEnv(inputs, workloadName, envName, value) { + const entry = workloadContainer(inputs, workloadName)?.env?.find((item) => item.name === envName); + assert.ok(entry, `${workloadName} ${envName} exists`); + entry.value = value; +} + +function assertRejected(inputs, pattern) { + assert.throws(() => validateDevM3Cardinality(inputs), pattern); +} + +test("accepts the checked-in DEV M3 minimum topology", () => { + validateDevM3Cardinality(cloneInputs()); +}); + +test("rejects duplicate box-simu resource identity in deploy source", () => { + const inputs = cloneInputs(); + deployService(inputs, "hwlab-box-simu").env.HWLAB_BOX_RESOURCE_ID = "res_boxsimu_1,res_boxsimu_1"; + + assertRejected(inputs, /hwlab-box-simu deploy HWLAB_BOX_RESOURCE_ID/u); +}); + +test("rejects duplicate gateway-simu identity in Kubernetes source", () => { + const inputs = cloneInputs(); + setWorkloadEnv(inputs, "hwlab-gateway-simu", "HWLAB_GATEWAY_ID", "gwsimu_1,gwsimu_1"); + + assertRejected(inputs, /hwlab-gateway-simu HWLAB_GATEWAY_ID/u); +}); + +test("rejects duplicate gateway session assignment for box-simu resources", () => { + const inputs = cloneInputs(); + deployService(inputs, "hwlab-box-simu").env.HWLAB_GATEWAY_SESSION_ID = "gws_gwsimu_1,gws_gwsimu_1"; + + assertRejected(inputs, /hwlab-box-simu deploy HWLAB_GATEWAY_SESSION_ID/u); +}); + +test("rejects missing M3 patch-panel route", () => { + const inputs = cloneInputs(); + const patchPanel = deployService(inputs, "hwlab-patch-panel"); + const wiringConfig = JSON.parse(patchPanel.env.HWLAB_PATCH_PANEL_WIRING_CONFIG); + wiringConfig.connections = []; + patchPanel.env.HWLAB_PATCH_PANEL_WIRING_CONFIG = JSON.stringify(wiringConfig); + + assertRejected(inputs, /hwlab-patch-panel deploy HWLAB_PATCH_PANEL_WIRING_CONFIG/u); +}); + +test("rejects wrong first-class route owner", () => { + const inputs = cloneInputs(); + deployService(inputs, "hwlab-patch-panel").m3Route.patchPanelServiceId = "hwlab-box-simu"; + + assertRejected(inputs, /hwlab-patch-panel deploy first-class M3 route/u); +}); From 1405fd1a2ee71af106c0d38be30134a9392f405b Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 07:18:50 +0000 Subject: [PATCH 2/2] feat: add M3 cloud-api IO RPC contract --- internal/cloud/json-rpc.mjs | 5 + internal/cloud/m3-io-control.mjs | 174 +++++++++++++++++++++ internal/cloud/m3-io-control.test.mjs | 213 ++++++++++++++++++++++++++ internal/cloud/server.mjs | 6 +- internal/cloud/server.test.mjs | 2 + scripts/src/m3-io-control-e2e.mjs | 22 ++- 6 files changed, 419 insertions(+), 3 deletions(-) diff --git a/internal/cloud/json-rpc.mjs b/internal/cloud/json-rpc.mjs index 2ac26324..3559d4a9 100644 --- a/internal/cloud/json-rpc.mjs +++ b/internal/cloud/json-rpc.mjs @@ -18,10 +18,13 @@ import { buildDbRuntimeReadiness } from "./db-contract.mjs"; import { describeCodeAgentAvailability } from "./code-agent-chat.mjs"; import { buildCloudApiReadiness } from "./health-contract.mjs"; import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; +import { M3_IO_RPC_METHODS, handleM3IoRpc } from "./m3-io-control.mjs"; export const SUPPORTED_RPC_METHODS = Object.freeze([ "system.health", "cloud.adapter.describe", + M3_IO_RPC_METHODS.doWrite, + M3_IO_RPC_METHODS.diRead, "gateway.session.register", "box.resource.register", "box.capability.report", @@ -36,6 +39,8 @@ export const SUPPORTED_RPC_METHODS = Object.freeze([ const rpcHandlers = new Map([ ["system.health", handleSystemHealth], ["cloud.adapter.describe", handleAdapterDescribe], + [M3_IO_RPC_METHODS.doWrite, async (params, envelope, context) => handleM3IoRpc("do.write", params, envelope, context)], + [M3_IO_RPC_METHODS.diRead, async (params, envelope, context) => handleM3IoRpc("di.read", params, envelope, context)], ["gateway.session.register", async (params, envelope, context) => getRuntimeStore(context).registerGatewaySession(params, envelope.meta)], ["box.resource.register", async (params, envelope, context) => getRuntimeStore(context).registerBoxResource(params, envelope.meta)], ["box.capability.report", async (params, envelope, context) => getRuntimeStore(context).reportBoxCapabilities(params, envelope.meta)], diff --git a/internal/cloud/m3-io-control.mjs b/internal/cloud/m3-io-control.mjs index 8ad97d6a..751c851c 100644 --- a/internal/cloud/m3-io-control.mjs +++ b/internal/cloud/m3-io-control.mjs @@ -13,6 +13,10 @@ import { export const M3_IO_CONTROL_CONTRACT_VERSION = "m3-io-control-v1"; export const M3_IO_CONTROL_ROUTE = "/v1/m3/io"; +export const M3_IO_RPC_METHODS = Object.freeze({ + doWrite: "m3.io.do.write", + diRead: "m3.io.di.read" +}); export const M3_IO_CHAIN = Object.freeze({ projectId: "prj_m3_hardware_loop", @@ -235,6 +239,25 @@ export async function handleM3IoControl(params = {}, context = {}) { })); } +export async function handleM3IoRpc(action, params = {}, envelope = {}, context = {}) { + return handleM3IoControl( + { + ...params, + action, + traceId: envelope.meta?.traceId ?? params.traceId, + requestId: envelope.id ?? params.requestId, + actorId: envelope.meta?.actorId ?? params.actorId + }, + { + ...context, + requestJson: context.requestJson ?? context.m3IoRequestJson, + traceId: envelope.meta?.traceId ?? context.traceId, + requestId: envelope.id ?? context.requestId, + actorId: envelope.meta?.actorId ?? context.actorId + } + ); +} + export function buildM3IoConfig(env = process.env) { const enabledValue = String(env.HWLAB_M3_IO_CONTROL_ENABLED ?? env.HWLAB_M3_CONTROL_ENABLED ?? "true").toLowerCase(); return { @@ -927,6 +950,7 @@ async function successControlResult(input) { outcome: "succeeded", reason: null }); + const target = controlTarget(input.command, input.operation.gatewaySessionId); return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, @@ -935,10 +959,22 @@ async function successControlResult(input) { action: input.action, chain: M3_IO_CHAIN, command: input.command, + target, + gatewayId: target.gatewayId, + gatewaySessionId: target.gatewaySessionId, + resourceId: target.resourceId, + boxId: target.boxId, + port: target.port, + value: target.value, operationId: input.operation.operationId, traceId: input.meta.traceId, auditId: records.auditEvent.auditId, evidenceId: records.evidenceRecord.evidenceId, + operationState: { + status: "succeeded", + reachable: true + }, + auditState: auditState(records.auditWrite, records.runtimeAfter), operation: { ...input.operation, status: "succeeded", @@ -964,6 +1000,12 @@ async function successControlResult(input) { auditEvent: records.auditEvent, evidenceRecord: records.evidenceRecord, evidenceState: evidenceState(records.runtimeAfter, records), + durableStatus: durableStatus(records.runtimeAfter), + blockerClassification: redactedBlockerClassification({ + code: records.runtimeAfter?.blocker, + reason: records.runtimeAfter?.reason, + runtime: records.runtimeAfter + }), persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records), observedAt: input.now }; @@ -975,6 +1017,7 @@ async function blockedControlResult(input) { outcome: "failed", reason: input.reason }); + const target = controlTarget(input.command, input.operation.gatewaySessionId); return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, @@ -983,6 +1026,13 @@ async function blockedControlResult(input) { action: input.action, chain: M3_IO_CHAIN, command: input.command, + target, + gatewayId: target.gatewayId, + gatewaySessionId: target.gatewaySessionId, + resourceId: target.resourceId, + boxId: target.boxId, + port: target.port, + value: target.value, operationId: input.operation.operationId, traceId: input.meta.traceId, auditId: records.auditEvent.auditId, @@ -992,6 +1042,17 @@ async function blockedControlResult(input) { message: input.reason, zh: input.reason }, + blockerClassification: redactedBlockerClassification({ + code: input.code, + reason: input.reason, + runtime: records.runtimeAfter + }), + operationState: { + status: "blocked", + reachable: false, + blocker: input.code + }, + auditState: auditState(records.auditWrite, records.runtimeAfter), operation: { ...input.operation, status: "failed", @@ -1018,12 +1079,14 @@ async function blockedControlResult(input) { auditEvent: records.auditEvent, evidenceRecord: records.evidenceRecord, evidenceState: evidenceState(records.runtimeAfter, records), + durableStatus: durableStatus(records.runtimeAfter), persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records), observedAt: input.now }; } function blockedResult({ action, command = null, meta, runtime, code, reason, httpStatus, now, details = {} }) { + const target = controlTarget(command, command?.gatewaySessionId); return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, @@ -1032,6 +1095,13 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht action: action || "unknown", chain: M3_IO_CHAIN, command, + target, + gatewayId: target.gatewayId, + gatewaySessionId: target.gatewaySessionId, + resourceId: target.resourceId, + boxId: target.boxId, + port: target.port, + value: target.value, operationId: null, traceId: meta.traceId, auditId: null, @@ -1041,6 +1111,21 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht message: reason, zh: reason }, + blockerClassification: redactedBlockerClassification({ + code, + reason, + runtime + }), + operationState: { + status: "blocked", + reachable: false, + blocker: code + }, + auditState: { + status: "not_written", + durableStatus: durableStatus(runtime), + reason: "operation was blocked before audit write" + }, controlPath: { status: "blocked", cloudApi: true, @@ -1053,6 +1138,7 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht auditWrite: { written: false, reason: "operation was blocked before audit write" }, evidenceWrite: { written: false, reason: "operation was blocked before evidence write" } }), + durableStatus: durableStatus(runtime), persistence: { runtime, writes: [] @@ -1166,11 +1252,14 @@ function createEvidenceRecord({ evidenceId, projectId, operationId, traceId, pay function evidenceState(runtime, records) { const durableReady = isDurableRuntimeReady(runtime); const recordsWritten = records.auditWrite?.written === true && records.evidenceWrite?.written === true; + const evidenceWriteState = recordWriteState(records.evidenceWrite, runtime); if (durableReady && recordsWritten) { return { status: "green", sourceKind: "DEV-LIVE", durable: true, + writeStatus: evidenceWriteState.status, + durableStatus: durableStatus(runtime), reason: "operation/audit/evidence 已通过 durable runtime 写入;仍需 DEV 浏览器 E2E 才能宣称验收通过。" }; } @@ -1180,10 +1269,95 @@ function evidenceState(runtime, records) { sourceKind: "BLOCKED", durable: runtime?.durable === true, blocker, + writeStatus: evidenceWriteState.status, + durableStatus: durableStatus(runtime), reason: `操作链路可达性与可信持久化分离:runtime durable 未 green(status=${runtime?.status ?? "unknown"},blocker=${blocker}),不能宣称可信闭环完全 green。` }; } +function auditState(auditWrite, runtime) { + return { + ...recordWriteState(auditWrite, runtime), + durableStatus: durableStatus(runtime) + }; +} + +function recordWriteState(write, runtime) { + if (write?.written === true && isDurableRuntimeReady(runtime)) { + return { + status: "persisted", + durable: true, + sourceKind: "DEV-LIVE" + }; + } + if (write?.written === true) { + return { + status: "written_non_durable", + durable: false, + sourceKind: "BLOCKED", + blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked + }; + } + return { + status: "not_written", + durable: false, + sourceKind: "BLOCKED", + blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked, + reason: write?.reason ?? "runtime write did not complete" + }; +} + +function durableStatus(runtime) { + return { + status: runtime?.status ?? "unknown", + durable: runtime?.durable === true, + ready: runtime?.ready === true, + liveRuntimeEvidence: runtime?.liveRuntimeEvidence === true, + blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked, + redacted: true + }; +} + +function redactedBlockerClassification({ code, reason, runtime } = {}) { + const blockerCode = code ?? runtime?.blocker ?? null; + if (!blockerCode) { + return null; + } + return { + code: blockerCode, + category: blockerCategory(blockerCode), + runtimeStatus: runtime?.status ?? "unknown", + redacted: true, + message: reason ? redactBlockerMessage(reason) : null + }; +} + +function blockerCategory(code) { + if (String(code).startsWith("runtime_durable")) return "runtime_durable"; + if (String(code).includes("gateway")) return "gateway_session"; + if (String(code).includes("wiring")) return "patch_panel_wiring"; + if (String(code).includes("box")) return "box_resource"; + if (String(code).includes("port")) return "port_direction"; + return "m3_io_control"; +} + +function redactBlockerMessage(message) { + return String(message) + .replace(/https?:\/\/[^\s,,;]+/giu, "[redacted-url]") + .replace(/postgres(?:ql)?:\/\/[^\s,,;]+/giu, "[redacted-db-url]"); +} + +function controlTarget(command, gatewaySessionId) { + return { + gatewayId: command?.gatewayId ?? null, + gatewaySessionId: gatewaySessionId ?? command?.gatewaySessionId ?? null, + resourceId: command?.resourceId ?? null, + boxId: command?.boxId ?? null, + port: command?.port ?? null, + value: Object.hasOwn(command ?? {}, "value") ? command.value : null + }; +} + function persistenceSummary(runtime, registration, operationWrite, records) { return { runtime, diff --git a/internal/cloud/m3-io-control.test.mjs b/internal/cloud/m3-io-control.test.mjs index 5202f901..4f785733 100644 --- a/internal/cloud/m3-io-control.test.mjs +++ b/internal/cloud/m3-io-control.test.mjs @@ -4,9 +4,12 @@ import test from "node:test"; import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; import { createBoxState, createGatewayState } from "../sim/model.mjs"; import { writeBoxPort } from "../sim/l2-runtime.mjs"; +import { validateResponse } from "../protocol/index.mjs"; +import { handleJsonRpcRequest, SUPPORTED_RPC_METHODS } from "./json-rpc.mjs"; import { M3_IO_BLOCKER_CODES, M3_IO_CHAIN, + M3_IO_RPC_METHODS, describeM3IoControl, handleM3IoControl } from "./m3-io-control.mjs"; @@ -65,10 +68,31 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, assert.equal(result.action, "do.write"); assert.equal(result.command.port, "DO1"); assert.equal(result.command.value, true); + assert.equal(result.gatewayId, "gwsimu_1"); + assert.equal(result.gatewaySessionId, "gws_gwsimu_1"); + assert.equal(result.resourceId, "res_boxsimu_1"); + assert.equal(result.boxId, "boxsimu_1"); + assert.equal(result.port, "DO1"); + assert.equal(result.value, true); + assert.deepEqual(result.target, { + gatewayId: "gwsimu_1", + gatewaySessionId: "gws_gwsimu_1", + resourceId: "res_boxsimu_1", + boxId: "boxsimu_1", + port: "DO1", + value: true + }); assert.match(result.operationId, /^op_m3_do_write_/u); assert.equal(result.traceId, "trc_m3_control_write_true"); assert.match(result.auditId, /^aud_m3_do_write_/u); assert.match(result.evidenceId, /^evd_m3_do_write_/u); + assert.equal(result.operationState.status, "succeeded"); + assert.equal(result.auditState.status, "written_non_durable"); + assert.equal(result.auditState.durableStatus.durable, false); + assert.equal(result.durableStatus.status, "degraded"); + assert.equal(result.durableStatus.redacted, true); + assert.equal(result.blockerClassification.category, "runtime_durable"); + assert.equal(result.blockerClassification.redacted, true); assert.equal(result.controlPath.cloudApi, true); assert.equal(result.controlPath.gatewaySimu, true); assert.equal(result.controlPath.boxSimu, true); @@ -78,6 +102,7 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, assert.equal(result.result.targetReadback.value, true); assert.equal(result.evidenceState.status, "blocked"); assert.equal(result.evidenceState.sourceKind, "BLOCKED"); + assert.equal(result.evidenceState.writeStatus, "written_non_durable"); assert.match(result.evidenceState.reason, /runtime durable 未 green/u); assert.equal(fixture.box1.ports.DO1.value, true); assert.equal(fixture.box2.ports.DI1.value, true); @@ -89,6 +114,15 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, "/invoke" ]); assert.equal(fixture.calls.every((call) => call.url.startsWith("http://gateway-") || call.url.startsWith("http://patch-panel")), true); + assert.equal(fixture.calls[1].body.operation, "hardware.port.write"); + assert.equal(fixture.calls[1].body.gatewaySessionId, "gws_gwsimu_1"); + assert.equal(fixture.calls[1].body.resourceId, "res_boxsimu_1"); + assert.equal(fixture.calls[1].body.port, "DO1"); + assert.equal(fixture.calls[1].body.value, true); + assert.equal(fixture.calls[2].body.signals[0].fromResourceId, "res_boxsimu_1"); + assert.equal(fixture.calls[4].body.operation, "hardware.port.read"); + assert.equal(fixture.calls[4].body.resourceId, "res_boxsimu_2"); + assert.equal(fixture.calls[4].body.port, "DI1"); const audit = runtimeStore.queryAuditEvents({ projectId: M3_IO_CHAIN.projectId }); assert.ok(audit.events.some((event) => event.action === "m3.io.do.write")); @@ -133,6 +167,9 @@ test("M3 DO write can prove control reachability while durable trusted persisten assert.equal(result.evidenceState.sourceKind, "BLOCKED"); assert.equal(result.evidenceState.durable, false); assert.equal(result.evidenceState.blocker, "runtime_durable_adapter_query_blocked"); + assert.equal(result.durableStatus.blocker, "runtime_durable_adapter_query_blocked"); + assert.equal(result.blockerClassification.code, "runtime_durable_adapter_query_blocked"); + assert.equal(result.blockerClassification.category, "runtime_durable"); assert.equal(result.persistence.runtime.status, "blocked"); assert.equal(result.persistence.runtime.liveRuntimeEvidence, false); assert.equal(result.persistence.writes.every((write) => write.written !== true), true); @@ -175,6 +212,14 @@ test("M3 DI read returns structured value through gateway-simu without patch-pan assert.equal(result.accepted, true); assert.equal(result.action, "di.read"); assert.equal(result.command.port, "DI1"); + assert.equal(result.gatewayId, "gwsimu_2"); + assert.equal(result.gatewaySessionId, "gws_gwsimu_2"); + assert.equal(result.resourceId, "res_boxsimu_2"); + assert.equal(result.boxId, "boxsimu_2"); + assert.equal(result.port, "DI1"); + assert.equal(result.value, null); + assert.equal(result.operationState.status, "succeeded"); + assert.equal(result.auditState.status, "written_non_durable"); assert.equal(result.result.value, true); assert.equal(result.controlPath.patchPanel, false); assert.deepEqual(fixture.calls.map((call) => call.path), ["/status", "/invoke"]); @@ -212,6 +257,9 @@ test("M3 IO control blocks with Chinese reason when gateway session is unavailab assert.equal(result.status, "blocked"); assert.equal(result.accepted, false); assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayUnavailable); + assert.equal(result.blockerClassification.category, "gateway_session"); + assert.equal(result.auditState.status, "not_written"); + assert.equal(result.durableStatus.redacted, true); assert.match(result.blocker.zh, /gateway 未注册\/不可用/u); assert.equal(result.controlPath.cloudApi, true); assert.equal(result.controlPath.frontendBypass, false); @@ -245,6 +293,7 @@ test("M3 IO control blocks invalid port direction instead of allowing generic wr assert.equal(result.status, "blocked"); assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.portDirectionInvalid); + assert.equal(result.blockerClassification.category, "port_direction"); assert.match(result.blocker.zh, /端口方向|资源越界/u); }); @@ -354,9 +403,173 @@ test("M3 IO control blocks when patch-panel wiring does not deliver DO1 to DI1", assert.equal(result.status, "blocked"); assert.equal(result.accepted, false); assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing); + assert.equal(result.blockerClassification.category, "patch_panel_wiring"); assert.match(result.blocker.zh, /wiring 不存在|未路由/u); }); +test("M3 IO control blocks when patch-panel reports missing target box", async () => { + const fixture = createM3ControlFixture({ + patchPanelBody: { + accepted: false, + diagnostics: [ + { + code: "target_endpoint_missing", + message: "target endpoint missing for res_boxsimu_2" + } + ], + routes: [] + } + }); + + const result = await handleM3IoControl( + { + action: "do.write", + value: true, + traceId: "trc_m3_target_box_missing", + requestId: "req_m3_target_box_missing", + actorId: "usr_m3_operator" + }, + { + runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }), + now: () => fixedNow, + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + requestJson: fixture.requestJson + } + ); + + assert.equal(result.status, "blocked"); + assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.boxUnavailable); + assert.equal(result.blockerClassification.category, "box_resource"); + assert.match(result.blocker.zh, /target endpoint missing/u); + assert.equal(result.controlPath.cloudApi, true); + assert.equal(result.controlPath.frontendBypass, false); +}); + +test("M3 IO JSON-RPC methods route do.write and di.read through the cloud-api handler", async () => { + const fixture = createM3ControlFixture(); + const runtimeStore = createCloudRuntimeStore({ + now: () => fixedNow + }); + const context = { + runtimeStore, + now: () => fixedNow, + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + m3IoRequestJson: fixture.requestJson + }; + + assert.ok(SUPPORTED_RPC_METHODS.includes(M3_IO_RPC_METHODS.doWrite)); + assert.ok(SUPPORTED_RPC_METHODS.includes(M3_IO_RPC_METHODS.diRead)); + + const write = await handleJsonRpcRequest( + { + jsonrpc: "2.0", + id: "req_m3_rpc_write_true", + method: M3_IO_RPC_METHODS.doWrite, + params: { + resourceId: "res_boxsimu_1", + boxId: "boxsimu_1", + port: "DO1", + value: true + }, + meta: { + traceId: "trc_m3_rpc_write_true", + actorId: "usr_m3_rpc", + serviceId: "hwlab-agent-worker", + environment: "dev" + } + }, + context + ); + + validateResponse(write); + assert.equal(Object.hasOwn(write, "error"), false, write.error?.message); + assert.equal(write.result.status, "succeeded"); + assert.equal(write.result.action, "do.write"); + assert.equal(write.result.traceId, "trc_m3_rpc_write_true"); + assert.equal(write.result.resourceId, "res_boxsimu_1"); + assert.equal(write.result.gatewaySessionId, "gws_gwsimu_1"); + assert.equal(write.result.port, "DO1"); + assert.equal(write.result.value, true); + assert.match(write.result.operationId, /^op_m3_do_write_/u); + assert.match(write.result.auditId, /^aud_m3_do_write_/u); + assert.match(write.result.evidenceId, /^evd_m3_do_write_/u); + assert.equal(write.result.controlPath.frontendBypass, false); + assert.equal(write.result.evidenceState.sourceKind, "BLOCKED"); + + const read = await handleJsonRpcRequest( + { + jsonrpc: "2.0", + id: "req_m3_rpc_read_di", + method: M3_IO_RPC_METHODS.diRead, + params: { + resourceId: "res_boxsimu_2", + boxId: "boxsimu_2", + port: "DI1" + }, + meta: { + traceId: "trc_m3_rpc_read_di", + actorId: "usr_m3_rpc", + serviceId: "hwlab-agent-worker", + environment: "dev" + } + }, + context + ); + + validateResponse(read); + assert.equal(Object.hasOwn(read, "error"), false, read.error?.message); + assert.equal(read.result.status, "succeeded"); + assert.equal(read.result.action, "di.read"); + assert.equal(read.result.traceId, "trc_m3_rpc_read_di"); + assert.equal(read.result.gatewayId, "gwsimu_2"); + assert.equal(read.result.gatewaySessionId, "gws_gwsimu_2"); + assert.equal(read.result.resourceId, "res_boxsimu_2"); + assert.equal(read.result.port, "DI1"); + assert.equal(read.result.result.value, true); + assert.equal(read.result.controlPath.patchPanel, false); + assert.equal(read.result.evidenceState.sourceKind, "BLOCKED"); + assert.equal(read.result.durableStatus.durable, false); +}); + +test("M3 IO fixture success cannot be promoted to DEV-LIVE without durable evidence", async () => { + const fixture = createM3ControlFixture(); + const result = await handleM3IoControl( + { + action: "do.write", + value: true, + traceId: "trc_m3_fixture_not_dev_live", + requestId: "req_m3_fixture_not_dev_live", + actorId: "usr_m3_operator" + }, + { + runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }), + now: () => fixedNow, + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + requestJson: fixture.requestJson + } + ); + + assert.equal(result.status, "succeeded"); + assert.equal(result.result.targetReadback.value, true); + assert.equal(result.evidenceState.status, "blocked"); + assert.equal(result.evidenceState.sourceKind, "BLOCKED"); + assert.equal(result.durableStatus.durable, false); + assert.notEqual(result.evidenceState.sourceKind, "DEV-LIVE"); + assert.notEqual(result.auditState.sourceKind, "DEV-LIVE"); +}); + function createBlockedDurableRuntimeStore() { return { async readiness() { diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index cf9cfb87..c2beada0 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -157,7 +157,8 @@ async function handleRpcHttpRequest(request, response, options) { adapter: "json-rpc", runtimeStore: options.runtimeStore, env: options.env, - dbProbe: options.dbProbe + dbProbe: options.dbProbe, + m3IoRequestJson: options.m3IoRequestJson }); sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse); } @@ -262,7 +263,8 @@ async function handleRestAdapter(request, response, url, options) { adapter: "rest", runtimeStore: options.runtimeStore, env: options.env, - dbProbe: options.dbProbe + dbProbe: options.dbProbe, + m3IoRequestJson: options.m3IoRequestJson } ); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index fc756340..121add06 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -1165,6 +1165,8 @@ test("cloud api /v1 exposes M3 IO control contract without generic frontend hard const indexPayload = await index.json(); assert.equal(indexPayload.m3IoControl.route, "/v1/m3/io"); assert.equal(indexPayload.m3IoControl.boundaries.directFrontendGatewayOrBoxAccess, false); + assert.ok(indexPayload.methods.includes("m3.io.do.write")); + assert.ok(indexPayload.methods.includes("m3.io.di.read")); const contract = await fetch(`http://127.0.0.1:${port}/v1/m3/io`); assert.equal(contract.status, 200); diff --git a/scripts/src/m3-io-control-e2e.mjs b/scripts/src/m3-io-control-e2e.mjs index 6a111757..0517143b 100644 --- a/scripts/src/m3-io-control-e2e.mjs +++ b/scripts/src/m3-io-control-e2e.mjs @@ -224,8 +224,9 @@ export async function buildM3IoControlSourceReport(options = {}) { } export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } = {}) { - const [serverSource, m3Source, appSource, htmlSource, artifactPublisherSource] = await Promise.all([ + const [serverSource, jsonRpcSource, m3Source, appSource, htmlSource, artifactPublisherSource] = await Promise.all([ readRepoText(root, "internal/cloud/server.mjs"), + readRepoText(root, "internal/cloud/json-rpc.mjs"), readRepoText(root, "internal/cloud/m3-io-control.mjs"), readRepoText(root, "web/hwlab-cloud-web/app.mjs"), readRepoText(root, "web/hwlab-cloud-web/index.html"), @@ -278,6 +279,18 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } = evidenceLevel: labels.source } ), + checkResult( + "cloud-api-json-rpc-method-contract", + jsonRpcSource.includes("M3_IO_RPC_METHODS.doWrite") && + jsonRpcSource.includes("M3_IO_RPC_METHODS.diRead") && + jsonRpcSource.includes('handleM3IoRpc("do.write"') && + jsonRpcSource.includes('handleM3IoRpc("di.read"'), + "cloud-api JSON-RPC exposes M3 do.write and di.read through the same controlled handler.", + { + methods: ["m3.io.do.write", "m3.io.di.read"], + evidenceLevel: labels.source + } + ), checkResult( "request-schema-contract", requestSchema.status === "pass", @@ -416,7 +429,14 @@ export function validateM3IoSourceResponseContract(m3Source) { /traceId:\s*input\.meta\.traceId/u, /auditId:\s*records\.auditEvent\.auditId/u, /evidenceId:\s*records\.evidenceRecord\.evidenceId/u, + /gatewaySessionId:\s*target\.gatewaySessionId/u, + /resourceId:\s*target\.resourceId/u, + /port:\s*target\.port/u, + /value:\s*target\.value/u, + /auditState:\s*auditState\(/u, /evidenceState:\s*evidenceState\(/u, + /durableStatus:\s*durableStatus\(/u, + /blockerClassification:\s*redactedBlockerClassification\(/u, /persistence:\s*persistenceSummary\(/u, /frontendBypass:\s*false/u, /patchPanelOnlyPropagation:\s*true/u,