1339 lines
50 KiB
JavaScript
1339 lines
50 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
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)), "../..");
|
|
|
|
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 = 150000;
|
|
const minCodeAgentBackendTimeoutMs = 150000;
|
|
|
|
export function normalizeCommit(value) {
|
|
if (typeof value !== "string") return "unknown";
|
|
const trimmed = value.trim().toLowerCase();
|
|
return commitPattern.test(trimmed) ? trimmed : "unknown";
|
|
}
|
|
|
|
export function shortCommit(value) {
|
|
const commit = normalizeCommit(value);
|
|
return commit === "unknown" ? "unknown" : commit.slice(0, 7);
|
|
}
|
|
|
|
export function commitsMatch(left, right) {
|
|
const a = normalizeCommit(left);
|
|
const b = normalizeCommit(right);
|
|
return a !== "unknown" && b !== "unknown" && (a === b || a.startsWith(b) || b.startsWith(a));
|
|
}
|
|
|
|
export function evaluateArtifactRuntimeReadiness({
|
|
artifactReport,
|
|
artifactCatalog,
|
|
desiredStatePlan,
|
|
runtimeReport,
|
|
liveRuntime,
|
|
targetRef,
|
|
targetCommitId
|
|
}) {
|
|
const target = {
|
|
ref: targetRef,
|
|
commitId: normalizeCommit(targetCommitId),
|
|
shortCommitId: shortCommit(targetCommitId)
|
|
};
|
|
const artifact = summarizeArtifactReport(artifactReport);
|
|
const catalog = summarizeArtifactCatalog(artifactCatalog);
|
|
const desiredState = summarizeDesiredStatePlan(desiredStatePlan, target);
|
|
const runtime = summarizeRuntimeSources(runtimeReport, liveRuntime);
|
|
const apiRuntime = runtime.api.effective;
|
|
const cloudWebRuntime = runtime.cloudWeb.effective;
|
|
const checks = [];
|
|
|
|
addCheck(checks, {
|
|
id: "target-ref-resolved",
|
|
pass: target.commitId !== "unknown",
|
|
type: "contract_blocker",
|
|
summary: `Target ${target.ref} resolves to ${target.shortCommitId}.`,
|
|
blockedSummary: `Target ${target.ref} could not be resolved to a Git commit.`,
|
|
nextTask: "Fetch origin/main and rerun this guard before preparing any DEV apply."
|
|
});
|
|
addCheck(checks, {
|
|
id: "artifact-report-published",
|
|
pass: artifact.publishVerified,
|
|
type: "runtime_blocker",
|
|
summary: `Artifact report status=${artifact.status}; required published=${artifact.publishedRequiredCount}/${artifact.requiredServiceCount}; sha256=${artifact.digestCounts.sha256}; invalid=${artifact.digestCounts.invalid}.`,
|
|
nextTask: "Complete the DEV artifact publish workflow and keep real sha256 digests for every required service."
|
|
});
|
|
addCheck(checks, {
|
|
id: "artifact-report-target-match",
|
|
pass: commitsMatch(artifact.sourceCommitId, target.commitId),
|
|
type: "observability_blocker",
|
|
summary: `Artifact report source=${artifact.shortCommitId}; target=${target.shortCommitId}.`,
|
|
nextTask: "Publish artifacts for latest origin/main or keep rollout readiness blocked."
|
|
});
|
|
addCheck(checks, {
|
|
id: "artifact-catalog-published",
|
|
pass: catalog.publishVerified,
|
|
type: "runtime_blocker",
|
|
summary: `Artifact catalog state=${catalog.artifactState}; ciPublished=${catalog.ciPublished}; registryVerified=${catalog.registryVerified}; sha256=${catalog.digestCounts.sha256}; not_published=${catalog.digestCounts.notPublished}; invalid=${catalog.digestCounts.invalid}.`,
|
|
nextTask: "Refresh deploy/artifact-catalog.dev.json only from a successful DEV artifact publish report."
|
|
});
|
|
addCheck(checks, {
|
|
id: "artifact-catalog-target-match",
|
|
pass: commitsMatch(catalog.commitId, target.commitId),
|
|
type: "observability_blocker",
|
|
summary: `Artifact catalog commit=${catalog.shortCommitId}; target=${target.shortCommitId}.`,
|
|
nextTask: "Refresh desired-state and artifact catalog identity to latest origin/main before apply."
|
|
});
|
|
addCheck(checks, {
|
|
id: "artifact-report-catalog-match",
|
|
pass: commitsMatch(artifact.sourceCommitId, catalog.commitId),
|
|
type: "observability_blocker",
|
|
summary: `Artifact report source=${artifact.shortCommitId}; catalog=${catalog.shortCommitId}.`,
|
|
nextTask: "Keep reports/dev-gate/dev-artifacts.json and deploy/artifact-catalog.dev.json aligned to the same artifact source."
|
|
});
|
|
addCheck(checks, {
|
|
id: "desired-state-internal-valid",
|
|
pass: desiredState.status !== "blocked" && desiredState.status !== "missing",
|
|
type: "contract_blocker",
|
|
summary: `Desired-state planner status=${desiredState.status}; convergence=${desiredState.targetConvergence.state}.`,
|
|
nextTask: "Repair deploy/deploy.json, deploy/artifact-catalog.dev.json, and deploy/k8s/base/workloads.yaml before any apply."
|
|
});
|
|
addCheck(checks, {
|
|
id: "desired-state-target-match",
|
|
pass: desiredState.matchesTarget,
|
|
type: "observability_blocker",
|
|
summary: `Desired-state deploy=${desiredState.deployCommitId}; target=${target.shortCommitId}; convergence=${desiredState.targetConvergence.state}; pending=${desiredState.targetConvergence.pendingTargetFields}.`,
|
|
nextTask: `Run node scripts/deploy-desired-state-plan.mjs --promotion-commit ${target.shortCommitId} --check after refreshing desired-state for latest origin/main.`
|
|
});
|
|
addCheck(checks, {
|
|
id: "rollout-read-access",
|
|
pass: runtime.live.mode === "live-read-only-http",
|
|
type: "observability_blocker",
|
|
summary: `Runtime rollout-read mode=${runtime.live.mode}.`,
|
|
nextTask: "Run this guard with read-only DEV endpoint access; SOURCE/local/static checks cannot claim latest-main deployment."
|
|
});
|
|
addCheck(checks, {
|
|
id: "api-runtime-identity-observed",
|
|
pass: apiRuntime.observed,
|
|
type: "observability_blocker",
|
|
summary: `API /health/live identity status=${apiRuntime.status}; source=${apiRuntime.evidenceSource}; commit=${apiRuntime.shortCommitId}.`,
|
|
nextTask: "Restore read-only access to Cloud API /health/live before treating DEV runtime identity as observed."
|
|
});
|
|
addCheck(checks, {
|
|
id: "api-runtime-target-match",
|
|
pass: apiRuntime.observed && commitsMatch(apiRuntime.commitId, target.commitId),
|
|
type: "runtime_blocker",
|
|
summary: `API /health/live commit=${apiRuntime.shortCommitId}; target=${target.shortCommitId}.`,
|
|
nextTask: "After publish and desired-state refresh, run the authorized DEV rollout path and re-observe /health/live."
|
|
});
|
|
addCheck(checks, {
|
|
id: "api-runtime-artifact-match",
|
|
pass: apiRuntime.observed && commitsMatch(apiRuntime.commitId, catalog.commitId) && commitsMatch(apiRuntime.commitId, artifact.sourceCommitId),
|
|
type: "runtime_blocker",
|
|
summary: `API /health/live commit=${apiRuntime.shortCommitId}; catalog=${catalog.shortCommitId}; artifact=${artifact.shortCommitId}.`,
|
|
nextTask: "Do not treat a healthy API route as current unless runtime, artifact report, and catalog identity all match."
|
|
});
|
|
addCheck(checks, {
|
|
id: "cloud-web-runtime-identity-observed",
|
|
pass: cloudWebRuntime.observed,
|
|
type: "observability_blocker",
|
|
summary: `Cloud Web :16666 /health/live identity status=${cloudWebRuntime.status}; source=${cloudWebRuntime.evidenceSource}; commit=${cloudWebRuntime.shortCommitId}.`,
|
|
nextTask: "Observe Cloud Web /health/live on :16666; HTML and static asset checks alone do not prove the deployed artifact revision."
|
|
});
|
|
addCheck(checks, {
|
|
id: "cloud-web-runtime-target-match",
|
|
pass: cloudWebRuntime.observed && commitsMatch(cloudWebRuntime.commitId, target.commitId),
|
|
type: "runtime_blocker",
|
|
summary: `Cloud Web :16666 revision=${cloudWebRuntime.shortCommitId}; target=${target.shortCommitId}.`,
|
|
nextTask: "Roll out the latest Cloud Web artifact through the authorized DEV path, then re-check :16666 /health/live."
|
|
});
|
|
addCheck(checks, {
|
|
id: "cloud-web-runtime-artifact-match",
|
|
pass: cloudWebRuntime.observed && commitsMatch(cloudWebRuntime.commitId, catalog.commitId) && commitsMatch(cloudWebRuntime.commitId, artifact.sourceCommitId),
|
|
type: "runtime_blocker",
|
|
summary: `Cloud Web revision=${cloudWebRuntime.shortCommitId}; catalog=${catalog.shortCommitId}; artifact=${artifact.shortCommitId}.`,
|
|
nextTask: "Block rollout claims until the Cloud Web served runtime identity matches the artifact catalog and publish report."
|
|
});
|
|
addCheck(checks, {
|
|
id: "cloud-web-published-build-freshness",
|
|
pass: artifact.cloudWeb.published && digestPattern.test(artifact.cloudWeb.digest) && artifact.cloudWeb.distFreshnessStatus === "pass",
|
|
type: "runtime_blocker",
|
|
summary: `Cloud Web artifact status=${artifact.cloudWeb.status}; imageTag=${artifact.cloudWeb.imageTag}; digest=${artifact.cloudWeb.digestStatus}; distFreshness=${artifact.cloudWeb.distFreshnessStatus}.`,
|
|
nextTask: "Run the Cloud Web build before publish and keep dist freshness evidence in the artifact publish report."
|
|
});
|
|
addCheck(checks, {
|
|
id: "source-local-static-not-authoritative",
|
|
pass: true,
|
|
type: "safety_blocker",
|
|
summary: "SOURCE, LOCAL, STATIC, and report snapshots remain non-authoritative until runtime and artifact identity checks above pass.",
|
|
nextTask: "Use this guard before apply and after any authorized rollout; never infer deployed latest-main from local source checks."
|
|
});
|
|
|
|
const blockers = checks.filter((item) => item.status === "blocked");
|
|
return {
|
|
target,
|
|
artifact,
|
|
catalog,
|
|
desiredState,
|
|
runtime,
|
|
ready: blockers.length === 0,
|
|
canInferFromSourceLocalStatic: false,
|
|
checks,
|
|
blockers
|
|
};
|
|
}
|
|
|
|
export function buildArtifactRuntimeReadinessReport({
|
|
artifactReport,
|
|
artifactCatalog,
|
|
desiredStatePlan,
|
|
runtimeReport,
|
|
edgeReport,
|
|
liveRuntime,
|
|
cloudWebDistFreshness,
|
|
m3IoSourceReadiness,
|
|
targetRef,
|
|
targetCommitId,
|
|
generatedFromCommit = "unknown",
|
|
explicitLive = false,
|
|
codeAgentTimeoutReadiness = null
|
|
}) {
|
|
const readiness = evaluateArtifactRuntimeReadiness({
|
|
artifactReport,
|
|
artifactCatalog,
|
|
desiredStatePlan,
|
|
runtimeReport,
|
|
liveRuntime,
|
|
targetRef,
|
|
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",
|
|
reportVersion: "v1",
|
|
status,
|
|
issue: "pikasTech/HWLAB#164",
|
|
supports: [
|
|
"pikasTech/HWLAB#7",
|
|
"pikasTech/HWLAB#78",
|
|
"pikasTech/HWLAB#131"
|
|
],
|
|
taskId: "dev-artifact-runtime-readiness",
|
|
commitId: shortCommit(generatedFromCommit),
|
|
acceptanceLevel: "dev_artifact_runtime_readiness",
|
|
devOnly: true,
|
|
prodDisabled: true,
|
|
generatedAt: new Date().toISOString(),
|
|
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",
|
|
documents: [
|
|
"docs/dev-artifact-publish.md",
|
|
"docs/dev-deploy-apply.md",
|
|
"docs/reference/deployment-publish.md"
|
|
],
|
|
summary: "Target commit, artifact publish/catalog identity, desired-state convergence, API runtime identity, and Cloud Web served runtime identity are separate gates."
|
|
},
|
|
validationCommands: [
|
|
"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",
|
|
guardCommand,
|
|
expectedBlockedGuardCommand
|
|
],
|
|
localSmoke: {
|
|
status,
|
|
commands: [expectedBlockedGuardCommand],
|
|
evidence: [
|
|
defaultArtifactReport,
|
|
defaultArtifactCatalog,
|
|
defaultRuntimeReport,
|
|
`${DEV_FRONTEND_ENDPOINT}/health/live`,
|
|
`${DEV_ENDPOINT}/health/live`
|
|
],
|
|
summary: "The guard is read-only and blocks unless artifact, desired-state, API runtime, and Cloud Web runtime identities all align."
|
|
},
|
|
dryRun: {
|
|
status: "not_applicable",
|
|
commands: [guardCommand],
|
|
evidence: ["No deploy, publish, apply, restart, k8s mutation, PROD access, or secret read is performed."],
|
|
summary: "This is a readiness guard, not a deployment dry-run or rollout substitute."
|
|
},
|
|
devPreconditions: {
|
|
status,
|
|
requirements: [
|
|
"Latest origin/main must resolve to a concrete target commit.",
|
|
"DEV artifact publish report and artifact catalog must match the target and contain real registry digests.",
|
|
"Desired-state deploy, catalog, workload image, and mirror env fields must converge to the target commit before apply.",
|
|
"Cloud API /health/live must report the target runtime identity.",
|
|
"Cloud Web :16666 /health/live must report the target served artifact identity.",
|
|
"M3/M4/M5 acceptance remains out of scope for this guard."
|
|
],
|
|
commands: [guardCommand],
|
|
summary: readiness.ready
|
|
? "Latest-main artifact, desired-state, API runtime, and Cloud Web runtime identity are aligned."
|
|
: "Latest-main DEV rollout readiness is blocked; see actionable blockers."
|
|
},
|
|
artifactRuntimeReadiness: {
|
|
version: "v1",
|
|
artifactReportPath: defaultArtifactReport,
|
|
artifactCatalogPath: defaultArtifactCatalog,
|
|
runtimeReportPath: defaultRuntimeReport,
|
|
...readiness
|
|
},
|
|
commanderDecision,
|
|
blockedReasons: readiness.blockers.map((item) => ({
|
|
scope: item.id,
|
|
type: item.type,
|
|
summary: item.summary,
|
|
nextTask: item.nextTask
|
|
})),
|
|
blockers: readiness.blockers.map((item) => ({
|
|
type: item.type,
|
|
scope: item.id,
|
|
status: "open",
|
|
summary: item.summary,
|
|
nextTask: item.nextTask
|
|
})),
|
|
safety: {
|
|
readOnly: true,
|
|
sourceOnlyWhenBlocked: true,
|
|
publicHttpGetOnly: readiness.runtime.live.mode === "live-read-only-http",
|
|
kubectlApply: false,
|
|
k8sMutation: false,
|
|
registryPush: false,
|
|
imageBuild: false,
|
|
serviceRestart: false,
|
|
prodTouched: false,
|
|
secretsRead: false,
|
|
canInferReadinessFromSourceLocalStatic: false,
|
|
statement: "No SOURCE, LOCAL, STATIC, or report-only check may claim deployed latest-main without matching artifact, desired-state, API runtime, and Cloud Web runtime identity."
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function observeLiveRuntimeIdentities({ live = true, timeoutMs = 5000 } = {}) {
|
|
const endpoints = {
|
|
api: new URL("/health/live", DEV_ENDPOINT).toString(),
|
|
cloudWeb: new URL("/health/live", DEV_FRONTEND_ENDPOINT).toString()
|
|
};
|
|
if (!live) {
|
|
return {
|
|
mode: "disabled",
|
|
endpoints,
|
|
api: notObservedRuntimeIdentity(endpoints.api, "live observation disabled by --no-live", "live-http"),
|
|
cloudWeb: notObservedRuntimeIdentity(endpoints.cloudWeb, "live observation disabled by --no-live", "live-http")
|
|
};
|
|
}
|
|
const [api, cloudWeb] = await Promise.all([
|
|
observeHealthLive(endpoints.api, { evidenceSource: "live-http", timeoutMs }),
|
|
observeHealthLive(endpoints.cloudWeb, { evidenceSource: "live-http", timeoutMs })
|
|
]);
|
|
return {
|
|
mode: "live-read-only-http",
|
|
endpoints,
|
|
api,
|
|
cloudWeb
|
|
};
|
|
}
|
|
|
|
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,
|
|
requiredBackendTimeoutMs: minCodeAgentBackendTimeoutMs,
|
|
transportWaitsForBackend: false,
|
|
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);
|
|
if (args.help) {
|
|
process.stdout.write(`${usage()}\n`);
|
|
return 0;
|
|
}
|
|
|
|
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);
|
|
const desiredStatePlan = await buildDesiredStatePlanOrBlocked(repoRoot, args.targetRef);
|
|
const report = buildArtifactRuntimeReadinessReport({
|
|
artifactReport,
|
|
artifactCatalog,
|
|
desiredStatePlan,
|
|
runtimeReport,
|
|
edgeReport,
|
|
liveRuntime,
|
|
cloudWebDistFreshness,
|
|
m3IoSourceReadiness,
|
|
targetRef: args.targetRef,
|
|
targetCommitId,
|
|
generatedFromCommit: resolveTargetCommit(repoRoot, "HEAD"),
|
|
explicitLive: args.live,
|
|
codeAgentTimeoutReadiness
|
|
});
|
|
|
|
if (args.writeReport) {
|
|
const outputPath = path.join(repoRoot, args.report);
|
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
const output = args.check ? summaryForCheck(report) : report;
|
|
process.stdout.write(`${JSON.stringify(output, null, args.pretty || args.check ? 2 : 0)}\n`);
|
|
|
|
if (args.expectBlocked) {
|
|
return (report.status === "blocked" || report.commanderDecision.canApply === false) ? 0 : 2;
|
|
}
|
|
if (args.check && !report.commanderDecision.canApply) {
|
|
return 2;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export function formatArtifactRuntimeReadinessFailure(error) {
|
|
return {
|
|
kind: "hwlab-artifact-runtime-readiness",
|
|
status: "error",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
safety: {
|
|
readOnly: true,
|
|
kubectlApply: false,
|
|
k8sMutation: false,
|
|
registryPush: false,
|
|
imageBuild: false,
|
|
serviceRestart: false,
|
|
prodTouched: false,
|
|
secretsRead: false
|
|
}
|
|
};
|
|
}
|
|
|
|
async function buildDesiredStatePlanOrBlocked(repoRoot, targetRef) {
|
|
try {
|
|
return await buildDesiredStatePlan({ repoRoot, targetRef });
|
|
} catch (error) {
|
|
return {
|
|
kind: "hwlab-deploy-desired-state-plan",
|
|
status: "blocked",
|
|
summary: {
|
|
desiredCommitId: "unknown",
|
|
diagnostics: 1,
|
|
blockers: 1
|
|
},
|
|
target: {
|
|
targetRef,
|
|
convergence: {
|
|
state: "planner_error",
|
|
comparableFields: 0,
|
|
matchingTargetFields: 0,
|
|
pendingTargetFields: 0
|
|
}
|
|
},
|
|
diagnostics: [{
|
|
severity: "blocker",
|
|
code: "planner_error",
|
|
message: error instanceof Error ? error.message : String(error)
|
|
}]
|
|
};
|
|
}
|
|
}
|
|
|
|
function summarizeArtifactReport(report) {
|
|
const publish = report?.artifactPublish;
|
|
if (!publish || typeof publish !== "object") {
|
|
return missingArtifactReport();
|
|
}
|
|
const services = Array.isArray(publish.services) ? publish.services : [];
|
|
const required = services.filter((service) => service?.artifactRequired === true);
|
|
const cloudWeb = services.find((service) => service?.serviceId === "hwlab-cloud-web") ?? null;
|
|
const digestCounts = countDigests(required);
|
|
const publishedRequiredCount = required.filter((service) => service?.status === "published").length;
|
|
const requiredServiceCount = Number.isInteger(publish.requiredServiceCount)
|
|
? publish.requiredServiceCount
|
|
: required.length;
|
|
const allRequiredServicesPublished = requiredServiceCount > 0 &&
|
|
publishedRequiredCount === requiredServiceCount &&
|
|
digestCounts.sha256 === requiredServiceCount;
|
|
return {
|
|
status: sanitizeString(publish.status),
|
|
sourceCommitId: normalizeCommit(publish.sourceCommitId ?? report.commitId),
|
|
shortCommitId: shortCommit(publish.sourceCommitId ?? report.commitId),
|
|
registryPrefix: sanitizeString(publish.registryPrefix),
|
|
serviceCount: Number.isInteger(publish.serviceCount) ? publish.serviceCount : services.length,
|
|
requiredServiceCount,
|
|
publishedRequiredCount,
|
|
allRequiredServicesPublished,
|
|
publishVerified: publish.status === "published" && allRequiredServicesPublished,
|
|
digestCounts,
|
|
cloudWeb: summarizeArtifactService(cloudWeb)
|
|
};
|
|
}
|
|
|
|
function summarizeArtifactCatalog(catalog) {
|
|
if (!catalog || typeof catalog !== "object") {
|
|
return missingArtifactCatalog();
|
|
}
|
|
const services = Array.isArray(catalog.services) ? catalog.services : [];
|
|
const required = services.filter((service) => service?.artifactRequired === true);
|
|
const digestCounts = countDigests(required);
|
|
const publishedRequiredCount = required.filter((service) => service?.publishState === "published").length;
|
|
const allRequiredServicesPublished = required.length > 0 &&
|
|
publishedRequiredCount === required.length &&
|
|
digestCounts.sha256 === required.length;
|
|
return {
|
|
artifactState: sanitizeString(catalog.artifactState),
|
|
commitId: normalizeCommit(catalog.commitId),
|
|
shortCommitId: shortCommit(catalog.commitId),
|
|
ciPublished: catalog.publish?.ciPublished === true,
|
|
registryVerified: catalog.publish?.registryVerified === true,
|
|
serviceCount: services.length,
|
|
requiredServiceCount: required.length,
|
|
publishedRequiredCount,
|
|
digestCounts,
|
|
publishVerified: catalog.artifactState === "published" &&
|
|
catalog.publish?.ciPublished === true &&
|
|
catalog.publish?.registryVerified === true &&
|
|
allRequiredServicesPublished
|
|
};
|
|
}
|
|
|
|
function summarizeDesiredStatePlan(plan, target) {
|
|
if (!plan || typeof plan !== "object") {
|
|
return {
|
|
status: "missing",
|
|
deployCommitId: "unknown",
|
|
shortCommitId: "unknown",
|
|
targetConvergence: emptyConvergence("missing"),
|
|
matchesTarget: false
|
|
};
|
|
}
|
|
const deployCommitId = normalizeCommit(plan.summary?.desiredCommitId);
|
|
const targetConvergence = plan.target?.convergence ?? emptyConvergence("not_requested");
|
|
const pendingTargetFields = Number.isInteger(targetConvergence.pendingTargetFields)
|
|
? targetConvergence.pendingTargetFields
|
|
: 0;
|
|
return {
|
|
status: sanitizeString(plan.status),
|
|
deployCommitId: shortCommit(deployCommitId),
|
|
fullDeployCommitId: deployCommitId,
|
|
targetConvergence: {
|
|
state: sanitizeString(targetConvergence.state),
|
|
comparableFields: Number.isInteger(targetConvergence.comparableFields) ? targetConvergence.comparableFields : 0,
|
|
matchingTargetFields: Number.isInteger(targetConvergence.matchingTargetFields) ? targetConvergence.matchingTargetFields : 0,
|
|
pendingTargetFields
|
|
},
|
|
matchesTarget: commitsMatch(deployCommitId, target.commitId) && pendingTargetFields === 0
|
|
};
|
|
}
|
|
|
|
function summarizeRuntimeSources(runtimeReport, liveRuntime) {
|
|
const reportApi = normalizeRuntimeIdentity(runtimeReport?.runtimeIdentity, "report-snapshot");
|
|
const live = liveRuntime ?? {
|
|
mode: "missing",
|
|
endpoints: {},
|
|
api: null,
|
|
cloudWeb: null
|
|
};
|
|
return {
|
|
live: {
|
|
mode: sanitizeString(live.mode),
|
|
endpoints: live.endpoints ?? {}
|
|
},
|
|
reportSnapshot: {
|
|
api: reportApi,
|
|
status: sanitizeString(runtimeReport?.status)
|
|
},
|
|
api: {
|
|
live: normalizeRuntimeIdentity(live.api, "live-http"),
|
|
reportSnapshot: reportApi,
|
|
effective: normalizeRuntimeIdentity(live.api, "live-http")
|
|
},
|
|
cloudWeb: {
|
|
live: normalizeRuntimeIdentity(live.cloudWeb, "live-http"),
|
|
reportSnapshot: normalizeRuntimeIdentity(runtimeReport?.cloudWebRuntimeIdentity, "report-snapshot"),
|
|
effective: normalizeRuntimeIdentity(live.cloudWeb, "live-http")
|
|
}
|
|
};
|
|
}
|
|
|
|
function summarizeArtifactService(service) {
|
|
if (!service) {
|
|
return {
|
|
status: "missing",
|
|
published: false,
|
|
imageTag: "unknown",
|
|
digest: "unknown",
|
|
digestStatus: "missing",
|
|
distFreshnessStatus: "missing"
|
|
};
|
|
}
|
|
const digest = sanitizeString(service.digest);
|
|
return {
|
|
status: sanitizeString(service.status),
|
|
published: service.status === "published",
|
|
imageTag: sanitizeString(service.imageTag),
|
|
digest,
|
|
digestStatus: digestPattern.test(digest) ? "sha256" : digest === "not_published" ? "not_published" : "invalid",
|
|
distFreshnessStatus: service.distFreshness?.status ? sanitizeString(service.distFreshness.status) : "missing"
|
|
};
|
|
}
|
|
|
|
function countDigests(services) {
|
|
const counts = {
|
|
sha256: 0,
|
|
notPublished: 0,
|
|
invalid: 0
|
|
};
|
|
for (const service of services) {
|
|
const digest = service?.digest;
|
|
if (digestPattern.test(String(digest ?? ""))) {
|
|
counts.sha256 += 1;
|
|
} else if (digest === "not_published" || digest == null) {
|
|
counts.notPublished += 1;
|
|
} else {
|
|
counts.invalid += 1;
|
|
}
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
async function observeHealthLive(endpoint, { evidenceSource, timeoutMs }) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: "GET",
|
|
headers: { accept: "application/json" },
|
|
signal: controller.signal
|
|
});
|
|
const text = await response.text();
|
|
const body = parseJson(text);
|
|
if (!response.ok || !body) {
|
|
return notObservedRuntimeIdentity(
|
|
endpoint,
|
|
body ? `HTTP ${response.status}` : `HTTP ${response.status}; response was not JSON`,
|
|
evidenceSource,
|
|
response.status
|
|
);
|
|
}
|
|
return observedRuntimeIdentity(endpoint, body, evidenceSource, response.status);
|
|
} catch (error) {
|
|
return notObservedRuntimeIdentity(
|
|
endpoint,
|
|
error instanceof Error ? error.message : String(error),
|
|
evidenceSource
|
|
);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function observedRuntimeIdentity(endpoint, body, evidenceSource, httpStatus) {
|
|
const commitId = body.commit?.id ?? body.commitId ?? body.revision ?? body.runtime?.commitId;
|
|
const imageTag = body.image?.tag ?? body.imageTag ?? body.runtime?.imageTag;
|
|
const imageDigest = body.image?.digest ?? body.imageDigest ?? body.runtime?.imageDigest;
|
|
return {
|
|
status: "observed",
|
|
observed: true,
|
|
source: "health-live",
|
|
evidenceSource,
|
|
endpoint,
|
|
serviceId: sanitizeString(body.serviceId),
|
|
environment: sanitizeString(body.environment),
|
|
healthStatus: sanitizeString(body.status),
|
|
artifactKind: sanitizeString(body.artifactKind),
|
|
commitId: normalizeCommit(commitId),
|
|
shortCommitId: shortCommit(commitId),
|
|
commitSource: sanitizeString(body.commit?.source ?? body.commitSource ?? (body.revision ? "runtime-env" : "unknown")),
|
|
imageTag: sanitizeString(imageTag),
|
|
imageDigest: sanitizeString(imageDigest),
|
|
observedAt: sanitizeTimestamp(body.observedAt),
|
|
httpStatus,
|
|
reason: null
|
|
};
|
|
}
|
|
|
|
function notObservedRuntimeIdentity(endpoint, reason, evidenceSource, httpStatus = null) {
|
|
return {
|
|
status: "not_observed",
|
|
observed: false,
|
|
source: "health-live",
|
|
evidenceSource,
|
|
endpoint,
|
|
serviceId: "not_observed",
|
|
environment: "not_observed",
|
|
healthStatus: "not_observed",
|
|
artifactKind: "not_observed",
|
|
commitId: "unknown",
|
|
shortCommitId: "unknown",
|
|
commitSource: "not_observed",
|
|
imageTag: "not_observed",
|
|
imageDigest: "not_observed",
|
|
observedAt: new Date().toISOString(),
|
|
httpStatus,
|
|
reason: oneLine(reason)
|
|
};
|
|
}
|
|
|
|
function normalizeRuntimeIdentity(identity, evidenceSource) {
|
|
if (!identity || identity.status !== "observed") {
|
|
return notObservedRuntimeIdentity(identity?.endpoint ?? "unknown", identity?.reason ?? "runtime identity was not observed", evidenceSource);
|
|
}
|
|
return {
|
|
...identity,
|
|
observed: true,
|
|
evidenceSource: identity.evidenceSource ?? evidenceSource,
|
|
commitId: normalizeCommit(identity.commitId ?? identity.revision),
|
|
shortCommitId: shortCommit(identity.commitId ?? identity.revision),
|
|
serviceId: sanitizeString(identity.serviceId),
|
|
environment: sanitizeString(identity.environment),
|
|
healthStatus: sanitizeString(identity.healthStatus ?? identity.status)
|
|
};
|
|
}
|
|
|
|
function missingArtifactReport() {
|
|
return {
|
|
status: "missing",
|
|
sourceCommitId: "unknown",
|
|
shortCommitId: "unknown",
|
|
registryPrefix: "unknown",
|
|
serviceCount: 0,
|
|
requiredServiceCount: 0,
|
|
publishedRequiredCount: 0,
|
|
allRequiredServicesPublished: false,
|
|
publishVerified: false,
|
|
digestCounts: { sha256: 0, notPublished: 0, invalid: 0 },
|
|
cloudWeb: summarizeArtifactService(null)
|
|
};
|
|
}
|
|
|
|
function missingArtifactCatalog() {
|
|
return {
|
|
artifactState: "missing",
|
|
commitId: "unknown",
|
|
shortCommitId: "unknown",
|
|
ciPublished: false,
|
|
registryVerified: false,
|
|
serviceCount: 0,
|
|
requiredServiceCount: 0,
|
|
publishedRequiredCount: 0,
|
|
digestCounts: { sha256: 0, notPublished: 0, invalid: 0 },
|
|
publishVerified: false
|
|
};
|
|
}
|
|
|
|
function emptyConvergence(state) {
|
|
return {
|
|
state,
|
|
comparableFields: 0,
|
|
matchingTargetFields: 0,
|
|
pendingTargetFields: 0
|
|
};
|
|
}
|
|
|
|
function addCheck(checks, { id, pass, type, summary, blockedSummary = null, nextTask }) {
|
|
checks.push({
|
|
id,
|
|
status: pass ? "pass" : "blocked",
|
|
type,
|
|
summary: pass ? summary : (blockedSummary ?? summary),
|
|
nextTask
|
|
});
|
|
}
|
|
|
|
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 >= minCodeAgentBackendTimeoutMs;
|
|
const transportWaitsForBackend = Number.isInteger(frontendTimeoutMs) && Number.isInteger(backendTimeoutMs) && frontendTimeoutMs > backendTimeoutMs;
|
|
return {
|
|
status: frontendReady && backendReady && transportWaitsForBackend ? "pass" : "blocked",
|
|
frontendTimeoutMs,
|
|
backendTimeoutMs,
|
|
requiredFrontendTimeoutMs: minCodeAgentFrontendTimeoutMs,
|
|
requiredBackendTimeoutMs: minCodeAgentBackendTimeoutMs,
|
|
transportWaitsForBackend,
|
|
summary: frontendReady && backendReady && transportWaitsForBackend
|
|
? "Code Agent browser/backend timeout budget is ready: browser transport waits for the structured backend result."
|
|
: "Code Agent timeout layering is unsafe; long requests can be misclassified before cloud-api returns a structured result."
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async function readOptionalJson(repoRoot, relativePath) {
|
|
try {
|
|
return await readJson(repoRoot, relativePath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveTargetCommit(repoRoot, ref) {
|
|
try {
|
|
return execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
timeout: 5000
|
|
}).trim();
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
targetRef: defaultTargetRef,
|
|
artifactReport: defaultArtifactReport,
|
|
artifactCatalog: defaultArtifactCatalog,
|
|
runtimeReport: defaultRuntimeReport,
|
|
edgeReport: defaultEdgeReport,
|
|
report: defaultOutputReport,
|
|
timeoutMs: 5000,
|
|
live: false,
|
|
writeReport: false,
|
|
check: false,
|
|
expectBlocked: false,
|
|
pretty: false,
|
|
help: false
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--target-ref") {
|
|
args.targetRef = requireValue(argv, index, arg);
|
|
index += 1;
|
|
} else if (arg === "--artifact-report") {
|
|
args.artifactReport = requireValue(argv, index, arg);
|
|
index += 1;
|
|
} else if (arg === "--artifact-catalog") {
|
|
args.artifactCatalog = requireValue(argv, index, arg);
|
|
index += 1;
|
|
} 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;
|
|
} else if (arg === "--timeout-ms") {
|
|
args.timeoutMs = Number.parseInt(requireValue(argv, index, arg), 10);
|
|
index += 1;
|
|
} else if (arg === "--write-report") {
|
|
args.writeReport = true;
|
|
} else if (arg === "--no-report") {
|
|
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") {
|
|
args.expectBlocked = true;
|
|
} else if (arg === "--pretty") {
|
|
args.pretty = true;
|
|
} else if (arg === "--help" || arg === "-h") {
|
|
args.help = true;
|
|
} else {
|
|
throw new Error(`unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (!Number.isInteger(args.timeoutMs) || args.timeoutMs <= 0) {
|
|
throw new Error("--timeout-ms must be a positive integer");
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function requireValue(argv, index, arg) {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`${arg} requires a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
`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, 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 report.commanderDecision;
|
|
}
|
|
|
|
function sanitizeString(value) {
|
|
return typeof value === "string" && value.trim().length > 0 ? oneLine(value) : "unknown";
|
|
}
|
|
|
|
function sanitizeTimestamp(value) {
|
|
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : new Date().toISOString();
|
|
}
|
|
|
|
function parseJson(text) {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function oneLine(value) {
|
|
return String(value ?? "unknown").replace(/\s+/gu, " ").trim().slice(0, 240) || "unknown";
|
|
}
|