Merge pull request #382 from pikasTech/feat/live-build-times-359

feat: show live HWLAB build metadata
This commit is contained in:
Lyon
2026-05-24 02:27:19 +08:00
committed by GitHub
16 changed files with 1018 additions and 46 deletions
+5 -13
View File
@@ -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") {
+4
View File
@@ -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,
+69
View File
@@ -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;
}
+289 -13
View File
@@ -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 = {};
+112
View File
@@ -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;
+9 -12
View File
@@ -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
+7
View File
@@ -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
};
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+101 -4
View File
@@ -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
+11
View File
@@ -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,
+166 -1
View File
@@ -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, {
+1
View File
@@ -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",
+134 -1
View File
@@ -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,
+6
View File
@@ -49,6 +49,12 @@
<span class="metric-label">工作区状态</span>
<strong id="live-status" class="tone-pending">等待验证</strong>
<small id="live-detail">正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。</small>
<details class="live-build-summary" id="live-build-summary">
<summary>
<span class="live-build-summary-label" id="live-build-latest">最新镜像构建时间:等待实时元数据</span>
</summary>
<div class="live-build-list" id="live-build-list" aria-label="live HWLAB 微服务构建时间明细"></div>
</details>
</div>
<button class="logout-button" id="logout-button" type="button">退出</button>
</header>
+8
View File
@@ -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"\)/);
+95 -1
View File
@@ -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 {