Files
pikasTech-HWLAB/internal/mvp-gate/summary.mjs
T
Lyon 7a0648088c fix: wire distinct M3 simulator identities
Merge PR #159 after Code Queue rebase and validation. Runner verified M3 cardinality, hardware-loop smoke, patch-panel runtime smoke, m3 readonly contract, and git diff check. This is source/config blocker removal only; it does not claim DEV-LIVE M3 pass.
2026-05-23 00:09:29 +08:00

360 lines
12 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { DEV_ENDPOINT } from "../protocol/index.mjs";
export { DEV_ENDPOINT };
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";
export const M3_WORKBENCH_TOPOLOGY = Object.freeze({
projectId: "prj_m3_hardware_loop",
projectStatus: "blocked",
gateways: Object.freeze([
Object.freeze({
gatewaySessionId: "gws_gwsimu_1",
serviceId: "hwlab-gateway-simu",
gatewayId: "gwsimu_1",
status: "connected",
environment: "dev",
endpoint: null
}),
Object.freeze({
gatewaySessionId: "gws_gwsimu_2",
serviceId: "hwlab-gateway-simu",
gatewayId: "gwsimu_2",
status: "connected",
environment: "dev",
endpoint: null
})
]),
boxResources: Object.freeze([
Object.freeze({
resourceId: "res_boxsimu_1",
gatewaySessionId: "gws_gwsimu_1",
boxId: "boxsimu_1",
resourceType: "simulator_endpoint",
name: "BOX-SIMU 1 source DO1",
state: "available",
environment: "dev"
}),
Object.freeze({
resourceId: "res_boxsimu_2",
gatewaySessionId: "gws_gwsimu_2",
boxId: "boxsimu_2",
resourceType: "simulator_endpoint",
name: "BOX-SIMU 2 target DI1",
state: "available",
environment: "dev"
})
]),
patchPanel: Object.freeze({
patchPanelStatusId: "pps_m3_hardware_loop",
serviceId: "hwlab-patch-panel",
state: "blocked",
environment: "dev",
activeConnectionCount: 1,
activeConnections: Object.freeze([
Object.freeze({
fromResourceId: "res_boxsimu_1",
fromPort: "DO1",
toResourceId: "res_boxsimu_2",
toPort: "DI1"
})
])
})
});
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
};
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
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: clone(M3_WORKBENCH_TOPOLOGY),
m5DryRunTopology: {
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;
}