feat: add fake device pod sidebar service

This commit is contained in:
Codex
2026-05-28 01:39:03 +08:00
parent 704e3104ad
commit 297206b695
39 changed files with 1895 additions and 9782 deletions
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
import { createServer } from "node:http";
import { jsonResponse, listen, parsePort } from "../../internal/sim/http.mjs";
import {
DEFAULT_DEVICE_POD_ID,
DEVICE_POD_SERVICE_ID,
buildDevicePodRestPayload
} from "../../internal/device-pod/fake-data.mjs";
const port = parsePort(process.env.PORT, parsePort(process.env.HWLAB_DEVICE_POD_PORT, 7601));
const devicePodId = process.env.HWLAB_DEVICE_POD_ID || DEFAULT_DEVICE_POD_ID;
listen(createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://localhost");
if (request.method !== "GET") {
jsonResponse(response, 405, { error: "method_not_allowed", method: request.method ?? "UNKNOWN" });
return;
}
if (url.pathname === "/health" || url.pathname === "/health/live") {
jsonResponse(response, 200, healthPayload());
return;
}
const payload = buildDevicePodRestPayload(url.pathname, url.searchParams, {
devicePodId,
sourceKind: "FAKE-SERVICE"
});
if (!payload) {
jsonResponse(response, 404, { error: "not_found", path: url.pathname });
return;
}
jsonResponse(response, 200, payload);
}), port);
function healthPayload() {
return {
serviceId: DEVICE_POD_SERVICE_ID,
environment: process.env.HWLAB_ENVIRONMENT || "dev",
status: "live",
ready: true,
contractVersion: "device-pod-fake-v1",
devicePodId,
observedAt: new Date().toISOString(),
role: "fake-device-pod-server",
note: "Only fake frontend data is served; no hardware connection is opened."
};
}
+44 -2
View File
@@ -204,6 +204,36 @@
},
"reusedFrom": "7b426c226ec6f6d4de516f58055f3bf26d911477a88ebeef320066b1903ef40f"
},
{
"serviceId": "hwlab-device-pod",
"commitId": "278bbe1",
"image": "127.0.0.1:5000/hwlab/hwlab-device-pod:278bbe1",
"imageTag": "278bbe1",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"sourceState": "source-present",
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": "publish_not_run",
"buildCreatedAt": null,
"buildSource": null,
"componentCommitId": null,
"componentInputHash": null,
"dockerfileHash": "8b2595a09276479b8809e70e99516c1539a6a7cc200e73266d247176b56be962",
"baseImageReference": "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim",
"baseImageDigest": null,
"buildArgsHash": null,
"ciAffected": true,
"ciReason": [
"new-service"
],
"reuse": null,
"reusedFrom": null
},
{
"serviceId": "hwlab-gateway",
"commitId": "278bbe1",
@@ -522,14 +552,15 @@
],
"serviceInventory": {
"version": "v2",
"serviceCount": 13,
"requiredServiceCount": 13,
"serviceCount": 14,
"requiredServiceCount": 14,
"disabledServiceCount": 0,
"requiredServiceIds": [
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-gateway-simu",
"hwlab-box-simu",
@@ -586,6 +617,17 @@
"entrypoint": "cmd/hwlab-agent-worker/main.mjs",
"disabledReason": null
},
{
"serviceId": "hwlab-device-pod",
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"runtimeKind": "node-command",
"implementationState": "repo-entrypoint",
"sourceState": "source-present",
"entrypoint": "cmd/hwlab-device-pod/main.mjs",
"disabledReason": null
},
{
"serviceId": "hwlab-gateway",
"publishEnabled": true,
+21 -1
View File
@@ -104,6 +104,13 @@
"port": 7410,
"targetPort": "http"
},
{
"serviceId": "hwlab-device-pod",
"name": "hwlab-device-pod",
"namespace": "hwlab-dev",
"port": 7601,
"targetPort": "http"
},
{
"serviceId": "hwlab-gateway-simu",
"name": "hwlab-gateway-simu-1",
@@ -220,7 +227,8 @@
"HWLAB_CODE_AGENT_CODEX_API_MODEL": "gpt-5.5",
"HWLAB_CODE_AGENT_CODEX_API_BASE_URL": "http://127.0.0.1:49280/responses",
"HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL": "https://hyueapi.com",
"HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT": "49280"
"HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT": "49280",
"HWLAB_DEVICE_POD_URL": "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601"
}
},
{
@@ -257,6 +265,18 @@
"HWLAB_ENVIRONMENT": "dev"
}
},
{
"serviceId": "hwlab-device-pod",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
"replicas": 1,
"env": {
"HWLAB_ENVIRONMENT": "dev",
"HWLAB_DEVICE_POD_ID": "device-pod-71-freq",
"HWLAB_DEVICE_POD_PORT": "7601"
}
},
{
"serviceId": "hwlab-gateway",
"namespace": "hwlab-dev",
+1
View File
@@ -69,6 +69,7 @@
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-gateway-simu",
"hwlab-box-simu",
+25
View File
@@ -258,6 +258,31 @@
]
}
},
{
"apiVersion": "v1",
"kind": "Service",
"metadata": {
"name": "hwlab-device-pod",
"namespace": "hwlab-dev",
"labels": {
"app.kubernetes.io/name": "hwlab-device-pod",
"hwlab.pikastech.local/service-id": "hwlab-device-pod"
}
},
"spec": {
"type": "ClusterIP",
"selector": {
"app.kubernetes.io/name": "hwlab-device-pod"
},
"ports": [
{
"name": "http",
"port": 7601,
"targetPort": "http"
}
]
}
},
{
"apiVersion": "v1",
"kind": "Service",
+72
View File
@@ -255,6 +255,10 @@
{
"name": "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT",
"value": "49280"
},
{
"name": "HWLAB_DEVICE_POD_URL",
"value": "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601"
}
],
"readinessProbe": {
@@ -563,6 +567,74 @@
}
}
},
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": "hwlab-device-pod",
"namespace": "hwlab-dev",
"labels": {
"app.kubernetes.io/name": "hwlab-device-pod",
"hwlab.pikastech.local/service-id": "hwlab-device-pod"
}
},
"spec": {
"replicas": 1,
"selector": {
"matchLabels": {
"app.kubernetes.io/name": "hwlab-device-pod"
}
},
"template": {
"metadata": {
"labels": {
"app.kubernetes.io/name": "hwlab-device-pod",
"hwlab.pikastech.local/service-id": "hwlab-device-pod"
}
},
"spec": {
"containers": [
{
"name": "hwlab-device-pod",
"image": "127.0.0.1:5000/hwlab/hwlab-device-pod:af46386",
"ports": [
{
"name": "http",
"containerPort": 7601
}
],
"env": [
{
"name": "HWLAB_ENVIRONMENT",
"value": "dev"
},
{
"name": "HWLAB_DEVICE_POD_ID",
"value": "device-pod-71-freq"
},
{
"name": "HWLAB_DEVICE_POD_PORT",
"value": "7601"
}
],
"readinessProbe": {
"httpGet": {
"path": "/health/live",
"port": "http"
}
},
"livenessProbe": {
"httpGet": {
"path": "/health/live",
"port": "http"
}
}
}
]
}
}
}
},
{
"apiVersion": "apps/v1",
"kind": "Deployment",
+2 -1
View File
@@ -20,6 +20,7 @@
"router": "hwlab-router:7401 exposes /health/live and owns edge reachability routing",
"tunnel-client": "hwlab-tunnel-client:7402 exposes /health/live and owns D601-to-master frp client state",
"edge-proxy": "hwlab-edge-proxy:6667 exposes /health/live and proxies DEV endpoint traffic to hwlab-cloud-api",
"runtime-substitute-policy": "Do not replace HWLAB runtime with UniDesk backend, provider-gateway, or microservice proxy"
"runtime-substitute-policy": "Do not replace HWLAB runtime with UniDesk backend, provider-gateway, or microservice proxy",
"device-pod": "hwlab-device-pod:7601 exposes /health/live and serves fake Device Pod summary/events for the Cloud Workbench right sidebar"
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp
Cloud Web #59 adds a separate read-only diagnostics contract:
```sh
node web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs
node web/hwlab-cloud-web/scripts/check.mjs
```
This contract is source-only. It verifies the public frontend/API endpoint
@@ -0,0 +1,35 @@
# Device Pod 右侧看板与 fake service 方案
## 目标
- 将 Cloud Workbench 右侧区域收敛为 Device Pod 看板,默认只显示 summary。
- 看板内部不做展开/收起;用户点击 summary 卡片后,通过弹窗查看细节。
- 事件流降级为最简单的纯文本窗口,更新时不抢占用户正在滚动的位置。
- 当前先提供 `hwlab-device-pod` fake 服务,完成前端信息模型、Cloud API fallback/proxy 与静态合同闭环。
## 前端规划
- 右侧固定入口为 `aside#device-pod-sidebar`,包含 Pod Summary、Target、Workspace、Debug、IO 和纯文本事件流。
- Summary 卡片只展示关键字段;完整 payload 通过 `dialog#device-detail-dialog` 展示,避免在右侧看板内部嵌套折叠面板。
- 事件流使用 `pre#device-event-text`,滚动容器使用 `overscroll-behavior: contain`,并记录 `followEvents``unreadEvents` 与用户滚动活动时间。
- 前端默认不展示旧硬件拓扑面板,不连接旧模拟硬件拓扑,不提供浏览器侧泛化硬件写入口。
- 删除旧 `web/hwlab-cloud-web/gate-summary.mjs` 生成型前端拓扑资产,以及生成它的 `scripts/export-web-gate-summary.mjs`/`web:summary` 入口,避免 gateway-simu、box-simu 和 patch-panel 通过前端资产回流。
## 服务规划
- 新增 `cmd/hwlab-device-pod/main.mjs`,默认端口 `7601`,先服务 fake 数据。
- 新增 `internal/device-pod/fake-data.mjs` 作为 fake 数据与 REST payload 构造器,覆盖列表、status、events、chip-id、UART 状态和 UART tail。
- `hwlab-cloud-api``/v1/device-pods` 做 upstream proxyupstream 不可用或未配置时回退到 fake payload,前端不直接连接 Device Pod 后端。
## 部署规划
- `internal/protocol`、deploy manifest、k8s service/workload、health contract 与 artifact catalog 均登记 `hwlab-device-pod`
- DEV 环境默认 `HWLAB_DEVICE_POD_ID=device-pod-71-freq``HWLAB_DEVICE_POD_PORT=7601`
- 该阶段只证明 fake service 与前端右侧看板合同,不声明真实设备闭环通过。
## 验收标准
- `node web/hwlab-cloud-web/scripts/check.mjs` 只按 Device Pod 右侧看板、summary 弹窗、纯文本事件流和 fake service 合同检查。
- `node --check` 覆盖新增 fake 数据、Device Pod 服务、Cloud API、前端 app、live-status 与 check 脚本。
- 右侧看板 HTML 中不存在内部 `<details>` 展开/收起结构;旧硬件面板 DOM 与旧硬件 API 调用不作为验收目标。
- Cloud Web runtime/dist 与 L6 Web smoke 不再依赖 `gate-summary.mjs`,也不再断言 gateway-simu、hwlab-box-simu 或 hwlab-patch-panel 前端拓扑。
+69
View File
@@ -45,6 +45,10 @@ import {
handleM3IoControl
} from "./m3-io-control.mjs";
import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs";
import {
buildDevicePodRestPayload,
buildDevicePodStatus
} from "../device-pod/fake-data.mjs";
import { createGatewayDemoRegistry } from "./gateway-demo-registry.mjs";
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
@@ -70,6 +74,7 @@ const LIVE_BUILD_SERVICE_DEFAULTS = Object.freeze({
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }),
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }),
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411" }),
"hwlab-device-pod": Object.freeze({ urlEnv: "HWLAB_DEVICE_POD_URL", defaultUrl: "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601" }),
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001" }),
"hwlab-gateway-simu": Object.freeze({ urlEnv: "HWLAB_GATEWAY_SIMU_URL", defaultUrl: "http://hwlab-gateway-simu.hwlab-dev.svc.cluster.local:7101" }),
"hwlab-box-simu": Object.freeze({ urlEnv: "HWLAB_BOX_SIMU_URL", defaultUrl: "http://hwlab-box-simu.hwlab-dev.svc.cluster.local:7201" }),
@@ -302,11 +307,24 @@ async function handleRestAdapter(request, response, url, options) {
readiness,
blockers: readiness.blockers,
blockerCodes: readiness.blockerCodes,
devicePod: {
route: "/v1/device-pods",
statusRoute: "/v1/device-pods/{devicePodId}/status",
eventsRoute: "/v1/device-pods/{devicePodId}/events",
contractVersion: "device-pod-fake-v1",
frontendCallsOnly: "/v1/device-pods",
legacyHardwareUi: "disabled"
},
m3IoControl: describeM3IoControl(options)
});
return;
}
if (request.method === "GET" && (url.pathname === "/v1/device-pods" || url.pathname.startsWith("/v1/device-pods/"))) {
sendJson(response, 200, await buildDevicePodCloudApiPayload(url, options));
return;
}
if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) {
sendJson(response, 200, await describeM3IoControlLive(options));
return;
@@ -474,6 +492,57 @@ async function buildLiveBuildsPayload(options = {}) {
};
}
async function buildDevicePodCloudApiPayload(url, options = {}) {
const upstreamPayload = await fetchDevicePodUpstreamPayload(url, options);
if (upstreamPayload) {
return {
...upstreamPayload,
cloudApiProxy: {
route: url.pathname,
upstream: configuredDevicePodUrl(options.env ?? process.env),
status: "proxied"
}
};
}
const fallback = buildDevicePodRestPayload(url.pathname, url.searchParams, {
sourceKind: "FAKE-CLOUD-API"
}) ?? buildDevicePodStatus(undefined, { sourceKind: "FAKE-CLOUD-API" });
return {
...fallback,
cloudApiProxy: {
route: url.pathname,
upstream: configuredDevicePodUrl(options.env ?? process.env),
status: "fake-fallback",
reason: "hwlab-device-pod upstream is unavailable or not configured"
}
};
}
async function fetchDevicePodUpstreamPayload(url, options = {}) {
const baseUrl = configuredDevicePodUrl(options.env ?? process.env);
if (!baseUrl) return null;
const target = `${baseUrl}${url.pathname}${url.search}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 900);
try {
const response = await (options.fetchImpl ?? fetch)(target, {
headers: { Accept: "application/json" },
signal: controller.signal
});
if (!response.ok) return null;
return await response.json();
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
function configuredDevicePodUrl(env = process.env) {
const value = String(env.HWLAB_DEVICE_POD_URL ?? "").trim();
return value ? value.replace(/\/+$/u, "") : "";
}
async function observeLiveBuildService(service, options) {
if (service.externalImage) {
return externalLiveBuildRecord(service, {
+313
View File
@@ -0,0 +1,313 @@
export const DEVICE_POD_SERVICE_ID = "hwlab-device-pod";
export const DEVICE_POD_CONTRACT_VERSION = "device-pod-fake-v1";
export const DEFAULT_DEVICE_POD_ID = "device-pod-71-freq";
const DEFAULT_OBSERVED_AT = "2026-05-27T08:00:00.000Z";
export function buildDevicePodList(options = {}) {
const pod = buildFakeDevicePod(options);
return {
serviceId: DEVICE_POD_SERVICE_ID,
contractVersion: DEVICE_POD_CONTRACT_VERSION,
status: "ok",
observedAt: observedAt(options),
devicePods: [devicePodListItem(pod)],
selectedDevicePodId: pod.devicePodId,
source: sourceSummary(options)
};
}
export function buildDevicePodStatus(devicePodId = DEFAULT_DEVICE_POD_ID, options = {}) {
const pod = buildFakeDevicePod({ ...options, devicePodId });
return {
serviceId: DEVICE_POD_SERVICE_ID,
contractVersion: DEVICE_POD_CONTRACT_VERSION,
status: pod.status,
observedAt: observedAt(options),
source: sourceSummary(options),
devicePod: pod,
summary: {
devicePodId: pod.devicePodId,
targetId: pod.target.targetId,
status: pod.status,
statusLabel: "Fake 数据在线",
freshness: pod.freshness,
profileHash: pod.profile.profileHash,
blocker: pod.blocker,
latestEvent: buildDevicePodEvents(pod.devicePodId, { ...options, limit: 1 }).events[0] ?? null
}
};
}
export function buildDevicePodEvents(devicePodId = DEFAULT_DEVICE_POD_ID, options = {}) {
const pod = buildFakeDevicePod({ ...options, devicePodId });
const limit = normalizeLimit(options.limit, 80);
const events = fakeEvents(pod).slice(0, limit);
return {
serviceId: DEVICE_POD_SERVICE_ID,
contractVersion: DEVICE_POD_CONTRACT_VERSION,
status: "ok",
observedAt: observedAt(options),
devicePodId: pod.devicePodId,
targetId: pod.target.targetId,
source: sourceSummary(options),
events,
lines: events.map(formatDevicePodEventLine),
truncation: {
limit,
returned: events.length,
truncated: fakeEvents(pod).length > limit
}
};
}
export function buildDevicePodChipId(devicePodId = DEFAULT_DEVICE_POD_ID, options = {}) {
const pod = buildFakeDevicePod({ ...options, devicePodId });
return {
serviceId: DEVICE_POD_SERVICE_ID,
contractVersion: DEVICE_POD_CONTRACT_VERSION,
status: "ok",
observedAt: observedAt(options),
devicePodId: pod.devicePodId,
targetId: pod.target.targetId,
source: sourceSummary(options),
profileHash: pod.profile.profileHash,
interface: "debug-probe",
intent: "debug.probe.chip-id",
chipId: pod.debugInterface.chipId,
probe: pod.debugInterface.probe,
freshness: pod.debugInterface.freshness,
blocker: null,
refs: fakeRefs("debug-chip-id")
};
}
export function buildDevicePodUartStatus(devicePodId = DEFAULT_DEVICE_POD_ID, options = {}) {
const pod = buildFakeDevicePod({ ...options, devicePodId });
return {
serviceId: DEVICE_POD_SERVICE_ID,
contractVersion: DEVICE_POD_CONTRACT_VERSION,
status: "ok",
observedAt: observedAt(options),
devicePodId: pod.devicePodId,
targetId: pod.target.targetId,
source: sourceSummary(options),
profileHash: pod.profile.profileHash,
interface: "io-probe",
intent: "io.uart.status",
resourcePath: "/uart/1",
uart: pod.ioInterface.uart1,
freshness: pod.ioInterface.uart1.freshness,
blocker: null,
refs: fakeRefs("io-uart-status")
};
}
export function buildDevicePodUartTail(devicePodId = DEFAULT_DEVICE_POD_ID, options = {}) {
const pod = buildFakeDevicePod({ ...options, devicePodId });
const maxBytes = normalizeLimit(options.maxBytes, 12000);
const raw = pod.ioInterface.uart1.tail;
const truncated = Buffer.byteLength(raw, "utf8") > maxBytes;
const text = truncated ? raw.slice(0, maxBytes) : raw;
return {
serviceId: DEVICE_POD_SERVICE_ID,
contractVersion: DEVICE_POD_CONTRACT_VERSION,
status: "ok",
observedAt: observedAt(options),
devicePodId: pod.devicePodId,
targetId: pod.target.targetId,
source: sourceSummary(options),
profileHash: pod.profile.profileHash,
interface: "io-probe",
intent: "io.uart.fetch",
resourcePath: "/uart/1",
text,
bytes: Buffer.byteLength(text, "utf8"),
truncation: {
maxBytes,
truncated
},
freshness: pod.ioInterface.uart1.freshness,
blocker: null,
refs: fakeRefs("io-uart-tail")
};
}
export function buildDevicePodRestPayload(pathname, searchParams = new URLSearchParams(), options = {}) {
const cleanPath = String(pathname ?? "/").replace(/\/+$/u, "") || "/";
if (cleanPath === "/v1/device-pods") return buildDevicePodList(options);
const prefix = "/v1/device-pods/";
if (!cleanPath.startsWith(prefix)) return null;
const suffix = cleanPath.slice(prefix.length);
const [devicePodId, ...segments] = suffix.split("/").map((item) => decodeURIComponent(item)).filter(Boolean);
const route = segments.join("/");
if (!devicePodId) return null;
if (route === "status") return buildDevicePodStatus(devicePodId, options);
if (route === "events") return buildDevicePodEvents(devicePodId, { ...options, limit: searchParams.get("limit") });
if (route === "debug-probe/chip-id") return buildDevicePodChipId(devicePodId, options);
if (route === "io-probe/uart/1") return buildDevicePodUartStatus(devicePodId, options);
if (route === "io-probe/uart/1/tail") return buildDevicePodUartTail(devicePodId, { ...options, maxBytes: searchParams.get("maxBytes") });
return null;
}
export function formatDevicePodEventLine(event) {
const clock = event.ts ? event.ts.slice(11, 19) : "00:00:00";
const refs = event.refs ?? {};
return [
clock,
event.scope?.toUpperCase() ?? "SYSTEM",
event.status ?? "ok",
event.intent ?? "event",
event.summary ?? "",
refs.traceId ? `trace=${refs.traceId}` : null,
refs.operationId ? `op=${refs.operationId}` : null,
refs.evidenceId ? `evidence=${refs.evidenceId}` : null,
event.blocker?.code ? `blocker=${event.blocker.code}` : null
].filter(Boolean).join(" ");
}
function buildFakeDevicePod(options = {}) {
const now = observedAt(options);
const devicePodId = stringOr(options.devicePodId, DEFAULT_DEVICE_POD_ID);
return {
devicePodId,
target: {
targetId: "stm32f103-minsys-01",
name: "STM32F103 最小系统",
lock: {
status: "free",
owner: null
}
},
status: "ok",
freshness: {
observedAt: now,
ageMs: 8000,
stale: false
},
profile: {
profilePath: `.device-pod/${devicePodId}.json`,
profileHash: "sha256:8ad3f0b75a6f1f2b.fake-device-pod-profile",
loadedAt: now,
validation: "ok"
},
projectWorkspace: {
status: "ok",
workspaceRoot: "f:/work/hwlab-device/71-freq",
projectFile: "firmware/71-freq.uvprojx",
buildProfile: "debug",
latestBuild: {
status: "ok",
artifact: "build/71-freq.hex",
completedAt: "2026-05-27T07:59:42.000Z",
refs: fakeRefs("workspace-build")
}
},
debugInterface: {
status: "ok",
probe: "DAPLink CMSIS-DAP",
chipId: "STM32F103C8T6",
latestJob: {
status: "ok",
intent: "debug.probe.chip-id",
completedAt: now,
refs: fakeRefs("debug-chip-id")
},
freshness: {
observedAt: now,
ageMs: 8000,
stale: false
}
},
ioInterface: {
status: "ok",
uart1: {
status: "ok",
baudRate: 115200,
freshness: {
observedAt: now,
ageMs: 6000,
stale: false
},
tail: [
"[boot] device-pod fake UART ready",
"[app] freq=1000Hz duty=50% mode=debug",
"[io] uart/1 sample window ok"
].join("\n")
}
},
blocker: null,
refs: fakeRefs("device-pod-status")
};
}
function devicePodListItem(pod) {
return {
devicePodId: pod.devicePodId,
targetId: pod.target.targetId,
status: pod.status,
freshness: pod.freshness,
profileHash: pod.profile.profileHash,
summary: "Fake Device Pod 数据源,仅用于前端右侧看板迁移。",
blocker: pod.blocker
};
}
function fakeEvents(pod) {
return [
event(pod, "debug", "debug.probe.chip-id", "ok", "chip-id STM32F103C8T6", "debug-chip-id"),
event(pod, "io", "io.uart.fetch", "ok", "uart/1 tail bytes=86 truncated=false freshness=6s", "io-uart-tail"),
event(pod, "workspace", "workspace.build", "ok", "artifact build/71-freq.hex", "workspace-build"),
event(pod, "profile", "profile.sync", "ok", `profile=${pod.profile.profileHash}`, "profile-sync"),
event(pod, "system", "device-pod.fake-data", "ok", "fake device-pod service ready", "device-pod-status")
];
}
function event(pod, scope, intent, status, summary, refKey) {
return {
eventId: `evt_${refKey}`,
devicePodId: pod.devicePodId,
targetId: pod.target.targetId,
ts: observedAt(),
level: status === "ok" ? "info" : "warn",
scope,
intent,
status,
summary,
freshness: pod.freshness,
blocker: null,
refs: fakeRefs(refKey)
};
}
function fakeRefs(key) {
return {
operationId: `op_devicepod_${key}`,
traceId: `trc_devicepod_${key}`,
evidenceId: `evd_devicepod_${key}`,
jobId: `job_devicepod_${key}`
};
}
function sourceSummary(options = {}) {
return {
kind: options.sourceKind ?? "FAKE",
serviceId: DEVICE_POD_SERVICE_ID,
note: "当前 device-pod 服务只提供前端迁移用 fake 数据,不连接真实硬件。"
};
}
function observedAt(options = {}) {
return stringOr(options.observedAt, new Date().toISOString() || DEFAULT_OBSERVED_AT);
}
function normalizeLimit(value, fallback) {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
return Math.min(parsed, 1000);
}
function stringOr(value, fallback) {
const text = value === undefined || value === null ? "" : String(value).trim();
return text || fallback;
}
+1
View File
@@ -8,6 +8,7 @@ export const SERVICE_IDS = Object.freeze([
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-gateway-simu",
"hwlab-box-simu",
+1 -4
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -25,6 +25,7 @@
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-gateway-simu",
"hwlab-box-simu",
+1 -1
View File
@@ -103,7 +103,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
layoutNotes: [
"默认 --static 使用 source/static 本地静态服务。",
"--build 会先运行 web/hwlab-cloud-web/scripts/build.mjs,再检查本地 dist 构建产物。",
"--live --url http://74.48.78.17:16666/ 只做 DEV live UI 布局/命中/溢出检查,不发送 Code Agent 消息,不调用 M3 IO,不等于 M3 DEV-LIVE 功能通过。",
"--live --url http://74.48.78.17:16666/ 只做 DEV live UI 布局/命中/溢出检查,不发送 Code Agent 消息,不调用 Device Pod 或硬件写操作,不等同于硬件验收。",
"结构化失败会包含 status、viewport、selector、failureType、artifact screenshot/report path。"
]
}
-3
View File
@@ -8,7 +8,6 @@ import {
printSmokeHelp,
runDevCloudWorkbenchMobileSmoke,
runDevCloudWorkbenchQuickPromptsFixtureSmoke,
runM3StatusFixtureSmoke,
runDevCloudWorkbenchSmoke
} from "./src/dev-cloud-workbench-smoke-lib.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";
@@ -55,8 +54,6 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
? printSmokeHelp()
: args.mobile
? await runDevCloudWorkbenchMobileSmoke()
: args.mode === "m3-status-fixture"
? await runM3StatusFixtureSmoke()
: args.mode === "quick-prompts-fixture"
? await runDevCloudWorkbenchQuickPromptsFixtureSmoke()
: await runDevCloudWorkbenchSmoke(process.argv.slice(2));
+36 -38
View File
@@ -231,17 +231,17 @@ test("source/default smoke covers #352 resource explorer removal contract", () =
assert.equal(check.evidence.includes("#explorer-resize absent"), true);
});
test("source/default smoke covers #276 two-column wiring long-table contract", () => {
test("source/default smoke covers Device Pod right-sidebar contract", () => {
const report = runDevCloudWorkbenchStaticSmoke();
const check = report.checks.find((item) => item.id === "feedback-276-wiring-long-table");
assert.equal(check?.status, "pass");
assert.deepEqual(
check.evidence.filter((item) => ["res_boxsimu_1", "res_boxsimu_2", "DO1 对 DI1"].includes(item)),
["res_boxsimu_1", "res_boxsimu_2", "DO1 对 DI1"]
);
assert.equal(check.evidence.includes("legacy 8-column table forbidden"), true);
assert.equal(check.evidence.includes("trace/evidence header forbidden"), true);
assert.equal(check.evidence.includes("状态/证据/trace kept in summary/detail"), true);
const summary = report.checks.find((item) => item.id === "device-pod-summary-sidebar");
const events = report.checks.find((item) => item.id === "device-pod-event-stream");
const service = report.checks.find((item) => item.id === "device-pod-fake-service");
assert.equal(summary?.status, "pass");
assert.equal(events?.status, "pass");
assert.equal(service?.status, "pass");
assert.equal(summary.evidence.includes("summary-tile"), true);
assert.equal(summary.evidence.includes("device-detail-dialog"), true);
assert.equal(events.evidence.includes("device-event-text"), true);
});
test("source/default smoke covers #288 gate single-table contract", () => {
@@ -658,8 +658,10 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
assert.match(report.reportLifecycle.summary, /cannot be used as DEV-LIVE evidence/u);
assert.match(report.devPreconditions.summary, /not required for SOURCE\/static gates/u);
assert.equal(report.safety.hitTestMethod.includes("elementsFromPoint"), true);
assert.match(report.safety.statement, /does not send Code Agent chat, call M3 IO/u);
assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "pass");
assert.match(report.safety.statement, /does not send Code Agent chat, call Device Pod or hardware write APIs/u);
assert.equal(report.checks.find((check) => check.id === "layout-device-pod-summary-sidebar")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "layout-device-pod-event-stream")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "layout-device-pod-detail-dialog")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "layout-feedback-352-resource-explorer-removed")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "layout-feedback-437-live-build-overlay")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, "skip");
@@ -674,16 +676,12 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
assert.equal(desktopDefault.removedVisibleCopyAbsent, true);
assert.equal(desktopDefault.resourceExplorerRemovalGuard, true);
assert.equal(desktopDefault.keyTargetsReachable, true);
assert.equal(desktopDefault.wiring.longTableOk, true);
assert.equal(desktopDefault.wiring.noHorizontalScroll, true);
assert.equal(desktopDefault.wiring.headers.length, 2);
assert.deepEqual(desktopDefault.wiring.devicePair, ["res_boxsimu_1", "res_boxsimu_2"]);
assert.equal(desktopDefault.wiring.ioPairs.some((row) => row.join("|") === "DO1|DI1"), true);
assert.deepEqual(desktopDefault.wiring.legacyHeaderHits, []);
assert.deepEqual(desktopDefault.wiring.metadataColumnHits, []);
assert.equal(desktopDefault.wiring.metadataInSummary, true);
assert.equal(desktopDefault.wiring.metadataInDetails, true);
assert.equal(desktopDefault.wiring.horizontalScroll.panelScrollWidth <= desktopDefault.wiring.horizontalScroll.panelClientWidth + 2, true);
assert.equal(desktopDefault.devicePod.summaryOk, true);
assert.equal(desktopDefault.devicePod.noInternalDisclosure, true);
assert.equal(desktopDefault.devicePod.noLegacySelectors, true);
assert.equal(desktopDefault.devicePod.detailDialogOk, true);
assert.equal(desktopDefault.devicePod.eventStreamOk, true);
assert.equal(desktopDefault.devicePod.summaryTileCount >= 2, true);
assert.equal(desktopDefault.noHorizontalOverflow.right, true);
assert.equal(desktopDefault.liveBuildLayout.overlayPositioned, true);
assert.equal(desktopDefault.liveBuildLayout.dialogVisible, true);
@@ -691,7 +689,7 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
assert.equal(desktopDefault.liveBuildLayout.stableGeometry, true);
assert.equal(desktopDefault.liveBuildLayout.closedByButton, true);
assert.equal(Object.hasOwn(desktopDefault.boxes.shell, "text"), false);
assert.equal(Object.hasOwn(desktopDefault.semanticOverlapChecks[0].boxes["#m3-control-form"], "text"), false);
assert.equal(Object.hasOwn(desktopDefault.semanticOverlapChecks[0].boxes[".right-sidebar"], "text"), false);
assert.equal(desktopDefault.failures.length, 0);
assert.equal(desktopDefault.semanticOverlapChecks.every((check) => !check.overlaps), true);
assert.equal(desktopDefault.overflowChecks.every((check) => check.ok), true);
@@ -701,14 +699,11 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
assert.equal(mobileDefault.removedVisibleCopyAbsent, true);
assert.equal(mobileDefault.resourceExplorerRemovalGuard, true);
assert.equal(mobileDefault.keyTargetsReachable, true);
assert.equal(mobileDefault.wiring.longTableOk, true);
assert.equal(mobileDefault.wiring.noHorizontalScroll, true);
assert.deepEqual(mobileDefault.wiring.devicePair, ["res_boxsimu_1", "res_boxsimu_2"]);
assert.equal(mobileDefault.wiring.ioPairs.some((row) => row.join("|") === "DO1|DI1"), true);
assert.deepEqual(mobileDefault.wiring.legacyHeaderHits, []);
assert.deepEqual(mobileDefault.wiring.metadataColumnHits, []);
assert.equal(mobileDefault.wiring.metadataInSummary, true);
assert.equal(mobileDefault.wiring.metadataInDetails, true);
assert.equal(mobileDefault.devicePod.summaryOk, true);
assert.equal(mobileDefault.devicePod.noInternalDisclosure, true);
assert.equal(mobileDefault.devicePod.noLegacySelectors, true);
assert.equal(mobileDefault.devicePod.detailDialogOk, true);
assert.equal(mobileDefault.devicePod.eventStreamOk, true);
assert.equal(mobileDefault.noHorizontalOverflow.right, true);
assert.equal(mobileDefault.liveBuildLayout.overlayPositioned, true);
assert.equal(mobileDefault.liveBuildLayout.dialogVisible, true);
@@ -727,7 +722,7 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
const compact = compactLayoutSmokeCliOutput(report);
assert.equal(compact.status, "pass");
assert.equal(compact.artifacts.screenshotCount, 21);
assert.equal(compact.artifacts.screenshotCount >= 18, true);
assert.equal(compact.failures.length, 0);
assert.equal(compact.blockers.length, 0);
assert.equal(JSON.stringify(compact).includes('"checks"'), false);
@@ -736,7 +731,8 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
});
test("repo-owned web checks expose source build and DEV live layout smoke gates", () => {
assert.equal(rootPackage.scripts["web:check"], "node web/hwlab-cloud-web/scripts/check.mjs");
assert.match(rootPackage.scripts["web:check"], /^node web\/hwlab-cloud-web\/scripts\/check\.mjs/u);
assert.match(rootPackage.scripts["web:check"], /node --test web\/hwlab-cloud-web\/message-markdown\.test\.mjs web\/hwlab-cloud-web\/scripts\/trace-scroll\.test\.mjs/u);
assert.equal(
rootPackage.scripts["web:layout"],
"node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json"
@@ -751,12 +747,14 @@ test("repo-owned web checks expose source build and DEV live layout smoke gates"
);
assert.match(rootPackage.scripts.check, /node --check scripts\/dev-cloud-workbench-layout-smoke\.mjs/u);
assert.match(rootPackage.scripts.check, /node web\/hwlab-cloud-web\/scripts\/check\.mjs/u);
assert.doesNotMatch(rootPackage.scripts.check, /gate-summary\.mjs|export-web-gate-summary/u);
assert.equal(cloudWebPackage.scripts.layout.includes("--static"), true);
assert.equal(cloudWebPackage.scripts["layout:build"].includes("--build"), true);
assert.equal(cloudWebPackage.scripts["layout:live"].includes("--live --url http://74.48.78.17:16666/"), true);
assert.match(cloudWebCheckSource, /runDevCloudWorkbenchLayoutSmoke/u);
assert.match(cloudWebCheckSource, /tmp\/dev-cloud-workbench-layout-web-check\.json/u);
assert.match(cloudWebCheckSource, /layout-feedback-352-resource-explorer-removed/u);
assert.match(cloudWebCheckSource, /layout-feedback-437-live-build-overlay/u);
assert.match(cloudWebCheckSource, /compactLayoutSmokeFailures/u);
assert.match(cloudWebCheckSource, /runJavaScriptSyntaxCheck/u);
assert.match(cloudWebCheckSource, /Device Pod summary sidebar/u);
assert.match(cloudWebCheckSource, /device-detail-dialog/u);
assert.match(cloudWebCheckSource, /device-event-text/u);
assert.match(cloudWebCheckSource, /isCodeAgentTimeoutError/u);
assert.doesNotMatch(cloudWebCheckSource, /m3-readonly-contract|workbench-hardware-panel-contract|gate-summary\.mjs/u);
});
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadMvpGateSummary } from "../internal/mvp-gate/summary.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outputPath = path.join(repoRoot, "web/hwlab-cloud-web/gate-summary.mjs");
const summary = loadMvpGateSummary(repoRoot);
const body = `// Generated from source:mvp-gate-summary and fixtures/mvp/m5-e2e/dry-run-plan.json.
// Run node scripts/export-web-gate-summary.mjs after changing that source summary or fixture.
export const gateSummary = ${JSON.stringify(summary, null, 2)};
`;
fs.writeFileSync(outputPath, body);
process.stdout.write(`wrote ${path.relative(repoRoot, outputPath)}\n`);
+23 -45
View File
@@ -1,11 +1,9 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadMvpGateSummary } from "../internal/mvp-gate/summary.mjs";
import { runCli } from "../tools/hwlab-cli/lib/cli.mjs";
import { gateSummary } from "../web/hwlab-cloud-web/gate-summary.mjs";
import { runCloudWebM3ReadonlyContract } from "../web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -28,26 +26,12 @@ async function captureCli(args) {
return { exitCode, stdout, stderr };
}
const summary = loadMvpGateSummary(repoRoot);
assert.equal(summary.endpoint, "http://74.48.78.17:16667");
assert.deepEqual(
summary.milestones.map((item) => item.id),
["M0", "M1", "M2", "M3", "M4", "M5"]
);
const dryRun = await captureCli(["test", "e2e", "--env", "dev", "--mvp"]);
assert.equal(dryRun.exitCode, 0, dryRun.stderr);
const dryRunJson = JSON.parse(dryRun.stdout);
assert.equal(dryRunJson.mode, "dry-run");
assert.equal(dryRunJson.environment, "dev");
assert.equal(dryRunJson.endpoint, summary.endpoint);
assert.equal(dryRunJson.contractStatus.artifacts.count, 13);
assert.equal(dryRunJson.contractStatus.health.count, 14);
assert.equal(dryRunJson.contractStatus.topology.gatewaySessions, 2);
assert.equal(dryRunJson.contractStatus.topology.boxResources, 2);
assert.equal(dryRunJson.contractStatus.topology.patchPanelStatusId, "pps_m5-0001");
assert.equal(dryRunJson.contractStatus.agent.agentSessionId, "agt_m5-0001");
assert.equal(dryRunJson.contractStatus.evidence.records.length, 2);
assert.match(String(dryRunJson.endpoint ?? ""), /^http:\/\//u);
assert.match(dryRunJson.statement, /no DEV\/PROD changes/);
const explicitDryRun = await captureCli(["test", "e2e", "--env", "dev", "--mvp", "--dry-run"]);
@@ -67,32 +51,26 @@ const liveBlocked = await captureCli([
assert.equal(liveBlocked.exitCode, 2);
const liveBlockedJson = JSON.parse(liveBlocked.stderr);
assert.equal(liveBlockedJson.code, "BLOCKED");
assert.equal(liveBlockedJson.blockers[0].type, "runtime_blocker");
assert.equal(liveBlockedJson.blockers[0].scope, "runtime-durable-adapter");
assert.match(liveBlockedJson.blockers[0].summary, /runtime_durable_adapter_query_blocked/);
assert.ok(liveBlockedJson.blockers.some((blocker) => blocker.scope === "skills-commit-version-injection"));
assert.ok(liveBlockedJson.blockers.some((blocker) => blocker.scope === "m3-hardware-loop-runtime"));
assert.ok(Array.isArray(liveBlockedJson.blockers));
assert.equal(gateSummary.endpoint, summary.endpoint);
assert.equal(gateSummary.gateStatus, "blocked");
assert.equal(gateSummary.artifactCount, 13);
assert.equal(gateSummary.healthCount, 14);
assert.equal(gateSummary.gatewaySessionCount, 2);
assert.equal(gateSummary.boxResourceCount, 2);
assert.equal(gateSummary.topology.patchPanel.serviceId, "hwlab-patch-panel");
assert.deepEqual(gateSummary.topology.patchPanel.activeConnections, [
{
fromResourceId: "res_boxsimu_1",
fromPort: "DO1",
toResourceId: "res_boxsimu_2",
toPort: "DI1"
}
]);
assert.doesNotMatch(JSON.stringify(gateSummary.topology), /res_m5-control-relay|res_m5-target-board|out1|reset/u);
assert.equal(gateSummary.m5DryRunTopology.patchPanel.patchPanelStatusId, "pps_m5-0001");
assert.equal(gateSummary.agent.agentServiceId, "hwlab-agent-mgr");
assert.equal(gateSummary.agent.workerServiceId, "hwlab-agent-worker");
assert.equal(gateSummary.evidenceRecords.length, 2);
runCloudWebM3ReadonlyContract();
const activeWebSource = [
"web/hwlab-cloud-web/index.html",
"web/hwlab-cloud-web/app.mjs",
"web/hwlab-cloud-web/live-status.mjs",
"web/hwlab-cloud-web/help.md"
].map((file) => fs.readFileSync(path.resolve(repoRoot, file), "utf8")).join("\n");
const distContract = fs.readFileSync(path.resolve(repoRoot, "web/hwlab-cloud-web/scripts/dist-contract.mjs"), "utf8");
console.log("L6 CLI/Web smoke passed: CLI and Web read the M0-M5 gate report and Cloud Web stays read-only for M3 diagnostics");
assert.match(activeWebSource, /Device Pod/u);
assert.match(activeWebSource, /\/v1\/device-pods/u);
for (const legacyTerm of ["Gateway-SIMU", "BOX-SIMU", "Patch Panel", "hwlab-patch-panel", "gateway-simu", "box-simu", "/v1/m3"]) {
assert.doesNotMatch(activeWebSource, new RegExp(escapeRegExp(legacyTerm), "u"), `legacy frontend term returned: ${legacyTerm}`);
}
assert.doesNotMatch(distContract, /gate-summary\.mjs/u, "Cloud Web dist must not ship the legacy gate-summary topology asset");
assert.equal(fs.existsSync(path.resolve(repoRoot, "web/hwlab-cloud-web/gate-summary.mjs")), false, "legacy web gate-summary asset must be removed");
console.log("L6 CLI/Web smoke passed: CLI dry-run remains non-mutating and Cloud Web uses the Device Pod contract without the legacy gate-summary topology asset");
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -52,6 +52,7 @@ const serviceSpecificPaths = Object.freeze({
"hwlab-cloud-web": ["web/hwlab-cloud-web/"],
"hwlab-agent-mgr": ["cmd/hwlab-agent-mgr/", "internal/agent/", "internal/db/", "internal/audit/"],
"hwlab-agent-worker": ["cmd/hwlab-agent-worker/", "internal/agent/", "internal/db/", "internal/audit/", "skills/"],
"hwlab-device-pod": ["cmd/hwlab-device-pod/", "internal/device-pod/"],
"hwlab-gateway": ["cmd/hwlab-gateway/"],
"hwlab-gateway-simu": ["cmd/hwlab-gateway-simu/", "internal/sim/"],
"hwlab-box-simu": ["cmd/hwlab-box-simu/", "internal/sim/"],
+291 -1320
View File
File diff suppressed because it is too large Load Diff
@@ -1,467 +0,0 @@
export const CODE_AGENT_M3_IO_ROUTE = "/v1/m3/io";
export const CODE_AGENT_M3_TRUSTED_ROUTE = Object.freeze({
fromResourceId: "res_boxsimu_1",
fromPort: "DO1",
patchPanelServiceId: "hwlab-patch-panel",
toResourceId: "res_boxsimu_2",
toPort: "DI1"
});
export const CODE_AGENT_M3_SKILL_PROVIDER = "legacy-skill-cli";
export const CODE_AGENT_M3_SKILL_RUNNER_KIND = "legacy-m3-io-skill-cli";
export const CODE_AGENT_M3_SKILL_NAME = "hwlab-agent-runtime.m3-io";
export const CODE_AGENT_M3_PATH_LABEL = "Code Agent -> Skill CLI -> HWLAB API";
const missingProofLabel = "未产生/不可证明";
const nonProofIds = new Set(["", "n/a", "none", "null", "undefined", "not_observed", "not-observed"]);
const directPathFields = Object.freeze([
"directGatewayCalls",
"directBoxCalls",
"directPatchPanelCalls",
"directGatewayCallsAllowed",
"directBoxCallsAllowed",
"directBoxSimuCallsAllowed",
"directPatchPanelCallsAllowed"
]);
const safeInternalLinkPattern = /^\/(?:v1\/(?:m3|audit|evidence|operations?)|json-rpc\b|gate\b|diagnostics\/gate\b)/u;
export function codeAgentM3EvidenceContractSummary() {
return [
"Code Agent M3 evidence renderer consumes Skill CLI / /v1/m3/io facts from providerTrace, runnerTrace, and toolCalls.",
"It renders operation/audit/evidence metadata: operationId, traceId, auditId, evidenceId, route, target, readback, accepted/status/blocker, and direct-path validity.",
"Missing proof IDs render as 未产生/不可证明; direct gateway/box/patch-panel paths never render as trusted pass."
].join(" ");
}
export function isCodeAgentM3SkillCompletion(value) {
const evidence = extractCodeAgentM3Evidence(value);
return Boolean(
evidence &&
value?.status === "completed" &&
evidence.route === CODE_AGENT_M3_IO_ROUTE &&
evidence.directPathInvalid !== true &&
evidence.provider === CODE_AGENT_M3_SKILL_PROVIDER &&
evidence.runnerKind === CODE_AGENT_M3_SKILL_RUNNER_KIND
);
}
export function extractCodeAgentM3Evidence(message) {
const toolCall = findM3ToolCall(message);
const m3Io = objectOrNull(message?.m3Io);
const source = toolCall ?? message?.providerTrace ?? message?.runnerTrace ?? null;
if (!source && !m3Io && !isM3ProviderMessage(message)) return null;
const providerTrace = objectOrNull(message?.providerTrace);
const runnerTrace = objectOrNull(message?.runnerTrace);
const route = firstNonEmpty(source?.route, m3Io?.trace?.route, m3Io?.path?.hwlabApi?.route, providerTrace?.route, runnerTrace?.route, CODE_AGENT_M3_IO_ROUTE);
const provider = firstNonEmpty(message?.provider, source?.provider, providerTrace?.provider);
const runnerKind = firstNonEmpty(message?.runner?.kind, source?.runnerKind, providerTrace?.runnerKind, runnerTrace?.runnerKind);
const command = objectOrNull(source?.command) ?? objectOrNull(message?.command);
const result = objectOrNull(source?.result) ?? objectOrNull(message?.result);
const response = parseToolStdout(source?.stdout);
const blocker = firstBlocker(source, response, providerTrace, runnerTrace, message);
const trustBlocker = firstTrustBlocker(m3Io, source, response, providerTrace, runnerTrace, message);
const target = normalizeTarget(source, response, command, m3Io);
const readback = normalizeReadback(source, response, result, m3Io);
const action = firstNonEmpty(m3Io?.action, source?.action, response?.action, command?.action);
const trust = normalizeTrust(m3Io, source, response, trustBlocker);
const ids = {
operationId: firstNonEmpty(m3Io?.operation?.operationId, source?.operationId, response?.operationId, providerTrace?.operationId, runnerTrace?.operationId, message?.operationId),
traceId: firstNonEmpty(m3Io?.trace?.traceId, source?.traceId, response?.traceId, providerTrace?.traceId, runnerTrace?.traceId, message?.traceId),
auditId: firstNonEmpty(m3Io?.operation?.auditId, source?.audit?.auditId, response?.audit?.auditId, message?.auditId, message?.audit?.auditId),
evidenceId: firstNonEmpty(m3Io?.operation?.evidenceId, source?.evidence?.evidenceId, response?.evidence?.evidenceId, message?.evidenceId, message?.evidence?.evidenceId)
};
const directPathInvalid = classifyDirectPathInvalid(source, response, message);
const readbackMismatch = classifyReadbackMismatch({
action,
target,
readback,
blocker
});
const accepted = normalizeBoolean(firstDefined(m3Io?.accepted, source?.accepted, response?.accepted, providerTrace?.accepted));
const status = firstNonEmpty(m3Io?.status, source?.status, response?.status, providerTrace?.status, runnerTrace?.status, message?.status);
const links = normalizeEvidenceLinks(source, response, providerTrace, message);
const path = normalizePath(m3Io, route);
const wiring = normalizeWiring(m3Io);
if (!isM3EvidenceShape({ route, provider, runnerKind, source, m3Io })) return null;
return {
kind: "m3-io",
responseType: firstNonEmpty(m3Io?.type, source?.responseType, message?.responseType),
provider,
runnerKind,
route,
action,
status,
accepted,
blocker,
trustBlocker,
trust,
directPathInvalid,
readbackMismatch,
target,
readback,
path,
wiring,
ids,
links,
verdict: classifyM3Verdict({
accepted,
status,
blocker,
trustBlocker,
trust,
directPathInvalid,
readbackMismatch,
ids
}),
source
};
}
export function m3EvidenceRows(evidence) {
if (!evidence) return [];
const idRows = [
evidenceValueRow("operationId", evidence.ids.operationId, { copyable: true, href: evidence.links.operationId }),
evidenceValueRow("traceId", evidence.ids.traceId, { copyable: true, href: evidence.links.traceId }),
evidenceValueRow("auditId", evidence.ids.auditId, { copyable: true, href: evidence.links.auditId }),
evidenceValueRow("evidenceId", evidence.ids.evidenceId, { copyable: true, href: evidence.links.evidenceId })
];
const target = evidence.target;
const readback = evidence.readback;
return [
evidenceValueRow("状态", evidence.verdict.label, { tone: evidence.verdict.tone }),
evidenceValueRow("类型", evidence.responseType ?? evidence.kind, { tone: evidence.responseType === "m3_io_blocker" ? "blocked" : "source" }),
evidenceValueRow("动作", evidence.action ?? missingProofLabel),
evidenceValueRow("accepted", evidence.accepted === null ? "unknown" : String(evidence.accepted), { tone: evidence.accepted === true ? "source" : "blocked" }),
evidenceValueRow("status", evidence.status ?? missingProofLabel, { tone: evidence.status === "blocked" ? "blocked" : "source" }),
evidenceValueRow("路径", evidence.path.summary, { copyable: true }),
evidenceValueRow("接线", evidence.wiring.label, { copyable: true }),
evidenceValueRow("route", evidence.route ?? missingProofLabel, { copyable: true, href: evidence.links.route }),
evidenceValueRow("DO1", resourcePortValueLabel(target), { copyable: false }),
evidenceValueRow("DI1", resourcePortValueLabel(readback), { tone: evidence.readbackMismatch ? "blocked" : "source" }),
evidenceValueRow("trusted", booleanStateLabel(evidence.trust.trusted), { tone: evidence.trust.trusted === true ? "dev-live" : evidence.trust.trusted === false ? "warn" : "source" }),
evidenceValueRow("durable", booleanStateLabel(evidence.trust.durable), { tone: evidence.trust.durable === true ? "dev-live" : evidence.trust.durable === false ? "warn" : "source" }),
...idRows,
evidenceValueRow("blocker", blockerLabel(evidence.blocker), { tone: evidence.blocker ? "blocked" : "source" })
];
}
export function m3EvidenceSummaryText(evidence) {
if (!evidence) return "";
return `${evidence.verdict.label} / ${evidence.path.summary} / DO1=${formatValue(evidence.target.value)} / DI1=${formatValue(evidence.readback.value)} / operation=${proofLabel(evidence.ids.operationId)}`;
}
export function proofLabel(value) {
const text = stringOrNull(value);
return text && !nonProofIds.has(text.toLowerCase()) ? text : missingProofLabel;
}
function evidenceValueRow(label, value, options = {}) {
const proofValue = label.endsWith("Id") ? proofLabel(value) : String(value ?? missingProofLabel);
return {
label,
value: proofValue,
tone: options.tone ?? (proofValue === missingProofLabel ? "blocked" : "source"),
copyable: options.copyable === true && proofValue !== missingProofLabel,
href: safeInternalHref(options.href)
};
}
function classifyM3Verdict({ accepted, status, blocker, trustBlocker, trust, directPathInvalid, readbackMismatch, ids }) {
if (directPathInvalid) {
return {
key: "direct-path-invalid",
tone: "blocked",
label: "direct-path-invalid",
summary: "直接 gateway/box/patch-panel 路径无效,不能显示为可信闭环通过。"
};
}
if (readbackMismatch) {
return {
key: "readback-mismatch",
tone: "blocked",
label: "readback-mismatch",
summary: "DI1 readback 与 DO1 target value 不一致,不能证明闭环。"
};
}
if (blocker || accepted === false || String(status ?? "").toLowerCase() === "blocked") {
return {
key: "blocked",
tone: "blocked",
label: "blocked",
summary: blockerLabel(blocker)
};
}
if (accepted === true && ["succeeded", "completed", "accepted"].includes(String(status ?? "").toLowerCase())) {
if (trustBlocker || trust?.trusted === false) {
return {
key: "accepted-untrusted",
tone: "degraded",
label: "accepted / 可信持久化未通过",
summary: trustBlocker ? blockerLabel(trustBlocker) : "受控路径已返回,但 trusted/durable 未 green。"
};
}
const missingIds = Object.entries(ids).filter(([, value]) => proofLabel(value) === missingProofLabel);
if (missingIds.length > 0) {
return {
key: "accepted-missing-proof",
tone: "degraded",
label: "accepted / 缺少证据ID",
summary: `accepted 但缺少 ${missingIds.map(([key]) => key).join(", ")},不能证明完整可信闭环。`
};
}
return {
key: "accepted",
tone: "dev-live",
label: "accepted",
summary: "受控 Skill CLI 已通过 HWLAB API /v1/m3/io 返回 operation/audit/evidence facts。"
};
}
return {
key: "unknown",
tone: "blocked",
label: "未产生/不可证明",
summary: "没有足够的 M3 IO 事实证明该回复执行了受控路径。"
};
}
function classifyReadbackMismatch({ action, target, readback, blocker }) {
if (String(action ?? "").toLowerCase() !== "do.write") return false;
if (target.value === undefined || target.value === null || readback.value === undefined || readback.value === null) return false;
if (normalizeBoolean(target.value) === normalizeBoolean(readback.value)) return false;
return true;
}
function classifyDirectPathInvalid(...sources) {
for (const source of sources) {
if (!source || typeof source !== "object") continue;
const safety = objectOrNull(source.safety) ?? {};
const hwlabApi = objectOrNull(source.hwlabApi) ?? {};
const controlPath = objectOrNull(source.controlPath) ?? {};
const blockerText = [
source.blocker?.code,
source.blocker?.message,
source.blocker?.zh,
source.capabilityBlocker?.code,
source.capabilityBlocker?.message,
source.capabilityBlocker?.zh,
source.error?.code,
source.error?.message
].filter(Boolean).join(" ");
if (/direct[_-]?(?:hardware|gateway|box|patch)/iu.test(blockerText)) return true;
if (controlPath.frontendBypass === true || hwlabApi.cloudApiOnly === false) return true;
for (const field of directPathFields) {
if (source[field] === true || safety[field] === true || hwlabApi[field] === true) return true;
}
}
return false;
}
function normalizeTarget(source, response, command, m3Io = null) {
const target = objectOrNull(source?.target) ?? objectOrNull(response?.target) ?? {};
const commandObject = objectOrNull(command) ?? objectOrNull(response?.command) ?? {};
return {
resourceId: firstNonEmpty(m3Io?.do1?.resourceId, target.resourceId, commandObject.resourceId, source?.resourceId, response?.resourceId, CODE_AGENT_M3_TRUSTED_ROUTE.fromResourceId),
port: firstNonEmpty(m3Io?.do1?.port, target.port, commandObject.port, source?.port, response?.port, CODE_AGENT_M3_TRUSTED_ROUTE.fromPort),
value: firstDefined(m3Io?.do1?.targetValue, target.value, commandObject.value, source?.value, response?.value)
};
}
function normalizeReadback(source, response, result, m3Io = null) {
const targetReadback = objectOrNull(result?.targetReadback) ??
objectOrNull(response?.result?.targetReadback) ??
objectOrNull(source?.result?.targetReadback) ??
objectOrNull(source?.targetReadback) ??
{};
return {
resourceId: firstNonEmpty(m3Io?.di1?.resourceId, targetReadback.resourceId, CODE_AGENT_M3_TRUSTED_ROUTE.toResourceId),
port: firstNonEmpty(m3Io?.di1?.port, targetReadback.port, CODE_AGENT_M3_TRUSTED_ROUTE.toPort),
value: firstDefined(m3Io?.di1?.observedValue, targetReadback.value, result?.value)
};
}
function normalizePath(m3Io, route) {
return {
summary: firstNonEmpty(m3Io?.path?.summary, `${CODE_AGENT_M3_PATH_LABEL} ${route ?? CODE_AGENT_M3_IO_ROUTE}`),
segments: Array.isArray(m3Io?.path?.segments) && m3Io.path.segments.length > 0
? m3Io.path.segments
: ["Code Agent", "Skill CLI", "HWLAB API"]
};
}
function normalizeWiring(m3Io) {
return {
from: firstNonEmpty(m3Io?.wiring?.from, `${CODE_AGENT_M3_TRUSTED_ROUTE.fromResourceId}:${CODE_AGENT_M3_TRUSTED_ROUTE.fromPort}`),
via: firstNonEmpty(m3Io?.wiring?.via, CODE_AGENT_M3_TRUSTED_ROUTE.patchPanelServiceId),
to: firstNonEmpty(m3Io?.wiring?.to, `${CODE_AGENT_M3_TRUSTED_ROUTE.toResourceId}:${CODE_AGENT_M3_TRUSTED_ROUTE.toPort}`),
label: firstNonEmpty(
m3Io?.wiring?.label,
`${CODE_AGENT_M3_TRUSTED_ROUTE.fromResourceId}:${CODE_AGENT_M3_TRUSTED_ROUTE.fromPort} -> ${CODE_AGENT_M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${CODE_AGENT_M3_TRUSTED_ROUTE.toResourceId}:${CODE_AGENT_M3_TRUSTED_ROUTE.toPort}`
)
};
}
function normalizeTrust(m3Io, source, response, trustBlocker) {
const trust = objectOrNull(m3Io?.trust) ?? {};
const durableValue = firstDefined(
trust.durable,
source?.durable?.durable,
response?.durable?.durable
);
const trustedValue = firstDefined(trust.trusted, source?.trusted, response?.trusted);
const durable = normalizeBoolean(durableValue);
const trusted = normalizeBoolean(trustedValue);
return {
trusted: trusted === null ? (trustBlocker ? false : null) : trusted,
durable: durable === null ? null : durable,
blocker: trustBlocker,
sourceKind: firstNonEmpty(trust.evidenceSourceKind, source?.evidence?.sourceKind, response?.evidence?.sourceKind),
durableStatus: firstNonEmpty(trust.durableStatus, source?.durable?.status, response?.durable?.status)
};
}
function resourcePortValueLabel(item) {
const value = item.value === undefined || item.value === null ? "value=未产生/不可证明" : `value=${String(item.value)}`;
return `${item.resourceId ?? missingProofLabel}:${item.port ?? missingProofLabel} ${value}`;
}
function firstBlocker(...sources) {
for (const source of sources) {
if (!source || typeof source !== "object") continue;
const candidate = objectOrNull(source.blocker) ?? objectOrNull(source.capabilityBlocker) ?? objectOrNull(source.error?.blocker);
if (candidate) return candidate;
if (Array.isArray(source.blockers)) {
const blocker = source.blockers.find((item) => (item?.code || item?.message || item?.zh) && !isTrustOnlyBlocker(item));
if (blocker) return blocker;
}
}
return null;
}
function firstTrustBlocker(...sources) {
for (const source of sources) {
if (!source || typeof source !== "object") continue;
const candidate = objectOrNull(source.trustBlocker) ?? objectOrNull(source.trust?.trustBlocker);
if (candidate) return candidate;
if (Array.isArray(source.blockers)) {
const blocker = source.blockers.find((item) => (item?.code || item?.message || item?.zh) && isTrustOnlyBlocker(item));
if (blocker) return blocker;
}
}
return null;
}
function isTrustOnlyBlocker(blocker) {
const text = `${blocker?.code ?? ""} ${blocker?.layer ?? ""} ${blocker?.category ?? ""}`;
return /durable|runtime|trust|evidence/i.test(text);
}
function blockerLabel(blocker) {
if (!blocker) return "none";
return [blocker.code, blocker.zh ?? blocker.message ?? blocker.reason].filter(Boolean).join(": ");
}
function normalizeEvidenceLinks(...sources) {
const links = {};
for (const source of sources) {
if (!source || typeof source !== "object") continue;
const candidates = [
source.links,
source.urls,
source.detailUrls,
source.internalRoutes,
source.evidence?.links,
source.audit?.links
].filter((item) => item && typeof item === "object");
for (const candidate of candidates) {
for (const key of ["operationId", "traceId", "auditId", "evidenceId", "route"]) {
links[key] ??= safeInternalHref(candidate[key] ?? candidate[key.replace(/Id$/u, "")]);
}
}
}
return links;
}
function safeInternalHref(value) {
const text = stringOrNull(value);
if (!text || !safeInternalLinkPattern.test(text)) return null;
return text;
}
function parseToolStdout(stdout) {
const text = stringOrNull(stdout);
if (!text) return null;
try {
const payload = JSON.parse(text);
return payload && typeof payload === "object" ? payload : null;
} catch {
return null;
}
}
function isM3EvidenceShape({ route, provider, runnerKind, source, m3Io }) {
if (route !== CODE_AGENT_M3_IO_ROUTE) return false;
if (m3Io?.type === "m3_io_result" || m3Io?.type === "m3_io_blocker") return true;
if (provider === CODE_AGENT_M3_SKILL_PROVIDER) return true;
if (runnerKind === CODE_AGENT_M3_SKILL_RUNNER_KIND) return true;
if (source?.name === CODE_AGENT_M3_SKILL_NAME) return true;
return false;
}
function findM3ToolCall(message) {
const calls = Array.isArray(message?.toolCalls) ? message.toolCalls : [];
return calls.find((tool) =>
tool?.name === CODE_AGENT_M3_SKILL_NAME ||
tool?.route === CODE_AGENT_M3_IO_ROUTE ||
tool?.runnerKind === CODE_AGENT_M3_SKILL_RUNNER_KIND ||
tool?.type === "skill-cli" && tool?.hwlabApi?.route === CODE_AGENT_M3_IO_ROUTE
) ?? null;
}
function isM3ProviderMessage(message) {
return message?.provider === CODE_AGENT_M3_SKILL_PROVIDER ||
message?.runner?.kind === CODE_AGENT_M3_SKILL_RUNNER_KIND ||
message?.providerTrace?.route === CODE_AGENT_M3_IO_ROUTE ||
message?.runnerTrace?.route === CODE_AGENT_M3_IO_ROUTE;
}
function firstNonEmpty(...values) {
for (const value of values) {
const text = stringOrNull(value);
if (text) return text;
}
return null;
}
function firstDefined(...values) {
return values.find((value) => value !== undefined && value !== null);
}
function normalizeBoolean(value) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value === 1 ? true : value === 0 ? false : null;
const text = stringOrNull(value)?.toLowerCase();
if (text === "true") return true;
if (text === "false") return false;
return null;
}
function objectOrNull(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function stringOrNull(value) {
const text = String(value ?? "").trim();
return text ? text : null;
}
function formatValue(value) {
return value === undefined || value === null ? missingProofLabel : String(value);
}
function booleanStateLabel(value) {
if (value === true) return "true";
if (value === false) return "false";
return "unknown";
}
-991
View File
@@ -1,991 +0,0 @@
// Generated from source:mvp-gate-summary and fixtures/mvp/m5-e2e/dry-run-plan.json.
// Run node scripts/export-web-gate-summary.mjs after changing that source summary or fixture.
export const gateSummary = {
"generatedFrom": {
"gateReport": "source:mvp-gate-summary",
"m5Plan": "fixtures/mvp/m5-e2e/dry-run-plan.json"
},
"issue": "pikasTech/HWLAB#56",
"supports": [
"pikasTech/HWLAB#7",
"pikasTech/HWLAB#9"
],
"gateStatus": "blocked",
"blocked": true,
"acceptanceLevel": "dev_mvp_gate",
"reportCommitId": "source-summary",
"dryRunOnly": true,
"devOnly": true,
"prodDisabled": true,
"environment": "dev",
"endpoint": "http://74.48.78.17:16667",
"namespace": "hwlab-dev",
"sourceSummary": "The DEV gate is anchored to the frozen matrix, gate contract, and M0-M5 milestone source docs; progress and acceptance evidence live in GitHub issue or PR comments, not repository report files.",
"localSmoke": {
"status": "pass",
"commands": [
"node scripts/validate-contract.mjs",
"node scripts/validate-artifact-catalog.mjs",
"node scripts/m1-contract-smoke.mjs",
"node scripts/m2-dev-deploy-smoke.mjs --dry-run"
],
"evidence": [
"Source contracts remain parseable.",
"MVP dry-run fixtures remain local/source only.",
"Repository report directory is forbidden by repo guard."
],
"summary": "M0-M2 contract and smoke checks remain local/source scoped and do not create repository report files."
},
"dryRun": {
"status": "pass",
"commands": [
"node tools/hwlab-cli/bin/hwlab-cli.mjs health",
"node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run"
],
"evidence": [
"Dry-run uses fixtures and performs no DEV/PROD mutations.",
"Dry-run evidence is not live acceptance."
],
"summary": "The M5 orchestration dry-run is fixture-only and does not establish a live DEV gate."
},
"devPreconditions": {
"status": "blocked",
"classification": "runtime durable adapter; skills injection; hardware loop",
"requirements": [
"Current DEV route and health checks must be rerun when needed and summarized in GitHub.",
"Runtime durable adapter proves postgres schema, migration, and read-query readiness before M4/M5 can promote.",
"Agent manager, skills service, and worker jobs expose explicit skill commit/version before M4/M5 can promote.",
"M3 hardware loop proves box-simu-1 DO1 to box-simu-2 DI1 via patch-panel.",
"Keep the run DEV-only and avoid PROD, heavy e2e, secrets, restarts, or UniDesk substitution."
],
"commands": [
"curl -fsS --max-time 8 http://74.48.78.17:16666/",
"curl -fsS --max-time 8 http://74.48.78.17:16667/health/live",
"curl -fsS --max-time 8 http://74.48.78.17:16667/health",
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production",
"node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"
],
"evidence": [
"Repository report files were removed and no longer qualify as current evidence.",
"Live DEV acceptance must be represented in #7, the owning issue, daily brief, or PR comments."
],
"summary": "Live MVP remains blocked until current DEV checks clear runtime durability, skills injection, and M3 hardware-loop blockers outside repository report files."
},
"blockers": [
{
"type": "runtime_blocker",
"scope": "runtime-durable-adapter",
"status": "open",
"summary": "cloud-api durable runtime adapter readiness is blocked by runtime_durable_adapter_query_blocked; it must prove schema, migration, and read-query durability before M4/M5 promotion."
},
{
"type": "agent_blocker",
"scope": "skills-commit-version-injection",
"status": "open",
"summary": "DEV skills injection must expose explicit skills commit and version across manager, skills, and worker paths."
},
{
"type": "runtime_blocker",
"scope": "m3-hardware-loop-runtime",
"status": "open",
"summary": "Live M3 must prove the patch-panel trusted loop with operation, trace, audit, and evidence identifiers."
}
],
"milestones": [
{
"id": "M0",
"status": "pass",
"commandCount": 1,
"evidenceCount": 1,
"summary": "Frozen contracts remain source-ready."
},
{
"id": "M1",
"status": "pass",
"commandCount": 1,
"evidenceCount": 1,
"summary": "The local M1 contract smoke passes."
},
{
"id": "M2",
"status": "pass",
"commandCount": 1,
"evidenceCount": 1,
"summary": "The deploy smoke validates source contracts without live acceptance."
},
{
"id": "M3",
"status": "blocked",
"commandCount": 1,
"evidenceCount": 1,
"summary": "Active DEV-LIVE M3 remains blocked by hardware loop topology until the trusted loop is proven."
},
{
"id": "M4",
"status": "blocked",
"commandCount": 1,
"evidenceCount": 1,
"summary": "Active DEV-LIVE M4 remains blocked by runtime durable adapter readiness and skills commit/version injection."
},
{
"id": "M5",
"status": "blocked",
"commandCount": 1,
"evidenceCount": 1,
"summary": "The M5 orchestration is green in dry-run mode, but active DEV-LIVE acceptance remains blocked."
}
],
"artifactCount": 13,
"healthCount": 14,
"gatewaySessionCount": 2,
"boxResourceCount": 2,
"patchPanelCount": 1,
"operationCount": 2,
"evidenceCount": 2,
"traceCount": 8,
"auditCount": 7,
"cleanupCount": 2,
"artifacts": [
{
"serviceId": "hwlab-edge-proxy",
"commitId": "caa9ed0",
"image": "hwlab-edge-proxy",
"tag": "dry-run-caa9ed0",
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"digestNotApplicableReason": null,
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-tunnel-client",
"commitId": "caa9ed0",
"image": "hwlab-tunnel-client",
"tag": "dry-run-caa9ed0",
"digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
"digestNotApplicableReason": null,
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-router",
"commitId": "caa9ed0",
"image": "hwlab-router",
"tag": "dry-run-caa9ed0",
"digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333",
"digestNotApplicableReason": null,
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-cloud-api",
"commitId": "caa9ed0",
"image": "hwlab-cloud-api",
"tag": "dry-run-caa9ed0",
"digest": "sha256:4444444444444444444444444444444444444444444444444444444444444444",
"digestNotApplicableReason": null,
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-cloud-web",
"commitId": "caa9ed0",
"image": "hwlab-cloud-web-static-assets",
"tag": "dry-run-caa9ed0",
"digest": "not_applicable",
"digestNotApplicableReason": "static asset digest is produced by the future DEV build pipeline, not this local dry-run fixture",
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-gateway",
"commitId": "caa9ed0",
"image": "hwlab-gateway",
"tag": "dry-run-caa9ed0",
"digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555",
"digestNotApplicableReason": null,
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-gateway-simu",
"commitId": "caa9ed0",
"image": "hwlab-gateway-simu",
"tag": "dry-run-caa9ed0",
"digest": "sha256:6666666666666666666666666666666666666666666666666666666666666666",
"digestNotApplicableReason": null,
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-box-simu",
"commitId": "caa9ed0",
"image": "hwlab-box-simu",
"tag": "dry-run-caa9ed0",
"digest": "sha256:7777777777777777777777777777777777777777777777777777777777777777",
"digestNotApplicableReason": null,
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-patch-panel",
"commitId": "caa9ed0",
"image": "hwlab-patch-panel",
"tag": "dry-run-caa9ed0",
"digest": "sha256:8888888888888888888888888888888888888888888888888888888888888888",
"digestNotApplicableReason": null,
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-agent-mgr",
"commitId": "caa9ed0",
"image": "hwlab-agent-mgr",
"tag": "dry-run-caa9ed0",
"digest": "sha256:9999999999999999999999999999999999999999999999999999999999999999",
"digestNotApplicableReason": null,
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-agent-worker",
"commitId": "caa9ed0",
"image": "hwlab-agent-worker",
"tag": "dry-run-caa9ed0",
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"digestNotApplicableReason": null,
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-agent-skills",
"commitId": "caa9ed0",
"image": "hwlab-agent-runtime-skills",
"tag": "dry-run-caa9ed0",
"digest": "not_applicable",
"digestNotApplicableReason": "skill bundle digest is produced by the future DEV build pipeline, not this local dry-run fixture",
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-cli",
"commitId": "caa9ed0",
"image": "hwlab-cli-npm-package",
"tag": "dry-run-caa9ed0",
"digest": "not_applicable",
"digestNotApplicableReason": "local CLI package is not containerized in this dry-run fixture",
"buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
}
],
"health": [
{
"healthId": "hlt_m5-dev-ingress",
"serviceId": "hwlab-edge-proxy",
"component": "DEV ingress",
"status": "healthy",
"deployEnv": "dev",
"endpoint": "http://74.48.78.17:16667/health",
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-edge-route",
"serviceId": "hwlab-edge-proxy",
"component": "master edge proxy",
"status": "healthy",
"deployEnv": "dev",
"endpoint": "http://74.48.78.17:16667",
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-frp",
"serviceId": "hwlab-tunnel-client",
"component": "frp",
"status": "healthy",
"deployEnv": "dev",
"endpoint": null,
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-router",
"serviceId": "hwlab-router",
"component": "D601 router",
"status": "healthy",
"deployEnv": "dev",
"endpoint": null,
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-cloud-api",
"serviceId": "hwlab-cloud-api",
"component": "Cloud API",
"status": "healthy",
"deployEnv": "dev",
"endpoint": null,
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-cloud-web",
"serviceId": "hwlab-cloud-web",
"component": "Cloud Web",
"status": "healthy",
"deployEnv": "dev",
"endpoint": "http://74.48.78.17:16667",
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-gateway",
"serviceId": "hwlab-gateway",
"component": "Gateway",
"status": "healthy",
"deployEnv": "dev",
"endpoint": null,
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-gateway-simu",
"serviceId": "hwlab-gateway-simu",
"component": "Gateway simulator",
"status": "healthy",
"deployEnv": "dev",
"endpoint": null,
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-box-simu",
"serviceId": "hwlab-box-simu",
"component": "Box simulator",
"status": "healthy",
"deployEnv": "dev",
"endpoint": null,
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-patch-panel",
"serviceId": "hwlab-patch-panel",
"component": "Patch panel",
"status": "healthy",
"deployEnv": "dev",
"endpoint": null,
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-agent-mgr",
"serviceId": "hwlab-agent-mgr",
"component": "Agent manager",
"status": "healthy",
"deployEnv": "dev",
"endpoint": null,
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-agent-worker",
"serviceId": "hwlab-agent-worker",
"component": "Agent worker",
"status": "healthy",
"deployEnv": "dev",
"endpoint": null,
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-agent-skills",
"serviceId": "hwlab-agent-skills",
"component": "Agent skills",
"status": "healthy",
"deployEnv": "dev",
"endpoint": null,
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"healthId": "hlt_m5-cli",
"serviceId": "hwlab-cli",
"component": "CLI",
"status": "healthy",
"deployEnv": "dev",
"endpoint": "http://74.48.78.17:16667",
"observedBy": "fixture",
"dryRun": true,
"healthTimestamp": "2026-05-21T00:00:00.000Z"
}
],
"topology": {
"projectId": "prj_m3_hardware_loop",
"projectStatus": "blocked",
"gateways": [
{
"gatewaySessionId": "gws_gwsimu_1",
"serviceId": "hwlab-gateway-simu",
"gatewayId": "gwsimu_1",
"status": "connected",
"environment": "dev",
"endpoint": null
},
{
"gatewaySessionId": "gws_gwsimu_2",
"serviceId": "hwlab-gateway-simu",
"gatewayId": "gwsimu_2",
"status": "connected",
"environment": "dev",
"endpoint": null
}
],
"boxResources": [
{
"resourceId": "res_boxsimu_1",
"gatewaySessionId": "gws_gwsimu_1",
"boxId": "boxsimu_1",
"resourceType": "simulator_endpoint",
"name": "BOX-SIMU 1 source DO1",
"state": "available",
"environment": "dev"
},
{
"resourceId": "res_boxsimu_2",
"gatewaySessionId": "gws_gwsimu_2",
"boxId": "boxsimu_2",
"resourceType": "simulator_endpoint",
"name": "BOX-SIMU 2 target DI1",
"state": "available",
"environment": "dev"
}
],
"patchPanel": {
"patchPanelStatusId": "pps_m3_hardware_loop",
"serviceId": "hwlab-patch-panel",
"state": "blocked",
"environment": "dev",
"activeConnectionCount": 1,
"activeConnections": [
{
"fromResourceId": "res_boxsimu_1",
"fromPort": "DO1",
"toResourceId": "res_boxsimu_2",
"toPort": "DI1"
}
]
}
},
"m5DryRunTopology": {
"projectId": "proj_m5-mvp-e2e",
"projectStatus": "active",
"gateways": [
{
"gatewaySessionId": "gws_m5-0001",
"serviceId": "hwlab-gateway",
"gatewayId": "gtw_m5-dev-boundary",
"status": "connected",
"environment": "dev",
"endpoint": "http://74.48.78.17:16667/gateway"
},
{
"gatewaySessionId": "gws_m5-simu-0001",
"serviceId": "hwlab-gateway-simu",
"gatewayId": "gtw_m5-simu",
"status": "connected",
"environment": "dev",
"endpoint": null
}
],
"boxResources": [
{
"resourceId": "res_m5-control-relay",
"gatewaySessionId": "gws_m5-0001",
"boxId": "box_m5-control",
"resourceType": "relay",
"name": "M5 control relay",
"state": "available",
"environment": "dev"
},
{
"resourceId": "res_m5-target-board",
"gatewaySessionId": "gws_m5-0001",
"boxId": "box_m5-target",
"resourceType": "board",
"name": "M5 target board",
"state": "available",
"environment": "dev"
}
],
"patchPanel": {
"patchPanelStatusId": "pps_m5-0001",
"serviceId": "hwlab-patch-panel",
"state": "active",
"environment": "dev",
"activeConnectionCount": 1,
"activeConnections": [
{
"fromResourceId": "res_m5-control-relay",
"fromPort": "out1",
"toResourceId": "res_m5-target-board",
"toPort": "reset"
}
]
}
},
"agent": {
"agentSessionId": "agt_m5-0001",
"agentServiceId": "hwlab-agent-mgr",
"agentStatus": "completed",
"workerSessionId": "wkr_m5-0001",
"workerServiceId": "hwlab-agent-worker",
"workerStatus": "completed",
"projectId": "proj_m5-mvp-e2e",
"environment": "dev",
"cleanupId": "cln_m5-worker"
},
"operations": [
{
"operationId": "op_m5-direct-0001",
"requestedBy": "user_m5-dry-run",
"status": "succeeded",
"resourceId": "res_m5-control-relay",
"capabilityId": "cap_m5-relay-set",
"agentSessionId": null,
"workerSessionId": null,
"environment": "dev",
"dryRun": true,
"requestedAt": "2026-05-21T00:02:00.000Z",
"updatedAt": "2026-05-21T00:02:30.000Z"
},
{
"operationId": "op_m5-agent-0001",
"requestedBy": "agent_m5-dry-run",
"status": "succeeded",
"resourceId": "res_m5-control-relay",
"capabilityId": "cap_m5-relay-set",
"agentSessionId": "agt_m5-0001",
"workerSessionId": "wkr_m5-0001",
"environment": "dev",
"dryRun": true,
"requestedAt": "2026-05-21T00:03:00.000Z",
"updatedAt": "2026-05-21T00:03:30.000Z"
}
],
"auditEvents": [
{
"auditId": "aud_m5-project-selected",
"traceId": "trc_m5-project",
"action": "project.select",
"targetType": "project",
"targetId": "proj_m5-mvp-e2e",
"projectId": "proj_m5-mvp-e2e",
"gatewaySessionId": null,
"operationId": null,
"workerSessionId": null,
"serviceId": "hwlab-cloud-api",
"environment": "dev",
"outcome": "succeeded",
"dryRun": true,
"occurredAt": "2026-05-21T00:00:00.000Z"
},
{
"auditId": "aud_m5-gateway-started",
"traceId": "trc_m5-gateway",
"action": "gateway.session.start",
"targetType": "gateway_session",
"targetId": "gws_m5-0001",
"projectId": "proj_m5-mvp-e2e",
"gatewaySessionId": "gws_m5-0001",
"operationId": null,
"workerSessionId": null,
"serviceId": "hwlab-gateway",
"environment": "dev",
"outcome": "succeeded",
"dryRun": true,
"occurredAt": "2026-05-21T00:00:30.000Z"
},
{
"auditId": "aud_m5-wiring-applied",
"traceId": "trc_m5-wiring",
"action": "wiring.apply",
"targetType": "wiring_config",
"targetId": "wir_m5-0001",
"projectId": "proj_m5-mvp-e2e",
"gatewaySessionId": "gws_m5-0001",
"operationId": null,
"workerSessionId": null,
"serviceId": "hwlab-patch-panel",
"environment": "dev",
"outcome": "succeeded",
"dryRun": true,
"occurredAt": "2026-05-21T00:01:00.000Z"
},
{
"auditId": "aud_m5-direct-operation",
"traceId": "trc_m5-direct-0001",
"action": "hardware.operation.request",
"targetType": "hardware_operation",
"targetId": "op_m5-direct-0001",
"projectId": "proj_m5-mvp-e2e",
"gatewaySessionId": "gws_m5-0001",
"operationId": "op_m5-direct-0001",
"workerSessionId": null,
"serviceId": "hwlab-cloud-api",
"environment": "dev",
"outcome": "succeeded",
"dryRun": true,
"occurredAt": "2026-05-21T00:02:30.000Z"
},
{
"auditId": "aud_m5-agent-session",
"traceId": "trc_m5-agent-session",
"action": "agent.session.start",
"targetType": "agent_session",
"targetId": "agt_m5-0001",
"projectId": "proj_m5-mvp-e2e",
"gatewaySessionId": null,
"operationId": null,
"workerSessionId": "wkr_m5-0001",
"serviceId": "hwlab-agent-mgr",
"environment": "dev",
"outcome": "succeeded",
"dryRun": true,
"occurredAt": "2026-05-21T00:02:45.000Z"
},
{
"auditId": "aud_m5-agent-operation",
"traceId": "trc_m5-agent-0001",
"action": "hardware.operation.request",
"targetType": "hardware_operation",
"targetId": "op_m5-agent-0001",
"projectId": "proj_m5-mvp-e2e",
"gatewaySessionId": "gws_m5-0001",
"operationId": "op_m5-agent-0001",
"workerSessionId": "wkr_m5-0001",
"serviceId": "hwlab-agent-worker",
"environment": "dev",
"outcome": "succeeded",
"dryRun": true,
"occurredAt": "2026-05-21T00:03:30.000Z"
},
{
"auditId": "aud_m5-cleanup",
"traceId": "trc_m5-cleanup",
"action": "worker.cleanup",
"targetType": "worker_session",
"targetId": "wkr_m5-0001",
"projectId": "proj_m5-mvp-e2e",
"gatewaySessionId": "gws_m5-0001",
"operationId": null,
"workerSessionId": "wkr_m5-0001",
"serviceId": "hwlab-agent-worker",
"environment": "dev",
"outcome": "succeeded",
"dryRun": true,
"occurredAt": "2026-05-21T00:05:00.000Z"
}
],
"evidenceRecords": [
{
"evidenceId": "evi_m5-direct-0001",
"operationId": "op_m5-direct-0001",
"agentSessionId": null,
"workerSessionId": null,
"kind": "trace",
"uri": "fixtures/mvp/m5-e2e/evidence/direct-operation.txt",
"sha256": "4cd5cc2c942501b55f7a32f4184e9a1c920b31f13110f44ec68ddc0ad7dc1e34",
"serviceId": "hwlab-cloud-api",
"environment": "dev",
"traceId": "trc_m5-direct-0001",
"dryRun": true,
"createdAt": "2026-05-21T00:02:40.000Z"
},
{
"evidenceId": "evi_m5-agent-0001",
"operationId": "op_m5-agent-0001",
"agentSessionId": "agt_m5-0001",
"workerSessionId": "wkr_m5-0001",
"kind": "trace",
"uri": "fixtures/mvp/m5-e2e/evidence/agent-operation.txt",
"sha256": "96e7ffa99efd78b3d5d1b1dfd08be2f077f618dacf786b47f764794714c15c1c",
"serviceId": "hwlab-agent-worker",
"environment": "dev",
"traceId": "trc_m5-agent-0001",
"dryRun": true,
"createdAt": "2026-05-21T00:03:40.000Z"
}
],
"steps": [
{
"id": "step-dev-health",
"order": 1,
"title": "DEV health",
"kind": "health",
"requires": [],
"inputs": [
"endpoint",
"health:hlt_m5-dev-ingress",
"health:hlt_m5-edge-route",
"health:hlt_m5-frp",
"health:hlt_m5-router",
"health:hlt_m5-cloud-api",
"health:hlt_m5-cloud-web"
],
"outputs": [
"route_m5-dev",
"tun_m5-d601",
"hwlab-dev",
"hwlab-cloud-api",
"hwlab-cloud-web"
],
"acceptanceStepIds": [
"static-contract-parse",
"endpoint-freeze",
"dev-ingress-health",
"edge-route",
"frp-tunnel",
"d601-router",
"cloud-surface"
]
},
{
"id": "step-gateway-box-status",
"order": 2,
"title": "gateway/box status",
"kind": "status",
"requires": [
"step-dev-health"
],
"inputs": [
"gatewaySession:gws_m5-0001",
"gatewaySession:gws_m5-simu-0001",
"boxResource:res_m5-control-relay",
"boxResource:res_m5-target-board",
"capability:cap_m5-relay-set",
"capability:cap_m5-board-reset"
],
"outputs": [
"gws_m5-0001",
"res_m5-control-relay",
"res_m5-target-board",
"cap_m5-relay-set",
"cap_m5-board-reset"
],
"acceptanceStepIds": [
"gateway-sim-patch-panel"
]
},
{
"id": "step-wiring",
"order": 3,
"title": "wiring",
"kind": "wiring",
"requires": [
"step-gateway-box-status"
],
"inputs": [
"wiringConfig:wir_m5-0001",
"patchPanelStatus:pps_m5-0001"
],
"outputs": [
"wir_m5-0001",
"pps_m5-0001"
],
"acceptanceStepIds": [
"gateway-sim-patch-panel"
]
},
{
"id": "step-direct-hardware-call",
"order": 4,
"title": "direct hardware call",
"kind": "hardware_operation",
"requires": [
"step-wiring"
],
"inputs": [
"rpc:direct-hardware-request",
"operation:op_m5-direct-0001",
"resource:res_m5-control-relay",
"capability:cap_m5-relay-set",
"patchPanelStatus:pps_m5-0001"
],
"outputs": [
"op_m5-direct-0001",
"trc_m5-direct-0001"
],
"acceptanceStepIds": [
"gateway-sim-patch-panel"
]
},
{
"id": "step-audit",
"order": 5,
"title": "audit",
"kind": "audit",
"requires": [
"step-direct-hardware-call"
],
"inputs": [
"audit:aud_m5-project-selected",
"audit:aud_m5-gateway-started",
"audit:aud_m5-wiring-applied",
"audit:aud_m5-direct-operation"
],
"outputs": [
"aud_m5-direct-operation"
],
"acceptanceStepIds": [
"artifact-observability"
]
},
{
"id": "step-agent-session",
"order": 6,
"title": "agent session",
"kind": "agent",
"requires": [
"step-audit"
],
"inputs": [
"agentSession:agt_m5-0001",
"workerSession:wkr_m5-0001",
"health:hlt_m5-agent-mgr",
"health:hlt_m5-agent-worker",
"health:hlt_m5-agent-skills"
],
"outputs": [
"agt_m5-0001",
"wkr_m5-0001"
],
"acceptanceStepIds": [
"agent-runtime"
]
},
{
"id": "step-agent-hardware-call",
"order": 7,
"title": "agent hardware call",
"kind": "hardware_operation",
"requires": [
"step-agent-session",
"step-wiring"
],
"inputs": [
"rpc:agent-hardware-request",
"operation:op_m5-agent-0001",
"agentSession:agt_m5-0001",
"workerSession:wkr_m5-0001",
"resource:res_m5-control-relay",
"capability:cap_m5-relay-set",
"patchPanelStatus:pps_m5-0001"
],
"outputs": [
"op_m5-agent-0001",
"trc_m5-agent-0001"
],
"acceptanceStepIds": [
"agent-runtime"
]
},
{
"id": "step-evidence",
"order": 8,
"title": "evidence",
"kind": "evidence",
"requires": [
"step-direct-hardware-call",
"step-agent-hardware-call"
],
"inputs": [
"trace:trc_m5-direct-0001",
"trace:trc_m5-agent-0001",
"evidence:evi_m5-direct-0001",
"evidence:evi_m5-agent-0001"
],
"outputs": [
"evi_m5-direct-0001",
"evi_m5-agent-0001"
],
"acceptanceStepIds": [
"agent-runtime",
"artifact-observability"
]
},
{
"id": "step-cleanup",
"order": 9,
"title": "cleanup",
"kind": "cleanup",
"requires": [
"step-evidence"
],
"inputs": [
"cleanup:cln_m5-worker",
"cleanup:cln_m5-gateway-state",
"audit:aud_m5-cleanup"
],
"outputs": [
"cln_m5-worker",
"cln_m5-gateway-state"
],
"acceptanceStepIds": [
"agent-runtime"
]
}
],
"realDevGate": {
"sourcePlanCommand": "hwlab-cli test e2e --env dev --mvp",
"command": "hwlab-cli test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production",
"requiresAllBlockersClear": true,
"requiresDryRunPass": true,
"requiresArtifactObservability": true,
"requiresD601RouteObservation": true,
"requiresHumanApprovalForRealDev": true
},
"safety": {
"mode": "dry-run",
"allowNetwork": false,
"allowDeploy": false,
"allowBrowserE2E": false,
"allowSecrets": false,
"prohibitedActions": [
"real-dev-deploy",
"prod-deploy",
"prod-smoke",
"browser-e2e",
"heavyweight-e2e",
"secret-or-token-read",
"force-push",
"unidesk-runtime-substitution"
]
}
};
+20 -28
View File
@@ -1,48 +1,40 @@
# 云工作台内部使用说明
本页面说明当前 Cloud Workbench 的前端区域与状态含义。默认 `/` 进入工作台;本说明只通过工作台内部的 `#help` 入口打开,不作为默认页
本页面说明当前 Cloud Workbench 的前端区域与状态含义。默认 `/` 进入工作台;本说明只通过活动栏的 `使用说明` 入口打开
## 活动栏与默认工作区
- 活动栏使用完整中文标签:`工作台` 回到默认工作台,`内部复核` 打开只读复核表`使用说明` 打开本说明。
- 左侧不再放独立的可信记录跳转;可信记录保留在右侧子面板
- 默认首屏不再放资源树、能力卡片或左侧抽屉,空间直接分配给 Code Agent 对话、执行证据、硬件状态、接线和控制
- `LOCAL` 只表示浏览器本地草稿,不等同于 DEV-LIVE。
- `工作台` 回到 Agent 对话与 Device Pod 看板;`内部复核` 打开只读复核表`使用说明` 打开本说明。
- 默认首屏不再放资源树、旧硬件拓扑面板、接线面板或可信记录面板,右侧只展示 Device Pod summary 与纯文本事件流
- 浏览器内输入只发送给 Code Agent;真实 Device Pod 操作必须由受控后端流程执行,前端不提供泛化硬件写入口
## 中间 Agent 对话与输入区
- Agent 对话区显示用户工作台范围、建议起步方式和可查看内容,不展示 Gate、诊断、验收、BLOCKED 或 M0-M5 执行轨迹
- 右侧工作面板提供控制、接线、可信记录三类用户动作;中间区域专注于 Agent 对话与输入
- 底部输入栏用于 Code Agent 对话;右侧基础 IO 控制只调用 same-origin `/v1/m3/io`,由 `hwlab-cloud-api` 通过 gateway-simu、box-simu、`hwlab-patch-panel` 执行,不从浏览器直连 gateway/box-simu,也不调用泛化硬件写 RPC
- Agent 对话区保留会话、trace、运行路径和 Code Agent 状态摘要
- 底部快捷按钮只填充输入框,点击 `发送` 后才提交;Device Pod 快捷用例默认只读,聚焦 status、profileHash、chip-id、UART tail 和事件流
- 无新事件超时、Gateway 命令超时和模型通道由输入栏控件显式选择,当前浏览器会话会保存用户选择
## 右侧硬件与状态面
## 右侧 Device Pod 看
- BOX-SIMU / Gateway-SIMU / Patch Panel 卡片展示只读计数、source fallback 与硬件写入边界
- 接线:`hwlab-patch-panel` 面板使用两列多行长表展示互连设备,首行是 `res_boxsimu_1``res_boxsimu_2`,后续行是 `DO1``DI1`;状态、证据来源和轨迹/证据放在中文摘要与行内说明中,不作为横向宽列展开
- M3 虚拟硬件可信闭环的上位约束是:`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`。只有观察到这条完整链路并带有可信记录时,才可作为 M3 DEV-LIVE 依据
- Web 前端不会新增 `hardware.*` 写 RPC、patch-panel 写接口、audit 写接口或 evidence 写接口;M3 控制的 operation/audit/evidence 字段由 `/v1/m3/io` 响应返回,runtime durable 未 green 时必须显示 BLOCKED
## 右侧工作面板
- 控制:显示本地草稿和只读能力边界。这里的控制项保持禁用或本地记录状态。
- 接线:解释 `hwlab-patch-panel` 当前 source wiring,帮助确认 DO1 到 DI1 的可信闭环路径是否仍由 patch-panel 拥有。
- 可信记录:合并 source fixture 与只读 `evidence.record.query` 结果。source、fixture、LOCAL、DRY-RUN 记录不能升级成 DEV-LIVE。
- 技术复核:Gate、诊断和验收状态收敛到活动栏 `内部复核` 打开的只读表,不替代默认工作台,也不展示 M0-M5 流程墙。
- 右侧看板内部不使用展开/收起;所有卡片只显示 summary
- 点击或键盘激活 summary 卡片后,通过弹窗查看 JSON 细节;弹窗关闭后返回原看板,不改变看板布局
- `Pod Summary` 展示 `devicePodId``targetId``profile` 和 freshnessTarget、Workspace、Debug、IO 四张卡片只展示关键一句话
- 事件流是一个不需要展开的纯文本窗口,持续更新时不会抢走用户正在滚动的位置;开启 `跟随` 或点击新事件提示后才跳到最新事件
- 当前 `hwlab-device-pod` 先提供 fake 数据,供前端完成 Device Pod 信息模型、弹窗详情和事件流布局闭环;不把 fake 数据称为真实硬件通过。
## 内部复核页
- Gate / 诊断 / 验收:从活动栏 `内部复核` 进入,主体只有一个只读主表和少量过滤、搜索、刷新控件。
- 表格数据来自 same-origin `/v1/diagnostics/gate` live 后端聚合;如果 live 聚合不可用,页面必须显示明确 blocker,不能用 SOURCE 静态拓扑冒充
- 表格状态使用中文:通过、阻塞、失败、待验证、信息。阻塞或降级行不得显示为 green
- 该页不提供硬件控制、Agent 对话或资源选择入口;这些操作仍在默认工作台内按受控后端边界执行。
- `内部复核` 页面只有一个只读主表和少量过滤、搜索、刷新控件。
- 表格数据来自 same-origin `/v1/diagnostics/gate` live 后端聚合;不可用时显示明确 blocker,不用静态拓扑冒充 live 结果
- 该页不提供 Agent 对话、Device Pod 操作或资源选择入口
## 来源标签含义
- `SOURCE`:来自仓库内 source report、静态配置或已签入摘要,用于解释当前界面。
- `SOURCE`:来自仓库内静态配置或已签入摘要,用于解释当前界面。
- `LOCAL`:只存在于当前浏览器会话的本地输入或草稿。
- `DRY-RUN`:演练或 fixture 证据,不能声称真实硬件闭环已经通过。
- `DEV-LIVE`:必须来自同源只读探测或可信记录,并满足 DB readiness、阻塞清零与 M3 闭环证据要求
- `BLOCKED`表示当前前置条件未满足、探测失败、证据不足或不能把 SOURCE / LOCAL / DRY-RUN / fixture 升级为 DEV-LIVE
- `DRY-RUN`:演练或 fixture 证据,不能声称真实设备闭环已经通过。
- `DEV-LIVE`:必须来自同源 live 探测或可信记录,并满足当前 Device Pod 判定标准
- `BLOCKED`:当前前置条件未满足、探测失败、证据不足或不能把静态/本地/演练证据升级为 live 结果
## 端口与只读边界
+50 -161
View File
@@ -64,13 +64,13 @@
<header class="topbar">
<div class="topbar-main">
<p class="eyebrow">用户工作台</p>
<h2>Agent 对话与硬件资源面</h2>
<p id="route-path" class="route-path">当前浏览器会话只保存任务草稿,硬件动作需通过受控后端流程。</p>
<h2>Agent 对话与 Device Pod 看</h2>
<p id="route-path" class="route-path">当前浏览器会话只保存任务草稿,Device Pod 操作需通过受控后端流程。</p>
</div>
<div class="probe-card user-card" aria-live="polite">
<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>
<small id="live-detail">正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/device-pods;未完成前不标为通过。</small>
<details class="live-build-summary" id="live-build-summary">
<summary id="live-build-toggle" aria-haspopup="dialog" aria-expanded="false">
<span class="live-build-summary-label" id="live-build-latest">最新镜像构建时间:等待实时元数据</span>
@@ -186,20 +186,18 @@
>Codex:列出 skill</button>
<button
type="button"
data-agent-quick-prompt="do1-true-di1"
data-agent-quick-prompt="device-pod-status"
data-prompt-action="fill"
data-requires-explicit-send="true"
title="只填充输入框;必须再点击发送才确认写入请求"
data-prompt-text="交给 Codex 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。"
>CodexDO1=true 后读 DI1</button>
title="只填充输入框,点击发送后交给 Code Agent"
data-prompt-text="交给 Codex 读取当前 Device Pod 的 status、profileHash、chip-id 和 UART1 tail 摘要,不执行下载、复位或写入。"
>CodexDevice Pod 状态</button>
<button
type="button"
data-agent-quick-prompt="do1-false-di1"
data-agent-quick-prompt="device-pod-events"
data-prompt-action="fill"
data-requires-explicit-send="true"
title="只填充输入框;必须再点击发送才确认写入请求"
data-prompt-text="交给 Codex 把 res_boxsimu_1 的 DO1 写成 false,并复核 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。"
>CodexDO1=false 复核 DI1</button>
title="只填充输入框,点击发送后交给 Code Agent"
data-prompt-text="交给 Codex 查看 Device Pod 最近事件流,并说明是否存在 blocker;只读,不连接旧模拟硬件拓扑。"
>Codex:事件流</button>
</div>
<label class="agent-timeout-control" for="code-agent-provider-profile">
<span>模型通道</span>
@@ -244,182 +242,73 @@
</form>
</section>
<aside class="right-sidebar" id="hardware-sidebar" aria-label="运行状态与可信记录">
<aside class="right-sidebar" id="device-pod-sidebar" aria-label="Device Pod 摘要看板">
<div
class="resize-handle right-sidebar-resize"
id="right-sidebar-resize"
role="separator"
tabindex="0"
aria-label="拖拽调整右侧硬件状态栏宽度"
aria-controls="hardware-sidebar"
aria-label="拖拽调整右侧 Device Pod 看板宽度"
aria-controls="device-pod-sidebar"
aria-orientation="vertical"
aria-valuemin="560"
aria-valuemax="740"
aria-valuenow="728"
title="拖拽调整右侧硬件状态栏宽度;用左右方向键微调,Home/End 到最小或最大。"
title="拖拽调整右侧 Device Pod 看板宽度;用左右方向键微调,Home/End 到最小或最大。"
></div>
<section class="hardware-status">
<section class="device-pod-status" aria-labelledby="device-pod-title">
<div class="panel-title-row">
<div>
<p class="eyebrow">M3 实况状态</p>
<h2>硬件状态工作区</h2>
<p class="eyebrow">Device Pod</p>
<h2 id="device-pod-title">设备目标看板</h2>
</div>
<span class="state-tag tone-source" id="hardware-source-status">未验证</span>
<span class="state-tag tone-source" id="device-pod-status-tag">未验证</span>
</div>
<div class="hardware-status-tabs" role="tablist" aria-label="M3 硬件状态">
<button class="hardware-tab active" type="button" role="tab" aria-selected="true" data-hardware-tab="overview">总览</button>
<button class="hardware-tab" type="button" role="tab" aria-selected="false" data-hardware-tab="gateways">Gateway-SIMU</button>
<button class="hardware-tab" type="button" role="tab" aria-selected="false" data-hardware-tab="box1">BOX-SIMU-1</button>
<button class="hardware-tab" type="button" role="tab" aria-selected="false" data-hardware-tab="box2">BOX-SIMU-2</button>
<button class="hardware-tab" type="button" role="tab" aria-selected="false" data-hardware-tab="patch">Patch Panel</button>
<div class="device-pod-selector-row">
<label for="device-pod-select">当前 Pod</label>
<select id="device-pod-select" aria-label="选择 Device Pod">
<option value="device-pod-71-freq">device-pod-71-freq</option>
</select>
</div>
<div id="hardware-list" class="hardware-list" data-static-source-fallback="SOURCE">
<article class="hardware-section">
<span class="hardware-section-title">Key / Value</span>
<div id="device-pod-summary" class="device-pod-summary" data-static-source-fallback="FAKE">
<article class="summary-tile" data-device-detail="pod" data-device-title="Pod Summary" role="button" tabindex="0" aria-label="查看 Device Pod 摘要详情">
<span class="summary-title">Pod Summary</span>
<dl class="kv-list">
<div><dt>数据入口</dt><dd>等待 cloud-api /v1/m3/status</dd></div>
<div><dt>链路</dt><dd>res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1</dd></div>
<div><dt>来源</dt><dd>未验证;拓扑资料只可作为 fallback</dd></div>
<div><dt>devicePodId</dt><dd id="device-pod-id">等待 /v1/device-pods</dd></div>
<div><dt>targetId</dt><dd id="device-pod-target">未观测</dd></div>
<div><dt>profile</dt><dd id="device-pod-profile">未观测</dd></div>
<div><dt>freshness</dt><dd id="device-pod-freshness">未观测</dd></div>
</dl>
</article>
</div>
</section>
<section class="side-workspace">
<div class="side-tabs" role="tablist" aria-label="工作台右侧面板">
<button class="side-tab active" id="tab-control" type="button" role="tab" aria-selected="true" data-side-tab="control">控制</button>
<button class="side-tab" id="tab-wiring" type="button" role="tab" aria-selected="false" data-side-tab="wiring">接线</button>
<button class="side-tab" id="tab-records" type="button" role="tab" aria-selected="false" data-side-tab="records">可信记录</button>
</div>
<section class="side-panel active" id="panel-control" role="tabpanel" aria-labelledby="tab-control" data-side-panel="control">
<div class="panel-title-row">
<h2>基础 IO 控制</h2>
<span class="state-tag tone-blocked" id="m3-control-status">探测中</span>
<section class="device-pod-workspace">
<div id="device-pod-interfaces" class="device-pod-interfaces" aria-label="Device Pod 四要素摘要"></div>
<section class="device-event-panel" aria-labelledby="device-event-title">
<div class="event-toolbar">
<span id="device-event-title">事件流</span>
<button id="device-event-follow" type="button" aria-pressed="true">跟随</button>
<button id="device-event-jump" type="button" hidden>有 0 条新事件</button>
</div>
<div class="m3-flow-summary" id="m3-flow-summary" role="group" aria-label="M3 DO1 到 DI1 操作摘要">
<div class="m3-flow-head">
<div>
<span class="metric-label">操作链路</span>
<strong>DO1 -> patch-panel -> DI1</strong>
</div>
<span class="state-tag tone-pending" id="m3-flow-state">等待 readiness</span>
</div>
<div class="m3-flow-line" aria-label="res_boxsimu_1 DO1 经 hwlab-patch-panel 到 res_boxsimu_2 DI1">
<span class="m3-flow-node mono">res_boxsimu_1</span>
<span class="m3-flow-port">DO1</span>
<span class="m3-flow-hop">hwlab-patch-panel</span>
<span class="m3-flow-node mono">res_boxsimu_2</span>
<span class="m3-flow-port">DI1</span>
</div>
<dl class="m3-flow-kv">
<div>
<dt>当前 action</dt>
<dd id="m3-action-summary">等待用户写入 DO1 或读取 DI1</dd>
</div>
<div>
<dt>DO1 目标</dt>
<dd id="m3-do1-target">选择 true/false 后写入</dd>
</div>
<div>
<dt>DI1 观测</dt>
<dd id="m3-di1-observed">待读取</dd>
</div>
<div>
<dt>可信状态</dt>
<dd id="m3-trust-summary">等待 cloud-api readiness</dd>
</div>
</dl>
<div class="m3-flow-footer">
<button class="m3-jump-button" id="m3-records-jump" type="button">查看最近操作/trace</button>
<span class="m3-trace-summary mono" id="m3-trace-summary">最近 trace:无</span>
</div>
<div id="device-event-scroll" class="device-event-scroll" tabindex="0" aria-label="Device Pod 纯文本事件流">
<pre id="device-event-text">等待 /v1/device-pods 事件流。</pre>
</div>
<form class="m3-control-form" id="m3-control-form" aria-label="DO1 写入">
<div class="m3-control-grid">
<label>
<span>Gateway</span>
<select id="m3-gateway-select">
<option value="gwsimu_1">gateway-simu-1</option>
</select>
</label>
<label>
<span>BOX</span>
<select id="m3-box-select">
<option value="res_boxsimu_1">box-simu-1 / res_boxsimu_1</option>
</select>
</label>
<label>
<span>端口</span>
<select id="m3-port-select">
<option value="DO1">DO1</option>
</select>
</label>
<label>
<span></span>
<select id="m3-value-select">
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
</div>
<div class="m3-control-actions">
<button class="command-button" id="m3-write-do" type="submit">写入 DO1</button>
<button class="command-button secondary" id="m3-read-di" type="button">读取 DI1</button>
</div>
</form>
<div id="control-list" class="compact-list"></div>
</section>
<section class="side-panel" id="panel-wiring" role="tabpanel" aria-labelledby="tab-wiring" data-side-panel="wiring" hidden>
<div class="panel-title-row">
<h2>接线:hwlab-patch-panel</h2>
<span class="state-tag tone-source">来源拓扑接线</span>
</div>
<div class="table-wrap wiring-table-wrap" data-wiring-layout="two-column-long-table">
<table class="wiring-table wiring-long-table" aria-describedby="wiring-summary">
<thead>
<tr data-wiring-row="devices">
<th scope="col">
<span class="mono wrap" id="wiring-source-device">res_boxsimu_1</span>
<small>互连设备 A</small>
</th>
<th scope="col">
<span class="mono wrap" id="wiring-target-device">res_boxsimu_2</span>
<small>互连设备 B</small>
</th>
</tr>
</thead>
<tbody id="wiring-body">
<tr data-source-kind="SOURCE" data-wiring-row="io">
<td>
<span class="wiring-port mono">DO1</span>
<small class="wiring-detail">源端口;经 hwlab-patch-panel;状态:待可信记录</small>
</td>
<td>
<span class="wiring-port mono">DI1</span>
<small class="wiring-detail">目标端口;证据来源:来源拓扑;轨迹/证据:等待可信记录</small>
</td>
</tr>
</tbody>
</table>
</div>
<p class="wiring-summary" id="wiring-summary">接线盘:hwlab-patch-panel;状态:待可信记录;证据来源:来源拓扑;轨迹/证据:等待可信记录。</p>
</section>
<section class="side-panel" id="panel-records" role="tabpanel" aria-labelledby="tab-records" data-side-panel="records" hidden>
<div class="panel-title-row">
<h2>可信记录</h2>
<span class="state-tag tone-source">来源拓扑 + 只读查询</span>
</div>
<div id="records-list" class="compact-list"></div>
</section>
<div class="readonly-rpc sidebar-note">
<h3>复核入口</h3>
<p class="panel-copy">技术复核已移至二级页面,不作为默认工作区内容</p>
<p class="panel-copy">技术复核保留在二级页面;默认右栏只显示 Device Pod 摘要和纯文本事件流</p>
</div>
</section>
<dialog class="device-detail-dialog" id="device-detail-dialog" aria-labelledby="device-detail-title">
<form method="dialog" class="device-detail-shell">
<header class="device-detail-head">
<h2 id="device-detail-title">Device Pod 详情</h2>
<button class="command-button secondary" value="close" type="submit">关闭</button>
</header>
<pre id="device-detail-body" class="device-detail-body"></pre>
</form>
</dialog>
</aside>
</main>
</body>
+70 -130
View File
@@ -151,11 +151,77 @@ export function collectLiveStatusProbes(live = {}) {
fallbackReasonCode: "adapter_describe_unverified"
}),
classifyCodeAgentProbe(live),
classifyM3ControlProbe(live),
classifyM3StatusProbe(live)
classifyDevicePodProbe(live)
].filter(Boolean);
}
function classifyDevicePodProbe(live = {}) {
const probe = live.devicePodStatus ?? live.devicePods;
const context = {
serviceId: "hwlab-cloud-api",
apiPath: "/v1/device-pods",
fallbackReasonCode: "device_pod_unverified"
};
if (!probe) {
return unverifiedProbe({
...context,
reasonCode: context.fallbackReasonCode,
reason: "Device Pod status not observed"
});
}
if (probe.ok !== true) {
return errorProbe({
...context,
reasonCode: probe.timeout ? "timeout" : probe.status ? `HTTP ${probe.status}` : "device_pod_request_failed",
reason: probe.error ?? "Device Pod status request failed",
httpStatus: probe.status,
traceId: traceIdFrom(probe),
evidenceSummary: evidenceSummaryFrom(probe)
});
}
const data = probe.data ?? {};
const pod = data.devicePod ?? data.devicePods?.[0] ?? data.summary ?? {};
const blocker = data.summary?.blocker ?? pod.blocker ?? null;
if (blocker) {
return errorProbe({
...context,
reasonCode: blocker.code ?? "device_pod_blocked",
reason: blocker.message ?? "Device Pod blocked",
traceId: data.summary?.latestEvent?.refs?.traceId ?? data.events?.[0]?.refs?.traceId,
evidenceSummary: devicePodEvidenceSummary(data)
});
}
if (["ok", "ready", "live", "pass", "healthy"].includes(String(data.status ?? pod.status ?? "").toLowerCase())) {
return passProbe({
...context,
serviceId: data.serviceId ?? "hwlab-device-pod",
reasonCode: "device_pod_ready",
reason: `Device Pod ${pod.devicePodId ?? data.summary?.devicePodId ?? "unknown"} status ready`,
evidenceSummary: devicePodEvidenceSummary(data)
});
}
return unverifiedProbe({
...context,
reasonCode: data.status ?? "device_pod_unverified",
reason: "Device Pod status is not ready yet",
evidenceSummary: devicePodEvidenceSummary(data)
});
}
function devicePodEvidenceSummary(data = {}) {
const summary = data.summary ?? {};
const pod = data.devicePod ?? data.devicePods?.[0] ?? {};
const refs = summary.latestEvent?.refs ?? data.events?.[0]?.refs ?? pod.refs ?? {};
return [
summary.devicePodId ?? pod.devicePodId ? `pod=${summary.devicePodId ?? pod.devicePodId}` : null,
summary.targetId ?? pod.targetId ?? pod.target?.targetId ? `target=${summary.targetId ?? pod.targetId ?? pod.target?.targetId}` : null,
summary.profileHash ?? pod.profileHash ?? pod.profile?.profileHash ? `profile=${shortEvidenceToken(summary.profileHash ?? pod.profileHash ?? pod.profile?.profileHash)}` : null,
refs.operationId ? `operation=${refs.operationId}` : null,
refs.traceId ? `trace=${refs.traceId}` : null,
refs.evidenceId ? `evidence=${refs.evidenceId}` : null
].filter(Boolean).join(" / ");
}
function classifyApiProbe(probe, context) {
if (!probe) {
return unverifiedProbe({
@@ -455,123 +521,6 @@ function blockerCodes(availability) {
return [...new Set(values.map(String))];
}
function classifyM3ControlProbe(live = {}) {
const probe = live.m3Control;
const context = {
serviceId: "hwlab-cloud-api",
apiPath: "/v1/m3/io",
fallbackReasonCode: "m3_io_unverified"
};
if (!probe) {
return unverifiedProbe({ ...context, reasonCode: context.fallbackReasonCode, reason: "not_observed" });
}
if (probe.ok !== true) {
return errorProbe({
...context,
reasonCode: probe.timeout ? "timeout" : probe.status ? `HTTP ${probe.status}` : "m3_io_request_failed",
reason: probe.error ?? "M3 IO readiness request failed",
httpStatus: probe.status,
traceId: traceIdFrom(probe)
});
}
const data = probe.data;
const readiness = data?.readiness ?? {};
if (data?.status === "available" && readiness.status === "ready" && readiness.controlReady === true) {
return passProbe({
...context,
reasonCode: "m3_io_ready",
reason: "cloud-api -> gateway-simu -> box-simu -> hwlab-patch-panel readiness ready",
rawStatus: data.status,
traceId: traceIdFrom(data),
evidenceSummary: evidenceSummaryFrom(data)
});
}
const blocker = readiness.blocker ?? data?.blocker;
return errorProbe({
...context,
serviceId: serviceForM3Layer(blocker?.layer) ?? context.serviceId,
reasonCode: blocker?.code ?? data?.status ?? "m3_io_not_ready",
reason: blocker?.zh ?? data?.blockedReason ?? "M3 IO readiness is not ready",
rawStatus: data?.status,
traceId: traceIdFrom(data),
evidenceSummary: evidenceSummaryFrom(data)
});
}
function classifyM3StatusProbe(live = {}) {
const probe = live.m3Status;
const context = {
serviceId: "hwlab-cloud-api",
apiPath: "/v1/m3/status",
fallbackReasonCode: "m3_status_unverified"
};
if (!probe) {
return unverifiedProbe({ ...context, reasonCode: context.fallbackReasonCode, reason: "not_observed" });
}
if (probe.ok !== true) {
return errorProbe({
...context,
reasonCode: probe.timeout ? "timeout" : probe.status ? `HTTP ${probe.status}` : "m3_status_request_failed",
reason: probe.error ?? "M3 status aggregation failed",
httpStatus: probe.status,
traceId: traceIdFrom(probe)
});
}
const data = probe.data;
const blocker = data?.blocker;
if (data?.status === "error") {
return errorProbe({
...context,
serviceId: serviceForM3Layer(blocker?.layer) ?? context.serviceId,
reasonCode: blocker?.code ?? "m3_status_error",
reason: blocker?.zh ?? data?.summary ?? "M3 status returned error",
rawStatus: data.status,
traceId: traceIdFrom(data),
evidenceSummary: evidenceSummaryFrom(data)
});
}
if (data?.status === "blocked" && blocker) {
const serviceId = serviceForM3Layer(blocker.layer) ?? (isReadOnlyReason(blocker.code) ? context.serviceId : context.serviceId);
const factory = isReadOnlyReason(blocker.code) || isReadOnlyReason(blocker.zh) ? readOnlyProbe : errorProbe;
return factory({
...context,
serviceId,
reasonCode: blocker.code ?? "m3_status_blocked",
reason: blocker.zh ?? data.summary ?? "M3 status blocked",
rawStatus: data.status,
traceId: traceIdFrom(data),
evidenceSummary: evidenceSummaryFrom(data)
});
}
if (data?.status === "blocked") {
return unverifiedProbe({
...context,
reasonCode: "m3_status_waiting_trusted_record",
reason: data.summary ?? "M3 status is waiting for trusted persistence evidence",
rawStatus: data.status,
traceId: traceIdFrom(data),
evidenceSummary: evidenceSummaryFrom(data)
});
}
if (data?.status === "live" || data?.sourceKind === "DEV-LIVE") {
return passProbe({
...context,
reasonCode: data.status ?? "dev_live",
reason: data.summary ?? "M3 status observed through cloud-api",
rawStatus: data.status,
traceId: traceIdFrom(data),
evidenceSummary: evidenceSummaryFrom(data)
});
}
return unverifiedProbe({
...context,
reasonCode: "m3_status_unverified",
reason: data?.summary ?? "M3 status not yet verified",
rawStatus: data?.status,
traceId: traceIdFrom(data),
evidenceSummary: evidenceSummaryFrom(data)
});
}
function durableRuntimeReason(payload) {
if (!hasRuntimeDurableSignal(payload)) return null;
@@ -677,7 +626,7 @@ export function classifyRuntimeDurableReadiness(payload = {}, options = {}) {
classification: "runtime_durable_ready",
reasonCode: "runtime_durable_ready",
reason: `runtime durable ready; adapter=${adapter}; queryResult=${queryResult || "not_observed"}`,
impact: "M3 durable postflight may run because runtime persistence readiness is green.",
impact: "Device Pod readiness checks may rely on durable runtime evidence when persistence readiness is green.",
safeNextAction: "Continue with repo-owned DEV CD postflight; do not read or print Secret values.",
retryable: true,
readonly: false,
@@ -720,7 +669,7 @@ export function classifyRuntimeDurableReadiness(payload = {}, options = {}) {
classification: nonReadyReasonCode,
reasonCode: nonReadyReasonCode,
reason: runtimeDurableBlockedReason({ reasonCode: nonReadyReasonCode, adapter, layer, queryResult }),
impact: "M3 true/false IO durable postflight is skipped or blocked so DEV CD cannot prove evidence persistence.",
impact: "Device Pod readiness remains source-level while durable runtime evidence is blocked.",
safeNextAction: runtimeDurableSafeNextAction(nonReadyReasonCode, layer),
retryable: true,
readonly: true,
@@ -1042,15 +991,6 @@ function evidenceSummaryFrom(value) {
return parts.length > 0 ? parts.join(" ") : null;
}
function serviceForM3Layer(layer) {
const text = normalizedStatus(layer);
if (!text) return null;
if (text.includes("patch")) return "hwlab-patch-panel";
if (text.includes("gateway")) return "hwlab-gateway-simu";
if (text.includes("box") || text.includes("resource")) return "hwlab-box-simu";
if (text.includes("runtime") || text.includes("durable")) return "hwlab-cloud-api";
return null;
}
function firstNonEmpty(...values) {
for (const value of values) {
+1 -2
View File
@@ -4,8 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"m3-readonly": "node scripts/m3-readonly-contract.mjs",
"check": "node --test auth.test.mjs code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs scripts/sidebar-resize.test.mjs scripts/trace-scroll.test.mjs && node scripts/check.mjs",
"check": "node --test auth.test.mjs code-agent-facts.test.mjs code-agent-status.test.mjs scripts/sidebar-resize.test.mjs scripts/trace-scroll.test.mjs && node scripts/check.mjs",
"build": "node scripts/build.mjs",
"layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json",
"layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json",
File diff suppressed because it is too large Load Diff
@@ -12,13 +12,9 @@ export const cloudWebDistRuntimeFiles = Object.freeze([
"app.mjs",
"code-agent-facts.mjs",
"code-agent-status.mjs",
"code-agent-m3-evidence.mjs",
"message-markdown.mjs",
"live-status.mjs",
"gate-summary.mjs",
"runtime.mjs",
"wiring-status.mjs",
"workbench-hardware-panel.mjs",
"help.md",
"third_party/marked/marked.esm.js",
"third_party/marked/LICENSE"
File diff suppressed because it is too large Load Diff
@@ -1,308 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
M3_IO_CHAIN,
M3_IO_CONTROL_ROUTE,
M3_STATUS_ROUTE
} from "../../../internal/cloud/m3-io-control.mjs";
import { checkFrontendNoDirectRuntimeCalls } from "../../../scripts/src/m3-io-control-e2e.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(rootDir, "../..");
const exactTrustedRoute = `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.patchPanelServiceId} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`;
const readOnlyRpcMethods = Object.freeze([
"system.health",
"cloud.adapter.describe",
"audit.event.query",
"evidence.record.query"
]);
function readText(relativePath) {
return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8");
}
export function runM3ControlPanelGuard() {
const html = readText("web/hwlab-cloud-web/index.html");
const app = readText("web/hwlab-cloud-web/app.mjs");
const styles = readText("web/hwlab-cloud-web/styles.css");
const artifactPublisher = readText("scripts/artifact-publish.mjs");
const cloudWebRouteSource = readText("internal/dev-entrypoint/cloud-web-routes.mjs");
const browserSource = `${html}\n${app}\n${styles}`;
assert.equal(M3_IO_CONTROL_ROUTE, "/v1/m3/io", "M3 IO control route must remain exact");
assert.equal(M3_STATUS_ROUTE, "/v1/m3/status", "M3 status aggregate route must remain exact");
assert.equal(
exactTrustedRoute,
"res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1",
"M3 trusted route constant must remain exact"
);
assertControlPanelDom(html);
assertControlPanelSource(app);
assertBrowserWriteBoundaries({ app, html, artifactPublisher, cloudWebRouteSource });
assertNoStaticDevLiveClaim({ html, app });
for (const pattern of [
/#panel-control\s*{\s*grid-template-rows:\s*auto auto auto minmax\(0,\s*1fr\);/s,
/\.m3-flow-summary\s*{/s,
/\.m3-flow-line\s*{/s,
/\.m3-flow-kv\s*{/s,
/\.m3-control-form\s*{/s,
/\.m3-control-grid\s*{/s,
/\.m3-control-actions\s*{/s
]) {
assert.match(styles, pattern, `missing M3 control style ${pattern.source}`);
}
for (const copy of [
"M3 IO 控制请求失败",
"M3 IO 只读 readiness 未 green",
"M3 IO 只读 readiness 已确认",
"受控路径受阻",
"等待 cloud-api 返回 M3 IO 只读 readiness",
"不可用时页面不直连模拟器",
"浏览器不直连 box-simu",
"阻塞原因=",
"仍在等待完整可信记录",
"页面不直连模拟器,也不伪造硬件状态。",
"控制可达 / 等待记录",
"待可信记录",
"选择 DO1 true/false 后点击“写入 DO1”",
"最近 trace 与 operation 可在“可信记录”页签追踪",
"DO1目标=",
"DI1观测=",
"trusted=",
"durable="
]) {
assert.match(browserSource, new RegExp(escapeRegExp(copy), "u"), `missing Chinese blocked/error copy: ${copy}`);
}
}
function assertControlPanelDom(html) {
const controlPanel = elementSectionById(html, "panel-control");
assert.match(controlPanel, /data-side-panel="control"/u, "right-side control tab panel must be present");
assert.match(controlPanel, /<h2>基础 IO 控制<\/h2>/u, "M3 control panel title must be user-visible without milestone shorthand");
assert.match(controlPanel, /id="m3-control-status"[^>]*>探测中</u, "M3 control status starts as probing, not live");
assert.match(controlPanel, /id="m3-flow-summary"[^>]*aria-label="M3 DO1 到 DI1 操作摘要"/u, "M3 operation summary must be visible above the form");
for (const id of ["m3-flow-state", "m3-action-summary", "m3-do1-target", "m3-di1-observed", "m3-trust-summary", "m3-trace-summary"]) {
assert.match(controlPanel, new RegExp(`id="${id}"`, "u"), `M3 flow summary missing #${id}`);
}
assert.match(controlPanel, /DO1 -> patch-panel -> DI1/u, "M3 summary must state the operator-facing DO1 to DI1 route");
assert.match(controlPanel, /id="m3-records-jump"[\s\S]*查看最近操作\/trace/u, "M3 summary must link recent trace to records tab");
assert.match(controlPanel, /<form[^>]+id="m3-control-form"[^>]+aria-label="DO1 写入"/u, "DO write form must be labeled");
assert.match(controlPanel, /id="m3-gateway-select"[\s\S]*value="gwsimu_1"[\s\S]*gateway-simu-1/u, "DO write uses source gateway");
assert.match(controlPanel, /id="m3-box-select"[\s\S]*value="res_boxsimu_1"[\s\S]*box-simu-1 \/ res_boxsimu_1/u, "DO write uses source box resource");
assert.match(controlPanel, /id="m3-port-select"[\s\S]*value="DO1"[\s\S]*DO1/u, "DO write exposes only DO1");
assert.match(controlPanel, /id="m3-value-select"[\s\S]*value="true"[\s\S]*true[\s\S]*value="false"[\s\S]*false/u, "DO write exposes true and false values");
assert.match(controlPanel, /id="m3-write-do"[^>]*type="submit"[^>]*>写入 DO1<\/button>/u, "DO write affordance must be visible");
assert.match(controlPanel, /id="m3-read-di"[^>]*type="button"[^>]*>读取 DI1<\/button>/u, "DI read affordance must be visible");
assert.match(controlPanel, /id="control-list"/u, "control panel must include operation feedback list");
assert.doesNotMatch(controlPanel, /tone-dev-live|开发实况 DEV-LIVE/u, "static control panel must not claim DEV-LIVE");
const wiringPanel = elementSectionById(html, "panel-wiring");
assert.match(html, new RegExp(escapeRegExp(exactTrustedRoute), "u"), "static fallback must show the exact M3 route");
for (const value of ["res_boxsimu_1", "DO1", "hwlab-patch-panel", "res_boxsimu_2", "DI1"]) {
assert.match(wiringPanel, new RegExp(escapeRegExp(value), "u"), `wiring panel missing ${value}`);
}
assert.match(wiringPanel, /data-wiring-layout="two-column-long-table"/u, "wiring panel must use the #276 two-column long-table layout");
assert.equal((wiringPanel.match(/<th\b/gu) ?? []).length, 2, "wiring table must have exactly two device columns");
assert.equal((wiringPanel.match(/<td\b/gu) ?? []).length, 2, "static wiring IO row must have exactly two port cells");
for (const header of ["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "轨迹/证据"]) {
assert.doesNotMatch(wiringPanel, new RegExp(`<th>${escapeRegExp(header)}<\\/th>`, "u"), `wiring table must not restore legacy wide column ${header}`);
}
assert.match(wiringPanel, /待可信记录/u, "static wiring row must stay blocked");
assert.match(wiringPanel, /等待可信记录/u, "static wiring row must require live evidence");
}
function assertControlPanelSource(app) {
const trustedRouteSource = constObjectSource(app, "M3_TRUSTED_ROUTE");
for (const [field, value] of [
["fromResourceId", M3_IO_CHAIN.sourceResourceId],
["fromPort", M3_IO_CHAIN.sourcePort],
["patchPanelServiceId", M3_IO_CHAIN.patchPanelServiceId],
["toResourceId", M3_IO_CHAIN.targetResourceId],
["toPort", M3_IO_CHAIN.targetPort]
]) {
assert.match(trustedRouteSource, new RegExp(`${field}:\\s*"${escapeRegExp(value)}"`, "u"), `M3 trusted route source missing ${field}`);
}
const initBody = functionBody(app, "initM3Control");
assert.match(initBody, /el\.m3ControlForm\.addEventListener\("submit"[\s\S]*runM3IoAction\("do\.write"\)/u, "submit must run do.write");
assert.match(initBody, /el\.m3ReadDi\.addEventListener\("click"[\s\S]*runM3IoAction\("di\.read"\)/u, "DI button must run di.read");
const actionBody = functionBody(app, "runM3IoAction");
assert.match(actionBody, /if \(!m3ControlCanOperate\(\)\)/u, "M3 action must fail closed before POST when readiness is blocked");
assert.match(actionBody, /fetchJson\("\/v1\/m3\/io",\s*\{[\s\S]*method:\s*"POST"/u, "M3 action must POST same-origin /v1/m3/io");
assert.match(actionBody, /fetchJson\("\/v1\/m3\/status"\)/u, "M3 action may refresh only through same-origin aggregate status after control");
assert.doesNotMatch(actionBody, /\bfetch\(/u, "M3 action must not use raw fetch");
assert.doesNotMatch(actionBody, /fetchJson\(\s*["'`](?!\/v1\/m3\/(?:io|status))/u, "M3 action must not call any route except /v1/m3/io and /v1/m3/status");
assert.match(actionBody, /gatewayId:\s*el\.m3GatewaySelect\.value/u, "DO write request must use the selected source gateway");
assert.match(actionBody, /resourceId:\s*el\.m3BoxSelect\.value/u, "DO write request must use the selected source resource");
assert.match(actionBody, /boxId:\s*"boxsimu_1"/u, "DO write request must target source boxsimu_1");
assert.match(actionBody, /port:\s*el\.m3PortSelect\.value/u, "DO write request must use selected DO1 port");
assert.match(actionBody, /value:\s*el\.m3ValueSelect\.value === "true"/u, "DO write request must convert true/false to boolean");
assert.match(actionBody, /gatewayId:\s*"gwsimu_2"/u, "DI read request must use target gateway");
assert.match(actionBody, /resourceId:\s*M3_TRUSTED_ROUTE\.toResourceId/u, "DI read request must use trusted target resource");
assert.match(actionBody, /boxId:\s*"boxsimu_2"/u, "DI read request must target boxsimu_2");
assert.match(actionBody, /port:\s*M3_TRUSTED_ROUTE\.toPort/u, "DI read request must use trusted DI1 port");
assert.match(actionBody, /"X-Trace-Id":\s*traceId/u, "M3 action must attach trace header");
assert.match(actionBody, /"X-Actor-Id":\s*"usr_hwlab_cloud_web"/u, "M3 action must attach web actor header");
assert.match(actionBody, /evidenceState\?\.status === "green" \? "DEV-LIVE" : "BLOCKED"/u, "M3 operation sourceKind must not claim DEV-LIVE unless evidence is green");
const canOperateBody = functionBody(app, "m3ControlCanOperate");
assert.match(canOperateBody, /readiness\?\.status === "ready"/u, "M3 controls must require readiness.status=ready");
assert.match(canOperateBody, /readiness\?\.controlReady === true/u, "M3 controls must require readiness.controlReady=true");
assert.match(canOperateBody, /readiness\?\.sourceKind === "DEV-LIVE"/u, "M3 controls must not unlock on SOURCE/DRY-RUN/BLOCKED readiness");
assert.match(canOperateBody, /readiness\?\.evidenceLevel === "DEV-LIVE"/u, "M3 controls must require live readiness evidence level");
const statusBody = functionBody(app, "renderM3ControlStatus");
assert.match(statusBody, /m3ControlStatusTitle\(\{ operation, contract, controlReady \}\)/u, "ready status title must use the control-ready-aware helper");
assert.match(statusBody, /renderM3FlowSummary\(\{ label, tone, operation, contract, controlReady, pending \}\)/u, "control status must keep the operator summary in sync");
const flowSummaryBody = functionBody(app, "renderM3FlowSummary");
for (const term of ["m3ActionSummary", "m3Do1Target", "m3Di1Observed", "m3TrustSummary", "m3TraceSummary"]) {
assert.match(flowSummaryBody, new RegExp(`el\\.${term}\\.textContent`, "u"), `M3 flow summary must update ${term}`);
}
assert.match(flowSummaryBody, /m3OperationDo1Target\(operation/u, "M3 flow summary must show the DO1 target");
assert.match(flowSummaryBody, /m3OperationDi1Observed\(operation/u, "M3 flow summary must show the DI1 observation");
assert.match(flowSummaryBody, /m3TrustSummaryText\(operation, contract\)/u, "M3 flow summary must show trusted/durable status");
assert.match(flowSummaryBody, /m3TraceSummaryText\(operation, status\)/u, "M3 flow summary must show recent operation/trace ids");
const statusTitleBody = functionBody(app, "m3ControlStatusTitle");
assert.match(statusTitleBody, /if \(controlReady\)/u, "ready status title must not fall through to blocked copy");
assert.match(statusTitleBody, /只读 readiness 已确认/u, "ready status title must show confirmed Chinese readiness copy");
assert.match(statusTitleBody, /return m3ControlBlockedReason\(contract\)/u, "blocked status title must still surface Chinese blocked reason");
const controlRowsBody = functionBody(app, "controlRows");
assert.match(controlRowsBody, /选择 DO1 true\/false 后点击/u, "control copy must start with an operator action script");
assert.match(controlRowsBody, /最近 trace 与 operation/u, "control copy must direct users to recent trace records");
assert.match(controlRowsBody, /按钮只走受控后端/u, "control copy must describe the controlled backend route");
assert.match(controlRowsBody, /gateway-simu -> box-simu -> hwlab-patch-panel/u, "control copy must show backend-owned path");
assert.match(controlRowsBody, /m3ControlBlockedReason\(\)/u, "blocked control copy must surface the cloud-api Chinese readiness reason");
assert.match(controlRowsBody, /受控路径读取/u, "control rows must describe DI read affordance");
const operationDetailBody = functionBody(app, "m3OperationDetail");
for (const field of ["operation", "trace", "audit", "evidence"]) {
assert.match(operationDetailBody, new RegExp(`recordField\\("${field}"`, "u"), `operation detail missing ${field}`);
}
assert.match(operationDetailBody, /operation\.blocker\?\.zh/u, "operation detail must prefer Chinese blocker copy");
assert.match(operationDetailBody, /DO1目标=/u, "operation detail must expose DO1 target");
assert.match(operationDetailBody, /DI1观测=/u, "operation detail must expose DI1 observation");
const operationRecordBody = functionBody(app, "operationRecordCards");
for (const field of ["action", "trace", "audit", "evidence"]) {
assert.match(operationRecordBody, new RegExp(`recordField\\("${field}"`, "u"), `operation record missing ${field}`);
}
assert.match(operationRecordBody, /DO1目标=/u, "operation records must expose DO1 target");
assert.match(operationRecordBody, /DI1观测=/u, "operation records must expose DI1 observation");
assert.match(operationRecordBody, /trusted=/u, "operation records must expose trusted state");
assert.match(operationRecordBody, /durable=/u, "operation records must expose durable state");
assert.match(operationRecordBody, /via=\/v1\/m3\/io/u, "operation record must show the control route");
assert.match(operationRecordBody, /frontendBypass=false/u, "operation record must show no frontend bypass");
}
function assertBrowserWriteBoundaries({ app, html, artifactPublisher, cloudWebRouteSource }) {
const browserSource = `${html}\n${app}`;
const sanitizedBrowserSource = browserSource.replace(/data-static-source-fallback="SOURCE"/gu, "");
const frontendGuard = checkFrontendNoDirectRuntimeCalls({
appSource: app,
htmlSource: html,
artifactPublisherSource: artifactPublisher,
cloudWebRouteSource
});
assert.equal(frontendGuard.status, "pass", frontendGuard.issues.join("\n"));
assert.deepEqual(
uniqueSorted(rpcCallsFrom(app)),
[...readOnlyRpcMethods].sort(),
"browser JSON-RPC calls must stay read-only"
);
const postTargets = [...app.matchAll(/fetchJson\(\s*["']([^"']+)["'][\s\S]{0,260}?method:\s*["']POST["']/gu)]
.map((match) => match[1]);
assert.deepEqual(
uniqueSorted(postTargets),
["/json-rpc", "/v1/agent/chat", "/v1/agent/chat/cancel", M3_IO_CONTROL_ROUTE].sort(),
"browser POST routes must be limited to read-only RPC envelope, agent chat lifecycle, and M3 IO control"
);
const controlWriteTargets = postTargets.filter((target) => target !== "/json-rpc" && target !== "/v1/agent/chat" && target !== "/v1/agent/chat/cancel");
assert.deepEqual(controlWriteTargets, [M3_IO_CONTROL_ROUTE], "same-origin /v1/m3/io is the only browser hardware write control route");
assert.match(app, /fetchJson\("\/v1\/m3\/status"\)/u, "browser hardware status reads must use same-origin /v1/m3/status");
for (const [pattern, label] of [
[/\bcallRpc\(\s*["']hardware\./u, "generic hardware JSON-RPC"],
[/hardware\.operation\.request/u, "hardware operation RPC"],
[/hardware\.invoke\.shell/u, "hardware shell RPC"],
[/audit\.event\.write/u, "audit write RPC"],
[/evidence\.record\.write/u, "evidence write RPC"],
[/\/v1\/rpc\//u, "direct REST RPC bridge"],
[/\bfetch(?:Json)?\(\s*["'`][^"'`]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu, "direct simulator fetch"],
[/https?:\/\/[^"'`\s]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu, "direct simulator URL"],
[/\/wiring\/apply|\/wiring\/reload|\/signals\/route/u, "patch-panel write endpoint"]
]) {
assert.doesNotMatch(sanitizedBrowserSource, pattern, `browser source must not expose ${label}`);
}
}
function assertNoStaticDevLiveClaim({ html, app }) {
const controlPanel = elementSectionById(html, "panel-control");
const wiringPanel = elementSectionById(html, "panel-wiring");
assert.doesNotMatch(`${controlPanel}\n${wiringPanel}`, /tone-dev-live|开发实况 DEV-LIVE/u, "static M3 panels must not claim DEV-LIVE");
assert.match(`${controlPanel}\n${wiringPanel}`, /tone-blocked|待可信记录/u, "static M3 panels must keep blocked/source posture");
assert.doesNotMatch(
functionBody(app, "renderM3ControlStatus"),
/contract\?\.status === "available"[\s\S]{0,120}disabled/u,
"M3 status rendering must not unlock controls from route availability alone"
);
}
function rpcCallsFrom(source) {
return [...source.matchAll(/callRpc\(\s*["']([^"']+)["']/gu)].map((match) => match[1]);
}
function uniqueSorted(values) {
return [...new Set(values)].sort();
}
function elementSectionById(source, id) {
const start = source.search(new RegExp(`<section\\b[^>]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "u"));
assert.notEqual(start, -1, `missing section #${id}`);
const end = source.indexOf("</section>", start);
assert.notEqual(end, -1, `unterminated section #${id}`);
return source.slice(start, end + "</section>".length);
}
function constObjectSource(source, constName) {
const match = source.match(new RegExp(`const\\s+${escapeRegExp(constName)}\\s*=\\s*Object\\.freeze\\(\\{`, "u"));
if (!match) return "";
const start = match.index;
const end = source.indexOf("});", start);
return end === -1 ? source.slice(start) : source.slice(start, end + 3);
}
function functionBody(source, functionName) {
const match = source.match(new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`, "u"));
if (!match) return "";
let depth = 0;
for (let index = match.index + match[0].length - 1; index < source.length; index += 1) {
const char = source[index];
if (char === "{") depth += 1;
if (char === "}") {
depth -= 1;
if (depth === 0) return source.slice(match.index, index + 1);
}
}
return "";
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
runM3ControlPanelGuard();
console.log("M3 control panel SOURCE/DOM guard passed");
}
@@ -1,148 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { gateSummary } from "../gate-summary.mjs";
import { runtime } from "../runtime.mjs";
const PUBLIC_FRONTEND_ENDPOINT = "http://74.48.78.17:16666";
const PUBLIC_API_EDGE_ENDPOINT = "http://74.48.78.17:16667";
const READ_ONLY_RPC_METHODS = Object.freeze([
"system.health",
"cloud.adapter.describe",
"audit.event.query",
"evidence.record.query"
]);
const CONTROL_ROUTE = "/v1/m3/io";
const STATUS_ROUTE = "/v1/m3/status";
const CODE_AGENT_MUTATION_ROUTES = Object.freeze([
"/v1/agent/chat",
"/v1/agent/chat/cancel"
]);
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(rootDir, "../..");
function readText(relativePath) {
return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8");
}
function readJson(relativePath) {
return JSON.parse(readText(relativePath));
}
function uniqueSorted(values) {
return [...new Set(values)].sort();
}
function rpcCallsFrom(source) {
return [...source.matchAll(/callRpc\(\s*["']([^"']+)["']/gu)].map((match) => match[1]);
}
export function runCloudWebM3ReadonlyContract() {
if (process.env.HWLAB_ENFORCE_M3_READONLY_CONTRACT !== "1") {
return {
status: "skipped",
reason: "legacy-readonly-contract-disabled-for-g14-gitops-rollout"
};
}
const html = readText("web/hwlab-cloud-web/index.html");
const app = readText("web/hwlab-cloud-web/app.mjs");
const styles = readText("web/hwlab-cloud-web/styles.css");
const runtimeSource = readText("web/hwlab-cloud-web/runtime.mjs");
const frontendSource = `${html}\n${app}\n${styles}\n${runtimeSource}`;
const acceptance = readJson("docs/dev-acceptance-checklist.json");
assert.equal(acceptance.publicEndpoints.frontend, PUBLIC_FRONTEND_ENDPOINT, "public frontend must stay on :16666");
assert.equal(acceptance.publicEndpoints.api, PUBLIC_API_EDGE_ENDPOINT, "public API must stay on :16667");
assert.equal(acceptance.publicEndpoints.edge, PUBLIC_API_EDGE_ENDPOINT, "public edge must stay on :16667");
assert.equal(runtime.endpoints.frontend, PUBLIC_FRONTEND_ENDPOINT, "web runtime frontend endpoint");
assert.equal(runtime.endpoints.api, PUBLIC_API_EDGE_ENDPOINT, "web runtime API endpoint");
assert.equal(runtime.endpoints.edge, PUBLIC_API_EDGE_ENDPOINT, "web runtime edge endpoint");
assert.equal(runtime.endpoints.dev, PUBLIC_API_EDGE_ENDPOINT, "legacy runtime dev endpoint alias");
assert.equal(gateSummary.endpoint, PUBLIC_API_EDGE_ENDPOINT, "gate summary endpoint");
assert.deepEqual(
uniqueSorted(rpcCallsFrom(app)),
[...READ_ONLY_RPC_METHODS].sort(),
"Cloud Web must call only read-only JSON-RPC diagnostics methods"
);
assert.match(app, /fetchJson\("\/v1"\)/u, "Cloud Web must read the public REST index");
assert.match(app, /fetchJson\("\/v1\/diagnostics\/gate"/u, "Internal review page must read the same-origin live diagnostics table route");
assert.match(app, /fetchJson\("\/v1\/m3\/io"/u, "Cloud Web M3 control must use only the same-origin cloud-api M3 IO route");
assert.match(app, /fetchJson\("\/v1\/m3\/status"\)/u, "Cloud Web M3 status must use only the same-origin cloud-api aggregate status route");
assert.match(app, /fetchJson\("\/json-rpc"/u, "Cloud Web must use same-origin JSON-RPC for diagnostics");
assert.deepEqual(runtime.serviceRoute, [
"browser/CLI/gateway",
"master hwlab-edge-proxy",
"frp",
"D601 hwlab-dev/hwlab-router",
"cloud-web/cloud-api"
], "Cloud Web runtime must retain the observed route contract");
assert.ok(app.includes("frontendBypass=false"), "M3 control evidence must explicitly record no frontend bypass");
for (const [pattern, label] of [
[/DEV Control Console/iu, "generic control console label"],
[/Operator Commands/iu, "operator command entry point label"],
[/safe CLI entry points/iu, "manual command launcher copy"],
[/--live --confirm-dev --confirmed-non-production/iu, "live mutation command"],
[/hardware\.operation\.request/iu, "direct hardware operation RPC"],
[/hardware\.invoke\.shell/iu, "direct hardware shell RPC"],
[/audit\.event\.write/iu, "audit write RPC"],
[/evidence\.record\.write/iu, "evidence write RPC"],
[/\/v1\/rpc\//iu, "direct REST RPC bridge"],
[/https?:\/\/[^"']*(?:gateway-simu|box-simu|patch-panel)[^"']*/iu, "direct simulator endpoint"],
[/\/wiring\/apply/iu, "patch-panel apply endpoint"],
[/\/wiring\/reload/iu, "patch-panel reload endpoint"],
[/method-pill[\s\S]{0,80}write/iu, "write method pill"]
]) {
assert.doesNotMatch(frontendSource, pattern, `Cloud Web frontend must not expose ${label}`);
}
const mutationFetches = [...app.matchAll(/fetchJson\(\s*["']([^"']+)["'][\s\S]{0,220}?method:\s*["']POST["']/gu)]
.map((match) => match[1])
.filter((path) => !CODE_AGENT_MUTATION_ROUTES.includes(path) && path !== CONTROL_ROUTE && path !== STATUS_ROUTE && path !== "/json-rpc");
assert.deepEqual(mutationFetches, [], "Cloud Web must not add POST mutation fetches outside Code Agent chat and M3 cloud-api control route");
const m3Milestone = gateSummary.milestones.find((item) => item.id === "M3");
assert.equal(m3Milestone?.status, "blocked", "Cloud Web must not present edge-only M3 as DEV-LIVE");
assert.match(m3Milestone.summary, /blocked by hardware loop topology/u, "M3 summary must explain the live blocker");
assert.ok(
gateSummary.blockers.some((blocker) => blocker.scope === "m3-hardware-loop-runtime"),
"M3 hardware-loop runtime blocker must remain visible"
);
assert.equal(gateSummary.topology.patchPanel.serviceId, "hwlab-patch-panel", "topology must route through patch-panel");
assert.deepEqual(gateSummary.topology.gateways.map((gateway) => gateway.gatewayId), ["gwsimu_1", "gwsimu_2"], "M3 workbench topology must expose two distinct gateway-simu identities");
assert.deepEqual(gateSummary.topology.boxResources.map((resource) => resource.resourceId), ["res_boxsimu_1", "res_boxsimu_2"], "M3 workbench topology must expose two distinct box-simu identities");
assert.deepEqual(gateSummary.topology.patchPanel.activeConnections, [
{
fromResourceId: "res_boxsimu_1",
fromPort: "DO1",
toResourceId: "res_boxsimu_2",
toPort: "DI1"
}
], "M3 workbench patch-panel links must show only res_boxsimu_1:DO1 -> res_boxsimu_2:DI1");
assert.doesNotMatch(
JSON.stringify(gateSummary.topology),
/res_m5-control-relay|res_m5-target-board|out1|reset/u,
"M5/reset dry-run wiring must not appear in the default M3 workbench topology"
);
assert.ok(gateSummary.health.length >= 1, "checked-in gate summary must retain source health context for fallback explanation");
assert.ok(gateSummary.evidenceRecords.every((record) => record.dryRun === true), "checked-in evidence must stay dry-run labeled");
assert.ok(
acceptance.m3EvidenceClassification.nonP0.includes("edge-only-diagnostic"),
"edge-only diagnostics must stay non-P0 support evidence"
);
assert.match(
acceptance.m3EvidenceClassification.promotionRule,
/Do not classify M3 support or non-P0 evidence as DEV-LIVE/u,
"M3 promotion rule must forbid edge-only DEV-LIVE promotion"
);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
runCloudWebM3ReadonlyContract();
console.log("Cloud Web M3 read-only diagnostics contract passed");
}
@@ -168,7 +168,7 @@ async function sidebarMetrics(page) {
const shell = document.querySelector("[data-app-shell]");
const rail = document.querySelector("#activity-rail");
const center = document.querySelector(".center-workspace");
const right = document.querySelector("#hardware-sidebar");
const right = document.querySelector("#device-pod-sidebar");
const handle = document.querySelector("#left-sidebar-resize");
const handleStyle = getComputedStyle(handle);
const handleBox = handle.getBoundingClientRect();
@@ -176,18 +176,14 @@ async function sidebarMetrics(page) {
const targetSelectors = [
"#command-input",
"#command-send",
".hardware-status",
"#hardware-list",
"#m3-write-do",
"#m3-read-di",
"[data-hardware-tab='overview']",
"[data-hardware-tab='gateways']",
"[data-hardware-tab='box1']",
"[data-hardware-tab='box2']",
"[data-hardware-tab='patch']"
"#device-pod-select",
'#device-pod-summary [data-device-detail="pod"]',
"#device-pod-interfaces .summary-tile",
"#device-event-scroll",
"#device-event-follow"
];
const keyTargets = targetSelectors.map((selector) => inspectTargetVisibility(selector));
const disabledTargets = ["#command-form", ".hardware-status"].map((selector) => inspectTargetVisibility(selector));
const disabledTargets = ["#command-form", "#device-pod-summary"].map((selector) => inspectTargetVisibility(selector));
return {
collapsed: shell.classList.contains("is-left-sidebar-collapsed"),
railWidth: Math.round(rail.getBoundingClientRect().width),
@@ -1,245 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
M3_TRUSTED_CHAIN,
createHardwareEvidencePanelModel,
defaultHardwareEvidencePanelData,
hardwareEvidencePanelSourceLegend,
renderHardwareEvidencePanel
} from "../workbench-hardware-panel.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const panelPath = path.resolve(rootDir, "workbench-hardware-panel.mjs");
const panelSource = fs.readFileSync(panelPath, "utf8");
const REQUIRED_SOURCE_KINDS = Object.freeze(["SOURCE", "DRY-RUN", "DEV-LIVE", "BLOCKED"]);
const FORBIDDEN_MUTATION_PATTERNS = Object.freeze([
/hardware\.operation\.request/u,
/callRpc\(\s*["']hardware\./u,
/hardware\.invoke\.shell/u,
/audit\.event\.write/u,
/evidence\.record\.write/u,
/\/wiring\/apply/u,
/\/wiring\/reload/u,
/fetch\(/u,
/XMLHttpRequest/u,
/sendBeacon/u
]);
export function runWorkbenchHardwarePanelContract() {
assert.equal(typeof renderHardwareEvidencePanel, "function", "renderer export must exist");
assert.equal(typeof createHardwareEvidencePanelModel, "function", "model export must exist");
assert.deepEqual(M3_TRUSTED_CHAIN, {
fromResourceId: "res_boxsimu_1",
fromPort: "DO1",
patchPanelServiceId: "hwlab-patch-panel",
toResourceId: "res_boxsimu_2",
toPort: "DI1"
});
const sourceKinds = hardwareEvidencePanelSourceLegend.map((item) => item.sourceKind);
assert.deepEqual(sourceKinds, REQUIRED_SOURCE_KINDS, "panel must expose SOURCE/DRY-RUN/DEV-LIVE/BLOCKED tags");
for (const sourceKind of REQUIRED_SOURCE_KINDS) {
assert.match(panelSource, new RegExp(`["']${sourceKind}["']`, "u"), `${sourceKind} label must be rendered by source`);
}
const blockedModel = createHardwareEvidencePanelModel(defaultHardwareEvidencePanelData);
assert.equal(blockedModel.trustedLoop.status, "blocked", "default model must stay blocked without DEV-LIVE evidence");
assert.equal(blockedModel.trustedLoop.tone, "degraded", "default model must render degraded, not green");
assert.equal(blockedModel.sourceKind, "BLOCKED", "missing DEV-LIVE evidence must force BLOCKED source kind");
assert.equal(blockedModel.chain.fromResourceId, "res_boxsimu_1");
assert.equal(blockedModel.chain.fromPort, "DO1");
assert.equal(blockedModel.chain.patchPanelServiceId, "hwlab-patch-panel");
assert.equal(blockedModel.chain.toResourceId, "res_boxsimu_2");
assert.equal(blockedModel.chain.toPort, "DI1");
assert.match(blockedModel.chain.detail, /BLOCKED/u);
assert.match(blockedModel.chain.detail, /runtime\.durable=false/u);
assert.equal(blockedModel.trustedLoop.runtimeDurability.ready, false);
assert.ok(
blockedModel.statuses.some((item) => item.kind === "BOX-SIMU" && item.id === "res_boxsimu_1"),
"BOX-SIMU source status must be present"
);
assert.ok(
blockedModel.statuses.some((item) => item.kind === "Gateway-SIMU"),
"Gateway-SIMU status must be present"
);
assert.ok(
blockedModel.statuses.some((item) => item.kind === "Patch Panel" && item.id === "hwlab-patch-panel"),
"Patch Panel status must be present"
);
assert.ok(blockedModel.records.operations.length >= 1, "operation summary must render");
assert.ok(blockedModel.records.audit.length >= 1, "audit summary must render");
assert.ok(blockedModel.records.evidence.length >= 1, "evidence summary must render");
assert.ok(
blockedModel.records.evidence.some((record) => record.sourceKind === "DRY-RUN"),
"fixture evidence must stay DRY-RUN labeled"
);
assert.ok(blockedModel.controls.length >= 1, "placeholder controls must exist");
for (const control of blockedModel.controls) {
assert.equal(control.disabled, true, `${control.id} must be disabled`);
assert.match(control.reason, /not wired \/ readonly \/ waiting for DEV-LIVE M3 loop/u);
}
const misleadingGreenModel = createHardwareEvidencePanelModel({
statuses: [
{
id: "res_boxsimu_1",
label: "BOX-SIMU 1",
kind: "BOX-SIMU",
status: "healthy",
sourceKind: "SOURCE"
}
],
chain: {
...M3_TRUSTED_CHAIN,
status: "healthy",
sourceKind: "SOURCE"
},
records: {
operations: [],
audit: [],
evidence: []
},
controls: []
});
assert.equal(misleadingGreenModel.trustedLoop.status, "blocked", "green-looking source data cannot promote M3");
assert.equal(misleadingGreenModel.chain.status, "blocked", "chain must be blocked without DEV-LIVE evidence");
assert.notEqual(misleadingGreenModel.statuses[0].status, "healthy", "healthy source-only status must be downgraded");
const devLiveEvidenceWithMemoryRuntime = {
chain: {
...M3_TRUSTED_CHAIN,
status: "verified",
sourceKind: "DEV-LIVE",
detail: "DEV-LIVE M3 loop observed and attached."
},
records: {
operations: [
{
id: "op_m3_dev_live_readonly_summary",
status: "succeeded",
sourceKind: "DEV-LIVE",
summary: "Existing trusted operation summary only."
}
],
audit: [
{
id: "aud_m3_dev_live_readonly_summary",
status: "recorded",
sourceKind: "DEV-LIVE",
summary: "Existing trusted audit summary only."
}
],
evidence: [
{
id: "evi_m3_dev_live_loop",
status: "verified",
sourceKind: "DEV-LIVE",
summary: "Trusted M3 loop evidence is already recorded."
}
]
}
};
const nonDurableModel = createHardwareEvidencePanelModel(devLiveEvidenceWithMemoryRuntime);
assert.equal(nonDurableModel.trustedLoop.status, "blocked", "DEV-LIVE-looking records cannot promote without durable runtime");
assert.equal(nonDurableModel.sourceKind, "BLOCKED");
assert.match(nonDurableModel.trustedLoop.reason, /runtime\.durable=false/u);
const trustedModel = createHardwareEvidencePanelModel({
...devLiveEvidenceWithMemoryRuntime,
runtime: {
adapter: "postgres",
durable: true,
ready: true,
status: "ready",
liveRuntimeEvidence: true
},
controls: [
{
id: "dev-live-safe-placeholder",
label: "Readonly",
disabled: false,
reason: ""
}
]
});
assert.equal(trustedModel.trustedLoop.status, "verified", "DEV-LIVE evidence plus exact chain can promote M3");
assert.equal(trustedModel.sourceKind, "DEV-LIVE");
assert.equal(trustedModel.controls[0].disabled, true, "controls must remain disabled even with DEV-LIVE evidence");
assert.match(trustedModel.controls[0].reason, /not wired \/ readonly \/ waiting for DEV-LIVE M3 loop/u);
const dom = createFakeDom();
const rendered = renderHardwareEvidencePanel(dom.container, defaultHardwareEvidencePanelData);
assert.equal(rendered.model.sourceKind, "BLOCKED", "renderer must use guarded model");
assert.equal(dom.container.children.length, 1, "renderer must replace the container with one panel root");
assert.equal(dom.container.children[0].dataset.sourceKind, "BLOCKED", "rendered panel must expose sourceKind");
const buttons = dom.container.querySelectorAll("button");
assert.equal(buttons.length, blockedModel.controls.length, "renderer must include all placeholder controls");
for (const button of buttons) {
assert.equal(button.disabled, true, "rendered placeholder controls must be disabled");
}
for (const pattern of FORBIDDEN_MUTATION_PATTERNS) {
assert.doesNotMatch(panelSource, pattern, `panel must not include mutation or network path ${pattern}`);
}
}
function createFakeDom() {
const document = {
createElement(tagName) {
return new FakeElement(tagName, document);
}
};
return {
document,
container: new FakeElement("div", document)
};
}
class FakeElement {
constructor(tagName, ownerDocument) {
this.tagName = tagName.toUpperCase();
this.ownerDocument = ownerDocument;
this.children = [];
this.dataset = {};
this.attributes = {};
this.className = "";
this.disabled = false;
this.textContent = "";
this.type = "";
}
append(...children) {
this.children.push(...children);
}
replaceChildren(...children) {
this.children = children;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
querySelectorAll(tagName) {
const normalizedTagName = tagName.toUpperCase();
const matches = [];
for (const child of this.children) {
if (child.tagName === normalizedTagName) {
matches.push(child);
}
matches.push(...child.querySelectorAll(tagName));
}
return matches;
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
runWorkbenchHardwarePanelContract();
console.log("Workbench hardware evidence panel contract passed");
}
+183 -628
View File
@@ -385,8 +385,6 @@ body > [data-app-shell][hidden] {
}
.topbar,
.hardware-status,
.side-workspace,
.workspace-panel {
background: rgba(24, 27, 24, 0.97);
border: 1px solid var(--line);
@@ -460,8 +458,7 @@ h3 {
}
.compact-list,
.conversation-list,
.hardware-list {
.conversation-list {
min-height: 0;
overflow: auto;
}
@@ -770,39 +767,6 @@ h3 {
gap: 8px;
}
.record-group {
min-width: 0;
display: grid;
gap: 7px;
padding: 8px;
background: var(--surface);
border: 1px solid var(--line);
}
.record-group-head {
min-width: 0;
display: grid;
gap: 2px;
}
.record-group-title {
color: var(--text);
font-weight: 800;
overflow-wrap: anywhere;
}
.record-group-meta {
color: var(--dim);
font-family: var(--mono);
font-size: 10px;
overflow-wrap: anywhere;
}
.record-group-list {
min-height: 0;
display: grid;
gap: 7px;
}
.code-agent-summary {
min-width: 0;
@@ -1095,7 +1059,6 @@ h3 {
.message-runtime-path,
.message-session-context,
.message-trace,
.message-m3-evidence,
.message-actions {
grid-column: 2;
min-width: 0;
@@ -1162,9 +1125,6 @@ h3 {
border-left-width: 3px;
}
.message-m3-evidence {
background: rgba(15, 17, 16, 0.44);
}
.message-actions {
display: flex;
@@ -1300,153 +1260,6 @@ h3 {
color: var(--text);
}
.message-m3-head {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
}
.message-m3-summary {
min-width: 0;
color: var(--muted);
font-family: var(--mono);
font-size: 10px;
overflow-wrap: anywhere;
}
.code-agent-fact-grid,
.message-m3-rows {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
}
.code-agent-fact-grid {
margin: 0;
}
.code-agent-fact-row {
min-width: 0;
display: grid;
gap: 2px;
padding: 6px;
background: var(--surface);
border: 1px solid var(--line);
}
.message-m3-row {
min-width: 0;
display: grid;
grid-template-columns: minmax(70px, auto) minmax(0, 1fr) auto;
gap: 5px;
align-items: center;
padding: 4px 5px;
background: var(--surface);
border: 1px solid var(--line);
}
.code-agent-fact-key,
.code-agent-fact-value {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.code-agent-fact-key {
color: var(--dim);
font-family: var(--mono);
font-size: 9px;
font-weight: 760;
text-transform: uppercase;
}
.code-agent-fact-value {
color: var(--text);
font-size: 10px;
}
.hwlab-api-facts {
min-width: 0;
display: grid;
gap: 5px;
}
.hwlab-api-facts-title {
color: var(--accent-2);
font-family: var(--mono);
font-size: 9px;
font-weight: 760;
text-transform: uppercase;
}
.hwlab-api-fact-code {
min-width: 0;
display: block;
padding: 6px;
background: var(--surface);
border: 1px solid var(--line);
color: var(--text);
font-family: var(--mono);
font-size: 10px;
line-height: 1.35;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
user-select: all;
}
.message-m3-key {
min-width: 0;
color: var(--dim);
font-family: var(--mono);
font-size: 10px;
overflow-wrap: anywhere;
}
.message-m3-value {
min-width: 0;
color: var(--text);
font-size: 10px;
overflow-wrap: anywhere;
word-break: break-word;
}
.message-m3-value[href] {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.message-m3-raw {
min-width: 0;
padding: 6px;
background: rgba(20, 24, 23, 0.72);
border: 1px solid var(--line-soft);
}
.message-m3-raw summary {
cursor: pointer;
color: var(--dim);
font-family: var(--mono);
font-size: 10px;
}
.message-m3-raw pre {
min-width: 0;
max-height: 240px;
margin: 7px 0 0;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
color: var(--muted);
font-family: var(--mono);
font-size: 10px;
line-height: 1.4;
}
.copy-chip {
min-width: 42px;
@@ -1766,83 +1579,6 @@ h3 {
overflow: hidden;
}
.hardware-status {
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 10px;
padding: 12px;
overflow: hidden;
}
.hardware-status-tabs {
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hardware-tab {
flex: 1 1 92px;
min-width: 0;
min-height: 30px;
padding: 5px 7px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
font-family: var(--mono);
font-size: 10px;
font-weight: 760;
cursor: pointer;
overflow-wrap: anywhere;
word-break: keep-all;
}
.hardware-tab:hover,
.hardware-tab:focus-visible,
.hardware-tab.active {
border-color: var(--line-strong);
background: var(--surface-2);
color: var(--text);
outline: 0;
}
.hardware-tab.active {
box-shadow: inset 0 -3px 0 var(--accent);
}
.hardware-list {
display: grid;
gap: 8px;
align-content: start;
overflow-x: hidden;
}
.hardware-section {
display: grid;
gap: 8px;
padding: 9px;
background: var(--surface-2);
border: 1px solid var(--line);
}
.hardware-section-title {
color: var(--accent-2);
font-family: var(--mono);
font-size: 10px;
font-weight: 760;
letter-spacing: 0;
text-transform: uppercase;
}
.hardware-rows {
display: grid;
gap: 6px;
}
.hardware-kv-section {
min-width: 0;
}
.kv-list {
min-width: 0;
@@ -1883,295 +1619,7 @@ h3 {
font-size: 11px;
}
.hardware-row {
min-width: 0;
display: grid;
gap: 5px 8px;
padding: 8px;
background: var(--surface);
border: 1px solid var(--line);
border-left-width: 3px;
}
.hardware-row-head {
grid-column: 1 / -1;
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.hardware-row-label,
.hardware-row-value,
.hardware-row-detail {
min-width: 0;
overflow-wrap: anywhere;
}
.hardware-row-label {
color: var(--text);
font-weight: 760;
}
.hardware-row-value {
color: var(--text);
font-family: var(--mono);
font-size: 14px;
font-weight: 800;
line-height: 1.1;
}
.hardware-row-detail {
color: var(--muted);
font-size: 11px;
}
.side-workspace {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
}
.side-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--line);
}
.side-tab {
min-width: 0;
min-height: 34px;
padding: 6px 4px;
border: 0;
border-right: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
font-size: 11px;
font-weight: 760;
cursor: pointer;
overflow-wrap: anywhere;
word-break: keep-all;
}
.side-tab:last-child {
border-right: 0;
}
.side-tab:hover,
.side-tab:focus-visible,
.side-tab.active {
background: var(--surface-2);
color: var(--text);
outline: 0;
}
.side-tab.active {
box-shadow: inset 0 -3px 0 var(--accent);
}
.side-panel {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
padding: 12px;
overflow: auto;
overflow-x: hidden;
overscroll-behavior: contain;
}
.side-panel[hidden] {
display: none;
}
#panel-control {
grid-template-rows: auto auto auto minmax(0, 1fr);
}
#panel-wiring {
grid-template-rows: auto auto auto;
align-content: start;
}
.m3-control-form {
min-width: 0;
display: grid;
gap: 9px;
padding: 9px;
background: var(--surface);
border: 1px solid var(--line);
}
.m3-flow-summary {
min-width: 0;
display: grid;
gap: 9px;
padding: 10px;
background: rgba(20, 24, 23, 0.78);
border: 1px solid var(--line-strong);
}
.m3-flow-head,
.m3-flow-footer {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.m3-flow-head > div {
min-width: 0;
display: grid;
gap: 2px;
}
.m3-flow-head strong {
min-width: 0;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.m3-flow-line {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1.1fr) auto minmax(0, 1.2fr) minmax(0, 1.1fr) auto;
gap: 6px;
align-items: center;
}
.m3-flow-node,
.m3-flow-port,
.m3-flow-hop {
min-width: 0;
padding: 6px 7px;
border: 1px solid var(--line);
background: var(--surface);
line-height: 1.25;
overflow-wrap: anywhere;
}
.m3-flow-port {
color: #151812;
background: var(--accent);
border-color: var(--accent);
font-family: var(--mono);
font-size: 10px;
font-weight: 900;
text-align: center;
}
.m3-flow-hop {
color: var(--accent-2);
font-family: var(--mono);
font-size: 10px;
font-weight: 800;
text-align: center;
}
.m3-flow-kv {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin: 0;
}
.m3-flow-kv div {
min-width: 0;
display: grid;
gap: 3px;
padding: 7px;
background: var(--surface);
border: 1px solid var(--line);
}
.m3-flow-kv dt {
color: var(--dim);
font-family: var(--mono);
font-size: 10px;
font-weight: 760;
}
.m3-flow-kv dd {
min-width: 0;
margin: 0;
color: var(--text);
font-family: var(--mono);
font-size: 10px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.m3-jump-button {
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--line-strong);
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-family: var(--mono);
font-size: 10px;
font-weight: 760;
}
.m3-jump-button:hover,
.m3-jump-button:focus-visible {
border-color: var(--accent);
outline: 0;
}
.m3-trace-summary {
min-width: 0;
color: var(--muted);
font-size: 10px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.m3-control-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.m3-control-grid label {
min-width: 0;
display: grid;
gap: 4px;
color: var(--muted);
font-size: 10px;
font-weight: 760;
text-transform: uppercase;
}
.m3-control-grid select {
width: 100%;
min-width: 0;
min-height: 30px;
padding: 5px 7px;
border: 1px solid var(--line-strong);
background: var(--surface-2);
color: var(--text);
font: 11px/1.2 var(--mono);
}
.m3-control-grid select:disabled,
.m3-control-actions button:disabled {
opacity: 0.62;
cursor: not-allowed;
}
.m3-control-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
.info-card {
display: grid;
@@ -2278,65 +1726,8 @@ table {
min-width: 860px;
}
.wiring-table-wrap {
width: 100%;
min-width: 0;
min-height: 128px;
max-height: min(220px, 42dvh);
overflow-x: hidden;
overflow-y: auto;
}
.wiring-long-table {
table-layout: fixed;
min-width: 0;
}
.wiring-long-table th,
.wiring-long-table td {
width: 50%;
}
.wiring-long-table th {
color: var(--text);
font-size: 11px;
text-transform: none;
}
.wiring-long-table th small,
.wiring-detail {
display: block;
margin-top: 4px;
color: var(--muted);
font-family: var(--font);
font-size: 10px;
font-weight: 640;
line-height: 1.35;
text-transform: none;
}
.wiring-port {
display: block;
color: var(--text);
font-size: 13px;
font-weight: 840;
line-height: 1.15;
}
.wiring-summary {
min-width: 0;
margin: 0;
padding: 9px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
font-size: 11px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.right-sidebar table,
.right-sidebar .wiring-table {
.right-sidebar table {
min-width: 0;
table-layout: fixed;
}
@@ -2559,9 +1950,6 @@ tbody tr:last-child td {
grid-row: 2;
}
.hardware-status {
grid-template-rows: auto minmax(0, auto) minmax(0, 1fr);
}
.topbar {
min-height: 0;
@@ -2644,13 +2032,6 @@ tbody tr:last-child td {
text-align: left;
}
.hardware-list,
.m3-flow-line,
.m3-flow-kv,
.m3-control-grid,
.m3-control-actions {
grid-template-columns: 1fr;
}
.kv-row {
grid-template-columns: 1fr;
@@ -2665,7 +2046,6 @@ tbody tr:last-child td {
.message-runtime-path,
.message-session-context,
.message-trace,
.message-m3-evidence,
.message-actions {
grid-column: 1;
}
@@ -2682,13 +2062,7 @@ tbody tr:last-child td {
grid-template-columns: 1fr;
}
.message-m3-rows {
grid-template-columns: 1fr;
}
.message-m3-row {
grid-template-columns: minmax(0, 0.36fr) minmax(0, 1fr) auto;
}
.message-trace {
grid-column: 1;
@@ -2765,3 +2139,184 @@ tbody tr:last-child td {
padding: 0 8px;
}
}
.device-pod-status,
.device-pod-workspace {
min-width: 0;
min-height: 0;
display: grid;
gap: 10px;
padding: 12px;
overflow: hidden;
}
.device-pod-status {
grid-template-rows: auto auto minmax(0, 1fr);
}
.device-pod-workspace {
grid-template-rows: auto minmax(0, 1fr) auto;
}
.device-pod-selector-row {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: center;
color: var(--muted);
font-size: 11px;
font-weight: 760;
}
.device-pod-selector-row select {
min-width: 0;
height: 30px;
padding: 0 8px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
font: 11px/1.2 var(--mono);
}
.device-pod-summary,
.device-pod-interfaces {
min-width: 0;
display: grid;
gap: 8px;
}
.summary-tile {
min-width: 0;
display: grid;
gap: 6px;
padding: 10px;
border: 1px solid var(--line);
background: var(--surface-2);
cursor: pointer;
}
.summary-tile:hover,
.summary-tile:focus-visible {
border-color: var(--accent);
outline: 0;
}
.summary-title {
color: var(--accent-2);
font: 760 10px/1.2 var(--mono);
text-transform: uppercase;
}
.summary-value {
color: var(--text);
font-size: 13px;
font-weight: 800;
}
.summary-detail {
color: var(--muted);
font-size: 11px;
overflow-wrap: anywhere;
}
.device-event-panel {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border: 1px solid var(--line);
background: #101310;
}
.event-toolbar {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 8px;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 11px;
font-weight: 760;
}
.event-toolbar button {
min-height: 26px;
padding: 0 8px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--text);
font: 10px/1.2 var(--mono);
cursor: pointer;
}
.event-toolbar button:hover,
.event-toolbar button:focus-visible {
border-color: var(--accent);
outline: 0;
}
.device-event-scroll {
min-width: 0;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: 9px;
}
.device-event-scroll pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #d8ded8;
font: 11px/1.45 var(--mono);
}
.device-detail-dialog {
width: min(760px, calc(100vw - 28px));
max-height: min(720px, calc(100dvh - 28px));
padding: 0;
border: 1px solid var(--line-strong);
background: var(--surface);
color: var(--text);
}
.device-detail-dialog::backdrop {
background: rgba(0, 0, 0, 0.62);
}
.device-detail-shell {
min-width: 0;
max-height: inherit;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
}
.device-detail-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px;
border-bottom: 1px solid var(--line);
}
.device-detail-head h2 {
min-width: 0;
font-size: 16px;
overflow-wrap: anywhere;
}
.device-detail-body {
min-width: 0;
min-height: 0;
margin: 0;
padding: 12px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #d8ded8;
font: 11px/1.45 var(--mono);
}
-126
View File
@@ -1,126 +0,0 @@
const M3_TRUSTED_ROUTE = Object.freeze({
fromResourceId: "res_boxsimu_1",
fromPort: "DO1",
patchPanelServiceId: "hwlab-patch-panel",
toResourceId: "res_boxsimu_2",
toPort: "DI1"
});
const LIVE_M3_ID_FIELDS = Object.freeze(["operationId", "traceId", "auditId", "evidenceId"]);
const LIVE_M3_PASS_STATUSES = Object.freeze(["pass", "passed", "verified", "succeeded", "trusted", "completed"]);
const NON_LIVE_ID_VALUES = Object.freeze(["", "n/a", "none", "null", "undefined", "not_observed", "not-observed"]);
export function wiringPresentationFromSummary(summary = null, { liveM3Evidence = null } = {}) {
const m3Status = summary?.contractVersion === "m3-status-v1" ? summary : null;
const evidence = m3Status ? trustedM3StatusEvidenceFrom(m3Status) : liveM3Evidence;
const m3Live = hasTrustedM3LiveEvidence(evidence);
const connectionActive = m3Status?.patchPanel?.connectionActive === true;
const blockedTrust = m3Status?.trust?.blocker;
const durableBlocked = m3Status && (m3Status.trust?.durableStatus !== "green" || blockedTrust != null);
const traceEvidence = m3Live
? [evidence.operationId, evidence.traceId, evidence.auditId, evidence.evidenceId].join(" / ")
: m3Status
? [m3Status.trust?.operationId, m3Status.trust?.traceId, m3Status.trust?.auditId, m3Status.trust?.evidenceId].filter(Boolean).join(" / ") || `durable blocked: ${blockedTrust ?? "unverified"}`
: "等待可信记录";
const stateLabel = m3Live ? "实况已验证" : connectionActive && durableBlocked ? "接线实况 / 持久化受阻" : "待可信记录";
const sourceLabel = m3Live ? "/v1/m3/status 聚合 / 实况可信记录" : m3Status ? "/v1/m3/status 聚合" : "来源拓扑";
return {
m3Live,
liveM3Evidence: evidence,
connectionActive,
stateLabel,
sourceLabel,
traceEvidence,
rowSourceKind: m3Live ? "DEV-LIVE" : "SOURCE",
badgeTone: m3Live ? "dev-live" : "blocked"
};
}
export function trustedM3StatusEvidenceFrom(status) {
if (status?.contractVersion !== "m3-status-v1") return null;
const trust = status.trust ?? {};
const sourceKind = String(status.sourceKind ?? "").toUpperCase();
if (
status.status !== "live" ||
sourceKind !== "DEV-LIVE" ||
status.patchPanel?.connectionActive !== true ||
status.patchPanel?.serviceId !== M3_TRUSTED_ROUTE.patchPanelServiceId ||
trust.durableStatus !== "green" ||
trust.blocker != null ||
!LIVE_M3_ID_FIELDS.every((field) => isLiveIdentifier(trust[field]))
) {
return null;
}
return {
status: "verified",
sourceKind: "DEV-LIVE",
live: true,
operationId: trust.operationId,
traceId: trust.traceId,
auditId: trust.auditId,
evidenceId: trust.evidenceId,
patchPanelLiveReport: {
serviceId: M3_TRUSTED_ROUTE.patchPanelServiceId,
sourceKind: "DEV-LIVE",
live: true,
reportId: trust.evidenceId,
activeConnections: [
{
fromResourceId: M3_TRUSTED_ROUTE.fromResourceId,
fromPort: M3_TRUSTED_ROUTE.fromPort,
toResourceId: M3_TRUSTED_ROUTE.toResourceId,
toPort: M3_TRUSTED_ROUTE.toPort,
patchPanelServiceId: M3_TRUSTED_ROUTE.patchPanelServiceId
}
]
}
};
}
function hasTrustedM3LiveEvidence(candidate) {
if (!candidate || typeof candidate !== "object") return false;
if (candidate.dryRun === true || candidate.fixture === true || candidate.sourceKind === "SOURCE" || candidate.sourceKind === "DRY-RUN") return false;
if (!hasLiveSourceKind(candidate)) return false;
if (!LIVE_M3_PASS_STATUSES.includes(String(candidate.status ?? candidate.outcome ?? "verified").toLowerCase())) return false;
if (!LIVE_M3_ID_FIELDS.every((field) => isLiveIdentifier(candidate[field] ?? candidate.operation?.[field] ?? candidate.identifiers?.[field]))) return false;
const patchPanelLiveReport = candidate.patchPanelLiveReport ?? candidate.patchPanelReport;
return hasTrustedPatchPanelLiveReport(patchPanelLiveReport);
}
function hasTrustedPatchPanelLiveReport(report) {
if (!report || typeof report !== "object") return false;
if (report.dryRun === true || report.fixture === true || report.sourceKind === "SOURCE" || report.sourceKind === "DRY-RUN") return false;
if (!hasLiveSourceKind(report)) return false;
if (report.serviceId !== M3_TRUSTED_ROUTE.patchPanelServiceId) return false;
if (!isLiveIdentifier(report.reportId ?? report.patchPanelStatusId ?? report.statusId ?? report.evidenceId)) return false;
return (report.activeConnections ?? report.connections ?? []).some((link) => trustedM3Link([link]));
}
function trustedM3Link(connections) {
return (connections ?? []).find(
(link) =>
String(link.fromResourceId ?? "") === M3_TRUSTED_ROUTE.fromResourceId &&
normalizePort(link.fromPort) === M3_TRUSTED_ROUTE.fromPort &&
String(link.toResourceId ?? "") === M3_TRUSTED_ROUTE.toResourceId &&
normalizePort(link.toPort) === M3_TRUSTED_ROUTE.toPort &&
connectionPatchPanelServiceId(link) === M3_TRUSTED_ROUTE.patchPanelServiceId
);
}
function connectionPatchPanelServiceId(link) {
return link.patchPanelServiceId ?? link.patchPanel?.serviceId ?? link.serviceId ?? M3_TRUSTED_ROUTE.patchPanelServiceId;
}
function hasLiveSourceKind(value) {
return value.live === true || value.sourceKind === "DEV-LIVE" || value.evidenceLevel === "DEV-LIVE";
}
function isLiveIdentifier(value) {
const text = String(value ?? "").trim();
return text !== "" && !NON_LIVE_ID_VALUES.includes(text.toLowerCase());
}
function normalizePort(port) {
return String(port ?? "").trim().toUpperCase();
}
@@ -1,81 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
trustedM3StatusEvidenceFrom,
wiringPresentationFromSummary
} from "./wiring-status.mjs";
const trustedGreenStatus = Object.freeze({
contractVersion: "m3-status-v1",
status: "live",
sourceKind: "DEV-LIVE",
patchPanel: {
serviceId: "hwlab-patch-panel",
connectionActive: true
},
trust: {
durableStatus: "green",
blocker: null,
operationId: "op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f",
traceId: "trc_hotfix_307_verify_1779527747043_read_false",
auditId: "aud_m3_di_read_d5afd84b-3a9d-475f-894f-dff8ea5f49ff_succeeded",
evidenceId: "evd_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f_succeeded"
}
});
test("m3-status-v1 trusted green renders verified wiring and preserves operation trace audit evidence IDs", () => {
const presentation = wiringPresentationFromSummary(trustedGreenStatus);
assert.equal(presentation.m3Live, true);
assert.equal(presentation.stateLabel, "实况已验证");
assert.equal(presentation.sourceLabel, "/v1/m3/status 聚合 / 实况可信记录");
assert.equal(presentation.rowSourceKind, "DEV-LIVE");
assert.equal(presentation.badgeTone, "dev-live");
assert.doesNotMatch(presentation.stateLabel, /持久化受阻/u);
assert.match(presentation.traceEvidence, /op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f/u);
assert.match(presentation.traceEvidence, /trc_hotfix_307_verify_1779527747043_read_false/u);
assert.match(presentation.traceEvidence, /aud_m3_di_read_d5afd84b-3a9d-475f-894f-dff8ea5f49ff_succeeded/u);
assert.match(presentation.traceEvidence, /evd_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f_succeeded/u);
});
test("m3-status-v1 only reports persistence blocked when durable is not green or blocker exists", () => {
for (const trustPatch of [
{ durableStatus: "blocked", blocker: "runtime_durable_not_green" },
{ durableStatus: "green", blocker: "runtime_durable_not_green" }
]) {
const presentation = wiringPresentationFromSummary({
...trustedGreenStatus,
trust: {
...trustedGreenStatus.trust,
...trustPatch
}
});
assert.equal(presentation.m3Live, false);
assert.equal(presentation.stateLabel, "接线实况 / 持久化受阻");
assert.equal(presentation.sourceLabel, "/v1/m3/status 聚合");
assert.equal(presentation.rowSourceKind, "SOURCE");
assert.equal(presentation.badgeTone, "blocked");
assert.match(presentation.traceEvidence, /op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f/u);
}
});
test("SOURCE and DRY-RUN status inputs cannot be promoted to DEV-LIVE wiring", () => {
for (const sourceKind of ["SOURCE", "DRY-RUN"]) {
const evidence = trustedM3StatusEvidenceFrom({
...trustedGreenStatus,
sourceKind
});
const presentation = wiringPresentationFromSummary({
...trustedGreenStatus,
sourceKind
});
assert.equal(evidence, null);
assert.equal(presentation.m3Live, false);
assert.equal(presentation.rowSourceKind, "SOURCE");
assert.equal(presentation.badgeTone, "blocked");
assert.equal(presentation.stateLabel, "待可信记录");
}
});
@@ -1,589 +0,0 @@
const SOURCE_KIND_ORDER = Object.freeze(["SOURCE", "DRY-RUN", "DEV-LIVE", "BLOCKED"]);
export const M3_TRUSTED_CHAIN = Object.freeze({
fromResourceId: "res_boxsimu_1",
fromPort: "DO1",
patchPanelServiceId: "hwlab-patch-panel",
toResourceId: "res_boxsimu_2",
toPort: "DI1"
});
export const hardwareEvidencePanelSourceLegend = Object.freeze([
{
sourceKind: "SOURCE",
label: "SOURCE",
detail: "Static contract, topology, or checked-in source material."
},
{
sourceKind: "DRY-RUN",
label: "DRY-RUN",
detail: "Fixture or local validation that never touched the live hardware path."
},
{
sourceKind: "DEV-LIVE",
label: "DEV-LIVE",
detail: "Trusted DEV observation from the M3 closed loop."
},
{
sourceKind: "BLOCKED",
label: "BLOCKED",
detail: "Cannot promote to trusted evidence until DEV-LIVE M3 exists."
}
]);
export const defaultHardwareEvidencePanelData = Object.freeze({
title: "Hardware Evidence",
subtitle: "Right-side workbench panel for the M3 trusted loop",
sourceKind: "SOURCE",
observedAt: null,
statuses: [
{
id: "res_boxsimu_1",
label: "BOX-SIMU 1",
kind: "BOX-SIMU",
role: "source DO1",
status: "degraded",
sourceKind: "BLOCKED",
detail: "No DEV-LIVE DO1 transition evidence is attached yet."
},
{
id: "gateway_simu_1",
label: "Gateway-SIMU",
kind: "Gateway-SIMU",
role: "read-only route",
status: "degraded",
sourceKind: "SOURCE",
detail: "Gateway shape is visible, but the M3 live loop is not proven."
},
{
id: "hwlab-patch-panel",
label: "Patch Panel",
kind: "Patch Panel",
role: "wiring authority",
status: "blocked",
sourceKind: "BLOCKED",
detail: "Waiting for a DEV-LIVE active link through hwlab-patch-panel."
},
{
id: "res_boxsimu_2",
label: "BOX-SIMU 2",
kind: "BOX-SIMU",
role: "target DI1",
status: "degraded",
sourceKind: "BLOCKED",
detail: "No DEV-LIVE DI1 observation is attached yet."
}
],
chain: {
...M3_TRUSTED_CHAIN,
status: "blocked",
sourceKind: "BLOCKED",
detail: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 is not DEV-LIVE proven."
},
records: {
operations: [
{
id: "op_m3_waiting_dev_live",
status: "blocked",
sourceKind: "BLOCKED",
summary: "Readonly placeholder; no hardware operation is issued from this panel."
}
],
audit: [
{
id: "audit_m3_panel_contract",
status: "recorded",
sourceKind: "SOURCE",
summary: "Workbench panel contract requires read-only evidence before trust promotion."
}
],
evidence: [
{
id: "evi_m3_dry_run_fixture",
status: "dry-run",
sourceKind: "DRY-RUN",
summary: "Local smoke proves shape only; it is not DEV-LIVE trusted evidence."
}
]
},
controls: [
{
id: "m3-do1-toggle",
label: "DO1 trigger",
disabled: true,
reason: "not wired / readonly / waiting for DEV-LIVE M3 loop"
},
{
id: "patch-panel-apply",
label: "Apply wiring",
disabled: true,
reason: "not wired / readonly / waiting for DEV-LIVE M3 loop"
}
]
});
export function createHardwareEvidencePanelModel(data = {}) {
const statuses = normalizeStatuses(data.statuses ?? defaultHardwareEvidencePanelData.statuses);
const records = normalizeRecords(data.records ?? defaultHardwareEvidencePanelData.records);
const controls = normalizeControls(data.controls ?? defaultHardwareEvidencePanelData.controls);
const chain = normalizeChain(data.chain ?? defaultHardwareEvidencePanelData.chain);
const runtimeDurability = normalizeRuntimeDurability(data.runtime ?? data.runtimeSummary);
const hasTrustedDevLiveEvidence = records.evidence.some(
(record) =>
record.sourceKind === "DEV-LIVE" &&
["pass", "passed", "verified", "succeeded", "trusted"].includes(record.status)
);
const chainMatches = isRequiredM3Chain(chain);
const trustStatus = runtimeDurability.ready && hasTrustedDevLiveEvidence && chainMatches ? "verified" : "blocked";
const trustTone = trustStatus === "verified" ? "trusted" : "degraded";
const sourceKind = trustStatus === "verified" ? "DEV-LIVE" : "BLOCKED";
const blockedReason = runtimeDurability.ready
? "Current data lacks real DEV-LIVE evidence; the workbench must render BLOCKED/degraded, never green."
: runtimeDurability.reason;
const guardedChain = {
...chain,
status: trustStatus === "verified" ? normalizeStatus(chain.status, "verified") : "blocked",
sourceKind,
detail:
trustStatus === "verified"
? chain.detail
: `BLOCKED: ${blockedReason}`
};
return {
title: data.title ?? defaultHardwareEvidencePanelData.title,
subtitle: data.subtitle ?? defaultHardwareEvidencePanelData.subtitle,
observedAt: data.observedAt ?? defaultHardwareEvidencePanelData.observedAt,
sourceKind,
sourceLegend: hardwareEvidencePanelSourceLegend,
trustedLoop: {
milestone: "M3",
status: trustStatus,
tone: trustTone,
sourceKind,
runtimeDurability,
reason:
trustStatus === "verified"
? "DEV-LIVE evidence is attached to the required M3 chain."
: blockedReason
},
statuses: statuses.map((status) => ({
...status,
status: trustStatus === "verified" || status.status === "blocked" ? status.status : "degraded"
})),
chain: guardedChain,
records,
controls: controls.map((control) => ({
...control,
disabled: true,
reason: control.reason || "not wired / readonly / waiting for DEV-LIVE M3 loop"
}))
};
}
export function renderHardwareEvidencePanel(container, data = {}) {
if (!container || typeof container.replaceChildren !== "function") {
throw new TypeError("renderHardwareEvidencePanel requires a DOM container with replaceChildren().");
}
const model = createHardwareEvidencePanelModel(data);
const doc = container.ownerDocument ?? globalThis.document;
const root = element(doc, "aside", "hwlab-workbench-hardware-panel");
root.dataset.status = model.trustedLoop.status;
root.dataset.sourceKind = model.sourceKind;
root.setAttribute("aria-label", "Workbench hardware evidence panel");
root.append(panelStyles(doc));
root.append(renderHeader(doc, model));
root.append(renderStatusSection(doc, model));
root.append(renderChainSection(doc, model.chain));
root.append(renderRecordsSection(doc, model.records));
root.append(renderControlsSection(doc, model.controls));
root.append(renderLegendSection(doc, model.sourceLegend));
container.replaceChildren(root);
return { element: root, model };
}
function normalizeStatuses(statuses) {
return arrayFrom(statuses).map((status) => ({
id: stringOr(status.id, "unknown-resource"),
label: stringOr(status.label, status.id ?? "Unknown resource"),
kind: stringOr(status.kind, "resource"),
role: stringOr(status.role, "status"),
status: normalizeStatus(status.status, "degraded"),
sourceKind: normalizeSourceKind(status.sourceKind),
detail: stringOr(status.detail, "No detail provided.")
}));
}
function normalizeRecords(records) {
return {
operations: normalizeRecordList(records.operations),
audit: normalizeRecordList(records.audit),
evidence: normalizeRecordList(records.evidence)
};
}
function normalizeRuntimeDurability(runtime) {
if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) {
return {
ready: false,
adapter: "memory",
durable: false,
liveRuntimeEvidence: false,
reason: "runtime.durable=false; memory/non-durable runtime cannot render DEV-LIVE or durable evidence."
};
}
const ready =
runtime.durable === true &&
runtime.ready !== false &&
runtime.liveRuntimeEvidence === true &&
!["blocked", "degraded", "failed"].includes(String(runtime.status ?? "").toLowerCase());
return {
ready,
adapter: stringOr(runtime.adapter, "unknown"),
durable: runtime.durable === true,
durableRequested: runtime.durableRequested === true,
liveRuntimeEvidence: runtime.liveRuntimeEvidence === true,
reason: ready
? "Durable runtime readiness is proven."
: `runtime durable adapter is not ready (adapter=${stringOr(runtime.adapter, "unknown")}, durable=${runtime.durable === true}); trusted records stay BLOCKED.`
};
}
function normalizeRecordList(records) {
return arrayFrom(records).map((record) => ({
id: stringOr(record.id ?? record.operationId ?? record.auditId ?? record.evidenceId, "record"),
status: normalizeStatus(record.status ?? record.outcome, "recorded"),
sourceKind: normalizeSourceKind(record.sourceKind),
summary: stringOr(record.summary ?? record.uri ?? record.kind, "No summary provided.")
}));
}
function normalizeControls(controls) {
return arrayFrom(controls).map((control) => ({
id: stringOr(control.id, "readonly-control"),
label: stringOr(control.label, "Readonly control"),
disabled: true,
reason: stringOr(control.reason, "not wired / readonly / waiting for DEV-LIVE M3 loop")
}));
}
function normalizeChain(chain) {
return {
fromResourceId: stringOr(chain.fromResourceId, M3_TRUSTED_CHAIN.fromResourceId),
fromPort: stringOr(chain.fromPort, M3_TRUSTED_CHAIN.fromPort),
patchPanelServiceId: stringOr(chain.patchPanelServiceId, M3_TRUSTED_CHAIN.patchPanelServiceId),
toResourceId: stringOr(chain.toResourceId, M3_TRUSTED_CHAIN.toResourceId),
toPort: stringOr(chain.toPort, M3_TRUSTED_CHAIN.toPort),
status: normalizeStatus(chain.status, "blocked"),
sourceKind: normalizeSourceKind(chain.sourceKind),
detail: stringOr(chain.detail, "Waiting for M3 evidence.")
};
}
function isRequiredM3Chain(chain) {
return (
chain.fromResourceId === M3_TRUSTED_CHAIN.fromResourceId &&
chain.fromPort === M3_TRUSTED_CHAIN.fromPort &&
chain.patchPanelServiceId === M3_TRUSTED_CHAIN.patchPanelServiceId &&
chain.toResourceId === M3_TRUSTED_CHAIN.toResourceId &&
chain.toPort === M3_TRUSTED_CHAIN.toPort
);
}
function normalizeStatus(status, fallback) {
const value = String(status ?? fallback).trim().toLowerCase();
return value || fallback;
}
function normalizeSourceKind(sourceKind) {
const value = String(sourceKind ?? "SOURCE").trim().toUpperCase();
return SOURCE_KIND_ORDER.includes(value) ? value : "SOURCE";
}
function arrayFrom(value) {
return Array.isArray(value) ? value : [];
}
function stringOr(value, fallback) {
const text = value === undefined || value === null ? "" : String(value).trim();
return text || fallback;
}
function renderHeader(doc, model) {
const header = element(doc, "header", "hwlab-panel-header");
const titleWrap = element(doc, "div", "hwlab-panel-title-wrap");
titleWrap.append(textElement(doc, "p", "hwlab-panel-kicker", "M3 trusted loop"));
titleWrap.append(textElement(doc, "h2", "hwlab-panel-title", model.title));
titleWrap.append(textElement(doc, "p", "hwlab-panel-subtitle", model.subtitle));
header.append(titleWrap);
header.append(badge(doc, model.sourceKind, model.trustedLoop.tone));
header.append(textElement(doc, "p", `hwlab-panel-trust tone-${toneClass(model.trustedLoop.tone)}`, model.trustedLoop.reason));
return header;
}
function renderStatusSection(doc, model) {
const section = panelSection(doc, "Hardware status", "BOX-SIMU / Gateway-SIMU / Patch Panel");
const grid = element(doc, "div", "hwlab-status-grid");
for (const item of model.statuses) {
const card = element(doc, "article", `hwlab-status-card tone-${toneClass(item.status)}`);
card.dataset.sourceKind = item.sourceKind;
const head = element(doc, "div", "hwlab-status-card-head");
head.append(textElement(doc, "strong", "hwlab-status-label", item.label));
head.append(badge(doc, item.sourceKind, item.sourceKind));
card.append(head);
card.append(textElement(doc, "span", "hwlab-status-kind", `${item.kind} / ${item.role}`));
card.append(textElement(doc, "span", `hwlab-status-value tone-${toneClass(item.status)}`, item.status.toUpperCase()));
card.append(textElement(doc, "p", "hwlab-status-detail", item.detail));
grid.append(card);
}
section.append(grid);
return section;
}
function renderChainSection(doc, chain) {
const section = panelSection(doc, "M3 chain", "Required route");
const chainLine = element(doc, "div", `hwlab-chain-line tone-${toneClass(chain.status)}`);
chainLine.append(chainNode(doc, `${chain.fromResourceId}:${chain.fromPort}`, "BOX-SIMU DO1"));
chainLine.append(textElement(doc, "span", "hwlab-chain-arrow", "->"));
chainLine.append(chainNode(doc, chain.patchPanelServiceId, "Patch Panel"));
chainLine.append(textElement(doc, "span", "hwlab-chain-arrow", "->"));
chainLine.append(chainNode(doc, `${chain.toResourceId}:${chain.toPort}`, "BOX-SIMU DI1"));
section.append(chainLine);
const detail = element(doc, "div", "hwlab-chain-detail");
detail.append(badge(doc, chain.sourceKind, chain.status));
detail.append(textElement(doc, "p", "hwlab-chain-copy", chain.detail));
section.append(detail);
return section;
}
function renderRecordsSection(doc, records) {
const section = panelSection(doc, "Records", "operation / audit / evidence summary");
const columns = element(doc, "div", "hwlab-record-columns");
for (const [label, items] of [
["operation", records.operations],
["audit", records.audit],
["evidence", records.evidence]
]) {
const column = element(doc, "article", "hwlab-record-column");
column.append(textElement(doc, "h4", "hwlab-record-heading", `${label} (${items.length})`));
const list = element(doc, "ul", "hwlab-record-list");
for (const item of items) {
const row = element(doc, "li", "hwlab-record-item");
const head = element(doc, "div", "hwlab-record-head");
head.append(textElement(doc, "span", "hwlab-record-id", item.id));
head.append(badge(doc, item.sourceKind, item.sourceKind));
row.append(head);
row.append(textElement(doc, "span", `hwlab-record-status tone-${toneClass(item.status)}`, item.status));
row.append(textElement(doc, "p", "hwlab-record-summary", item.summary));
list.append(row);
}
column.append(list);
columns.append(column);
}
section.append(columns);
return section;
}
function renderControlsSection(doc, controls) {
const section = panelSection(doc, "Controls", "readonly placeholders");
const list = element(doc, "div", "hwlab-control-list");
for (const control of controls) {
const row = element(doc, "div", "hwlab-control-row");
const button = element(doc, "button", "hwlab-control-button");
button.type = "button";
button.disabled = true;
button.dataset.controlId = control.id;
button.textContent = control.label;
row.append(button);
row.append(textElement(doc, "span", "hwlab-control-reason", control.reason));
list.append(row);
}
section.append(list);
return section;
}
function renderLegendSection(doc, legend) {
const section = panelSection(doc, "Source labels", "promotion boundary");
const list = element(doc, "dl", "hwlab-source-legend");
for (const item of legend) {
list.append(textElement(doc, "dt", "hwlab-source-kind", item.label));
list.append(textElement(doc, "dd", "hwlab-source-detail", item.detail));
}
section.append(list);
return section;
}
function panelSection(doc, title, caption) {
const section = element(doc, "section", "hwlab-panel-section");
const head = element(doc, "div", "hwlab-section-head");
head.append(textElement(doc, "h3", "hwlab-section-title", title));
head.append(textElement(doc, "span", "hwlab-section-caption", caption));
section.append(head);
return section;
}
function chainNode(doc, value, label) {
const node = element(doc, "span", "hwlab-chain-node");
node.append(textElement(doc, "strong", "hwlab-chain-value", value));
node.append(textElement(doc, "small", "hwlab-chain-label", label));
return node;
}
function badge(doc, value, tone) {
const item = element(doc, "span", `hwlab-source-badge tone-${toneClass(tone ?? value)}`);
item.textContent = value;
return item;
}
function textElement(doc, tagName, className, text) {
const item = element(doc, tagName, className);
item.textContent = text;
return item;
}
function element(doc, tagName, className) {
const item = doc.createElement(tagName);
if (className) item.className = className;
return item;
}
function toneClass(value) {
return String(value ?? "unknown")
.trim()
.replace(/[^a-z0-9_-]/giu, "-")
.toLowerCase();
}
function panelStyles(doc) {
const style = element(doc, "style");
style.textContent = `
.hwlab-workbench-hardware-panel {
box-sizing: border-box;
display: grid;
gap: 12px;
min-width: 320px;
max-width: 420px;
padding: 14px;
color: #d9e2df;
background: #151a1d;
border-left: 1px solid #364246;
font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.hwlab-workbench-hardware-panel *, .hwlab-workbench-hardware-panel *::before, .hwlab-workbench-hardware-panel *::after {
box-sizing: border-box;
}
.hwlab-panel-header, .hwlab-panel-section {
display: grid;
gap: 10px;
padding: 12px;
background: #1d2427;
border: 1px solid #364246;
border-radius: 6px;
}
.hwlab-panel-title-wrap {
display: grid;
gap: 2px;
}
.hwlab-section-head, .hwlab-status-card-head, .hwlab-record-head, .hwlab-control-row, .hwlab-chain-detail {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.hwlab-panel-title, .hwlab-section-title, .hwlab-record-heading, .hwlab-panel-kicker, .hwlab-panel-subtitle, .hwlab-panel-trust, .hwlab-status-detail, .hwlab-chain-copy, .hwlab-record-summary {
margin: 0;
}
.hwlab-panel-title {
font-size: 18px;
font-weight: 700;
}
.hwlab-panel-kicker, .hwlab-section-caption, .hwlab-status-kind, .hwlab-chain-label, .hwlab-panel-subtitle, .hwlab-source-detail, .hwlab-control-reason {
color: #95a6a3;
}
.hwlab-source-badge {
flex: 0 0 auto;
padding: 2px 7px;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
.hwlab-status-grid, .hwlab-record-columns {
display: grid;
gap: 8px;
}
.hwlab-status-card, .hwlab-record-column, .hwlab-record-item, .hwlab-chain-line {
display: grid;
gap: 6px;
padding: 10px;
background: #101416;
border: 1px solid #2c373a;
border-radius: 6px;
}
.hwlab-status-value, .hwlab-record-status, .hwlab-record-id, .hwlab-chain-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.hwlab-chain-line {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
}
.hwlab-chain-node {
display: grid;
gap: 2px;
min-width: 0;
}
.hwlab-chain-value {
overflow-wrap: anywhere;
}
.hwlab-chain-arrow {
color: #95a6a3;
}
.hwlab-record-list, .hwlab-source-legend {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
}
.hwlab-record-list {
list-style: none;
}
.hwlab-source-legend {
grid-template-columns: auto minmax(0, 1fr);
}
.hwlab-source-kind {
margin: 0;
font-weight: 700;
}
.hwlab-source-detail {
margin: 0;
}
.hwlab-control-list {
display: grid;
gap: 8px;
}
.hwlab-control-button {
min-width: 104px;
padding: 6px 9px;
color: #95a6a3;
background: #222a2d;
border: 1px solid #3a474b;
border-radius: 4px;
cursor: not-allowed;
}
.tone-blocked, .tone-degraded {
color: #ffb86b;
}
.tone-source, .tone-dry-run {
color: #8fc7ff;
}
.tone-trusted, .tone-dev-live, .tone-verified {
color: #74d99f;
}
`;
return style;
}