549 lines
19 KiB
JavaScript
549 lines
19 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_SOURCE = "source:mvp-gate-summary";
|
|
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 defaultMvpGateReport() {
|
|
return {
|
|
reportVersion: "v1",
|
|
issue: "pikasTech/HWLAB#39",
|
|
taskId: "dev-mvp-gate-source-summary",
|
|
commitId: "source-summary",
|
|
acceptanceLevel: "dev_mvp_gate",
|
|
devOnly: true,
|
|
prodDisabled: true,
|
|
gateStatus: "blocked",
|
|
sourceContract: {
|
|
status: "pass",
|
|
documents: [
|
|
"docs/dev-gate-report.md",
|
|
"docs/dev-acceptance-matrix.md",
|
|
"docs/m0-contract-audit.md",
|
|
"docs/m1-local-smoke.md",
|
|
"docs/m2-dev-deploy-smoke.md",
|
|
"docs/m3-hardware-loop.md",
|
|
"docs/m4-agent-loop.md",
|
|
"docs/m5-mvp-e2e.md",
|
|
"docs/runtime-boundary-guard.md",
|
|
"docs/topology-constraints.md",
|
|
"docs/artifact-catalog.md",
|
|
"docs/schema-drift-map.md",
|
|
"protocol/README.md",
|
|
"protocol/audit.md",
|
|
"protocol/evidence-chain.md",
|
|
"protocol/mvp-e2e-contract.md"
|
|
],
|
|
summary: "The DEV gate is anchored to the frozen matrix, gate contract, and M0-M5 milestone source docs; progress and acceptance evidence live in GitHub issue or PR comments, not repository report files."
|
|
},
|
|
validationCommands: [
|
|
"node scripts/repo-reports-guard.mjs",
|
|
"node --check scripts/validate-dev-gate-report.mjs",
|
|
"node scripts/validate-dev-gate-report.mjs"
|
|
],
|
|
localSmoke: {
|
|
status: "pass",
|
|
commands: [
|
|
"node scripts/validate-contract.mjs",
|
|
"node scripts/validate-artifact-catalog.mjs",
|
|
"node scripts/m1-contract-smoke.mjs",
|
|
"node scripts/m2-dev-deploy-smoke.mjs --dry-run"
|
|
],
|
|
evidence: [
|
|
"Source contracts remain parseable.",
|
|
"MVP dry-run fixtures remain local/source only.",
|
|
"Repository report directory is forbidden by repo guard."
|
|
],
|
|
summary: "M0-M2 contract and smoke checks remain local/source scoped and do not create repository report files."
|
|
},
|
|
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"
|
|
],
|
|
evidence: [
|
|
"Dry-run uses fixtures and performs no DEV/PROD mutations.",
|
|
"Dry-run evidence is not live acceptance."
|
|
],
|
|
summary: "The M5 orchestration dry-run is fixture-only and does not establish a live DEV gate."
|
|
},
|
|
devPreconditions: {
|
|
status: "blocked",
|
|
classification: "runtime durable adapter; skills injection; hardware loop",
|
|
requirements: [
|
|
"Current DEV route and health checks must be rerun when needed and summarized in GitHub.",
|
|
"Runtime durable adapter proves postgres schema, migration, and read-query readiness before M4/M5 can promote.",
|
|
"Agent manager, skills service, and worker jobs expose explicit skill commit/version before M4/M5 can promote.",
|
|
"M3 hardware loop proves box-simu-1 DO1 to box-simu-2 DI1 via patch-panel.",
|
|
"Keep the run DEV-only and avoid PROD, heavy e2e, secrets, restarts, or UniDesk substitution."
|
|
],
|
|
commands: [
|
|
"curl -fsS --max-time 8 http://74.48.78.17:16666/",
|
|
"curl -fsS --max-time 8 http://74.48.78.17:16667/health/live",
|
|
"curl -fsS --max-time 8 http://74.48.78.17:16667/health",
|
|
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production",
|
|
"node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"
|
|
],
|
|
evidence: [
|
|
"Repository report files were removed and no longer qualify as current evidence.",
|
|
"Live DEV acceptance must be represented in #7, the owning issue, daily brief, or PR comments."
|
|
],
|
|
summary: "Live MVP remains blocked until current DEV checks clear runtime durability, skills injection, and M3 hardware-loop blockers outside repository report files."
|
|
},
|
|
blockers: [
|
|
{
|
|
type: "runtime_blocker",
|
|
scope: "runtime-durable-adapter",
|
|
status: "open",
|
|
sourceIssue: "pikasTech/HWLAB#164",
|
|
classification: "runtime_durable_adapter_query_blocked",
|
|
summary:
|
|
"cloud-api durable runtime adapter readiness is blocked by runtime_durable_adapter_query_blocked; it must prove schema, migration, and read-query durability before M4/M5 promotion."
|
|
},
|
|
{
|
|
type: "agent_blocker",
|
|
scope: "skills-commit-version-injection",
|
|
status: "open",
|
|
sourceIssue: "pikasTech/HWLAB#164",
|
|
classification: "skills injection",
|
|
summary: "DEV skills injection must expose explicit skills commit and version across manager, skills, and worker paths."
|
|
},
|
|
{
|
|
type: "runtime_blocker",
|
|
scope: "m3-hardware-loop-runtime",
|
|
status: "open",
|
|
sourceIssue: "pikasTech/HWLAB#38",
|
|
classification: "hardware loop",
|
|
summary: "Live M3 must prove the patch-panel trusted loop with operation, trace, audit, and evidence identifiers."
|
|
}
|
|
],
|
|
milestones: [
|
|
{
|
|
id: "M0",
|
|
status: "pass",
|
|
commands: ["node scripts/validate-contract.mjs"],
|
|
evidence: ["Source contracts are parseable."],
|
|
summary: "Frozen contracts remain source-ready."
|
|
},
|
|
{
|
|
id: "M1",
|
|
status: "pass",
|
|
commands: ["node scripts/m1-contract-smoke.mjs"],
|
|
evidence: ["Local skeleton smoke remains local-only."],
|
|
summary: "The local M1 contract smoke passes."
|
|
},
|
|
{
|
|
id: "M2",
|
|
status: "pass",
|
|
commands: ["node scripts/m2-dev-deploy-smoke.mjs --dry-run"],
|
|
evidence: ["Deploy smoke remains fixture-only."],
|
|
summary: "The deploy smoke validates source contracts without live acceptance."
|
|
},
|
|
{
|
|
id: "M3",
|
|
status: "blocked",
|
|
commands: ["node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"],
|
|
evidence: ["Repository report snapshots were removed; current live trusted-loop proof is required."],
|
|
summary: "Active DEV-LIVE M3 remains blocked by hardware loop topology until the trusted loop is proven.",
|
|
blocker: "hardware loop"
|
|
},
|
|
{
|
|
id: "M4",
|
|
status: "blocked",
|
|
commands: ["node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"],
|
|
evidence: ["Runtime durable adapter readiness and skills injection must be current live facts."],
|
|
summary: "Active DEV-LIVE M4 remains blocked by runtime durable adapter readiness and skills commit/version injection.",
|
|
blocker: "runtime_durable_adapter_query_blocked; skills injection"
|
|
},
|
|
{
|
|
id: "M5",
|
|
status: "blocked",
|
|
commands: ["node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run"],
|
|
evidence: ["M5 dry-run remains fixture-only; live acceptance must be posted in GitHub."],
|
|
summary: "The M5 orchestration is green in dry-run mode, but active DEV-LIVE acceptance remains blocked.",
|
|
blocker: "runtime durable adapter; skills injection; hardware loop"
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
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_SOURCE,
|
|
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 ?? null;
|
|
const planPath = options.planPath ?? DEFAULT_M5_PLAN_PATH;
|
|
const report = reportPath ? readJsonFile(repoRoot, reportPath) : defaultMvpGateReport();
|
|
const plan = readJsonFile(repoRoot, planPath);
|
|
const summary = buildMvpGateSummary({ report, plan });
|
|
summary.generatedFrom = {
|
|
gateReport: reportPath ?? DEFAULT_GATE_REPORT_SOURCE,
|
|
m5Plan: planPath
|
|
};
|
|
return summary;
|
|
}
|