From 88e37edd19f9642dd9e3732d35cae9926c86c0e1 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 14:07:44 +0000 Subject: [PATCH] feat: add RPT-004 MVP E2E harness --- package.json | 6 +- scripts/rpt004-mvp-e2e-harness.mjs | 12 + scripts/src/rpt004-mvp-e2e-harness.mjs | 1195 +++++++++++++++++++ scripts/src/rpt004-mvp-e2e-harness.test.mjs | 214 ++++ scripts/validate-dev-gate-report.mjs | 138 ++- 5 files changed, 1562 insertions(+), 3 deletions(-) create mode 100644 scripts/rpt004-mvp-e2e-harness.mjs create mode 100644 scripts/src/rpt004-mvp-e2e-harness.mjs create mode 100644 scripts/src/rpt004-mvp-e2e-harness.test.mjs diff --git a/package.json b/package.json index a04324eb..41670e6a 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.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/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.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/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.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 web/hwlab-cloud-web/wiring-status.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.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/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.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/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && 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 web/hwlab-cloud-web/wiring-status.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", @@ -38,6 +38,8 @@ "dev-runtime:provisioning": "node scripts/dev-runtime-provisioning.mjs", "dev-runtime:migration": "node scripts/dev-runtime-migration.mjs", "dev-runtime:postflight": "node scripts/dev-runtime-postflight.mjs", + "rpt004:mvp:e2e": "node scripts/rpt004-mvp-e2e-harness.mjs", + "rpt004:mvp:e2e:live": "node scripts/rpt004-mvp-e2e-harness.mjs --live --url http://74.48.78.17:16666/ --api-url http://74.48.78.17:16667/", "m3:io:e2e": "node scripts/m3-io-control-e2e.mjs", "web:layout": "node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report reports/dev-gate/dev-cloud-workbench-layout.json", "web:layout:build": "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report reports/dev-gate/dev-cloud-workbench-layout-build.json", diff --git a/scripts/rpt004-mvp-e2e-harness.mjs b/scripts/rpt004-mvp-e2e-harness.mjs new file mode 100644 index 00000000..871522b7 --- /dev/null +++ b/scripts/rpt004-mvp-e2e-harness.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { + formatRpt004Failure, + runRpt004Cli +} from "./src/rpt004-mvp-e2e-harness.mjs"; + +try { + process.exitCode = await runRpt004Cli(process.argv.slice(2)); +} catch (error) { + process.stdout.write(`${JSON.stringify(formatRpt004Failure(error), null, 2)}\n`); + process.exitCode = 1; +} diff --git a/scripts/src/rpt004-mvp-e2e-harness.mjs b/scripts/src/rpt004-mvp-e2e-harness.mjs new file mode 100644 index 00000000..86005eff --- /dev/null +++ b/scripts/src/rpt004-mvp-e2e-harness.mjs @@ -0,0 +1,1195 @@ +import { createHash, randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs"; +import { + DEV_ENDPOINT, + DEV_FRONTEND_ENDPOINT, + ENVIRONMENT_DEV, + SERVICE_IDS +} from "../../internal/protocol/index.mjs"; +import { + M3_IO_CHAIN, + M3_IO_CONTROL_ROUTE +} from "../../internal/cloud/m3-io-control.mjs"; +import { + buildM3IoControlSourceReport, + runM3IoControlLiveReport +} from "./m3-io-control-e2e.mjs"; +import { runDevCloudWorkbenchLayoutSmoke } from "./dev-cloud-workbench-smoke-lib.mjs"; +import { classifyCodexRunnerCapability } from "./code-agent-response-contract.mjs"; +import { + HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, + HWLAB_M3_IO_SKILL_NAME +} from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const defaultJsonReportPath = "reports/dev-gate/rpt-004-mvp-e2e.json"; +const defaultMarkdownReportPath = "reports/dev-gate/rpt-004-mvp-e2e.md"; +const issue = "pikasTech/HWLAB#316"; +const taskId = "rpt004-mvp-e2e-harness"; +const reportKind = "rpt-004-mvp-e2e-harness"; +const requiredWorkbenchLabels = Object.freeze([ + "HWLAB 云工作台", + "硬件资源", + "Agent 对话", + "工作清单", + "可信记录", + "内部复核", + "使用说明" +]); +const workbenchMarkers = Object.freeze([ + "data-app-shell", + "workbench-shell", + "resource-tree", + "center-workspace", + "right-sidebar", + "command-form" +]); +const expectedM3OperationIds = Object.freeze([ + ["write-do1-true", "do.write", true], + ["read-di1-true", "di.read", true], + ["write-do1-false", "do.write", false], + ["read-di1-false", "di.read", false] +]); +const statusRank = Object.freeze({ + pass: 0, + not_run: 1, + blocked: 2, + degraded: 2, + not_current: 3 +}); + +export async function runRpt004Cli(argv = [], options = {}) { + const args = parseArgs(argv); + if (args.help) { + process.stdout.write(`${usage()}\n`); + return 0; + } + + const report = await buildRpt004Report(args, options); + if (args.writeReport) { + await writeReports(report, args, options.repoRoot ?? repoRoot); + } + + const printable = args.pretty || args.writeReport ? report : compactReport(report); + process.stdout.write(`${JSON.stringify(printable, null, 2)}\n`); + return report.status === "pass" || args.live !== true ? 0 : 2; +} + +export async function buildRpt004Report(args = {}, options = {}) { + const parsed = normalizeArgs(args); + const now = options.now ?? (() => new Date().toISOString()); + const root = options.repoRoot ?? repoRoot; + const report = baseReport({ parsed, now, root }); + + const expected = await buildExpectedArtifactState({ root, expectedCommit: parsed.expectedCommit }); + report.expected = expected; + report.dimensions.artifact = expected.check; + addCheck(report, expected.check); + + if (!parsed.live) { + addCheck(report, sourceHarnessCheck(root)); + addNotRunDimensions(report, "RPT-004 source check did not touch live DEV endpoints."); + return finalizeReport(report); + } + + validateLiveBoundary(parsed); + const httpGetJson = options.httpGetJson ?? getJson; + const httpGetText = options.httpGetText ?? getText; + const apiHealthUrl = joinBasePath(parsed.apiUrl, "/health/live"); + const webHealthUrl = joinBasePath(parsed.url, "/health/live"); + const [apiHealth, webHealth, html] = await Promise.all([ + httpGetJson(apiHealthUrl, parsed.timeoutMs), + httpGetJson(webHealthUrl, parsed.timeoutMs), + httpGetText(parsed.url, parsed.timeoutMs) + ]); + + report.live.apiHealth = summarizeHealthResponse("hwlab-cloud-api", apiHealthUrl, apiHealth); + report.live.webHealth = summarizeHealthResponse("hwlab-cloud-web", webHealthUrl, webHealth); + report.live.indexHtml = summarizeHtmlResponse(parsed.url, html); + + const revisionCheck = classifyLiveRevision({ + expected, + apiHealth: report.live.apiHealth, + webHealth: report.live.webHealth + }); + const healthCheck = classifyLiveHealth(report.live.apiHealth, report.live.webHealth); + const workbenchCheck = classifyDefaultChineseWorkbench(report.live.indexHtml); + report.dimensions.revision = revisionCheck; + report.dimensions.health = healthCheck; + report.dimensions.workbench = workbenchCheck; + addCheck(report, revisionCheck); + addCheck(report, healthCheck); + addCheck(report, workbenchCheck); + + const currentAndHealthy = revisionCheck.status === "pass" && healthCheck.status === "pass"; + if (!currentAndHealthy) { + const reason = revisionCheck.status !== "pass" + ? "live revision is not current" + : "live health is degraded or blocked"; + addNotRunDimensions(report, `RPT-004 stopped before downstream smoke because ${reason}.`); + return finalizeReport(report); + } + + const layoutCheck = await runLayoutDimension(parsed, options); + report.dimensions.layout = layoutCheck; + addCheck(report, layoutCheck); + + const codeAgentCheck = await runCodeAgentSkillPathDimension(parsed, options); + report.dimensions.codeAgentSkillPath = codeAgentCheck; + addCheck(report, codeAgentCheck); + + const m3Report = await runM3TrustedLoop(parsed, options); + report.live.m3 = m3Report.summary; + report.dimensions.m3TrustedLoop = m3Report.loopCheck; + report.dimensions.durableEvidence = m3Report.durableCheck; + addCheck(report, m3Report.loopCheck); + addCheck(report, m3Report.durableCheck); + + return finalizeReport(report); +} + +export function parseArgs(argv = []) { + const args = { + live: false, + url: `${DEV_FRONTEND_ENDPOINT}/`, + apiUrl: `${DEV_ENDPOINT}/`, + expectedCommit: null, + reportPath: defaultJsonReportPath, + markdownPath: defaultMarkdownReportPath, + writeReport: false, + pretty: false, + timeoutMs: 10000, + help: false + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--check") args.live = false; + else if (arg === "--live") args.live = true; + else if (arg === "--url") args.url = readArg(argv, ++index, arg); + else if (arg === "--api-url") args.apiUrl = readArg(argv, ++index, arg); + else if (arg === "--expected-commit") args.expectedCommit = readArg(argv, ++index, arg); + else if (arg === "--report") { + args.reportPath = readArg(argv, ++index, arg); + args.writeReport = true; + } else if (arg === "--markdown") { + args.markdownPath = readArg(argv, ++index, arg); + args.writeReport = true; + } else if (arg === "--write-report") { + args.writeReport = true; + } else if (arg === "--no-write") { + args.writeReport = false; + } else if (arg === "--pretty") { + args.pretty = true; + } else if (arg === "--timeout-ms") { + args.timeoutMs = parseTimeout(readArg(argv, ++index, arg)); + } else if (arg === "--help" || arg === "-h") { + args.help = true; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + + if (args.live && !argv.includes("--no-write")) { + args.writeReport = true; + } + args.expectedCommit = args.expectedCommit ? sanitizeCommit(args.expectedCommit) : null; + if (args.expectedCommit === "unknown") { + throw new Error("--expected-commit must be a 7-40 character Git SHA"); + } + return args; +} + +export function classifyRpt004Conclusion(checks = []) { + const byId = new Map(checks.map((check) => [check.id, check])); + const revision = byId.get("live-revision-current"); + if (revision?.status === "not_current") { + return { + status: "not_current", + classification: "NOT_CURRENT", + mvpPass: false, + summary: "RPT-004 is NOT_CURRENT: live 16666/16667 revision does not match the expected DEV artifact commit." + }; + } + + const worst = worstStatus(checks.map((check) => check.status)); + if (worst === "pass") { + return { + status: "pass", + classification: "MVP_PASS", + mvpPass: true, + summary: "RPT-004 MVP PASS: current live revision, default Chinese workbench, layout smoke, Code Agent skill path, M3 trusted IO loop, and durable evidence are green." + }; + } + + const sourceOnly = checks.some((check) => check.id === "rpt004-source-contract") && + checks.every((check) => ["pass", "not_run"].includes(check.status)); + if (sourceOnly) { + return { + status: "pass", + classification: "CHECK_ONLY", + mvpPass: false, + summary: "RPT-004 harness source check passed; no live DEV smoke ran, so this is not MVP PASS." + }; + } + + return { + status: "blocked", + classification: "BLOCKED", + mvpPass: false, + summary: "RPT-004 is BLOCKED: at least one required live dimension is blocked, degraded, skipped, or not run." + }; +} + +export function classifyCodeAgentSkillPath(payload, { httpStatus = null, httpOk = false } = {}) { + const capability = classifyCodexRunnerCapability(payload, { httpStatus }); + const toolCalls = Array.isArray(payload?.toolCalls) ? payload.toolCalls : []; + const skillItems = Array.isArray(payload?.skills?.items) ? payload.skills.items : []; + const openAiFallback = + payload?.provider === "openai-responses" || + payload?.runner?.kind === "openai-responses-fallback" || + payload?.implementationType === "openai-responses-fallback"; + const skillsDiscoverCompleted = toolCalls.some((tool) => + tool?.name === "skills.discover" && tool?.status === "completed" + ); + const repoSkillReady = skillItems.some((skill) => + skill?.name === "hwlab-agent-runtime" || + skill?.name === HWLAB_M3_IO_SKILL_NAME || + String(skill?.source ?? "").includes("skills/hwlab-agent-runtime/SKILL.md") + ); + const missing = []; + if (!httpOk) missing.push(`HTTP ${httpStatus ?? "not_observed"}`); + if (capability.status !== "pass") missing.push(capability.blocker ?? "runner-capability"); + if (openAiFallback) missing.push("openai-fallback"); + if (!skillsDiscoverCompleted) missing.push("skills.discover toolCall"); + if (payload?.skills?.status !== "ready") missing.push("skills.status=ready"); + if (!repoSkillReady) missing.push("repo-owned hwlab-agent-runtime skill"); + + return { + id: "code-agent-skill-path", + status: missing.length === 0 ? "pass" : "blocked", + summary: missing.length === 0 + ? "Code Agent skill discovery used the repo-owned Codex stdio runner with completed toolCalls and hwlab-agent-runtime skill evidence." + : `Code Agent skill path is blocked: missing ${missing.join(", ")}.`, + evidenceLevel: missing.length === 0 ? "DEV-LIVE" : "BLOCKED", + observations: { + provider: payload?.provider ?? null, + model: payload?.model ?? null, + backend: payload?.backend ?? null, + runnerKind: payload?.runner?.kind ?? null, + capability, + openAiFallback, + toolCalls: toolCalls.map((tool) => ({ + name: tool?.name ?? null, + status: tool?.status ?? null, + type: tool?.type ?? null + })), + skills: { + status: payload?.skills?.status ?? null, + count: payload?.skills?.count ?? (Array.isArray(payload?.skills?.items) ? payload.skills.items.length : null), + repoSkillReady + }, + assistantReplyStored: false + } + }; +} + +export function classifyM3DurableEvidence(operations = []) { + const byId = new Map(operations.map((operation) => [operation.id, operation])); + const sequence = expectedM3OperationIds.map(([id, action, expectedValue]) => { + const operation = byId.get(id) ?? {}; + const evidenceState = operation.evidenceState ?? {}; + return { + id, + action, + expectedValue, + observedValue: Object.hasOwn(operation, "resultValue") ? operation.resultValue : null, + status: operation.status ?? "missing", + idsPresent: Boolean(operation.operationId && operation.traceId && operation.auditId && operation.evidenceId), + valueMatches: operation.resultValue === expectedValue, + durableGreen: evidenceState.status === "green" && + evidenceState.sourceKind === "DEV-LIVE" && + evidenceState.durable === true, + persisted: evidenceState.writeStatus === "persisted" + }; + }); + const ready = sequence.every((step) => + step.status === "succeeded" && + step.idsPresent && + step.valueMatches && + step.durableGreen && + step.persisted + ); + return { + id: "operation-audit-evidence-durable-green", + status: ready ? "pass" : "blocked", + summary: ready + ? "Every M3 DO/DI step produced operationId, traceId, auditId, evidenceId, DEV-LIVE green evidence, and persisted write state." + : "M3 operation/audit/evidence durable green contract is incomplete.", + evidenceLevel: ready ? "DEV-LIVE" : "BLOCKED", + observations: { + requiredRoute: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.patchPanelServiceId} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`, + sequence + } + }; +} + +function baseReport({ parsed, now, root }) { + const source = observeSource(root); + return { + $schema: "https://hwlab.pikastech.local/schemas/rpt-004-mvp-e2e-harness.schema.json", + $id: "https://hwlab.pikastech.local/reports/dev-gate/rpt-004-mvp-e2e.json", + reportVersion: "v1", + reportKind, + issue, + supports: [ + "pikasTech/HWLAB#7", + "pikasTech/HWLAB#78", + "pikasTech/HWLAB#131", + "pikasTech/HWLAB#227", + "pikasTech/HWLAB#236", + "pikasTech/HWLAB#315", + "pikasTech/HWLAB#316", + "pikasTech/HWLAB#334" + ], + taskId, + task: "DC-DCSN-P0-2026-003", + acceptanceLevel: "rpt004_mvp_e2e", + devOnly: true, + prodDisabled: true, + reportLifecycle: activeReportLifecycle("RPT-004 live MVP E2E harness for active DEV 16666/16667 verification."), + mode: parsed.live ? "dev-live" : "source-check", + status: "blocked", + generatedAt: now(), + commitId: source.reportCommitId, + source, + target: { + environment: ENVIRONMENT_DEV, + browserUrl: parsed.url, + apiUrl: parsed.apiUrl, + apiHealthUrl: joinBasePath(parsed.apiUrl, "/health/live"), + webHealthUrl: joinBasePath(parsed.url, "/health/live"), + m3IoUrl: joinBasePath(parsed.apiUrl, M3_IO_CONTROL_ROUTE) + }, + sourceContract: { + status: "pass", + documents: [ + "AGENTS.md", + "docs/reference/dev-runtime-boundary.md", + "docs/reference/cloud-workbench.md", + "docs/reference/code-agent-chat-readiness.md", + "skills/hwlab-agent-runtime/SKILL.md" + ], + summary: "RPT-004 keeps layout, live revision, M3 trusted IO, durable evidence, and Code Agent skill-path checks in one repo-owned entrypoint." + }, + validationCommands: [ + "node --check scripts/rpt004-mvp-e2e-harness.mjs", + "node --check scripts/src/rpt004-mvp-e2e-harness.mjs", + "node --test scripts/src/rpt004-mvp-e2e-harness.test.mjs", + "node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write", + "node scripts/rpt004-mvp-e2e-harness.mjs --live --url http://74.48.78.17:16666/ --api-url http://74.48.78.17:16667/", + "node scripts/validate-dev-gate-report.mjs reports/dev-gate/rpt-004-mvp-e2e.json" + ], + localSmoke: { + status: "not_run", + commands: ["node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write"], + evidence: ["Source-check mode validates the harness without touching live DEV."], + summary: "Local/source check is not a substitute for RPT-004 live MVP PASS." + }, + dryRun: { + status: "not_applicable", + commands: ["node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write"], + evidence: ["RPT-004 is a bounded live smoke harness, not a deployment dry-run."], + summary: "No deploy apply, rollout, kubectl mutation, or PROD action is performed." + }, + devPreconditions: { + status: parsed.live ? "checked-live" : "source-only", + requirements: [ + "16666 and 16667 must be the active DEV endpoints.", + "Live API and Cloud Web revisions must match the expected DEV artifact commit.", + "API /health/live must be ready and durable before the M3 trusted IO loop runs.", + "OpenAI Responses text fallback cannot satisfy the Code Agent skill-path/toolCalls gate." + ], + summary: "RPT-004 stops before M3 live writes when revision or health preconditions are not green." + }, + expected: null, + live: {}, + dimensions: { + artifact: null, + revision: null, + health: null, + workbench: null, + layout: null, + m3TrustedLoop: null, + durableEvidence: null, + codeAgentSkillPath: null + }, + checks: [], + blockers: [], + safety: { + devOnly: true, + prodTouched: false, + deployApplyAttempted: false, + rolloutAttempted: false, + kubectlMutationAttempted: false, + readsKubernetesSecrets: false, + secretValuesPrinted: false, + liveM3WritesRequireCurrentHealthyRevision: true, + m3WritesLimitedToSimulatorDoDiResetLoop: true, + openAiFallbackCountsAsPass: false, + statement: "This harness performs only public DEV HTTP checks, browser layout smoke, Code Agent skill discovery, and the bounded M3 simulator DO/DI loop after current+healthy preconditions. It never applies deploys or starts rollouts." + } + }; +} + +async function buildExpectedArtifactState({ root, expectedCommit }) { + const deploy = await readJsonOptional(root, "deploy/deploy.json"); + const catalog = await readJsonOptional(root, "deploy/artifact-catalog.dev.json"); + const artifactReport = await readJsonOptional(root, "reports/dev-gate/dev-artifacts.json"); + const deployCommit = sanitizeCommit(deploy?.commitId); + const catalogCommit = sanitizeCommit(catalog?.commitId); + const reportCommit = sanitizeCommit(artifactReport?.artifactPublish?.sourceCommitId ?? artifactReport?.commitId); + const commitId = expectedCommit ?? firstKnown(deployCommit, catalogCommit, reportCommit); + const services = Array.isArray(catalog?.services) ? catalog.services : []; + const serviceStates = services.map((service) => { + const serviceCommit = sanitizeCommit(service?.commitId); + return { + serviceId: service?.serviceId ?? "unknown", + commitId: serviceCommit, + imageTag: String(service?.imageTag ?? imageTagFromImage(service?.image) ?? "unknown"), + digest: String(service?.digest ?? "unknown"), + publishState: service?.publishState ?? "unknown", + commitMatches: commitEquivalent(serviceCommit, commitId), + digestPresent: /^sha256:[a-f0-9]{64}$/u.test(String(service?.digest ?? "")) + }; + }); + const missingServices = SERVICE_IDS.filter((serviceId) => !serviceStates.some((service) => service.serviceId === serviceId)); + const mismatches = []; + if (!commitId || commitId === "unknown") mismatches.push("expected commit not observed"); + if (deployCommit !== "unknown" && !commitEquivalent(deployCommit, commitId)) mismatches.push("deploy commit mismatch"); + if (catalogCommit !== "unknown" && !commitEquivalent(catalogCommit, commitId)) mismatches.push("catalog commit mismatch"); + if (reportCommit !== "unknown" && !commitEquivalent(reportCommit, commitId)) mismatches.push("artifact report commit mismatch"); + if (catalog?.artifactState !== "published") mismatches.push("artifact catalog is not published"); + if (catalog?.publish?.ciPublished !== true || catalog?.publish?.registryVerified !== true) { + mismatches.push("artifact catalog publish flags are not green"); + } + if (missingServices.length > 0) mismatches.push(`missing services: ${missingServices.join(",")}`); + for (const service of serviceStates) { + if (service.publishState !== "published") mismatches.push(`${service.serviceId} is not published`); + if (!service.commitMatches) mismatches.push(`${service.serviceId} commit mismatch`); + if (!service.digestPresent) mismatches.push(`${service.serviceId} digest missing`); + } + + const status = mismatches.length === 0 ? "pass" : "blocked"; + return { + commitId, + shortCommitId: commitId === "unknown" ? "unknown" : commitId.slice(0, 7), + imageTag: commitId === "unknown" ? "unknown" : commitId.slice(0, 7), + deployCommit, + catalogCommit, + artifactReportCommit: reportCommit, + serviceCount: serviceStates.length, + requiredServiceCount: SERVICE_IDS.length, + services: serviceStates, + check: { + id: "expected-artifact-current", + status, + summary: status === "pass" + ? `Expected DEV artifact commit ${commitId.slice(0, 7)} is published for all required services.` + : `Expected DEV artifact state is blocked: ${mismatches.join("; ")}.`, + evidenceLevel: status === "pass" ? "SOURCE" : "BLOCKED", + observations: { + commitId, + deployCommit, + catalogCommit, + artifactReportCommit: reportCommit, + serviceCount: serviceStates.length, + missingServices, + mismatches + } + } + }; +} + +function sourceHarnessCheck(root) { + const requiredFiles = [ + "scripts/rpt004-mvp-e2e-harness.mjs", + "scripts/src/rpt004-mvp-e2e-harness.mjs", + "scripts/src/rpt004-mvp-e2e-harness.test.mjs", + "scripts/dev-cloud-workbench-layout-smoke.mjs", + "scripts/m3-io-control-e2e.mjs", + "scripts/code-agent-chat-smoke.mjs", + "skills/hwlab-agent-runtime/SKILL.md" + ]; + const missing = requiredFiles.filter((file) => !pathExists(root, file)); + return { + id: "rpt004-source-contract", + status: missing.length === 0 ? "pass" : "blocked", + summary: missing.length === 0 + ? "RPT-004 source harness and reused smoke entrypoints are present." + : `RPT-004 source harness is missing files: ${missing.join(", ")}.`, + evidenceLevel: missing.length === 0 ? "SOURCE" : "BLOCKED", + observations: { + requiredFiles, + missing + } + }; +} + +function classifyLiveRevision({ expected, apiHealth, webHealth }) { + const mismatches = []; + if (expected.check.status !== "pass") mismatches.push("expected artifact state is not green"); + if (!apiHealth.reachable) mismatches.push("api health not reachable"); + if (!webHealth.reachable) mismatches.push("web health not reachable"); + if (apiHealth.environment !== ENVIRONMENT_DEV) mismatches.push(`api environment=${apiHealth.environment}`); + if (webHealth.environment !== ENVIRONMENT_DEV) mismatches.push(`web environment=${webHealth.environment}`); + if (!commitEquivalent(apiHealth.revision, expected.commitId) && apiHealth.imageTag !== expected.imageTag) { + mismatches.push(`api revision ${apiHealth.revision}/${apiHealth.imageTag} != ${expected.shortCommitId}`); + } + if (!commitEquivalent(webHealth.revision, expected.commitId) && webHealth.imageTag !== expected.imageTag) { + mismatches.push(`web revision ${webHealth.revision}/${webHealth.imageTag} != ${expected.shortCommitId}`); + } + const status = mismatches.length === 0 ? "pass" : "not_current"; + return { + id: "live-revision-current", + status, + summary: status === "pass" + ? `Live 16666/16667 revisions match expected artifact commit ${expected.shortCommitId}.` + : `Live revision is NOT_CURRENT: ${mismatches.join("; ")}.`, + evidenceLevel: status === "pass" ? "DEV-LIVE" : "BLOCKED", + observations: { + expectedCommit: expected.commitId, + expectedImageTag: expected.imageTag, + api: { + endpoint: apiHealth.endpoint, + serviceId: apiHealth.serviceId, + revision: apiHealth.revision, + imageTag: apiHealth.imageTag, + digest: apiHealth.digest + }, + web: { + endpoint: webHealth.endpoint, + serviceId: webHealth.serviceId, + revision: webHealth.revision, + imageTag: webHealth.imageTag, + digest: webHealth.digest + }, + mismatches + } + }; +} + +function classifyLiveHealth(apiHealth, webHealth) { + const blockers = []; + if (!apiHealth.reachable) blockers.push("api /health/live unreachable"); + if (!webHealth.reachable) blockers.push("web /health/live unreachable"); + if (!["ready", "ok"].includes(apiHealth.healthStatus) || apiHealth.ready !== true) { + blockers.push(`api health ${apiHealth.healthStatus}; ready=${apiHealth.ready}`); + } + if (apiHealth.runtime.durable !== true || apiHealth.runtime.ready !== true || apiHealth.readiness.durability.ready !== true) { + blockers.push(`runtime durable not green: ${apiHealth.runtime.status}/${apiHealth.runtime.blocker}`); + } + if (!["ok", "ready"].includes(webHealth.healthStatus)) { + blockers.push(`web health ${webHealth.healthStatus}`); + } + return { + id: "live-health-ready", + status: blockers.length === 0 ? "pass" : "blocked", + summary: blockers.length === 0 + ? "Live health is ready and runtime durable readiness is green." + : `Live health is BLOCKED/degraded: ${blockers.join("; ")}.`, + evidenceLevel: blockers.length === 0 ? "DEV-LIVE" : "BLOCKED", + observations: { + apiStatus: apiHealth.healthStatus, + apiReady: apiHealth.ready, + runtime: apiHealth.runtime, + readiness: apiHealth.readiness, + blockerCodes: apiHealth.blockerCodes, + webStatus: webHealth.healthStatus, + blockers + } + }; +} + +function classifyDefaultChineseWorkbench(html) { + const missingLabels = requiredWorkbenchLabels.filter((label) => !html.text.includes(label)); + const missingMarkers = workbenchMarkers.filter((marker) => !html.text.includes(marker)); + const workspaceDefault = /data-view=["']workspace["'][^>]*>/u.test(html.text) && + !/]*data-view=["']workspace["'][^>]*hidden/u.test(html.text); + const gateHidden = /data-view=["']gate["'][^>]*hidden/u.test(html.text); + const blockers = []; + if (!html.ok) blockers.push(`index HTML HTTP ${html.httpStatus ?? "not_observed"}`); + if (missingLabels.length > 0) blockers.push(`missing Chinese labels: ${missingLabels.join(",")}`); + if (missingMarkers.length > 0) blockers.push(`missing workbench markers: ${missingMarkers.join(",")}`); + if (!workspaceDefault) blockers.push("workspace view is not the default visible view"); + if (!gateHidden) blockers.push("internal gate view is not hidden by default"); + return { + id: "16666-default-chinese-workbench", + status: blockers.length === 0 ? "pass" : "blocked", + summary: blockers.length === 0 + ? "16666 default route serves the Chinese user workbench." + : `16666 default Chinese workbench check is blocked: ${blockers.join("; ")}.`, + evidenceLevel: blockers.length === 0 ? "DEV-LIVE" : "BLOCKED", + observations: { + url: html.url, + httpStatus: html.httpStatus, + contentType: html.contentType, + missingLabels, + missingMarkers, + workspaceDefault, + gateHidden, + bodyHash: html.bodyHash + } + }; +} + +async function runLayoutDimension(parsed, options) { + const layoutRunner = options.layoutRunner ?? runDevCloudWorkbenchLayoutSmoke; + const layout = await layoutRunner({ + live: true, + urlExplicit: true, + url: parsed.url, + timeoutMs: parsed.timeoutMs, + artifactDir: path.join("tmp", "rpt004-layout", safeTimestamp()) + }); + const pass = layout.status === "pass"; + return { + id: "layout-smoke-hit-target", + status: pass ? "pass" : "blocked", + summary: pass + ? "Live layout smoke passed across desktop/mobile hit target, overflow, and route checks." + : `Live layout smoke is blocked: ${layout.summary ?? layout.status}.`, + evidenceLevel: pass ? "DEV-LIVE-LAYOUT" : "BLOCKED", + observations: { + status: layout.status, + sourceMode: layout.sourceMode, + url: layout.url, + failureCount: Array.isArray(layout.failures) ? layout.failures.length : 0, + blockers: layout.blockers ?? [], + screenshotDir: layout.artifacts?.screenshotDir ?? null, + screenshotCount: Array.isArray(layout.artifacts?.screenshots) ? layout.artifacts.screenshots.length : 0 + } + }; +} + +async function runCodeAgentSkillPathDimension(parsed, options) { + const endpoint = joinBasePath(parsed.apiUrl, "/v1/agent/chat"); + const response = options.codeAgentRunner + ? await options.codeAgentRunner({ endpoint, parsed }) + : await postJson(endpoint, { + conversationId: `cnv_rpt004_skills_${randomUUID()}`, + traceId: `trc_rpt004_skills_${randomUUID()}`, + projectId: "prj_hwlab-cloud-workbench", + message: "列出你能使用的所有skill" + }, parsed.timeoutMs); + const payload = response.json ?? response.body ?? null; + const check = classifyCodeAgentSkillPath(payload, { + httpStatus: response.status, + httpOk: response.ok + }); + return { + ...check, + observations: { + ...check.observations, + endpoint, + httpStatus: response.status, + traceId: payload?.traceId ?? null, + messageId: payload?.messageId ?? null, + assistantReplyStored: false + } + }; +} + +async function runM3TrustedLoop(parsed, options) { + const m3Runner = options.m3Runner ?? defaultM3Runner; + const fullReport = await m3Runner(parsed); + const operations = Array.isArray(fullReport.liveOperations) ? fullReport.liveOperations : []; + const durableCheck = classifyM3DurableEvidence(operations); + const trustedGreen = fullReport.summary?.trustedGreen === true && + fullReport.summary?.status === "pass" && + durableCheck.status === "pass"; + const loopCheck = { + id: "m3-v1-io-trusted-loop", + status: trustedGreen ? "pass" : "blocked", + summary: trustedGreen + ? "M3 /v1/m3/io DO1 true/read/false/read trusted loop passed through the patch-panel route." + : `M3 /v1/m3/io trusted loop is blocked: ${fullReport.summary?.classification ?? "unknown"}.`, + evidenceLevel: trustedGreen ? "DEV-LIVE" : "BLOCKED", + observations: { + route: M3_IO_CONTROL_ROUTE, + requiredRoute: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.patchPanelServiceId} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`, + classification: fullReport.summary?.classification ?? null, + trustedGreen: fullReport.summary?.trustedGreen === true, + operationCount: operations.length, + operations: operations.map((operation) => ({ + id: operation.id, + action: operation.action, + status: operation.status, + resultValue: Object.hasOwn(operation, "resultValue") ? operation.resultValue : null, + operationId: operation.operationId ?? null, + traceId: operation.traceId ?? null, + auditId: operation.auditId ?? null, + evidenceId: operation.evidenceId ?? null, + evidenceState: { + status: operation.evidenceState?.status ?? null, + sourceKind: operation.evidenceState?.sourceKind ?? null, + durable: operation.evidenceState?.durable === true, + writeStatus: operation.evidenceState?.writeStatus ?? null + } + })) + } + }; + return { + loopCheck, + durableCheck, + summary: { + mode: fullReport.mode, + status: fullReport.summary?.status ?? "unknown", + classification: fullReport.summary?.classification ?? null, + trustedGreen: fullReport.summary?.trustedGreen === true, + operationCount: operations.length + } + }; +} + +async function defaultM3Runner(parsed) { + const sourceReport = await buildM3IoControlSourceReport({ repoRoot }); + return runM3IoControlLiveReport({ + flags: new Set(["--live", "--confirm-dev", "--confirmed-non-production"]), + live: true, + confirmDev: true, + confirmedNonProduction: true, + target: "api", + frontendUrl: parsed.url, + apiUrl: joinBasePath(parsed.apiUrl, M3_IO_CONTROL_ROUTE), + timeoutMs: parsed.timeoutMs + }, sourceReport); +} + +function addNotRunDimensions(report, reason) { + for (const [key, id] of [ + ["layout", "layout-smoke-hit-target"], + ["codeAgentSkillPath", "code-agent-skill-path"], + ["m3TrustedLoop", "m3-v1-io-trusted-loop"], + ["durableEvidence", "operation-audit-evidence-durable-green"] + ]) { + if (report.dimensions[key]) continue; + const check = { + id, + status: "not_run", + summary: reason, + evidenceLevel: "BLOCKED", + observations: { + skipped: true, + reason + } + }; + report.dimensions[key] = check; + addCheck(report, check); + } +} + +function finalizeReport(report) { + const conclusion = classifyRpt004Conclusion(report.checks); + report.status = conclusion.status; + report.conclusion = conclusion; + report.evidenceLevel = conclusion.mvpPass ? "DEV-LIVE-MVP" : conclusion.classification; + report.devLive = conclusion.mvpPass; + report.localSmoke.status = report.mode === "source-check" ? "pass" : "not_run"; + report.devPreconditions.status = conclusion.status === "pass" ? "pass" : conclusion.status; + report.blockers = report.checks + .filter((check) => !["pass"].includes(check.status)) + .filter((check) => !(conclusion.classification === "CHECK_ONLY" && check.status === "not_run")) + .map((check) => ({ + type: check.status === "not_current" ? "runtime_blocker" : "observability_blocker", + scope: check.id, + status: "open", + summary: check.summary, + evidenceLevel: check.evidenceLevel ?? "BLOCKED" + })); + return report; +} + +function addCheck(report, check) { + if (!check) return; + report.checks.push(check); +} + +function compactReport(report) { + return { + reportKind: report.reportKind, + taskId: report.taskId, + mode: report.mode, + status: report.status, + conclusion: report.conclusion, + expected: { + commitId: report.expected?.commitId, + imageTag: report.expected?.imageTag, + serviceCount: report.expected?.serviceCount + }, + target: report.target, + dimensions: Object.fromEntries(Object.entries(report.dimensions).map(([key, value]) => [ + key, + value ? { + status: value.status, + summary: value.summary + } : null + ])), + blockers: report.blockers, + artifacts: report.artifacts ?? null, + safety: report.safety + }; +} + +async function writeReports(report, args, root) { + const jsonPath = path.resolve(root, args.reportPath); + const markdownPath = path.resolve(root, args.markdownPath); + report.artifacts = { + jsonReportPath: relativeOrAbsolute(jsonPath, root), + markdownReportPath: relativeOrAbsolute(markdownPath, root), + artifactReport: "not applicable: harness/scripts/tests only; no runtime service image was changed by RPT-004." + }; + await mkdir(path.dirname(jsonPath), { recursive: true }); + await mkdir(path.dirname(markdownPath), { recursive: true }); + await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + await writeFile(markdownPath, renderMarkdown(report), "utf8"); +} + +export function renderMarkdown(report) { + const rows = Object.entries(report.dimensions).map(([key, check]) => + `| ${key} | ${check?.status ?? "missing"} | ${escapeMarkdown(check?.summary ?? "missing")} |` + ); + return [ + "# RPT-004 MVP E2E Harness Report", + "", + `- status: ${report.status}`, + `- classification: ${report.conclusion?.classification ?? "unknown"}`, + `- mvpPass: ${report.conclusion?.mvpPass === true}`, + `- generatedAt: ${report.generatedAt}`, + `- expectedCommit: ${report.expected?.commitId ?? "unknown"}`, + "", + "| Dimension | Status | Summary |", + "| --- | --- | --- |", + ...rows, + "", + "## Blockers", + "", + ...(report.blockers.length === 0 + ? ["- none"] + : report.blockers.map((blocker) => `- ${blocker.scope}: ${blocker.summary}`)), + "", + "## Safety", + "", + `- deployApplyAttempted: ${report.safety.deployApplyAttempted}`, + `- rolloutAttempted: ${report.safety.rolloutAttempted}`, + `- secretValuesPrinted: ${report.safety.secretValuesPrinted}`, + `- openAiFallbackCountsAsPass: ${report.safety.openAiFallbackCountsAsPass}`, + "" + ].join("\n"); +} + +function summarizeHealthResponse(serviceId, endpoint, response) { + const json = response.json ?? {}; + const revision = sanitizeCommit(json.commit?.id ?? json.commitId ?? json.revision); + const imageTag = sanitizeRuntimeString(json.image?.tag ?? imageTagFromImage(json.image?.reference)); + return { + endpoint, + reachable: response.ok === true && Boolean(response.json), + httpStatus: response.status, + serviceId: sanitizeRuntimeString(json.serviceId ?? serviceId), + environment: sanitizeRuntimeString(json.environment), + healthStatus: sanitizeRuntimeString(json.status), + ready: json.ready === true, + revision, + imageTag, + digest: sanitizeRuntimeString(json.image?.digest), + observedAt: sanitizeTimestamp(json.observedAt), + runtime: { + durable: json.runtime?.durable === true, + ready: json.runtime?.ready === true, + status: sanitizeRuntimeString(json.runtime?.status), + blocker: sanitizeRuntimeString(json.runtime?.blocker), + liveRuntimeEvidence: json.runtime?.liveRuntimeEvidence === true + }, + readiness: { + ready: json.readiness?.ready === true, + status: sanitizeRuntimeString(json.readiness?.status), + durability: { + ready: json.readiness?.durability?.ready === true, + status: sanitizeRuntimeString(json.readiness?.durability?.status), + blocker: sanitizeRuntimeString(json.readiness?.durability?.blocker), + blockedLayer: sanitizeRuntimeString(json.readiness?.durability?.blockedLayer), + queryResult: sanitizeRuntimeString(json.readiness?.durability?.queryResult) + } + }, + blockerCodes: Array.isArray(json.blockerCodes) + ? json.blockerCodes.filter((item) => typeof item === "string") + : [], + error: response.error ? redactFailureText(response.error) : null + }; +} + +function summarizeHtmlResponse(url, response) { + const text = typeof response.body === "string" ? response.body : ""; + return { + url, + ok: response.ok === true && response.status === 200 && /text\/html/iu.test(response.contentType ?? ""), + httpStatus: response.status, + contentType: response.contentType ?? "", + text, + bodyHash: createHash("sha256").update(text).digest("hex").slice(0, 16), + error: response.error ? redactFailureText(response.error) : null + }; +} + +async function getJson(url, timeoutMs) { + const response = await request(url, { method: "GET", timeoutMs, headers: { accept: "application/json" } }); + return { + ...response, + json: parseJson(response.body) + }; +} + +async function getText(url, timeoutMs) { + return request(url, { method: "GET", timeoutMs, headers: { accept: "text/html,*/*" } }); +} + +async function postJson(url, body, timeoutMs) { + const response = await request(url, { + method: "POST", + timeoutMs, + headers: { + accept: "application/json", + "content-type": "application/json" + }, + body: JSON.stringify(body) + }); + return { + ...response, + json: parseJson(response.body) + }; +} + +async function request(url, { method, timeoutMs, headers, body } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method, + headers, + body, + signal: controller.signal + }); + const text = await response.text(); + return { + ok: response.ok, + status: response.status, + contentType: response.headers.get("content-type") ?? "", + body: text, + error: response.ok ? null : response.statusText + }; + } catch (error) { + return { + ok: false, + status: 0, + contentType: "", + body: "", + error: error.name === "AbortError" ? `request timed out after ${timeoutMs}ms` : error.message + }; + } finally { + clearTimeout(timer); + } +} + +export function validateLiveBoundary(parsed) { + for (const [name, value, expectedPort] of [ + ["--url", parsed.url, 16666], + ["--api-url", parsed.apiUrl, 16667] + ]) { + const url = new URL(value); + const port = Number(url.port || (url.protocol === "http:" ? 80 : 443)); + if (url.protocol !== "http:" || url.hostname !== "74.48.78.17" || port !== expectedPort) { + throw new Error(`${name} must target active DEV http://74.48.78.17:${expectedPort}/`); + } + if (port === 6666 || port === 6667) { + throw new Error(`${name} must use active DEV ports 16666/16667, not deprecated ${port}`); + } + if (/\bprod(?:uction)?\b/iu.test(url.href)) { + throw new Error(`${name} must not target PROD`); + } + } +} + +function normalizeArgs(args) { + return { + ...parseArgs([]), + ...args, + timeoutMs: args.timeoutMs ?? parseArgs([]).timeoutMs + }; +} + +function observeSource(root) { + const commitId = runGit(root, ["rev-parse", "--verify", "HEAD"]) ?? "unknown"; + const shortCommitId = commitId === "unknown" ? "unknown" : commitId.slice(0, 12); + const status = runGit(root, ["status", "--porcelain"]); + const dirty = status === null ? null : status.length > 0; + return { + status: commitId === "unknown" ? "unknown" : "observed", + commitId, + shortCommitId, + reportCommitId: commitId !== "unknown" && dirty === false ? shortCommitId : "unknown", + worktreeState: dirty === null ? "unknown" : dirty ? "dirty" : "clean" + }; +} + +function runGit(root, args) { + try { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000 + }).trim(); + } catch { + return null; + } +} + +async function readJsonOptional(root, relativePath) { + try { + return JSON.parse(await readFile(path.join(root, relativePath), "utf8")); + } catch { + return null; + } +} + +function pathExists(root, relativePath) { + try { + execFileSync("test", ["-f", path.join(root, relativePath)], { stdio: "ignore", timeout: 1000 }); + return true; + } catch { + return false; + } +} + +function joinBasePath(value, targetPath) { + const base = new URL(value); + return new URL(targetPath, `${base.protocol}//${base.host}/`).toString(); +} + +function readArg(argv, index, flag) { + const value = argv[index]; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +function parseTimeout(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error("--timeout-ms must be a positive integer"); + } + return Math.min(parsed, 120000); +} + +function worstStatus(statuses) { + return statuses + .filter(Boolean) + .sort((a, b) => (statusRank[b] ?? 2) - (statusRank[a] ?? 2))[0] ?? "not_run"; +} + +function firstKnown(...values) { + return values.find((value) => value && value !== "unknown") ?? "unknown"; +} + +function commitEquivalent(actual, expected) { + if (!actual || !expected || actual === "unknown" || expected === "unknown") return false; + return actual === expected || actual.startsWith(expected) || expected.startsWith(actual); +} + +function sanitizeCommit(value) { + if (typeof value !== "string") return "unknown"; + const trimmed = value.trim().toLowerCase(); + return /^[a-f0-9]{7,40}$/u.test(trimmed) ? trimmed : "unknown"; +} + +function sanitizeRuntimeString(value) { + return typeof value === "string" && value.trim() ? value.trim() : "unknown"; +} + +function sanitizeTimestamp(value) { + return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : new Date().toISOString(); +} + +function imageTagFromImage(image) { + if (typeof image !== "string") return null; + return image.match(/:([^:/]+)$/u)?.[1] ?? null; +} + +function parseJson(value) { + try { + return value ? JSON.parse(value) : null; + } catch { + return null; + } +} + +function safeTimestamp() { + return new Date().toISOString().replace(/[:.]/gu, "-"); +} + +function relativeOrAbsolute(absolutePath, root) { + const relative = path.relative(root, absolutePath); + return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : absolutePath; +} + +function escapeMarkdown(value) { + return String(value ?? "").replace(/\|/gu, "\\|").replace(/\n/gu, " "); +} + +function redactFailureText(value) { + return String(value ?? "") + .replace(/postgres(?:ql)?:\/\/[^\s"'<>]+/giu, "[redacted-postgres-url]") + .replace(/(password\s*[=:]\s*)[^\s,;]+/giu, "$1[redacted]") + .replace(/(token\s*[=:]\s*)[^\s,;]+/giu, "$1[redacted]") + .replace(/(OPENAI_API_KEY\s*[=:]\s*)[^\s,;]+/giu, "$1[redacted]"); +} + +function usage() { + return [ + "Usage: node scripts/rpt004-mvp-e2e-harness.mjs --live --url http://74.48.78.17:16666/ --api-url http://74.48.78.17:16667/", + "", + "Default --check/source mode validates the harness only and does not claim MVP PASS.", + "--live reads DEV health and the 16666 workbench, then runs layout, Code Agent skill discovery, and the bounded M3 DO/DI loop only after current+healthy preconditions pass.", + "The command writes JSON and Markdown reports by default in --live mode. It never deploys, rolls out, reads secrets, or touches PROD." + ].join("\n"); +} + +export function formatRpt004Failure(error) { + const message = redactFailureText(error instanceof Error ? error.message : String(error)); + return { + reportKind, + issue, + taskId, + status: "blocked", + conclusion: { + status: "blocked", + classification: "BLOCKED", + mvpPass: false, + summary: "RPT-004 harness failed before producing a full report." + }, + error: message, + safety: { + prodTouched: false, + deployApplyAttempted: false, + rolloutAttempted: false, + secretValuesPrinted: false + } + }; +} diff --git a/scripts/src/rpt004-mvp-e2e-harness.test.mjs b/scripts/src/rpt004-mvp-e2e-harness.test.mjs new file mode 100644 index 00000000..524cc60d --- /dev/null +++ b/scripts/src/rpt004-mvp-e2e-harness.test.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyCodeAgentSkillPath, + classifyM3DurableEvidence, + classifyRpt004Conclusion, + parseArgs, + validateLiveBoundary +} from "./rpt004-mvp-e2e-harness.mjs"; + +test("RPT-004 CLI accepts the required live URL/API URL shape", () => { + const args = parseArgs([ + "--live", + "--url", + "http://74.48.78.17:16666/", + "--api-url", + "http://74.48.78.17:16667/" + ]); + + assert.equal(args.live, true); + assert.equal(args.url, "http://74.48.78.17:16666/"); + assert.equal(args.apiUrl, "http://74.48.78.17:16667/"); + assert.equal(args.writeReport, true); + assert.doesNotThrow(() => validateLiveBoundary(args)); +}); + +test("RPT-004 live boundary rejects deprecated or swapped active DEV ports", () => { + assert.throws( + () => validateLiveBoundary({ url: "http://74.48.78.17:6666/", apiUrl: "http://74.48.78.17:16667/" }), + /16666/u + ); + assert.throws( + () => validateLiveBoundary({ url: "http://74.48.78.17:16667/", apiUrl: "http://74.48.78.17:16666/" }), + /16666/u + ); +}); + +test("RPT-004 classifies deployed revision drift as NOT_CURRENT, never MVP PASS", () => { + const conclusion = classifyRpt004Conclusion([ + check("expected-artifact-current", "pass"), + check("live-revision-current", "not_current"), + check("live-health-ready", "pass"), + check("16666-default-chinese-workbench", "pass") + ]); + + assert.equal(conclusion.status, "not_current"); + assert.equal(conclusion.classification, "NOT_CURRENT"); + assert.equal(conclusion.mvpPass, false); +}); + +test("RPT-004 classifies degraded live health as BLOCKED, never MVP PASS", () => { + const conclusion = classifyRpt004Conclusion([ + check("expected-artifact-current", "pass"), + check("live-revision-current", "pass"), + check("live-health-ready", "blocked"), + check("16666-default-chinese-workbench", "pass"), + check("m3-v1-io-trusted-loop", "not_run") + ]); + + assert.equal(conclusion.status, "blocked"); + assert.equal(conclusion.classification, "BLOCKED"); + assert.equal(conclusion.mvpPass, false); +}); + +test("RPT-004 reports MVP_PASS only when every required live dimension passes", () => { + const conclusion = classifyRpt004Conclusion([ + check("expected-artifact-current", "pass"), + check("live-revision-current", "pass"), + check("live-health-ready", "pass"), + check("16666-default-chinese-workbench", "pass"), + check("layout-smoke-hit-target", "pass"), + check("code-agent-skill-path", "pass"), + check("m3-v1-io-trusted-loop", "pass"), + check("operation-audit-evidence-durable-green", "pass") + ]); + + assert.equal(conclusion.status, "pass"); + assert.equal(conclusion.classification, "MVP_PASS"); + assert.equal(conclusion.mvpPass, true); +}); + +test("Code Agent OpenAI fallback cannot satisfy skill path/toolCalls readiness", () => { + const result = classifyCodeAgentSkillPath({ + status: "completed", + provider: "openai-responses", + model: "gpt-5.5", + backend: "hwlab-cloud-api/openai-responses", + implementationType: "openai-responses-fallback", + runner: { + kind: "openai-responses-fallback" + }, + reply: { + content: "text answer" + }, + toolCalls: [], + skills: { + status: "not_requested", + items: [] + } + }, { httpStatus: 200, httpOk: true }); + + assert.equal(result.status, "blocked"); + assert.match(result.summary, /openai-fallback/u); +}); + +test("Code Agent Codex stdio skill discovery passes with completed toolCalls and repo skill", () => { + const result = classifyCodeAgentSkillPath({ + status: "completed", + conversationId: "cnv_test", + sessionId: "ses_test", + messageId: "msg_test", + traceId: "trc_test", + provider: "codex-stdio", + model: "gpt-5.5", + backend: "hwlab-cloud-api/codex-mcp-stdio-runner", + workspace: "/workspace/hwlab", + sandbox: "workspace-write", + capabilityLevel: "long-lived-codex-stdio-session", + sessionMode: "codex-mcp-stdio-long-lived", + implementationType: "repo-owned-codex-mcp-stdio-session", + session: { + sessionId: "ses_test", + status: "idle", + idleTimeoutMs: 1800000, + lastTraceId: "trc_test", + longLivedSession: true, + codexStdio: true + }, + sessionReuse: { + reused: false, + turn: 1 + }, + runner: { + kind: "codex-mcp-stdio-runner", + workspace: "/workspace/hwlab", + sandbox: "workspace-write", + sessionMode: "codex-mcp-stdio-long-lived", + implementationType: "repo-owned-codex-mcp-stdio-session", + codexStdio: true, + writeCapable: true, + durableSession: true + }, + runnerTrace: { + runnerKind: "codex-mcp-stdio-runner" + }, + longLivedSessionGate: { + status: "pass", + pass: true + }, + toolCalls: [{ + name: "skills.discover", + status: "completed", + type: "file-read" + }], + skills: { + status: "ready", + items: [{ + name: "hwlab-agent-runtime", + source: "/workspace/hwlab/skills/hwlab-agent-runtime/SKILL.md" + }], + count: 1 + } + }, { httpStatus: 200, httpOk: true }); + + assert.equal(result.status, "pass"); +}); + +test("M3 durable evidence requires operation, audit, evidence, DEV-LIVE green, and persisted writes", () => { + const green = classifyM3DurableEvidence(greenOperations()); + assert.equal(green.status, "pass"); + + const blockedOps = greenOperations(); + blockedOps[2].evidenceState.writeStatus = "written_non_durable"; + const blocked = classifyM3DurableEvidence(blockedOps); + assert.equal(blocked.status, "blocked"); + assert.equal(blocked.observations.sequence[2].persisted, false); +}); + +function check(id, status) { + return { + id, + status, + summary: `${id} ${status}` + }; +} + +function greenOperations() { + return [ + operation("write-do1-true", "do.write", true), + operation("read-di1-true", "di.read", true), + operation("write-do1-false", "do.write", false), + operation("read-di1-false", "di.read", false) + ]; +} + +function operation(id, action, value) { + return { + id, + action, + status: "succeeded", + operationId: `op_${id}`, + traceId: `trc_${id}`, + auditId: `aud_${id}`, + evidenceId: `evd_${id}`, + resultValue: value, + evidenceState: { + status: "green", + sourceKind: "DEV-LIVE", + durable: true, + writeStatus: "persisted" + } + }; +} diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 81737a81..6c446693 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -32,7 +32,8 @@ const issueFamily = Object.freeze({ DEV_EVIDENCE_BLOCKER_AGGREGATOR: "pikasTech/HWLAB#41", DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58", DEV_CLOUD_WORKBENCH_LIVE: "pikasTech/HWLAB#7", - DEV_CLOUD_WORKBENCH_LAYOUT: "pikasTech/HWLAB#273" + DEV_CLOUD_WORKBENCH_LAYOUT: "pikasTech/HWLAB#273", + RPT004_MVP_E2E: "pikasTech/HWLAB#316" }); const allowedIssues = new Set([ contractIssue, @@ -526,6 +527,10 @@ async function validateReport(relativePath) { await validateAggregatorV2Report(relativePath, report); return; } + if (report.reportKind === "rpt-004-mvp-e2e-harness" || report.taskId === "rpt004-mvp-e2e-harness") { + await validateRpt004MvpE2eReport(report, label); + return; + } if (report.issue === requiredDevM3Issue || report.taskId === "dev-m3-hardware-loop") { await validateDevM3Report(report, label); @@ -772,6 +777,137 @@ async function validateReport(relativePath) { } } +async function validateRpt004MvpE2eReport(report, label) { + for (const field of [ + "$schema", + "$id", + "reportVersion", + "reportKind", + "issue", + "taskId", + "acceptanceLevel", + "devOnly", + "prodDisabled", + "mode", + "status", + "conclusion", + "sourceContract", + "validationCommands", + "devPreconditions", + "target", + "expected", + "dimensions", + "checks", + "blockers", + "safety" + ]) { + assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`); + } + + assert.equal(report.reportVersion, "v1", `${label}.reportVersion`); + assert.equal(report.reportKind, "rpt-004-mvp-e2e-harness", `${label}.reportKind`); + assert.equal(report.issue, issueFamily.RPT004_MVP_E2E, `${label}.issue`); + assert.equal(report.taskId, "rpt004-mvp-e2e-harness", `${label}.taskId`); + assert.equal(report.acceptanceLevel, "rpt004_mvp_e2e", `${label}.acceptanceLevel`); + assert.equal(report.devOnly, true, `${label}.devOnly`); + assert.equal(report.prodDisabled, true, `${label}.prodDisabled`); + assert.ok(["source-check", "dev-live"].includes(report.mode), `${label}.mode`); + assert.ok(["pass", "blocked", "not_current"].includes(report.status), `${label}.status`); + + assertObject(report.conclusion, `${label}.conclusion`); + assert.ok( + ["CHECK_ONLY", "BLOCKED", "NOT_CURRENT", "MVP_PASS"].includes(report.conclusion.classification), + `${label}.conclusion.classification` + ); + assert.equal(typeof report.conclusion.mvpPass, "boolean", `${label}.conclusion.mvpPass`); + if (report.status !== "pass" || report.conclusion.classification !== "MVP_PASS") { + assert.equal(report.conclusion.mvpPass, false, `${label}.conclusion.mvpPass must stay false outside MVP_PASS`); + } + if (report.conclusion.classification === "NOT_CURRENT") { + assert.equal(report.status, "not_current", `${label}.status for NOT_CURRENT`); + } + + assertCommandList(report.validationCommands, `${label}.validationCommands`, { minLength: 5 }); + for (const requiredCommand of [ + "node --check scripts/rpt004-mvp-e2e-harness.mjs", + "node --check scripts/src/rpt004-mvp-e2e-harness.mjs", + "node --test scripts/src/rpt004-mvp-e2e-harness.test.mjs", + "node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write", + "node scripts/rpt004-mvp-e2e-harness.mjs --live --url http://74.48.78.17:16666/ --api-url http://74.48.78.17:16667/" + ]) { + assert.ok( + report.validationCommands.includes(requiredCommand), + `${label}.validationCommands missing ${requiredCommand}` + ); + } + + assertObject(report.target, `${label}.target`); + assert.equal(report.target.environment, "dev", `${label}.target.environment`); + assert.equal(report.target.browserUrl, "http://74.48.78.17:16666/", `${label}.target.browserUrl`); + assert.equal(report.target.apiUrl, "http://74.48.78.17:16667/", `${label}.target.apiUrl`); + assert.equal(report.target.m3IoUrl, "http://74.48.78.17:16667/v1/m3/io", `${label}.target.m3IoUrl`); + + assertObject(report.expected, `${label}.expected`); + assert.match(report.expected.commitId, /^[a-f0-9]{7,40}$/u, `${label}.expected.commitId`); + assert.equal(report.expected.serviceCount, report.expected.requiredServiceCount, `${label}.expected.serviceCount`); + + const requiredDimensions = [ + "artifact", + "revision", + "health", + "workbench", + "layout", + "m3TrustedLoop", + "durableEvidence", + "codeAgentSkillPath" + ]; + assertObject(report.dimensions, `${label}.dimensions`); + for (const id of requiredDimensions) { + assert.ok(Object.hasOwn(report.dimensions, id), `${label}.dimensions missing ${id}`); + } + + assertArray(report.checks, `${label}.checks`); + const minimumCheckCount = report.mode === "dev-live" ? 4 : 2; + assert.ok( + report.checks.length >= minimumCheckCount, + `${label}.checks must include at least ${minimumCheckCount} entries` + ); + const checkIds = new Set(report.checks.map((check) => check.id)); + assert.ok(checkIds.has("expected-artifact-current"), `${label}.checks missing expected-artifact-current`); + if (report.mode === "dev-live") { + for (const id of [ + "live-revision-current", + "live-health-ready", + "16666-default-chinese-workbench", + "layout-smoke-hit-target", + "code-agent-skill-path", + "m3-v1-io-trusted-loop", + "operation-audit-evidence-durable-green" + ]) { + assert.ok(checkIds.has(id) || report.status !== "pass", `${label}.checks missing ${id}`); + } + } + for (const [index, check] of report.checks.entries()) { + assertObject(check, `${label}.checks[${index}]`); + assertString(check.id, `${label}.checks[${index}].id`); + assert.ok(["pass", "blocked", "not_current", "not_run"].includes(check.status), `${label}.checks[${index}].status`); + assertString(check.summary, `${label}.checks[${index}].summary`); + } + + assertBlockers(report.blockers, `${label}.blockers`); + assertObject(report.safety, `${label}.safety`); + assert.equal(report.safety.devOnly, true, `${label}.safety.devOnly`); + assert.equal(report.safety.prodTouched, false, `${label}.safety.prodTouched`); + assert.equal(report.safety.deployApplyAttempted, false, `${label}.safety.deployApplyAttempted`); + assert.equal(report.safety.rolloutAttempted, false, `${label}.safety.rolloutAttempted`); + assert.equal(report.safety.secretValuesPrinted, false, `${label}.safety.secretValuesPrinted`); + assert.equal(report.safety.openAiFallbackCountsAsPass, false, `${label}.safety.openAiFallbackCountsAsPass`); + + if (report.artifacts?.artifactReport) { + assert.match(report.artifacts.artifactReport, /not applicable/u, `${label}.artifacts.artifactReport`); + } +} + async function assertDocumentSet(documentsValue, label, requiredDocs) { assertStringArray(documentsValue, label, { minLength: requiredDocs.length }); const documents = new Set(documentsValue);