diff --git a/docs/m3-hardware-loop.md b/docs/m3-hardware-loop.md index 059d254d..2e462d3c 100644 --- a/docs/m3-hardware-loop.md +++ b/docs/m3-hardware-loop.md @@ -92,8 +92,9 @@ node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-pr ``` The legacy `--confirmed-non-production` flag is accepted as compatibility, but -new handoffs should use `--expect-non-prod`. The script refuses the default -mode and refuses `--dry-run` combined with `--live`. +new handoffs should use `--expect-non-prod`. Missing or incomplete live +authorization stays on the source/read-only plan path, and `--dry-run` combined +with `--live` remains no-write. The live smoke writes `reports/dev-gate/dev-m3-hardware-loop.json`. It first checks the frozen DEV endpoint, `http://74.48.78.17:16667`, and stops at the @@ -113,9 +114,14 @@ HWLAB_DEV_PATCH_PANEL_URL ``` Those targets must be two distinct HWLAB DEV simulator services for each -simulator kind, plus one HWLAB DEV patch panel, not UniDesk substitutes. The -script then checks two box simulators, two gateway simulators, active -patch-panel wiring for +box/gateway role, plus one callable HWLAB DEV patch panel, not UniDesk +substitutes. Missing direct targets are classified as `target_missing`, +duplicated box or gateway identities as `identity_not_distinct`, and missing +`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` wiring as +`patch_panel_wiring_missing`. Each of those stop conditions keeps +`liveOperation.status` at `not_run` and prevents the DEV write/read smoke from +starting. The script then checks two box simulators, two gateway simulators, +active patch-panel wiring for `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`, a live direct call that reads `DI1=true`, and returned operation, trace, audit, and evidence identifiers. Fixture-backed local M3 output is never accepted as live DEV diff --git a/package.json b/package.json index 7767b70b..a851ec23 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", - "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/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.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/server.mjs && node --check internal/sim/model.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-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-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.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/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/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.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-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/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.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/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.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/server.mjs && node --check internal/sim/model.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-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-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.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/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/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.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-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/dev-m3-hardware-loop-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.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 f729fbe5..0d11afaa 100644 --- a/scripts/dev-m3-hardware-loop-smoke.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -7,7 +7,7 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; @@ -33,6 +33,14 @@ const requiredM3Connection = Object.freeze({ const requiredM3GatewayIds = Object.freeze(["gwsimu_1", "gwsimu_2"]); const runnerK3sKubeconfigPath = "/etc/rancher/k3s/k3s.yaml"; +const m3FailureClassifications = Object.freeze({ + targetMissing: "target_missing", + identityNotDistinct: "identity_not_distinct", + patchPanelWiringMissing: "patch_panel_wiring_missing", + operationFailed: "operation_failed", + evidenceMissing: "evidence_missing" +}); + const serviceTargets = Object.freeze([ { id: "box-simu-1", @@ -90,11 +98,31 @@ function parseArgs(argv) { return { flags, values, - dryRun: flags.has("--dry-run") || flags.has("--plan"), - live: flags.has("--live") || flags.has("--allow-live") + dryRun: flags.has("--dry-run") || flags.has("--plan") || flags.has("--source-read-only"), + live: flags.has("--live") }; } +function classifyDirectTargetBlockerScope(scope) { + if (scope === "m3-patch-panel-wiring") { + return m3FailureClassifications.patchPanelWiringMissing; + } + if (scope === "m3-box-simu-identity" || scope === "m3-gateway-simu-identity") { + return m3FailureClassifications.identityNotDistinct; + } + if (scope === "m3-service-discovery" || scope === "m3-direct-target-missing") { + return m3FailureClassifications.targetMissing; + } + return m3FailureClassifications.operationFailed; +} + +function classifyLiveOperationError(error) { + const message = error instanceof Error ? error.message : String(error); + return /\b(auditId|evidenceId)\b/u.test(message) + ? m3FailureClassifications.evidenceMissing + : m3FailureClassifications.operationFailed; +} + function requireSafetyGates({ flags, dryRun, live }) { const forbiddenFlags = [ "--prod", @@ -112,28 +140,20 @@ function requireSafetyGates({ flags, dryRun, live }) { assert.ok(!flags.has(flag), `${flag} is forbidden for DEV M3 smoke`); } - assert.ok( - !(dryRun && live), - "DEV M3 smoke refuses to combine --dry-run/--plan with --live/--allow-live; plan mode never runs the M3 write path" - ); + const confirmedLive = + live && + flags.has("--confirm-dev") && + (flags.has("--expect-non-prod") || flags.has("--confirmed-non-production")) && + !dryRun && + !flags.has("--allow-live"); - if (dryRun) { + if (!confirmedLive) { return { mode: "dry-run", liveWriteAllowed: false }; } - assert.ok( - live, - "DEV M3 smoke refuses to run by default; use --dry-run for a no-write plan or --live --confirm-dev --expect-non-prod after approval" - ); - assert.ok(flags.has("--confirm-dev"), "live DEV smoke requires --confirm-dev"); - assert.ok( - flags.has("--expect-non-prod") || flags.has("--confirmed-non-production"), - "live DEV smoke requires --expect-non-prod (legacy --confirmed-non-production is accepted only as compatibility)" - ); - return { mode: "live", liveWriteAllowed: true @@ -648,10 +668,11 @@ function createLiveOperationPlan() { "targetPort=DI1", "targetState.ports.DI1.propagatedBy=hwlab-patch-panel" ], + failureClassifications: Object.values(m3FailureClassifications), refusalPolicy: [ "no arguments defaults to refusal", - "--dry-run/--plan cannot be combined with --live/--allow-live", - "live mode refuses without --confirm-dev and --expect-non-prod", + "commands missing --live, --confirm-dev, or non-production confirmation stay source/read-only", + "non-production confirmation is --expect-non-prod; legacy --confirmed-non-production remains accepted", "a dry-run, source, local, fixture, or read-only blocker report leaves liveOperation.status as not_run or blocked, never pass" ] }; @@ -963,7 +984,7 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery type: "observability_blocker", scope: "m3-service-discovery", status: "open", - classification: "runner_kubeconfig_readonly_gap", + classification: classifyDirectTargetBlockerScope("m3-service-discovery"), sourceIssue: "pikasTech/HWLAB#46", summary: `DEV ingress is reachable on frozen :16667, but direct M3 target discovery via ${kubernetesDiscovery.source} is ${kubernetesDiscovery.status}; environment URLs missing ${environmentTargets.missing.join(", ")}; discovery detail=${kubernetesDiscovery.summary}.` }); @@ -972,7 +993,7 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery type: "observability_blocker", scope: "m3-direct-target-missing", status: "open", - classification: "direct_target_missing", + classification: classifyDirectTargetBlockerScope("m3-direct-target-missing"), sourceIssue: "pikasTech/HWLAB#64", summary: `Read-only service discovery did not expose enough callable DEV M3 direct targets: box-simu=${boxCandidates.length}/2, gateway-simu=${gatewayCandidates.length}/2, patch-panel=${patchCandidates.length}/1.` }); @@ -983,7 +1004,7 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery type: "runtime_blocker", scope: "m3-box-simu-identity", status: "open", - classification: "direct_target_identity_gap", + classification: classifyDirectTargetBlockerScope("m3-box-simu-identity"), sourceIssue: "pikasTech/HWLAB#64", summary: `DEV exposes two box-simu endpoints, but live identities are not the required distinct resources res_boxsimu_1 and res_boxsimu_2; observed resources=${[...distinctBoxResources].join(", ") || "none"}.` }); @@ -994,7 +1015,7 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery type: "runtime_blocker", scope: "m3-gateway-simu-identity", status: "open", - classification: "direct_target_identity_gap", + classification: classifyDirectTargetBlockerScope("m3-gateway-simu-identity"), sourceIssue: "pikasTech/HWLAB#64", summary: `DEV exposes two gateway-simu endpoints, but live gateway identities are not distinct; observed identities=${[...distinctGatewayIdentities].join(", ") || "none"}.` }); @@ -1007,7 +1028,7 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery type: "runtime_blocker", scope: "m3-patch-panel-wiring", status: "open", - classification: "direct_target_m3_wiring_missing", + classification: classifyDirectTargetBlockerScope("m3-patch-panel-wiring"), 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}.` }); @@ -1256,8 +1277,9 @@ async function runLiveM3Targets(targets) { assert.ok(after.ok, "box-simu-2 DI1 read failed"); assert.equal(after.json?.ports?.DI1?.value, true, "box-simu-2 DI1 must read true"); - const auditId = route.json?.auditId ?? write.json?.auditId ?? after.json?.auditId; - const evidenceId = route.json?.evidenceId ?? write.json?.evidenceId ?? after.json?.evidenceId; + const deliveryResponse = route.json?.deliveries?.[0]?.response; + const auditId = route.json?.auditId ?? deliveryResponse?.auditId ?? write.json?.auditId ?? after.json?.auditId; + const evidenceId = route.json?.evidenceId ?? deliveryResponse?.evidenceId ?? write.json?.evidenceId ?? after.json?.evidenceId; assert.equal(typeof auditId, "string", "live M3 result must include auditId"); assert.equal(typeof evidenceId, "string", "live M3 result must include evidenceId"); @@ -1319,6 +1341,7 @@ function baseReport({ commitId, observedAt }) { confirmDevRequired: true, expectNonProdRequired: true, confirmedNonProductionRequired: true, + defaultSourceReadOnly: true, dryRunPlanSupported: true, dryRunCallsLiveEndpoints: false, prodForbidden: true, @@ -1447,12 +1470,12 @@ async function runDryRunPlan({ values }) { type: "safety_blocker", scope: "m3-live-write-authorization", status: "open", - classification: "dry_run_plan_only", + classification: m3FailureClassifications.targetMissing, summary: "Plan-only mode is intentionally non-mutating; run the bounded live smoke only after explicit DEV/non-PROD approval and after read-only preconditions identify the exact HWLAB targets." }); report.summary = { status: "blocked", - classification: "dry_run_plan_only", + classification: m3FailureClassifications.targetMissing, observedAt, result: "DRY-RUN plan emitted live write prerequisites, endpoint plan, and required evidence fields; it did not call DEV endpoints or produce DEV-LIVE evidence." }; @@ -1537,12 +1560,12 @@ async function main() { type: "network_blocker", scope: "frp", status: "open", - classification: "frp", + classification: m3FailureClassifications.targetMissing, summary: "Blocked at the #33 DEV runtime readiness condition: public DEV ingress does not accept HWLAB health requests, so #36/M3 hardware-loop checks were not reached." }); report.summary = { status: "blocked", - classification: "frp", + classification: m3FailureClassifications.targetMissing, observedAt, result: "No live DEV M3 evidence was produced; fixture-backed local evidence was not used as a substitute." }; @@ -1602,7 +1625,7 @@ async function main() { report.blockers.push(...directTargets.blockers); report.summary = { status: "blocked", - classification: directTargets.blockers[0]?.classification ?? "direct_target_blocked", + classification: directTargets.blockers[0]?.classification ?? m3FailureClassifications.targetMissing, observedAt, result: "DEV ingress and direct targets were reachable, but M3 DEV-LIVE was not triggered because required identities or patch-panel wiring were missing." }; @@ -1672,17 +1695,18 @@ async function main() { `[dev-m3-smoke] operationId=${live.operationId} auditId=${live.auditId} evidenceId=${live.evidenceId}` ); } catch (error) { + const classification = classifyLiveOperationError(error); addNotRunM3Checks(report, "A live DEV M3 runtime contract check failed before all checks passed."); report.blockers.push({ type: "runtime_blocker", scope: "m3-hardware-loop-runtime", status: "open", - classification: "hardware loop", + classification, summary: error instanceof Error ? error.message : String(error) }); report.summary = { status: "blocked", - classification: "hardware loop", + classification, observedAt, result: "Live DEV M3 runtime was reachable but did not satisfy the M3 hardware-loop contract." }; @@ -1692,9 +1716,21 @@ async function main() { } } -try { - await main(); -} catch (error) { - console.error(`[dev-m3-smoke] ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; +export { + classifyDirectTargetBlockerScope, + classifyLiveOperationError, + createLiveOperationPlan, + hasRequiredM3Connection, + m3FailureClassifications, + parseArgs, + requireSafetyGates +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + await main(); + } catch (error) { + console.error(`[dev-m3-smoke] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } } diff --git a/scripts/dev-m3-hardware-loop-smoke.test.mjs b/scripts/dev-m3-hardware-loop-smoke.test.mjs new file mode 100644 index 00000000..4cdc27d0 --- /dev/null +++ b/scripts/dev-m3-hardware-loop-smoke.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + classifyDirectTargetBlockerScope, + createLiveOperationPlan, + hasRequiredM3Connection, + parseArgs, + requireSafetyGates +} from "./dev-m3-hardware-loop-smoke.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const testOutputDir = "tmp/dev-m3-hardware-loop-smoke-test"; + +function runSmoke(args) { + return execFileSync("node", ["scripts/dev-m3-hardware-loop-smoke.mjs", ...args], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }); +} + +test("default M3 smoke is source/read-only and never claims DEV-LIVE", async () => { + await mkdir(path.join(repoRoot, testOutputDir), { recursive: true }); + const output = path.join(testOutputDir, "default-source-readonly.json"); + try { + const stdout = runSmoke(["--output", output]); + assert.match(stdout, /mode=dry-run/u); + const report = JSON.parse(await readFile(path.join(repoRoot, output), "utf8")); + + assert.equal(report.dryRunPlan.mode, "plan-only"); + assert.equal(report.dryRunPlan.dryRunCallsLiveEndpoints, false); + assert.equal(report.dryRunPlan.liveWriteWillRun, false); + assert.equal(report.liveOperation.status, "not_run"); + assert.equal(report.liveOperation.operationId, "not_observed"); + assert.equal(report.summary.status, "blocked"); + assert.match(report.summary.result, /did not call DEV endpoints/u); + assert.equal( + report.liveChecks.find((check) => check.id === "direct-call-do-write-di-read")?.status, + "not_run" + ); + } finally { + await rm(path.join(repoRoot, output), { force: true }); + } +}); + +test("--dry-run aliases the same source/read-only no-write path", async () => { + await mkdir(path.join(repoRoot, testOutputDir), { recursive: true }); + const output = path.join(testOutputDir, "dry-run-source-readonly.json"); + try { + runSmoke(["--dry-run", "--output", output]); + const report = JSON.parse(await readFile(path.join(repoRoot, output), "utf8")); + + assert.equal(report.dryRunPlan.mode, "plan-only"); + assert.equal(report.dryRunPlan.liveWriteWillRun, false); + assert.ok( + report.dryRunPlan.failureClassifications.includes("patch_panel_wiring_missing") + ); + assert.equal(report.liveOperation.status, "not_run"); + } finally { + await rm(path.join(repoRoot, output), { force: true }); + } +}); + +test("M3 safety gates keep incomplete live commands source/read-only", () => { + assert.deepEqual(requireSafetyGates(parseArgs([])), { + mode: "dry-run", + liveWriteAllowed: false + }); + assert.deepEqual(requireSafetyGates(parseArgs(["--live", "--confirm-dev"])), { + mode: "dry-run", + liveWriteAllowed: false + }); + assert.deepEqual(requireSafetyGates(parseArgs(["--dry-run", "--live", "--confirm-dev", "--expect-non-prod"])), { + mode: "dry-run", + liveWriteAllowed: false + }); + assert.deepEqual(requireSafetyGates(parseArgs(["--live", "--confirm-dev", "--expect-non-prod"])), { + mode: "live", + liveWriteAllowed: true + }); + assert.deepEqual(requireSafetyGates(parseArgs(["--live", "--confirm-dev", "--confirmed-non-production"])), { + mode: "live", + liveWriteAllowed: true + }); +}); + +test("M3 blocker scopes classify missing identities and patch-panel route gaps", () => { + assert.equal(classifyDirectTargetBlockerScope("m3-service-discovery"), "target_missing"); + assert.equal(classifyDirectTargetBlockerScope("m3-direct-target-missing"), "target_missing"); + assert.equal(classifyDirectTargetBlockerScope("m3-box-simu-identity"), "identity_not_distinct"); + assert.equal(classifyDirectTargetBlockerScope("m3-gateway-simu-identity"), "identity_not_distinct"); + assert.equal(classifyDirectTargetBlockerScope("m3-patch-panel-wiring"), "patch_panel_wiring_missing"); + + const plan = createLiveOperationPlan(); + assert.equal(plan.evidenceLevel, "DRY-RUN"); + assert.equal(plan.liveWriteWillRun, false); + assert.ok(plan.failureClassifications.includes("identity_not_distinct")); + assert.ok(plan.failureClassifications.includes("patch_panel_wiring_missing")); + 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", () => { + const wrongRoute = hasRequiredM3Connection({ + status: { + activeConnections: [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_1", + toPort: "DI1" + } + ] + }, + wiring: { + connections: [ + { + from: { resourceId: "res_boxsimu_1", port: "DO1" }, + to: { resourceId: "res_boxsimu_1", port: "DI1" }, + mode: "exclusive" + } + ] + } + }); + assert.equal(Boolean(wrongRoute.active), false); + assert.equal(Boolean(wrongRoute.configured), false); + + const requiredRoute = hasRequiredM3Connection({ + status: { + activeConnections: [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + } + ] + }, + wiring: { + connections: [ + { + from: { resourceId: "res_boxsimu_1", port: "DO1" }, + to: { resourceId: "res_boxsimu_2", port: "DI1" }, + mode: "exclusive" + } + ] + } + }); + assert.equal(Boolean(requiredRoute.active), true); + assert.equal(Boolean(requiredRoute.configured), true); +}); diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 576c3ca3..94526acb 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -1722,6 +1722,9 @@ async function validateDevM3Report(report, label) { if (Object.hasOwn(report.safetyGates, "dryRunCallsLiveEndpoints")) { assert.equal(report.safetyGates.dryRunCallsLiveEndpoints, false, `${label}.safetyGates.dryRunCallsLiveEndpoints`); } + if (Object.hasOwn(report.safetyGates, "defaultSourceReadOnly")) { + assert.equal(report.safetyGates.defaultSourceReadOnly, true, `${label}.safetyGates.defaultSourceReadOnly`); + } assertArray(report.liveChecks, `${label}.liveChecks`); assert.ok(report.liveChecks.length >= 8, `${label}.liveChecks must include DEV M3 checks`); @@ -1835,6 +1838,21 @@ async function validateDevM3Report(report, label) { assert.equal(plan.route, "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", `${label}.dryRunPlan.route`); assert.equal(plan.dryRunCallsLiveEndpoints, false, `${label}.dryRunPlan.dryRunCallsLiveEndpoints`); assert.equal(plan.liveWriteWillRun, false, `${label}.dryRunPlan.liveWriteWillRun`); + if (Object.hasOwn(plan, "failureClassifications")) { + assertStringArray(plan.failureClassifications, `${label}.dryRunPlan.failureClassifications`, { minLength: 5 }); + for (const classification of [ + "target_missing", + "identity_not_distinct", + "patch_panel_wiring_missing", + "operation_failed", + "evidence_missing" + ]) { + assert.ok( + plan.failureClassifications.includes(classification), + `${label}.dryRunPlan.failureClassifications missing ${classification}` + ); + } + } assertStringArray(plan.liveWritePreconditions, `${label}.dryRunPlan.liveWritePreconditions`, { minLength: 4 }); assertStringArray(plan.evidenceFields, `${label}.dryRunPlan.evidenceFields`, { minLength: 4 }); assertArray(plan.endpointPlan, `${label}.dryRunPlan.endpointPlan`);