diff --git a/docs/m5-mvp-e2e.md b/docs/m5-mvp-e2e.md new file mode 100644 index 00000000..2e2abe67 --- /dev/null +++ b/docs/m5-mvp-e2e.md @@ -0,0 +1,88 @@ +# M5 MVP E2E Dry-Run Orchestration + +M5 is the static orchestration gate before the first real DEV MVP e2e run. It +does not deploy, does not call DEV, does not run browser e2e, and does not read +secrets. The only executable gate in this milestone is: + +```sh +node scripts/m5-mvp-e2e-dry-run.mjs +``` + +The script reads the frozen protocol contracts, the DEV acceptance checklist, +`fixtures/mvp/runtime.json`, and `fixtures/mvp/m5-e2e/dry-run-plan.json`. It +validates the dependency graph and checks that each step has resolvable inputs +and outputs. + +## Dry-Run Flow + +| Order | Step | Purpose | Required Inputs | Required Outputs | +| --- | --- | --- | --- | --- | +| 1 | DEV health | Confirm the frozen DEV endpoint and recorded edge/frp/D601/cloud health contracts. | DEV endpoint, ingress, edge route, frp, router, cloud API/web health records | Route, tunnel, namespace, cloud service identities | +| 2 | Gateway/box status | Confirm gateway, simulator, resources, and capabilities are present. | Gateway sessions, box resources, capabilities | Gateway session, resource IDs, capability IDs | +| 3 | Wiring | Confirm the active wiring config is patch-panel owned. | Wiring config, patch-panel status | Wiring config ID, patch-panel status ID | +| 4 | Direct hardware call | Validate the direct JSON-RPC hardware operation envelope and field flow. | RPC request/response, resource, capability, patch-panel status | Operation ID, trace ID | +| 5 | Audit | Confirm mutating setup and direct operation audit records exist. | Project, gateway, wiring, operation audit records | Direct operation audit ID | +| 6 | Agent session | Confirm agent manager, worker, and skills contracts are linked. | Agent/worker sessions, agent health records | Agent session ID, worker session ID | +| 7 | Agent hardware call | Validate the agent worker JSON-RPC hardware operation envelope and field flow. | RPC request/response, agent, worker, resource, capability, patch-panel status | Operation ID, trace ID | +| 8 | Evidence | Confirm both hardware operations emit trace and evidence records. | Trace events, evidence records | Evidence IDs | +| 9 | Cleanup | Confirm worker and gateway transient cleanup are recorded. | Cleanup records, cleanup audit | Cleanup IDs | + +## M0-M4 Dependency Graph + +M5 closes over the earlier MVP contracts as contract-only prerequisites: + +| Source | Provides | Consumed By M5 | +| --- | --- | --- | +| M0 contract audit | Frozen service IDs, protocol schemas, MVP e2e sequence contract | DEV health, hardware operation envelopes, evidence contract | +| M1 local smoke | Static parse boundary and dry-run safety posture | DEV health and cleanup safety checks | +| M2 cloud core | Project record, JSON-RPC envelope, audit surface | Audit, direct hardware call, agent hardware call | +| M3 hardware loop | Gateway session, box resources/capabilities, wiring config, patch-panel status | Gateway/box status, wiring, direct hardware call, agent hardware call | +| M4 agent runtime | Agent session, worker session, trace events, evidence, cleanup | Agent session, agent hardware call, evidence, cleanup | + +The graph must remain acyclic. Each M5 step may only depend on earlier steps +except the agent hardware call, which also depends on the already-established +wiring step. + +## Blocker Classes + +M5 keeps the blocker classes from the DEV acceptance matrix: + +| Class | Meaning | +| --- | --- | +| `contract_blocker` | Static contract, schema, checklist, or documentation cannot be parsed or contradicts frozen names. | +| `environment_blocker` | DEV endpoint, environment, namespace, or PROD boundary is wrong. | +| `network_blocker` | Master edge, frp, D601 route, or public DEV ingress cannot be observed or routes incorrectly. | +| `runtime_blocker` | HWLAB cloud, router, gateway, simulator, box simulator, or patch-panel runtime contract fails. | +| `agent_blocker` | Agent manager, worker, skills, trace, audit, or cleanup contract fails. | +| `observability_blocker` | Artifact identity, commit, image/tag/digest, build source, deploy env, or health timestamp is missing. | +| `safety_blocker` | Any prohibited action was attempted: real deployment, PROD target, browser/heavyweight e2e, secret read, force push, or UniDesk runtime substitution. | + +## Real DEV E2E Gate + +Do not switch to the real command until all of these are true: + +1. `node scripts/m5-mvp-e2e-dry-run.mjs` passes on `origin/main`. +2. `docs/dev-acceptance-checklist.json` and `docs/dev-acceptance-matrix.md` + still freeze DEV at `http://74.48.78.17:6667`. +3. Every accepted artifact has `serviceId`, `commitId`, `image`, `tag`, + `digest` or `not_applicable` reason, `buildSource`, `deployEnv`, and + `healthTimestamp`. +4. DEV ingress, master edge proxy, frp, and D601 `hwlab-dev/hwlab-router` have + current route observations. +5. Cloud API/web, gateway, gateway simulator, box simulator, patch panel, agent + manager, worker, and skills are all observed in DEV with HWLAB service + identities. +6. Gateway and box control remain patch-panel owned; no direct box bypass path + is accepted. +7. Agent worker scope, trace, audit, evidence, and cleanup are observable and + tied to one DEV project. +8. No blocker class is open and a human has explicitly approved a real DEV + smoke. + +Only after those gates are satisfied should the runner switch from dry-run to: + +```sh +hwlab-cli test e2e --env dev --mvp +``` + +That command is intentionally not executed by M5. diff --git a/fixtures/mvp/m5-e2e/dry-run-plan.json b/fixtures/mvp/m5-e2e/dry-run-plan.json new file mode 100644 index 00000000..7e2d0d01 --- /dev/null +++ b/fixtures/mvp/m5-e2e/dry-run-plan.json @@ -0,0 +1,1353 @@ +{ + "$schema": "https://hwlab.pikastech.local/fixtures/mvp/m5-e2e/dry-run-plan.schema.json", + "$id": "https://hwlab.pikastech.local/fixtures/mvp/m5-e2e/dry-run-plan.json", + "planVersion": "m5-v1", + "issue": "pikasTech/HWLAB#26", + "mode": "dry-run", + "environment": "dev", + "endpoint": "http://74.48.78.17:6667", + "safety": { + "allowNetwork": false, + "allowDeploy": false, + "allowBrowserE2E": false, + "allowSecrets": false, + "allowedSourceGlobs": [ + "protocol/**", + "docs/dev-acceptance-checklist.json", + "docs/dev-acceptance-matrix.md", + "fixtures/mvp/runtime.json", + "fixtures/mvp/m5-e2e/**" + ], + "prohibitedActions": [ + "real-dev-deploy", + "prod-deploy", + "prod-smoke", + "browser-e2e", + "heavyweight-e2e", + "secret-or-token-read", + "force-push", + "unidesk-runtime-substitution" + ] + }, + "m0ToM4Dependencies": [ + { + "id": "m0-contract-audit", + "status": "contract_only", + "provides": [ + "frozen service ids", + "protocol schemas", + "MVP E2E sequence contract" + ], + "requiredFor": [ + "step-dev-health", + "step-direct-hardware-call", + "step-agent-hardware-call", + "step-evidence" + ] + }, + { + "id": "m1-local-smoke", + "status": "contract_only", + "provides": [ + "local parse and smoke boundary", + "dry-run safety posture" + ], + "requiredFor": [ + "step-dev-health", + "step-cleanup" + ] + }, + { + "id": "m2-cloud-core", + "status": "contract_only", + "provides": [ + "project record", + "JSON-RPC envelope", + "audit surface" + ], + "requiredFor": [ + "step-audit", + "step-direct-hardware-call", + "step-agent-hardware-call" + ] + }, + { + "id": "m3-hardware-loop", + "status": "contract_only", + "provides": [ + "gateway session", + "box resources", + "box capabilities", + "wiring config", + "patch-panel status" + ], + "requiredFor": [ + "step-gateway-box-status", + "step-wiring", + "step-direct-hardware-call", + "step-agent-hardware-call" + ] + }, + { + "id": "m4-agent-runtime", + "status": "contract_only", + "provides": [ + "agent session", + "worker session", + "trace events", + "agent evidence", + "worker cleanup" + ], + "requiredFor": [ + "step-agent-session", + "step-agent-hardware-call", + "step-evidence", + "step-cleanup" + ] + } + ], + "artifacts": [ + { + "serviceId": "hwlab-edge-proxy", + "commitId": "caa9ed0", + "image": "hwlab-edge-proxy", + "tag": "dry-run-caa9ed0", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-tunnel-client", + "commitId": "caa9ed0", + "image": "hwlab-tunnel-client", + "tag": "dry-run-caa9ed0", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-router", + "commitId": "caa9ed0", + "image": "hwlab-router", + "tag": "dry-run-caa9ed0", + "digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-cloud-api", + "commitId": "caa9ed0", + "image": "hwlab-cloud-api", + "tag": "dry-run-caa9ed0", + "digest": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-cloud-web", + "commitId": "caa9ed0", + "image": "hwlab-cloud-web-static-assets", + "tag": "dry-run-caa9ed0", + "digest": "not_applicable", + "digestNotApplicableReason": "static asset digest is produced by the future DEV build pipeline, not this local dry-run fixture", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-gateway", + "commitId": "caa9ed0", + "image": "hwlab-gateway", + "tag": "dry-run-caa9ed0", + "digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-gateway-simu", + "commitId": "caa9ed0", + "image": "hwlab-gateway-simu", + "tag": "dry-run-caa9ed0", + "digest": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-box-simu", + "commitId": "caa9ed0", + "image": "hwlab-box-simu", + "tag": "dry-run-caa9ed0", + "digest": "sha256:7777777777777777777777777777777777777777777777777777777777777777", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-patch-panel", + "commitId": "caa9ed0", + "image": "hwlab-patch-panel", + "tag": "dry-run-caa9ed0", + "digest": "sha256:8888888888888888888888888888888888888888888888888888888888888888", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-agent-mgr", + "commitId": "caa9ed0", + "image": "hwlab-agent-mgr", + "tag": "dry-run-caa9ed0", + "digest": "sha256:9999999999999999999999999999999999999999999999999999999999999999", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-agent-worker", + "commitId": "caa9ed0", + "image": "hwlab-agent-worker", + "tag": "dry-run-caa9ed0", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-agent-skills", + "commitId": "caa9ed0", + "image": "hwlab-agent-runtime-skills", + "tag": "dry-run-caa9ed0", + "digest": "not_applicable", + "digestNotApplicableReason": "skill bundle digest is produced by the future DEV build pipeline, not this local dry-run fixture", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "serviceId": "hwlab-cli", + "commitId": "caa9ed0", + "image": "hwlab-cli-npm-package", + "tag": "dry-run-caa9ed0", + "digest": "not_applicable", + "digestNotApplicableReason": "local CLI package is not containerized in this dry-run fixture", + "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z" + } + ], + "health": [ + { + "healthId": "hlt_m5-dev-ingress", + "serviceId": "hwlab-edge-proxy", + "component": "DEV ingress", + "status": "healthy", + "deployEnv": "dev", + "endpoint": "http://74.48.78.17:6667/health", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "routeId": "route_m5-dev", + "nextHopServiceId": "hwlab-tunnel-client" + } + }, + { + "healthId": "hlt_m5-edge-route", + "serviceId": "hwlab-edge-proxy", + "component": "master edge proxy", + "status": "healthy", + "deployEnv": "dev", + "endpoint": "http://74.48.78.17:6667", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "routeId": "route_m5-dev", + "targetServiceId": "hwlab-tunnel-client" + } + }, + { + "healthId": "hlt_m5-frp", + "serviceId": "hwlab-tunnel-client", + "component": "frp", + "status": "healthy", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "tunnelId": "tun_m5-d601", + "targetServiceId": "hwlab-router", + "namespace": "hwlab-dev" + } + }, + { + "healthId": "hlt_m5-router", + "serviceId": "hwlab-router", + "component": "D601 router", + "status": "healthy", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "namespace": "hwlab-dev", + "routeTargetServiceIds": [ + "hwlab-cloud-api", + "hwlab-cloud-web" + ] + } + }, + { + "healthId": "hlt_m5-cloud-api", + "serviceId": "hwlab-cloud-api", + "component": "Cloud API", + "status": "healthy", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "live": true, + "rpcProbe": "json-rpc-envelope" + } + }, + { + "healthId": "hlt_m5-cloud-web", + "serviceId": "hwlab-cloud-web", + "component": "Cloud Web", + "status": "healthy", + "deployEnv": "dev", + "endpoint": "http://74.48.78.17:6667", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "directHardwareCall": false + } + }, + { + "healthId": "hlt_m5-gateway", + "serviceId": "hwlab-gateway", + "component": "Gateway", + "status": "healthy", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "gatewaySessionId": "gws_m5-0001", + "patchPanelRequired": true + } + }, + { + "healthId": "hlt_m5-gateway-simu", + "serviceId": "hwlab-gateway-simu", + "component": "Gateway simulator", + "status": "healthy", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "gatewaySessionId": "gws_m5-simu-0001", + "boxIds": [ + "box_m5-control", + "box_m5-target" + ] + } + }, + { + "healthId": "hlt_m5-box-simu", + "serviceId": "hwlab-box-simu", + "component": "Box simulator", + "status": "healthy", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "resourceIds": [ + "res_m5-control-relay", + "res_m5-target-board" + ], + "patchPanelOnlyPropagation": true + } + }, + { + "healthId": "hlt_m5-patch-panel", + "serviceId": "hwlab-patch-panel", + "component": "Patch panel", + "status": "healthy", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "patchPanelStatusId": "pps_m5-0001", + "wiringConfigId": "wir_m5-0001" + } + }, + { + "healthId": "hlt_m5-agent-mgr", + "serviceId": "hwlab-agent-mgr", + "component": "Agent manager", + "status": "healthy", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "agentSessionId": "agt_m5-0001", + "workerLeak": false + } + }, + { + "healthId": "hlt_m5-agent-worker", + "serviceId": "hwlab-agent-worker", + "component": "Agent worker", + "status": "healthy", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "workerSessionId": "wkr_m5-0001", + "scopedProjectId": "proj_m5-mvp-e2e" + } + }, + { + "healthId": "hlt_m5-agent-skills", + "serviceId": "hwlab-agent-skills", + "component": "Agent skills", + "status": "healthy", + "deployEnv": "dev", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "skillName": "hwlab-agent-runtime" + } + }, + { + "healthId": "hlt_m5-cli", + "serviceId": "hwlab-cli", + "component": "CLI", + "status": "healthy", + "deployEnv": "dev", + "endpoint": "http://74.48.78.17:6667", + "healthTimestamp": "2026-05-21T00:00:00.000Z", + "observedBy": "fixture", + "dryRun": true, + "output": { + "command": "hwlab-cli test e2e --env dev --mvp --dry-run" + } + } + ], + "project": { + "projectId": "proj_m5-mvp-e2e", + "name": "HWLAB M5 MVP E2E Dry Run", + "description": "Static dry-run project that validates MVP E2E orchestration dependencies without touching DEV.", + "status": "active", + "environment": "dev", + "labels": { + "track": "mvp", + "milestone": "m5" + }, + "createdBy": "service_hwlab-cli", + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + "gatewaySessions": [ + { + "gatewaySessionId": "gws_m5-0001", + "projectId": "proj_m5-mvp-e2e", + "serviceId": "hwlab-gateway", + "gatewayId": "gtw_m5-dev-boundary", + "endpoint": "http://74.48.78.17:6667/gateway", + "status": "connected", + "environment": "dev", + "startedAt": "2026-05-21T00:00:00.000Z", + "lastSeenAt": "2026-05-21T00:00:00.000Z", + "labels": { + "mode": "dry-run" + } + }, + { + "gatewaySessionId": "gws_m5-simu-0001", + "projectId": "proj_m5-mvp-e2e", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gtw_m5-simu", + "status": "connected", + "environment": "dev", + "startedAt": "2026-05-21T00:00:00.000Z", + "lastSeenAt": "2026-05-21T00:00:00.000Z", + "labels": { + "mode": "dry-run" + } + } + ], + "boxResources": [ + { + "resourceId": "res_m5-control-relay", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "boxId": "box_m5-control", + "resourceType": "relay", + "name": "M5 control relay", + "state": "available", + "environment": "dev", + "metadata": { + "ports": [ + "out1" + ] + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + { + "resourceId": "res_m5-target-board", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "boxId": "box_m5-target", + "resourceType": "board", + "name": "M5 target board", + "state": "available", + "environment": "dev", + "metadata": { + "ports": [ + "reset" + ] + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + } + ], + "boxCapabilities": [ + { + "capabilityId": "cap_m5-relay-set", + "resourceId": "res_m5-control-relay", + "projectId": "proj_m5-mvp-e2e", + "name": "relay.set", + "description": "Set relay output state through the gateway and patch panel.", + "direction": "output", + "valueType": "boolean", + "constraints": { + "allowedValues": [ + true, + false + ] + }, + "mutatesState": true, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + { + "capabilityId": "cap_m5-board-reset", + "resourceId": "res_m5-target-board", + "projectId": "proj_m5-mvp-e2e", + "name": "board.reset", + "description": "Receive a reset signal from the patch panel owned connection.", + "direction": "input", + "valueType": "boolean", + "constraints": { + "allowedValues": [ + true, + false + ] + }, + "mutatesState": false, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + } + ], + "wiringConfig": { + "wiringConfigId": "wir_m5-0001", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "name": "M5 relay to target board reset", + "status": "active", + "connections": [ + { + "from": { + "resourceId": "res_m5-control-relay", + "port": "out1", + "capabilityId": "cap_m5-relay-set" + }, + "to": { + "resourceId": "res_m5-target-board", + "port": "reset", + "capabilityId": "cap_m5-board-reset" + }, + "mode": "exclusive" + } + ], + "constraints": { + "patchPanelOwnsRouting": true, + "directBoxBypassAllowed": false + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + "patchPanelStatus": { + "patchPanelStatusId": "pps_m5-0001", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "wiringConfigId": "wir_m5-0001", + "serviceId": "hwlab-patch-panel", + "state": "active", + "environment": "dev", + "activeConnections": [ + { + "fromResourceId": "res_m5-control-relay", + "fromPort": "out1", + "toResourceId": "res_m5-target-board", + "toPort": "reset" + } + ], + "observedAt": "2026-05-21T00:00:00.000Z", + "metadata": { + "dryRun": true, + "sourceWiringConfigId": "wir_m5-0001" + } + }, + "agentSession": { + "agentSessionId": "agt_m5-0001", + "projectId": "proj_m5-mvp-e2e", + "serviceId": "hwlab-agent-mgr", + "status": "completed", + "environment": "dev", + "requestedBy": "user_m5-dry-run", + "goal": "Exercise the M5 MVP dry-run hardware call through an agent worker.", + "startedAt": "2026-05-21T00:00:00.000Z", + "completedAt": "2026-05-21T00:05:00.000Z", + "updatedAt": "2026-05-21T00:05:00.000Z", + "metadata": { + "dryRun": true, + "workerSessionId": "wkr_m5-0001", + "cleanupId": "cln_m5-worker" + } + }, + "workerSession": { + "workerSessionId": "wkr_m5-0001", + "agentSessionId": "agt_m5-0001", + "projectId": "proj_m5-mvp-e2e", + "serviceId": "hwlab-agent-worker", + "gatewaySessionId": "gws_m5-0001", + "status": "completed", + "environment": "dev", + "startedAt": "2026-05-21T00:01:00.000Z", + "completedAt": "2026-05-21T00:05:00.000Z", + "updatedAt": "2026-05-21T00:05:00.000Z", + "metadata": { + "dryRun": true, + "scopedProjectId": "proj_m5-mvp-e2e", + "workspaceVolumeId": "vol_m5-agent" + } + }, + "operations": [ + { + "operationId": "op_m5-direct-0001", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "resourceId": "res_m5-control-relay", + "capabilityId": "cap_m5-relay-set", + "requestedBy": "user_m5-dry-run", + "input": { + "value": true, + "dryRun": true + }, + "output": { + "accepted": true, + "patchPanelStatusId": "pps_m5-0001" + }, + "status": "succeeded", + "environment": "dev", + "requestedAt": "2026-05-21T00:02:00.000Z", + "startedAt": "2026-05-21T00:02:10.000Z", + "completedAt": "2026-05-21T00:02:30.000Z", + "updatedAt": "2026-05-21T00:02:30.000Z" + }, + { + "operationId": "op_m5-agent-0001", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "agentSessionId": "agt_m5-0001", + "workerSessionId": "wkr_m5-0001", + "resourceId": "res_m5-control-relay", + "capabilityId": "cap_m5-relay-set", + "requestedBy": "agent_m5-dry-run", + "input": { + "value": false, + "dryRun": true + }, + "output": { + "accepted": true, + "patchPanelStatusId": "pps_m5-0001" + }, + "status": "succeeded", + "environment": "dev", + "requestedAt": "2026-05-21T00:03:00.000Z", + "startedAt": "2026-05-21T00:03:10.000Z", + "completedAt": "2026-05-21T00:03:30.000Z", + "updatedAt": "2026-05-21T00:03:30.000Z" + } + ], + "rpc": [ + { + "name": "direct-hardware-request", + "operationId": "op_m5-direct-0001", + "request": { + "jsonrpc": "2.0", + "id": "req_m5-direct-0001", + "method": "hardware.operation.request", + "params": { + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "resourceId": "res_m5-control-relay", + "capabilityId": "cap_m5-relay-set", + "input": { + "value": true, + "dryRun": true + } + }, + "meta": { + "traceId": "trc_m5-direct-0001", + "serviceId": "hwlab-cloud-api", + "environment": "dev" + } + }, + "response": { + "jsonrpc": "2.0", + "id": "req_m5-direct-0001", + "result": { + "accepted": true, + "operationId": "op_m5-direct-0001", + "gatewaySessionId": "gws_m5-0001" + }, + "meta": { + "traceId": "trc_m5-direct-0001", + "serviceId": "hwlab-gateway", + "environment": "dev" + } + } + }, + { + "name": "agent-hardware-request", + "operationId": "op_m5-agent-0001", + "request": { + "jsonrpc": "2.0", + "id": "req_m5-agent-0001", + "method": "hardware.operation.request", + "params": { + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "agentSessionId": "agt_m5-0001", + "workerSessionId": "wkr_m5-0001", + "resourceId": "res_m5-control-relay", + "capabilityId": "cap_m5-relay-set", + "input": { + "value": false, + "dryRun": true + } + }, + "meta": { + "traceId": "trc_m5-agent-0001", + "serviceId": "hwlab-agent-worker", + "environment": "dev" + } + }, + "response": { + "jsonrpc": "2.0", + "id": "req_m5-agent-0001", + "result": { + "accepted": true, + "operationId": "op_m5-agent-0001", + "gatewaySessionId": "gws_m5-0001", + "agentSessionId": "agt_m5-0001", + "workerSessionId": "wkr_m5-0001" + }, + "meta": { + "traceId": "trc_m5-agent-0001", + "serviceId": "hwlab-gateway", + "environment": "dev" + } + } + } + ], + "traceEvents": [ + { + "traceEventId": "tev_m5-direct-accepted", + "traceId": "trc_m5-direct-0001", + "projectId": "proj_m5-mvp-e2e", + "operationId": "op_m5-direct-0001", + "serviceId": "hwlab-cloud-api", + "level": "info", + "message": "Direct dry-run hardware operation accepted.", + "environment": "dev", + "metadata": { + "state": "accepted" + }, + "occurredAt": "2026-05-21T00:02:00.000Z" + }, + { + "traceEventId": "tev_m5-direct-dispatched", + "traceId": "trc_m5-direct-0001", + "projectId": "proj_m5-mvp-e2e", + "operationId": "op_m5-direct-0001", + "serviceId": "hwlab-gateway", + "level": "info", + "message": "Direct dry-run hardware operation dispatched to gateway.", + "environment": "dev", + "metadata": { + "state": "dispatched" + }, + "occurredAt": "2026-05-21T00:02:05.000Z" + }, + { + "traceEventId": "tev_m5-direct-executed", + "traceId": "trc_m5-direct-0001", + "projectId": "proj_m5-mvp-e2e", + "operationId": "op_m5-direct-0001", + "serviceId": "hwlab-patch-panel", + "level": "info", + "message": "Direct dry-run hardware operation executed by patch-panel-owned wiring.", + "environment": "dev", + "metadata": { + "state": "executed" + }, + "occurredAt": "2026-05-21T00:02:20.000Z" + }, + { + "traceEventId": "tev_m5-direct-completed", + "traceId": "trc_m5-direct-0001", + "projectId": "proj_m5-mvp-e2e", + "operationId": "op_m5-direct-0001", + "serviceId": "hwlab-cloud-api", + "level": "info", + "message": "Direct dry-run hardware operation completed.", + "environment": "dev", + "metadata": { + "state": "completed" + }, + "occurredAt": "2026-05-21T00:02:30.000Z" + }, + { + "traceEventId": "tev_m5-agent-accepted", + "traceId": "trc_m5-agent-0001", + "projectId": "proj_m5-mvp-e2e", + "agentSessionId": "agt_m5-0001", + "workerSessionId": "wkr_m5-0001", + "operationId": "op_m5-agent-0001", + "serviceId": "hwlab-agent-worker", + "level": "info", + "message": "Agent dry-run hardware operation accepted.", + "environment": "dev", + "metadata": { + "state": "accepted" + }, + "occurredAt": "2026-05-21T00:03:00.000Z" + }, + { + "traceEventId": "tev_m5-agent-dispatched", + "traceId": "trc_m5-agent-0001", + "projectId": "proj_m5-mvp-e2e", + "agentSessionId": "agt_m5-0001", + "workerSessionId": "wkr_m5-0001", + "operationId": "op_m5-agent-0001", + "serviceId": "hwlab-gateway", + "level": "info", + "message": "Agent dry-run hardware operation dispatched to gateway.", + "environment": "dev", + "metadata": { + "state": "dispatched" + }, + "occurredAt": "2026-05-21T00:03:05.000Z" + }, + { + "traceEventId": "tev_m5-agent-executed", + "traceId": "trc_m5-agent-0001", + "projectId": "proj_m5-mvp-e2e", + "agentSessionId": "agt_m5-0001", + "workerSessionId": "wkr_m5-0001", + "operationId": "op_m5-agent-0001", + "serviceId": "hwlab-patch-panel", + "level": "info", + "message": "Agent dry-run hardware operation executed by patch-panel-owned wiring.", + "environment": "dev", + "metadata": { + "state": "executed" + }, + "occurredAt": "2026-05-21T00:03:20.000Z" + }, + { + "traceEventId": "tev_m5-agent-completed", + "traceId": "trc_m5-agent-0001", + "projectId": "proj_m5-mvp-e2e", + "agentSessionId": "agt_m5-0001", + "workerSessionId": "wkr_m5-0001", + "operationId": "op_m5-agent-0001", + "serviceId": "hwlab-agent-worker", + "level": "info", + "message": "Agent dry-run hardware operation completed.", + "environment": "dev", + "metadata": { + "state": "completed" + }, + "occurredAt": "2026-05-21T00:03:30.000Z" + } + ], + "auditEvents": [ + { + "auditId": "aud_m5-project-selected", + "traceId": "trc_m5-project", + "actorType": "user", + "actorId": "user_m5-dry-run", + "action": "project.select", + "targetType": "project", + "targetId": "proj_m5-mvp-e2e", + "projectId": "proj_m5-mvp-e2e", + "serviceId": "hwlab-cloud-api", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "dryRun": true + }, + "occurredAt": "2026-05-21T00:00:00.000Z" + }, + { + "auditId": "aud_m5-gateway-started", + "traceId": "trc_m5-gateway", + "actorType": "service", + "actorId": "service_hwlab-gateway", + "action": "gateway.session.start", + "targetType": "gateway_session", + "targetId": "gws_m5-0001", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "serviceId": "hwlab-gateway", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "dryRun": true + }, + "occurredAt": "2026-05-21T00:00:30.000Z" + }, + { + "auditId": "aud_m5-wiring-applied", + "traceId": "trc_m5-wiring", + "actorType": "service", + "actorId": "service_hwlab-patch-panel", + "action": "wiring.apply", + "targetType": "wiring_config", + "targetId": "wir_m5-0001", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "serviceId": "hwlab-patch-panel", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "patchPanelStatusId": "pps_m5-0001", + "dryRun": true + }, + "occurredAt": "2026-05-21T00:01:00.000Z" + }, + { + "auditId": "aud_m5-direct-operation", + "traceId": "trc_m5-direct-0001", + "actorType": "user", + "actorId": "user_m5-dry-run", + "action": "hardware.operation.request", + "targetType": "hardware_operation", + "targetId": "op_m5-direct-0001", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "operationId": "op_m5-direct-0001", + "serviceId": "hwlab-cloud-api", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "dryRun": true + }, + "occurredAt": "2026-05-21T00:02:30.000Z" + }, + { + "auditId": "aud_m5-agent-session", + "traceId": "trc_m5-agent-session", + "actorType": "agent", + "actorId": "agent_m5-dry-run", + "action": "agent.session.start", + "targetType": "agent_session", + "targetId": "agt_m5-0001", + "projectId": "proj_m5-mvp-e2e", + "workerSessionId": "wkr_m5-0001", + "serviceId": "hwlab-agent-mgr", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "dryRun": true + }, + "occurredAt": "2026-05-21T00:02:45.000Z" + }, + { + "auditId": "aud_m5-agent-operation", + "traceId": "trc_m5-agent-0001", + "actorType": "worker", + "actorId": "worker_m5-dry-run", + "action": "hardware.operation.request", + "targetType": "hardware_operation", + "targetId": "op_m5-agent-0001", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "workerSessionId": "wkr_m5-0001", + "operationId": "op_m5-agent-0001", + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "agentSessionId": "agt_m5-0001", + "dryRun": true + }, + "occurredAt": "2026-05-21T00:03:30.000Z" + }, + { + "auditId": "aud_m5-cleanup", + "traceId": "trc_m5-cleanup", + "actorType": "worker", + "actorId": "worker_m5-dry-run", + "action": "worker.cleanup", + "targetType": "worker_session", + "targetId": "wkr_m5-0001", + "projectId": "proj_m5-mvp-e2e", + "gatewaySessionId": "gws_m5-0001", + "workerSessionId": "wkr_m5-0001", + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "cleanupId": "cln_m5-worker", + "dryRun": true + }, + "occurredAt": "2026-05-21T00:05:00.000Z" + } + ], + "evidenceRecords": [ + { + "evidenceId": "evi_m5-direct-0001", + "projectId": "proj_m5-mvp-e2e", + "operationId": "op_m5-direct-0001", + "kind": "trace", + "uri": "fixtures/mvp/m5-e2e/evidence/direct-operation.txt", + "mimeType": "text/plain", + "sha256": "4cd5cc2c942501b55f7a32f4184e9a1c920b31f13110f44ec68ddc0ad7dc1e34", + "sizeBytes": 180, + "serviceId": "hwlab-cloud-api", + "environment": "dev", + "metadata": { + "traceId": "trc_m5-direct-0001", + "dryRun": true + }, + "createdAt": "2026-05-21T00:02:40.000Z" + }, + { + "evidenceId": "evi_m5-agent-0001", + "projectId": "proj_m5-mvp-e2e", + "operationId": "op_m5-agent-0001", + "agentSessionId": "agt_m5-0001", + "workerSessionId": "wkr_m5-0001", + "kind": "trace", + "uri": "fixtures/mvp/m5-e2e/evidence/agent-operation.txt", + "mimeType": "text/plain", + "sha256": "96e7ffa99efd78b3d5d1b1dfd08be2f077f618dacf786b47f764794714c15c1c", + "sizeBytes": 178, + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "metadata": { + "traceId": "trc_m5-agent-0001", + "dryRun": true + }, + "createdAt": "2026-05-21T00:03:40.000Z" + } + ], + "cleanup": [ + { + "cleanupId": "cln_m5-worker", + "targetType": "worker_session", + "targetId": "wkr_m5-0001", + "status": "completed", + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "requires": [ + "op_m5-agent-0001", + "evi_m5-agent-0001" + ], + "outputs": { + "workerSessionStatus": "completed", + "workspaceReleased": true, + "gatewaySessionClosed": false + }, + "completedAt": "2026-05-21T00:05:00.000Z" + }, + { + "cleanupId": "cln_m5-gateway-state", + "targetType": "gateway_session", + "targetId": "gws_m5-0001", + "status": "completed", + "serviceId": "hwlab-gateway", + "environment": "dev", + "requires": [ + "op_m5-direct-0001", + "op_m5-agent-0001" + ], + "outputs": { + "transientStateCleared": true, + "patchPanelStatePreservedForEvidence": true + }, + "completedAt": "2026-05-21T00:05:10.000Z" + } + ], + "steps": [ + { + "id": "step-dev-health", + "order": 1, + "title": "DEV health", + "kind": "health", + "requires": [], + "inputs": [ + "endpoint", + "health:hlt_m5-dev-ingress", + "health:hlt_m5-edge-route", + "health:hlt_m5-frp", + "health:hlt_m5-router", + "health:hlt_m5-cloud-api", + "health:hlt_m5-cloud-web" + ], + "outputs": [ + "route_m5-dev", + "tun_m5-d601", + "hwlab-dev", + "hwlab-cloud-api", + "hwlab-cloud-web" + ], + "acceptanceStepIds": [ + "static-contract-parse", + "endpoint-freeze", + "dev-ingress-health", + "edge-route", + "frp-tunnel", + "d601-router", + "cloud-surface" + ] + }, + { + "id": "step-gateway-box-status", + "order": 2, + "title": "gateway/box status", + "kind": "status", + "requires": [ + "step-dev-health" + ], + "inputs": [ + "gatewaySession:gws_m5-0001", + "gatewaySession:gws_m5-simu-0001", + "boxResource:res_m5-control-relay", + "boxResource:res_m5-target-board", + "capability:cap_m5-relay-set", + "capability:cap_m5-board-reset" + ], + "outputs": [ + "gws_m5-0001", + "res_m5-control-relay", + "res_m5-target-board", + "cap_m5-relay-set", + "cap_m5-board-reset" + ], + "acceptanceStepIds": [ + "gateway-sim-patch-panel" + ] + }, + { + "id": "step-wiring", + "order": 3, + "title": "wiring", + "kind": "wiring", + "requires": [ + "step-gateway-box-status" + ], + "inputs": [ + "wiringConfig:wir_m5-0001", + "patchPanelStatus:pps_m5-0001" + ], + "outputs": [ + "wir_m5-0001", + "pps_m5-0001" + ], + "acceptanceStepIds": [ + "gateway-sim-patch-panel" + ] + }, + { + "id": "step-direct-hardware-call", + "order": 4, + "title": "direct hardware call", + "kind": "hardware_operation", + "requires": [ + "step-wiring" + ], + "inputs": [ + "rpc:direct-hardware-request", + "operation:op_m5-direct-0001", + "resource:res_m5-control-relay", + "capability:cap_m5-relay-set", + "patchPanelStatus:pps_m5-0001" + ], + "outputs": [ + "op_m5-direct-0001", + "trc_m5-direct-0001" + ], + "acceptanceStepIds": [ + "gateway-sim-patch-panel" + ] + }, + { + "id": "step-audit", + "order": 5, + "title": "audit", + "kind": "audit", + "requires": [ + "step-direct-hardware-call" + ], + "inputs": [ + "audit:aud_m5-project-selected", + "audit:aud_m5-gateway-started", + "audit:aud_m5-wiring-applied", + "audit:aud_m5-direct-operation" + ], + "outputs": [ + "aud_m5-direct-operation" + ], + "acceptanceStepIds": [ + "artifact-observability" + ] + }, + { + "id": "step-agent-session", + "order": 6, + "title": "agent session", + "kind": "agent", + "requires": [ + "step-audit" + ], + "inputs": [ + "agentSession:agt_m5-0001", + "workerSession:wkr_m5-0001", + "health:hlt_m5-agent-mgr", + "health:hlt_m5-agent-worker", + "health:hlt_m5-agent-skills" + ], + "outputs": [ + "agt_m5-0001", + "wkr_m5-0001" + ], + "acceptanceStepIds": [ + "agent-runtime" + ] + }, + { + "id": "step-agent-hardware-call", + "order": 7, + "title": "agent hardware call", + "kind": "hardware_operation", + "requires": [ + "step-agent-session", + "step-wiring" + ], + "inputs": [ + "rpc:agent-hardware-request", + "operation:op_m5-agent-0001", + "agentSession:agt_m5-0001", + "workerSession:wkr_m5-0001", + "resource:res_m5-control-relay", + "capability:cap_m5-relay-set", + "patchPanelStatus:pps_m5-0001" + ], + "outputs": [ + "op_m5-agent-0001", + "trc_m5-agent-0001" + ], + "acceptanceStepIds": [ + "agent-runtime" + ] + }, + { + "id": "step-evidence", + "order": 8, + "title": "evidence", + "kind": "evidence", + "requires": [ + "step-direct-hardware-call", + "step-agent-hardware-call" + ], + "inputs": [ + "trace:trc_m5-direct-0001", + "trace:trc_m5-agent-0001", + "evidence:evi_m5-direct-0001", + "evidence:evi_m5-agent-0001" + ], + "outputs": [ + "evi_m5-direct-0001", + "evi_m5-agent-0001" + ], + "acceptanceStepIds": [ + "agent-runtime", + "artifact-observability" + ] + }, + { + "id": "step-cleanup", + "order": 9, + "title": "cleanup", + "kind": "cleanup", + "requires": [ + "step-evidence" + ], + "inputs": [ + "cleanup:cln_m5-worker", + "cleanup:cln_m5-gateway-state", + "audit:aud_m5-cleanup" + ], + "outputs": [ + "cln_m5-worker", + "cln_m5-gateway-state" + ], + "acceptanceStepIds": [ + "agent-runtime" + ] + } + ], + "expectedSequence": [ + "step-dev-health", + "step-gateway-box-status", + "step-wiring", + "step-direct-hardware-call", + "step-audit", + "step-agent-session", + "step-agent-hardware-call", + "step-evidence", + "step-cleanup" + ], + "realDevGate": { + "command": "hwlab-cli test e2e --env dev --mvp", + "requiresAllBlockersClear": true, + "requiresDryRunPass": true, + "requiresArtifactObservability": true, + "requiresD601RouteObservation": true, + "requiresHumanApprovalForRealDev": true + } +} diff --git a/fixtures/mvp/m5-e2e/evidence/agent-operation.txt b/fixtures/mvp/m5-e2e/evidence/agent-operation.txt new file mode 100644 index 00000000..80970617 --- /dev/null +++ b/fixtures/mvp/m5-e2e/evidence/agent-operation.txt @@ -0,0 +1,6 @@ +M5 MVP E2E dry-run evidence +operationId: op_m5-agent-0001 +traceId: trc_m5-agent-0001 +mode: dry-run +source: fixtures/mvp/m5-e2e/dry-run-plan.json +No DEV or PROD request was made. diff --git a/fixtures/mvp/m5-e2e/evidence/direct-operation.txt b/fixtures/mvp/m5-e2e/evidence/direct-operation.txt new file mode 100644 index 00000000..42b9ca99 --- /dev/null +++ b/fixtures/mvp/m5-e2e/evidence/direct-operation.txt @@ -0,0 +1,6 @@ +M5 MVP E2E dry-run evidence +operationId: op_m5-direct-0001 +traceId: trc_m5-direct-0001 +mode: dry-run +source: fixtures/mvp/m5-e2e/dry-run-plan.json +No DEV or PROD request was made. diff --git a/scripts/m5-mvp-e2e-dry-run.mjs b/scripts/m5-mvp-e2e-dry-run.mjs new file mode 100644 index 00000000..256df6e6 --- /dev/null +++ b/scripts/m5-mvp-e2e-dry-run.mjs @@ -0,0 +1,632 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const planPath = "fixtures/mvp/m5-e2e/dry-run-plan.json"; +const checklistPath = "docs/dev-acceptance-checklist.json"; +const matrixPath = "docs/dev-acceptance-matrix.md"; +const runtimePath = "fixtures/mvp/runtime.json"; +const contractPath = "protocol/mvp-e2e-contract.md"; +const commonSchemaPath = "protocol/schemas/common.json"; +const DEV_ENDPOINT = "http://74.48.78.17:6667"; +const ENVIRONMENT_DEV = "dev"; +const JSON_RPC_VERSION = "2.0"; +let serviceIds = []; +let serviceIdSet = new Set(); + +const requiredArtifactFields = [ + "serviceId", + "commitId", + "image", + "tag", + "digest", + "buildSource", + "deployEnv", + "healthTimestamp" +]; + +const requiredStepSequence = [ + "step-dev-health", + "step-gateway-box-status", + "step-wiring", + "step-direct-hardware-call", + "step-audit", + "step-agent-session", + "step-agent-hardware-call", + "step-evidence", + "step-cleanup" +]; + +const requiredTraceStates = ["accepted", "dispatched", "executed", "completed"]; + +function readJSON(relativePath) { + return readFile(path.join(repoRoot, relativePath), "utf8").then((raw) => JSON.parse(raw)); +} + +function byKey(items, key) { + const map = new Map(); + for (const item of items) { + assert.ok(item && typeof item === "object", `${key} item must be an object`); + assert.ok(item[key], `${key} item missing ${key}`); + assert.equal(map.has(item[key]), false, `${key} ${item[key]} must be unique`); + map.set(item[key], item); + } + return map; +} + +function requireDevObject(label, object, environmentField = "environment") { + assert.equal(object[environmentField], ENVIRONMENT_DEV, `${label} ${environmentField}`); +} + +function requireTimestamp(label, value) { + assert.equal(typeof value, "string", `${label} timestamp must be a string`); + assert.equal(Number.isNaN(Date.parse(value)), false, `${label} timestamp must parse`); +} + +function requireServiceId(label, serviceId) { + assert.equal(serviceIdSet.has(serviceId), true, `${label} unknown serviceId ${serviceId}`); +} + +function assertArrayIncludesAll(label, actual, expected) { + assert.ok(Array.isArray(actual), `${label} must be an array`); + for (const item of expected) { + assert.equal(actual.includes(item), true, `${label} missing ${item}`); + } +} + +function assertNoProdTarget(value, trail = "plan") { + if (typeof value === "string") { + assert.equal(value.includes(":6666"), false, `${trail} must not target PROD port`); + assert.equal(/\bprod\b/i.test(value), false, `${trail} must not target PROD`); + return; + } + + if (Array.isArray(value)) { + value.forEach((item, index) => assertNoProdTarget(item, `${trail}[${index}]`)); + return; + } + + if (value && typeof value === "object") { + for (const [key, nested] of Object.entries(value)) { + if ( + key === "prohibitedActions" || + key === "failureCriteria" || + key === "failRule" || + key === "digest" || + key === "sha256" + ) { + continue; + } + assertNoProdTarget(nested, `${trail}.${key}`); + } + } +} + +function assertObjectHasOnlyDryRunSafety(plan) { + assert.equal(plan.mode, "dry-run", "plan mode"); + assert.equal(plan.safety.allowNetwork, false, "network must be disabled"); + assert.equal(plan.safety.allowDeploy, false, "deploy must be disabled"); + assert.equal(plan.safety.allowBrowserE2E, false, "browser e2e must be disabled"); + assert.equal(plan.safety.allowSecrets, false, "secret reads must be disabled"); + assertArrayIncludesAll("prohibited actions", plan.safety.prohibitedActions, [ + "real-dev-deploy", + "prod-deploy", + "prod-smoke", + "browser-e2e", + "heavyweight-e2e", + "secret-or-token-read", + "force-push", + "unidesk-runtime-substitution" + ]); +} + +function validateMeta(meta) { + assert.ok(meta && typeof meta === "object" && !Array.isArray(meta), "meta must be an object"); + assert.ok(meta.traceId, "meta.traceId is required"); + requireServiceId("meta.serviceId", meta.serviceId); + assert.equal(meta.environment, ENVIRONMENT_DEV, "meta.environment"); +} + +function validateRequest(envelope) { + assert.ok(envelope && typeof envelope === "object" && !Array.isArray(envelope), "request must be an object"); + assert.equal(envelope.jsonrpc, JSON_RPC_VERSION, "request jsonrpc"); + assert.notEqual(envelope.id, undefined, "request id"); + assert.match(envelope.method, /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/, "request method"); + if (Object.hasOwn(envelope, "params")) { + assert.ok( + envelope.params && typeof envelope.params === "object" && !Array.isArray(envelope.params), + "request params must be an object" + ); + } + validateMeta(envelope.meta); +} + +function validateResponse(envelope) { + assert.ok(envelope && typeof envelope === "object" && !Array.isArray(envelope), "response must be an object"); + assert.equal(envelope.jsonrpc, JSON_RPC_VERSION, "response jsonrpc"); + assert.notEqual(envelope.id, undefined, "response id"); + const hasResult = Object.hasOwn(envelope, "result"); + const hasError = Object.hasOwn(envelope, "error"); + assert.notEqual(hasResult, hasError, "response must contain exactly one of result or error"); + if (hasError) { + assert.ok(envelope.error && typeof envelope.error === "object", "response error must be an object"); + assert.equal(Number.isInteger(envelope.error.code), true, "response error code"); + assert.ok(envelope.error.message, "response error message"); + } + validateMeta(envelope.meta); +} + +function assertEndpointFreeze(plan, checklist, runtime, contract) { + assert.equal(plan.environment, ENVIRONMENT_DEV, "plan environment"); + assert.equal(plan.endpoint, DEV_ENDPOINT, "plan endpoint"); + assert.equal(checklist.environment, ENVIRONMENT_DEV, "checklist environment"); + assert.equal(checklist.endpoint, DEV_ENDPOINT, "checklist endpoint"); + assert.equal(runtime.endpoints.dev, DEV_ENDPOINT, "runtime fixture endpoint"); + assert.equal(contract.includes(DEV_ENDPOINT), true, "MVP contract must include DEV endpoint"); + assertNoProdTarget(plan); +} + +function assertArtifacts(plan, checklist) { + assert.deepEqual(checklist.artifactObservabilityFields, requiredArtifactFields); + const artifacts = byKey(plan.artifacts, "serviceId"); + const requiredServiceIds = new Set([ + "hwlab-edge-proxy", + "hwlab-tunnel-client", + "hwlab-router", + "hwlab-cloud-api", + "hwlab-cloud-web", + "hwlab-gateway", + "hwlab-gateway-simu", + "hwlab-box-simu", + "hwlab-patch-panel", + "hwlab-agent-mgr", + "hwlab-agent-worker", + "hwlab-agent-skills", + "hwlab-cli" + ]); + + for (const serviceId of requiredServiceIds) { + assert.equal(artifacts.has(serviceId), true, `artifact missing ${serviceId}`); + } + + for (const artifact of artifacts.values()) { + requireServiceId(`artifact ${artifact.serviceId}`, artifact.serviceId); + for (const field of requiredArtifactFields) { + assert.ok(artifact[field], `artifact ${artifact.serviceId} missing ${field}`); + } + assert.equal(artifact.deployEnv, ENVIRONMENT_DEV, `artifact ${artifact.serviceId} deployEnv`); + requireTimestamp(`artifact ${artifact.serviceId}.healthTimestamp`, artifact.healthTimestamp); + if (artifact.digest === "not_applicable") { + assert.ok( + artifact.digestNotApplicableReason, + `artifact ${artifact.serviceId} must explain not_applicable digest` + ); + } else { + assert.match(artifact.digest, /^sha256:[a-f0-9]{64}$/, `artifact ${artifact.serviceId} digest`); + } + } + + return artifacts; +} + +function assertHealth(plan, checklist, artifacts) { + const healthById = byKey(plan.health, "healthId"); + const healthByComponent = byKey(plan.health, "component"); + const healthByService = new Map(); + for (const health of plan.health) { + if (!healthByService.has(health.serviceId)) { + healthByService.set(health.serviceId, health); + } + } + + for (const contract of checklist.healthContracts) { + const health = healthByComponent.get(contract.component); + assert.ok(health, `health missing component ${contract.component}`); + assert.equal(health.serviceId, contract.serviceId, `health ${contract.component} serviceId`); + assert.equal(artifacts.has(health.serviceId), true, `health ${contract.component} missing artifact`); + assert.equal(health.status, "healthy", `health ${contract.component} status`); + assert.equal(health.deployEnv, ENVIRONMENT_DEV, `health ${contract.component} deployEnv`); + assert.equal(health.dryRun, true, `health ${contract.component} dryRun`); + requireTimestamp(`health ${contract.component}`, health.healthTimestamp); + } + + assert.equal(healthByService.get("hwlab-edge-proxy").endpoint, `${DEV_ENDPOINT}/health`); + assert.equal(healthByService.get("hwlab-cloud-web").output.directHardwareCall, false); + assert.equal(healthByService.get("hwlab-gateway").output.patchPanelRequired, true); + assert.equal(healthByService.get("hwlab-box-simu").output.patchPanelOnlyPropagation, true); + assert.equal(healthByService.get("hwlab-agent-mgr").output.workerLeak, false); + + return { byId: healthById, byService: healthByService, count: plan.health.length }; +} + +function assertAcceptanceSteps(plan, checklist) { + const checklistStepIds = new Set(checklist.smokeSteps.map((step) => step.id)); + assert.deepEqual( + checklist.smokeSteps.map((step) => step.order), + checklist.smokeSteps.map((_, index) => index + 1), + "checklist smoke steps must be ordered" + ); + + const referencedStepIds = new Set(); + for (const step of plan.steps) { + for (const acceptanceStepId of step.acceptanceStepIds) { + assert.equal( + checklistStepIds.has(acceptanceStepId), + true, + `${step.id} references unknown acceptance step ${acceptanceStepId}` + ); + referencedStepIds.add(acceptanceStepId); + } + } + + assertArrayIncludesAll("dry-run acceptance coverage", [...referencedStepIds], [ + "static-contract-parse", + "endpoint-freeze", + "dev-ingress-health", + "edge-route", + "frp-tunnel", + "d601-router", + "cloud-surface", + "gateway-sim-patch-panel", + "agent-runtime", + "artifact-observability" + ]); +} + +function assertStepGraph(plan) { + const steps = plan.steps.slice().sort((a, b) => a.order - b.order); + assert.deepEqual( + steps.map((step) => step.id), + requiredStepSequence, + "M5 sequence must cover required dry-run flow" + ); + assert.deepEqual(plan.expectedSequence, requiredStepSequence, "expectedSequence must match required sequence"); + + const seen = new Set(); + for (const step of steps) { + assert.equal(seen.has(step.id), false, `duplicate step ${step.id}`); + assert.ok(Array.isArray(step.inputs) && step.inputs.length > 0, `${step.id} inputs`); + assert.ok(Array.isArray(step.outputs) && step.outputs.length > 0, `${step.id} outputs`); + for (const dependency of step.requires) { + assert.equal(seen.has(dependency), true, `${step.id} requires ${dependency} before it is produced`); + } + seen.add(step.id); + } +} + +function assertProjectGatewayAndBoxes(plan) { + requireDevObject("project", plan.project); + assert.equal(plan.project.status, "active", "project status"); + + const gatewaySessions = byKey(plan.gatewaySessions, "gatewaySessionId"); + assert.equal(gatewaySessions.has("gws_m5-0001"), true, "real gateway boundary session is required"); + assert.equal(gatewaySessions.has("gws_m5-simu-0001"), true, "gateway simulator session is required"); + + for (const session of gatewaySessions.values()) { + assert.equal(session.projectId, plan.project.projectId, `gateway ${session.gatewaySessionId} project`); + assert.ok(["hwlab-gateway", "hwlab-gateway-simu"].includes(session.serviceId), "gateway serviceId"); + assert.equal(session.status, "connected", `gateway ${session.gatewaySessionId} status`); + requireDevObject(`gateway ${session.gatewaySessionId}`, session); + } + + const resources = byKey(plan.boxResources, "resourceId"); + const capabilities = byKey(plan.boxCapabilities, "capabilityId"); + + for (const resource of resources.values()) { + assert.equal(resource.projectId, plan.project.projectId, `resource ${resource.resourceId} project`); + assert.equal(gatewaySessions.has(resource.gatewaySessionId), true, `resource ${resource.resourceId} gateway`); + assert.equal(resource.state, "available", `resource ${resource.resourceId} state`); + requireDevObject(`resource ${resource.resourceId}`, resource); + } + + for (const capability of capabilities.values()) { + assert.equal(capability.projectId, plan.project.projectId, `capability ${capability.capabilityId} project`); + assert.equal(resources.has(capability.resourceId), true, `capability ${capability.capabilityId} resource`); + assert.match(capability.name, /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/); + } + + return { gatewaySessions, resources, capabilities }; +} + +function assertWiring(plan, { gatewaySessions, resources, capabilities }) { + const wiring = plan.wiringConfig; + assert.equal(wiring.projectId, plan.project.projectId, "wiring project"); + assert.equal(gatewaySessions.has(wiring.gatewaySessionId), true, "wiring gateway"); + assert.equal(wiring.status, "active", "wiring status"); + assert.equal(wiring.constraints.patchPanelOwnsRouting, true, "wiring must be patch-panel-owned"); + assert.equal(wiring.constraints.directBoxBypassAllowed, false, "wiring must reject direct box bypass"); + + for (const connection of wiring.connections) { + for (const side of ["from", "to"]) { + const endpoint = connection[side]; + assert.equal(resources.has(endpoint.resourceId), true, `wiring ${side} resource`); + assert.equal(capabilities.has(endpoint.capabilityId), true, `wiring ${side} capability`); + assert.equal( + capabilities.get(endpoint.capabilityId).resourceId, + endpoint.resourceId, + `wiring ${side} capability must belong to resource` + ); + } + } + + const patch = plan.patchPanelStatus; + assert.equal(patch.projectId, plan.project.projectId, "patch project"); + assert.equal(patch.gatewaySessionId, wiring.gatewaySessionId, "patch gateway"); + assert.equal(patch.wiringConfigId, wiring.wiringConfigId, "patch wiring"); + assert.equal(patch.serviceId, "hwlab-patch-panel", "patch service"); + assert.equal(patch.state, "active", "patch state"); + requireDevObject("patch", patch); + requireTimestamp("patch observedAt", patch.observedAt); + + const activeConnectionKeys = new Set( + patch.activeConnections.map( + (connection) => + `${connection.fromResourceId}:${connection.fromPort}->${connection.toResourceId}:${connection.toPort}` + ) + ); + for (const connection of wiring.connections) { + const key = `${connection.from.resourceId}:${connection.from.port}->${connection.to.resourceId}:${connection.to.port}`; + assert.equal(activeConnectionKeys.has(key), true, `patch panel missing active connection ${key}`); + } +} + +function assertRpcAndOperations(plan, { gatewaySessions, resources, capabilities }) { + const operations = byKey(plan.operations, "operationId"); + const rpcByName = byKey(plan.rpc, "name"); + + for (const rpc of rpcByName.values()) { + validateRequest(rpc.request); + validateResponse(rpc.response); + assert.equal(rpc.request.meta.environment, ENVIRONMENT_DEV, `${rpc.name} request env`); + assert.equal(rpc.response.meta.environment, ENVIRONMENT_DEV, `${rpc.name} response env`); + assert.equal(rpc.request.meta.traceId, rpc.response.meta.traceId, `${rpc.name} trace continuity`); + assert.equal(rpc.request.params.dryRun, undefined, `${rpc.name} params must not use top-level dryRun`); + assert.equal(rpc.request.params.input.dryRun, true, `${rpc.name} input dryRun`); + assert.equal(rpc.response.result.operationId, rpc.operationId, `${rpc.name} response operation`); + assert.equal(operations.has(rpc.operationId), true, `${rpc.name} operation exists`); + } + + assert.equal(rpcByName.has("direct-hardware-request"), true, "direct RPC is required"); + assert.equal(rpcByName.has("agent-hardware-request"), true, "agent RPC is required"); + + for (const operation of operations.values()) { + assert.equal(operation.projectId, plan.project.projectId, `operation ${operation.operationId} project`); + assert.equal(gatewaySessions.has(operation.gatewaySessionId), true, `operation ${operation.operationId} gateway`); + assert.equal(resources.has(operation.resourceId), true, `operation ${operation.operationId} resource`); + assert.equal(capabilities.has(operation.capabilityId), true, `operation ${operation.operationId} capability`); + assert.equal(operation.status, "succeeded", `operation ${operation.operationId} status`); + assert.equal(operation.input.dryRun, true, `operation ${operation.operationId} dryRun`); + assert.equal(operation.output.patchPanelStatusId, plan.patchPanelStatus.patchPanelStatusId); + requireDevObject(`operation ${operation.operationId}`, operation); + } + + const agentOperation = operations.get("op_m5-agent-0001"); + assert.equal(agentOperation.agentSessionId, plan.agentSession.agentSessionId, "agent operation session"); + assert.equal(agentOperation.workerSessionId, plan.workerSession.workerSessionId, "agent operation worker"); + + return { operations, rpcByName }; +} + +function assertAgent(plan, { gatewaySessions }) { + const { agentSession, workerSession } = plan; + assert.equal(agentSession.projectId, plan.project.projectId, "agent project"); + assert.equal(agentSession.serviceId, "hwlab-agent-mgr", "agent service"); + assert.equal(agentSession.status, "completed", "agent status"); + assert.equal(agentSession.metadata.workerSessionId, workerSession.workerSessionId, "agent worker link"); + requireDevObject("agent", agentSession); + + assert.equal(workerSession.agentSessionId, agentSession.agentSessionId, "worker agent link"); + assert.equal(workerSession.projectId, plan.project.projectId, "worker project"); + assert.equal(workerSession.serviceId, "hwlab-agent-worker", "worker service"); + assert.equal(workerSession.status, "completed", "worker status"); + assert.equal(gatewaySessions.has(workerSession.gatewaySessionId), true, "worker gateway"); + assert.equal(workerSession.metadata.scopedProjectId, plan.project.projectId, "worker project scope"); + requireDevObject("worker", workerSession); +} + +function assertTraceAuditEvidenceAndCleanup(plan, { operations }) { + const traceEvents = byKey(plan.traceEvents, "traceEventId"); + const auditEvents = byKey(plan.auditEvents, "auditId"); + const evidenceRecords = byKey(plan.evidenceRecords, "evidenceId"); + const cleanupRecords = byKey(plan.cleanup, "cleanupId"); + + for (const trace of traceEvents.values()) { + assert.equal(trace.projectId, plan.project.projectId, `trace ${trace.traceEventId} project`); + requireServiceId(`trace ${trace.traceEventId}`, trace.serviceId); + requireDevObject(`trace ${trace.traceEventId}`, trace); + requireTimestamp(`trace ${trace.traceEventId}`, trace.occurredAt); + if (trace.operationId) { + assert.equal(operations.has(trace.operationId), true, `trace ${trace.traceEventId} operation`); + } + } + + for (const operationId of ["op_m5-direct-0001", "op_m5-agent-0001"]) { + const states = plan.traceEvents + .filter((trace) => trace.operationId === operationId) + .map((trace) => trace.metadata.state); + assert.deepEqual(states, requiredTraceStates, `${operationId} trace lifecycle`); + } + + for (const audit of auditEvents.values()) { + assert.equal(audit.projectId, plan.project.projectId, `audit ${audit.auditId} project`); + requireServiceId(`audit ${audit.auditId}`, audit.serviceId); + requireDevObject(`audit ${audit.auditId}`, audit); + requireTimestamp(`audit ${audit.auditId}`, audit.occurredAt); + assert.ok(audit.outcome, `audit ${audit.auditId} outcome`); + } + + assert.equal( + [...auditEvents.values()].some((audit) => audit.action === "hardware.operation.request"), + true, + "hardware operation audit required" + ); + assert.equal(auditEvents.has("aud_m5-cleanup"), true, "cleanup audit required"); + + for (const evidence of evidenceRecords.values()) { + assert.equal(evidence.projectId, plan.project.projectId, `evidence ${evidence.evidenceId} project`); + assert.equal(operations.has(evidence.operationId), true, `evidence ${evidence.evidenceId} operation`); + requireServiceId(`evidence ${evidence.evidenceId}`, evidence.serviceId); + requireDevObject(`evidence ${evidence.evidenceId}`, evidence); + requireTimestamp(`evidence ${evidence.evidenceId}`, evidence.createdAt); + } + + for (const operationId of operations.keys()) { + assert.equal( + [...evidenceRecords.values()].some((evidence) => evidence.operationId === operationId), + true, + `evidence missing for ${operationId}` + ); + } + + for (const cleanup of cleanupRecords.values()) { + assert.equal(cleanup.status, "completed", `cleanup ${cleanup.cleanupId} status`); + assert.equal(cleanup.environment, ENVIRONMENT_DEV, `cleanup ${cleanup.cleanupId} environment`); + requireServiceId(`cleanup ${cleanup.cleanupId}`, cleanup.serviceId); + requireTimestamp(`cleanup ${cleanup.cleanupId}`, cleanup.completedAt); + } + + assert.equal(cleanupRecords.get("cln_m5-worker").outputs.workspaceReleased, true, "worker cleanup release"); + assert.equal(cleanupRecords.get("cln_m5-gateway-state").outputs.transientStateCleared, true); + + return { traceEvents, auditEvents, evidenceRecords, cleanupRecords }; +} + +async function assertEvidenceFiles(evidenceRecords) { + for (const evidence of evidenceRecords.values()) { + const absolutePath = path.join(repoRoot, evidence.uri); + const content = await readFile(absolutePath); + const digest = crypto.createHash("sha256").update(content).digest("hex"); + const stats = await stat(absolutePath); + assert.equal(digest, evidence.sha256, `evidence ${evidence.evidenceId} sha256`); + assert.equal(stats.size, evidence.sizeBytes, `evidence ${evidence.evidenceId} sizeBytes`); + } +} + +function assertStepInputsOutputs(plan, indexes) { + const resolvers = { + health: (id) => indexes.healthIds.has(id), + gatewaySession: (id) => indexes.gatewaySessions.has(id), + boxResource: (id) => indexes.resources.has(id), + resource: (id) => indexes.resources.has(id), + capability: (id) => indexes.capabilities.has(id), + wiringConfig: (id) => id === plan.wiringConfig.wiringConfigId, + patchPanelStatus: (id) => id === plan.patchPanelStatus.patchPanelStatusId, + rpc: (id) => indexes.rpcByName.has(id), + operation: (id) => indexes.operations.has(id), + audit: (id) => indexes.auditEvents.has(id), + agentSession: (id) => id === plan.agentSession.agentSessionId, + workerSession: (id) => id === plan.workerSession.workerSessionId, + trace: (id) => indexes.traceIds.has(id), + evidence: (id) => indexes.evidenceRecords.has(id), + cleanup: (id) => indexes.cleanupRecords.has(id) + }; + + for (const step of plan.steps) { + for (const input of step.inputs) { + if (input === "endpoint") { + continue; + } + const [type, id] = input.split(":"); + assert.ok(type && id, `${step.id} input ${input} must be typed`); + assert.ok(resolvers[type], `${step.id} input ${input} has unknown type ${type}`); + assert.equal(resolvers[type](id), true, `${step.id} input ${input} does not resolve`); + } + } + + const allOutputs = new Set([ + "route_m5-dev", + "tun_m5-d601", + "hwlab-dev", + "hwlab-cloud-api", + "hwlab-cloud-web", + ...indexes.gatewaySessions.keys(), + ...indexes.resources.keys(), + ...indexes.capabilities.keys(), + plan.wiringConfig.wiringConfigId, + plan.patchPanelStatus.patchPanelStatusId, + ...indexes.operations.keys(), + ...indexes.traceIds, + ...indexes.auditEvents.keys(), + plan.agentSession.agentSessionId, + plan.workerSession.workerSessionId, + ...indexes.evidenceRecords.keys(), + ...indexes.cleanupRecords.keys() + ]); + + for (const step of plan.steps) { + for (const output of step.outputs) { + assert.equal(allOutputs.has(output), true, `${step.id} output ${output} does not resolve`); + } + } +} + +function assertDependencies(plan) { + assertArrayIncludesAll( + "M0-M4 dependencies", + plan.m0ToM4Dependencies.map((dependency) => dependency.id), + ["m0-contract-audit", "m1-local-smoke", "m2-cloud-core", "m3-hardware-loop", "m4-agent-runtime"] + ); + + const dependencyStepIds = new Set(plan.steps.map((step) => step.id)); + for (const dependency of plan.m0ToM4Dependencies) { + assert.ok(dependency.provides.length > 0, `${dependency.id} provides`); + assert.equal(dependency.status, "contract_only", `${dependency.id} status`); + for (const stepId of dependency.requiredFor) { + assert.equal(dependencyStepIds.has(stepId), true, `${dependency.id} references unknown ${stepId}`); + } + } + + assert.equal(plan.realDevGate.command, "hwlab-cli test e2e --env dev --mvp"); + assert.equal(plan.realDevGate.requiresAllBlockersClear, true); + assert.equal(plan.realDevGate.requiresDryRunPass, true); + assert.equal(plan.realDevGate.requiresHumanApprovalForRealDev, true); +} + +const [plan, checklist, runtime, contract, matrix, commonSchema] = await Promise.all([ + readJSON(planPath), + readJSON(checklistPath), + readJSON(runtimePath), + readFile(path.join(repoRoot, contractPath), "utf8"), + readFile(path.join(repoRoot, matrixPath), "utf8"), + readJSON(commonSchemaPath) +]); + +serviceIds = commonSchema.$defs.serviceId.enum; +serviceIdSet = new Set(serviceIds); + +assert.equal(matrix.includes("HWLAB DEV Acceptance Matrix"), true, "acceptance matrix exists"); +assertObjectHasOnlyDryRunSafety(plan); +assertEndpointFreeze(plan, checklist, runtime, contract); +const artifacts = assertArtifacts(plan, checklist); +const health = assertHealth(plan, checklist, artifacts); +assertAcceptanceSteps(plan, checklist); +assertStepGraph(plan); +const topology = assertProjectGatewayAndBoxes(plan); +assertWiring(plan, topology); +assertAgent(plan, topology); +const rpcAndOperations = assertRpcAndOperations(plan, topology); +const evidenceIndexes = assertTraceAuditEvidenceAndCleanup(plan, rpcAndOperations); +await assertEvidenceFiles(evidenceIndexes.evidenceRecords); +assertStepInputsOutputs(plan, { + ...topology, + ...rpcAndOperations, + ...evidenceIndexes, + healthIds: new Set(health.byId.keys()), + traceIds: new Set(plan.traceEvents.map((trace) => trace.traceId)) +}); +assertDependencies(plan); + +console.log( + [ + `M5 MVP E2E dry-run passed: ${plan.steps.length} steps`, + `${artifacts.size} artifacts`, + `${health.count} health contracts`, + `${rpcAndOperations.operations.size} hardware operations`, + `${evidenceIndexes.evidenceRecords.size} evidence records`, + "0 network calls" + ].join(", ") +);