diff --git a/README.md b/README.md index 224b33ea..e1f7fd79 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,21 @@ Run the lightweight validation suite: ```sh npm run check ``` + +## L6 Manual Acceptance Entry + +The external MVP acceptance surface is DEV-only and report-driven: + +```sh +npm run l6:smoke +npm run web:check +npm run cli:dry-run +node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp +``` + +The CLI command defaults to a JSON dry-run plan sourced from +`reports/dev-gate/dev-mvp-gate-report.json` and +`fixtures/mvp/m5-e2e/dry-run-plan.json`. A live DEV attempt requires +`--live --confirm-dev --confirmed-non-production`; with open gate blockers it +returns `BLOCKED` evidence rather than using fixtures or UniDesk runtime as a +substitute. diff --git a/docs/m5-mvp-e2e.md b/docs/m5-mvp-e2e.md index 2e2abe67..446277e3 100644 --- a/docs/m5-mvp-e2e.md +++ b/docs/m5-mvp-e2e.md @@ -2,7 +2,7 @@ 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: +secrets. The source contract gate is: ```sh node scripts/m5-mvp-e2e-dry-run.mjs @@ -13,6 +13,19 @@ The script reads the frozen protocol contracts, the DEV acceptance checklist, validates the dependency graph and checks that each step has resolvable inputs and outputs. +The L6 external acceptance CLI wraps the same report and dry-run plan: + +```sh +node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp +``` + +This command defaults to a JSON dry-run plan. `--dry-run` is accepted for +backward compatibility, but it is no longer required for safety. The output +must identify the M0-M5 report source, artifact and DEV health contract counts, +two gateway sessions, two box resources, patch-panel status, agent and worker +session IDs, evidence records, open blockers, and the statement that no +DEV/PROD changes were made. + ## Dry-Run Flow | Order | Step | Purpose | Required Inputs | Required Outputs | @@ -79,10 +92,14 @@ Do not switch to the real command until all of these are true: 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: +Only after those gates are satisfied should the runner switch from dry-run to +the explicit live DEV command: ```sh -hwlab-cli test e2e --env dev --mvp +hwlab-cli test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production ``` -That command is intentionally not executed by M5. +That command is intentionally not executed by M5. While +`reports/dev-gate/dev-mvp-gate-report.json` contains open blockers, the CLI +must return `code: "BLOCKED"` with blocker evidence instead of substituting +fixture output as live DEV evidence. diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 2ff8d4de..8255fc38 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -89,6 +89,39 @@ reason. | M4 agent automation loop | Source-only or dry-run, then MVP e2e | Agent manager, worker, skills, session lifecycle, scoped workspace, trace events, cleanup, and evidence chain are observable in fixtures or dry-run before any DEV runtime action. | | M5 MVP e2e dry-run and acceptance | Dry-run, then MVP e2e | `npm run cli:dry-run` or equivalent names the MVP route, closed loops, evidence, and cleanup without mutation. Real acceptance is DEV-only, bounded, and requires prior DEV runtime smoke pass. | +## L6 CLI/Web Acceptance Entry + +Use the L6 entry points for external manual acceptance triage. They read the +checked-in `reports/dev-gate/dev-mvp-gate-report.json` and +`fixtures/mvp/m5-e2e/dry-run-plan.json`; they do not call UniDesk runtime and +do not claim live DEV evidence from fixtures. + +Safe source and dry-run checks: + +```sh +npm run l6:smoke +npm run web:check +npm run cli:dry-run +node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp +``` + +The default CLI MVP command is a JSON dry-run plan even when `--dry-run` is not +provided. It must include the DEV endpoint, artifact/health counts, two +gateway sessions, two box resources, the patch-panel status, agent and worker +session IDs, evidence records, and a statement that no DEV/PROD changes were +made. + +The live DEV entry point is intentionally separate: + +```sh +node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production +``` + +When the M0-M5 gate report still has open blockers, the live command must stop +with `code: "BLOCKED"` and include the blocker evidence. It must not use +fixture data, UniDesk services, PROD, heavy e2e, restarts, or secret reads to +produce a green result. + ## Phase Procedures ### Source-Only Procedure diff --git a/internal/mvp-gate/summary.mjs b/internal/mvp-gate/summary.mjs new file mode 100644 index 00000000..8e43c6e7 --- /dev/null +++ b/internal/mvp-gate/summary.mjs @@ -0,0 +1,295 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const DEV_ENDPOINT = "http://74.48.78.17:6667"; +export const DEFAULT_GATE_REPORT_PATH = "reports/dev-gate/dev-mvp-gate-report.json"; +export const DEFAULT_M5_PLAN_PATH = "fixtures/mvp/m5-e2e/dry-run-plan.json"; + +function readJsonFile(repoRoot, relativePath) { + const absolutePath = path.resolve(repoRoot, relativePath); + const raw = fs.readFileSync(absolutePath, "utf8"); + return JSON.parse(raw); +} + +function countByServiceId(items) { + return new Map(items.map((item) => [item.serviceId, item])); +} + +function requireArray(value, label) { + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array`); + } + return value; +} + +function assertRequiredMvpGate(report, plan) { + if (report.devOnly !== true || report.prodDisabled !== true) { + throw new Error("MVP gate report must be DEV-only and PROD-disabled"); + } + if (report.gateStatus !== "blocked") { + throw new Error("MVP gate report must not claim live DEV pass in this MVP fixture"); + } + if (plan.mode !== "dry-run" || plan.environment !== "dev" || plan.endpoint !== DEV_ENDPOINT) { + throw new Error("M5 plan must stay dry-run, dev, and pinned to the frozen DEV endpoint"); + } + + const milestones = requireArray(report.milestones, "report.milestones"); + for (const milestone of ["M0", "M1", "M2", "M3", "M4", "M5"]) { + if (!milestones.some((item) => item.id === milestone)) { + throw new Error(`MVP gate report missing milestone ${milestone}`); + } + } + + const artifacts = requireArray(plan.artifacts, "plan.artifacts"); + const health = requireArray(plan.health, "plan.health"); + const gatewaySessions = requireArray(plan.gatewaySessions, "plan.gatewaySessions"); + const boxResources = requireArray(plan.boxResources, "plan.boxResources"); + const evidenceRecords = requireArray(plan.evidenceRecords, "plan.evidenceRecords"); + const operations = requireArray(plan.operations, "plan.operations"); + + const byService = countByServiceId(artifacts); + for (const serviceId of [ + "hwlab-cloud-web", + "hwlab-cloud-api", + "hwlab-gateway", + "hwlab-gateway-simu", + "hwlab-box-simu", + "hwlab-patch-panel", + "hwlab-agent-mgr", + "hwlab-agent-worker", + "hwlab-cli" + ]) { + if (!byService.has(serviceId)) { + throw new Error(`M5 plan missing artifact for ${serviceId}`); + } + } + + if (gatewaySessions.length < 2) { + throw new Error("M5 plan must include two gateway sessions"); + } + if (boxResources.length < 2) { + throw new Error("M5 plan must include two box resources"); + } + if (!plan.patchPanelStatus?.patchPanelStatusId) { + throw new Error("M5 plan missing patch-panel status evidence"); + } + if (!plan.agentSession?.agentSessionId || !plan.workerSession?.workerSessionId) { + throw new Error("M5 plan missing agent and worker session evidence"); + } + if (evidenceRecords.length < 2 || operations.length < 2) { + throw new Error("M5 plan must include direct and agent evidence records"); + } +} + +function summarizeArtifact(artifact) { + return { + serviceId: artifact.serviceId, + commitId: artifact.commitId, + image: artifact.image, + tag: artifact.tag, + digest: artifact.digest, + digestNotApplicableReason: artifact.digestNotApplicableReason ?? null, + buildSource: artifact.buildSource, + deployEnv: artifact.deployEnv, + healthTimestamp: artifact.healthTimestamp + }; +} + +function summarizeHealth(health) { + return { + healthId: health.healthId, + serviceId: health.serviceId, + component: health.component, + status: health.status, + deployEnv: health.deployEnv, + endpoint: health.endpoint ?? null, + observedBy: health.observedBy, + dryRun: health.dryRun === true, + healthTimestamp: health.healthTimestamp + }; +} + +function summarizeMilestone(milestone) { + return { + id: milestone.id, + status: milestone.status, + commandCount: milestone.commands?.length ?? 0, + evidenceCount: milestone.evidence?.length ?? 0, + summary: milestone.summary + }; +} + +function summarizeBlocker(blocker) { + return { + type: blocker.type, + scope: blocker.scope, + status: blocker.status, + summary: blocker.summary + }; +} + +function summarizeStep(step) { + return { + id: step.id, + order: step.order, + title: step.title, + kind: step.kind, + requires: step.requires, + inputs: step.inputs, + outputs: step.outputs, + acceptanceStepIds: step.acceptanceStepIds + }; +} + +function summarizeGatewaySession(session) { + return { + gatewaySessionId: session.gatewaySessionId, + serviceId: session.serviceId, + gatewayId: session.gatewayId, + status: session.status, + environment: session.environment, + endpoint: session.endpoint ?? null + }; +} + +function summarizeBoxResource(resource) { + return { + resourceId: resource.resourceId, + gatewaySessionId: resource.gatewaySessionId, + boxId: resource.boxId, + resourceType: resource.resourceType, + name: resource.name, + state: resource.state, + environment: resource.environment + }; +} + +function summarizeEvidence(record) { + return { + evidenceId: record.evidenceId, + operationId: record.operationId, + agentSessionId: record.agentSessionId ?? null, + workerSessionId: record.workerSessionId ?? null, + kind: record.kind, + uri: record.uri, + sha256: record.sha256, + serviceId: record.serviceId, + environment: record.environment, + dryRun: record.metadata?.dryRun === true + }; +} + +export function buildMvpGateSummary({ report, plan }) { + assertRequiredMvpGate(report, plan); + + const artifacts = plan.artifacts.map(summarizeArtifact); + const health = plan.health.map(summarizeHealth); + const milestones = report.milestones.map(summarizeMilestone); + const blockers = report.blockers.map(summarizeBlocker); + const steps = plan.steps.map(summarizeStep).sort((a, b) => a.order - b.order); + const blocked = blockers.some((blocker) => blocker.status === "open") || report.gateStatus === "blocked"; + + return { + generatedFrom: { + gateReport: DEFAULT_GATE_REPORT_PATH, + m5Plan: DEFAULT_M5_PLAN_PATH + }, + issue: "pikasTech/HWLAB#56", + supports: ["pikasTech/HWLAB#7", "pikasTech/HWLAB#9"], + gateStatus: report.gateStatus, + blocked, + acceptanceLevel: report.acceptanceLevel, + reportCommitId: report.commitId, + dryRunOnly: true, + devOnly: report.devOnly === true, + prodDisabled: report.prodDisabled === true, + environment: plan.environment, + endpoint: plan.endpoint, + namespace: "hwlab-dev", + sourceSummary: report.sourceContract.summary, + localSmoke: report.localSmoke, + dryRun: report.dryRun, + devPreconditions: report.devPreconditions, + blockers, + milestones, + artifactCount: artifacts.length, + healthCount: health.length, + gatewaySessionCount: plan.gatewaySessions.length, + boxResourceCount: plan.boxResources.length, + patchPanelCount: 1, + operationCount: plan.operations.length, + evidenceCount: plan.evidenceRecords.length, + traceCount: plan.traceEvents.length, + auditCount: plan.auditEvents.length, + cleanupCount: plan.cleanup.length, + artifacts, + health, + topology: { + projectId: plan.project.projectId, + projectStatus: plan.project.status, + gateways: plan.gatewaySessions.map(summarizeGatewaySession), + boxResources: plan.boxResources.map(summarizeBoxResource), + patchPanel: { + patchPanelStatusId: plan.patchPanelStatus.patchPanelStatusId, + serviceId: plan.patchPanelStatus.serviceId, + state: plan.patchPanelStatus.state, + environment: plan.patchPanelStatus.environment, + activeConnectionCount: plan.patchPanelStatus.activeConnections.length, + activeConnections: plan.patchPanelStatus.activeConnections + } + }, + agent: { + agentSessionId: plan.agentSession.agentSessionId, + agentServiceId: plan.agentSession.serviceId, + agentStatus: plan.agentSession.status, + workerSessionId: plan.workerSession.workerSessionId, + workerServiceId: plan.workerSession.serviceId, + workerStatus: plan.workerSession.status, + projectId: plan.agentSession.projectId, + environment: plan.agentSession.environment, + cleanupId: plan.agentSession.metadata.cleanupId + }, + operations: plan.operations.map((operation) => ({ + operationId: operation.operationId, + requestedBy: operation.requestedBy, + status: operation.status, + resourceId: operation.resourceId, + capabilityId: operation.capabilityId, + agentSessionId: operation.agentSessionId ?? null, + workerSessionId: operation.workerSessionId ?? null, + environment: operation.environment + })), + evidenceRecords: plan.evidenceRecords.map(summarizeEvidence), + steps, + realDevGate: { + sourcePlanCommand: plan.realDevGate.command, + command: `${plan.realDevGate.command} --live --confirm-dev --confirmed-non-production`, + requiresAllBlockersClear: plan.realDevGate.requiresAllBlockersClear, + requiresDryRunPass: plan.realDevGate.requiresDryRunPass, + requiresArtifactObservability: plan.realDevGate.requiresArtifactObservability, + requiresD601RouteObservation: plan.realDevGate.requiresD601RouteObservation, + requiresHumanApprovalForRealDev: plan.realDevGate.requiresHumanApprovalForRealDev + }, + safety: { + mode: plan.mode, + allowNetwork: plan.safety.allowNetwork, + allowDeploy: plan.safety.allowDeploy, + allowBrowserE2E: plan.safety.allowBrowserE2E, + allowSecrets: plan.safety.allowSecrets, + prohibitedActions: plan.safety.prohibitedActions + } + }; +} + +export function loadMvpGateSummary(repoRoot, options = {}) { + const reportPath = options.reportPath ?? DEFAULT_GATE_REPORT_PATH; + const planPath = options.planPath ?? DEFAULT_M5_PLAN_PATH; + const report = readJsonFile(repoRoot, reportPath); + const plan = readJsonFile(repoRoot, planPath); + const summary = buildMvpGateSummary({ report, plan }); + summary.generatedFrom = { + gateReport: reportPath, + m5Plan: planPath + }; + return summary; +} diff --git a/package.json b/package.json index abc3a192..9db45861 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,17 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check 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/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/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/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check 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/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/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/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", "dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty", "dev:evidence:check": "node scripts/dev-evidence-blocker-aggregator.mjs --check", "dev-edge:smoke": "node scripts/dev-edge-health-smoke.mjs", "cli:health": "node tools/hwlab-cli/bin/hwlab-cli.mjs health", - "cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run", + "cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp", "cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list", + "l6:smoke": "node scripts/l6-cli-web-smoke.mjs", + "web:summary": "node scripts/export-web-gate-summary.mjs", "web:check": "node web/hwlab-cloud-web/scripts/check.mjs", "web:build": "node web/hwlab-cloud-web/scripts/build.mjs", "artifact-catalog:refresh-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked", diff --git a/scripts/export-web-gate-summary.mjs b/scripts/export-web-gate-summary.mjs new file mode 100644 index 00000000..b3daf8e5 --- /dev/null +++ b/scripts/export-web-gate-summary.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadMvpGateSummary } from "../internal/mvp-gate/summary.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const outputPath = path.join(repoRoot, "web/hwlab-cloud-web/gate-summary.mjs"); +const summary = loadMvpGateSummary(repoRoot); +const body = `// Generated from reports/dev-gate/dev-mvp-gate-report.json and fixtures/mvp/m5-e2e/dry-run-plan.json. +// Run node scripts/export-web-gate-summary.mjs after changing those fixtures. +export const gateSummary = ${JSON.stringify(summary, null, 2)}; +`; + +fs.writeFileSync(outputPath, body); +process.stdout.write(`wrote ${path.relative(repoRoot, outputPath)}\n`); diff --git a/scripts/l6-cli-web-smoke.mjs b/scripts/l6-cli-web-smoke.mjs new file mode 100644 index 00000000..afc5cc09 --- /dev/null +++ b/scripts/l6-cli-web-smoke.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadMvpGateSummary } from "../internal/mvp-gate/summary.mjs"; +import { runCli } from "../tools/hwlab-cli/lib/cli.mjs"; +import { gateSummary } from "../web/hwlab-cloud-web/gate-summary.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +async function captureCli(args) { + let stdout = ""; + let stderr = ""; + const exitCode = await runCli(args, { + cwd: repoRoot, + stdout: { + write(chunk) { + stdout += chunk; + } + }, + stderr: { + write(chunk) { + stderr += chunk; + } + } + }); + return { exitCode, stdout, stderr }; +} + +const summary = loadMvpGateSummary(repoRoot); +assert.equal(summary.endpoint, "http://74.48.78.17:6667"); +assert.deepEqual( + summary.milestones.map((item) => item.id), + ["M0", "M1", "M2", "M3", "M4", "M5"] +); + +const dryRun = await captureCli(["test", "e2e", "--env", "dev", "--mvp"]); +assert.equal(dryRun.exitCode, 0, dryRun.stderr); +const dryRunJson = JSON.parse(dryRun.stdout); +assert.equal(dryRunJson.mode, "dry-run"); +assert.equal(dryRunJson.environment, "dev"); +assert.equal(dryRunJson.endpoint, summary.endpoint); +assert.equal(dryRunJson.contractStatus.artifacts.count, 13); +assert.equal(dryRunJson.contractStatus.health.count, 14); +assert.equal(dryRunJson.contractStatus.topology.gatewaySessions, 2); +assert.equal(dryRunJson.contractStatus.topology.boxResources, 2); +assert.equal(dryRunJson.contractStatus.topology.patchPanelStatusId, "pps_m5-0001"); +assert.equal(dryRunJson.contractStatus.agent.agentSessionId, "agt_m5-0001"); +assert.equal(dryRunJson.contractStatus.evidence.records.length, 2); +assert.match(dryRunJson.statement, /no DEV\/PROD changes/); + +const explicitDryRun = await captureCli(["test", "e2e", "--env", "dev", "--mvp", "--dry-run"]); +assert.equal(explicitDryRun.exitCode, 0, explicitDryRun.stderr); +assert.equal(JSON.parse(explicitDryRun.stdout).mode, "dry-run"); + +const liveBlocked = await captureCli([ + "test", + "e2e", + "--env", + "dev", + "--mvp", + "--live", + "--confirm-dev", + "--confirmed-non-production" +]); +assert.equal(liveBlocked.exitCode, 2); +const liveBlockedJson = JSON.parse(liveBlocked.stderr); +assert.equal(liveBlockedJson.code, "BLOCKED"); +assert.equal(liveBlockedJson.blockers[0].type, "network_blocker"); + +assert.equal(gateSummary.endpoint, summary.endpoint); +assert.equal(gateSummary.gateStatus, "blocked"); +assert.equal(gateSummary.artifactCount, 13); +assert.equal(gateSummary.healthCount, 14); +assert.equal(gateSummary.gatewaySessionCount, 2); +assert.equal(gateSummary.boxResourceCount, 2); +assert.equal(gateSummary.topology.patchPanel.serviceId, "hwlab-patch-panel"); +assert.equal(gateSummary.agent.agentServiceId, "hwlab-agent-mgr"); +assert.equal(gateSummary.agent.workerServiceId, "hwlab-agent-worker"); +assert.equal(gateSummary.evidenceRecords.length, 2); + +console.log("L6 CLI/Web smoke passed: CLI and Web read the M0-M5 gate report"); diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs index 2fa5ec98..ca4b4461 100644 --- a/scripts/m1-contract-smoke.mjs +++ b/scripts/m1-contract-smoke.mjs @@ -310,11 +310,19 @@ async function smokeCliDryRunFixture() { }); assert.equal(exitCode, 0, stderr); - assert.match(stdout, /hwlab-cli test e2e --env dev --mvp --dry-run/); - assert.match(stdout, /cloud-web\/cloud-api/); - assert.match(stdout, /hardware trusted closed loop/); - assert.match(stdout, /evidence_mvp-runtime-plan/); - assert.match(stdout, /dry-run only: no DEV\/PROD changes were made/); + const output = JSON.parse(stdout); + assert.equal(output.command, "hwlab-cli test e2e --env dev --mvp --dry-run"); + assert.equal(output.mode, "dry-run"); + assert.equal(output.environment, "dev"); + assert.equal(output.endpoint, DEV_ENDPOINT); + assert.equal(output.safety.allowNetwork, false); + assert.equal(output.contractStatus.artifacts.services.includes("hwlab-cloud-web"), true); + assert.equal(output.contractStatus.artifacts.services.includes("hwlab-cloud-api"), true); + assert.equal(output.contractStatus.topology.gatewaySessions, 2); + assert.equal(output.contractStatus.topology.boxResources, 2); + assert.equal(output.contractStatus.agent.agentSessionId, "agt_m5-0001"); + assert.equal(output.contractStatus.evidence.records.length, 2); + assert.match(output.statement, /no DEV\/PROD changes/); logOk("CLI dry-run fixture parse"); } diff --git a/tools/hwlab-cli/lib/cli.mjs b/tools/hwlab-cli/lib/cli.mjs index e65ec55a..1d6dc49e 100644 --- a/tools/hwlab-cli/lib/cli.mjs +++ b/tools/hwlab-cli/lib/cli.mjs @@ -1,60 +1,4 @@ -import fs from "node:fs"; -import path from "node:path"; -import { DEV_ENDPOINT } from "../../../internal/protocol/index.mjs"; - -const DEFAULT_PROJECTS = [ - { - projectId: "proj_mvp-l6", - name: "HWLAB L6 MVP", - status: "active", - environment: "dev", - updatedAt: "2026-05-21T00:00:00.000Z" - }, - { - projectId: "proj_cloud-web", - name: "Cloud Web Skeleton", - status: "active", - environment: "dev", - updatedAt: "2026-05-20T12:00:00.000Z" - } -]; - -function loadRuntimeFixture(cwd) { - const fixturePath = path.resolve(cwd, "fixtures/mvp/runtime.json"); - if (!fs.existsSync(fixturePath)) { - return null; - } - const raw = fs.readFileSync(fixturePath, "utf8"); - return JSON.parse(raw); -} - -function resolveRuntime(cwd) { - const fixture = loadRuntimeFixture(cwd); - if (fixture) { - return fixture; - } - return { - endpoints: { dev: DEV_ENDPOINT }, - projects: DEFAULT_PROJECTS, - mvpSteps: [ - "browser/CLI/gateway", - "master hwlab-edge-proxy", - "frp", - "D601 hwlab-dev/hwlab-router", - "cloud-web/cloud-api", - "hardware trusted closed loop", - "agent automation closed loop", - "evidence record", - "worker cleanup" - ], - loops: [ - { name: "hardware trusted closed loop" }, - { name: "agent automation closed loop" } - ], - evidence: [], - cleanup: [] - }; -} +import { DEV_ENDPOINT, loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs"; function formatJson(value) { return JSON.stringify(value, null, 2); @@ -64,60 +8,8 @@ function writeLine(stream, line = "") { stream.write(`${line}\n`); } -function writeProjectList(runtime, stdout) { - writeLine(stdout, "hwlab-cli project list"); - writeLine(stdout, `endpoint: ${runtime.endpoints?.dev ?? DEV_ENDPOINT}`); - for (const project of runtime.projects ?? []) { - writeLine( - stdout, - `${project.projectId} | ${project.name} | ${project.status} | ${project.environment} | ${project.updatedAt}` - ); - } -} - -function writeHealth(runtime, stdout) { - writeLine(stdout, "hwlab-cli health"); - writeLine(stdout, "status: ok"); - writeLine(stdout, `environment: dev`); - writeLine(stdout, `endpoint: ${runtime.endpoints?.dev ?? DEV_ENDPOINT}`); - writeLine(stdout, `projects: ${(runtime.projects ?? []).length}`); -} - -function writeDryRun(runtime, stdout) { - writeLine(stdout, "hwlab-cli test e2e --env dev --mvp --dry-run"); - writeLine(stdout, `endpoint: ${runtime.endpoints?.dev ?? DEV_ENDPOINT}`); - writeLine(stdout, "mvp route:"); - writeLine( - stdout, - ` ${runtime.serviceRoute?.join(" -> ") ?? "browser/CLI/gateway -> master hwlab-edge-proxy -> frp -> D601 hwlab-dev/hwlab-router -> cloud-web/cloud-api"}` - ); - for (const step of runtime.mvpSteps ?? []) { - writeLine(stdout, `- ${step}`); - } - writeLine(stdout, "closed loops:"); - for (const loop of runtime.loops ?? []) { - writeLine(stdout, `- ${loop.name}${loop.description ? `: ${loop.description}` : ""}`); - } - writeLine(stdout, "evidence record:"); - for (const evidence of runtime.evidence ?? []) { - writeLine( - stdout, - `- ${evidence.evidenceId} | ${evidence.kind} | ${evidence.serviceId} | ${evidence.uri}` - ); - } - writeLine(stdout, "worker cleanup:"); - for (const step of runtime.cleanup ?? []) { - writeLine(stdout, `- ${step}`); - } - writeLine(stdout, "dry-run only: no DEV/PROD changes were made"); -} - -function writeHelp(stdout) { - writeLine(stdout, "hwlab-cli"); - writeLine(stdout, "commands:"); - writeLine(stdout, " health"); - writeLine(stdout, " project list"); - writeLine(stdout, " test e2e --env dev --mvp --dry-run"); +function writeJson(stream, value) { + writeLine(stream, formatJson(value)); } function parseArgs(args) { @@ -127,11 +19,12 @@ function parseArgs(args) { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg.startsWith("--")) { - flags.add(arg); const next = args[index + 1]; if (next && !next.startsWith("--")) { options.set(arg, next); index += 1; + } else { + flags.add(arg); } } else { rest.push(arg); @@ -140,46 +33,230 @@ function parseArgs(args) { return { flags, options, rest }; } +function makeError(code, message, details = {}) { + return { + ok: false, + code, + message, + ...details + }; +} + +function mvpGateOverview(summary) { + return { + ok: true, + command: "hwlab-cli health", + environment: summary.environment, + endpoint: summary.endpoint, + gateStatus: summary.gateStatus, + devOnly: summary.devOnly, + prodDisabled: summary.prodDisabled, + reportCommitId: summary.reportCommitId, + artifactCount: summary.artifactCount, + healthCount: summary.healthCount, + milestoneStatus: Object.fromEntries(summary.milestones.map((item) => [item.id, item.status])), + blocked: summary.blocked, + blockers: summary.blockers + }; +} + +function mvpProjectList(summary) { + return { + ok: true, + command: "hwlab-cli project list", + environment: summary.environment, + endpoint: summary.endpoint, + projects: [ + { + projectId: summary.topology.projectId, + name: "HWLAB M5 MVP E2E Dry Run", + status: summary.topology.projectStatus, + environment: summary.environment, + source: summary.generatedFrom.m5Plan + } + ], + topology: { + gatewaySessions: summary.topology.gateways.map((gateway) => gateway.gatewaySessionId), + boxResources: summary.topology.boxResources.map((resource) => resource.resourceId), + patchPanelStatusId: summary.topology.patchPanel.patchPanelStatusId + } + }; +} + +function mvpDryRunPlan(summary, command) { + return { + ok: true, + command, + mode: "dry-run", + dryRunOnly: true, + environment: summary.environment, + endpoint: summary.endpoint, + gateStatus: summary.gateStatus, + blocked: summary.blocked, + generatedFrom: summary.generatedFrom, + safety: summary.safety, + milestones: summary.milestones, + contractStatus: { + artifacts: { + status: "fixture", + count: summary.artifactCount, + commitIds: [...new Set(summary.artifacts.map((artifact) => artifact.commitId))], + services: summary.artifacts.map((artifact) => artifact.serviceId) + }, + health: { + status: "fixture", + count: summary.healthCount, + components: summary.health.map((health) => ({ + component: health.component, + serviceId: health.serviceId, + status: health.status, + dryRun: health.dryRun + })) + }, + topology: { + projectId: summary.topology.projectId, + gatewaySessions: summary.gatewaySessionCount, + boxResources: summary.boxResourceCount, + patchPanelStatusId: summary.topology.patchPanel.patchPanelStatusId, + activeConnectionCount: summary.topology.patchPanel.activeConnectionCount + }, + agent: summary.agent, + evidence: { + operations: summary.operationCount, + traceEvents: summary.traceCount, + auditEvents: summary.auditCount, + records: summary.evidenceRecords, + cleanupRecords: summary.cleanupCount + } + }, + steps: summary.steps.map((step) => ({ + id: step.id, + order: step.order, + title: step.title, + kind: step.kind, + requires: step.requires + })), + blockers: summary.blockers, + realDevGate: summary.realDevGate, + statement: "dry-run only: no DEV/PROD changes were made" + }; +} + +function mvpLiveBlocked(summary, command) { + return { + ok: false, + code: "BLOCKED", + command, + mode: "live-dev", + environment: summary.environment, + endpoint: summary.endpoint, + gateStatus: summary.gateStatus, + blocked: true, + blockers: summary.blockers, + requiredBeforeRetry: [ + "Clear all open blockers in reports/dev-gate/dev-mvp-gate-report.json.", + "Record live HWLAB DEV health for /health and /live on the frozen DEV endpoint.", + "Keep the run DEV-only; do not use UniDesk runtime or PROD substitutes." + ], + generatedFrom: summary.generatedFrom + }; +} + +function writeHelp(stdout) { + writeJson(stdout, { + ok: true, + command: "hwlab-cli", + commands: [ + "health", + "project list", + "test e2e --env dev --mvp", + "test e2e --env dev --mvp --dry-run", + "test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production" + ], + defaults: { + testE2eMvp: "dry-run plan", + output: "json", + devEndpoint: DEV_ENDPOINT + } + }); +} + export async function runCli(argv, io) { const stdout = io.stdout ?? process.stdout; const stderr = io.stderr ?? process.stderr; - const runtime = resolveRuntime(io.cwd ?? process.cwd()); + const repoRoot = io.cwd ?? process.cwd(); + let summary; + + try { + summary = loadMvpGateSummary(repoRoot); + } catch (error) { + writeJson(stderr, makeError("GATE_SUMMARY_UNREADABLE", error.message)); + return 1; + } + const [command = "help", subcommand, ...rest] = argv; const { flags, options } = parseArgs(rest); if (command === "health") { - writeHealth(runtime, stdout); + writeJson(stdout, mvpGateOverview(summary)); return 0; } if (command === "project" && subcommand === "list") { - writeProjectList(runtime, stdout); + writeJson(stdout, mvpProjectList(summary)); return 0; } if (command === "test" && subcommand === "e2e") { const env = options.get("--env") ?? null; - const isDryRun = flags.has("--dry-run"); const isMvp = flags.has("--mvp"); + const isLive = flags.has("--live"); + const isDryRun = flags.has("--dry-run") || !isLive; + const commandLine = `hwlab-cli test e2e --env ${env ?? ""}${isMvp ? " --mvp" : ""}${ + isLive ? " --live" : isDryRun && flags.has("--dry-run") ? " --dry-run" : "" + }`; + if (env !== "dev") { - writeLine(stderr, "only --env dev is supported in the skeleton"); + writeJson(stderr, makeError("UNSUPPORTED_ENV", "Only --env dev is supported for the MVP gate.", { env })); return 1; } - if (!isMvp || !isDryRun) { - writeLine(stderr, "use --mvp --dry-run for the skeleton"); + if (!isMvp) { + writeJson(stderr, makeError("MISSING_MVP_FLAG", "Use --mvp for the HWLAB MVP E2E gate.")); return 1; } - writeDryRun(runtime, stdout); + + if (isLive) { + if (!flags.has("--confirm-dev") || !flags.has("--confirmed-non-production")) { + writeJson( + stderr, + makeError("LIVE_CONFIRMATION_REQUIRED", "Live DEV requires --confirm-dev --confirmed-non-production.", { + requiredFlags: ["--confirm-dev", "--confirmed-non-production"] + }) + ); + return 1; + } + if (summary.blocked) { + writeJson(stderr, mvpLiveBlocked(summary, commandLine)); + return 2; + } + writeJson( + stderr, + makeError("LIVE_DEV_NOT_IMPLEMENTED", "The MVP CLI does not execute live DEV e2e from this runner.") + ); + return 2; + } + + writeJson(stdout, mvpDryRunPlan(summary, commandLine)); return 0; } if (command === "project" && !subcommand) { - writeLine(stderr, "usage: hwlab-cli project list"); + writeJson(stderr, makeError("USAGE", "usage: hwlab-cli project list")); return 1; } if (command === "test" && !subcommand) { - writeLine(stderr, "usage: hwlab-cli test e2e --env dev --mvp --dry-run"); + writeJson(stderr, makeError("USAGE", "usage: hwlab-cli test e2e --env dev --mvp")); return 1; } diff --git a/tools/hwlab-cli/package.json b/tools/hwlab-cli/package.json index 9fde11f8..acf13a57 100644 --- a/tools/hwlab-cli/package.json +++ b/tools/hwlab-cli/package.json @@ -7,7 +7,7 @@ "hwlab-cli": "./bin/hwlab-cli.mjs" }, "scripts": { - "check": "node --check bin/hwlab-cli.mjs && node --check lib/cli.mjs", + "check": "node --check bin/hwlab-cli.mjs && node --check lib/cli.mjs && node ../../scripts/l6-cli-web-smoke.mjs", "health": "node bin/hwlab-cli.mjs health" } } diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 01021424..6a12d72a 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -1,40 +1,148 @@ -import { runtime } from "./runtime.mjs"; +import { gateSummary } from "./gate-summary.mjs"; -const runtimeGrid = document.querySelector("#runtime-grid"); -const steps = document.querySelector("#mvp-steps"); -const projectsBody = document.querySelector("#projects-body"); const status = document.querySelector("#status"); +const metaGrid = document.querySelector("#meta-grid"); +const milestoneGrid = document.querySelector("#milestone-grid"); +const healthBody = document.querySelector("#health-body"); +const artifactBody = document.querySelector("#artifact-body"); +const topologyGrid = document.querySelector("#topology-grid"); +const agentGrid = document.querySelector("#agent-grid"); +const evidenceBody = document.querySelector("#evidence-body"); +const blockerList = document.querySelector("#blocker-list"); +const commandList = document.querySelector("#command-list"); -status.textContent = "dev skeleton"; - -const runtimePairs = [ - ["Endpoint", runtime.endpoints.dev], - ["Route", runtime.serviceRoute.join(" -> ")], - ["Closed loops", runtime.closedLoops.join(" / ")], - ["Evidence", runtime.evidence[0].uri] -]; - -for (const [label, value] of runtimePairs) { - const dt = document.createElement("dt"); - dt.textContent = label; - const dd = document.createElement("dd"); - dd.textContent = value; - runtimeGrid.append(dt, dd); +function appendText(parent, tagName, text, className) { + const element = document.createElement(tagName); + element.textContent = text; + if (className) { + element.className = className; + } + parent.append(element); + return element; } -for (const step of runtime.mvpSteps) { +function appendMetricGrid(parent, pairs) { + for (const [label, value] of pairs) { + const item = document.createElement("div"); + item.className = "metric"; + appendText(item, "span", label, "metric-label"); + appendText(item, "strong", String(value ?? "n/a"), "metric-value"); + parent.append(item); + } +} + +function badge(text, tone = text) { + const span = document.createElement("span"); + span.className = `badge tone-${String(tone).replace(/[^a-z0-9_-]/gi, "-").toLowerCase()}`; + span.textContent = text; + return span; +} + +function cell(text, className) { + const td = document.createElement("td"); + td.textContent = text ?? "n/a"; + if (className) { + td.className = className; + } + return td; +} + +status.replaceChildren(badge(gateSummary.gateStatus, gateSummary.gateStatus)); + +appendMetricGrid(metaGrid, [ + ["Environment", gateSummary.environment], + ["Endpoint", gateSummary.endpoint], + ["Namespace", gateSummary.namespace], + ["Report commit", gateSummary.reportCommitId], + ["Gate report", gateSummary.generatedFrom.gateReport], + ["M5 plan", gateSummary.generatedFrom.m5Plan] +]); + +for (const milestone of gateSummary.milestones) { + const item = document.createElement("article"); + item.className = "milestone"; + const head = document.createElement("div"); + head.className = "milestone-head"; + appendText(head, "strong", milestone.id); + head.append(badge(milestone.status, milestone.status)); + item.append(head); + appendText(item, "p", milestone.summary); + appendText(item, "small", `${milestone.commandCount} commands / ${milestone.evidenceCount} evidence lines`); + milestoneGrid.append(item); +} + +for (const row of gateSummary.health) { + const tr = document.createElement("tr"); + tr.append(cell(row.component)); + tr.append(cell(row.serviceId, "mono")); + const statusCell = document.createElement("td"); + statusCell.append(badge(row.status, row.status)); + tr.append(statusCell); + tr.append(cell(row.observedBy)); + tr.append(cell(row.endpoint ?? gateSummary.endpoint, "mono wrap")); + healthBody.append(tr); +} + +for (const artifact of gateSummary.artifacts) { + const tr = document.createElement("tr"); + tr.append(cell(artifact.serviceId, "mono")); + tr.append(cell(artifact.commitId, "mono")); + tr.append(cell(`${artifact.image}:${artifact.tag}`, "mono wrap")); + tr.append(cell(artifact.digest, "mono wrap")); + tr.append(cell(artifact.healthTimestamp, "mono")); + artifactBody.append(tr); +} + +appendMetricGrid(topologyGrid, [ + ["Project", gateSummary.topology.projectId], + ["Gateways", gateSummary.gatewaySessionCount], + ["Box resources", gateSummary.boxResourceCount], + ["Patch panel", gateSummary.topology.patchPanel.patchPanelStatusId], + ["Active links", gateSummary.topology.patchPanel.activeConnectionCount], + [ + "Connection", + gateSummary.topology.patchPanel.activeConnections + .map((link) => `${link.fromResourceId}:${link.fromPort} -> ${link.toResourceId}:${link.toPort}`) + .join(", ") + ] +]); + +appendMetricGrid(agentGrid, [ + ["Agent session", gateSummary.agent.agentSessionId], + ["Agent service", gateSummary.agent.agentServiceId], + ["Worker session", gateSummary.agent.workerSessionId], + ["Worker service", gateSummary.agent.workerServiceId], + ["Trace events", gateSummary.traceCount], + ["Audit events", gateSummary.auditCount], + ["Cleanup records", gateSummary.cleanupCount], + ["Cleanup id", gateSummary.agent.cleanupId] +]); + +for (const record of gateSummary.evidenceRecords) { + const tr = document.createElement("tr"); + tr.append(cell(record.evidenceId, "mono")); + tr.append(cell(record.operationId, "mono")); + tr.append(cell(record.serviceId, "mono")); + tr.append(cell(record.uri, "mono wrap")); + tr.append(cell(record.sha256, "mono wrap")); + evidenceBody.append(tr); +} + +for (const blocker of gateSummary.blockers) { const li = document.createElement("li"); - li.textContent = step; - steps.append(li); + const head = document.createElement("div"); + head.className = "blocker-head"; + head.append(badge(blocker.type, "blocked")); + appendText(head, "strong", blocker.scope); + li.append(head); + appendText(li, "p", blocker.summary); + blockerList.append(li); } -for (const project of runtime.projects) { - const row = document.createElement("tr"); - row.innerHTML = ` - ${project.projectId}
${project.name} - ${project.status} - ${project.environment} - ${project.updatedAt} - `; - projectsBody.append(row); +for (const command of [ + "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp", + "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production", + "node scripts/m5-mvp-e2e-dry-run.mjs" +]) { + appendText(commandList, "li", command, "mono"); } diff --git a/web/hwlab-cloud-web/gate-summary.mjs b/web/hwlab-cloud-web/gate-summary.mjs new file mode 100644 index 00000000..1ba23880 --- /dev/null +++ b/web/hwlab-cloud-web/gate-summary.mjs @@ -0,0 +1,809 @@ +// Generated from reports/dev-gate/dev-mvp-gate-report.json and fixtures/mvp/m5-e2e/dry-run-plan.json. +// Run node scripts/export-web-gate-summary.mjs after changing those fixtures. +export const gateSummary = { + "generatedFrom": { + "gateReport": "reports/dev-gate/dev-mvp-gate-report.json", + "m5Plan": "fixtures/mvp/m5-e2e/dry-run-plan.json" + }, + "issue": "pikasTech/HWLAB#56", + "supports": [ + "pikasTech/HWLAB#7", + "pikasTech/HWLAB#9" + ], + "gateStatus": "blocked", + "blocked": true, + "acceptanceLevel": "dev_mvp_gate", + "reportCommitId": "6eb4c199b0c63cca7fd68fc3306f7a9cbe4323ea", + "dryRunOnly": true, + "devOnly": true, + "prodDisabled": true, + "environment": "dev", + "endpoint": "http://74.48.78.17:6667", + "namespace": "hwlab-dev", + "sourceSummary": "The DEV gate is anchored to the frozen matrix, gate contract, and M0-M5 milestone docs on origin/main.", + "localSmoke": { + "status": "pass", + "commands": [ + "node scripts/validate-contract.mjs", + "node scripts/validate-m0-contract.mjs", + "node scripts/validate-artifact-catalog.mjs", + "node scripts/validate-runtime-boundary.mjs", + "node scripts/validate-schema-drift-map.mjs", + "node scripts/validate-evidence-chain.mjs", + "node scripts/m1-contract-smoke.mjs", + "node scripts/m2-dev-deploy-smoke.mjs --dry-run", + "node scripts/m3-hardware-loop-smoke.mjs", + "node scripts/m4-agent-loop-smoke.mjs" + ], + "evidence": [ + "validated 15 JSON files and 13 service ids", + "validated M0 contract examples: 7 JSON files, 13 service ids", + "validated 13 DEV artifact catalog services at 24eb3bf", + "validated HWLAB runtime boundary guard for 13 services; forbidden substitutes: unidesk-backend, provider-gateway, microservice-proxy", + "validated schema drift map with 59 aligned fields and 12 gaps", + "validated 2 evidence chain fixtures and 2 evidence records", + "[m1-smoke] passed", + "[m2-smoke] validated 11 service contracts", + "[m2-smoke] mode=dry-run endpoint=http://74.48.78.17:6667", + "[m2-smoke] no real DEV/PROD request was made", + "M3 hardware loop smoke passed", + "[m4-smoke] ok m4-agent-automation-loop-local-contract-smoke" + ], + "summary": "M0-M4 contract and smoke checks pass locally without contacting the live DEV ingress." + }, + "dryRun": { + "status": "pass", + "commands": [ + "node tools/hwlab-cli/bin/hwlab-cli.mjs health", + "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run", + "node scripts/m5-mvp-e2e-dry-run.mjs" + ], + "evidence": [ + "hwlab-cli health -> status: ok, environment: dev, endpoint: http://74.48.78.17:6667, projects: 2", + "hwlab-cli dry-run -> 9 steps, 13 artifacts, 14 health contracts, 2 hardware operations, 2 evidence records, 0 network calls", + "M5 MVP E2E dry-run passed: 9 steps, 13 artifacts, 14 health contracts, 2 hardware operations, 2 evidence records, 0 network calls" + ], + "summary": "The M5 orchestration dry-run is green, but it remains fixture-only and does not establish a live DEV gate." + }, + "devPreconditions": { + "status": "blocked", + "requirements": [ + "Obtain a live HTTP 200/JSON health observation from http://74.48.78.17:6667/health and /live", + "Record live route observations for hwlab-edge-proxy, hwlab-tunnel-client, hwlab-router, hwlab-cloud-api/web, hwlab-gateway, hwlab-gateway-simu, hwlab-box-simu, hwlab-patch-panel, hwlab-agent-mgr, hwlab-agent-worker, and hwlab-agent-skills", + "Keep the run DEV-only and avoid PROD, heavy e2e, secrets, restarts, or UniDesk substitution" + ], + "commands": [ + "curl -fsS --max-time 5 http://74.48.78.17:6667/health", + "curl -fsS --max-time 5 http://74.48.78.17:6667/live" + ], + "evidence": [ + "curl exit 7 for /health: could not connect to 74.48.78.17 port 6667", + "curl exit 7 for /live: could not connect to 74.48.78.17 port 6667" + ], + "summary": "The frozen DEV endpoint is not reachable from this runner, so no live DEV gate can be claimed yet." + }, + "blockers": [ + { + "type": "network_blocker", + "scope": "devPreconditions", + "status": "open", + "summary": "http://74.48.78.17:6667/health and /live both fail with curl exit 7; live DEV ingress evidence is missing." + } + ], + "milestones": [ + { + "id": "M0", + "status": "pass", + "commandCount": 6, + "evidenceCount": 6, + "summary": "Frozen contract, artifact, boundary, drift, and evidence fixtures are parseable and aligned to the DEV boundary." + }, + { + "id": "M1", + "status": "pass", + "commandCount": 1, + "evidenceCount": 4, + "summary": "The local M1 contract smoke passes." + }, + { + "id": "M2", + "status": "pass", + "commandCount": 1, + "evidenceCount": 3, + "summary": "The deploy smoke remains fixture-only and validates the frozen route phases and artifact metadata." + }, + { + "id": "M3", + "status": "pass", + "commandCount": 1, + "evidenceCount": 3, + "summary": "The local hardware trusted loop smoke passes." + }, + { + "id": "M4", + "status": "pass", + "commandCount": 1, + "evidenceCount": 1, + "summary": "The local agent automation loop smoke passes." + }, + { + "id": "M5", + "status": "pass", + "commandCount": 3, + "evidenceCount": 3, + "summary": "The M5 orchestration is green in dry-run mode, but it stays fixture-only and does not claim live DEV readiness." + } + ], + "artifactCount": 13, + "healthCount": 14, + "gatewaySessionCount": 2, + "boxResourceCount": 2, + "patchPanelCount": 1, + "operationCount": 2, + "evidenceCount": 2, + "traceCount": 8, + "auditCount": 7, + "cleanupCount": 2, + "artifacts": [ + { + "serviceId": "hwlab-edge-proxy", + "commitId": "caa9ed0", + "image": "hwlab-edge-proxy", + "tag": "dry-run-caa9ed0", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "digestNotApplicableReason": null, + "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", + "digestNotApplicableReason": null, + "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", + "digestNotApplicableReason": null, + "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", + "digestNotApplicableReason": null, + "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", + "digestNotApplicableReason": null, + "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", + "digestNotApplicableReason": null, + "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", + "digestNotApplicableReason": null, + "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", + "digestNotApplicableReason": null, + "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", + "digestNotApplicableReason": null, + "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", + "digestNotApplicableReason": null, + "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", + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-edge-route", + "serviceId": "hwlab-edge-proxy", + "component": "master edge proxy", + "status": "healthy", + "deployEnv": "dev", + "endpoint": "http://74.48.78.17:6667", + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-frp", + "serviceId": "hwlab-tunnel-client", + "component": "frp", + "status": "healthy", + "deployEnv": "dev", + "endpoint": null, + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-router", + "serviceId": "hwlab-router", + "component": "D601 router", + "status": "healthy", + "deployEnv": "dev", + "endpoint": null, + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-cloud-api", + "serviceId": "hwlab-cloud-api", + "component": "Cloud API", + "status": "healthy", + "deployEnv": "dev", + "endpoint": null, + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-cloud-web", + "serviceId": "hwlab-cloud-web", + "component": "Cloud Web", + "status": "healthy", + "deployEnv": "dev", + "endpoint": "http://74.48.78.17:6667", + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-gateway", + "serviceId": "hwlab-gateway", + "component": "Gateway", + "status": "healthy", + "deployEnv": "dev", + "endpoint": null, + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-gateway-simu", + "serviceId": "hwlab-gateway-simu", + "component": "Gateway simulator", + "status": "healthy", + "deployEnv": "dev", + "endpoint": null, + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-box-simu", + "serviceId": "hwlab-box-simu", + "component": "Box simulator", + "status": "healthy", + "deployEnv": "dev", + "endpoint": null, + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-patch-panel", + "serviceId": "hwlab-patch-panel", + "component": "Patch panel", + "status": "healthy", + "deployEnv": "dev", + "endpoint": null, + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-agent-mgr", + "serviceId": "hwlab-agent-mgr", + "component": "Agent manager", + "status": "healthy", + "deployEnv": "dev", + "endpoint": null, + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-agent-worker", + "serviceId": "hwlab-agent-worker", + "component": "Agent worker", + "status": "healthy", + "deployEnv": "dev", + "endpoint": null, + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-agent-skills", + "serviceId": "hwlab-agent-skills", + "component": "Agent skills", + "status": "healthy", + "deployEnv": "dev", + "endpoint": null, + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + }, + { + "healthId": "hlt_m5-cli", + "serviceId": "hwlab-cli", + "component": "CLI", + "status": "healthy", + "deployEnv": "dev", + "endpoint": "http://74.48.78.17:6667", + "observedBy": "fixture", + "dryRun": true, + "healthTimestamp": "2026-05-21T00:00:00.000Z" + } + ], + "topology": { + "projectId": "proj_m5-mvp-e2e", + "projectStatus": "active", + "gateways": [ + { + "gatewaySessionId": "gws_m5-0001", + "serviceId": "hwlab-gateway", + "gatewayId": "gtw_m5-dev-boundary", + "status": "connected", + "environment": "dev", + "endpoint": "http://74.48.78.17:6667/gateway" + }, + { + "gatewaySessionId": "gws_m5-simu-0001", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gtw_m5-simu", + "status": "connected", + "environment": "dev", + "endpoint": null + } + ], + "boxResources": [ + { + "resourceId": "res_m5-control-relay", + "gatewaySessionId": "gws_m5-0001", + "boxId": "box_m5-control", + "resourceType": "relay", + "name": "M5 control relay", + "state": "available", + "environment": "dev" + }, + { + "resourceId": "res_m5-target-board", + "gatewaySessionId": "gws_m5-0001", + "boxId": "box_m5-target", + "resourceType": "board", + "name": "M5 target board", + "state": "available", + "environment": "dev" + } + ], + "patchPanel": { + "patchPanelStatusId": "pps_m5-0001", + "serviceId": "hwlab-patch-panel", + "state": "active", + "environment": "dev", + "activeConnectionCount": 1, + "activeConnections": [ + { + "fromResourceId": "res_m5-control-relay", + "fromPort": "out1", + "toResourceId": "res_m5-target-board", + "toPort": "reset" + } + ] + } + }, + "agent": { + "agentSessionId": "agt_m5-0001", + "agentServiceId": "hwlab-agent-mgr", + "agentStatus": "completed", + "workerSessionId": "wkr_m5-0001", + "workerServiceId": "hwlab-agent-worker", + "workerStatus": "completed", + "projectId": "proj_m5-mvp-e2e", + "environment": "dev", + "cleanupId": "cln_m5-worker" + }, + "operations": [ + { + "operationId": "op_m5-direct-0001", + "requestedBy": "user_m5-dry-run", + "status": "succeeded", + "resourceId": "res_m5-control-relay", + "capabilityId": "cap_m5-relay-set", + "agentSessionId": null, + "workerSessionId": null, + "environment": "dev" + }, + { + "operationId": "op_m5-agent-0001", + "requestedBy": "agent_m5-dry-run", + "status": "succeeded", + "resourceId": "res_m5-control-relay", + "capabilityId": "cap_m5-relay-set", + "agentSessionId": "agt_m5-0001", + "workerSessionId": "wkr_m5-0001", + "environment": "dev" + } + ], + "evidenceRecords": [ + { + "evidenceId": "evi_m5-direct-0001", + "operationId": "op_m5-direct-0001", + "agentSessionId": null, + "workerSessionId": null, + "kind": "trace", + "uri": "fixtures/mvp/m5-e2e/evidence/direct-operation.txt", + "sha256": "4cd5cc2c942501b55f7a32f4184e9a1c920b31f13110f44ec68ddc0ad7dc1e34", + "serviceId": "hwlab-cloud-api", + "environment": "dev", + "dryRun": true + }, + { + "evidenceId": "evi_m5-agent-0001", + "operationId": "op_m5-agent-0001", + "agentSessionId": "agt_m5-0001", + "workerSessionId": "wkr_m5-0001", + "kind": "trace", + "uri": "fixtures/mvp/m5-e2e/evidence/agent-operation.txt", + "sha256": "96e7ffa99efd78b3d5d1b1dfd08be2f077f618dacf786b47f764794714c15c1c", + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "dryRun": true + } + ], + "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" + ] + } + ], + "realDevGate": { + "sourcePlanCommand": "hwlab-cli test e2e --env dev --mvp", + "command": "hwlab-cli test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production", + "requiresAllBlockersClear": true, + "requiresDryRunPass": true, + "requiresArtifactObservability": true, + "requiresD601RouteObservation": true, + "requiresHumanApprovalForRealDev": true + }, + "safety": { + "mode": "dry-run", + "allowNetwork": false, + "allowDeploy": false, + "allowBrowserE2E": false, + "allowSecrets": false, + "prohibitedActions": [ + "real-dev-deploy", + "prod-deploy", + "prod-smoke", + "browser-e2e", + "heavyweight-e2e", + "secret-or-token-read", + "force-push", + "unidesk-runtime-substitution" + ] + } +}; diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 6f04d800..f71a9bf5 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -3,7 +3,7 @@ - HWLAB Cloud Web + HWLAB DEV MVP Gate @@ -11,47 +11,126 @@
-

HWLAB

-

Cloud Web

+

HWLAB L6 Frontend / CLI MVP

+

DEV MVP Gate

-
dev skeleton
+
-
+
-

Runtime

-

Mock dashboard for the L6 CLI and cloud-web surface.

+

Acceptance Source

+

Contract-driven view of the checked-in M0-M5 gate report and M5 dry-run plan.

-
+
+
+ +
+
+

M0-M5 Gates

+

Local contract and dry-run status. Live DEV remains separate from fixture evidence.

+
+
-

MVP route

-

Fixed dry-run path shared with the CLI skeleton.

-
-
    -
    - -
    -
    -

    Projects

    -

    Static project list seeded from the mock fixture.

    +

    DEV Health Contract

    +

    Read from the M5 gate fixture; live health is not claimed while blockers are open.

    - + + - - + + - +
    ProjectComponentService StatusEnvironmentUpdatedObservedEndpoint
    + +
    +
    +

    Artifact / Deploy Commit

    +

    Artifact identity fields required by the MVP DoD.

    +
    +
    + + + + + + + + + + + +
    ServiceCommitImageDigestHealth timestamp
    +
    +
    + +
    +
    +
    +

    2 Box / 2 Gateway / Patch Panel

    +

    Topology contract for the hardware trusted loop.

    +
    +
    +
    + +
    +
    +

    Agent Session Evidence

    +

    Agent and worker session IDs tied to trace, audit, evidence, and cleanup.

    +
    +
    +
    +
    + +
    +
    +

    Evidence Records

    +

    Direct and agent hardware operations remain fixture-bound until live DEV blockers clear.

    +
    +
    + + + + + + + + + + + +
    EvidenceOperationServiceURISHA-256
    +
    +
    + +
    +
    +
    +

    Blocked Evidence

    +

    Open blocker evidence that prevents live DEV acceptance.

    +
    +
      +
      + +
      +
      +

      Operator Commands

      +

      Safe CLI entry points for manual acceptance.

      +
      +
        +
        +
        diff --git a/web/hwlab-cloud-web/scripts/build.mjs b/web/hwlab-cloud-web/scripts/build.mjs index 8d8a057d..b0b1d620 100644 --- a/web/hwlab-cloud-web/scripts/build.mjs +++ b/web/hwlab-cloud-web/scripts/build.mjs @@ -5,7 +5,7 @@ const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), ". const distDir = path.resolve(rootDir, "dist"); fs.mkdirSync(distDir, { recursive: true }); -for (const file of ["index.html", "styles.css", "app.mjs", "runtime.mjs"]) { +for (const file of ["index.html", "styles.css", "app.mjs", "gate-summary.mjs"]) { fs.copyFileSync(path.resolve(rootDir, file), path.resolve(distDir, file)); } diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index be93ed4f..0ff86020 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -1,8 +1,13 @@ +import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs"; +import { gateSummary } from "../gate-summary.mjs"; -const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); -const requiredFiles = ["index.html", "styles.css", "app.mjs", "runtime.mjs"]; +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = path.resolve(rootDir, "../.."); +const requiredFiles = ["index.html", "styles.css", "app.mjs", "gate-summary.mjs"]; for (const file of requiredFiles) { const filePath = path.resolve(rootDir, file); @@ -12,8 +17,27 @@ for (const file of requiredFiles) { } const html = fs.readFileSync(path.resolve(rootDir, "index.html"), "utf8"); -if (!html.includes("HWLAB Cloud Web")) { - throw new Error("index.html missing title"); -} +assert.match(html, /HWLAB DEV MVP Gate/); +assert.match(html, /health-body/); +assert.match(html, /artifact-body/); +assert.match(html, /agent-grid/); -console.log("hwlab-cloud-web check ok"); +const sourceSummary = loadMvpGateSummary(repoRoot); +assert.equal(gateSummary.endpoint, sourceSummary.endpoint); +assert.equal(gateSummary.gateStatus, sourceSummary.gateStatus); +assert.equal(gateSummary.milestones.length, 6); +assert.deepEqual( + gateSummary.milestones.map((item) => item.id), + ["M0", "M1", "M2", "M3", "M4", "M5"] +); +assert.equal(gateSummary.artifactCount, 13); +assert.equal(gateSummary.healthCount, 14); +assert.equal(gateSummary.gatewaySessionCount, 2); +assert.equal(gateSummary.boxResourceCount, 2); +assert.equal(gateSummary.topology.patchPanel.serviceId, "hwlab-patch-panel"); +assert.equal(gateSummary.agent.agentServiceId, "hwlab-agent-mgr"); +assert.equal(gateSummary.agent.workerServiceId, "hwlab-agent-worker"); +assert.equal(gateSummary.evidenceRecords.length, 2); +assert.equal(gateSummary.safety.allowNetwork, false); + +console.log("hwlab-cloud-web check ok: gate summary reads M0-M5 report"); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 1f8a2437..51c6b500 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -1,13 +1,18 @@ :root { color-scheme: dark; - --bg: #0d1117; - --surface: #161b22; - --surface-2: #1f2630; - --text: #e6edf3; - --muted: #9aa4b2; - --accent: #58a6ff; - --border: #30363d; - --good: #3fb950; + --bg: #101214; + --surface: #171a1f; + --surface-2: #20242b; + --surface-3: #252a32; + --text: #edf0e8; + --muted: #a9b0aa; + --line: #373d43; + --accent: #f0c75e; + --ok: #7fc97a; + --warn: #f0a35e; + --bad: #ff6b5f; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --body: "Aptos", "Segoe UI", system-ui, sans-serif; } * { @@ -20,40 +25,41 @@ body { min-height: 100%; background: var(--bg); color: var(--text); - font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font: 13px/1.45 var(--body); } body { - padding: 24px; + padding: 18px; } .shell { - max-width: 1160px; + max-width: 1380px; margin: 0 auto; display: grid; - gap: 16px; + gap: 12px; } .topbar, .panel { background: var(--surface); - border: 1px solid var(--border); + border: 1px solid var(--line); } .topbar { + min-height: 74px; display: flex; justify-content: space-between; gap: 16px; align-items: center; - padding: 16px 18px; + padding: 14px 16px; } .eyebrow { - margin: 0 0 4px; + margin: 0 0 2px; color: var(--muted); - text-transform: uppercase; + font-size: 11px; letter-spacing: 0; - font-size: 12px; + text-transform: uppercase; } h1, @@ -61,94 +67,259 @@ h2, p, dl, ol, +ul, table { margin: 0; } h1 { - font-size: 22px; + font-size: 24px; + line-height: 1.1; +} + +h2 { + font-size: 15px; + line-height: 1.2; } .status { - padding: 8px 12px; - border: 1px solid var(--border); - background: var(--surface-2); - color: var(--good); + display: flex; + align-items: center; + justify-content: flex-end; } .panel { - padding: 16px 18px; + padding: 14px 16px; +} + +.panel.compact { + padding: 12px 14px; } .panel-head { display: grid; - gap: 4px; - margin-bottom: 16px; + gap: 3px; + margin-bottom: 12px; } .panel-head p { color: var(--muted); + max-width: 880px; } -.grid { +.split { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px 18px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; } -.grid dt { - color: var(--muted); - font-size: 12px; -} - -.grid dd { - margin: 2px 0 0; - font-weight: 600; - word-break: break-word; -} - -.steps { +.metrics { display: grid; gap: 8px; - padding-left: 20px; } -.steps li { - padding-left: 8px; +.metrics.six { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.metrics.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.metric, +.milestone { + min-width: 0; + background: var(--surface-2); + border: 1px solid var(--line); + padding: 9px 10px; +} + +.metric-label { + display: block; + color: var(--muted); + font-size: 11px; + line-height: 1.2; +} + +.metric-value { + display: block; + margin-top: 4px; + font-size: 13px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.milestones { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; +} + +.milestone { + display: grid; + gap: 7px; + min-height: 118px; +} + +.milestone-head, +.blocker-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.milestone p, +.blockers p { + color: var(--text); + overflow-wrap: anywhere; +} + +.milestone small { + color: var(--muted); +} + +.badge { + display: inline-flex; + align-items: center; + width: fit-content; + max-width: 100%; + min-height: 24px; + padding: 3px 8px; + border: 1px solid var(--line); + background: var(--surface-3); + color: var(--text); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + overflow-wrap: anywhere; +} + +.tone-pass, +.tone-healthy { + color: var(--ok); +} + +.tone-blocked, +.tone-network_blocker { + color: var(--bad); +} + +.tone-not_run { + color: var(--warn); } .table-wrap { overflow-x: auto; + border: 1px solid var(--line); } table { width: 100%; + min-width: 900px; border-collapse: collapse; + background: var(--surface-2); } th, td { text-align: left; - padding: 10px 8px; - border-bottom: 1px solid var(--border); + vertical-align: top; + padding: 8px 9px; + border-bottom: 1px solid var(--line); } th { color: var(--muted); - font-weight: 500; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; } -@media (max-width: 760px) { +td { + max-width: 360px; + overflow-wrap: anywhere; +} + +tbody tr:last-child td { + border-bottom: 0; +} + +.mono { + font-family: var(--mono); + font-size: 12px; +} + +.wrap { + overflow-wrap: anywhere; + word-break: break-word; +} + +.danger { + border-color: color-mix(in srgb, var(--bad) 45%, var(--line)); +} + +.blockers, +.commands { + display: grid; + gap: 8px; + padding-left: 0; + list-style: none; +} + +.blockers li, +.commands li { + min-width: 0; + padding: 9px 10px; + background: var(--surface-2); + border: 1px solid var(--line); + overflow-wrap: anywhere; +} + +.commands { + counter-reset: command; +} + +.commands li { + counter-increment: command; +} + +.commands li::before { + content: counter(command) ". "; + color: var(--accent); + font-family: var(--body); + font-weight: 700; +} + +@media (max-width: 1120px) { + .metrics.six, + .milestones { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 820px) { body { - padding: 14px; + padding: 10px; + } + + .topbar, + .split { + grid-template-columns: 1fr; } .topbar { - flex-direction: column; - align-items: flex-start; + display: grid; + align-items: start; } - .grid { + .status { + justify-content: flex-start; + } + + .metrics.six, + .metrics.two, + .milestones { grid-template-columns: 1fr; } }