fix: add compact DEV rollout readiness guard
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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 <origin-main-sha> --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) {
|
||||
|
||||
Reference in New Issue
Block a user