feat: add M5 DEV gate aggregator v2
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
# DEV Evidence / Blocker Aggregator
|
||||
|
||||
This is the lightweight report-only entry point for `pikasTech/HWLAB#41`. It
|
||||
helps a commander decide whether the DEV acceptance path is green, blocked, or
|
||||
ready for another repair round across `#33-#39`, `#7`, and `#31`.
|
||||
This is the lightweight report-only entry point for `pikasTech/HWLAB#41` and
|
||||
the M5 DEV gate aggregator v2 for `pikasTech/HWLAB#58`. It helps a commander
|
||||
decide whether the DEV acceptance path is green, blocked, or ready for another
|
||||
repair round across `#33-#39`, `#46`, `#7`, and the MVP DoD in `#9`.
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
@@ -16,6 +17,17 @@ For a short validation response:
|
||||
node scripts/dev-evidence-blocker-aggregator.mjs --check
|
||||
```
|
||||
|
||||
To refresh the committed v2 machine and human reports:
|
||||
|
||||
```sh
|
||||
node scripts/dev-evidence-blocker-aggregator.mjs --write-report
|
||||
```
|
||||
|
||||
The generated files are:
|
||||
|
||||
- `reports/dev-gate/dev-m5-gate-aggregator-v2.json`
|
||||
- `reports/dev-gate/dev-m5-gate-aggregator-v2.md`
|
||||
|
||||
## Source Levels
|
||||
|
||||
The aggregator keeps evidence levels separate so fixture output cannot be
|
||||
@@ -42,27 +54,37 @@ promoted to live DEV evidence:
|
||||
|
||||
## Blocker Posture
|
||||
|
||||
If GitHub issues are not readable in the runner, the report marks
|
||||
`issue-read` as blocked and uses only committed repository artifacts plus the
|
||||
task prompt. This keeps #33-#39 status visible without treating issue comments
|
||||
as silently available.
|
||||
The v2 report reads committed report fixtures only:
|
||||
|
||||
- `reports/dev-gate/dev-preflight-report.json`
|
||||
- `reports/dev-gate/dev-deploy-report.json`
|
||||
- `reports/dev-gate/dev-artifacts.json`
|
||||
- `reports/dev-gate/dev-edge-health.json`
|
||||
- `reports/dev-gate/dev-m3-hardware-loop.json`
|
||||
- `reports/dev-gate/dev-m4-agent-loop.json`
|
||||
- `reports/dev-gate/dev-mvp-gate-report.json`
|
||||
- `reports/d601-k3s-readonly-observability.json`
|
||||
|
||||
The report is blocked until real DEV-LIVE evidence is attached for the DEV
|
||||
route and downstream HWLAB runtime services. Current source, local, and dry-run
|
||||
contracts are useful readiness signals, but they are not live DEV proof.
|
||||
|
||||
## Next-Round Buckets
|
||||
## Next-Round Order
|
||||
|
||||
The JSON output groups recommended next actions into:
|
||||
The JSON output groups recommended next actions in dependency order:
|
||||
|
||||
- `artifact/base image`: real publish or provenance evidence for frozen HWLAB
|
||||
service IDs.
|
||||
- `edge/network`: read-only DEV route observations for ingress, edge, frp, and
|
||||
D601 router.
|
||||
- `runtime/health`: DEV health JSON for cloud, gateway/simulator,
|
||||
patch-panel, agent, worker, skills, and CLI surfaces.
|
||||
- `M3/M4 live`: narrow authorized DEV-only loop evidence with trace, audit,
|
||||
operation, session, evidence, and cleanup IDs.
|
||||
1. `base-image`: preload or tag an approved Node 20 DEV builder image, then
|
||||
rerun the base-image and artifact publish preflights.
|
||||
2. `artifact-publish`, `artifact-catalog`, `ghcr`: publish every frozen HWLAB
|
||||
service artifact and record immutable registry digests for the selected
|
||||
`origin/main` commit.
|
||||
3. `d601-k3s`, `kubectl`, kubeconfig/maintenance bridge: restore read-only
|
||||
hwlab-dev cluster observability without printing Secrets.
|
||||
4. `cloud-api-db`: prove DEV cloud-api DB env and health readiness with
|
||||
redacted values only.
|
||||
5. `dev-edge`, `dev-edge-health`, `dev-ingress-health`: repair
|
||||
`:6667`/frp/edge/router reachability, then rerun read-only DEV edge health
|
||||
before M3/M4/M5 live loops.
|
||||
|
||||
## Boundaries
|
||||
|
||||
|
||||
@@ -64,6 +64,34 @@ but it must include:
|
||||
- evidence, audit, trace, operation, agent session, worker session, gateway
|
||||
session, and project IDs when the phase touches an MVP loop.
|
||||
|
||||
## DEV Gate Aggregator v2
|
||||
|
||||
Use `reports/dev-gate/dev-m5-gate-aggregator-v2.json` and
|
||||
`reports/dev-gate/dev-m5-gate-aggregator-v2.md` as the current report-only M5
|
||||
gate summary for `pikasTech/HWLAB#58`. Refresh them only from committed
|
||||
fixtures:
|
||||
|
||||
```sh
|
||||
node scripts/dev-evidence-blocker-aggregator.mjs --write-report
|
||||
node scripts/validate-dev-gate-report.mjs
|
||||
```
|
||||
|
||||
The aggregator separates `SOURCE`, `LOCAL`, `DRY-RUN`, `DEV-LIVE`, and
|
||||
`BLOCKED`. It must never promote source, local, dry-run, contract-only,
|
||||
not-run, or failed-live evidence into `DEV-LIVE`.
|
||||
|
||||
Current unblock order:
|
||||
|
||||
1. Provide an approved Node 20 DEV builder base image for artifact publish.
|
||||
2. Publish every frozen HWLAB service artifact and record immutable registry
|
||||
digests for the selected `origin/main` commit.
|
||||
3. Restore read-only D601 `hwlab-dev` k3s observability with `kubectl` or an
|
||||
approved kubeconfig/maintenance bridge, without reading Secrets.
|
||||
4. Prove DEV `hwlab-cloud-api` DB env and health readiness with redacted values
|
||||
only.
|
||||
5. Repair the `:6667`/`frp`/edge/router path and rerun read-only DEV edge
|
||||
health before any M3, M4, or M5 live loop.
|
||||
|
||||
## Phase Gates
|
||||
|
||||
Run phases in order. A later phase may start only after every earlier required
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"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/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 internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.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 cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/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/l2-runtime-contract-smoke.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/l2-runtime-contract-smoke.mjs && 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",
|
||||
"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 internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.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 cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/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-gate-report.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/src/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/l2-runtime-contract-smoke.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-gate-report.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --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",
|
||||
"l2:smoke": "node scripts/l2-runtime-contract-smoke.mjs",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
# HWLAB M5 DEV Gate Aggregator v2
|
||||
|
||||
Status: blocked
|
||||
Generated from: `1e9591a6611e`
|
||||
Scope: DEV only, report-only
|
||||
|
||||
## Summary
|
||||
|
||||
SOURCE, LOCAL, and DRY-RUN evidence exists, but DEV-LIVE acceptance is blocked by artifact, observability, DB, edge/frp, and loop evidence gaps.
|
||||
|
||||
## Milestones
|
||||
|
||||
| Milestone | Status | Highest visible level | Live evidence | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| M0 | pass | SOURCE | missing_or_blocked | contract source is available; highest visible level is SOURCE; status is pass. |
|
||||
| M1 | pass | LOCAL | missing_or_blocked | local smoke is available; highest visible level is LOCAL; status is pass. |
|
||||
| M2 | blocked | DRY-RUN | missing_or_blocked | deploy/runtime readiness is blocked before live DEV; highest visible level is DRY-RUN; status is blocked. |
|
||||
| M3 | blocked | LOCAL | missing_or_blocked | hardware loop has source/local shape but no live operation; highest visible level is LOCAL; status is blocked. |
|
||||
| M4 | blocked | LOCAL | missing_or_blocked | agent loop has local smoke but live preflight is blocked; highest visible level is LOCAL; status is blocked. |
|
||||
| M5 | blocked | DRY-RUN | missing_or_blocked | dry-run is green but live MVP gate is blocked; highest visible level is DRY-RUN; status is blocked. |
|
||||
|
||||
## #9 DoD Checks
|
||||
|
||||
| Check | Status | Evidence level | Summary |
|
||||
| --- | --- | --- | --- |
|
||||
| m0-source-contract | pass | SOURCE | M0 contract checks are source-level evidence only. |
|
||||
| m1-local-smoke | pass | LOCAL | M1 local smoke is not a live DEV substitute. |
|
||||
| artifact-publish-digests | blocked | BLOCKED | artifactState=contract-skeleton, ciPublished=false, registryVerified=false, sha256=0, not_published=13 |
|
||||
| d601-k3s-observability | blocked | BLOCKED | D601 runner lacks kubectl/k3s/kubeconfig observability for hwlab-dev. |
|
||||
| dev-edge-frp-6667 | blocked | BLOCKED | No committed report proves live HTTP 200/JSON on http://74.48.78.17:6667. |
|
||||
| cloud-api-db-ready | blocked | BLOCKED | Manifest-level DB env exists, but live DB health readiness is still blocked/missing. |
|
||||
| m5-mvp-dev-live | blocked | BLOCKED | M5 dry-run passed; bounded DEV-LIVE MVP e2e has not passed. |
|
||||
|
||||
## Blockers
|
||||
|
||||
| Priority | Order | Type | Scope | Summary |
|
||||
| --- | ---: | --- | --- | --- |
|
||||
| P0 | 1 | environment_blocker | base-image | HWLAB_DEV_BASE_IMAGE is not set and no approved local DEV builder base image was found in the Docker image cache; expected node:20-bookworm-slim or 127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim. |
|
||||
| P1 | 3 | observability_blocker | artifact-publish | DEV artifact catalog has no CI publish, registry verification, or registry digests |
|
||||
| P1 | 3 | observability_blocker | artifact-source-commit | deploy/catalog artifact commit 24eb3bf/24eb3bf does not match source 9b47ebe49f70 |
|
||||
| P1 | 3 | runtime_blocker | artifact-catalog | deploy/artifact-catalog.dev.json does not prove published artifacts for origin/main 1a2efd4; ciPublished=false, registryVerified=false, not_published=13. |
|
||||
| P1 | 3 | runtime_blocker | dev-artifact-publish | reports/dev-gate/dev-artifacts.json does not prove all HWLAB service artifacts for origin/main 1a2efd4; current status is blocked with 0/13 published. |
|
||||
| P1 | 3 | runtime_blocker | ghcr | This preflight could not verify GHCR manifests for the DEV catalog images without reading credentials. |
|
||||
| P1 | 4 | environment_blocker | d601-k3s-client-binary | Neither kubectl nor k3s is installed in this runner PATH. |
|
||||
| P1 | 4 | environment_blocker | d601-kubeconfig-path | No readable KUBECONFIG/default k3s kubeconfig path was found by metadata checks. |
|
||||
| P1 | 4 | environment_blocker | d601-maintenance-ssh-bridge | No SSH maintenance bridge was detected from ssh binary plus known environment variables or ~/.ssh/config metadata. |
|
||||
| P1 | 4 | environment_blocker | kubectl | kubectl is not installed in the runner |
|
||||
| P1 | 4 | environment_blocker | d601-k3s | D601 runner lacks kubectl and no default kubeconfig was used by this preflight. |
|
||||
| P1 | 5 | runtime_blocker | cloud-api-db | cloud-api health reports DB config blocked; missing HWLAB_CLOUD_DB_URL, HWLAB_CLOUD_DB_SSL_MODE |
|
||||
| P1 | 5 | runtime_blocker | cloud-api-db-health-gate | cloud-api DB runtime env is not ready; missing HWLAB_CLOUD_DB_URL, HWLAB_CLOUD_DB_SSL_MODE. |
|
||||
| P2 | 6 | network_blocker | dev-edge | http://74.48.78.17:6667/health/live is not reachable from this runner. |
|
||||
| P2 | 6 | network_blocker | dev-edge-health | live network probes require --live |
|
||||
| P2 | 6 | network_blocker | dev-ingress-health | Blocked at the #33 DEV runtime readiness condition: public DEV ingress does not accept HWLAB health requests, so #36/M3 hardware-loop checks were not reached. |
|
||||
| P2 | 6 | network_blocker | devPreconditions | connect ECONNREFUSED 74.48.78.17:6667 |
|
||||
| P2 | 6 | network_blocker | devPreconditions | http://74.48.78.17:6667/health and /live both fail with curl exit 7; live DEV ingress evidence is missing. |
|
||||
|
||||
## Next Unblock Order
|
||||
|
||||
1. Preload node:20-bookworm-slim into the D601 Docker cache, or tag it as 127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim. Set HWLAB_DEV_BASE_IMAGE=node:20-bookworm-slim or HWLAB_DEV_BASE_IMAGE=127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim when invoking artifact publish. Rerun this preflight and require status=ready before #35 artifact publish. Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes.
|
||||
Evidence required: Base-image preflight status=ready with approved Node 20 builder base and no UniDesk/runtime substitute.
|
||||
2. Publish DEV images from the intended commit and update the catalog with registry digests and verification evidence.
|
||||
Evidence required: Artifact publish report with ciPublished=true, registryVerified=true, and sha256 digest for each frozen service ID.
|
||||
3. Install kubectl in the D601 Code Queue runner image, or mount an approved k3s kubectl client path for read-only hwlab-dev observation.
|
||||
Evidence required: Read-only kubectl/k3s report proving pods/services/configmaps are observable in hwlab-dev without reading Secrets.
|
||||
4. Configure and verify the DEV cloud-api DB env readiness through health output, without reading secret values.
|
||||
Evidence required: Cloud API health/live output showing DB env ready and redacted secret references, without secret material.
|
||||
5. Bring up or repair the D601-to-master frp route and hwlab-edge-proxy, then rerun the health probe.
|
||||
Evidence required: Read-only DEV route observation for :6667/frp/edge/router with HWLAB service identity and artifact identity.
|
||||
|
||||
## Validation
|
||||
|
||||
- `node --check scripts/dev-evidence-blocker-aggregator.mjs`
|
||||
- `node --check scripts/src/dev-evidence-blocker-aggregator.mjs`
|
||||
- `node scripts/dev-evidence-blocker-aggregator.mjs --check`
|
||||
- `node scripts/dev-evidence-blocker-aggregator.mjs --markdown`
|
||||
- `node --check scripts/validate-dev-gate-report.mjs`
|
||||
- `node scripts/validate-dev-gate-report.mjs`
|
||||
|
||||
## Boundary
|
||||
|
||||
This report reads committed reports and fixtures only. It does not deploy, call DEV, call PROD, read secrets, restart runtime, run heavy e2e, or substitute UniDesk runtime for HWLAB runtime.
|
||||
@@ -1,281 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { access, readdir, readFile } from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
|
||||
import {
|
||||
buildReport,
|
||||
formatCheckSummary,
|
||||
renderMarkdown,
|
||||
repoRoot
|
||||
} from "./src/dev-evidence-blocker-aggregator.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const levels = ["SOURCE", "LOCAL", "DRY-RUN", "DEV-LIVE", "BLOCKED"];
|
||||
const blockerTypes = ["contract_blocker", "environment_blocker", "network_blocker", "runtime_blocker", "agent_blocker", "observability_blocker", "safety_blocker"];
|
||||
const requiredArtifactFields = ["serviceId", "commitId", "image", "tag", "digest", "buildSource", "deployEnv", "healthTimestamp"];
|
||||
const issue = (id) => `pikasTech/HWLAB${id}`;
|
||||
const allSupportedIssues = ["#33", "#34", "#35", "#36", "#37", "#38", "#39", "#7", "#31"].map(issue);
|
||||
|
||||
const contracts = [
|
||||
contract("M0", "SOURCE", "artifact/base image", ["#7", "#31"], ["docs/m0-contract-audit.md", "protocol/schemas/evidence-record.schema.json", "protocol/evidence-chain.md", "protocol/examples/m0-contract/service-ids.json", "scripts/validate-m0-contract.mjs", "scripts/validate-evidence-chain.mjs"], ["node scripts/validate-m0-contract.mjs", "node scripts/validate-evidence-chain.mjs"], "Source contracts freeze service IDs, JSON-RPC, audit, topology, evidence records, and deploy shape."),
|
||||
contract("M1", "LOCAL", "runtime/health", ["#7", "#31"], ["docs/m1-local-smoke.md", "fixtures/mvp/runtime.json", "scripts/m1-contract-smoke.mjs"], ["node scripts/m1-contract-smoke.mjs"], "Local smoke covers cloud-api, simulators, patch-panel routing, CLI dry-run boundary, and no DEV/PROD mutation."),
|
||||
contract("M2", "DRY-RUN", "edge/network", ["#7", "#31", "#33", "#34", "#35", "#39"], ["docs/m2-dev-deploy-smoke.md", "docs/dev-acceptance-checklist.json", "fixtures/dev-deploy-smoke/dev-deploy-smoke.json", "deploy/artifact-catalog.dev.json", "scripts/m2-dev-deploy-smoke.mjs", "scripts/validate-artifact-catalog.mjs", "scripts/validate-runtime-boundary.mjs"], ["node scripts/m2-dev-deploy-smoke.mjs --dry-run", "node scripts/validate-artifact-catalog.mjs", "node scripts/validate-runtime-boundary.mjs"], "Dry-run contract covers edge proxy, frp, D601 router, cloud surface, agent runtime, sim, and patch-panel route phases."),
|
||||
contract("M3", "LOCAL", "M3/M4 live", ["#7", "#31", "#36", "#39"], ["docs/m3-hardware-loop.md", "fixtures/mvp/m3-hardware-loop/topology.json", "scripts/m3-hardware-loop-smoke.mjs"], ["node scripts/m3-hardware-loop-smoke.mjs"], "Local hardware trusted loop fixture proves patch-panel-owned box-simu-1 DO1 to box-simu-2 DI1 propagation."),
|
||||
contract("M4", "LOCAL", "M3/M4 live", ["#7", "#31", "#37", "#39"], ["docs/m4-agent-loop.md", "fixtures/mvp/m4-agent-loop/runtime.json", "fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md", "scripts/m4-agent-loop-smoke.mjs"], ["node scripts/m4-agent-loop-smoke.mjs"], "Local agent loop fixture covers session lifecycle, workspace isolation, skills commit wiring, evidence, and cleanup."),
|
||||
contract("M5", "DRY-RUN", "runtime/health", ["#7", "#31", "#38", "#39"], ["docs/m5-mvp-e2e.md", "protocol/mvp-e2e-contract.md", "fixtures/mvp/m5-e2e/dry-run-plan.json", "scripts/m5-mvp-e2e-dry-run.mjs"], ["node scripts/m5-mvp-e2e-dry-run.mjs"], "Dry-run orchestration checks the M0-M4 dependency graph, MVP sequence, health, trace, audit, evidence, and cleanup contracts.")
|
||||
];
|
||||
|
||||
const visibleParallelResults = [
|
||||
parallel("#33", "BLOCKED", "Prompt says #33-#39 are parallel real DEV workstreams. No issue-specific committed DEV-LIVE result is visible in this checkout."),
|
||||
parallel("#34", "DRY-RUN", "Repository M2 dry-run and artifact contracts can support deploy/artifact review, but this runner cannot read the issue result itself."),
|
||||
parallel("#35", "DRY-RUN", "Repository DEV route fixtures cover edge, frp, and D601 router contracts as DRY-RUN evidence only."),
|
||||
parallel("#36", "LOCAL", "Repository M3 topology fixture and smoke script provide LOCAL evidence only; no live DEV M3 loop output is attached."),
|
||||
parallel("#37", "LOCAL", "Repository M4 runtime fixture and smoke script provide LOCAL evidence only; no live DEV M4 agent run output is attached."),
|
||||
parallel("#38", "DRY-RUN", "Repository M5 dry-run plan validates orchestration readiness, not a live DEV MVP e2e run."),
|
||||
parallel("#39", "BLOCKED", "No repository-local DEV-LIVE gate result is visible to this aggregator.")
|
||||
];
|
||||
|
||||
const nextRoundRecommendations = [
|
||||
next("artifact/base image", "high", ["#33", "#35"], "Publish or attach real DEV artifact provenance for each frozen service ID: commitId, image, immutable tag, digest, buildSource, deployEnv, and healthTimestamp."),
|
||||
next("edge/network", "high", ["#35", "#39"], "Attach read-only DEV route observations for public ingress, master edge proxy, frp, and D601 hwlab-dev/hwlab-router without deploying, restarting, or reading secrets."),
|
||||
next("runtime/health", "high", ["#34", "#35", "#38", "#39"], "Attach current DEV health JSON for cloud API/web, gateway or simulator, box simulator, patch panel, agent manager, worker, skills, and CLI, all tied to HWLAB service IDs."),
|
||||
next("M3/M4 live", "medium", ["#36", "#37", "#38"], "After DEV runtime smoke is green, run the smallest authorized DEV-only M3/M4 loop and attach trace, audit, evidence, operation, session, and cleanup IDs.")
|
||||
];
|
||||
|
||||
function contract(milestone, source, category, supports, paths, commands, summary) {
|
||||
return {
|
||||
milestone,
|
||||
source,
|
||||
status: source === "SOURCE" ? "contract_available" : `${source.toLowerCase().replace("-", "_")}_contract_available`,
|
||||
category,
|
||||
supports: supports.map(issue),
|
||||
paths,
|
||||
commands,
|
||||
summary
|
||||
};
|
||||
}
|
||||
|
||||
function parallel(issue, source, visibleResult) {
|
||||
return {
|
||||
issue: `pikasTech/HWLAB${issue}`,
|
||||
issueRead: "blocked",
|
||||
visibleResult,
|
||||
source
|
||||
};
|
||||
}
|
||||
|
||||
function next(category, priority, owners, recommendation) {
|
||||
return { category, priority, owners, recommendation };
|
||||
}
|
||||
|
||||
async function readJSON(relativePath) {
|
||||
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function pathExists(relativePath) {
|
||||
try {
|
||||
await access(path.join(repoRoot, relativePath), fsConstants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function gitCommit() {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
}).trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
async function assertStaticContracts() {
|
||||
const checklist = await readJSON("docs/dev-acceptance-checklist.json");
|
||||
assert.equal(checklist.environment, ENVIRONMENT_DEV, "DEV checklist environment");
|
||||
assert.equal(checklist.endpoint, DEV_ENDPOINT, "DEV checklist endpoint");
|
||||
assert.deepEqual(checklist.artifactObservabilityFields, requiredArtifactFields);
|
||||
|
||||
const catalog = await readJSON("deploy/artifact-catalog.dev.json");
|
||||
assert.equal(catalog.environment, ENVIRONMENT_DEV, "artifact catalog environment");
|
||||
assert.equal(catalog.endpoint, DEV_ENDPOINT, "artifact catalog endpoint");
|
||||
assert.equal(catalog.publish.ciPublished, false, "skeleton catalog must not claim CI publish");
|
||||
assert.equal(catalog.publish.registryVerified, false, "skeleton catalog must not claim registry verification");
|
||||
assert.deepEqual(catalog.services.map((service) => service.serviceId).sort(), [...SERVICE_IDS].sort());
|
||||
|
||||
const m2Fixture = await readJSON("fixtures/dev-deploy-smoke/dev-deploy-smoke.json");
|
||||
assert.equal(m2Fixture.environment, ENVIRONMENT_DEV, "M2 fixture environment");
|
||||
assert.equal(m2Fixture.endpoint, DEV_ENDPOINT, "M2 fixture endpoint");
|
||||
assert.equal(m2Fixture.defaultMode, "dry-run", "M2 fixture default mode");
|
||||
|
||||
const runtime = await readJSON("fixtures/mvp/runtime.json");
|
||||
assert.equal(runtime.endpoints.dev, DEV_ENDPOINT, "MVP runtime endpoint");
|
||||
|
||||
const m5Plan = await readJSON("fixtures/mvp/m5-e2e/dry-run-plan.json");
|
||||
assert.equal(m5Plan.mode, "dry-run", "M5 plan mode");
|
||||
assert.equal(m5Plan.environment, ENVIRONMENT_DEV, "M5 plan environment");
|
||||
assert.equal(m5Plan.endpoint, DEV_ENDPOINT, "M5 plan endpoint");
|
||||
assert.equal(m5Plan.safety.allowNetwork, false, "M5 dry-run network gate");
|
||||
assert.equal(m5Plan.safety.allowDeploy, false, "M5 dry-run deploy gate");
|
||||
assert.equal(m5Plan.safety.allowSecrets, false, "M5 dry-run secret gate");
|
||||
}
|
||||
|
||||
async function collectContracts() {
|
||||
return Promise.all(
|
||||
contracts.map(async (entry) => {
|
||||
const paths = await Promise.all(entry.paths.map(async (item) => ({ path: item, exists: await pathExists(item) })));
|
||||
const missing = paths.filter((item) => !item.exists).map((item) => item.path);
|
||||
return {
|
||||
...entry,
|
||||
status: missing.length === 0 ? entry.status : "blocked_missing_contract_file",
|
||||
paths,
|
||||
missing
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function collectDevGateReports() {
|
||||
const entries = await readdir(path.join(repoRoot, "reports/dev-gate"), { withFileTypes: true });
|
||||
const reports = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
const reportPath = path.join("reports/dev-gate", entry.name);
|
||||
const report = await readJSON(reportPath);
|
||||
reports.push({
|
||||
path: reportPath,
|
||||
issue: report.issue,
|
||||
taskId: report.taskId,
|
||||
acceptanceLevel: report.acceptanceLevel,
|
||||
status: "contract_available",
|
||||
blockers: Array.isArray(report.blockers) ? report.blockers.length : 0,
|
||||
notes: report.notes ?? ""
|
||||
});
|
||||
}
|
||||
return reports.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
function buildBlockers(collectedContracts) {
|
||||
const blockers = [
|
||||
blocker("observability_blocker", "issue-read", "GitHub issue state is not readable in this runner; visible #33-#39 results are limited to repository artifacts and prompt context."),
|
||||
blocker("network_blocker", "DEV-LIVE", "No repository-local artifact can be treated as live DEV evidence for ingress, edge, frp, D601 router, or downstream HWLAB services."),
|
||||
blocker("observability_blocker", "artifact-provenance", "The checked artifact catalog is a contract skeleton; it explicitly does not claim real CI publish, registry verification, or deployment."),
|
||||
blocker("runtime_blocker", "M3/M4 DEV loops", "M3 and M4 currently have local contract smoke evidence only; live DEV trace, audit, operation, evidence, and cleanup IDs are not attached.")
|
||||
];
|
||||
for (const item of collectedContracts) {
|
||||
if (item.missing.length > 0) {
|
||||
blockers.push(blocker("contract_blocker", item.milestone, `${item.milestone} is missing repository contract file(s): ${item.missing.join(", ")}.`));
|
||||
}
|
||||
}
|
||||
return blockers;
|
||||
}
|
||||
|
||||
function blocker(type, scope, summary) {
|
||||
return { type, scope, status: "open", source: "BLOCKED", summary };
|
||||
}
|
||||
|
||||
function groupBySource(collectedContracts, devLive, blockers) {
|
||||
const grouped = Object.fromEntries(levels.map((level) => [level, []]));
|
||||
for (const item of collectedContracts) {
|
||||
grouped[item.source].push({
|
||||
milestone: item.milestone,
|
||||
supports: item.supports,
|
||||
status: item.status,
|
||||
category: item.category,
|
||||
summary: item.summary
|
||||
});
|
||||
}
|
||||
grouped["DEV-LIVE"].push(...devLive);
|
||||
grouped.BLOCKED.push(...blockers);
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function validateOutput(output) {
|
||||
assert.equal(output.environment, ENVIRONMENT_DEV, "output environment");
|
||||
assert.equal(output.endpoint, DEV_ENDPOINT, "output endpoint");
|
||||
assert.equal(output.devOnly, true, "output devOnly");
|
||||
assert.equal(output.prodDisabled, true, "output prodDisabled");
|
||||
assert.deepEqual(Object.keys(output.levels), levels, "output levels");
|
||||
assert.ok(output.blockers.length > 0, "aggregator must not report green without DEV-LIVE evidence");
|
||||
for (const item of output.blockers) {
|
||||
assert.ok(blockerTypes.includes(item.type), `unknown blocker type ${item.type}`);
|
||||
assert.equal(item.status, "open", "aggregator blockers remain open until live evidence is attached");
|
||||
}
|
||||
}
|
||||
|
||||
async function buildReport() {
|
||||
await assertStaticContracts();
|
||||
const evidenceContracts = await collectContracts();
|
||||
const blockers = buildBlockers(evidenceContracts);
|
||||
const devLive = [{
|
||||
milestone: "M2-M5",
|
||||
issue: "pikasTech/HWLAB#39",
|
||||
status: "not_available",
|
||||
category: "DEV-LIVE",
|
||||
summary: "No live DEV evidence was read or generated by this report-only aggregator."
|
||||
}];
|
||||
const output = {
|
||||
reportVersion: "v1",
|
||||
taskId: "dev-evidence-blocker-aggregator",
|
||||
issue: "pikasTech/HWLAB#41",
|
||||
supports: allSupportedIssues,
|
||||
generatedFromCommit: gitCommit(),
|
||||
environment: ENVIRONMENT_DEV,
|
||||
endpoint: DEV_ENDPOINT,
|
||||
devOnly: true,
|
||||
prodDisabled: true,
|
||||
safety: {
|
||||
reportOnly: true,
|
||||
noLiveOperation: true,
|
||||
noDeploy: true,
|
||||
noProd: true,
|
||||
noSecretRead: true,
|
||||
noHeavyE2E: true,
|
||||
noBrowserTest: true
|
||||
},
|
||||
issueRead: {
|
||||
status: "blocked",
|
||||
reason: "GitHub issue data is not available from this runner; this report uses repository files and prompt context only."
|
||||
},
|
||||
overall: {
|
||||
status: "blocked",
|
||||
green: false,
|
||||
reason: "SOURCE, LOCAL, and DRY-RUN contracts are visible, but DEV-LIVE evidence and issue reads are blocked."
|
||||
},
|
||||
evidenceContracts,
|
||||
devGateReports: await collectDevGateReports(),
|
||||
visibleParallelResults,
|
||||
levels: groupBySource(evidenceContracts, devLive, blockers),
|
||||
blockers,
|
||||
nextRoundRecommendations,
|
||||
validationCommands: [
|
||||
"node --check scripts/dev-evidence-blocker-aggregator.mjs",
|
||||
"node scripts/dev-evidence-blocker-aggregator.mjs --check",
|
||||
"node scripts/dev-evidence-blocker-aggregator.mjs --pretty"
|
||||
]
|
||||
};
|
||||
validateOutput(output);
|
||||
return output;
|
||||
function hasFlag(name) {
|
||||
return process.argv.includes(name);
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await buildReport();
|
||||
if (process.argv.includes("--check")) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: true,
|
||||
taskId: output.taskId,
|
||||
status: output.overall.status,
|
||||
blockers: output.blockers.length,
|
||||
sourceLevels: levels
|
||||
}) + "\n");
|
||||
const report = await buildReport();
|
||||
const wroteReports = hasFlag("--write-report");
|
||||
|
||||
if (wroteReports) {
|
||||
const jsonPath = path.join(repoRoot, "reports/dev-gate/dev-m5-gate-aggregator-v2.json");
|
||||
const markdownPath = path.join(repoRoot, "reports/dev-gate/dev-m5-gate-aggregator-v2.md");
|
||||
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await writeFile(markdownPath, renderMarkdown(report));
|
||||
}
|
||||
|
||||
if (hasFlag("--check")) {
|
||||
process.stdout.write(`${JSON.stringify(formatCheckSummary(report))}\n`);
|
||||
} else if (hasFlag("--markdown")) {
|
||||
process.stdout.write(renderMarkdown(report));
|
||||
} else if (wroteReports) {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
...formatCheckSummary(report),
|
||||
wrote: [
|
||||
"reports/dev-gate/dev-m5-gate-aggregator-v2.json",
|
||||
"reports/dev-gate/dev-m5-gate-aggregator-v2.md"
|
||||
]
|
||||
})}\n`);
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(output, null, process.argv.includes("--pretty") ? 2 : 0) + "\n");
|
||||
process.stdout.write(`${JSON.stringify(report, null, hasFlag("--pretty") ? 2 : 0)}\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(JSON.stringify({ ok: false, error: message }) + "\n");
|
||||
process.stderr.write(`${JSON.stringify({ ok: false, error: message })}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
|
||||
|
||||
export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
const levels = Object.freeze(["SOURCE", "LOCAL", "DRY-RUN", "DEV-LIVE", "BLOCKED"]);
|
||||
const statusRank = Object.freeze({
|
||||
pass: 0,
|
||||
"contract-ready": 0,
|
||||
"manifest-ready": 0,
|
||||
ready: 0,
|
||||
published: 0,
|
||||
blocked: 1,
|
||||
failed: 1,
|
||||
not_run: 2,
|
||||
not_observed: 2,
|
||||
unavailable: 2,
|
||||
not_applicable: 3
|
||||
});
|
||||
const blockerTypes = new Set([
|
||||
"contract_blocker",
|
||||
"environment_blocker",
|
||||
"network_blocker",
|
||||
"runtime_blocker",
|
||||
"agent_blocker",
|
||||
"observability_blocker",
|
||||
"safety_blocker"
|
||||
]);
|
||||
|
||||
const reportPaths = Object.freeze({
|
||||
devPreflight: "reports/dev-gate/dev-preflight-report.json",
|
||||
devDeploy: "reports/dev-gate/dev-deploy-report.json",
|
||||
devArtifacts: "reports/dev-gate/dev-artifacts.json",
|
||||
devEdgeHealth: "reports/dev-gate/dev-edge-health.json",
|
||||
devM3Hardware: "reports/dev-gate/dev-m3-hardware-loop.json",
|
||||
devM4Agent: "reports/dev-gate/dev-m4-agent-loop.json",
|
||||
devM5Gate: "reports/dev-gate/dev-mvp-gate-report.json",
|
||||
d601Observability: "reports/d601-k3s-readonly-observability.json"
|
||||
});
|
||||
|
||||
const validationCommands = Object.freeze([
|
||||
"node --check scripts/dev-evidence-blocker-aggregator.mjs",
|
||||
"node --check scripts/src/dev-evidence-blocker-aggregator.mjs",
|
||||
"node scripts/dev-evidence-blocker-aggregator.mjs --check",
|
||||
"node scripts/dev-evidence-blocker-aggregator.mjs --markdown",
|
||||
"node --check scripts/validate-dev-gate-report.mjs",
|
||||
"node scripts/validate-dev-gate-report.mjs"
|
||||
]);
|
||||
|
||||
const sourceContracts = Object.freeze([
|
||||
{
|
||||
milestone: "M0",
|
||||
issue: "pikasTech/HWLAB#31",
|
||||
category: "contract",
|
||||
level: "SOURCE",
|
||||
status: "pass",
|
||||
sources: [
|
||||
"docs/m0-contract-audit.md",
|
||||
"protocol/README.md",
|
||||
"protocol/evidence-chain.md",
|
||||
"protocol/schemas/evidence-record.schema.json",
|
||||
"protocol/examples/m0-contract/service-ids.json"
|
||||
],
|
||||
commands: [
|
||||
"node scripts/validate-contract.mjs",
|
||||
"node scripts/validate-m0-contract.mjs",
|
||||
"node scripts/validate-evidence-chain.mjs"
|
||||
],
|
||||
summary: "Frozen service IDs, JSON-RPC, audit, topology, evidence, and DEV-only deploy contracts are source-ready."
|
||||
},
|
||||
{
|
||||
milestone: "M1",
|
||||
issue: "pikasTech/HWLAB#7",
|
||||
category: "local-smoke",
|
||||
level: "LOCAL",
|
||||
status: "pass",
|
||||
sources: [
|
||||
"docs/m1-local-smoke.md",
|
||||
"fixtures/mvp/runtime.json",
|
||||
"scripts/m1-contract-smoke.mjs"
|
||||
],
|
||||
commands: ["node scripts/m1-contract-smoke.mjs"],
|
||||
summary: "Local skeleton smoke covers cloud API, simulators, patch-panel routing, CLI dry-run boundary, and no DEV/PROD mutation."
|
||||
}
|
||||
]);
|
||||
|
||||
function issue(id) {
|
||||
return `pikasTech/HWLAB#${id}`;
|
||||
}
|
||||
|
||||
function short(value) {
|
||||
return typeof value === "string" && value.length > 12 ? value.slice(0, 12) : value ?? "unknown";
|
||||
}
|
||||
|
||||
function oneLine(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function primaryStatus(report) {
|
||||
return report.status ??
|
||||
report.gateStatus ??
|
||||
report.conclusion ??
|
||||
report.artifactPublish?.status ??
|
||||
(report.blockers?.length > 0 ? "blocked" : "not_run");
|
||||
}
|
||||
|
||||
function rank(status) {
|
||||
return statusRank[status] ?? 2;
|
||||
}
|
||||
|
||||
function worstStatus(values) {
|
||||
const statuses = values.filter(Boolean);
|
||||
if (statuses.length === 0) return "not_run";
|
||||
return statuses.sort((a, b) => rank(b) - rank(a))[0];
|
||||
}
|
||||
|
||||
function statusIsPass(status) {
|
||||
return rank(status) === 0;
|
||||
}
|
||||
|
||||
function statusHasEvidence(status) {
|
||||
return !["not_run", "not_observed", "unavailable", "not_applicable"].includes(status);
|
||||
}
|
||||
|
||||
async function readJSON(relativePath) {
|
||||
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function gitCommit() {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
}).trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBlocker(blocker, sourceReport, fallbackPriority = "P3") {
|
||||
assert.ok(blocker && typeof blocker === "object" && !Array.isArray(blocker), "blocker must be an object");
|
||||
assert.ok(blockerTypes.has(blocker.type), `unknown blocker type ${blocker.type}`);
|
||||
const nextTask = oneLine(blocker.nextTask ?? blocker.unblockHint ?? blocker.next ?? "");
|
||||
|
||||
return {
|
||||
id: `${sourceReport.taskId ?? sourceReport.reportKind ?? "report"}:${blocker.scope}`,
|
||||
priority: fallbackPriority,
|
||||
type: blocker.type,
|
||||
scope: blocker.scope,
|
||||
status: blocker.status ?? "open",
|
||||
source: sourceReport.path,
|
||||
sourceIssue: sourceReport.issue,
|
||||
summary: oneLine(blocker.summary),
|
||||
...(nextTask ? { nextTask } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeBlockers(blockers) {
|
||||
const byKey = new Map();
|
||||
for (const blocker of blockers) {
|
||||
const key = `${blocker.type}:${blocker.scope}:${blocker.summary}`;
|
||||
const existing = byKey.get(key);
|
||||
if (!existing) {
|
||||
byKey.set(key, blocker);
|
||||
} else if (!existing.nextTask && blocker.nextTask) {
|
||||
byKey.set(key, { ...existing, nextTask: blocker.nextTask });
|
||||
} else if (blocker.priority < existing.priority) {
|
||||
byKey.set(key, { ...blocker, nextTask: blocker.nextTask || existing.nextTask });
|
||||
}
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
function hasBlocker(blockers, predicate) {
|
||||
return blockers.some(predicate);
|
||||
}
|
||||
|
||||
function applyPriority(blocker) {
|
||||
if (blocker.scope === "base-image") {
|
||||
return {
|
||||
...blocker,
|
||||
priority: "P0",
|
||||
unblockOrder: 1,
|
||||
unblocks: [issue(35), issue(33)],
|
||||
rationale: "Artifact publishing cannot start until an approved Node 20 DEV builder base image is available."
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
blocker.scope === "hwlab-router" ||
|
||||
blocker.scope === "hwlab-tunnel-client" ||
|
||||
blocker.scope === "hwlab-edge-proxy"
|
||||
) {
|
||||
return {
|
||||
...blocker,
|
||||
priority: "P0",
|
||||
unblockOrder: 2,
|
||||
unblocks: [issue(35), issue(36), issue(33)],
|
||||
rationale: "The DEV route services need real HWLAB runtime entrypoints before image publish, frp, or ingress smoke can be meaningful."
|
||||
};
|
||||
}
|
||||
|
||||
if (blocker.scope.includes("artifact") || blocker.scope === "ghcr") {
|
||||
return {
|
||||
...blocker,
|
||||
priority: "P1",
|
||||
unblockOrder: 3,
|
||||
unblocks: [issue(35), issue(33), issue(39)],
|
||||
rationale: "The gate cannot promote deploy or runtime observations without immutable image provenance and digests."
|
||||
};
|
||||
}
|
||||
|
||||
if (blocker.scope === "kubectl" || blocker.scope === "d601-k3s" || blocker.scope.startsWith("d601-")) {
|
||||
return {
|
||||
...blocker,
|
||||
priority: "P1",
|
||||
unblockOrder: 4,
|
||||
unblocks: [issue(34), issue(33), issue(36), issue(38)],
|
||||
rationale: "D601 hwlab-dev cluster state must be observable read-only before any live apply or M3/M4/M5 acceptance."
|
||||
};
|
||||
}
|
||||
|
||||
if (blocker.scope.includes("cloud-api-db")) {
|
||||
return {
|
||||
...blocker,
|
||||
priority: "P1",
|
||||
unblockOrder: 5,
|
||||
unblocks: [issue(34), issue(33), issue(39)],
|
||||
rationale: "Cloud API DB env and health readiness block runtime health and MVP evidence even if ingress starts responding."
|
||||
};
|
||||
}
|
||||
|
||||
if (blocker.scope.includes("edge") || blocker.scope.includes("ingress") || blocker.summary.includes("6667")) {
|
||||
return {
|
||||
...blocker,
|
||||
priority: "P2",
|
||||
unblockOrder: 6,
|
||||
unblocks: [issue(36), issue(38), issue(37), issue(39)],
|
||||
rationale: "The :6667/frp path must be reachable before any DEV-LIVE M3, M4, or M5 evidence can be collected."
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...blocker,
|
||||
priority: blocker.priority ?? "P3",
|
||||
unblockOrder: 99,
|
||||
unblocks: [issue(39)],
|
||||
rationale: "Residual blocker that must be classified before claiming a green DEV gate."
|
||||
};
|
||||
}
|
||||
|
||||
function sourceEntry(entry) {
|
||||
return {
|
||||
milestone: entry.milestone,
|
||||
issue: entry.issue,
|
||||
level: entry.level,
|
||||
status: entry.status,
|
||||
category: entry.category,
|
||||
sources: entry.sources,
|
||||
commands: entry.commands,
|
||||
summary: entry.summary
|
||||
};
|
||||
}
|
||||
|
||||
function reportEvidence({ milestone, level, report, status, category, summary, evidence = [], commands = [] }) {
|
||||
return {
|
||||
milestone,
|
||||
issue: report.issue,
|
||||
taskId: report.taskId ?? report.reportKind,
|
||||
reportPath: report.path,
|
||||
commitId: short(report.commitId ?? report.target?.commitId),
|
||||
level,
|
||||
status,
|
||||
category,
|
||||
commands,
|
||||
evidence,
|
||||
summary
|
||||
};
|
||||
}
|
||||
|
||||
function collectM2Evidence(reports) {
|
||||
const preflight = reports.devPreflight;
|
||||
const deploy = reports.devDeploy;
|
||||
const artifacts = reports.devArtifacts;
|
||||
const edge = reports.devEdgeHealth;
|
||||
const d601 = reports.d601Observability;
|
||||
const artifactIdentity = preflight.artifactIdentity;
|
||||
|
||||
return [
|
||||
reportEvidence({
|
||||
milestone: "M2",
|
||||
level: "SOURCE",
|
||||
report: preflight,
|
||||
status: "pass",
|
||||
category: "deploy-manifest",
|
||||
evidence: [
|
||||
"source-contract-static=pass",
|
||||
`artifact catalog state=${artifactIdentity.artifactCatalog.artifactState}`,
|
||||
`catalog commit=${artifactIdentity.artifactCatalog.commitId}`
|
||||
],
|
||||
commands: preflight.validationCommands,
|
||||
summary: "Deploy manifests, FRP/master-edge contracts, and safety boundary are source-readable and scoped to hwlab-dev."
|
||||
}),
|
||||
reportEvidence({
|
||||
milestone: "M2",
|
||||
level: "DRY-RUN",
|
||||
report: deploy,
|
||||
status: deploy.dryRun?.status ?? primaryStatus(deploy),
|
||||
category: "deploy-apply",
|
||||
evidence: deploy.dryRun?.evidence ?? [],
|
||||
commands: deploy.dryRun?.commands ?? [],
|
||||
summary: deploy.dryRun?.summary ?? "DEV apply dry-run report is available."
|
||||
}),
|
||||
reportEvidence({
|
||||
milestone: "M2",
|
||||
level: "DRY-RUN",
|
||||
report: artifacts,
|
||||
status: artifacts.artifactPublish?.status ?? primaryStatus(artifacts),
|
||||
category: "artifact-publish",
|
||||
evidence: [
|
||||
`services=${artifacts.artifactPublish?.services?.length ?? 0}`,
|
||||
`baseImagePreflight=${artifacts.artifactPublish?.baseImagePreflight?.status ?? "unknown"}`,
|
||||
`published=${artifacts.artifactPublish?.services?.filter((service) => service.status === "published").length ?? 0}/${SERVICE_IDS.length}`
|
||||
],
|
||||
commands: artifacts.validationCommands,
|
||||
summary: "Artifact publish preflight exists but is blocked before any real image publish."
|
||||
}),
|
||||
reportEvidence({
|
||||
milestone: "M2",
|
||||
level: "SOURCE",
|
||||
report: edge,
|
||||
status: edge.edgeHealth?.status ?? primaryStatus(edge),
|
||||
category: "edge-frp-contract",
|
||||
evidence: [
|
||||
`mode=${edge.edgeHealth?.mode ?? "unknown"}`,
|
||||
`classification=${edge.edgeHealth?.classification ?? "unknown"}`,
|
||||
`cloudApiDb=${edge.edgeHealth?.contracts?.deploy?.cloudApiDb?.status ?? "unknown"}`
|
||||
],
|
||||
commands: edge.validationCommands,
|
||||
summary: "Committed edge report is contract-only/not-run; it must not be counted as DEV-LIVE."
|
||||
}),
|
||||
reportEvidence({
|
||||
milestone: "M2",
|
||||
level: "BLOCKED",
|
||||
report: d601,
|
||||
status: d601.conclusion,
|
||||
category: "d601-observability",
|
||||
evidence: [
|
||||
`kubectl=${d601.environment?.binaries?.kubectl?.available === true ? "available" : "missing"}`,
|
||||
`k3s=${d601.environment?.binaries?.k3s?.available === true ? "available" : "missing"}`,
|
||||
`clusterReadable=${d601.cluster?.readable === true ? "yes" : "no"}`
|
||||
],
|
||||
commands: d601.validationCommands,
|
||||
summary: "D601 k3s observability is blocked; cluster state has not been read."
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
function collectM3Evidence(reports) {
|
||||
const m3 = reports.devM3Hardware;
|
||||
const m5Milestone = reports.devM5Gate.milestones?.find((item) => item.id === "M3");
|
||||
return [
|
||||
reportEvidence({
|
||||
milestone: "M3",
|
||||
level: "SOURCE",
|
||||
report: m3,
|
||||
status: "manifest-ready",
|
||||
category: "hardware-loop-cardinality",
|
||||
evidence: m3.readOnlySupplementalEvidence?.map((item) => `${item.id}=${item.status}`) ?? [],
|
||||
commands: ["node scripts/validate-dev-m3-cardinality.mjs"],
|
||||
summary: "Static DEV manifest cardinality declares two box simulators, two gateway simulators, and one patch panel."
|
||||
}),
|
||||
reportEvidence({
|
||||
milestone: "M3",
|
||||
level: "LOCAL",
|
||||
report: reports.devM5Gate,
|
||||
status: m5Milestone?.status ?? "not_run",
|
||||
category: "hardware-loop-local",
|
||||
evidence: m5Milestone?.evidence ?? [],
|
||||
commands: m5Milestone?.commands ?? ["node scripts/m3-hardware-loop-smoke.mjs"],
|
||||
summary: m5Milestone?.summary ?? "No local M3 smoke evidence was attached."
|
||||
}),
|
||||
reportEvidence({
|
||||
milestone: "M3",
|
||||
level: statusIsPass(m3.liveOperation?.status) ? "DEV-LIVE" : "BLOCKED",
|
||||
report: m3,
|
||||
status: m3.liveOperation?.status ?? "not_run",
|
||||
category: "hardware-loop-live",
|
||||
evidence: [
|
||||
`operationId=${m3.liveOperation?.operationId ?? "not_observed"}`,
|
||||
`traceId=${m3.liveOperation?.traceId ?? "not_observed"}`,
|
||||
`auditId=${m3.liveOperation?.auditId ?? "not_observed"}`,
|
||||
`evidenceId=${m3.liveOperation?.evidenceId ?? "not_observed"}`
|
||||
],
|
||||
commands: ["node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"],
|
||||
summary: m3.liveOperation?.summary ?? "No live M3 hardware operation was observed."
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
function collectM4Evidence(reports) {
|
||||
const m4 = reports.devM4Agent;
|
||||
return [
|
||||
reportEvidence({
|
||||
milestone: "M4",
|
||||
level: "LOCAL",
|
||||
report: m4,
|
||||
status: m4.localSmoke?.status ?? "not_run",
|
||||
category: "agent-loop-local",
|
||||
evidence: m4.localSmoke?.evidence ?? [],
|
||||
commands: m4.localSmoke?.commands ?? [],
|
||||
summary: m4.localSmoke?.summary ?? "No local M4 smoke evidence was attached."
|
||||
}),
|
||||
reportEvidence({
|
||||
milestone: "M4",
|
||||
level: "DRY-RUN",
|
||||
report: m4,
|
||||
status: m4.dryRun?.status ?? "not_run",
|
||||
category: "agent-loop-dry-run",
|
||||
evidence: m4.dryRun?.evidence ?? [],
|
||||
commands: m4.dryRun?.commands ?? [],
|
||||
summary: m4.dryRun?.summary ?? "No M4 dry-run evidence was attached."
|
||||
}),
|
||||
reportEvidence({
|
||||
milestone: "M4",
|
||||
level: statusIsPass(m4.livePreflight?.status) ? "DEV-LIVE" : "BLOCKED",
|
||||
report: m4,
|
||||
status: m4.livePreflight?.status ?? m4.devPreconditions?.status ?? "not_run",
|
||||
category: "agent-loop-live-preflight",
|
||||
evidence: m4.livePreflight?.evidence ?? [],
|
||||
commands: m4.livePreflight?.commands ?? [],
|
||||
summary: m4.livePreflight?.summary ?? m4.devPreconditions?.summary ?? "No live M4 observation was recorded."
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
function collectM5Evidence(reports) {
|
||||
const m5 = reports.devM5Gate;
|
||||
return [
|
||||
reportEvidence({
|
||||
milestone: "M5",
|
||||
level: "DRY-RUN",
|
||||
report: m5,
|
||||
status: m5.dryRun?.status ?? "not_run",
|
||||
category: "mvp-e2e-dry-run",
|
||||
evidence: m5.dryRun?.evidence ?? [],
|
||||
commands: m5.dryRun?.commands ?? [],
|
||||
summary: m5.dryRun?.summary ?? "No M5 dry-run evidence was attached."
|
||||
}),
|
||||
reportEvidence({
|
||||
milestone: "M5",
|
||||
level: statusIsPass(m5.devPreconditions?.status) ? "DEV-LIVE" : "BLOCKED",
|
||||
report: m5,
|
||||
status: m5.devPreconditions?.status ?? m5.gateStatus ?? "blocked",
|
||||
category: "mvp-e2e-live",
|
||||
evidence: m5.devPreconditions?.evidence ?? [],
|
||||
commands: m5.devPreconditions?.commands ?? [],
|
||||
summary: m5.devPreconditions?.summary ?? "No live M5 e2e evidence was attached."
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
function collectBlockers(reports) {
|
||||
const blockers = [];
|
||||
for (const report of Object.values(reports)) {
|
||||
for (const blocker of report.blockers ?? []) {
|
||||
blockers.push(normalizeBlocker(blocker, report));
|
||||
}
|
||||
for (const blocker of report.artifactPublish?.blockers ?? []) {
|
||||
blockers.push(normalizeBlocker(blocker, report));
|
||||
}
|
||||
for (const blocker of report.devDeployApply?.remainingBlockers ?? []) {
|
||||
blockers.push(normalizeBlocker(blocker, report));
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = dedupeBlockers(blockers).map(applyPriority);
|
||||
return normalized.sort((a, b) => a.unblockOrder - b.unblockOrder || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
function deriveMilestones(evidence, blockers) {
|
||||
const evidenceByMilestone = new Map();
|
||||
for (const item of evidence) {
|
||||
if (!evidenceByMilestone.has(item.milestone)) {
|
||||
evidenceByMilestone.set(item.milestone, []);
|
||||
}
|
||||
evidenceByMilestone.get(item.milestone).push(item);
|
||||
}
|
||||
|
||||
return ["M0", "M1", "M2", "M3", "M4", "M5"].map((milestone) => {
|
||||
const items = evidenceByMilestone.get(milestone) ?? [];
|
||||
const liveItems = items.filter((item) => item.level === "DEV-LIVE");
|
||||
const highestVisibleLevel = levels.findLast((level) => items.some((item) => item.level === level && level !== "BLOCKED" && statusHasEvidence(item.status))) ?? "BLOCKED";
|
||||
const hasPassingLive = liveItems.some((item) => statusIsPass(item.status));
|
||||
const milestoneBlockers = blockers.filter((blocker) => {
|
||||
if (milestone === "M2") return blocker.unblocks.includes(issue(33)) || blocker.unblocks.includes(issue(35)) || blocker.unblocks.includes(issue(36));
|
||||
if (milestone === "M3") return blocker.unblocks.includes(issue(38));
|
||||
if (milestone === "M4") return blocker.unblocks.includes(issue(37));
|
||||
if (milestone === "M5") return blocker.unblocks.includes(issue(39));
|
||||
return false;
|
||||
});
|
||||
const status = milestoneBlockers.length > 0 || (liveItems.length > 0 && !hasPassingLive)
|
||||
? "blocked"
|
||||
: worstStatus(items.map((item) => item.status));
|
||||
|
||||
return {
|
||||
id: milestone,
|
||||
status,
|
||||
highestVisibleLevel,
|
||||
liveEvidence: hasPassingLive ? "pass" : "missing_or_blocked",
|
||||
evidenceCount: items.length,
|
||||
blockerCount: milestoneBlockers.length,
|
||||
summary: summarizeMilestone(milestone, highestVisibleLevel, status)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeMilestone(milestone, highestVisibleLevel, status) {
|
||||
const labels = {
|
||||
M0: "contract source is available",
|
||||
M1: "local smoke is available",
|
||||
M2: "deploy/runtime readiness is blocked before live DEV",
|
||||
M3: "hardware loop has source/local shape but no live operation",
|
||||
M4: "agent loop has local smoke but live preflight is blocked",
|
||||
M5: "dry-run is green but live MVP gate is blocked"
|
||||
};
|
||||
return `${labels[milestone]}; highest visible level is ${highestVisibleLevel}; status is ${status}.`;
|
||||
}
|
||||
|
||||
function groupByLevel(evidence, blockers) {
|
||||
const grouped = Object.fromEntries(levels.map((level) => [level, []]));
|
||||
for (const item of evidence) {
|
||||
grouped[item.level].push({
|
||||
milestone: item.milestone,
|
||||
issue: item.issue,
|
||||
taskId: item.taskId,
|
||||
status: item.status,
|
||||
category: item.category,
|
||||
reportPath: item.reportPath,
|
||||
summary: item.summary
|
||||
});
|
||||
}
|
||||
grouped.BLOCKED.push(
|
||||
...blockers.map((blocker) => ({
|
||||
priority: blocker.priority,
|
||||
order: blocker.unblockOrder,
|
||||
type: blocker.type,
|
||||
scope: blocker.scope,
|
||||
sourceIssue: blocker.sourceIssue,
|
||||
source: blocker.source,
|
||||
summary: blocker.summary,
|
||||
nextTask: blocker.nextTask
|
||||
}))
|
||||
);
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function buildDoD(reports, milestones, blockers) {
|
||||
const m0 = milestones.find((item) => item.id === "M0");
|
||||
const m1 = milestones.find((item) => item.id === "M1");
|
||||
const m5 = milestones.find((item) => item.id === "M5");
|
||||
const preflight = reports.devPreflight;
|
||||
const artifactIdentity = preflight.artifactIdentity;
|
||||
const d601 = reports.d601Observability;
|
||||
|
||||
return {
|
||||
status: blockers.length === 0 && milestones.every((item) => item.status === "pass") ? "green" : "blocked",
|
||||
green: blockers.length === 0 && milestones.every((item) => item.status === "pass"),
|
||||
checks: [
|
||||
{
|
||||
id: "m0-source-contract",
|
||||
status: m0?.status === "pass" ? "pass" : "blocked",
|
||||
evidenceLevel: "SOURCE",
|
||||
summary: "M0 contract checks are source-level evidence only."
|
||||
},
|
||||
{
|
||||
id: "m1-local-smoke",
|
||||
status: m1?.status === "pass" ? "pass" : "blocked",
|
||||
evidenceLevel: "LOCAL",
|
||||
summary: "M1 local smoke is not a live DEV substitute."
|
||||
},
|
||||
{
|
||||
id: "artifact-publish-digests",
|
||||
status: artifactIdentity.publishVerified ? "pass" : "blocked",
|
||||
evidenceLevel: artifactIdentity.publishVerified ? "DEV-LIVE" : "BLOCKED",
|
||||
summary: `artifactState=${artifactIdentity.artifactCatalog.artifactState}, ciPublished=${artifactIdentity.artifactCatalog.ciPublished}, registryVerified=${artifactIdentity.artifactCatalog.registryVerified}, sha256=${artifactIdentity.artifactCatalog.digestCounts.sha256}, not_published=${artifactIdentity.artifactCatalog.digestCounts.notPublished}`
|
||||
},
|
||||
{
|
||||
id: "d601-k3s-observability",
|
||||
status: d601.cluster?.readable ? "pass" : "blocked",
|
||||
evidenceLevel: d601.cluster?.readable ? "DEV-LIVE" : "BLOCKED",
|
||||
summary: d601.cluster?.readable
|
||||
? "D601 hwlab-dev cluster was readable."
|
||||
: "D601 runner lacks kubectl/k3s/kubeconfig observability for hwlab-dev."
|
||||
},
|
||||
{
|
||||
id: "dev-edge-frp-6667",
|
||||
status: hasBlocker(blockers, (blocker) => blocker.scope.includes("edge") || blocker.scope.includes("ingress") || blocker.summary.includes("6667")) ? "blocked" : "pass",
|
||||
evidenceLevel: "BLOCKED",
|
||||
summary: "No committed report proves live HTTP 200/JSON on http://74.48.78.17:6667."
|
||||
},
|
||||
{
|
||||
id: "cloud-api-db-ready",
|
||||
status: hasBlocker(blockers, (blocker) => blocker.scope.includes("cloud-api-db")) ? "blocked" : "pass",
|
||||
evidenceLevel: "BLOCKED",
|
||||
summary: "Manifest-level DB env exists, but live DB health readiness is still blocked/missing."
|
||||
},
|
||||
{
|
||||
id: "m5-mvp-dev-live",
|
||||
status: m5?.status === "pass" && m5.liveEvidence === "pass" ? "pass" : "blocked",
|
||||
evidenceLevel: "BLOCKED",
|
||||
summary: "M5 dry-run passed; bounded DEV-LIVE MVP e2e has not passed."
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function buildNextSteps(blockers) {
|
||||
const byOrder = new Map();
|
||||
for (const blocker of blockers) {
|
||||
if (!byOrder.has(blocker.unblockOrder)) {
|
||||
byOrder.set(blocker.unblockOrder, []);
|
||||
}
|
||||
byOrder.get(blocker.unblockOrder).push(blocker);
|
||||
}
|
||||
|
||||
return [...byOrder.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([blockerOrder, items], index) => {
|
||||
const first = items[0];
|
||||
return {
|
||||
order: index + 1,
|
||||
blockerOrder,
|
||||
priority: first.priority,
|
||||
scopes: [...new Set(items.map((item) => item.scope))],
|
||||
sourceIssues: [...new Set(items.flatMap((item) => [item.sourceIssue, ...item.unblocks].filter(Boolean)))].sort(),
|
||||
rationale: first.rationale,
|
||||
action: actionForBlockerOrder(blockerOrder, items),
|
||||
evidenceRequired: evidenceRequiredFor(first.scope)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function actionForBlockerOrder(blockerOrder, items) {
|
||||
if (blockerOrder === 2) {
|
||||
const scopes = items.map((item) => item.scope).sort().join(", ");
|
||||
return `Add real runtime entrypoints or dedicated Dockerfiles for ${scopes}, then rerun the artifact publish preflight.`;
|
||||
}
|
||||
return items.find((item) => item.nextTask)?.nextTask || fallbackAction(items[0].scope);
|
||||
}
|
||||
|
||||
function fallbackAction(scope) {
|
||||
if (scope === "base-image") return "Preload or tag node:20-bookworm-slim and rerun the base-image and artifact publish preflights.";
|
||||
if (scope.includes("artifact")) return "Publish DEV artifacts for every frozen HWLAB service and record immutable registry digests.";
|
||||
if (scope.includes("edge") || scope.includes("ingress")) return "Repair frp/master-edge/D601 router path and rerun read-only DEV edge health.";
|
||||
if (scope.includes("cloud-api-db")) return "Configure DEV cloud-api DB env readiness and rerun health/preflight without exposing secrets.";
|
||||
if (scope.includes("kubectl") || scope.includes("k3s")) return "Provide read-only kubectl/kubeconfig observability for hwlab-dev.";
|
||||
return "Resolve the blocker and attach source/local/dry-run/DEV-live evidence at the correct level.";
|
||||
}
|
||||
|
||||
function evidenceRequiredFor(scope) {
|
||||
if (scope === "base-image") return "Base-image preflight status=ready with approved Node 20 builder base and no UniDesk/runtime substitute.";
|
||||
if (scope === "hwlab-router" || scope === "hwlab-tunnel-client" || scope === "hwlab-edge-proxy") return "Real repo entrypoints or dedicated Dockerfiles for route services, followed by artifact preflight.";
|
||||
if (scope.includes("artifact") || scope === "ghcr") return "Artifact publish report with ciPublished=true, registryVerified=true, and sha256 digest for each frozen service ID.";
|
||||
if (scope.includes("kubectl") || scope.includes("k3s")) return "Read-only kubectl/k3s report proving pods/services/configmaps are observable in hwlab-dev without reading Secrets.";
|
||||
if (scope.includes("cloud-api-db")) return "Cloud API health/live output showing DB env ready and redacted secret references, without secret material.";
|
||||
if (scope.includes("edge") || scope.includes("ingress")) return "Read-only DEV route observation for :6667/frp/edge/router with HWLAB service identity and artifact identity.";
|
||||
return "A committed report with the exact evidence level and command used.";
|
||||
}
|
||||
|
||||
function validateReport(report) {
|
||||
assert.equal(report.reportVersion, "v2", "reportVersion");
|
||||
assert.equal(report.issue, issue(58), "issue");
|
||||
assert.equal(report.environment, ENVIRONMENT_DEV, "environment");
|
||||
assert.equal(report.endpoint, DEV_ENDPOINT, "endpoint");
|
||||
assert.equal(report.devOnly, true, "devOnly");
|
||||
assert.equal(report.prodDisabled, true, "prodDisabled");
|
||||
assert.deepEqual(Object.keys(report.levels), [...levels], "level keys");
|
||||
assert.ok(report.blockers.length >= 1, "must remain blocked without DEV-LIVE evidence");
|
||||
assert.equal(report.overall.status, "blocked", "overall status");
|
||||
assert.ok(report.nextSteps.length >= 1, "next steps required");
|
||||
for (const blocker of report.blockers) {
|
||||
assert.ok(blockerTypes.has(blocker.type), `unknown blocker type ${blocker.type}`);
|
||||
assert.match(blocker.priority, /^P[0-3]$/u, `invalid priority ${blocker.priority}`);
|
||||
assert.equal(blocker.status, "open", "blocker status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildReport() {
|
||||
const entries = await Promise.all(
|
||||
Object.entries(reportPaths).map(async ([key, relativePath]) => {
|
||||
const report = await readJSON(relativePath);
|
||||
return [key, { ...report, path: relativePath }];
|
||||
})
|
||||
);
|
||||
const reports = Object.fromEntries(entries);
|
||||
const evidence = [
|
||||
...sourceContracts.map(sourceEntry),
|
||||
...collectM2Evidence(reports),
|
||||
...collectM3Evidence(reports),
|
||||
...collectM4Evidence(reports),
|
||||
...collectM5Evidence(reports)
|
||||
];
|
||||
const blockers = collectBlockers(reports);
|
||||
const milestones = deriveMilestones(evidence, blockers);
|
||||
const dod = buildDoD(reports, milestones, blockers);
|
||||
const nextSteps = buildNextSteps(blockers);
|
||||
const report = {
|
||||
"$schema": "https://hwlab.pikastech.local/schemas/dev-m5-gate-aggregator-v2.schema.json",
|
||||
"$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-m5-gate-aggregator-v2.json",
|
||||
reportVersion: "v2",
|
||||
reportKind: "dev-m5-gate-aggregator",
|
||||
issue: issue(58),
|
||||
supports: [issue(7), issue(9), issue(31), issue(33), issue(34), issue(35), issue(36), issue(37), issue(38), issue(39), issue(46)],
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedFromCommit: gitCommit(),
|
||||
environment: ENVIRONMENT_DEV,
|
||||
endpoint: DEV_ENDPOINT,
|
||||
devOnly: true,
|
||||
prodDisabled: true,
|
||||
safety: {
|
||||
reportOnly: true,
|
||||
noDeploy: true,
|
||||
noProd: true,
|
||||
noSecretRead: true,
|
||||
noRuntimeRestart: true,
|
||||
noLiveProbe: true,
|
||||
noHeavyE2E: true,
|
||||
noUniDeskRuntimeSubstitute: true
|
||||
},
|
||||
sourceReports: Object.fromEntries(
|
||||
Object.entries(reports).map(([key, report]) => [
|
||||
key,
|
||||
{
|
||||
path: report.path,
|
||||
issue: report.issue,
|
||||
taskId: report.taskId ?? report.reportKind,
|
||||
status: primaryStatus(report),
|
||||
commitId: short(report.commitId ?? report.target?.commitId)
|
||||
}
|
||||
])
|
||||
),
|
||||
overall: {
|
||||
status: dod.status,
|
||||
green: dod.green,
|
||||
reason: dod.green
|
||||
? "All #9 DEV DoD checks are green."
|
||||
: "SOURCE, LOCAL, and DRY-RUN evidence exists, but DEV-LIVE acceptance is blocked by artifact, observability, DB, edge/frp, and loop evidence gaps."
|
||||
},
|
||||
dod,
|
||||
milestones,
|
||||
evidence,
|
||||
levels: groupByLevel(evidence, blockers),
|
||||
blockers,
|
||||
nextSteps,
|
||||
validationCommands
|
||||
};
|
||||
validateReport(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
export function formatCheckSummary(report) {
|
||||
return {
|
||||
ok: true,
|
||||
taskId: report.reportKind,
|
||||
issue: report.issue,
|
||||
status: report.overall.status,
|
||||
green: report.overall.green,
|
||||
blockers: report.blockers.length,
|
||||
nextStep: report.nextSteps[0]?.action ?? null,
|
||||
levels
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMarkdown(report) {
|
||||
const milestoneLines = report.milestones
|
||||
.map((milestone) => `| ${milestone.id} | ${milestone.status} | ${milestone.highestVisibleLevel} | ${milestone.liveEvidence} | ${milestone.summary} |`)
|
||||
.join("\n");
|
||||
const dodLines = report.dod.checks
|
||||
.map((check) => `| ${check.id} | ${check.status} | ${check.evidenceLevel} | ${check.summary} |`)
|
||||
.join("\n");
|
||||
const blockerLines = report.blockers
|
||||
.map((blocker) => `| ${blocker.priority} | ${blocker.unblockOrder} | ${blocker.type} | ${blocker.scope} | ${blocker.summary} |`)
|
||||
.join("\n");
|
||||
const nextLines = report.nextSteps
|
||||
.map((step) => `${step.order}. ${step.action}\n Evidence required: ${step.evidenceRequired}`)
|
||||
.join("\n");
|
||||
|
||||
return `# HWLAB M5 DEV Gate Aggregator v2
|
||||
|
||||
Status: ${report.overall.status}
|
||||
Generated from: \`${report.generatedFromCommit}\`
|
||||
Scope: DEV only, report-only
|
||||
|
||||
## Summary
|
||||
|
||||
${report.overall.reason}
|
||||
|
||||
## Milestones
|
||||
|
||||
| Milestone | Status | Highest visible level | Live evidence | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
${milestoneLines}
|
||||
|
||||
## #9 DoD Checks
|
||||
|
||||
| Check | Status | Evidence level | Summary |
|
||||
| --- | --- | --- | --- |
|
||||
${dodLines}
|
||||
|
||||
## Blockers
|
||||
|
||||
| Priority | Order | Type | Scope | Summary |
|
||||
| --- | ---: | --- | --- | --- |
|
||||
${blockerLines}
|
||||
|
||||
## Next Unblock Order
|
||||
|
||||
${nextLines}
|
||||
|
||||
## Validation
|
||||
|
||||
${report.validationCommands.map((command) => `- \`${command}\``).join("\n")}
|
||||
|
||||
## Boundary
|
||||
|
||||
This report reads committed reports and fixtures only. It does not deploy, call DEV, call PROD, read secrets, restart runtime, run heavy e2e, or substitute UniDesk runtime for HWLAB runtime.
|
||||
`;
|
||||
}
|
||||
@@ -18,7 +18,8 @@ const issueFamily = Object.freeze({
|
||||
DEV_M3_HARDWARE_LOOP: "pikasTech/HWLAB#38",
|
||||
DEV_M5_GATE_REPORT: "pikasTech/HWLAB#39",
|
||||
DEV_BASE_IMAGE_PREFLIGHT: "pikasTech/HWLAB#40",
|
||||
DEV_EVIDENCE_BLOCKER_AGGREGATOR: "pikasTech/HWLAB#41"
|
||||
DEV_EVIDENCE_BLOCKER_AGGREGATOR: "pikasTech/HWLAB#41",
|
||||
DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58"
|
||||
});
|
||||
const allowedIssues = new Set([
|
||||
contractIssue,
|
||||
@@ -26,10 +27,19 @@ const allowedIssues = new Set([
|
||||
]);
|
||||
const requiredPreflightIssue = issueFamily.DEV_GATE_PREFLIGHT;
|
||||
const requiredMilestoneIds = ["M0", "M1", "M2", "M3", "M4", "M5"];
|
||||
const evidenceLevels = new Set(["SOURCE", "LOCAL", "DRY-RUN", "DEV-LIVE", "BLOCKED"]);
|
||||
const requiredValidationCommands = [
|
||||
"node --check scripts/validate-dev-gate-report.mjs",
|
||||
"node scripts/validate-dev-gate-report.mjs"
|
||||
];
|
||||
const requiredAggregatorV2Commands = [
|
||||
"node --check scripts/dev-evidence-blocker-aggregator.mjs",
|
||||
"node --check scripts/src/dev-evidence-blocker-aggregator.mjs",
|
||||
"node scripts/dev-evidence-blocker-aggregator.mjs --check",
|
||||
"node scripts/dev-evidence-blocker-aggregator.mjs --markdown",
|
||||
"node --check scripts/validate-dev-gate-report.mjs",
|
||||
"node scripts/validate-dev-gate-report.mjs"
|
||||
];
|
||||
const requiredDevDeployApplySupports = [
|
||||
"pikasTech/HWLAB#50",
|
||||
"pikasTech/HWLAB#7",
|
||||
@@ -277,6 +287,10 @@ async function validateReport(relativePath) {
|
||||
await validatePreflightReport(relativePath, report);
|
||||
return;
|
||||
}
|
||||
if (report.reportKind === "dev-m5-gate-aggregator") {
|
||||
await validateAggregatorV2Report(relativePath, report);
|
||||
return;
|
||||
}
|
||||
|
||||
const label = relativePath;
|
||||
assertObject(report, label);
|
||||
@@ -1091,6 +1105,247 @@ async function validatePreflightReport(relativePath, report) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertEvidenceLevel(value, label) {
|
||||
assertString(value, label);
|
||||
assert.ok(evidenceLevels.has(value), `${label} must be one of ${Array.from(evidenceLevels).join(", ")}`);
|
||||
}
|
||||
|
||||
function assertBoolean(value, label) {
|
||||
assert.equal(typeof value, "boolean", `${label} must be a boolean`);
|
||||
}
|
||||
|
||||
async function validateAggregatorV2Report(relativePath, report) {
|
||||
const label = relativePath;
|
||||
|
||||
assertObject(report, label);
|
||||
for (const field of [
|
||||
"$schema",
|
||||
"$id",
|
||||
"reportVersion",
|
||||
"reportKind",
|
||||
"issue",
|
||||
"supports",
|
||||
"generatedAt",
|
||||
"generatedFromCommit",
|
||||
"environment",
|
||||
"endpoint",
|
||||
"devOnly",
|
||||
"prodDisabled",
|
||||
"safety",
|
||||
"sourceReports",
|
||||
"overall",
|
||||
"dod",
|
||||
"milestones",
|
||||
"evidence",
|
||||
"levels",
|
||||
"blockers",
|
||||
"nextSteps",
|
||||
"validationCommands"
|
||||
]) {
|
||||
assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`);
|
||||
}
|
||||
|
||||
assertString(report.$schema, `${label}.$schema`);
|
||||
assertString(report.$id, `${label}.$id`);
|
||||
assert.equal(report.reportVersion, "v2", `${label}.reportVersion`);
|
||||
assert.equal(report.reportKind, "dev-m5-gate-aggregator", `${label}.reportKind`);
|
||||
assert.equal(report.issue, issueFamily.DEV_M5_GATE_AGGREGATOR_V2, `${label}.issue`);
|
||||
assertTimestamp(report.generatedAt, `${label}.generatedAt`);
|
||||
assert.match(report.generatedFromCommit, /^([a-f0-9]{7,40}|unknown)$/, `${label}.generatedFromCommit`);
|
||||
assert.equal(report.environment, "dev", `${label}.environment`);
|
||||
assert.equal(report.endpoint, "http://74.48.78.17:6667", `${label}.endpoint`);
|
||||
assert.equal(report.devOnly, true, `${label}.devOnly`);
|
||||
assert.equal(report.prodDisabled, true, `${label}.prodDisabled`);
|
||||
|
||||
assertStringArray(report.supports, `${label}.supports`, { minLength: 10 });
|
||||
for (const requiredIssue of [
|
||||
"pikasTech/HWLAB#7",
|
||||
"pikasTech/HWLAB#9",
|
||||
"pikasTech/HWLAB#33",
|
||||
"pikasTech/HWLAB#34",
|
||||
"pikasTech/HWLAB#35",
|
||||
"pikasTech/HWLAB#36",
|
||||
"pikasTech/HWLAB#37",
|
||||
"pikasTech/HWLAB#38",
|
||||
"pikasTech/HWLAB#39",
|
||||
"pikasTech/HWLAB#46"
|
||||
]) {
|
||||
assert.ok(report.supports.includes(requiredIssue), `${label}.supports missing ${requiredIssue}`);
|
||||
}
|
||||
|
||||
assertObject(report.safety, `${label}.safety`);
|
||||
for (const field of [
|
||||
"reportOnly",
|
||||
"noDeploy",
|
||||
"noProd",
|
||||
"noSecretRead",
|
||||
"noRuntimeRestart",
|
||||
"noLiveProbe",
|
||||
"noHeavyE2E",
|
||||
"noUniDeskRuntimeSubstitute"
|
||||
]) {
|
||||
assert.equal(report.safety[field], true, `${label}.safety.${field}`);
|
||||
}
|
||||
|
||||
assertObject(report.sourceReports, `${label}.sourceReports`);
|
||||
for (const requiredReport of [
|
||||
"devPreflight",
|
||||
"devDeploy",
|
||||
"devArtifacts",
|
||||
"devEdgeHealth",
|
||||
"devM3Hardware",
|
||||
"devM4Agent",
|
||||
"devM5Gate",
|
||||
"d601Observability"
|
||||
]) {
|
||||
assert.ok(Object.hasOwn(report.sourceReports, requiredReport), `${label}.sourceReports missing ${requiredReport}`);
|
||||
const sourceReport = report.sourceReports[requiredReport];
|
||||
assertObject(sourceReport, `${label}.sourceReports.${requiredReport}`);
|
||||
for (const field of ["path", "issue", "taskId", "status", "commitId"]) {
|
||||
assert.ok(Object.hasOwn(sourceReport, field), `${label}.sourceReports.${requiredReport} missing ${field}`);
|
||||
assertString(sourceReport[field], `${label}.sourceReports.${requiredReport}.${field}`);
|
||||
}
|
||||
assertRepoRelativePath(sourceReport.path, `${label}.sourceReports.${requiredReport}.path`);
|
||||
}
|
||||
|
||||
assertObject(report.overall, `${label}.overall`);
|
||||
assert.ok(["green", "blocked"].includes(report.overall.status), `${label}.overall.status`);
|
||||
assertBoolean(report.overall.green, `${label}.overall.green`);
|
||||
assertString(report.overall.reason, `${label}.overall.reason`);
|
||||
assert.equal(report.overall.green, report.overall.status === "green", `${label}.overall.green must match status`);
|
||||
|
||||
assertObject(report.dod, `${label}.dod`);
|
||||
assert.ok(["green", "blocked"].includes(report.dod.status), `${label}.dod.status`);
|
||||
assertBoolean(report.dod.green, `${label}.dod.green`);
|
||||
assertArray(report.dod.checks, `${label}.dod.checks`);
|
||||
assert.ok(report.dod.checks.length >= 7, `${label}.dod.checks must cover #9 DoD gates`);
|
||||
assertUnique(report.dod.checks.map((check) => check.id), `${label}.dod.checks`);
|
||||
for (const [index, check] of report.dod.checks.entries()) {
|
||||
const checkLabel = `${label}.dod.checks[${index}]`;
|
||||
assertObject(check, checkLabel);
|
||||
for (const field of ["id", "status", "evidenceLevel", "summary"]) {
|
||||
assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`);
|
||||
}
|
||||
assert.match(check.id, /^[a-z][a-z0-9-]*$/, `${checkLabel}.id`);
|
||||
assert.ok(["pass", "blocked"].includes(check.status), `${checkLabel}.status`);
|
||||
assertEvidenceLevel(check.evidenceLevel, `${checkLabel}.evidenceLevel`);
|
||||
assertString(check.summary, `${checkLabel}.summary`);
|
||||
}
|
||||
|
||||
assertArray(report.milestones, `${label}.milestones`);
|
||||
assert.deepEqual(
|
||||
report.milestones.map((milestone) => milestone.id),
|
||||
requiredMilestoneIds,
|
||||
`${label}.milestones must cover M0-M5 in order`
|
||||
);
|
||||
for (const [index, milestone] of report.milestones.entries()) {
|
||||
const milestoneLabel = `${label}.milestones[${index}]`;
|
||||
assertObject(milestone, milestoneLabel);
|
||||
for (const field of ["id", "status", "highestVisibleLevel", "liveEvidence", "evidenceCount", "blockerCount", "summary"]) {
|
||||
assert.ok(Object.hasOwn(milestone, field), `${milestoneLabel} missing ${field}`);
|
||||
}
|
||||
assertStatus(milestone.status, `${milestoneLabel}.status`);
|
||||
assertEvidenceLevel(milestone.highestVisibleLevel, `${milestoneLabel}.highestVisibleLevel`);
|
||||
assert.ok(["pass", "missing_or_blocked"].includes(milestone.liveEvidence), `${milestoneLabel}.liveEvidence`);
|
||||
assert.ok(Number.isInteger(milestone.evidenceCount), `${milestoneLabel}.evidenceCount`);
|
||||
assert.ok(Number.isInteger(milestone.blockerCount), `${milestoneLabel}.blockerCount`);
|
||||
assertString(milestone.summary, `${milestoneLabel}.summary`);
|
||||
}
|
||||
|
||||
assertArray(report.evidence, `${label}.evidence`);
|
||||
assert.ok(report.evidence.length >= 10, `${label}.evidence must include M0-M5 entries`);
|
||||
for (const [index, evidence] of report.evidence.entries()) {
|
||||
const evidenceLabel = `${label}.evidence[${index}]`;
|
||||
assertObject(evidence, evidenceLabel);
|
||||
for (const field of ["milestone", "issue", "level", "status", "category", "summary"]) {
|
||||
assert.ok(Object.hasOwn(evidence, field), `${evidenceLabel} missing ${field}`);
|
||||
}
|
||||
assert.match(evidence.milestone, /^M[0-5]$/, `${evidenceLabel}.milestone`);
|
||||
assertString(evidence.issue, `${evidenceLabel}.issue`);
|
||||
assertEvidenceLevel(evidence.level, `${evidenceLabel}.level`);
|
||||
assertString(evidence.status, `${evidenceLabel}.status`);
|
||||
assertString(evidence.category, `${evidenceLabel}.category`);
|
||||
assertString(evidence.summary, `${evidenceLabel}.summary`);
|
||||
if (Object.hasOwn(evidence, "reportPath")) {
|
||||
assertRepoRelativePath(evidence.reportPath, `${evidenceLabel}.reportPath`);
|
||||
}
|
||||
if (Object.hasOwn(evidence, "commands")) {
|
||||
assertStringArray(evidence.commands, `${evidenceLabel}.commands`);
|
||||
}
|
||||
if (Object.hasOwn(evidence, "evidence")) {
|
||||
assertStringArray(evidence.evidence, `${evidenceLabel}.evidence`);
|
||||
}
|
||||
if (Object.hasOwn(evidence, "sources")) {
|
||||
assertStringArray(evidence.sources, `${evidenceLabel}.sources`);
|
||||
}
|
||||
}
|
||||
|
||||
assertObject(report.levels, `${label}.levels`);
|
||||
assert.deepEqual(Object.keys(report.levels), Array.from(evidenceLevels), `${label}.levels keys`);
|
||||
for (const level of evidenceLevels) {
|
||||
assertArray(report.levels[level], `${label}.levels.${level}`);
|
||||
}
|
||||
|
||||
assertArray(report.blockers, `${label}.blockers`);
|
||||
assert.ok(report.blockers.length >= 1, `${label}.blockers must not be empty while blocked`);
|
||||
for (const [index, blocker] of report.blockers.entries()) {
|
||||
const blockerLabel = `${label}.blockers[${index}]`;
|
||||
assertObject(blocker, blockerLabel);
|
||||
for (const field of ["id", "priority", "type", "scope", "status", "source", "sourceIssue", "summary", "unblockOrder", "unblocks", "rationale"]) {
|
||||
assert.ok(Object.hasOwn(blocker, field), `${blockerLabel} missing ${field}`);
|
||||
}
|
||||
assertString(blocker.id, `${blockerLabel}.id`);
|
||||
assert.match(blocker.priority, /^P[0-3]$/, `${blockerLabel}.priority`);
|
||||
assert.ok(blockerTypes.has(blocker.type), `${blockerLabel}.type`);
|
||||
assertString(blocker.scope, `${blockerLabel}.scope`);
|
||||
assert.equal(blocker.status, "open", `${blockerLabel}.status`);
|
||||
assertRepoRelativePath(blocker.source, `${blockerLabel}.source`);
|
||||
assertString(blocker.sourceIssue, `${blockerLabel}.sourceIssue`);
|
||||
assertString(blocker.summary, `${blockerLabel}.summary`);
|
||||
assert.ok(Number.isInteger(blocker.unblockOrder), `${blockerLabel}.unblockOrder`);
|
||||
assertStringArray(blocker.unblocks, `${blockerLabel}.unblocks`, { minLength: 1 });
|
||||
assertString(blocker.rationale, `${blockerLabel}.rationale`);
|
||||
if (Object.hasOwn(blocker, "nextTask")) {
|
||||
assertString(blocker.nextTask, `${blockerLabel}.nextTask`);
|
||||
}
|
||||
}
|
||||
|
||||
assertArray(report.nextSteps, `${label}.nextSteps`);
|
||||
assert.ok(report.nextSteps.length >= 5, `${label}.nextSteps must contain blocker order`);
|
||||
assert.deepEqual(
|
||||
report.nextSteps.map((step) => step.order),
|
||||
[...report.nextSteps].map((step) => step.order).sort((a, b) => a - b),
|
||||
`${label}.nextSteps must be sorted by order`
|
||||
);
|
||||
for (const [index, step] of report.nextSteps.entries()) {
|
||||
const stepLabel = `${label}.nextSteps[${index}]`;
|
||||
assertObject(step, stepLabel);
|
||||
for (const field of ["order", "priority", "scopes", "sourceIssues", "rationale", "action", "evidenceRequired"]) {
|
||||
assert.ok(Object.hasOwn(step, field), `${stepLabel} missing ${field}`);
|
||||
}
|
||||
assert.ok(Number.isInteger(step.order), `${stepLabel}.order`);
|
||||
if (Object.hasOwn(step, "blockerOrder")) {
|
||||
assert.ok(Number.isInteger(step.blockerOrder), `${stepLabel}.blockerOrder`);
|
||||
}
|
||||
assert.match(step.priority, /^P[0-3]$/, `${stepLabel}.priority`);
|
||||
assertStringArray(step.scopes, `${stepLabel}.scopes`, { minLength: 1 });
|
||||
assertStringArray(step.sourceIssues, `${stepLabel}.sourceIssues`, { minLength: 1 });
|
||||
assertString(step.rationale, `${stepLabel}.rationale`);
|
||||
assertString(step.action, `${stepLabel}.action`);
|
||||
assertString(step.evidenceRequired, `${stepLabel}.evidenceRequired`);
|
||||
}
|
||||
|
||||
assertStringArray(report.validationCommands, `${label}.validationCommands`, {
|
||||
minLength: requiredAggregatorV2Commands.length
|
||||
});
|
||||
for (const requiredCommand of requiredAggregatorV2Commands) {
|
||||
assert.ok(
|
||||
report.validationCommands.includes(requiredCommand),
|
||||
`${label}.validationCommands missing ${requiredCommand}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const reportFiles = await collectReportFiles();
|
||||
assert.ok(reportFiles.length >= 1, "expected at least one dev-gate report JSON file");
|
||||
|
||||
Reference in New Issue
Block a user