import { execFileSync } from "node:child_process"; import { readdirSync, statSync } from "node:fs"; 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 { buildDesiredStatePlan } from "./deploy-desired-state-plan.mjs"; import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs"; import { readStructuredFile } from "./structured-config.mjs"; const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const cloudWebSourceRoot = "web/hwlab-cloud-web/src"; function bunExecutable() { return process.env.HWLAB_BUN_BINARY || process.env.BUN_BINARY || "bun"; } function inspectCloudWebDistFreshness(webRoot) { try { const stdout = execFileSync(bunExecutable(), ["web/hwlab-cloud-web/scripts/dist-contract.ts", "--inspect", "--root", webRoot], { cwd: defaultRepoRoot, encoding: "utf8" }); return JSON.parse(stdout); } catch (error) { return { status: "blocked", distPath: "web/hwlab-cloud-web/dist", buildCommand: "cd web/hwlab-cloud-web && bun run build", freshnessCommand: "cd web/hwlab-cloud-web && bun run build", files: [], mismatches: ["dist-contract-inspect"], error: error instanceof Error ? error.message : String(error) }; } } async function readCloudWebAppSource(repoRoot) { const files = collectCloudWebSourceFiles(path.join(repoRoot, cloudWebSourceRoot)); const parts = await Promise.all(files.map((file) => readFile(file, "utf8"))); return parts.join("\n"); } function collectCloudWebSourceFiles(dir) { const files = []; for (const entry of readdirSync(dir)) { const full = path.join(dir, entry); const stat = statSync(full); if (stat.isDirectory()) files.push(...collectCloudWebSourceFiles(full)); else if (/\.(ts|tsx|css)$/u.test(full)) files.push(full); } return files.sort(); } const defaultArtifactReport = tempReportPath("dev-artifacts.json"); const defaultArtifactCatalog = "deploy/artifact-catalog.dev.json"; const defaultRuntimeReport = tempReportPath("dev-cloud-workbench-live.json"); const defaultEdgeReport = tempReportPath("dev-edge-health.json"); const defaultOutputReport = tempReportPath("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 = 1200000; const minCodeAgentBackendTimeoutMs = 1200000; 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: "Do not refresh source desired-state. Let node Tekton promotion write artifact catalog identity to the configured GitOps branch, then verify Argo/runtime revision." }); 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 ${defaultArtifactReport} 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.yaml, 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 as a read-only review, then rely on node Tekton promotion for catalog writes.` }); 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 GitOps promotion, verify Argo DEV rollout 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/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/reference/node-gitops-cicd.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 = ensureNotRepoReportsPath(repoRoot, args.report, "--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: "bun run --cwd web/hwlab-cloud-web build", checkCommand: "bun run --cwd web/hwlab-cloud-web check", 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, cloudApiEntrySource] = await Promise.all([ readCloudWebAppSource(repoRoot), readFile(path.join(repoRoot, "cmd/hwlab-cloud-api/main.ts"), "utf8") ]); const frontendTimeoutMs = numericSourceConstant(appSource, "DEFAULT_CODE_AGENT_TIMEOUT_MS"); const backendTimeoutMs = parseTimeoutFallbackMs(cloudApiEntrySource, "HWLAB_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); } function parseTimeoutFallbackMs(source, envName) { const escaped = envName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); const match = source.match(new RegExp(`parseTimeout\\(process\\.env\\.${escaped},\\s*([0-9_]+)`, "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) => /\.(?:ts|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.startsWith(`${cloudWebSourceRoot}/`)) && matches.some((file) => file === "internal/cloud/m3-io-control.ts" || 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 [ "bun run --cwd web/hwlab-cloud-web build", "bun run --cwd web/hwlab-cloud-web check", "node scripts/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/gitops-render.mjs --no-write", guardCommand ]; } if (blockedReasons.length > 0) { return [ "node scripts/dev-edge-health-smoke.mjs --live", "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", guardCommand ]; } return [ "node scripts/gitops-render.mjs --no-write", "node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --live --no-report" ]; } async function readJson(repoRoot, relativePath) { return readStructuredFile(repoRoot, relativePath); } 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 === "--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} [--report ${defaultOutputReportPath}] [--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"; }