88e647e380
Merge PR #146 after Code Queue rebase to main a444387 and validation. This improves the deployed workbench UI surface for trusted records with explicit DEV-LIVE/SOURCE/DRY-RUN/BLOCKED labels. It does not claim M3 DEV-LIVE and does not change PROD or secrets.
385 lines
12 KiB
JavaScript
385 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,
|
|
traceId: record.metadata?.traceId ?? null,
|
|
dryRun: record.metadata?.dryRun === true,
|
|
createdAt: record.createdAt ?? null
|
|
};
|
|
}
|
|
|
|
function summarizeAuditEvent(event) {
|
|
return {
|
|
auditId: event.auditId,
|
|
traceId: event.traceId,
|
|
action: event.action,
|
|
targetType: event.targetType,
|
|
targetId: event.targetId,
|
|
projectId: event.projectId ?? null,
|
|
gatewaySessionId: event.gatewaySessionId ?? null,
|
|
operationId: event.operationId ?? null,
|
|
workerSessionId: event.workerSessionId ?? null,
|
|
serviceId: event.serviceId,
|
|
environment: event.environment,
|
|
outcome: event.outcome ?? null,
|
|
dryRun: event.metadata?.dryRun === true,
|
|
occurredAt: event.occurredAt
|
|
};
|
|
}
|
|
|
|
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,
|
|
dryRun: operation.input?.dryRun === true,
|
|
requestedAt: operation.requestedAt ?? null,
|
|
updatedAt: operation.updatedAt ?? null
|
|
})),
|
|
auditEvents: plan.auditEvents.map(summarizeAuditEvent),
|
|
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;
|
|
}
|