From 6eef6351f41761ee79d20e3d92458b6c764d1883 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 18:24:32 +0000 Subject: [PATCH] feat: show live HWLAB build metadata --- cmd/hwlab-agent-mgr/main.mjs | 18 +- cmd/hwlab-agent-worker/main.mjs | 4 + internal/build-metadata.mjs | 69 ++++ internal/cloud/server.mjs | 302 +++++++++++++++++- internal/cloud/server.test.mjs | 112 +++++++ internal/dev-entrypoint/http.mjs | 21 +- internal/sim/model.mjs | 7 + package.json | 2 +- scripts/dev-artifact-publish.mjs | 105 +++++- scripts/refresh-artifact-catalog.mjs | 11 + scripts/src/dev-cloud-workbench-smoke-lib.mjs | 167 +++++++++- scripts/src/m3-io-control-e2e.mjs | 1 + web/hwlab-cloud-web/app.mjs | 135 +++++++- web/hwlab-cloud-web/index.html | 6 + web/hwlab-cloud-web/scripts/check.mjs | 8 + web/hwlab-cloud-web/styles.css | 96 +++++- 16 files changed, 1018 insertions(+), 46 deletions(-) create mode 100644 internal/build-metadata.mjs diff --git a/cmd/hwlab-agent-mgr/main.mjs b/cmd/hwlab-agent-mgr/main.mjs index 40c19963..25f1ab6d 100644 --- a/cmd/hwlab-agent-mgr/main.mjs +++ b/cmd/hwlab-agent-mgr/main.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { createServer } from "node:http"; +import { buildMetadataFromEnv } from "../../internal/build-metadata.mjs"; import { AGENT_MGR_SERVICE_ID, createSkillsManifest @@ -155,6 +156,9 @@ function createHealthResponse(opts) { return { serviceId: AGENT_MGR_SERVICE_ID, ok: true, + status: health.status, + environment: process.env.HWLAB_ENVIRONMENT || "dev", + ...buildMetadataFromEnv(process.env, { serviceId: AGENT_MGR_SERVICE_ID }), health, skillsManifest: managerSkills.status === "ready" ? createSkillsManifest({ commitId: managerSkills.commitId, version: managerSkills.version }) @@ -171,19 +175,7 @@ async function serveHealth(opts) { const server = createServer((request, response) => { const url = new URL(request.url || "/", "http://hwlab-agent-mgr.local"); if (url.pathname === "/health" || url.pathname === "/health/live") { - sendJson(response, 200, { - ...createHealthResponse(opts), - environment: process.env.HWLAB_ENVIRONMENT || "dev", - commit: { - id: process.env.HWLAB_COMMIT_ID || "unknown", - source: "runtime-env" - }, - image: { - reference: process.env.HWLAB_IMAGE || "unknown", - tag: process.env.HWLAB_IMAGE_TAG || "unknown", - digest: process.env.HWLAB_IMAGE_DIGEST || "unknown" - } - }); + sendJson(response, 200, createHealthResponse(opts)); return; } if (url.pathname === "/" || url.pathname === "/help") { diff --git a/cmd/hwlab-agent-worker/main.mjs b/cmd/hwlab-agent-worker/main.mjs index dbf53465..fa052f51 100644 --- a/cmd/hwlab-agent-worker/main.mjs +++ b/cmd/hwlab-agent-worker/main.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { buildMetadataFromEnv } from "../../internal/build-metadata.mjs"; import { AGENT_WORKER_SERVICE_ID } from "../../internal/agent/index.mjs"; import { assessSkillsInjection, @@ -35,6 +36,9 @@ async function dispatch(name, opts) { return { serviceId: AGENT_WORKER_SERVICE_ID, ok: true, + status: "ok", + environment: process.env.HWLAB_ENVIRONMENT || "dev", + ...buildMetadataFromEnv(process.env, { serviceId: AGENT_WORKER_SERVICE_ID }), runtimeMode: "local-dry-run", skills: assessSkillsInjection({ skillCommitId: opts.skillCommitId, diff --git a/internal/build-metadata.mjs b/internal/build-metadata.mjs new file mode 100644 index 00000000..b76a6639 --- /dev/null +++ b/internal/build-metadata.mjs @@ -0,0 +1,69 @@ +const unknownValue = "unknown"; + +export function normalizeIsoTimestamp(value) { + const source = String(value ?? "").trim(); + if (!source) return null; + if (/^\d+$/u.test(source)) { + const seconds = Number.parseInt(source, 10); + if (Number.isSafeInteger(seconds)) { + const date = new Date(seconds * 1000); + if (Number.isFinite(date.getTime())) return date.toISOString(); + } + } + const date = new Date(source); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; +} + +export function imageTagFromReference(imageReference) { + const source = String(imageReference ?? "").trim(); + const slashIndex = source.lastIndexOf("/"); + const colonIndex = source.lastIndexOf(":"); + if (colonIndex <= slashIndex) return null; + return source.slice(colonIndex + 1) || null; +} + +export function shortRevision(value) { + const source = String(value ?? "").trim(); + return source.length >= 7 ? source.slice(0, 7) : source || unknownValue; +} + +export function buildMetadataFromEnv(env = process.env, { serviceId = "hwlab-unknown", fallbackImageRepository = null } = {}) { + const commitId = firstNonEmpty(env.HWLAB_COMMIT_ID, env.HWLAB_GIT_SHA, env.GIT_COMMIT) ?? unknownValue; + const revision = firstNonEmpty(env.HWLAB_REVISION, commitId) ?? unknownValue; + const fallbackRepository = fallbackImageRepository ?? `ghcr.io/pikastech/${serviceId}`; + const imageReference = firstNonEmpty(env.HWLAB_IMAGE, `${fallbackRepository}:${shortRevision(commitId)}`) ?? unknownValue; + const imageTag = firstNonEmpty(env.HWLAB_IMAGE_TAG, imageTagFromReference(imageReference), shortRevision(commitId)) ?? unknownValue; + const buildCreatedAt = normalizeIsoTimestamp( + firstNonEmpty(env.HWLAB_BUILD_CREATED_AT, env.HWLAB_IMAGE_CREATED_AT, env.SOURCE_DATE_EPOCH, env.BUILD_DATE) + ); + const buildSource = firstNonEmpty(env.HWLAB_BUILD_SOURCE, "runtime-env") ?? "runtime-env"; + + return { + revision, + commit: { + id: commitId, + source: buildSource + }, + image: { + reference: imageReference, + tag: imageTag, + digest: firstNonEmpty(env.HWLAB_IMAGE_DIGEST, unknownValue) ?? unknownValue + }, + build: { + createdAt: buildCreatedAt, + source: buildSource, + provenance: firstNonEmpty(env.HWLAB_BUILD_PROVENANCE, null), + metadataSource: buildCreatedAt ? "runtime-env:HWLAB_BUILD_CREATED_AT" : "unavailable", + unavailableReason: buildCreatedAt ? null : "HWLAB_BUILD_CREATED_AT missing from runtime environment" + } + }; +} + +function firstNonEmpty(...values) { + for (const value of values) { + if (value === null || value === undefined) continue; + const text = String(value).trim(); + if (text) return text; + } + return null; +} diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index b2f3bf8a..711cafc9 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -1,6 +1,11 @@ import { createServer } from "node:http"; import { randomUUID } from "node:crypto"; +import { + buildMetadataFromEnv, + imageTagFromReference, + normalizeIsoTimestamp +} from "../build-metadata.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES } from "../protocol/index.mjs"; import { AUDIT_FIELD_NAMES, @@ -35,6 +40,23 @@ import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs"; import { createGatewayDemoRegistry } from "./gateway-demo-registry.mjs"; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; +const LIVE_BUILD_TIMEOUT_MS = 900; +const LIVE_BUILD_SERVICE_ENDPOINTS = Object.freeze([ + Object.freeze({ serviceId: "hwlab-cloud-api", serviceName: "hwlab-cloud-api", urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }), + Object.freeze({ serviceId: "hwlab-cloud-web", serviceName: "hwlab-cloud-web", urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }), + Object.freeze({ serviceId: "hwlab-agent-mgr", serviceName: "hwlab-agent-mgr", urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }), + Object.freeze({ serviceId: "hwlab-agent-worker", serviceName: "hwlab-agent-worker", urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411", activation: "suspended-job-template" }), + Object.freeze({ serviceId: "hwlab-gateway", serviceName: "hwlab-gateway", urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001", activation: "manual" }), + Object.freeze({ serviceId: "hwlab-gateway-simu", serviceName: "hwlab-gateway-simu", urlEnv: "HWLAB_GATEWAY_SIMU_URL", defaultUrl: "http://hwlab-gateway-simu.hwlab-dev.svc.cluster.local:7101" }), + Object.freeze({ serviceId: "hwlab-box-simu", serviceName: "hwlab-box-simu", urlEnv: "HWLAB_BOX_SIMU_URL", defaultUrl: "http://hwlab-box-simu.hwlab-dev.svc.cluster.local:7201" }), + Object.freeze({ serviceId: "hwlab-patch-panel", serviceName: "hwlab-patch-panel", urlEnv: "HWLAB_PATCH_PANEL_URL", defaultUrl: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" }), + Object.freeze({ serviceId: "hwlab-router", serviceName: "hwlab-router", urlEnv: "HWLAB_ROUTER_URL", defaultUrl: "http://hwlab-router.hwlab-dev.svc.cluster.local:7401" }), + Object.freeze({ serviceId: "hwlab-tunnel-client", serviceName: "hwlab-tunnel-client", urlEnv: "HWLAB_TUNNEL_CLIENT_URL", defaultUrl: "http://hwlab-tunnel-client.hwlab-dev.svc.cluster.local:7402" }), + Object.freeze({ serviceId: "hwlab-frpc", serviceName: "hwlab-frpc", externalImage: true, defaultImage: "127.0.0.1:5000/hwlab/frpc:v0.68.1" }), + Object.freeze({ serviceId: "hwlab-edge-proxy", serviceName: "hwlab-edge-proxy", urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }), + Object.freeze({ serviceId: "hwlab-cli", serviceName: "hwlab-cli", urlEnv: "HWLAB_CLI_URL", defaultUrl: "http://hwlab-cli.hwlab-dev.svc.cluster.local:7501", activation: "suspended-job-template" }), + Object.freeze({ serviceId: "hwlab-agent-skills", serviceName: "hwlab-agent-skills", urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" }) +]); export function createCloudApiServer(options = {}) { const env = options.env ?? process.env; @@ -61,9 +83,10 @@ export function createCloudApiServer(options = {}) { export async function buildHealthPayload(options = {}) { const serviceId = CLOUD_API_SERVICE_ID; const env = options.env ?? process.env; - const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown"; - const imageReference = process.env.HWLAB_IMAGE || "unknown"; - const imageTag = process.env.HWLAB_IMAGE_TAG || commitId.slice(0, 7) || "unknown"; + const metadata = buildMetadataFromEnv(env, { + serviceId, + fallbackImageRepository: "ghcr.io/pikastech/hwlab-cloud-api" + }); const dbProbe = await buildDbRuntimeReadiness(env, options.dbProbe); const codeAgent = describeCodeAgentAvailability(env, options); const runtime = await runtimeReadiness(options.runtimeStore ?? createConfiguredCloudRuntimeStore({ ...options, env })); @@ -75,6 +98,7 @@ export async function buildHealthPayload(options = {}) { environment: ENVIRONMENT_DEV, status: readiness.status, ready: readiness.ready, + revision: metadata.revision, readiness, blockers: readiness.blockers, blockerCodes: readiness.blockerCodes, @@ -84,16 +108,10 @@ export async function buildHealthPayload(options = {}) { healthPath: "/health", livePath: "/health/live" }, - commit: { - id: commitId, - source: process.env.HWLAB_BUILD_SOURCE || "runtime-env" - }, - image: { - reference: imageReference, - tag: imageTag, - digest: process.env.HWLAB_IMAGE_DIGEST || "unknown" - }, - endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT, + commit: metadata.commit, + image: metadata.image, + build: metadata.build, + endpoint: env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT, observedAt: new Date().toISOString(), db, codeAgent, @@ -218,6 +236,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "GET" && url.pathname === "/v1/live-builds") { + sendJson(response, 200, await buildLiveBuildsPayload(options)); + return; + } + if (request.method === "GET" && url.pathname === "/v1/gateway/sessions") { sendJson(response, 200, options.gatewayRegistry.describe()); return; @@ -311,6 +334,259 @@ async function handleRestAdapter(request, response, url, options) { sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse); } +async function buildLiveBuildsPayload(options = {}) { + const env = options.env ?? process.env; + const services = await Promise.all( + LIVE_BUILD_SERVICE_ENDPOINTS.map((service) => observeLiveBuildService(service, { ...options, env })) + ); + const hwlabServices = services.filter((service) => service.kind === "hwlab"); + const latest = [...hwlabServices] + .filter((service) => service.build.createdAt) + .sort((left, right) => Date.parse(right.build.createdAt) - Date.parse(left.build.createdAt))[0] ?? null; + + return { + serviceId: CLOUD_API_SERVICE_ID, + environment: ENVIRONMENT_DEV, + status: "ok", + contractVersion: "live-builds-v1", + observedAt: new Date().toISOString(), + source: { + kind: "live-health", + route: "/v1/live-builds", + healthPath: "/health/live", + note: "Each row is read from the current live service health payload or marked unavailable/external; observedAt is not used as build time." + }, + latest, + counts: { + total: services.length, + hwlab: hwlabServices.length, + withBuildTime: hwlabServices.filter((service) => Boolean(service.build.createdAt)).length, + unavailable: hwlabServices.filter((service) => service.build.createdAt === null).length, + external: services.filter((service) => service.kind === "external").length + }, + services + }; +} + +async function observeLiveBuildService(service, options) { + if (service.externalImage) { + return externalLiveBuildRecord(service, { + healthUrl: null, + httpStatus: null, + imageReference: service.defaultImage ?? "unknown", + reason: "外部镜像或非 HWLAB 构建产物" + }); + } + + if (service.serviceId === CLOUD_API_SERVICE_ID) { + const health = await buildHealthPayload(options); + return liveBuildRecordFromHealth(service, { + ok: true, + status: 200, + url: "/health/live", + payload: health + }); + } + + const baseUrl = String(options.env?.[service.urlEnv] || service.defaultUrl || "").replace(/\/+$/u, ""); + if (!baseUrl) { + return unavailableLiveBuildRecord(service, { + reasonCode: "service_url_unavailable", + reason: `${service.serviceId} 没有配置 live health URL` + }); + } + + const healthPath = service.healthPath ?? "/health/live"; + return liveBuildRecordFromHealth( + service, + await fetchServiceHealth(`${baseUrl}${healthPath}`, options.fetchImpl ?? fetch) + ); +} + +async function fetchServiceHealth(url, fetchImpl) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LIVE_BUILD_TIMEOUT_MS); + try { + const response = await fetchImpl(url, { + headers: { Accept: "application/json" }, + signal: controller.signal + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + return { + ok: false, + status: response.status, + url, + reasonCode: "invalid_json", + reason: `${url} returned non-JSON health payload` + }; + } + return { + ok: response.ok, + status: response.status, + url, + payload, + reasonCode: response.ok ? null : "http_error", + reason: response.ok ? null : `${url} returned HTTP ${response.status}` + }; + } catch (error) { + const timedOut = error?.name === "AbortError"; + return { + ok: false, + status: null, + url, + reasonCode: timedOut ? "timeout" : "request_failed", + reason: timedOut ? `${url} timed out after ${LIVE_BUILD_TIMEOUT_MS}ms` : error.message + }; + } finally { + clearTimeout(timeout); + } +} + +function liveBuildRecordFromHealth(service, result) { + if (!result.ok) { + return unavailableLiveBuildRecord(service, { + healthUrl: result.url, + httpStatus: result.status, + reasonCode: result.reasonCode ?? "health_unavailable", + reason: healthUnavailableReason(service, result.reason ?? `${service.serviceId} health unavailable`) + }); + } + + const payload = result.payload ?? {}; + const payloadServiceId = payload.serviceId ?? service.serviceId; + if (!String(payloadServiceId).startsWith("hwlab-")) { + return externalLiveBuildRecord(service, { + healthUrl: result.url, + httpStatus: result.status, + imageReference: payload.image?.reference ?? payload.image ?? "unknown", + imageTag: payload.image?.tag ?? null, + imageDigest: payload.image?.digest ?? "unknown", + commitId: payload.commit?.id ?? payload.revision ?? payload.version ?? "unknown", + commitSource: payload.commit?.source ?? "external-health", + revision: payload.revision ?? payload.commit?.id ?? payload.version ?? "unknown", + reason: "外部镜像或非 HWLAB 构建产物" + }); + } + + const imageReference = payload.image?.reference ?? payload.image ?? "unknown"; + const commitId = payload.commit?.id ?? payload.revision ?? "unknown"; + const createdAt = normalizeHealthBuildTime(payload); + return { + serviceId: service.serviceId, + name: service.serviceName, + kind: "hwlab", + status: createdAt ? "ok" : "build_time_unavailable", + healthUrl: result.url, + httpStatus: result.status, + build: { + createdAt, + source: payload.build?.source ?? payload.commit?.source ?? "health-payload", + metadataSource: payload.build?.metadataSource ?? (createdAt ? "health:build.createdAt" : "unavailable"), + unavailableReason: createdAt ? null : "构建时间不可用:health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt" + }, + image: { + reference: imageReference, + tag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? "unknown", + digest: payload.image?.digest ?? "unknown" + }, + commit: { + id: commitId, + source: payload.commit?.source ?? "health-payload" + }, + revision: payload.revision ?? commitId + }; +} + +function unavailableLiveBuildRecord(service, { healthUrl = null, httpStatus = null, reasonCode, reason }) { + return { + serviceId: service.serviceId, + name: service.serviceName, + kind: "hwlab", + status: "unavailable", + healthUrl, + httpStatus, + build: { + createdAt: null, + metadataSource: "unavailable", + unavailableReason: `构建时间不可用:${reason}` + }, + image: { + reference: "unknown", + tag: "unknown", + digest: "unknown" + }, + commit: { + id: "unknown", + source: "unavailable" + }, + revision: "unknown", + error: { + code: reasonCode, + message: reason + } + }; +} + +function externalLiveBuildRecord(service, { + healthUrl = null, + httpStatus = null, + imageReference = "unknown", + imageTag = null, + imageDigest = "unknown", + commitId = "unknown", + commitSource = "external-image", + revision = commitId, + reason +}) { + return { + serviceId: service.serviceId, + name: service.serviceName, + kind: "external", + status: "external", + healthUrl, + httpStatus, + build: { + createdAt: null, + metadataSource: "external-image", + unavailableReason: reason + }, + image: { + reference: imageReference, + tag: imageTag ?? imageTagFromReference(imageReference) ?? "unknown", + digest: imageDigest + }, + commit: { + id: commitId, + source: commitSource + }, + revision + }; +} + +function healthUnavailableReason(service, reason) { + if (service.activation === "suspended-job-template") { + return `${service.serviceId} 是 suspended Job template,当前 live desired replicas=0,无可读取的常驻 /health/live`; + } + if (service.activation === "manual") { + return `${service.serviceId} 是手动激活服务,当前 live health 不可用:${reason}`; + } + return reason; +} + +function normalizeHealthBuildTime(payload) { + return normalizeIsoTimestamp( + payload?.build?.createdAt ?? + payload?.image?.createdAt ?? + payload?.buildCreatedAt ?? + payload?.metadata?.buildCreatedAt ?? + null + ); +} + async function handleGatewayPollHttp(request, response, options) { const body = await readBody(request, options.bodyLimitBytes); let params = {}; diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 3445a21a..aa64a1c1 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -55,6 +55,9 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.status, "degraded"); assert.equal(healthPayload.commit.id.length > 0, true); assert.equal(healthPayload.image.reference.length > 0, true); + assert.equal(typeof healthPayload.revision, "string"); + assert.equal(typeof healthPayload.build, "object"); + assert.equal(Object.hasOwn(healthPayload.build, "createdAt"), true); assert.equal(healthPayload.service.id, "hwlab-cloud-api"); assert.equal(healthPayload.db.status, "blocked"); assert.equal(healthPayload.db.connected, false); @@ -138,6 +141,7 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthLivePayload.serviceId, "hwlab-cloud-api"); assert.equal(healthLivePayload.status, "degraded"); assert.equal(healthLivePayload.commit.id.length > 0, true); + assert.equal(typeof healthLivePayload.build, "object"); assert.equal(healthLivePayload.db.ready, false); assert.equal(healthLivePayload.codeAgent.status, "partial"); assert.equal(healthLivePayload.codeAgent.ready, false); @@ -168,6 +172,114 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { } }); +test("cloud api aggregates live HWLAB image build times from health payloads", async () => { + const healthByPath = new Map([ + ["/hwlab-cloud-web/health/live", { + serviceId: "hwlab-cloud-web", + status: "ok", + revision: "webabcdef123456", + commit: { id: "webabcdef123456", source: "test" }, + image: { + reference: "127.0.0.1:5000/hwlab/hwlab-cloud-web:webabcd", + tag: "webabcd", + digest: "sha256:" + "1".repeat(64) + }, + build: { + createdAt: "2026-05-23T02:10:00.000Z", + metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT" + } + }], + ["/hwlab-agent-mgr/health/live", { + serviceId: "hwlab-agent-mgr", + status: "ok", + revision: "mgrabcdef123456", + commit: { id: "mgrabcdef123456", source: "test" }, + image: { + reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:mgrabcd", + tag: "mgrabcd", + digest: "sha256:" + "2".repeat(64) + }, + build: { + createdAt: "2026-05-23T04:20:00.000Z", + metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT" + } + }], + ["/external/health", { + serviceId: "postgres", + status: "ok", + image: { + reference: "postgres:16", + tag: "16" + } + }] + ]); + + const server = createCloudApiServer({ + env: { + PATH: "", + HWLAB_COMMIT_ID: "apiabcdef123456", + HWLAB_IMAGE: "127.0.0.1:5000/hwlab/hwlab-cloud-api:apiabcd", + HWLAB_IMAGE_TAG: "apiabcd", + HWLAB_BUILD_CREATED_AT: "2026-05-23T01:00:00.000Z", + HWLAB_CLOUD_WEB_SERVICE_URL: "http://live.test/hwlab-cloud-web", + HWLAB_AGENT_MGR_URL: "http://live.test/hwlab-agent-mgr", + HWLAB_AGENT_WORKER_URL: "http://live.test/missing-agent-worker", + HWLAB_GATEWAY_URL: "http://live.test/missing-gateway", + HWLAB_GATEWAY_SIMU_URL: "http://live.test/missing-gateway-simu", + HWLAB_BOX_SIMU_URL: "http://live.test/missing-box-simu", + HWLAB_PATCH_PANEL_URL: "http://live.test/missing-patch-panel", + HWLAB_ROUTER_URL: "http://live.test/missing-router", + HWLAB_TUNNEL_CLIENT_URL: "http://live.test/missing-tunnel-client", + HWLAB_EDGE_PROXY_URL: "http://live.test/external", + HWLAB_CLI_URL: "http://live.test/missing-cli", + HWLAB_AGENT_SKILLS_URL: "http://live.test/missing-agent-skills" + }, + fetchImpl: async (url) => { + const path = new URL(url).pathname; + const payload = healthByPath.get(path); + if (!payload) { + return { + ok: false, + status: 503, + async text() { + return JSON.stringify({ error: "offline" }); + } + }; + } + return { + ok: true, + status: 200, + async text() { + return JSON.stringify(payload); + } + }; + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/live-builds`); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.contractVersion, "live-builds-v1"); + assert.equal(payload.latest.serviceId, "hwlab-agent-mgr"); + assert.equal(payload.latest.build.createdAt, "2026-05-23T04:20:00.000Z"); + assert.equal(payload.latest.image.tag, "mgrabcd"); + assert.equal(payload.latest.commit.id, "mgrabcdef123456"); + assert.equal(payload.counts.total, 14); + assert.equal(payload.counts.external, 2); + assert.ok(payload.counts.withBuildTime >= 3); + assert.ok(payload.services.some((service) => service.serviceId === "hwlab-agent-worker" && /suspended Job template/u.test(service.build.unavailableReason))); + assert.ok(payload.services.some((service) => service.kind === "external" && service.serviceId === "hwlab-edge-proxy" && service.healthUrl.endsWith("/health"))); + assert.ok(payload.services.some((service) => service.kind === "external" && service.serviceId === "hwlab-frpc" && service.image.tag === "v0.68.1")); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api health reports DB env presence and live connection classification without leaking values", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; diff --git a/internal/dev-entrypoint/http.mjs b/internal/dev-entrypoint/http.mjs index 8e6a31ce..e8d7278f 100644 --- a/internal/dev-entrypoint/http.mjs +++ b/internal/dev-entrypoint/http.mjs @@ -1,5 +1,6 @@ import { createServer, request as httpRequest } from "node:http"; +import { buildMetadataFromEnv } from "../build-metadata.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../protocol/index.mjs"; const DEFAULT_PROXY_TIMEOUT_MS = 150000; @@ -21,29 +22,25 @@ export function resolveHostPort({ listenEnv, hostEnv, portEnv, fallbackPort }) { } export function healthPayload({ serviceId, role, details = {} }) { - const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown"; - const imageReference = process.env.HWLAB_IMAGE || `ghcr.io/pikastech/${serviceId}:${commitId.slice(0, 7) || "unknown"}`; - const imageTag = process.env.HWLAB_IMAGE_TAG || commitId.slice(0, 7) || "unknown"; + const metadata = buildMetadataFromEnv(process.env, { + serviceId, + fallbackImageRepository: `ghcr.io/pikastech/${serviceId}` + }); return { serviceId, environment: ENVIRONMENT_DEV, status: "ok", + revision: metadata.revision, service: { id: serviceId, role, healthPath: "/health", livePath: "/health/live" }, - commit: { - id: commitId, - source: process.env.HWLAB_BUILD_SOURCE || "runtime-env" - }, - image: { - reference: imageReference, - tag: imageTag, - digest: process.env.HWLAB_IMAGE_DIGEST || "unknown" - }, + commit: metadata.commit, + image: metadata.image, + build: metadata.build, endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT, observedAt: new Date().toISOString(), details diff --git a/internal/sim/model.mjs b/internal/sim/model.mjs index 1b070246..9573096a 100644 --- a/internal/sim/model.mjs +++ b/internal/sim/model.mjs @@ -1,4 +1,5 @@ import { ENVIRONMENT_DEV } from "../protocol/index.mjs"; +import { buildMetadataFromEnv } from "../build-metadata.mjs"; export const DEFAULT_PROJECT_ID = "prj_mvp_topology"; export const DEFAULT_GATEWAY_SESSION_ID = "gws_mvp_simu"; @@ -356,11 +357,17 @@ export function createGatewayState({ } export function createHealth({ serviceId, name, now = isoNow() }) { + const metadata = buildMetadataFromEnv(process.env, { serviceId }); return { serviceId, name, environment: ENVIRONMENT_DEV, live: true, + status: "ok", + revision: metadata.revision, + commit: metadata.commit, + image: metadata.image, + build: metadata.build, observedAt: now }; } diff --git a/package.json b/package.json index 7e7631b8..a99feff3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/code-agent-facts.test.mjs web/hwlab-cloud-web/code-agent-status.test.mjs web/hwlab-cloud-web/wiring-status.test.mjs web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/code-agent-facts.test.mjs web/hwlab-cloud-web/code-agent-status.test.mjs web/hwlab-cloud-web/wiring-status.test.mjs web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 1b7a2db6..6f619f79 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -381,15 +381,68 @@ function nonEmptyEnv(name, fallback) { } function healthPayload() { + const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown"; + const imageReference = process.env.HWLAB_IMAGE || "unknown"; + const imageTag = process.env.HWLAB_IMAGE_TAG || imageTagFromReference(imageReference) || shortRevision(commitId); + const buildCreatedAt = normalizeIsoTimestamp( + process.env.HWLAB_BUILD_CREATED_AT || + process.env.HWLAB_IMAGE_CREATED_AT || + process.env.SOURCE_DATE_EPOCH || + process.env.BUILD_DATE + ); + const buildSource = process.env.HWLAB_BUILD_SOURCE || "runtime-env"; return { serviceId, environment, status: "ok", artifactKind: runtimeKind, - revision: process.env.HWLAB_COMMIT_ID || "unknown" + revision: process.env.HWLAB_REVISION || commitId, + commit: { + id: commitId, + source: buildSource + }, + image: { + reference: imageReference, + tag: imageTag, + digest: process.env.HWLAB_IMAGE_DIGEST || "unknown" + }, + build: { + createdAt: buildCreatedAt, + source: buildSource, + provenance: process.env.HWLAB_BUILD_PROVENANCE || null, + metadataSource: buildCreatedAt ? "runtime-env:HWLAB_BUILD_CREATED_AT" : "unavailable", + unavailableReason: buildCreatedAt ? null : "HWLAB_BUILD_CREATED_AT missing from runtime environment" + } }; } +function normalizeIsoTimestamp(value) { + const source = String(value || "").trim(); + if (!source) return null; + if (/^\d+$/u.test(source)) { + const seconds = Number.parseInt(source, 10); + if (Number.isSafeInteger(seconds)) { + const date = new Date(seconds * 1000); + if (Number.isFinite(date.getTime())) return date.toISOString(); + } + } + const date = new Date(source); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; +} + +function imageTagFromReference(imageReference) { + const source = String(imageReference || "").trim(); + const slashIndex = source.lastIndexOf("/"); + const colonIndex = source.lastIndexOf(":"); + if (colonIndex <= slashIndex) return null; + return source.slice(colonIndex + 1) || null; +} + +function shortRevision(value) { + const source = String(value || "").trim(); + return source.length >= 7 ? source.slice(0, 7) : source || "unknown"; +} + function runNodeEntrypoint(file) { const child = spawn(process.execPath, [file], { cwd: "/app", @@ -730,6 +783,13 @@ function dockerfile(baseImage, port) { "ARG HWLAB_ARTIFACT_KIND", "ARG HWLAB_SERVICE_ENTRYPOINT", "ARG HWLAB_COMMIT_ID", + "ARG HWLAB_REVISION", + "ARG HWLAB_IMAGE", + "ARG HWLAB_IMAGE_TAG", + "ARG HWLAB_IMAGE_DIGEST", + "ARG HWLAB_BUILD_CREATED_AT", + "ARG HWLAB_BUILD_SOURCE", + "ARG HWLAB_BUILD_PROVENANCE", "ARG PORT", "ARG HWLAB_PORT", "ENV CODEX_HOME=/codex-home", @@ -745,6 +805,13 @@ function dockerfile(baseImage, port) { "ENV HWLAB_ARTIFACT_KIND=$HWLAB_ARTIFACT_KIND", "ENV HWLAB_SERVICE_ENTRYPOINT=$HWLAB_SERVICE_ENTRYPOINT", "ENV HWLAB_COMMIT_ID=$HWLAB_COMMIT_ID", + "ENV HWLAB_REVISION=$HWLAB_REVISION", + "ENV HWLAB_IMAGE=$HWLAB_IMAGE", + "ENV HWLAB_IMAGE_TAG=$HWLAB_IMAGE_TAG", + "ENV HWLAB_IMAGE_DIGEST=$HWLAB_IMAGE_DIGEST", + "ENV HWLAB_BUILD_CREATED_AT=$HWLAB_BUILD_CREATED_AT", + "ENV HWLAB_BUILD_SOURCE=$HWLAB_BUILD_SOURCE", + "ENV HWLAB_BUILD_PROVENANCE=$HWLAB_BUILD_PROVENANCE", "ENV PORT=$PORT", "ENV HWLAB_PORT=$HWLAB_PORT", "COPY package.json ./package.json", @@ -895,14 +962,17 @@ function hasFatalBlocker(blockers) { return blockers.some((item) => fatalBlockerTypes.has(item.type)); } -async function buildService({ args, repo, commitId, shortCommit, service }) { +async function buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt }) { const tag = shortCommit; const ref = imageRef(args.registryPrefix, service.serviceId, tag); + const buildSource = `${repo}@${commitId}`; if (!buildableImplementationStates.has(service.implementationState)) { return { ...service, image: ref, imageTag: tag, + buildCreatedAt, + buildSource, status: "blocked_missing_runtime", digest: "not_published" }; @@ -915,6 +985,8 @@ async function buildService({ args, repo, commitId, shortCommit, service }) { ...service, image: ref, imageTag: tag, + buildCreatedAt, + buildSource, status: "build_failed", digest: "not_published", blocker: blocker({ @@ -933,6 +1005,8 @@ async function buildService({ args, repo, commitId, shortCommit, service }) { ...service, image: ref, imageTag: tag, + buildCreatedAt, + buildSource, status: "build_failed", digest: "not_published", distFreshness, @@ -955,9 +1029,12 @@ async function buildService({ args, repo, commitId, shortCommit, service }) { const labels = [ ["org.opencontainers.image.source", "https://github.com/pikasTech/HWLAB"], ["org.opencontainers.image.revision", commitId], + ["org.opencontainers.image.created", buildCreatedAt], ["org.opencontainers.image.title", service.serviceId], ["hwlab.pikastech.local/repo", repo], ["hwlab.pikastech.local/commit", commitId], + ["hwlab.pikastech.local/build-created-at", buildCreatedAt], + ["hwlab.pikastech.local/build-source", buildSource], ["hwlab.pikastech.local/service-id", service.serviceId], ["hwlab.pikastech.local/environment", ENVIRONMENT_DEV] ]; @@ -967,6 +1044,13 @@ async function buildService({ args, repo, commitId, shortCommit, service }) { ["HWLAB_ARTIFACT_KIND", service.runtimeKind], ["HWLAB_SERVICE_ENTRYPOINT", service.entrypoint ?? ""], ["HWLAB_COMMIT_ID", commitId], + ["HWLAB_REVISION", commitId], + ["HWLAB_IMAGE", ref], + ["HWLAB_IMAGE_TAG", tag], + ["HWLAB_IMAGE_DIGEST", "unknown"], + ["HWLAB_BUILD_CREATED_AT", buildCreatedAt], + ["HWLAB_BUILD_SOURCE", buildSource], + ["HWLAB_BUILD_PROVENANCE", "scripts/dev-artifact-publish.mjs"], ["PORT", String(port)], ["HWLAB_PORT", String(port)] ]; @@ -992,6 +1076,8 @@ async function buildService({ args, repo, commitId, shortCommit, service }) { ...service, image: ref, imageTag: tag, + buildCreatedAt, + buildSource, status: "build_failed", digest: "not_published", blocker: blocker({ @@ -1008,6 +1094,8 @@ async function buildService({ args, repo, commitId, shortCommit, service }) { ...service, image: ref, imageTag: tag, + buildCreatedAt, + buildSource, status: "built", localImageId: result.stdout.trim(), digest: "not_published" @@ -1132,13 +1220,15 @@ function artifactNotPublishedReason(artifact, mode, fatalBlocked) { return artifact.notPublishedReason ?? "publish_not_run"; } -function artifactRecord({ args, service, commitId, shortCommit, status, digest = "not_published" }) { +function artifactRecord({ args, service, commitId, shortCommit, buildCreatedAt = null, buildSource = null, status, digest = "not_published" }) { return { ...service, sourceCommitId: commitId, status, image: imageRef(args.registryPrefix, service.serviceId, shortCommit), imageTag: shortCommit, + buildCreatedAt, + buildSource, digest, repositoryDigest: null }; @@ -1165,6 +1255,8 @@ function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlo registryTarget: imageRepository(args.registryPrefix, artifact.serviceId), image, imageTag: artifact.imageTag ?? shortCommit, + buildCreatedAt: artifact.buildCreatedAt ?? null, + buildSource: artifact.buildSource ?? null, digest, repositoryDigest: artifact.repositoryDigest ?? null, digestStatus: /^sha256:[a-f0-9]{64}$/u.test(digest) ? "real" : "placeholder", @@ -1262,6 +1354,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif baseImage: args.baseImage ?? null, baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), environment: ENVIRONMENT_DEV, + buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null, serviceInventory, publishPlan, serviceCount: artifacts.length, @@ -1275,6 +1368,8 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif status: artifact.status, image: artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit), imageTag: artifact.imageTag ?? shortCommit, + buildCreatedAt: artifact.buildCreatedAt ?? null, + buildSource: artifact.buildSource ?? null, digest: artifact.digest ?? "not_published", repositoryDigest: artifact.repositoryDigest ?? null, runtimeKind: artifact.runtimeKind, @@ -1337,6 +1432,7 @@ async function main() { gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git") ]); const shortCommit = commitId.slice(0, 7); + const buildCreatedAt = new Date().toISOString(); const repo = repoLabelFromRemote(remoteUrl); const services = await resolveServices(args.services, catalog); @@ -1357,7 +1453,7 @@ async function main() { const artifactByServiceId = new Map(artifacts.map((artifact) => [artifact.serviceId, artifact])); for (const service of services.filter((item) => item.artifactRequired)) { emit("build_start", { serviceId: service.serviceId, image: imageRef(args.registryPrefix, service.serviceId, shortCommit) }); - const artifact = await buildService({ args, repo, commitId, shortCommit, service }); + const artifact = await buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt }); artifactByServiceId.set(service.serviceId, artifact); if (artifact.blocker) blockers.push(artifact.blocker); emit("build_done", { serviceId: service.serviceId, status: artifact.status, image: artifact.image }); @@ -1397,6 +1493,7 @@ async function main() { status: service.status, image: service.image, imageTag: service.imageTag, + buildCreatedAt: service.buildCreatedAt, digest: service.digest, distFreshness: service.distFreshness, notPublishedReason: service.notPublishedReason diff --git a/scripts/refresh-artifact-catalog.mjs b/scripts/refresh-artifact-catalog.mjs index 3d707ee1..ae6a25be 100644 --- a/scripts/refresh-artifact-catalog.mjs +++ b/scripts/refresh-artifact-catalog.mjs @@ -182,6 +182,8 @@ function publishRecordsFromReport(report, target) { serviceId: service.serviceId, image: service.image, imageTag: image.tag, + buildCreatedAt: service.buildCreatedAt ?? null, + buildSource: service.buildSource ?? null, digest: service.digest, repositoryDigest: service.repositoryDigest ?? `${image.repository}@${service.digest}` }); @@ -196,6 +198,9 @@ function updateEnvObject(env, service, target, publishRecord) { if (Object.hasOwn(env, "HWLAB_COMMIT_ID")) env.HWLAB_COMMIT_ID = target.shortCommitId; if (Object.hasOwn(env, "HWLAB_IMAGE")) env.HWLAB_IMAGE = service.image; if (Object.hasOwn(env, "HWLAB_IMAGE_TAG")) env.HWLAB_IMAGE_TAG = service.imageTag; + if (Object.hasOwn(env, "HWLAB_REVISION")) env.HWLAB_REVISION = target.commitId; + if (Object.hasOwn(env, "HWLAB_BUILD_CREATED_AT")) env.HWLAB_BUILD_CREATED_AT = publishRecord?.buildCreatedAt ?? ""; + if (Object.hasOwn(env, "HWLAB_BUILD_SOURCE")) env.HWLAB_BUILD_SOURCE = publishRecord?.buildSource ?? ""; if (Object.hasOwn(env, "HWLAB_SKILLS_COMMIT_ID")) env.HWLAB_SKILLS_COMMIT_ID = target.shortCommitId; if (Object.hasOwn(env, "HWLAB_IMAGE_DIGEST")) env.HWLAB_IMAGE_DIGEST = publishRecord?.digest ?? "not_published"; } @@ -214,6 +219,9 @@ function updateEnvList(envList, service, target, publishRecord) { if (entry.name === "HWLAB_COMMIT_ID") entry.value = target.shortCommitId; if (entry.name === "HWLAB_IMAGE") entry.value = service.image; if (entry.name === "HWLAB_IMAGE_TAG") entry.value = service.imageTag; + if (entry.name === "HWLAB_REVISION") entry.value = target.commitId; + if (entry.name === "HWLAB_BUILD_CREATED_AT") entry.value = publishRecord?.buildCreatedAt ?? ""; + if (entry.name === "HWLAB_BUILD_SOURCE") entry.value = publishRecord?.buildSource ?? ""; if (entry.name === "HWLAB_SKILLS_COMMIT_ID") entry.value = target.shortCommitId; if (entry.name === "HWLAB_IMAGE_DIGEST") entry.value = publishRecord?.digest ?? "not_published"; } @@ -264,6 +272,8 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, catalogService.commitId = target.shortCommitId; catalogService.image = image; catalogService.imageTag = imageTag; + catalogService.buildCreatedAt = publishRecord?.buildCreatedAt ?? null; + catalogService.buildSource = publishRecord?.buildSource ?? null; catalogService.digest = publishRecord?.digest ?? "not_published"; catalogService.publishState = publishRecord ? "published" : "skeleton-only"; catalogService.publishEnabled = inventory?.publishEnabled ?? required; @@ -276,6 +286,7 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, serviceId, image, imageTag, + buildCreatedAt: catalogService.buildCreatedAt, digest: catalogService.digest, publishState: catalogService.publishState, artifactRequired: catalogService.artifactRequired, diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 88777637..a1a2ab7a 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -585,7 +585,7 @@ function runStaticSmoke() { addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", { blocker: "safety_blocker", - evidence: ["/health/live", "/v1", "/v1/diagnostics/gate", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods] + evidence: ["/health/live", "/v1", "/v1/live-builds", "/v1/diagnostics/gate", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods] }); addCheck(checks, blockers, "api-live-status-attribution", hasApiLiveStatusAttribution(files), "Default workbench status maps raw API health into pass, error, unverified, or read-only with concrete service/API attribution.", { @@ -2329,6 +2329,7 @@ function hasSameOriginReadOnlyBoundary(app) { return ( /fetchJson\("\/health\/live"\)/u.test(app) && /fetchJson\("\/v1"\)/u.test(app) && + /fetchJson\("\/v1\/live-builds"\)/u.test(app) && /fetchJson\("\/v1\/m3\/status"\)/u.test(app) && /fetchJson\("\/v1\/diagnostics\/gate"/u.test(app) && /fetchJson\("\/v1\/agent\/chat"/u.test(app) && @@ -3087,6 +3088,18 @@ async function inspectLiveDom(url, options = {}) { diagnosticsHidden: diagnostics ? diagnostics.hidden : null, outerScrollLocked: document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2, rootAfterScrollAttempt, + liveBuildLatest: document.querySelector("#live-build-latest")?.textContent?.replace(/\s+/gu, " ").trim() ?? "", + liveBuildRows: document.querySelectorAll("#live-build-list .live-build-row").length, + liveBuildDefaultVisible: visible("#live-build-latest"), + liveBuildSummaryOpen: document.querySelector("#live-build-summary")?.open === true, + liveBuildListVisibleWhenClosed: visible("#live-build-list"), + liveBuildListScrollableWhenOpen: (() => { + const details = document.querySelector("#live-build-summary"); + const list = document.querySelector("#live-build-list"); + if (!details || !list) return false; + details.open = true; + return visible("#live-build-list") && list.scrollHeight >= list.clientHeight && list.clientHeight <= Math.ceil(window.innerHeight * 0.42) + 4; + })(), firstViewportForbiddenPresent: forbiddenTerms.filter((term) => textHasForbiddenTerm(firstViewportText, term)), firstViewportTextSample: firstViewportText.slice(0, 400), labelsPresent: ["硬件资源", "Agent 对话", "工作清单", "可信记录", "使用说明"].every((label) => text.includes(label)), @@ -3143,6 +3156,14 @@ async function inspectLiveDom(url, options = {}) { dom.rootAfterScrollAttempt.windowScrollY === 0 && dom.rootAfterScrollAttempt.htmlScrollTop === 0 && dom.rootAfterScrollAttempt.bodyScrollTop === 0 && + dom.liveBuildDefaultVisible && + /最新镜像构建时间:\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} 北京时间/u.test(dom.liveBuildLatest) && + /hwlab-agent-mgr/u.test(dom.liveBuildLatest) && + /tag f09ad05/u.test(dom.liveBuildLatest) && + dom.liveBuildRows === 0 && + dom.liveBuildSummaryOpen === false && + dom.liveBuildListVisibleWhenClosed === false && + dom.liveBuildListScrollableWhenOpen && dom.firstViewportForbiddenPresent.length === 0 && dom.labelsPresent && dom.navLabelsPresent && @@ -3176,6 +3197,8 @@ async function inspectLiveDom(url, options = {}) { `title=${dom.title}`, `bodyOverflow=${dom.bodyOverflow}`, `htmlOverflow=${dom.htmlOverflow}`, + `liveBuildLatest=${dom.liveBuildLatest}`, + `liveBuildRowsClosed=${dom.liveBuildRows}`, `firstViewportForbidden=${dom.firstViewportForbiddenPresent.join(",") || "none"}`, `gateRouteAvailable=${dom.gateRouteAvailable}`, `wiringLayout=${dom.wiringLayout}`, @@ -5236,6 +5259,10 @@ async function handleQuickPromptsFixtureApi({ request, response, url }) { }); return true; } + if (request.method === "GET" && url.pathname === "/v1/live-builds") { + jsonResponse(response, 200, liveBuildsFixturePayload()); + return true; + } if (request.method === "GET" && url.pathname === "/v1/m3/io") { jsonResponse(response, 200, { status: "blocked", @@ -5290,6 +5317,10 @@ async function handleM3StatusFixtureApi({ request, response, url, options = {} } jsonResponse(response, 200, m3StatusFixtureHealth(options.m3State)); return true; } + if (request.method === "GET" && url.pathname === "/v1/live-builds") { + jsonResponse(response, 200, liveBuildsFixturePayload()); + return true; + } if (request.method === "GET" && url.pathname === "/v1/m3/io") { jsonResponse(response, 200, { status: "available", @@ -5498,6 +5529,132 @@ function m3StatusFixturePayload(m3State = { doValue: false, diValue: false, obse }; } +function liveBuildsFixturePayload() { + return { + serviceId: "hwlab-cloud-api", + environment: "dev", + status: "ok", + contractVersion: "live-builds-v1", + observedAt: "2026-05-23T00:30:00.000Z", + source: { + kind: "live-health", + route: "/v1/live-builds", + healthPath: "/health/live" + }, + latest: { + serviceId: "hwlab-agent-mgr", + name: "hwlab-agent-mgr", + kind: "hwlab", + status: "ok", + build: { + createdAt: "2026-05-23T00:20:00.000Z", + metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT" + }, + image: { + reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:f09ad05", + tag: "f09ad05", + digest: "sha256:" + "1".repeat(64) + }, + commit: { + id: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4", + source: "fixture" + }, + revision: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4" + }, + counts: { + total: 4, + hwlab: 4, + withBuildTime: 2, + unavailable: 2, + external: 0 + }, + services: [ + { + serviceId: "hwlab-cloud-api", + name: "hwlab-cloud-api", + kind: "hwlab", + status: "ok", + build: { + createdAt: "2026-05-23T00:10:00.000Z", + metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT" + }, + image: { + reference: "127.0.0.1:5000/hwlab/hwlab-cloud-api:f09ad05", + tag: "f09ad05", + digest: "sha256:" + "0".repeat(64) + }, + commit: { + id: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4", + source: "fixture" + }, + revision: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4" + }, + { + serviceId: "hwlab-agent-mgr", + name: "hwlab-agent-mgr", + kind: "hwlab", + status: "ok", + build: { + createdAt: "2026-05-23T00:20:00.000Z", + metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT" + }, + image: { + reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:f09ad05", + tag: "f09ad05", + digest: "sha256:" + "1".repeat(64) + }, + commit: { + id: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4", + source: "fixture" + }, + revision: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4" + }, + { + serviceId: "hwlab-agent-worker", + name: "hwlab-agent-worker", + kind: "hwlab", + status: "unavailable", + build: { + createdAt: null, + metadataSource: "unavailable", + unavailableReason: "构建时间不可用:hwlab-agent-worker 是 suspended Job template,当前 live desired replicas=0" + }, + image: { + reference: "unknown", + tag: "unknown", + digest: "unknown" + }, + commit: { + id: "unknown", + source: "unavailable" + }, + revision: "unknown" + }, + { + serviceId: "hwlab-cli", + name: "hwlab-cli", + kind: "hwlab", + status: "unavailable", + build: { + createdAt: null, + metadataSource: "unavailable", + unavailableReason: "构建时间不可用:hwlab-cli 是 suspended Job template,当前 live desired replicas=0" + }, + image: { + reference: "unknown", + tag: "unknown", + digest: "unknown" + }, + commit: { + id: "unknown", + source: "unavailable" + }, + revision: "unknown" + } + ] + }; +} + async function handleLocalAgentFixtureApi({ request, response, url, options = {} }) { if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) { jsonResponse(response, 200, { @@ -5536,6 +5693,10 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} }); return true; } + if (request.method === "GET" && url.pathname === "/v1/live-builds") { + jsonResponse(response, 200, liveBuildsFixturePayload()); + return true; + } if (request.method === "GET" && url.pathname === "/v1/m3/io") { jsonResponse(response, 200, { status: "available", @@ -5613,6 +5774,10 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} }); return true; } + if (request.method === "GET" && url.pathname === "/v1/live-builds") { + jsonResponse(response, 200, liveBuildsFixturePayload()); + return true; + } if (request.method === "POST" && url.pathname === "/json-rpc") { const body = await readJsonBody(request); jsonResponse(response, 200, { diff --git a/scripts/src/m3-io-control-e2e.mjs b/scripts/src/m3-io-control-e2e.mjs index 977fd42e..cf3c340a 100644 --- a/scripts/src/m3-io-control-e2e.mjs +++ b/scripts/src/m3-io-control-e2e.mjs @@ -332,6 +332,7 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", "/health/live", "/v1", "/v1/diagnostics/gate", + "/v1/live-builds", "/v1/agent/chat", "/v1/m3/io", "/v1/m3/status", diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 0a36076d..4f37965d 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -99,6 +99,9 @@ const el = { routePath: byId("route-path"), liveStatus: byId("live-status"), liveDetail: byId("live-detail"), + liveBuildSummary: byId("live-build-summary"), + liveBuildLatest: byId("live-build-latest"), + liveBuildList: byId("live-build-list"), agentChatStatus: byId("agent-chat-status"), codeAgentSummary: byId("code-agent-summary"), codeAgentSummaryIcon: byId("code-agent-summary-icon"), @@ -646,6 +649,7 @@ function renderProbePending() { el.liveStatus.textContent = "等待验证"; el.liveStatus.className = "tone-pending"; el.liveDetail.textContent = "正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。"; + renderLiveBuilds(null); renderCodeAgentSummary(); } @@ -683,8 +687,9 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI async function loadLiveSurface() { const projectId = gateSummary.topology.projectId; - const [healthLive, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([ + const [healthLive, liveBuilds, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([ fetchJson("/health/live"), + fetchJson("/v1/live-builds"), fetchJson("/v1"), fetchJson("/v1/m3/io"), fetchJson("/v1/m3/status"), @@ -697,6 +702,7 @@ async function loadLiveSurface() { return { observedAt: new Date().toISOString(), healthLive, + liveBuilds, restIndex, m3Control, m3Status, @@ -938,6 +944,7 @@ function renderLiveSurface(live) { state.codeAgentAvailability = codeAgentAvailabilityFrom(live); el.liveDetail.textContent = surfaceStatus.detail ?? codeAgentAvailabilitySummary(); + renderLiveBuilds(live.liveBuilds); renderAgentChatStatus(deriveAgentChatStatus()); renderCodeAgentSummary(); renderHardwareStatus(state.m3Control.status); @@ -948,6 +955,132 @@ function renderLiveSurface(live) { renderDrafts(); } +function renderLiveBuilds(response) { + const payload = response?.ok ? response.data : null; + const services = Array.isArray(payload?.services) ? payload.services : []; + const latest = payload?.latest ?? null; + + if (latest?.build?.createdAt) { + el.liveBuildLatest.textContent = [ + `最新镜像构建时间:${formatBeijingTime(latest.build.createdAt)}`, + latest.name || latest.serviceId || "未知服务", + `tag ${latest.image?.tag || "unknown"}`, + `commit ${shortToken(latest.commit?.id)}`, + `revision ${shortToken(latest.revision)}` + ].join(" · "); + } else { + el.liveBuildLatest.textContent = "最新镜像构建时间:构建时间不可用"; + } + + if (!response) { + replaceChildren( + el.liveBuildList, + liveBuildUnavailableRow("等待实时元数据聚合返回。") + ); + return; + } + + if (!response.ok) { + replaceChildren( + el.liveBuildList, + liveBuildUnavailableRow(`构建时间不可用:${response.error || "实时元数据聚合不可用"}`) + ); + return; + } + + if (!services.length) { + replaceChildren( + el.liveBuildList, + liveBuildUnavailableRow("构建时间不可用:当前实时元数据未返回微服务明细。") + ); + return; + } + + replaceChildren(el.liveBuildList, ...services.map(liveBuildRow)); +} + +function liveBuildRow(service) { + const row = document.createElement("div"); + row.className = `live-build-row live-build-row-${toneClass(service.status)}`; + + const header = document.createElement("div"); + header.className = "live-build-row-head"; + header.append( + textSpan(service.name || service.serviceId || "未知服务", "live-build-service"), + textSpan(liveBuildTimeText(service), "live-build-time") + ); + + const meta = document.createElement("div"); + meta.className = "live-build-meta"; + meta.append( + textSpan(`tag ${service.image?.tag || "unknown"}`), + textSpan(`commit ${shortToken(service.commit?.id)}`), + textSpan(`revision ${shortToken(service.revision)}`) + ); + + const reasonText = liveBuildReasonText(service); + if (reasonText) { + row.append(header, meta, textSpan(reasonText, "live-build-reason")); + } else { + row.append(header, meta); + } + return row; +} + +function liveBuildUnavailableRow(reason) { + return liveBuildRow({ + serviceId: "live-build-metadata", + name: "实时元数据", + kind: "hwlab", + status: "unavailable", + build: { + createdAt: null, + unavailableReason: reason + }, + image: { + tag: "unknown" + }, + commit: { + id: "unknown" + }, + revision: "unknown" + }); +} + +function liveBuildTimeText(service) { + if (service.kind === "external") return "外部镜像"; + const createdAt = service.build?.createdAt; + return createdAt ? formatBeijingTime(createdAt) : "构建时间不可用"; +} + +function liveBuildReasonText(service) { + if (service.kind === "external") return "外部镜像或非 HWLAB 构建产物,不参与最新 HWLAB 构建时间计算。"; + if (service.build?.unavailableReason) return service.build.unavailableReason; + if (service.error?.message) return `构建时间不可用:${service.error.message}`; + return ""; +} + +function formatBeijingTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "构建时间不可用"; + const parts = Object.fromEntries(new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + }).formatToParts(date).map((part) => [part.type, part.value])); + return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second} 北京时间`; +} + +function shortToken(value) { + const text = String(value ?? "unknown").trim() || "unknown"; + return text.length > 12 ? text.slice(0, 12) : text; +} + function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]) { const status = classifyWorkbenchLiveStatus({ ...live, diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index b2cd095f..577318e4 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -49,6 +49,12 @@ 工作区状态 等待验证 正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。 +
+ + 最新镜像构建时间:等待实时元数据 + +
+
diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 5c910e6b..b1e4afc0 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -878,6 +878,14 @@ assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.right-sidebar\s*{[\s\ assert.match(styles, /@media \(max-width: 520px\)[\s\S]*?\.command-bar\s*{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto auto;/); assert.match(app, /fetchJson\("\/v1"\)/); assert.match(app, /fetchJson\("\/v1\/diagnostics\/gate"/); +assert.match(app, /fetchJson\("\/v1\/live-builds"\)/); +assert.match(html, /id="live-build-latest"/); +assert.match(html, /id="live-build-list"/); +assert.match(styles, /\.live-build-summary\s*{/); +assert.match(app, /formatBeijingTime/); +assert.match(app, /Asia\/Shanghai/); +assert.match(app, /最新镜像构建时间/); +assert.match(app, /构建时间不可用/); assert.match(app, /fetchJson\("\/health\/live"\)/); assert.match(app, /callRpc\("system\.health"\)/); assert.match(app, /callRpc\("cloud\.adapter\.describe"\)/); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 4c4a1590..c62b1b4e 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -416,7 +416,7 @@ h3 { .topbar { min-height: 82px; display: grid; - grid-template-columns: minmax(0, 1fr) minmax(260px, 360px) auto; + grid-template-columns: minmax(0, 1fr) minmax(320px, 460px) auto; gap: 12px; align-items: stretch; padding: 12px; @@ -465,6 +465,91 @@ h3 { overflow-wrap: anywhere; } +.live-build-summary { + min-width: 0; + margin-top: 4px; + padding-top: 7px; + border-top: 1px solid var(--line); +} + +.live-build-summary summary { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + color: var(--text); + font-family: var(--mono); + font-size: 10px; + line-height: 1.35; + cursor: pointer; + overflow-wrap: anywhere; +} + +.live-build-summary-label { + min-width: 0; + overflow-wrap: anywhere; +} + +.live-build-list { + max-height: min(280px, 42dvh); + margin-top: 7px; + display: grid; + gap: 6px; + overflow: auto; + overscroll-behavior: contain; +} + +.live-build-row { + min-width: 0; + display: grid; + gap: 3px; + padding: 7px; + background: var(--surface); + border: 1px solid var(--line); +} + +.live-build-row-head { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + gap: 8px; +} + +.live-build-service, +.live-build-time { + min-width: 0; + overflow-wrap: anywhere; +} + +.live-build-service { + color: var(--text); + font-weight: 800; +} + +.live-build-time { + color: var(--accent-2); + font-family: var(--mono); + font-size: 10px; + text-align: right; +} + +.live-build-meta { + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 4px 7px; + color: var(--dim); + font-family: var(--mono); + font-size: 10px; + overflow-wrap: anywhere; +} + +.live-build-reason { + color: var(--muted); + font-size: 10px; + overflow-wrap: anywhere; +} + .view { min-height: 0; overflow: auto; @@ -1855,6 +1940,15 @@ tbody tr:last-child td { justify-self: start; } + .live-build-row-head { + grid-template-columns: 1fr; + gap: 2px; + } + + .live-build-time { + text-align: left; + } + .hardware-list, .m3-control-grid, .m3-control-actions {