From 553f8cd0442dc8fb6dee6b8d2a0a2dd1723b8337 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 05:30:16 +0000 Subject: [PATCH] fix: add compact DEV rollout readiness guard --- package.json | 4 +- .../artifact-runtime-readiness-guard.test.mjs | 149 ++++++ .../src/artifact-runtime-readiness-guard.mjs | 500 +++++++++++++++++- 3 files changed, 622 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index 6ffdc0a4..3211d7e4 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --check && node scripts/dev-runtime-migration.mjs --check && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/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/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.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/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.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/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.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/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-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 --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --check && node scripts/dev-runtime-migration.mjs --check && 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 scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/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/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.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/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.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/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.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/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-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 --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && 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 scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/artifact-runtime-readiness-guard.test.mjs b/scripts/artifact-runtime-readiness-guard.test.mjs index a65cf59f..9465dec1 100644 --- a/scripts/artifact-runtime-readiness-guard.test.mjs +++ b/scripts/artifact-runtime-readiness-guard.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildCommanderReadinessSummary, commitsMatch, evaluateArtifactRuntimeReadiness, normalizeCommit @@ -246,3 +247,151 @@ test("Cloud Web publish report must retain build freshness evidence", () => { assert.equal(result.artifact.cloudWeb.distFreshnessStatus, "missing"); assert.ok(result.blockers.some((item) => item.id === "cloud-web-published-build-freshness")); }); + +test("compact commander summary exposes canPublish/canApply and dry-run devLiveClaim false", () => { + const result = readiness(); + const summary = buildCommanderReadinessSummary({ + readiness: result, + targetRef: "origin/main", + generatedFromCommit: targetSha, + explicitLive: false, + cloudWebDistFreshness: { status: "pass", mismatches: [] }, + codeAgentTimeoutReadiness: { + status: "pass", + frontendTimeoutMs: 35000, + backendTimeoutMs: 120000, + requiredFrontendTimeoutMs: 35000, + summary: "ready" + }, + runtimeReport: { + runtimeIdentity: { + ready: true, + runtime: { durable: true, ready: true }, + readiness: { ready: true, durability: { ready: true } } + } + } + }); + + assert.equal(summary.kind, "hwlab-dev-rollout-readiness"); + assert.equal(summary.canPublish, true); + assert.equal(summary.canApply, true); + assert.equal(summary.devLiveClaim, false); + assert.deepEqual(summary.blockedReasons, []); + assert.equal(summary.currentSourceCommit.shortCommitId, targetSha.slice(0, 7)); +}); + +test("compact summary blocks publish on stale Cloud Web source dist", () => { + const result = readiness(); + const summary = buildCommanderReadinessSummary({ + readiness: result, + targetRef: "origin/main", + generatedFromCommit: targetSha, + explicitLive: false, + cloudWebDistFreshness: { status: "blocked", mismatches: ["index.html"] }, + codeAgentTimeoutReadiness: { + status: "pass", + frontendTimeoutMs: 35000, + backendTimeoutMs: 120000, + requiredFrontendTimeoutMs: 35000, + summary: "ready" + }, + runtimeReport: { + runtimeIdentity: { + ready: true, + runtime: { durable: true, ready: true }, + readiness: { ready: true, durability: { ready: true } } + } + } + }); + + assert.equal(summary.canPublish, false); + assert.equal(summary.canApply, false); + assert.equal(summary.cloudWebBuildFreshness.status, "blocked"); + assert.ok(summary.blockedReasons.some((item) => item.id === "cloud-web-build-freshness")); + assert.ok(summary.nextCommands.includes("node web/hwlab-cloud-web/scripts/build.mjs")); +}); + +test("compact summary separates Code Agent timeout and durable runtime blockers from apply readiness", () => { + const result = readiness(); + const summary = buildCommanderReadinessSummary({ + readiness: result, + targetRef: "origin/main", + generatedFromCommit: targetSha, + explicitLive: false, + cloudWebDistFreshness: { status: "pass", mismatches: [] }, + codeAgentTimeoutReadiness: { + status: "blocked", + frontendTimeoutMs: 4500, + backendTimeoutMs: 120000, + requiredFrontendTimeoutMs: 35000, + summary: "Code Agent browser timeout is too short." + }, + runtimeReport: { + runtimeIdentity: { + ready: false, + runtime: { + durable: false, + ready: false, + blocker: "runtime_durable_adapter_query_blocked", + connection: { queryResult: "query_blocked" } + }, + readiness: { + ready: false, + durability: { + ready: false, + blocker: "runtime_durable_adapter_query_blocked", + blockedLayer: "durability_query", + queryResult: "query_blocked" + } + }, + blockerCodes: ["runtime_durable_adapter_query_blocked"] + } + } + }); + + assert.equal(summary.canPublish, false); + assert.equal(summary.canApply, false); + assert.equal(summary.codeAgentTimeoutReadiness.status, "blocked"); + assert.equal(summary.runtimeDurableBlocker.classification, "runtime_durable_adapter_query_blocked"); + assert.ok(summary.blockedReasons.some((item) => item.id === "code-agent-timeout-readiness")); + assert.ok(summary.blockedReasons.some((item) => item.id === "runtime-durable:runtime_durable_adapter_query_blocked")); +}); + +test("compact summary keeps provider-secret blockers out of publish but blocks apply", () => { + const result = readiness(); + const summary = buildCommanderReadinessSummary({ + readiness: result, + targetRef: "origin/main", + generatedFromCommit: targetSha, + explicitLive: false, + cloudWebDistFreshness: { status: "pass", mismatches: [] }, + codeAgentTimeoutReadiness: { + status: "pass", + frontendTimeoutMs: 35000, + backendTimeoutMs: 120000, + requiredFrontendTimeoutMs: 35000, + summary: "ready" + }, + edgeReport: { + blockers: [ + { + scope: "code-agent-provider-secret", + classification: "provider_secret_missing", + summary: "Code Agent provider Secret is missing." + } + ] + }, + runtimeReport: { + runtimeIdentity: { + ready: true, + runtime: { durable: true, ready: true }, + readiness: { ready: true, durability: { ready: true } } + } + } + }); + + assert.equal(summary.canPublish, true); + assert.equal(summary.canApply, false); + assert.equal(summary.codeAgentProviderReadiness.status, "blocked"); + assert.ok(summary.blockedReasons.some((item) => item.id === "code-agent-provider:code-agent-provider-secret")); +}); diff --git a/scripts/src/artifact-runtime-readiness-guard.mjs b/scripts/src/artifact-runtime-readiness-guard.mjs index 9f97cddd..03f26159 100644 --- a/scripts/src/artifact-runtime-readiness-guard.mjs +++ b/scripts/src/artifact-runtime-readiness-guard.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT } from "../../internal/protocol/index.mjs"; +import { inspectCloudWebDistFreshness } from "../../web/hwlab-cloud-web/scripts/dist-contract.mjs"; import { buildDesiredStatePlan } from "./deploy-desired-state-plan.mjs"; const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -12,12 +13,15 @@ const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url) const defaultArtifactReport = "reports/dev-gate/dev-artifacts.json"; const defaultArtifactCatalog = "deploy/artifact-catalog.dev.json"; const defaultRuntimeReport = "reports/dev-gate/dev-cloud-workbench-live.json"; +const defaultEdgeReport = "reports/dev-gate/dev-edge-health.json"; const defaultOutputReport = "reports/dev-gate/dev-artifact-runtime-readiness.json"; const defaultTargetRef = "origin/main"; const guardCommand = `node scripts/artifact-runtime-readiness-guard.mjs --target-ref ${defaultTargetRef} --check --no-report`; const expectedBlockedGuardCommand = `${guardCommand} --expect-blocked`; const digestPattern = /^sha256:[a-f0-9]{64}$/u; const commitPattern = /^[a-f0-9]{7,40}$/u; +const minCodeAgentFrontendTimeoutMs = 35000; +const defaultCodeAgentBackendTimeoutMs = 120000; export function normalizeCommit(value) { if (typeof value !== "string") return "unknown"; @@ -198,10 +202,15 @@ export function buildArtifactRuntimeReadinessReport({ artifactCatalog, desiredStatePlan, runtimeReport, + edgeReport, liveRuntime, + cloudWebDistFreshness, + m3IoSourceReadiness, targetRef, targetCommitId, - generatedFromCommit = "unknown" + generatedFromCommit = "unknown", + explicitLive = false, + codeAgentTimeoutReadiness = null }) { const readiness = evaluateArtifactRuntimeReadiness({ artifactReport, @@ -213,6 +222,18 @@ export function buildArtifactRuntimeReadinessReport({ targetCommitId }); const status = readiness.ready ? "pass" : "blocked"; + const commanderDecision = buildCommanderReadinessSummary({ + readiness, + runtimeReport, + edgeReport, + liveRuntime, + cloudWebDistFreshness, + m3IoSourceReadiness, + targetRef, + generatedFromCommit, + explicitLive, + codeAgentTimeoutReadiness + }); return { $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-artifact-runtime-readiness.json", @@ -230,8 +251,9 @@ export function buildArtifactRuntimeReadinessReport({ devOnly: true, prodDisabled: true, generatedAt: new Date().toISOString(), - evidenceLevel: status === "pass" ? "DEV-LIVE-RUNTIME-IDENTITY-NON-M3-M4-M5" : "BLOCKED", - devLive: status === "pass", + evidenceLevel: explicitLive && status === "pass" ? "DEV-LIVE-RUNTIME-IDENTITY-NON-M3-M4-M5" : "BLOCKED", + devLive: explicitLive && status === "pass", + devLiveClaim: commanderDecision.devLiveClaim, reportLifecycle: activeReportLifecycle("Current latest-main DEV artifact/runtime readiness guard; SOURCE and static checks cannot imply deployed 16666 readiness."), sourceContract: { status: "pass", @@ -289,6 +311,7 @@ export function buildArtifactRuntimeReadinessReport({ runtimeReportPath: defaultRuntimeReport, ...readiness }, + commanderDecision, blockedReasons: readiness.blockers.map((item) => ({ scope: item.id, type: item.type, @@ -344,6 +367,127 @@ export async function observeLiveRuntimeIdentities({ live = true, timeoutMs = 50 }; } +export function buildCommanderReadinessSummary({ + readiness, + runtimeReport = null, + edgeReport = null, + liveRuntime = null, + cloudWebDistFreshness = null, + m3IoSourceReadiness = null, + targetRef = defaultTargetRef, + generatedFromCommit = "unknown", + explicitLive = false, + codeAgentTimeoutReadiness = null +}) { + const sourceCommit = normalizeCommit(generatedFromCommit); + const deployDesiredState = readiness.desiredState; + const artifactCatalog = readiness.catalog; + const cloudWebBuild = summarizeCloudWebBuildFreshness(cloudWebDistFreshness, readiness.artifact.cloudWeb); + const codeAgentTimeout = codeAgentTimeoutReadiness ?? { + status: "blocked", + frontendTimeoutMs: null, + backendTimeoutMs: defaultCodeAgentBackendTimeoutMs, + requiredFrontendTimeoutMs: minCodeAgentFrontendTimeoutMs, + summary: "Code Agent timeout readiness was not inspected." + }; + const runtimeDurable = classifyRuntimeDurableBlocker(runtimeReport, readiness.runtime); + const codeAgentProvider = summarizeCodeAgentProvider(edgeReport, runtimeReport); + const m3Source = summarizeM3IoSourceReadiness(m3IoSourceReadiness); + const publishBlockerIds = new Set(["target-ref-resolved", "artifact-report-published"]); + const applyBlockerIds = new Set([ + "target-ref-resolved", + "artifact-report-published", + "artifact-report-target-match", + "artifact-catalog-published", + "artifact-catalog-target-match", + "artifact-report-catalog-match", + "desired-state-internal-valid", + "desired-state-target-match", + "cloud-web-published-build-freshness" + ]); + const liveRuntimeBlockerIds = new Set([ + "rollout-read-access", + "api-runtime-identity-observed", + "api-runtime-target-match", + "api-runtime-artifact-match", + "cloud-web-runtime-identity-observed", + "cloud-web-runtime-target-match", + "cloud-web-runtime-artifact-match" + ]); + if (explicitLive) { + for (const id of liveRuntimeBlockerIds) applyBlockerIds.add(id); + } + const canPublish = readiness.blockers.every((blocker) => !publishBlockerIds.has(blocker.id)) && + cloudWebBuild.sourceDistStatus === "pass" && + codeAgentTimeout.status === "pass" && + m3Source.status !== "blocked"; + const canApply = canPublish && + runtimeDurable.status === "pass" && + codeAgentProvider.status !== "blocked" && + readiness.blockers.every((blocker) => !applyBlockerIds.has(blocker.id)); + const blockedReasons = [ + ...compactReasons(readiness.blockers, { canPublish, canApply, explicitLive, liveRuntimeBlockerIds }), + ...extraBlockedReasons({ + canPublish, + cloudWebBuild, + codeAgentTimeout, + codeAgentProvider, + m3Source, + runtimeDurable + }) + ]; + + return { + kind: "hwlab-dev-rollout-readiness", + status: blockedReasons.length === 0 ? "pass" : "blocked", + targetRef, + currentSourceCommit: { + commitId: sourceCommit, + shortCommitId: shortCommit(sourceCommit) + }, + deployDesiredStateCommit: { + commitId: deployDesiredState.fullDeployCommitId ?? "unknown", + shortCommitId: deployDesiredState.deployCommitId, + convergence: deployDesiredState.targetConvergence.state, + pendingTargetFields: deployDesiredState.targetConvergence.pendingTargetFields + }, + artifactCatalogState: { + commitId: artifactCatalog.commitId, + shortCommitId: artifactCatalog.shortCommitId, + state: artifactCatalog.artifactState, + ciPublished: artifactCatalog.ciPublished, + registryVerified: artifactCatalog.registryVerified, + publishVerified: artifactCatalog.publishVerified, + requiredPublished: `${artifactCatalog.publishedRequiredCount}/${artifactCatalog.requiredServiceCount}`, + digestCounts: artifactCatalog.digestCounts + }, + cloudWebBuildFreshness: cloudWebBuild, + codeAgentTimeoutReadiness: codeAgentTimeout, + codeAgentProviderReadiness: codeAgentProvider, + m3IoSourceReadiness: m3Source, + runtimeDurableBlocker: runtimeDurable, + runtimeIdentity: { + liveChecksRan: explicitLive, + liveMode: readiness.runtime.live.mode, + apiCommit: readiness.runtime.api.effective.shortCommitId, + cloudWebCommit: readiness.runtime.cloudWeb.effective.shortCommitId + }, + canPublish, + canApply, + devLiveClaim: explicitLive && canApply && readiness.ready, + blockedReasons: dedupeReasons(blockedReasons), + nextCommands: nextCommands({ canPublish, canApply, blockedReasons }), + safety: { + readOnly: true, + liveChecksRan: explicitLive, + publishApplyRestart: false, + registryPush: false, + secretsRead: false, + prodTouched: false + } + }; +} + export async function runArtifactRuntimeReadinessGuardCli(argv = process.argv.slice(2), options = {}) { const repoRoot = options.repoRoot ?? defaultRepoRoot; const args = parseArgs(argv); @@ -352,10 +496,14 @@ export async function runArtifactRuntimeReadinessGuardCli(argv = process.argv.sl return 0; } - const [artifactReport, artifactCatalog, runtimeReport, liveRuntime] = await Promise.all([ + const [artifactReport, artifactCatalog, runtimeReport, edgeReport, cloudWebDistFreshness, codeAgentTimeoutReadiness, m3IoSourceReadiness, liveRuntime] = await Promise.all([ readOptionalJson(repoRoot, args.artifactReport), readOptionalJson(repoRoot, args.artifactCatalog), readOptionalJson(repoRoot, args.runtimeReport), + readOptionalJson(repoRoot, args.edgeReport), + inspectCloudWebDistFreshness(path.join(repoRoot, "web/hwlab-cloud-web")), + inspectCodeAgentTimeoutReadiness(repoRoot), + inspectM3IoSourceReadiness(repoRoot), observeLiveRuntimeIdentities({ live: args.live, timeoutMs: args.timeoutMs }) ]); const targetCommitId = resolveTargetCommit(repoRoot, args.targetRef); @@ -365,10 +513,15 @@ export async function runArtifactRuntimeReadinessGuardCli(argv = process.argv.sl artifactCatalog, desiredStatePlan, runtimeReport, + edgeReport, liveRuntime, + cloudWebDistFreshness, + m3IoSourceReadiness, targetRef: args.targetRef, targetCommitId, - generatedFromCommit: resolveTargetCommit(repoRoot, "HEAD") + generatedFromCommit: resolveTargetCommit(repoRoot, "HEAD"), + explicitLive: args.live, + codeAgentTimeoutReadiness }); if (args.writeReport) { @@ -381,9 +534,9 @@ export async function runArtifactRuntimeReadinessGuardCli(argv = process.argv.sl process.stdout.write(`${JSON.stringify(output, null, args.pretty || args.check ? 2 : 0)}\n`); if (args.expectBlocked) { - return report.status === "blocked" ? 0 : 2; + return (report.status === "blocked" || report.commanderDecision.canApply === false) ? 0 : 2; } - if (args.check && report.status !== "pass") { + if (args.check && !report.commanderDecision.canApply) { return 2; } return 0; @@ -740,6 +893,305 @@ function addCheck(checks, { id, pass, type, summary, blockedSummary = null, next }); } +function summarizeCloudWebBuildFreshness(distFreshness, artifactCloudWeb) { + const sourceStatus = distFreshness?.status === "pass" ? "pass" : "blocked"; + const artifactStatus = artifactCloudWeb?.distFreshnessStatus ?? "missing"; + const status = sourceStatus === "pass" && (artifactStatus === "pass" || artifactCloudWeb?.published !== true) + ? "pass" + : "blocked"; + return { + status, + sourceDistStatus: sourceStatus, + artifactDistFreshnessStatus: artifactStatus, + mismatches: Array.isArray(distFreshness?.mismatches) ? distFreshness.mismatches.slice(0, 8) : [], + buildCommand: "node web/hwlab-cloud-web/scripts/build.mjs", + checkCommand: "node web/hwlab-cloud-web/scripts/check.mjs", + summary: sourceStatus === "pass" + ? "Cloud Web source dist freshness is ready for publish review." + : "Cloud Web dist is stale or missing for source or published artifact evidence." + }; +} + +async function inspectCodeAgentTimeoutReadiness(repoRoot) { + const [appSource, chatSource] = await Promise.all([ + readFile(path.join(repoRoot, "web/hwlab-cloud-web/app.mjs"), "utf8"), + readFile(path.join(repoRoot, "internal/cloud/code-agent-chat.mjs"), "utf8") + ]); + const frontendTimeoutMs = numericSourceConstant(appSource, "DEFAULT_CODE_AGENT_TIMEOUT_MS"); + const backendTimeoutMs = numericSourceConstant(chatSource, "DEFAULT_CODE_AGENT_TIMEOUT_MS"); + const frontendReady = Number.isInteger(frontendTimeoutMs) && frontendTimeoutMs >= minCodeAgentFrontendTimeoutMs; + const backendReady = Number.isInteger(backendTimeoutMs) && backendTimeoutMs >= minCodeAgentFrontendTimeoutMs; + return { + status: frontendReady && backendReady ? "pass" : "blocked", + frontendTimeoutMs, + backendTimeoutMs, + requiredFrontendTimeoutMs: minCodeAgentFrontendTimeoutMs, + summary: frontendReady && backendReady + ? "Code Agent browser/backend timeout budget is ready for provider latency." + : "Code Agent browser timeout is too short for provider-backed replies; long requests can be misclassified as failed." + }; +} + +function numericSourceConstant(source, name) { + const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*([0-9_]+)\\s*;`, "u")); + if (!match) return null; + return Number.parseInt(match[1].replace(/_/gu, ""), 10); +} + +async function inspectM3IoSourceReadiness(repoRoot) { + const files = await listTrackedFiles(repoRoot); + const candidateFiles = files.filter((file) => + /\.(?:mjs|js|json|md|html|css|yaml|yml)$/u.test(file) && + file !== "scripts/src/artifact-runtime-readiness-guard.mjs" && + file !== "scripts/artifact-runtime-readiness-guard.test.mjs" + ); + const matches = []; + const contractFiles = []; + for (const relativePath of candidateFiles) { + const text = await readFile(path.join(repoRoot, relativePath), "utf8").catch(() => ""); + if (/\/v1\/m3\/io/u.test(text)) { + matches.push(relativePath); + } + if (/m3-io-control-v1|M3_IO_CONTROL_ROUTE|same-origin \/v1\/m3\/io/u.test(text)) { + contractFiles.push(relativePath); + } + } + const present = matches.length > 0; + const sourceReady = contractFiles.length > 0 && + matches.some((file) => file === "web/hwlab-cloud-web/app.mjs") && + matches.some((file) => file === "internal/cloud/m3-io-control.mjs" || file === "scripts/src/m3-io-control-e2e.mjs"); + return { + status: present ? (sourceReady ? "pass" : "blocked") : "absent", + present, + paths: matches.slice(0, 12), + contractPaths: contractFiles.slice(0, 12), + requiredForApply: false, + summary: present + ? sourceReady + ? "M3 /v1/m3/io source surface is present with same-origin web/API contract evidence." + : "M3 /v1/m3/io source surface is present but missing expected web/API source contract evidence." + : "M3 /v1/m3/io source surface is not present; no M3 IO source readiness gate is applied." + }; +} + +async function listTrackedFiles(repoRoot) { + try { + const output = execFileSync("git", ["ls-files"], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000 + }); + return output.split(/\r?\n/u).filter(Boolean); + } catch { + return []; + } +} + +function summarizeM3IoSourceReadiness(readiness) { + if (!readiness) { + return { + status: "absent", + present: false, + requiredForApply: false, + summary: "M3 /v1/m3/io source surface was not inspected." + }; + } + if (readiness.status === "present") { + return { ...readiness, status: "pass" }; + } + return readiness; +} + +function summarizeCodeAgentProvider(edgeReport, runtimeReport) { + const blockers = [ + ...(edgeReport?.blockers ?? []), + ...(runtimeReport?.blockers ?? []) + ]; + const providerBlocker = blockers.find((blocker) => + String(blocker.scope ?? "").includes("code-agent") || + /Code Agent|provider Secret|OPENAI_API_KEY|provider_unavailable/iu.test(`${blocker.summary ?? ""} ${blocker.classification ?? ""}`) + ); + if (providerBlocker) { + return { + status: "blocked", + blocker: sanitizeString(providerBlocker.scope), + classification: sanitizeString(providerBlocker.classification), + summary: oneLine(providerBlocker.summary), + separateFromDbLive: true + }; + } + const liveJourney = (runtimeReport?.checks ?? []).find((check) => check.id === "live-code-agent-browser-journey"); + return { + status: liveJourney?.status === "pass" ? "pass" : "not_observed", + blocker: null, + classification: liveJourney?.status === "pass" ? "completed-browser-journey" : "not_observed", + summary: liveJourney?.summary ?? "Code Agent provider readiness was not observed by this dry-run/source guard.", + separateFromDbLive: true + }; +} + +function classifyRuntimeDurableBlocker(runtimeReport, runtimeSources) { + const identity = runtimeReport?.runtimeIdentity ?? runtimeSources?.api?.reportSnapshot ?? runtimeSources?.api?.effective ?? null; + const runtime = identity?.runtime ?? {}; + const readiness = identity?.readiness ?? {}; + const durability = readiness?.durability ?? {}; + const blockerCodes = Array.isArray(identity?.blockerCodes) ? identity.blockerCodes : []; + const durableBlocked = runtime?.blocker === "runtime_durable_adapter_query_blocked" || + durability?.blocker === "runtime_durable_adapter_query_blocked" || + blockerCodes.includes("runtime_durable_adapter_query_blocked") || + durability?.blockedLayer === "durability_query" || + runtime?.connection?.queryResult === "query_blocked" || + durability?.queryResult === "query_blocked"; + const ready = identity?.ready === true && + runtime?.durable === true && + (runtime?.ready === true || readiness?.ready === true) && + durability?.ready === true && + !durableBlocked; + return { + status: ready ? "pass" : "blocked", + blocker: ready ? null : sanitizeString(durability?.blocker ?? runtime?.blocker ?? blockerCodes[0]), + blockedLayer: sanitizeString(durability?.blockedLayer), + queryResult: sanitizeString(durability?.queryResult ?? runtime?.connection?.queryResult), + runtimeDurable: runtime?.durable === true, + runtimeReady: runtime?.ready === true, + durabilityReady: durability?.ready === true, + classification: durableBlocked ? "runtime_durable_adapter_query_blocked" : ready ? "durable-ready" : "runtime-durability-not-ready", + summary: ready + ? "Runtime durable adapter is ready." + : "Runtime durable adapter is not ready; durable read/query blockers stay separate from source/apply readiness." + }; +} + +function compactReasons(blockers, { canPublish, canApply, explicitLive, liveRuntimeBlockerIds }) { + return blockers + .filter((blocker) => explicitLive || !liveRuntimeBlockerIds.has(blocker.id)) + .map((blocker) => ({ + id: blocker.id, + type: blocker.type, + class: reasonClassForBlocker(blocker.id, { canPublish, canApply }), + summary: oneLine(blocker.summary), + next: oneLine(blocker.nextTask) + })); +} + +function extraBlockedReasons({ + canPublish, + cloudWebBuild, + codeAgentTimeout, + codeAgentProvider, + m3Source, + runtimeDurable +}) { + const reasons = []; + if (cloudWebBuild.sourceDistStatus !== "pass") { + reasons.push({ + id: "cloud-web-build-freshness", + type: "runtime_blocker", + class: "publish", + summary: cloudWebBuild.summary, + next: cloudWebBuild.buildCommand + }); + } + if (cloudWebBuild.artifactDistFreshnessStatus !== "pass" && canPublish) { + reasons.push({ + id: "cloud-web-artifact-build-freshness", + type: "runtime_blocker", + class: "apply", + summary: "Cloud Web published artifact is missing fresh dist evidence.", + next: "Publish a Cloud Web artifact that records artifactPublish.services[].distFreshness.status=pass." + }); + } + if (codeAgentTimeout.status !== "pass") { + reasons.push({ + id: "code-agent-timeout-readiness", + type: "agent_blocker", + class: "runtime", + summary: codeAgentTimeout.summary, + next: "Increase the Cloud Web Code Agent request timeout and rerun web/check plus focused readiness tests." + }); + } + if (codeAgentProvider.status === "blocked") { + reasons.push({ + id: `code-agent-provider:${codeAgentProvider.blocker}`, + type: "agent_blocker", + class: "runtime", + summary: codeAgentProvider.summary, + next: "Restore provider Secret/key-presence and DEV egress readiness without printing secret values." + }); + } + if (runtimeDurable.status !== "pass") { + reasons.push({ + id: `runtime-durable:${runtimeDurable.classification}`, + type: "runtime_blocker", + class: "runtime", + summary: runtimeDurable.summary, + next: "Repair durable runtime auth/schema/migration/read readiness, then rerun read-only health checks." + }); + } + if (m3Source.present && !["pass", "present"].includes(m3Source.status)) { + reasons.push({ + id: "m3-io-source-readiness", + type: "contract_blocker", + class: "source", + summary: m3Source.summary, + next: "Repair the source-only M3 IO contract before apply review." + }); + } + return reasons; +} + +function reasonClassForBlocker(id, { canPublish, canApply }) { + if (!canPublish && [ + "target-ref-resolved", + "artifact-report-published" + ].includes(id)) { + return "publish"; + } + if (!canApply) return "apply"; + return "runtime"; +} + +function dedupeReasons(reasons) { + const byId = new Map(); + for (const reason of reasons) { + if (!byId.has(reason.id)) byId.set(reason.id, reason); + } + return [...byId.values()].slice(0, 20); +} + +function nextCommands({ canPublish, canApply, blockedReasons }) { + if (!canPublish) { + return [ + "node web/hwlab-cloud-web/scripts/build.mjs", + "node web/hwlab-cloud-web/scripts/check.mjs", + "node scripts/dev-artifact-publish.mjs --preflight --no-report", + "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked --no-write", + guardCommand + ]; + } + if (!canApply) { + return [ + "node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty", + "node scripts/deploy-desired-state-plan.mjs --promotion-commit --check", + "node scripts/dev-deploy-apply.mjs --dry-run --write-report", + guardCommand + ]; + } + if (blockedReasons.length > 0) { + return [ + "node scripts/dev-edge-health-smoke.mjs --live", + "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", + "node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production", + guardCommand + ]; + } + return [ + "node scripts/dev-deploy-apply.mjs --dry-run --write-report", + "node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --live --no-report" + ]; +} + async function readJson(repoRoot, relativePath) { const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); return JSON.parse(raw); @@ -772,9 +1224,10 @@ function parseArgs(argv) { artifactReport: defaultArtifactReport, artifactCatalog: defaultArtifactCatalog, runtimeReport: defaultRuntimeReport, + edgeReport: defaultEdgeReport, report: defaultOutputReport, timeoutMs: 5000, - live: true, + live: false, writeReport: false, check: false, expectBlocked: false, @@ -796,6 +1249,9 @@ function parseArgs(argv) { } else if (arg === "--runtime-report") { args.runtimeReport = requireValue(argv, index, arg); index += 1; + } else if (arg === "--edge-report") { + args.edgeReport = requireValue(argv, index, arg); + index += 1; } else if (arg === "--report") { args.report = requireValue(argv, index, arg); index += 1; @@ -808,6 +1264,8 @@ function parseArgs(argv) { args.writeReport = false; } else if (arg === "--no-live") { args.live = false; + } else if (arg === "--live") { + args.live = true; } else if (arg === "--check") { args.check = true; } else if (arg === "--expect-blocked") { @@ -837,36 +1295,20 @@ function requireValue(argv, index, arg) { function usage() { return [ - `usage: ${guardCommand} [--write-report] [--no-live] [--expect-blocked]`, + `usage: ${guardCommand} [--write-report] [--live] [--expect-blocked]`, "", "Read-only latest-main DEV artifact/runtime readiness guard.", "", "The command compares target main, artifact publish/catalog identity, desired-state convergence,", - "Cloud API /health/live identity, and Cloud Web :16666 /health/live identity.", + "Cloud API /health/live identity, Cloud Web :16666 /health/live identity, source build freshness,", + "Code Agent timeout handling, M3 /v1/m3/io source readiness if present, and durable runtime blockers.", + "By default it does not run live HTTP checks and prints devLiveClaim=false; pass --live for explicit read-only live checks.", "It does not deploy, publish, apply, restart services, mutate k8s, touch PROD, or read secrets." ].join("\n"); } function summaryForCheck(report) { - return { - status: report.status, - target: report.artifactRuntimeReadiness.target.shortCommitId, - desiredState: { - deployCommitId: report.artifactRuntimeReadiness.desiredState.deployCommitId, - convergence: report.artifactRuntimeReadiness.desiredState.targetConvergence.state - }, - artifact: { - sourceCommitId: report.artifactRuntimeReadiness.artifact.shortCommitId, - catalogCommitId: report.artifactRuntimeReadiness.catalog.shortCommitId, - reportPublished: report.artifactRuntimeReadiness.artifact.publishVerified, - catalogPublished: report.artifactRuntimeReadiness.catalog.publishVerified - }, - runtime: { - api: report.artifactRuntimeReadiness.runtime.api.effective.shortCommitId, - cloudWeb16666: report.artifactRuntimeReadiness.runtime.cloudWeb.effective.shortCommitId - }, - blockedReasons: report.blockedReasons - }; + return report.commanderDecision; } function sanitizeString(value) {