From 297206b695a0e424ad457d5ee09295193f7c93be Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 01:39:03 +0800 Subject: [PATCH] feat: add fake device pod sidebar service --- cmd/hwlab-device-pod/main.mjs | 47 + deploy/artifact-catalog.dev.json | 46 +- deploy/deploy.json | 22 +- deploy/deploy.schema.json | 1 + deploy/k8s/base/services.yaml | 25 + deploy/k8s/base/workloads.yaml | 72 + deploy/k8s/dev/health-contract.yaml | 3 +- docs/m5-mvp-e2e.md | 2 +- docs/plan/device-pod-sidebar-fake-service.md | 35 + internal/cloud/server.mjs | 69 + internal/device-pod/fake-data.mjs | 313 +++ internal/protocol/index.mjs | 1 + package.json | 5 +- protocol/schemas/common.json | 1 + scripts/dev-cloud-workbench-layout-smoke.mjs | 2 +- scripts/dev-cloud-workbench-smoke.mjs | 3 - scripts/dev-cloud-workbench-smoke.test.mjs | 74 +- scripts/export-web-gate-summary.mjs | 16 - scripts/l6-cli-web-smoke.mjs | 68 +- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 2384 +++-------------- scripts/src/g14-ci-plan-lib.mjs | 1 + web/hwlab-cloud-web/app.mjs | 1611 ++--------- .../code-agent-m3-evidence.mjs | 467 ---- web/hwlab-cloud-web/gate-summary.mjs | 991 ------- web/hwlab-cloud-web/help.md | 48 +- web/hwlab-cloud-web/index.html | 211 +- web/hwlab-cloud-web/live-status.mjs | 200 +- web/hwlab-cloud-web/package.json | 3 +- web/hwlab-cloud-web/scripts/check.mjs | 1543 ++--------- web/hwlab-cloud-web/scripts/dist-contract.mjs | 4 - .../scripts/live-status-contract.test.mjs | 1083 -------- .../scripts/m3-control-panel-guard.mjs | 308 --- .../scripts/m3-readonly-contract.mjs | 148 - .../scripts/sidebar-resize.test.mjs | 18 +- .../workbench-hardware-panel-contract.mjs | 245 -- web/hwlab-cloud-web/styles.css | 811 ++---- web/hwlab-cloud-web/wiring-status.mjs | 126 - web/hwlab-cloud-web/wiring-status.test.mjs | 81 - .../workbench-hardware-panel.mjs | 589 ---- 39 files changed, 1895 insertions(+), 9782 deletions(-) create mode 100644 cmd/hwlab-device-pod/main.mjs create mode 100644 docs/plan/device-pod-sidebar-fake-service.md create mode 100644 internal/device-pod/fake-data.mjs delete mode 100644 scripts/export-web-gate-summary.mjs delete mode 100644 web/hwlab-cloud-web/code-agent-m3-evidence.mjs delete mode 100644 web/hwlab-cloud-web/gate-summary.mjs delete mode 100644 web/hwlab-cloud-web/scripts/live-status-contract.test.mjs delete mode 100644 web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs delete mode 100644 web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs delete mode 100644 web/hwlab-cloud-web/scripts/workbench-hardware-panel-contract.mjs delete mode 100644 web/hwlab-cloud-web/wiring-status.mjs delete mode 100644 web/hwlab-cloud-web/wiring-status.test.mjs delete mode 100644 web/hwlab-cloud-web/workbench-hardware-panel.mjs diff --git a/cmd/hwlab-device-pod/main.mjs b/cmd/hwlab-device-pod/main.mjs new file mode 100644 index 00000000..f0f1510f --- /dev/null +++ b/cmd/hwlab-device-pod/main.mjs @@ -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." + }; +} diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index d7582bfd..1371903f 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -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, diff --git a/deploy/deploy.json b/deploy/deploy.json index 591be5c1..95529979 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -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", diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 75da314f..1d632a7b 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -69,6 +69,7 @@ "hwlab-cloud-web", "hwlab-agent-mgr", "hwlab-agent-worker", + "hwlab-device-pod", "hwlab-gateway", "hwlab-gateway-simu", "hwlab-box-simu", diff --git a/deploy/k8s/base/services.yaml b/deploy/k8s/base/services.yaml index 0374249e..7d70ea76 100644 --- a/deploy/k8s/base/services.yaml +++ b/deploy/k8s/base/services.yaml @@ -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", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 14913651..ffa6fb68 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -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", diff --git a/deploy/k8s/dev/health-contract.yaml b/deploy/k8s/dev/health-contract.yaml index 22d8bc94..35e684df 100644 --- a/deploy/k8s/dev/health-contract.yaml +++ b/deploy/k8s/dev/health-contract.yaml @@ -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" } } diff --git a/docs/m5-mvp-e2e.md b/docs/m5-mvp-e2e.md index 894abb6f..21a3fa12 100644 --- a/docs/m5-mvp-e2e.md +++ b/docs/m5-mvp-e2e.md @@ -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 diff --git a/docs/plan/device-pod-sidebar-fake-service.md b/docs/plan/device-pod-sidebar-fake-service.md new file mode 100644 index 00000000..18121ba3 --- /dev/null +++ b/docs/plan/device-pod-sidebar-fake-service.md @@ -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 proxy;upstream 不可用或未配置时回退到 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 中不存在内部 `
` 展开/收起结构;旧硬件面板 DOM 与旧硬件 API 调用不作为验收目标。 +- Cloud Web runtime/dist 与 L6 Web smoke 不再依赖 `gate-summary.mjs`,也不再断言 gateway-simu、hwlab-box-simu 或 hwlab-patch-panel 前端拓扑。 diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index d26aee91..ac034136 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -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, { diff --git a/internal/device-pod/fake-data.mjs b/internal/device-pod/fake-data.mjs new file mode 100644 index 00000000..6beae8d7 --- /dev/null +++ b/internal/device-pod/fake-data.mjs @@ -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; +} diff --git a/internal/protocol/index.mjs b/internal/protocol/index.mjs index 3c68799c..c81808f3 100644 --- a/internal/protocol/index.mjs +++ b/internal/protocol/index.mjs @@ -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", diff --git a/package.json b/package.json index ea63b119..565f17cc 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/repo-reports-guard.mjs && node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/dev-runtime-hotfix-audit.mjs && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/src/dev-runtime-hotfix-audit.test.mjs", - "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --test cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check tools/hwlab-gateway-shell.mjs && node --check tools/hwlab-gateway-tran.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.test.mjs && node --check scripts/artifact-publish.mjs && node --check scripts/g14-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.test.mjs && node --test scripts/src/dev-runtime-hotfix-audit.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --test cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check tools/hwlab-gateway-shell.mjs && node --check tools/hwlab-gateway-tran.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.test.mjs && node --check scripts/artifact-publish.mjs && node --check scripts/g14-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.test.mjs && node --test scripts/src/dev-runtime-hotfix-audit.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", @@ -23,9 +23,6 @@ "cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp", "cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list", "l6:smoke": "node scripts/l6-cli-web-smoke.mjs", - "web:summary": "node scripts/export-web-gate-summary.mjs", - "web:m3-readonly": "node web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs", - "web:m3-control-panel": "node web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs", "web:check": "node web/hwlab-cloud-web/scripts/check.mjs && node --test web/hwlab-cloud-web/message-markdown.test.mjs web/hwlab-cloud-web/scripts/trace-scroll.test.mjs", "web:build": "node web/hwlab-cloud-web/scripts/build.mjs", "artifact-catalog:preview-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --blocked --no-write", diff --git a/protocol/schemas/common.json b/protocol/schemas/common.json index f171f769..dbd8a77c 100644 --- a/protocol/schemas/common.json +++ b/protocol/schemas/common.json @@ -25,6 +25,7 @@ "hwlab-cloud-web", "hwlab-agent-mgr", "hwlab-agent-worker", + "hwlab-device-pod", "hwlab-gateway", "hwlab-gateway-simu", "hwlab-box-simu", diff --git a/scripts/dev-cloud-workbench-layout-smoke.mjs b/scripts/dev-cloud-workbench-layout-smoke.mjs index 4f201bca..4817eb9e 100644 --- a/scripts/dev-cloud-workbench-layout-smoke.mjs +++ b/scripts/dev-cloud-workbench-layout-smoke.mjs @@ -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。" ] } diff --git a/scripts/dev-cloud-workbench-smoke.mjs b/scripts/dev-cloud-workbench-smoke.mjs index f4b3295f..d30fe35f 100644 --- a/scripts/dev-cloud-workbench-smoke.mjs +++ b/scripts/dev-cloud-workbench-smoke.mjs @@ -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)); diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 8a6abb2e..af6abe6c 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -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); }); diff --git a/scripts/export-web-gate-summary.mjs b/scripts/export-web-gate-summary.mjs deleted file mode 100644 index cead000a..00000000 --- a/scripts/export-web-gate-summary.mjs +++ /dev/null @@ -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`); diff --git a/scripts/l6-cli-web-smoke.mjs b/scripts/l6-cli-web-smoke.mjs index c8938ab9..5079b535 100644 --- a/scripts/l6-cli-web-smoke.mjs +++ b/scripts/l6-cli-web-smoke.mjs @@ -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, "\\$&"); +} diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 61394b33..ae8dbc66 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -5,7 +5,7 @@ import http from "node:http"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { gateSummary } from "../../web/hwlab-cloud-web/gate-summary.mjs"; +import { buildDevicePodRestPayload } from "../../internal/device-pod/fake-data.mjs"; import { runtime } from "../../web/hwlab-cloud-web/runtime.mjs"; import { classifyCodeAgentBrowserJourney, @@ -25,6 +25,7 @@ const helpOwner = "codex_1779444232735_1"; const legacyFailureWindowMs = 4500; const codeAgentLongTimeoutMs = 1800000; const localAgentFixtureDelayMs = 5200; +const sourceFixtureProjectId = "hwlab-device-pod-source-fixture"; const codeAgentE2ePrompts = Object.freeze([ { id: "simple-chinese", @@ -57,24 +58,22 @@ const codeAgentQuickPromptFixtures = Object.freeze([ writable: false }, { - id: "do1-true-di1", - label: "Codex:DO1=true 后读 DI1", - prompt: "交给 Codex 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", - writable: true + id: "device-pod-status", + label: "Codex:Device Pod 状态", + prompt: "交给 Codex 读取当前 Device Pod 的 status、profileHash、chip-id 和 UART1 tail 摘要,不执行下载、复位或写入。", + writable: false }, { - id: "do1-false-di1", - label: "Codex:DO1=false 复核 DI1", - prompt: "交给 Codex 把 res_boxsimu_1 的 DO1 写成 false,并复核 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", - writable: true + id: "device-pod-events", + label: "Codex:事件流", + prompt: "交给 Codex 查看 Device Pod 最近事件流,并说明是否存在 blocker;只读,不连接旧模拟硬件拓扑。", + writable: false } ]); const readOnlyRpcMethods = Object.freeze([ "system.health", - "cloud.adapter.describe", - "audit.event.query", - "evidence.record.query" + "cloud.adapter.describe" ]); const requiredWebAssets = Object.freeze([ @@ -84,25 +83,23 @@ const requiredWebAssets = Object.freeze([ "app.mjs", "code-agent-facts.mjs", "code-agent-status.mjs", - "code-agent-m3-evidence.mjs", + "message-markdown.mjs", "live-status.mjs", "runtime.mjs", - "gate-summary.mjs", - "wiring-status.mjs", - "workbench-hardware-panel.mjs", "help.md", "third_party/marked/marked.esm.js", "third_party/marked/LICENSE" ]); -const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "auth.mjs", "app.mjs", "code-agent-facts.mjs", "code-agent-status.mjs", "live-status.mjs", "wiring-status.mjs"]); +const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "auth.mjs", "app.mjs", "code-agent-facts.mjs", "code-agent-status.mjs", "message-markdown.mjs", "live-status.mjs"]); const chineseWorkbenchLabels = Object.freeze([ "云工作台登录", "HWLAB 云工作台", - "硬件资源", + "Device Pod", + "设备目标看板", "Agent 对话", - "可信记录", + "事件流", "内部复核", "使用说明" ]); @@ -219,74 +216,6 @@ const removedResourceExplorerCopyTerms = Object.freeze([ "编写任务" ]); -const requiredWiringLongTableHeaders = Object.freeze(["res_boxsimu_1", "res_boxsimu_2"]); -const forbiddenLegacyWiringColumns = Object.freeze([ - "源设备", - "源端口", - "接线盘", - "目标设备", - "目标端口", - "状态", - "证据来源", - "轨迹/证据", - "trace", - "evidence", - "trace/evidence" -]); -const requiredWiringSummaryTerms = Object.freeze(["hwlab-patch-panel", "状态", "证据来源", "轨迹/证据"]); - -const requiredTrustedRecordTerms = Object.freeze([ - "Code Agent 对话记录", - "接线/operation", - "audit 记录", - "evidence 证据", - "trace/messageId", - "audit.event.query + SOURCE 回退", - "evidence.record.query + SOURCE 回退", - "conversationId、traceId 和 messageId", - "provider/model/trace/conversation", - "失败原因=", - "只读 audit 缺少 M3 patch-panel live report / operation / trace / evidence 绑定", - "只读 evidence 缺少 M3 patch-panel live report / operation / trace / audit 绑定", - "当前显示 SOURCE 回退,不能冒充 DEV-LIVE" -]); - -const requiredHardwareStatusTerms = Object.freeze([ - "hardware-source-status", - "hardware-status-tabs", - "hardwareKvGroup", - "/v1/m3/status", - "Gateway-SIMU", - "BOX-SIMU-1", - "BOX-SIMU-2", - "Patch Panel", - "Gateway 在线", - "BOX 在线", - "patch-panel", - "runtime durable", - "DISABLED 后续能力", - "SOURCE", - "DEV-LIVE", - "unverified_cloud_api_m3_status" -]); - -const requiredM3ControlUserFlowTerms = Object.freeze([ - "m3-flow-summary", - "m3-action-summary", - "m3-do1-target", - "m3-di1-observed", - "m3-trust-summary", - "m3-trace-summary", - "DO1 -> patch-panel -> DI1", - "查看最近操作/trace", - "选择 DO1 true/false 后点击“写入 DO1”", - "最近 trace 与 operation 可在“可信记录”页签追踪", - "DO1目标=", - "DI1观测=", - "trusted=", - "durable=" -]); - const workbenchMarkers = Object.freeze([ "data-app-shell", "workbench-shell", @@ -296,7 +225,10 @@ const workbenchMarkers = Object.freeze([ "conversation-list", "agent-chat-status", "right-sidebar", - "hardware-list", + "device-pod-sidebar", + "device-pod-summary", + "device-pod-interfaces", + "device-event-text", "command-form" ]); @@ -387,7 +319,6 @@ export async function runDevCloudWorkbenchSmoke(argv = []) { if (args.mode === "auth-fixture") return runDevCloudWorkbenchAuthFixtureSmoke(); if (args.mode === "local-agent-timeout-fixture") return runDevCloudWorkbenchTimeoutFixtureSmoke(); if (args.mode === "dom-only") return runLiveDomOnlySmoke(args); - if (args.mode === "m3-status-fixture") return runM3StatusFixtureSmoke(); if (args.mode === "layout") return runDevCloudWorkbenchLayoutSmoke(args); if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke(); if (args.mode === "session-continuity-fixture") return runDevCloudWorkbenchSessionContinuityFixtureSmoke(); @@ -442,8 +373,6 @@ export function parseSmokeArgs(argv) { args.confirmDevLive = true; } else if (arg === "--dom-only") { args.mode = "dom-only"; - } else if (arg === "--m3-status-fixture") { - args.mode = "m3-status-fixture"; } else if (arg === "--layout") { args.mode = "layout"; } else if (arg === "--build") { @@ -541,32 +470,19 @@ function runStaticSmoke() { evidence: [...helpRouteAliases, "routeFromLocation final fallback is workspace"] }); - addCheck(checks, blockers, "feedback-276-wiring-long-table", wiringTableContract(files.html, files.styles, files.app), "Patch-panel wiring panel is a two-column long table: device headers first, IO mapping rows after, and metadata moved to Chinese summary/detail text.", { + addCheck(checks, blockers, "device-pod-summary-sidebar", devicePodSummarySidebarContract(files), "Right sidebar is a Device Pod summary board: no internal expand/collapse, summary tiles only, and click/keyboard opens the detail dialog.", { blocker: "contract_blocker", - evidence: [ - ...requiredWiringLongTableHeaders, - "DO1 对 DI1", - "data-wiring-layout=\"two-column-long-table\"", - "legacy 8-column table forbidden", - "trace/evidence header forbidden", - "状态/证据/trace kept in summary/detail", - ...requiredWiringSummaryTerms - ] + evidence: ["device-pod-sidebar", "device-pod-summary", "summary-tile", "device-detail-dialog", "no
in right sidebar"] }); - addCheck(checks, blockers, "trusted-record-groups", trustedRecordGroups(files), "Trusted Records panel groups Code Agent, wiring/operation, audit, and evidence summaries with source labels and live-failure fallback.", { - blocker: "observability_blocker", - evidence: requiredTrustedRecordTerms - }); - - addCheck(checks, blockers, "m3-control-user-flow", m3ControlUserFlowContract(files), "Right control panel exposes an operator-readable DO1 -> patch-panel -> DI1 action summary, current action, DO1 target, DI1 observation, trusted/durable state, and recent trace navigation.", { - blocker: "contract_blocker", - evidence: requiredM3ControlUserFlowTerms - }); - - addCheck(checks, blockers, "m3-default-wiring-source", defaultTopologyIsM3Only(), "Default 16666 workbench topology exposes only the M3 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 wiring.", { + addCheck(checks, blockers, "device-pod-event-stream", devicePodEventStreamContract(files), "Device Pod events render as a simple plain-text stream whose updates do not steal the user's scroll position.", { blocker: "runtime_blocker", - evidence: ["res_boxsimu_1:DO1", "hwlab-patch-panel", "res_boxsimu_2:DI1"] + evidence: ["device-event-scroll", "device-event-text", "setDeviceEventFollow", "deviceEventUserActive", "overscroll-behavior: contain"] + }); + + addCheck(checks, blockers, "device-pod-fake-service", devicePodFakeServiceContract(files), "The fake hwlab-device-pod service and Cloud API proxy/fallback expose the Device Pod REST surface.", { + blocker: "runtime_blocker", + evidence: ["hwlab-device-pod", "/v1/device-pods", "/status", "/events", "buildDevicePodRestPayload"] }); addCheck(checks, blockers, "outer-scroll-contract", hasScrollLockContract(files.styles), "Outer page scrolling is locked and internal workbench panes own scrolling.", { @@ -589,18 +505,18 @@ function runStaticSmoke() { "aside#resource-explorer absent", "#explorer-resize absent", "resource tree / quick actions / capability cards absent", - "right side keeps a 560-740px M3 status workspace boundary through --right-width" + "right side keeps a 560-740px Device Pod summary boundary through --right-width" ] }); addCheck(checks, blockers, "api-live-status-attribution", hasApiLiveStatusAttribution(files), "Default workbench status maps raw API health into pass, error, unverified, or read-only with concrete service/API attribution.", { blocker: "observability_blocker", - evidence: ["API 正常", "API 错误", "等待验证", "只读模式", "hwlab-cloud-api /health/live", "/v1/agent/chat", "/v1/m3/io", "raw degraded kept as internalRawStatuses"] + evidence: ["API 正常", "API 错误", "等待验证", "只读模式", "hwlab-cloud-api /health/live", "/v1/agent/chat", "/v1/device-pods", "raw degraded kept as internalRawStatuses"] }); addCheck(checks, blockers, "workbench-live-surface-timeout-contract", hasWorkbenchLiveSurfaceTimeoutContract(files), "Workbench live status uses a dedicated live-surface timeout instead of the 4500ms light API budget.", { blocker: "runtime_blocker", - evidence: ["DEFAULT_LIVE_SURFACE_TIMEOUT_MS=12000", "liveSurfaceFetch", "liveSurfaceRpc", "/v1/m3/status", "system.health"] + evidence: ["DEFAULT_LIVE_SURFACE_TIMEOUT_MS=12000", "liveSurfaceFetch", "liveSurfaceRpc", "/v1/device-pods", "system.health"] }); addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", { @@ -608,7 +524,7 @@ function runStaticSmoke() { evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] }); - addCheck(checks, blockers, "code-agent-quick-prompts-contract", hasCodeAgentQuickPromptContract(files), "Code Agent quick prompts fill the input with Codex examples while hardware-write prompts require explicit send confirmation.", { + addCheck(checks, blockers, "code-agent-quick-prompts-contract", hasCodeAgentQuickPromptContract(files), "Code Agent quick prompts fill the input with Codex examples and Device Pod read-only probes; no quick prompt auto-sends.", { blocker: "safety_blocker", evidence: codeAgentQuickPromptFixtures.flatMap((prompt) => [prompt.id, prompt.label, prompt.prompt]) }); @@ -646,7 +562,7 @@ function runStaticSmoke() { addCheck(checks, blockers, "route-control-stable-dimensions", hasStableRouteControls(files), "Route controls and status tags have stable dimensions, readable wrapping, and usable labels on small screens.", { blocker: "runtime_blocker", - evidence: ["rail-button min-width/min-height", "state-tag wrapping", "side-tab stable labels", "mobile trace column"] + evidence: ["rail-button min-width/min-height", "state-tag wrapping", "summary-tile wrapping", "mobile trace column"] }); addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed full Code Agent replies expose Codex stdio runnerTrace and reject echo/mock/stub, OpenAI fallback, and local shortcut completions.", { @@ -665,32 +581,14 @@ function runStaticSmoke() { ] }); - addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(source), "Workbench source does not expose hardware write APIs or live mutation commands.", { + addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(`${files.html}\n${files.app}\n${files.liveStatus}`), "Workbench frontend source does not expose hardware write APIs or live mutation commands.", { blocker: "safety_blocker", evidence: forbiddenWritePatterns.map(String) }); addCheck(checks, blockers, "source-not-dev-live", sourceEvidenceNotDevLive(source), "SOURCE/LOCAL/DRY-RUN/fixture evidence is not promoted to DEV-LIVE.", { blocker: "observability_blocker", - evidence: ["M3 remains blocked without live loop evidence", "checked-in evidence records remain dry-run"] - }); - - addCheck(checks, blockers, "m3-fixture-counts-not-dev-live", m3FixtureCountsNotDevLive(files.app), "Fixture/source patch-panel wiring plus generic runtime counts cannot render M3 DEV-LIVE.", { - blocker: "observability_blocker", - evidence: [ - "hasTrustedM3LiveEvidence requires patchPanelLiveReport", - "operationId/traceId/auditId/evidenceId required", - "runtime counts are SOURCE-only" - ] - }); - - addCheck(checks, blockers, "m3-rendered-workbench-not-m5-fixture", m3RenderedWorkbenchNotM5Fixture(files.app), "Right workbench hardware status and wiring table render the M3 chain, not M5 fixture rows or undefined DOM-node rows.", { - blocker: "runtime_blocker", - evidence: [ - "renderHardwareStatus renders tabbed Key/Value groups", - "renderWiringList uses M3_TRUSTED_ROUTE", - "M5 activeConnections are not mapped into the workbench wiring table" - ] + evidence: ["SOURCE remains source-level", "Device Pod fake data is marked FAKE", "no DEV-LIVE claim from fixture data"] }); const help = inspectHelpContract(files); @@ -881,8 +779,8 @@ async function runLiveSmoke(args) { codeAgentTimeoutMs: codeAgentLongTimeoutMs, retainedApiFields: ["status", "provider", "model", "backend", "traceId", "hasReply", "error.code", "error.missingEnv", "error.providerStatus"], statement: status === "degraded" - ? "Live smoke records deployed UI usable in degraded/read-only mode only; API health/runtime durability is degraded, so this is not full DEV-LIVE acceptance and must not be used to infer M3/M4/M5 acceptance." - : "Live smoke opens the deployed workbench and sends two controlled Code Agent messages; it does not call hardware write APIs and must not be used to infer M3 DEV-LIVE hardware-loop acceptance." + ? "Live smoke records deployed UI usable in degraded/read-only mode only; API health/runtime durability is degraded, so this is not full DEV-LIVE acceptance and must not be used to infer Device Pod hardware acceptance." + : "Live smoke opens the deployed workbench and sends two controlled Code Agent messages; it does not call Device Pod or hardware write APIs." } }); } @@ -1086,21 +984,21 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { id: "layout-desktop-default", status: desktop.default.pass ? "pass" : "blocked", viewport: desktop.default.viewport, - summary: "Desktop 1366x768 default layout has no resource explorer DOM and keeps M3 controls, Code Agent input, hardware panels, hit targets, overflow, and outer scroll in contract.", + summary: "Desktop 1366x768 default layout has no resource explorer DOM and keeps Code Agent input plus the Device Pod summary board reachable without outer scroll.", observations: desktop.default }, { id: "layout-narrow-desktop-default", status: narrowDesktop.default.pass ? "pass" : "blocked", viewport: narrowDesktop.default.viewport, - summary: "Narrow desktop 1024x768 layout keeps the right hardware area below the workbench without covered controls or outer scroll.", + summary: "Narrow desktop 1024x768 layout keeps the right Device Pod board below the workbench without covered controls or outer scroll.", observations: narrowDesktop.default }, { id: "layout-mobile-default", status: mobile.default.pass ? "pass" : "blocked", viewport: mobile.default.viewport, - summary: "Mobile 390x844 default layout has no resource drawer and preserves Agent input plus M3 tabs/controls.", + summary: "Mobile 390x844 default layout has no resource drawer and preserves Agent input plus Device Pod summaries/events.", observations: mobile.default }, { @@ -1118,28 +1016,28 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { { id: "layout-left-sidebar-collapse", status: Object.values(viewportResults).every((result) => result.leftCollapse?.pass) ? "pass" : "blocked", - summary: "#278 left activity rail collapses and expands on desktop and mobile, persists in localStorage, and gives Code Agent plus M3 panels the reclaimed space without restoring the removed resource explorer.", + summary: "#278 left activity rail collapses and expands on desktop and mobile, persists in localStorage, and gives Code Agent plus Device Pod panels the reclaimed space without restoring the removed resource explorer.", observations: summarizeLeftSidebarCollapseCoverage(viewportResults) }, { id: "layout-right-sidebar-resize-desktop", status: desktop.rightResize.pass ? "pass" : "blocked", viewport: desktop.rightResize.viewport ?? desktop.default.viewport, - summary: "Desktop right hardware sidebar resize supports pointer drag, keyboard bounds, localStorage v1 restore, and keeps Code Agent plus M3 controls reachable.", + summary: "Desktop right Device Pod sidebar resize supports pointer drag, keyboard bounds, localStorage v1 restore, and keeps Code Agent plus summary/event controls reachable.", observations: desktop.rightResize }, { id: "layout-right-sidebar-resize-narrow-desktop", status: narrowDesktop.rightResize.pass ? "pass" : "blocked", viewport: narrowDesktop.rightResize.viewport ?? narrowDesktop.default.viewport, - summary: "Narrow desktop disables the right-sidebar drag handle while the stacked M3 hardware area remains reachable without outer page scroll.", + summary: "Narrow desktop disables the right-sidebar drag handle while the stacked Device Pod board remains reachable without outer page scroll.", observations: narrowDesktop.rightResize }, { id: "layout-right-sidebar-resize-mobile", status: mobile.rightResize.pass ? "pass" : "blocked", viewport: mobile.rightResize.viewport ?? mobile.default.viewport, - summary: "Mobile disables the right-sidebar drag handle so it cannot cover M3 tabs, Code Agent input, or the hardware status area.", + summary: "Mobile disables the right-sidebar drag handle so it cannot cover Device Pod summaries/events or the Code Agent input.", observations: mobile.rightResize }, { @@ -1164,22 +1062,22 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { observations: mobile.gate.currentRoute }, { - id: "layout-issue-287-future-hardware-status-tabs", - status: allViewportCoverage(viewportResults, "issue287HardwareTabsReady") ? "pass" : "skip", - summary: "#287 hardware status tabs are current layout contract: desktop and 390x844 mobile viewports must expose center-hit tabs without horizontal overflow.", - observations: summarizeCoverageGap(viewportResults, "issue287") + id: "layout-device-pod-summary-sidebar", + status: allViewportCoverage(viewportResults, "devicePodSummaryBoardOk") ? "pass" : "blocked", + summary: "Device Pod right sidebar shows summary tiles only, with no internal expand/collapse or legacy hardware selectors.", + observations: summarizeCoverageGap(viewportResults, "devicePodSummary") }, { - id: "layout-hardware-tabs-center-hit", - status: allViewportCoverage(viewportResults, "hardwareTabsCenterHit") ? "pass" : "blocked", - summary: "Hardware status tabs are center-hit reachable in desktop, narrow desktop, and 390x844 mobile layout checks.", - observations: summarizeCoverageGap(viewportResults, "hardwareTabs") + id: "layout-device-pod-event-stream", + status: allViewportCoverage(viewportResults, "devicePodEventStreamOk") ? "pass" : "blocked", + summary: "Device Pod event stream is a plain-text scroll window independent from the user's page scroll.", + observations: summarizeCoverageGap(viewportResults, "devicePodEvents") }, { - id: "layout-wiring-readable-two-column", - status: allViewportCoverage(viewportResults, "wiringReadableTwoColumn") ? "pass" : "blocked", - summary: "Patch-panel wiring stays a two-column long table with DO1/DI1 text readable, non-overlapping, and horizontally contained.", - observations: summarizeCoverageGap(viewportResults, "wiring") + id: "layout-device-pod-detail-dialog", + status: allViewportCoverage(viewportResults, "devicePodDetailDialogOk") ? "pass" : "blocked", + summary: "Clicking a Device Pod summary tile opens details in the modal dialog and returns to the unchanged summary board when closed.", + observations: summarizeCoverageGap(viewportResults, "devicePodDialog") }, { id: "layout-issue-288-future-single-table-gate", @@ -1244,9 +1142,9 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { evidenceLevel: useLiveUrl ? blockers.length === 0 ? "DEV-LIVE-LAYOUT" : "BLOCKED" : "SOURCE", devLive: false, summary: useLiveUrl - ? "Live browser layout smoke verifies workbench layout, hit targets, overflow, /gate usability, and the two-column wiring panel only; it is not M3 hardware acceptance." - : "Static local browser layout smoke verifies workbench layout, hit targets, overflow, /gate usability, and the two-column wiring panel only; deployment still requires DEV live verification.", - refs: ["pikasTech/HWLAB#273", "pikasTech/HWLAB#276", "pikasTech/HWLAB#278", "pikasTech/HWLAB#287", "pikasTech/HWLAB#288", "pikasTech/HWLAB#352", "pikasTech/HWLAB#437", "pikasTech/HWLAB#239", "pikasTech/HWLAB#99", "pikasTech/HWLAB#227"], + ? "Live browser layout smoke verifies workbench layout, hit targets, overflow, /gate usability, and the Device Pod summary board only." + : "Static local browser layout smoke verifies workbench layout, hit targets, overflow, /gate usability, and the Device Pod summary board only; deployment still requires DEV live verification.", + refs: ["pikasTech/HWLAB#273", "pikasTech/HWLAB#278", "pikasTech/HWLAB#288", "pikasTech/HWLAB#352", "pikasTech/HWLAB#437", "pikasTech/HWLAB#239", "pikasTech/HWLAB#99"], viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })), checks, blockers, @@ -1390,7 +1288,7 @@ function layoutSafety() { codeAgentPostSent: false, hardwareWriteApis: false, hitTestMethod: "document.elementsFromPoint plus normal Playwright clicks without forced clicks", - statement: "Layout smoke opens the workbench, uses real Playwright clicks for sidebar controls, and samples hit targets with elementsFromPoint. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance; it must not claim M3 DEV-LIVE hardware-loop acceptance." + statement: "Layout smoke opens the workbench, uses real Playwright clicks for sidebar controls, and samples hit targets with elementsFromPoint. It does not send Code Agent chat, call Device Pod or hardware write APIs, mutate DEV, or claim hardware acceptance." }; } @@ -1477,7 +1375,7 @@ function layoutReportLifecycle(useLiveUrl, status) { activeBrowserEndpoint: runtime.endpoints.frontend, deprecatedEndpoint: null, summary: useLiveUrl - ? `DEV live layout smoke ${status}; this is UI layout/clickability evidence only and not M3 hardware-loop acceptance.` + ? `DEV live layout smoke ${status}; this is UI layout/clickability evidence only and not hardware acceptance.` : `Repository source/static layout smoke ${status}; this runs without public DEV dependency and cannot be used as DEV-LIVE evidence.` }; } @@ -1490,7 +1388,7 @@ function layoutSourceContract() { "docs/reference/cloud-workbench.md", "docs/reference/code-agent-chat-readiness.md" ], - summary: "Cloud Workbench layout smoke protects the #99 workbench route, #227 visible M3 control area, #278 left navigation collapse/expand, #352 resource-explorer removal, Code Agent input, right hardware/trusted-record containers, /gate current route, and outer-scroll lock without claiming hardware acceptance." + summary: "Cloud Workbench layout smoke protects the #99 workbench route, #278 left navigation collapse/expand, #352 resource-explorer removal, Code Agent input, right Device Pod summary board, /gate current route, and outer-scroll lock without claiming hardware acceptance." }; } @@ -1539,7 +1437,7 @@ function layoutDevPreconditions(useLiveUrl, status) { "local-build mode must rebuild and inspect Cloud Web dist before publish/apply.", "DEV live mode must target http://74.48.78.17:16666/ after a controlled DEV deployment or confirmed live revision.", "The report must include viewport, selector, failureType, and artifact/report paths for failures.", - "Layout pass is not M3 DEV-LIVE trusted closure and does not replace #227 DO/DI functional evidence." + "Layout pass is not hardware acceptance and does not replace Device Pod runtime evidence." ], commands: ["npm run web:layout:live"], summary: useLiveUrl @@ -1739,13 +1637,17 @@ function readStaticFiles() { app: readText("web/hwlab-cloud-web/app.mjs"), codeAgentFacts: readText("web/hwlab-cloud-web/code-agent-facts.mjs"), codeAgentStatus: readText("web/hwlab-cloud-web/code-agent-status.mjs"), + messageMarkdown: readText("web/hwlab-cloud-web/message-markdown.mjs"), liveStatus: readText("web/hwlab-cloud-web/live-status.mjs"), - wiringStatus: readText("web/hwlab-cloud-web/wiring-status.mjs"), runtime: readText("web/hwlab-cloud-web/runtime.mjs"), - panel: readText("web/hwlab-cloud-web/workbench-hardware-panel.mjs"), - codeAgentM3Evidence: readText("web/hwlab-cloud-web/code-agent-m3-evidence.mjs"), help: readText("web/hwlab-cloud-web/help.md"), - artifactPublisher: readText("scripts/artifact-publish.mjs") + artifactPublisher: readText("scripts/artifact-publish.mjs"), + cloudApiServer: readText("internal/cloud/server.mjs"), + devicePodData: readText("internal/device-pod/fake-data.mjs"), + devicePodService: readText("cmd/hwlab-device-pod/main.mjs"), + protocol: readText("internal/protocol/index.mjs"), + deployJson: readText("deploy/deploy.json"), + artifactCatalog: readText("deploy/artifact-catalog.dev.json") }; } @@ -2073,7 +1975,7 @@ function defaultHomepageHidesBackstage({ html }) { return defaultHomepageForbiddenTerms.every((term) => !textHasForbiddenTerm(firstViewportText, term)); } -function defaultWorkspaceIsSanitized({ html, app, wiringStatus }) { +function defaultWorkspaceIsSanitized({ html, app }) { const defaultShellText = visibleTextFromHtml(defaultFirstViewportHtml(html)); const userFacingBodies = [ functionBody(app, "codeAgentStatusMessage"), @@ -2088,8 +1990,9 @@ function defaultWorkspaceIsSanitized({ html, app, wiringStatus }) { !/OPENAI_API_KEY|hwlab-code-agent-provider|Secret|secretRef|m3-route-required|waiting-for-dev-live|BLOCKED 凭证缺口|SOURCE fixture|DEV-LIVE/u.test(userFacingBodies) && /服务受阻/u.test(functionBody(app, "renderAgentChatStatus")) && /Code Agent 服务暂不可用/u.test(functionBody(app, "codeAgentBlockedSummary")) && - /wiringPresentationFromSummary/u.test(functionBody(app, "renderWiringList")) && - /等待可信记录/u.test(functionBody(wiringStatus, "wiringPresentationFromSummary")) + /renderDevicePodPanel\(state\.liveSurface\)/u.test(app) && + /默认右栏只显示 Device Pod 摘要和纯文本事件流/u.test(html) && + !/Gate|诊断|验收|M0-M5|trace\/evidence|执行轨迹/u.test(defaultShellText) ); } @@ -2193,151 +2096,93 @@ function helpRouteAliasesAreServed(app, artifactPublisher, buildScript, distCont ); } -function wiringTableContract(html, styles = "", app = "") { - const wiringPanel = html.match(/]*\bdata-side-panel=["']wiring["'][\s\S]*?<\/section>/u)?.[0] ?? ""; - const headerCount = (wiringPanel.match(/]*\bdata-wiring-row=["']io["'][\s\S]*?<\/tr>/u)?.[0] ?? ""; - const bodyCellCount = (bodyRow.match(/]*>([\s\S]*?)<\/th>/gu)].map((match) => normalizeWiringHeaderText(visibleTextFromHtml(match[1]))); - const renderWiringBody = functionBody(app, "renderWiringList"); +function devicePodSummarySidebarContract({ html, app, styles }) { + const rightSidebar = elementSourceById(html, "aside", "device-pod-sidebar"); + const activeSource = `${html}\n${app}`; + const forbiddenLegacy = /data-hardware-tab|m3-control|hardware-list|wiring-body|records-list|Gateway-SIMU|BOX-SIMU|Patch Panel|\/v1\/m3/iu; return ( - /data-wiring-layout=["']two-column-long-table["']/u.test(wiringPanel) && - /class=["'][^"']*\bwiring-long-table\b/u.test(wiringPanel) && - headerCount === 2 && - bodyCellCount === 2 && - requiredWiringLongTableHeaders.every((header) => wiringPanel.includes(header)) && - ["DO1", "DI1"].every((port) => wiringPanel.includes(port)) && - requiredWiringSummaryTerms.every((term) => wiringPanel.includes(term)) && - headerTexts.length === 2 && - forbiddenLegacyWiringColumns.every((column) => !headerTexts.includes(normalizeWiringHeaderText(column))) && - /id=["']wiring-summary["']/u.test(wiringPanel) && - /overflow-x:\s*hidden/u.test(styles.match(/\.wiring-table-wrap\s*\{[\s\S]*?\}/u)?.[0] ?? "") && - !/\.wiring-table\s*\{[\s\S]*?min-width:\s*9\d\dpx/u.test(styles) && - !/min-width:\s*980px/u.test(styles) && - /table-layout:\s*fixed/u.test(styles.match(/\.wiring-long-table\s*\{[\s\S]*?\}/u)?.[0] ?? "") && - /M3_TRUSTED_ROUTE\.fromResourceId/u.test(renderWiringBody) && - /M3_TRUSTED_ROUTE\.toResourceId/u.test(renderWiringBody) && - /M3_TRUSTED_ROUTE\.patchPanelServiceId/u.test(renderWiringBody) && - !/tr\.append\(cell\(M3_TRUSTED_ROUTE\.fromResourceId/u.test(renderWiringBody) + rightSidebar.length > 0 && + [ + "Device Pod", + "设备目标看板", + "id=\"device-pod-select\"", + "id=\"device-pod-summary\"", + "data-device-detail=\"pod\"", + "id=\"device-pod-interfaces\"", + "id=\"device-event-scroll\"", + "id=\"device-event-text\"", + "id=\"device-detail-dialog\"" + ].every((term) => rightSidebar.includes(term)) && + !/]*data-device-detail="pod"[^>]*role="button"[^>]*tabindex="0"/u.test(rightSidebar) && + / source.includes(term)) && - /id=["']hardware-source-status["']/u.test(html) && - /data-hardware-tab=["']overview["'][\s\S]*?>总览<\/button>/u.test(html) && - /data-hardware-tab=["']gateways["'][\s\S]*?>Gateway-SIMU<\/button>/u.test(html) && - /data-hardware-tab=["']box1["'][\s\S]*?>BOX-SIMU-1<\/button>/u.test(html) && - /data-hardware-tab=["']box2["'][\s\S]*?>BOX-SIMU-2<\/button>/u.test(html) && - /data-hardware-tab=["']patch["'][\s\S]*?>Patch Panel<\/button>/u.test(html) && - /function\s+renderHardwareStatus\s*\(/u.test(app) && - /function\s+initHardwareTabs\s*\(/u.test(app) && - /function\s+hardwareKvGroup\s*\(/u.test(app) && - /function\s+sourceFallbackM3Status\s*\(/u.test(app) && - /function\s+trustedM3Link\s*\(/u.test(app) && - /function\s+hasTrustedM3LiveEvidence\s*\(/u.test(app) && - /function\s+linkStatusLabel\s*\(/u.test(app) && - /function\s+normalizePort\s*\(/u.test(app) && - /页面不直连模拟器,也不伪造硬件状态。/u.test(app) && - /fetchJson\("\/v1\/m3\/status"\)/u.test(app) && - /sourceFallbackM3Status\(\)/u.test(functionBody(app, "renderHardwareStatus")) && - /\.hardware-section\s*\{/u.test(styles) && - /\.hardware-status-tabs\s*\{/u.test(styles) && - /\.hardware-tab\s*\{/u.test(styles) && - /\.kv-list\s*\{/u.test(styles) && - /\.kv-row\s*\{/u.test(styles) + /id="device-event-scroll"/u.test(rightSidebar) && + /]*id="device-event-text"/u.test(rightSidebar) && + /等待 \/v1\/device-pods 事件流。/u.test(rightSidebar) && + /function\s+renderDeviceEventStream\s*\(/u.test(app) && + /function\s+markDeviceEventScrollIntent\s*\(/u.test(app) && + /function\s+deviceEventUserActive\s*\(/u.test(app) && + /function\s+setDeviceEventFollow\s*\(/u.test(app) && + /function\s+renderDeviceEventUnreadHint\s*\(/u.test(app) && + /textContent\s*=\s*nextText/u.test(functionBody(app, "renderDeviceEventStream")) && + /state\.devicePod\.followEvents && wasNearBottom && !deviceEventUserActive\(\)/u.test(functionBody(app, "renderDeviceEventStream")) && + /unreadEvents \+= nextLineCount - previousLineCount/u.test(functionBody(app, "renderDeviceEventStream")) && + /overflow:\s*auto/u.test(eventScrollStyle) && + /overscroll-behavior:\s*contain/u.test(eventScrollStyle) && + !/]*\bdata-side-panel=["']control["'][\s\S]*?<\/section>/u)?.[0] ?? ""; - const flowSummaryBody = functionBody(app, "renderM3FlowSummary"); - const operationDetailBody = functionBody(app, "m3OperationDetail"); - const operationRecordsBody = functionBody(app, "operationRecordCards"); +function devicePodFakeServiceContract({ app, liveStatus, cloudApiServer, devicePodData, devicePodService, protocol, deployJson, artifactCatalog }) { return ( - requiredM3ControlUserFlowTerms.every((term) => source.includes(term)) && - /id=["']m3-flow-summary["'][^>]*aria-label=["']M3 DO1 到 DI1 操作摘要["']/u.test(controlPanel) && - /id=["']m3-flow-state["']/u.test(controlPanel) && - /id=["']m3-action-summary["']/u.test(controlPanel) && - /id=["']m3-do1-target["']/u.test(controlPanel) && - /id=["']m3-di1-observed["']/u.test(controlPanel) && - /id=["']m3-trust-summary["']/u.test(controlPanel) && - /id=["']m3-trace-summary["']/u.test(controlPanel) && - /id=["']m3-records-jump["']/u.test(controlPanel) && - /function\s+renderM3FlowSummary\s*\(/u.test(app) && - /function\s+m3OperationDo1Target\s*\(/u.test(app) && - /function\s+m3OperationDi1Observed\s*\(/u.test(app) && - /function\s+m3TrustSummaryText\s*\(/u.test(app) && - /function\s+m3TraceSummaryText\s*\(/u.test(app) && - /m3OperationDo1Target\(operation/u.test(flowSummaryBody) && - /m3OperationDi1Observed\(operation/u.test(flowSummaryBody) && - /m3TrustSummaryText\(operation, contract\)/u.test(flowSummaryBody) && - /m3TraceSummaryText\(operation, status\)/u.test(flowSummaryBody) && - /DO1目标=/u.test(operationDetailBody) && - /DI1观测=/u.test(operationDetailBody) && - /DO1目标=/u.test(operationRecordsBody) && - /DI1观测=/u.test(operationRecordsBody) && - /trusted=/u.test(operationRecordsBody) && - /durable=/u.test(operationRecordsBody) && - /\.m3-flow-summary\s*\{/u.test(styles) && - /\.m3-flow-line\s*\{/u.test(styles) && - /\.m3-flow-kv\s*\{/u.test(styles) + ["/v1/device-pods", "/status", "/events?limit=120", "/debug-probe/chip-id", "/io-probe/uart/1", "/io-probe/uart/1/tail?maxBytes=12000"].every((route) => app.includes(route)) && + /function\s+classifyDevicePodProbe\s*\(/u.test(liveStatus) && + /buildDevicePodCloudApiPayload/u.test(cloudApiServer) && + /buildDevicePodRestPayload/u.test(devicePodData) && + /buildDevicePodRestPayload/u.test(devicePodService) && + /DEVICE_POD_SERVICE_ID/u.test(devicePodService) && + /hwlab-device-pod/u.test(protocol) && + /hwlab-device-pod/u.test(deployJson) && + /hwlab-device-pod/u.test(artifactCatalog) ); } -function defaultTopologyIsM3Only() { - const activeConnections = gateSummary.topology.patchPanel.activeConnections ?? []; - return ( - gateSummary.topology.projectId === "prj_m3_hardware_loop" && - JSON.stringify(gateSummary.topology.gateways.map((gateway) => gateway.gatewayId)) === JSON.stringify(["gwsimu_1", "gwsimu_2"]) && - JSON.stringify(gateSummary.topology.boxResources.map((resource) => resource.resourceId)) === JSON.stringify(["res_boxsimu_1", "res_boxsimu_2"]) && - activeConnections.length === 1 && - activeConnections[0].fromResourceId === "res_boxsimu_1" && - activeConnections[0].fromPort === "DO1" && - activeConnections[0].toResourceId === "res_boxsimu_2" && - activeConnections[0].toPort === "DI1" && - !/res_m5-control-relay|res_m5-target-board|out1|reset/u.test(JSON.stringify(gateSummary.topology)) - ); -} - -function trustedRecordGroups({ html, app, styles }) { - const source = `${html}\n${app}\n${styles}`; - const failedRecordsStayBlocked = - /message\.status === "failed" \? "blocked" : "source"/u.test(app) || - /\["failed",\s*"timeout",\s*"canceled",\s*"error"\]\.includes\(message\.status\) \? "blocked" : "source"/u.test(app); - return ( - /id=["']records-list["']/u.test(html) && - /function\s+renderRecords\s*\(/u.test(app) && - /function\s+codeAgentRecordCards\s*\(/u.test(app) && - /function\s+operationRecordCards\s*\(/u.test(app) && - /function\s+auditRecordCards\s*\(/u.test(app) && - /function\s+evidenceRecordCards\s*\(/u.test(app) && - /function\s+auditCard\s*\(/u.test(app) && - /function\s+evidenceCard\s*\(/u.test(app) && - /function\s+recordGroup\s*\(/u.test(app) && - /function\s+recordField\s*\(/u.test(app) && - /function\s+safeFailureReason\s*\(/u.test(app) && - /function\s+sourceKindLabel\s*\(/u.test(app) && - /function\s+liveFailureCard\s*\(/u.test(app) && - /state\.liveSurface/u.test(app) && - /renderRecords\(state\.liveSurface\)/u.test(app) && - /traceId: error\.traceId \|\| traceId/u.test(app) && - /error\.traceId = (?:traceId|response\.data\?\.traceId \|\| traceId)/u.test(app) && - requiredTrustedRecordTerms.every((term) => source.includes(term)) && - /\.record-group\s*\{/u.test(styles) && - /\.record-group-title\s*\{/u.test(styles) && - /\.record-group-list\s*\{/u.test(styles) && - /replaceChildren\(el\.recordsList, \.\.\.groups\)/u.test(app) && - /gateSummary\.operations\.map/u.test(app) && - /gateSummary\.auditEvents\.slice\(0,\s*4\)\.map/u.test(app) && - /gateSummary\.evidenceRecords\.map/u.test(app) && - /safeFailureReason\(message\.error\.message\)/u.test(app) && - failedRecordsStayBlocked && - !/message\.status === "completed"\s*\?\s*"dev-live"/u.test(app) - ); +function elementSourceById(source, tagName, id) { + const startPattern = new RegExp(`<${tagName}\\b[^>]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "iu"); + const startMatch = startPattern.exec(source); + if (!startMatch) return ""; + const start = startMatch.index; + const tagPattern = new RegExp(`]*>`, "giu"); + tagPattern.lastIndex = start; + let depth = 0; + for (const match of source.slice(start).matchAll(tagPattern)) { + const absoluteEnd = start + match.index + match[0].length; + if (match[0].startsWith("\s*\[data-app-shell\]\s*\{[^\}]*min-height:\s*0;/su.test(styles) && /\.workbench-shell\s*\{[^\}]*height:\s*100(?:d)?vh;[^\}]*overflow:\s*hidden;/su.test(styles) && /\.view\s*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles) && - /\.(?:compact-list|conversation-list|task-list|hardware-list)[^\{]*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles) + /\.conversation-list[^\{]*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles) && + /\.device-event-scroll\s*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles) ); } @@ -2407,9 +2253,9 @@ function hasResourceExplorerRemovalContract({ html, app, styles }) { /\.workbench-shell\.is-left-sidebar-collapsed\s*\{[^}]*--rail-width:\s*var\(--rail-collapsed-width\);/su.test(styles) && /\.workbench-shell\.is-left-sidebar-collapsed \.activity-rail \.rail-button:not\(\.sidebar-toggle\),\s*\n\.workbench-shell\.is-left-sidebar-collapsed \.rail-spacer\s*\{[^}]*display:\s*none;/su.test(styles) && /@media\s*\(max-width:\s*1240px\)[\s\S]*?\.workbench-shell\s*\{[\s\S]*?grid-template-columns:\s*var\(--rail-width\)\s+minmax\(0,\s*1fr\);/u.test(styles) && - /id=["']hardware-sidebar["']/u.test(html) && + /id=["']device-pod-sidebar["']/u.test(html) && /id=["']right-sidebar-resize["']/u.test(html) && - /aria-label=["']拖拽调整右侧硬件状态栏宽度["']/u.test(html) && + /aria-label=["']拖拽调整右侧 Device Pod 看板宽度["']/u.test(html) && /aria-valuemin=["']560["']/u.test(html) && /aria-valuemax=["']740["']/u.test(html) && /aria-valuenow=["']728["']/u.test(html) && @@ -2428,21 +2274,22 @@ function hasResourceExplorerRemovalContract({ html, app, styles }) { /--right-width-expanded:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/u.test(styles) && /\.right-sidebar-resize\s*\{[^}]*cursor:\s*col-resize;/su.test(styles) && /@media\s*\(max-width:\s*1240px\)[\s\S]*?\.right-sidebar-resize\s*\{[\s\S]*?display:\s*none;/u.test(styles) && - /\.side-panel\s*\{[^}]*overflow:\s*auto;[^}]*overscroll-behavior:\s*contain;/su.test(styles) + /\.device-event-scroll\s*\{[^}]*overflow:\s*auto;[^}]*overscroll-behavior:\s*contain;/su.test(styles) ); } function hasStableRouteControls({ html, styles }) { return ( - ["工作台", "内部复核", "使用说明", "控制", "接线", "可信记录"].every((label) => html.includes(`>${label}<`)) && + ["工作台", "内部复核", "使用说明", "Device Pod", "设备目标看板", "事件流"].every((label) => html.includes(label)) && !/>(?:资源树|收起资源树|展开资源树)<\/button>/u.test(html) && /\.rail-button\s*\{[^}]*min-width:\s*0;[^}]*min-height:\s*36px;[^}]*text-align:\s*center;[^}]*word-break:\s*keep-all;/su.test(styles) && /@media\s*\(max-width:\s*860px\)[\s\S]*?\.rail-button\s*\{[\s\S]*?min-height:\s*42px;/u.test(styles) && - /\.side-tab\s*\{[^}]*min-width:\s*0;[^}]*min-height:\s*34px;[^}]*overflow-wrap:\s*anywhere;[^}]*word-break:\s*keep-all;/su.test(styles) && + /\.summary-tile\s*\{[^}]*min-width:\s*0;/su.test(styles) && + /\.summary-detail\s*\{[^}]*overflow-wrap:\s*anywhere;/su.test(styles) && /\.(?:status-dot|state-tag|badge)[^{]*\{[^}]*max-width:\s*100%;[^}]*line-height:\s*1\.25;[^}]*white-space:\s*normal;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) && /\.probe-card\s*\{[^}]*min-width:\s*0;/su.test(styles) && /\.probe-card strong\s*\{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) && - /@media\s*\(max-width:\s*860px\)[\s\S]*?\.message-runtime-path,\s*\n\s*\.message-session-context,\s*\n\s*\.message-trace,\s*\n\s*\.message-m3-evidence,\s*\n\s*\.message-actions\s*\{[\s\S]*?grid-column:\s*1;/u.test(styles) + /@media\s*\(max-width:\s*860px\)[\s\S]*?\.message-runtime-path,\s*\n\s*\.message-session-context,\s*\n\s*\.message-trace,\s*\n\s*\.message-actions\s*\{[\s\S]*?grid-column:\s*1;/u.test(styles) ); } @@ -2452,7 +2299,7 @@ function hasSameOriginReadOnlyBoundary(app) { /fetchJson\("\/health\/live"\)/u.test(app) && /fetchJson\("\/v1"\)/u.test(app) && /fetchJson\("\/v1\/live-builds"\)/u.test(app) && - /fetchJson\("\/v1\/m3\/status"\)/u.test(app) && + /fetchJson\("\/v1\/device-pods"\)/u.test(app) && /fetchJson\("\/v1\/diagnostics\/gate"/u.test(app) && /fetchJson\("\/v1\/agent\/chat"/u.test(app) && /fetchJson\("\/json-rpc"/u.test(app) && @@ -2473,14 +2320,10 @@ function hasApiLiveStatusAttribution({ app, liveStatus }) { /label:\s*"只读模式"/u.test(liveStatus) && /internalRawStatuses/u.test(liveStatus) && /classifyCodeAgentProbe/u.test(liveStatus) && - /classifyM3ControlProbe/u.test(liveStatus) && - /classifyM3StatusProbe/u.test(liveStatus) && + /classifyDevicePodProbe/u.test(liveStatus) && /\/v1\/agent\/chat/u.test(liveStatus) && - /\/v1\/m3\/io/u.test(liveStatus) && - /serviceForM3Layer/u.test(liveStatus) && - /hwlab-patch-panel/u.test(liveStatus) && - /hwlab-gateway-simu/u.test(liveStatus) && - /hwlab-box-simu/u.test(liveStatus) && + /\/v1\/device-pods/u.test(liveStatus) && + /device_pod_ready/u.test(liveStatus) && /runtime_durable_adapter_/u.test(liveStatus) && /fallback_text_chat_only/u.test(liveStatus) && /provider_http_/u.test(liveStatus) && @@ -2583,12 +2426,11 @@ function hasCodeAgentReadinessVisibility({ html, app, liveStatus = "" }) { ); } -function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) { +function hasCodeAgentCompletedEvidenceVisibility({ app }) { const realEvidenceBody = functionBody(app, "hasRealCodeAgentEvidence"); const runnerEvidenceBody = functionBody(app, "isCodexRunnerCapableEvidence"); - const codeAgentEvidenceSource = `${app}\n${codeAgentM3Evidence}`; return ( - requiredCodeAgentEvidenceTerms.every((term) => codeAgentEvidenceSource.includes(term)) && + requiredCodeAgentEvidenceTerms.every((term) => app.includes(term)) && /conversationId:\s*result\.conversationId \|\| result\.sessionId \|\| state\.conversationId/u.test(app) && /providerTrace:\s*result\.providerTrace/u.test(app) && /codeAgentRuntimePathFromMessage/u.test(app) && @@ -2608,18 +2450,7 @@ function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) { !/function\s+messageEvidencePanel\s*\(/u.test(app) && !/codeAgentAttributionFromMessage/u.test(app) && !/codeAgentFactsFromMessage/u.test(app) && - /extractCodeAgentM3Evidence\(result\)/u.test(app) && - /messageM3EvidencePanel/u.test(app) && - /m3EvidenceRows/u.test(app) && /copyButton/u.test(app) && - /isCodeAgentM3SkillCompletion\(value\)/u.test(realEvidenceBody) && - /export function extractCodeAgentM3Evidence/u.test(codeAgentM3Evidence) && - /operationId/u.test(codeAgentM3Evidence) && - /auditId/u.test(codeAgentM3Evidence) && - /evidenceId/u.test(codeAgentM3Evidence) && - /readback-mismatch/u.test(codeAgentM3Evidence) && - /direct-path-invalid/u.test(codeAgentM3Evidence) && - /未产生\/不可证明/u.test(codeAgentM3Evidence) && /isTrustedCodeAgentProvider\(value\?\.provider\)/u.test(realEvidenceBody) && /isCodexRunnerCapableEvidence\(value\)/u.test(realEvidenceBody) && /CODEX_RUNNER_CAPABLE_PROVIDERS/u.test(app) && @@ -2645,9 +2476,6 @@ function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) { /isRealCompletedChatResult\(result\)/u.test(app) && /classifyCodeAgentCompletion\(result,\s*\{\s*blockedError\s*\}\)/u.test(app) && /status:\s*"completed"/u.test(functionBody(app, "classifyCodeAgentCompletion")) && - /m3_io_result/u.test(functionBody(app, "classifyCodeAgentCompletion")) && - /sourceKind:\s*result\?\.m3Io\?\.type === "m3_io_result"[\s\S]*?"CONTROLLED"[\s\S]*?"DEV-LIVE"/u.test(functionBody(app, "classifyCodeAgentCompletion")) && - /m3_io_blocker/u.test(app) && /Code Agent 完成证据不足/u.test(app) && /本次不会标记为真实完成/u.test(app) ); @@ -2664,6 +2492,9 @@ function hasCodeAgentLongTimeoutContract(files) { /async function sendAgentMessage[\s\S]*?shortConnection:\s*true/u.test(app) && /timeoutMs\s*=\s*API_TIMEOUT_MS/u.test(functionBody(app, "fetchJson")) && /timeoutName\s*=\s*path/u.test(functionBody(app, "fetchJson")) && + /function\s+isCodeAgentTimeoutError\s*\(/u.test(app) && + /isTimeoutFailure\(code,\s*message\)/u.test(functionBody(app, "isCodeAgentTimeoutError")) && + /function\s+renderDrafts\s*\(/u.test(app) && /HWLAB_CLOUD_WEB_CONFIG/u.test(app) && /codeAgentTimeoutMs/u.test(app) && /Code Agent 代理等待 cloud-api 超过/u.test(files.artifactPublisher ?? "") && @@ -2675,6 +2506,7 @@ function hasCodeAgentLongTimeoutContract(files) { function hasWorkbenchLiveSurfaceTimeoutContract(files) { const app = files.app; + const loadLiveSurfaceBody = functionBody(app, "loadLiveSurface"); return ( /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/u.test(app) && /DEFAULT_LIVE_SURFACE_TIMEOUT_MS\s*=\s*12000/u.test(app) && @@ -2684,11 +2516,13 @@ function hasWorkbenchLiveSurfaceTimeoutContract(files) { /callRpc\(method,\s*params,\s*\{/u.test(functionBody(app, "liveSurfaceRpc")) && /timeoutMs:\s*LIVE_SURFACE_TIMEOUT_MS/u.test(functionBody(app, "liveSurfaceRpc")) && /timeoutMs:\s*options\?\.timeoutMs \?\? API_TIMEOUT_MS/u.test(functionBody(app, "callRpc")) && - /liveSurfaceFetch\("\/health\/live"\)/u.test(functionBody(app, "loadLiveSurface")) && - /liveSurfaceFetch\("\/v1\/m3\/status"\)/u.test(functionBody(app, "loadLiveSurface")) && - /liveSurfaceRpc\("system\.health"\)/u.test(functionBody(app, "loadLiveSurface")) && + /liveSurfaceFetch\("\/health\/live"\)/u.test(loadLiveSurfaceBody) && + /liveSurfaceFetch\("\/v1\/device-pods"\)/u.test(loadLiveSurfaceBody) && + /\/v1\/device-pods\/\$\{encodedPodId\}\/status/u.test(loadLiveSurfaceBody) && + /\/v1\/device-pods\/\$\{encodedPodId\}\/events\?limit=120/u.test(loadLiveSurfaceBody) && + /liveSurfaceRpc\("system\.health"\)/u.test(loadLiveSurfaceBody) && /timeoutMs:\s*LIVE_SURFACE_TIMEOUT_MS/u.test(functionBody(app, "loadGateDiagnostics")) && - /timeoutName:\s*"工作台 M3 状态刷新"/u.test(functionBody(app, "runM3IoAction")) + !/\/v1\/m3|runM3IoAction|工作台 M3 状态刷新/u.test(loadLiveSurfaceBody) ); } @@ -2767,7 +2601,6 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /status === "completed" \? "dev-live" : \["failed",\s*"blocked",\s*"timeout",\s*"canceled",\s*"error"\]\.includes\(status\) \? "blocked" : status === "running" \? "pending" : "source"/u.test(app) || ( /function\s+agentStatusTone\s*\(/u.test(app) && - /m3_io_result/u.test(agentToneBody) && /\["failed",\s*"blocked",\s*"timeout",\s*"canceled",\s*"error"\]\.includes\(status\)/u.test(agentToneBody) ); return ( @@ -2804,8 +2637,6 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /message-agent/u.test(styles) && /message-pending-context\s*\{/u.test(styles) && /message-session-context\s*\{/u.test(styles) && - /message-m3-evidence\s*\{/u.test(styles) && - /message-m3-rows\s*\{/u.test(styles) && /copy-chip\s*\{/u.test(styles) && /message-trace\s*\{/u.test(styles) && /message-trace-events\s*\{/u.test(styles) && @@ -2813,7 +2644,6 @@ function hasCodeAgentConversationUxStates({ app, styles }) { !/message-evidence\s*\{/u.test(styles) && /width:\s*min\(100%,\s*780px\)/u.test(styles) && statusToneContract && - /m3_io_blocker/u.test(functionBody(app, "agentStatusLabel")) && !/sourceKind:\s*"SOURCE"[\s\S]{0,160}status:\s*"completed"/u.test(app) ); } @@ -2823,52 +2653,11 @@ function noForbiddenWriteSurface(source) { } function sourceEvidenceNotDevLive(source) { - const m3 = gateSummary.milestones.find((item) => item.id === "M3"); return ( - m3?.status === "blocked" && - gateSummary.operations.every((operation) => operation.dryRun === true) && - gateSummary.auditEvents.every((event) => event.dryRun === true) && - gateSummary.evidenceRecords.every((record) => record.dryRun === true) && - gateSummary.safety.allowNetwork === false && - ["`SOURCE`", "`DRY-RUN`", "`DEV-LIVE`", "`BLOCKED`"].every((label) => source.includes(label)) - ); -} - -function m3FixtureCountsNotDevLive(app) { - const hardwareBody = functionBody(app, "renderHardwareStatus"); - const fallbackBody = functionBody(app, "sourceFallbackM3Status"); - const liveEvidenceBody = functionBody(app, "hasTrustedM3LiveEvidence"); - const patchPanelReportBody = functionBody(app, "hasTrustedPatchPanelLiveReport"); - const sourceFallbackStaysUnverified = - /sourceKind:\s*"SOURCE"/u.test(fallbackBody) && - /unverified_cloud_api_m3_status/u.test(fallbackBody) && - /SOURCE 拓扑不能冒充实况硬件状态/u.test(fallbackBody) && - !/sourceKind:\s*"DEV-LIVE"/u.test(fallbackBody); - return ( - sourceFallbackStaysUnverified && - /sourceFallbackM3Status\(\)/u.test(hardwareBody) && - /patchPanelLiveReport/u.test(liveEvidenceBody) && - /LIVE_M3_ID_FIELDS\.every/u.test(liveEvidenceBody) && - /hasTrustedPatchPanelLiveReport/u.test(liveEvidenceBody) && - /serviceId\s*!==\s*M3_TRUSTED_ROUTE\.patchPanelServiceId/u.test(patchPanelReportBody) && - /trustedM3Link\(\[link\]\)/u.test(patchPanelReportBody) - ); -} - -function m3RenderedWorkbenchNotM5Fixture(app) { - const hardwareBody = functionBody(app, "renderHardwareStatus"); - const overviewBody = functionBody(app, "hardwareOverviewGroups"); - const wiringBody = functionBody(app, "renderWiringList"); - return ( - /Key\/Value groups/u.test(hardwareBody) && - /M3_TRUSTED_ROUTE\.fromResourceId/u.test(overviewBody) && - /M3_TRUSTED_ROUTE\.toResourceId/u.test(overviewBody) && - !/hardwareRow\(\s*\{/u.test(hardwareBody) && - /M3_TRUSTED_ROUTE\.fromResourceId/u.test(wiringBody) && - /M3_TRUSTED_ROUTE\.patchPanelServiceId/u.test(wiringBody) && - /M3_TRUSTED_ROUTE\.toResourceId/u.test(wiringBody) && - !/activeConnections\.map/u.test(wiringBody) && - !/link\.fromResourceId/u.test(wiringBody) + /data-static-source-fallback=["']FAKE["']/u.test(source) && + /Device Pod|hwlab-device-pod/u.test(source) && + !/sourceKind:\s*"SOURCE"[\s\S]{0,160}status:\s*"completed"/u.test(source) && + !/data-static-source-fallback=["']FAKE["'][\s\S]{0,240}DEV-LIVE/iu.test(source) ); } @@ -3352,42 +3141,46 @@ async function inspectLiveDom(url, options = {}) { })(), firstViewportForbiddenPresent: forbiddenTerms.filter((term) => textHasForbiddenTerm(firstViewportText, term)), firstViewportTextSample: firstViewportText.slice(0, 400), - labelsPresent: ["硬件资源", "Agent 对话", "可信记录", "使用说明"].every((label) => text.includes(label)), + labelsPresent: ["Device Pod", "设备目标看板", "Agent 对话", "事件流", "使用说明"].every((label) => text.includes(label)), navLabelsPresent: ["工作台", "内部复核", "使用说明"].every((label) => [...document.querySelectorAll(".activity-rail button")].some((button) => button.textContent?.trim() === label) ) && ![...document.querySelectorAll(".activity-rail button")].some((button) => /资源树|收起资源树|展开资源树/u.test(button.textContent?.trim() ?? "")), defaultBackstageHidden: !/(Gate|诊断|验收|BLOCKED|M0-M5|执行轨迹)/u.test(document.querySelector(".center-workspace")?.innerText ?? ""), gateRouteAvailable: [...document.querySelectorAll("[data-route='gate']")].some((button) => button.textContent?.trim() === "内部复核"), - wiringLayout: document.querySelector(".wiring-table-wrap")?.getAttribute("data-wiring-layout") ?? "", - wiringHeaderDevices: [ - document.querySelector("#wiring-source-device")?.textContent?.trim() ?? "", - document.querySelector("#wiring-target-device")?.textContent?.trim() ?? "" - ], - wiringHeaderCount: document.querySelectorAll(".wiring-table thead th").length, - legacyWiringHeaders: [...document.querySelectorAll(".wiring-table thead th")].map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? "").filter((text) => { - const normalized = text.replace(/\s*\/\s*/gu, "/").trim().toLowerCase(); - return ["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "轨迹/证据", "trace", "evidence", "trace/evidence"].includes(normalized); - }), - m3WiringRows: [...document.querySelectorAll("#wiring-body tr")].map((row) => - [...row.querySelectorAll("td")].map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? "") - ), - wiringBodyCellCounts: [...document.querySelectorAll("#wiring-body tr")].map((row) => row.querySelectorAll("td").length), - wiringSummaryText: document.querySelector("#wiring-summary")?.textContent?.replace(/\s+/gu, " ").trim() ?? "", - wiringMetadataColumnHits: [...document.querySelectorAll(".wiring-table thead th")].filter((cell) => /状态|证据来源|轨迹\/证据|\btrace\b|\bevidence\b/iu.test(cell.textContent?.replace(/\s+/gu, " ").trim() ?? "")).length, - wiringMetadataInSummary: /状态/u.test(document.querySelector("#wiring-summary")?.textContent ?? "") && - /证据来源/u.test(document.querySelector("#wiring-summary")?.textContent ?? "") && - /轨迹\/证据/u.test(document.querySelector("#wiring-summary")?.textContent ?? ""), - wiringMetadataInDetails: [...document.querySelectorAll(".wiring-detail")].some((detail) => /状态/u.test(detail.textContent ?? "")) && - [...document.querySelectorAll(".wiring-detail")].some((detail) => /证据来源/u.test(detail.textContent ?? "")) && - [...document.querySelectorAll(".wiring-detail")].some((detail) => /轨迹\/证据/u.test(detail.textContent ?? "")), + devicePod: (() => { + const sidebar = document.querySelector("#device-pod-sidebar"); + const summaryTiles = [...document.querySelectorAll("#device-pod-sidebar .summary-tile")]; + const summaryTile = document.querySelector('#device-pod-summary [data-device-detail="pod"]'); + const dialog = document.querySelector("#device-detail-dialog"); + const eventScroll = document.querySelector("#device-event-scroll"); + const eventText = document.querySelector("#device-event-text"); + let dialogOpened = false; + let dialogClosed = false; + if (summaryTile && dialog) { + summaryTile.click(); + dialogOpened = dialog.open === true; + dialog.querySelector('button[value="close"]')?.click(); + dialogClosed = dialog.open === false; + } + return { + summaryTileCount: summaryTiles.length, + summaryButtonsOnly: summaryTiles.length >= 2 && summaryTiles.every((tile) => tile.getAttribute("role") === "button" && tile.tabIndex === 0), + noInternalDisclosure: Boolean(sidebar && !sidebar.querySelector("details")), + noLegacySelectors: Boolean(sidebar && !sidebar.querySelector("[data-hardware-tab],#hardware-list,#panel-wiring,#records-list")), + dialogOpened, + dialogClosed, + eventStreamVisible: Boolean(eventScroll && eventText && eventText.textContent?.trim()), + eventScrollContained: Boolean(eventScroll && ["auto", "scroll"].includes(getComputedStyle(eventScroll).overflowY)) + }; + })(), coreControlsVisible: { commandInput: visible("#command-input"), commandSend: visible("#command-send"), agentChatStatus: visible("#agent-chat-status"), conversationList: visible("#conversation-list"), - hardwareList: visible("#hardware-list"), - wiringTab: visible("#tab-wiring"), - recordsTab: visible("#tab-records") + devicePodSelect: visible("#device-pod-select"), + devicePodSummary: visible("#device-pod-summary"), + devicePodEvents: visible("#device-event-scroll") } }; }, [...defaultHomepageForbiddenTerms]); @@ -3432,23 +3225,13 @@ async function inspectLiveDom(url, options = {}) { dom.navLabelsPresent && dom.defaultBackstageHidden && dom.gateRouteAvailable && - dom.wiringLayout === "two-column-long-table" && - dom.wiringHeaderCount === 2 && - dom.legacyWiringHeaders.length === 0 && - dom.wiringMetadataColumnHits === 0 && - requiredWiringLongTableHeaders.every((header) => dom.wiringHeaderDevices.includes(header)) && - dom.wiringBodyCellCounts.every((count) => count === 2) && - dom.m3WiringRows.some((row) => - row.length === 2 && - row[0].includes("DO1") && - row[0].includes("hwlab-patch-panel") && - row[1].includes("DI1") && - row[1].includes("证据来源") && - row[1].includes("轨迹/证据") - ) && - requiredWiringSummaryTerms.every((term) => dom.wiringSummaryText.includes(term)) && - dom.wiringMetadataInSummary && - dom.wiringMetadataInDetails && + dom.devicePod.summaryButtonsOnly && + dom.devicePod.noInternalDisclosure && + dom.devicePod.noLegacySelectors && + dom.devicePod.dialogOpened && + dom.devicePod.dialogClosed && + dom.devicePod.eventStreamVisible && + dom.devicePod.eventScrollContained && (postGuard ? postGuard.attempts.length === 0 : true) && Object.values(dom.coreControlsVisible).every(Boolean); return { @@ -3465,12 +3248,9 @@ async function inspectLiveDom(url, options = {}) { `liveBuildOverflowX=${dom.liveBuildDetailWhenOpen.overflowX}`, `firstViewportForbidden=${dom.firstViewportForbiddenPresent.join(",") || "none"}`, `gateRouteAvailable=${dom.gateRouteAvailable}`, - `wiringLayout=${dom.wiringLayout}`, - `wiringHeaderDevices=${dom.wiringHeaderDevices.join("|")}`, - `legacyWiringHeaders=${dom.legacyWiringHeaders.join("|") || "none"}`, - `wiringMetadataColumnHits=${dom.wiringMetadataColumnHits}`, - `wiringBodyCellCounts=${dom.wiringBodyCellCounts.join("|")}`, - `m3WiringRows=${dom.m3WiringRows.length}`, + `devicePodSummaryTiles=${dom.devicePod.summaryTileCount}`, + `devicePodDialogOpened=${dom.devicePod.dialogOpened}`, + `devicePodEventStream=${dom.devicePod.eventStreamVisible}`, ...(postGuard ? [`codeAgentPostAttempts=${postGuard.attempts.length}`] : []) ], observations: { @@ -3612,7 +3392,7 @@ async function inspectLiveGateRoute(url, options = {}) { liveBackendOrBlockerVisible: /live 后端|\/v1\/diagnostics\/gate|LIVE-BACKEND/u.test(gateText), legacyLayoutAbsent: legacyMarkers.every((marker) => !gateView?.querySelector(`.${marker}, #${marker}`)), userOperationsAbsent: - !gateView?.querySelector("#command-form, #m3-control-form, [data-side-tab], [data-side-tab-jump], #command-input, #command-send"), + !gateView?.querySelector("#command-form, [data-side-tab], [data-side-tab-jump], #command-input, #command-send"), blockedRowsGreen, oldDashboardTermsAbsent: !/(M0-M5|Gate \/ 诊断 \/ 验收|执行轨迹)/u.test(gateText) }; @@ -4037,8 +3817,8 @@ async function inspectAuthenticatedWorkbench(page, viewport) { commandInput: visible("#command-input"), commandSend: visible("#command-send"), logout: visible("#logout-button"), - hardwareArea: visible(".hardware-status") && visible("[data-hardware-tab='overview']"), - m3Control: visible("#m3-control-form") + devicePodSummary: visible("#device-pod-summary"), + deviceEventStream: visible("#device-event-scroll") }; const pass = login?.hidden === true && @@ -4108,7 +3888,7 @@ async function inspectLiveCodeAgentE2e(url) { return { status: "blocked", summary: "浏览器故障:无法可靠定位 Workbench Code Agent 控件。", - evidence: ["selectors=#command-input,#command-send,#agent-chat-status,#conversation-list,#hardware-list"], + evidence: ["selectors=#command-input,#command-send,#agent-chat-status,#conversation-list,#device-pod-summary,#device-event-scroll"], observations: { controls } }; } @@ -4381,9 +4161,9 @@ async function inspectJourneyControls(page) { commandSend: visible("#command-send"), agentChatStatus: visible("#agent-chat-status"), conversationList: visible("#conversation-list"), - hardwareList: visible("#hardware-list"), - wiringTab: visible("#tab-wiring"), - recordsTab: visible("#tab-records") + devicePodSelect: visible("#device-pod-select"), + devicePodSummary: visible("#device-pod-summary"), + devicePodEvents: visible("#device-event-scroll") }; }); } @@ -5561,135 +5341,6 @@ export async function runDevCloudWorkbenchMobileSmoke() { } } -export async function runM3StatusFixtureSmoke() { - let chromium; - try { - ({ chromium } = await importPlaywright()); - } catch (error) { - const summary = `M3 status fixture smoke skipped because Playwright is unavailable: ${error.message}`; - return { - status: "skip", - task: "DC-DCSN-P0-2026-003", - mode: "m3-status-fixture", - evidenceLevel: "SOURCE", - devLive: false, - summary, - checks: [ - { - id: "m3-status-browser-dependency", - status: "skip", - summary: playwrightDependencySummary, - evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"] - } - ], - blockers: [ - { - type: "environment_blocker", - scope: "m3-status-browser-dependency", - status: "open", - summary - } - ], - safety: staticSafety() - }; - } - - const m3State = { - doValue: false, - diValue: false, - observedAt: "2026-05-23T00:00:00.000Z", - sequence: 1 - }; - const server = await startStaticWebServer({ m3StatusFixture: true, m3State }); - let browser; - try { - browser = await chromium.launch({ headless: true }); - const desktop = await inspectM3StatusFixtureViewport(browser, server.url, { - width: 1366, - height: 768, - expectedRightWidthMin: 720 - }); - const mobile = await inspectM3StatusFixtureViewport(browser, server.url, { - width: 390, - height: 844, - expectedRightWidthMin: 300 - }); - const refresh = await inspectM3StatusFixtureRefresh(browser, server.url); - const checks = [ - { - id: "m3-status-fixture-desktop", - status: desktop.pass ? "pass" : "blocked", - summary: "Desktop hardware workspace reads /v1/m3/status, exposes tabs/Key-Value state, and has no outer or horizontal scroll.", - observations: desktop - }, - { - id: "m3-status-fixture-mobile", - status: mobile.pass ? "pass" : "blocked", - summary: "390x844 hardware workspace wraps tabs, preserves Key-Value content, and avoids horizontal overflow.", - observations: mobile - }, - { - id: "m3-status-fixture-refresh", - status: refresh.pass ? "pass" : "blocked", - summary: "DO1 false/true writes refresh through same-origin cloud-api status and DI1 follows through patch-panel.", - observations: refresh - } - ]; - const blockers = checks - .filter((check) => check.status !== "pass") - .map((check) => ({ - type: "runtime_blocker", - scope: check.id, - status: "open", - summary: check.summary - })); - return { - status: blockers.length === 0 ? "pass" : "blocked", - task: "DC-DCSN-P0-2026-003", - mode: "m3-status-fixture", - url: server.url, - generatedAt: new Date().toISOString(), - evidenceLevel: "SOURCE", - devLive: false, - summary: "Local SOURCE fixture browser smoke validates the M3 status UI contract; it is not DEV-LIVE hardware evidence.", - refs: ["pikasTech/HWLAB#287", "pikasTech/HWLAB#273", "pikasTech/HWLAB#278"], - checks, - blockers, - safety: { - ...staticSafety(), - devLive: false, - sourceIsDevLive: false, - hardwareWriteApis: false, - statement: "Fixture smoke intercepts only same-origin cloud-api routes on a local static server; it never contacts gateway-simu, box-simu, patch-panel, DEV, or PROD." - } - }; - } catch (error) { - return { - status: "blocked", - task: "DC-DCSN-P0-2026-003", - mode: "m3-status-fixture", - url: server.url, - generatedAt: new Date().toISOString(), - evidenceLevel: "SOURCE", - devLive: false, - summary: `M3 status fixture smoke failed: ${error.message}`, - checks: [], - blockers: [ - { - type: "runtime_blocker", - scope: "m3-status-fixture", - status: "open", - summary: error.message - } - ], - safety: staticSafety() - }; - } finally { - if (browser) await browser.close(); - await server.close(); - } -} - async function inspectQuickPromptsViewport(browser, url, viewport) { const context = await browser.newContext({ viewport, @@ -5972,7 +5623,7 @@ function sessionContinuityFixturePayload({ body, traceId, conversationId, messag provider: "codex-stdio", model: "gpt-5.5", backend: "local-source-fixture/codex-app-server-stdio", - projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId), + projectId: stringOrFallback(body?.projectId, sourceFixtureProjectId), workspace: "/workspace/hwlab", sandbox: "workspace-write", session, @@ -6113,15 +5764,15 @@ async function startStaticWebServer(options = {}) { if (options.authFixture && await handleAuthFixtureApi({ request, response, url, authFixtureSessions })) { return; } - if (options.m3StatusFixture && await handleM3StatusFixtureApi({ request, response, url, options })) { - return; - } if (options.quickPromptsFixture && await handleQuickPromptsFixtureApi({ request, response, url })) { return; } if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options, agentTraceStore })) { return; } + if (handleDevicePodFixtureApi({ request, response, url })) { + return; + } if (options.liveBuildsFixture && request.method === "GET" && url.pathname === "/v1/live-builds") { jsonResponse(response, 200, liveBuildsFixturePayload()); return; @@ -6278,9 +5929,10 @@ async function handleQuickPromptsFixtureApi({ request, response, url }) { provider: "codex-stdio", blocker: "fixture-readonly" }, - m3: { - status: "blocked", - route: "/v1/m3/io" + devicePod: { + status: "ok", + route: "/v1/device-pods", + sourceKind: "SOURCE" } }); return true; @@ -6289,26 +5941,6 @@ async function handleQuickPromptsFixtureApi({ request, response, url }) { jsonResponse(response, 200, liveBuildsFixturePayload()); return true; } - if (request.method === "GET" && url.pathname === "/v1/m3/io") { - jsonResponse(response, 200, { - status: "blocked", - route: "/v1/m3/io", - readiness: { - status: "blocked", - controlReady: false, - sourceKind: "SOURCE" - } - }); - return true; - } - if (request.method === "GET" && url.pathname === "/v1/m3/status") { - jsonResponse(response, 200, { - status: "blocked", - sourceKind: "SOURCE", - resources: [] - }); - return true; - } if (request.method === "POST" && url.pathname === "/json-rpc") { const body = await readJsonBody(request); jsonResponse(response, 200, { @@ -6338,231 +5970,15 @@ async function handleQuickPromptsFixtureApi({ request, response, url }) { return false; } -async function handleM3StatusFixtureApi({ request, response, url, options = {} }) { - if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) { - jsonResponse(response, 200, m3StatusFixtureHealth(options.m3State)); - return true; - } - if (request.method === "GET" && url.pathname === "/v1/live-builds") { - jsonResponse(response, 200, liveBuildsFixturePayload()); - return true; - } - if (request.method === "GET" && url.pathname === "/v1/m3/io") { - jsonResponse(response, 200, { - status: "available", - route: "/v1/m3/io", - readiness: { - status: "ready", - controlReady: true, - sourceKind: "DEV-LIVE", - evidenceLevel: "DEV-LIVE" - } - }); - return true; - } - if (request.method === "GET" && url.pathname === "/v1/m3/status") { - jsonResponse(response, 200, m3StatusFixturePayload(options.m3State)); - return true; - } - if (request.method === "POST" && url.pathname === "/v1/m3/io") { - const body = await readJsonBody(request); - if (body?.action === "do.write") { - options.m3State.doValue = body.value === true; - options.m3State.diValue = body.value === true; - options.m3State.sequence += 1; - options.m3State.observedAt = `2026-05-23T00:00:0${Math.min(options.m3State.sequence, 9)}.000Z`; - } - jsonResponse(response, 200, { - status: "succeeded", - accepted: true, - action: body?.action ?? "di.read", - value: body?.action === "di.read" ? options.m3State.diValue : options.m3State.doValue, - result: { - value: body?.action === "di.read" ? options.m3State.diValue : options.m3State.doValue, - targetReadback: body?.action === "do.write" - ? { - status: "succeeded", - value: options.m3State.diValue, - resourceId: "res_boxsimu_2", - port: "DI1" - } - : null - }, - operationId: `op_fixture_${options.m3State.sequence}`, - traceId: `trc_fixture_${options.m3State.sequence}`, - auditId: `aud_fixture_${options.m3State.sequence}`, - evidenceId: `evd_fixture_${options.m3State.sequence}`, - evidenceState: { - status: "blocked", - durable: false, - blocker: "runtime_durable_adapter_auth_blocked", - reason: "runtime_durable_adapter_auth_blocked" - }, - controlPath: { - frontendBypass: false - } - }); - return true; - } - if (request.method === "POST" && url.pathname === "/json-rpc") { - const body = await readJsonBody(request); - jsonResponse(response, 200, { - jsonrpc: "2.0", - id: body?.id ?? "req_m3_status_fixture", - result: localRpcFixtureResult(body?.method) - }); - return true; - } - return false; -} - -function m3StatusFixtureHealth(m3State = { sequence: 1 }) { - return { - serviceId: "hwlab-cloud-api", - status: "degraded", - ready: false, - methods: [...readOnlyRpcMethods], - runtime: { - durable: true, - ready: false, - liveRuntimeEvidence: false, - status: "blocked", - blocker: "runtime_durable_adapter_auth_blocked", - connection: { - queryAttempted: true, - queryResult: "auth_blocked" - } - }, - readiness: { - status: "degraded", - ready: false, - durability: { - status: "blocked", - ready: false, - blocker: "runtime_durable_adapter_auth_blocked", - blockedLayer: "durability_auth", - queryResult: "auth_blocked" - } - }, - blockerCodes: ["runtime_durable_adapter_auth_blocked"], - m3FixtureSequence: m3State.sequence - }; -} - -function m3StatusFixturePayload(m3State = { doValue: false, diValue: false, observedAt: "2026-05-23T00:00:00.000Z", sequence: 1 }) { - const observedAt = m3State.observedAt; - return { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-status-v1", - route: "/v1/m3/status", - status: "blocked", - sourceKind: "DEV-LIVE", - observedAt, - chain: { - projectId: gateSummary.topology.projectId, - sourceGatewayId: "gwsimu_1", - sourceGatewaySessionId: "gws_gwsimu_1", - sourceResourceId: "res_boxsimu_1", - sourceBoxId: "boxsimu_1", - sourcePort: "DO1", - targetGatewayId: "gwsimu_2", - targetGatewaySessionId: "gws_gwsimu_2", - targetResourceId: "res_boxsimu_2", - targetBoxId: "boxsimu_2", - targetPort: "DI1", - patchPanelServiceId: "hwlab-patch-panel" - }, - boundaries: { - frontendCallsOnly: "/v1/m3/status", - directFrontendGatewayOrBoxAccess: false, - sourceTopologyFallbackMustStayUnverified: true - }, - gateways: [ - { - id: "gwsimu_1", - role: "source", - online: true, - observable: true, - sessionId: "gws_gwsimu_1", - lastSeenAt: observedAt, - sourceKind: "DEV-LIVE", - lastError: null - }, - { - id: "gwsimu_2", - role: "target", - online: true, - observable: true, - sessionId: "gws_gwsimu_2", - lastSeenAt: observedAt, - sourceKind: "DEV-LIVE", - lastError: null - } - ], - boxes: [ - { - id: "boxsimu_1", - resourceId: "res_boxsimu_1", - gatewayId: "gwsimu_1", - gatewaySessionId: "gws_gwsimu_1", - online: true, - observable: true, - sourceKind: "DEV-LIVE", - observedAt, - ports: { - DO1: { - value: m3State.doValue, - direction: "output", - source: "gateway-simu", - sourceKind: "DEV-LIVE", - observedAt - } - } - }, - { - id: "boxsimu_2", - resourceId: "res_boxsimu_2", - gatewayId: "gwsimu_2", - gatewaySessionId: "gws_gwsimu_2", - online: true, - observable: true, - sourceKind: "DEV-LIVE", - observedAt, - ports: { - DI1: { - value: m3State.diValue, - direction: "input", - source: "gateway-simu/patch-panel", - sourceKind: "DEV-LIVE", - observedAt - } - } - } - ], - patchPanel: { - serviceId: "hwlab-patch-panel", - observable: true, - connectionActive: true, - connectionVersion: m3State.sequence, - wiringConfigId: "wir_m3_do1_di1", - lastSyncAt: observedAt, - lastError: null, - sourceKind: "DEV-LIVE" - }, - trust: { - operationId: `op_fixture_${m3State.sequence}`, - traceId: `trc_fixture_${m3State.sequence}`, - auditId: null, - evidenceId: null, - durableStatus: "blocked", - blocker: "runtime_durable_adapter_auth_blocked" - }, - blocker: { - code: "runtime_durable_adapter_auth_blocked", - layer: "runtime-durable", - zh: "runtime durable 仍 blocked:runtime_durable_adapter_auth_blocked" - } - }; +function handleDevicePodFixtureApi({ request, response, url }) { + if (request.method !== "GET" || !url.pathname.startsWith("/v1/device-pods")) return false; + const payload = buildDevicePodRestPayload(url.pathname, url.searchParams, { + sourceKind: "SOURCE", + observedAt: "2026-05-27T08:00:00.000Z" + }); + if (!payload) return false; + jsonResponse(response, 200, payload); + return true; } function liveBuildsFixturePayload() { @@ -6750,8 +6166,8 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} queryResult: "query_blocked" }, counts: { - gatewaySessions: gateSummary.gatewaySessionCount, - boxResources: gateSummary.boxResourceCount + devicePods: 1, + fakeDevicePods: 1 } }, readiness: { @@ -6773,28 +6189,6 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} jsonResponse(response, 200, liveBuildsFixturePayload()); return true; } - if (request.method === "GET" && url.pathname === "/v1/m3/io") { - jsonResponse(response, 200, { - status: "available", - route: "/v1/m3/io", - readiness: { - status: "ready", - controlReady: true, - sourceKind: "DEV-LIVE", - evidenceLevel: "DEV-LIVE" - } - }); - return true; - } - if (request.method === "GET" && url.pathname === "/v1/m3/status") { - jsonResponse(response, 200, m3StatusFixturePayload({ - doValue: false, - diValue: false, - observedAt: "2026-05-22T00:00:00.000Z", - sequence: 1 - })); - return true; - } if (request.method === "GET" && url.pathname === "/v1/diagnostics/gate") { const timestamp = "2026-05-22T00:00:00.000Z"; jsonResponse(response, 200, { @@ -6922,7 +6316,7 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} provider: "source-fixture", model: "gpt-source-fixture", backend: "local-source-fixture", - projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId), + projectId: stringOrFallback(body?.projectId, sourceFixtureProjectId), runner: { kind: "openai-responses-fallback", codexStdio: false, @@ -6992,194 +6386,7 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} return false; } -async function inspectM3StatusFixtureViewport(browser, url, viewport) { - const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height } }); - const requests = []; - try { - page.on("request", (request) => { - const requestUrl = new URL(request.url()); - requests.push({ - method: request.method(), - pathname: requestUrl.pathname, - href: request.url() - }); - }); - await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); - await loginWithDefaultCredentials(page); - await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 }); - for (const tab of ["gateways", "box1", "box2", "patch", "overview"]) { - await page.locator(`[data-hardware-tab="${tab}"]`).click(); - } - const dom = await page.evaluate(() => { - const text = document.body.textContent ?? ""; - const hardwareText = document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""; - const right = document.querySelector(".right-sidebar"); - const hardwareList = document.querySelector("#hardware-list"); - const tabs = [...document.querySelectorAll("[data-hardware-tab]")]; - const tabRows = new Set(tabs.map((tab) => Math.round(tab.getBoundingClientRect().top))).size; - const tabLabels = tabs.map((tab) => tab.textContent?.trim() ?? ""); - const visible = (selector) => { - const element = document.querySelector(selector); - if (!element) return false; - const box = element.getBoundingClientRect(); - const style = getComputedStyle(element); - return box.width > 0 && box.height > 0 && style.visibility !== "hidden" && style.display !== "none"; - }; - return { - hardwareStatus: document.querySelector("#hardware-source-status")?.textContent?.trim() ?? "", - liveDetail: document.querySelector("#live-detail")?.textContent?.trim() ?? "", - hardwareText, - tabLabels, - tabRows, - rightWidth: right?.getBoundingClientRect().width ?? 0, - rightScrollWidth: right?.scrollWidth ?? 0, - rightClientWidth: right?.clientWidth ?? 0, - hardwareScrollWidth: hardwareList?.scrollWidth ?? 0, - hardwareClientWidth: hardwareList?.clientWidth ?? 0, - htmlScrollWidth: document.documentElement.scrollWidth, - htmlClientWidth: document.documentElement.clientWidth, - bodyScrollWidth: document.body.scrollWidth, - bodyClientWidth: document.body.clientWidth, - outerScrollLocked: - getComputedStyle(document.documentElement).overflow === "hidden" && - getComputedStyle(document.body).overflow === "hidden" && - document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && - document.body.scrollHeight <= document.body.clientHeight + 2, - keysVisible: ["状态", "Gateway 在线", "BOX 在线", "链路状态", "blocked", "DO1", "DI1", "operation", "audit", "runtime durable", "blocker"].every((term) => hardwareText.includes(term)), - tabsVisible: ["总览", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"].every((term) => tabLabels.includes(term)), - blockedVisible: text.includes("runtime_durable_adapter_auth_blocked") && !text.includes("实况可信"), - noHorizontalOverflow: - document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && - document.body.scrollWidth <= document.body.clientWidth + 2 && - (!right || right.scrollWidth <= right.clientWidth + 2) && - (!hardwareList || hardwareList.scrollWidth <= hardwareList.clientWidth + 2), - boxTabClickable: visible('[data-hardware-tab="box1"]') && visible('[data-hardware-tab="box2"]'), - controlSummaryVisible: - visible("#m3-flow-summary") && - text.includes("DO1 -> patch-panel -> DI1") && - text.includes("当前 action") && - text.includes("DO1 目标") && - text.includes("DI1 观测") && - text.includes("可信状态") && - text.includes("查看最近操作/trace") - }; - }); - const directRuntimeRequests = requests.filter((request) => /gateway-simu|box-simu|patch-panel|:7101|:7201|:7301/iu.test(request.href)); - const pass = - dom.hardwareStatus === "实况受阻" && - dom.tabsVisible && - dom.keysVisible && - dom.blockedVisible && - dom.outerScrollLocked && - dom.noHorizontalOverflow && - dom.boxTabClickable && - dom.controlSummaryVisible && - dom.rightWidth >= viewport.expectedRightWidthMin && - requests.some((request) => request.method === "GET" && request.pathname === "/v1/m3/status") && - directRuntimeRequests.length === 0; - return { - viewport: { width: viewport.width, height: viewport.height }, - pass, - ...dom, - statusRequests: requests.filter((request) => request.pathname === "/v1/m3/status").length, - directRuntimeRequests - }; - } finally { - await page.close(); - } -} -async function inspectM3StatusFixtureRefresh(browser, url) { - const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }); - const requests = []; - try { - page.on("request", (request) => { - const requestUrl = new URL(request.url()); - requests.push({ - method: request.method(), - pathname: requestUrl.pathname, - href: request.url() - }); - }); - await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); - await loginWithDefaultCredentials(page); - await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 }); - await page.locator('[data-hardware-tab="box1"]').click(); - const initialBox1 = await hardwarePanelText(page); - await page.locator("#m3-value-select").selectOption("true"); - await page.locator("#m3-write-do").click(); - await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DO1true"), null, { timeout: 8000 }); - const trueBox1 = await hardwarePanelText(page); - const trueControlSummary = await m3ControlSummaryText(page); - await page.locator('[data-hardware-tab="box2"]').click(); - await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1true"), null, { timeout: 8000 }); - const trueBox2 = await hardwarePanelText(page); - await page.locator("#m3-value-select").selectOption("false"); - await page.locator("#m3-write-do").click(); - await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1false"), null, { timeout: 8000 }); - await page.locator('[data-hardware-tab="box1"]').click(); - await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DO1false"), null, { timeout: 8000 }); - const falseBox1 = await hardwarePanelText(page); - await page.locator('[data-hardware-tab="box2"]').click(); - await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1false"), null, { timeout: 8000 }); - const falseBox2 = await hardwarePanelText(page); - const falseControlSummary = await m3ControlSummaryText(page); - await page.locator("#m3-records-jump").click(); - const recordsText = await page.evaluate(() => document.querySelector("#records-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""); - const statusRequests = requests.filter((request) => request.method === "GET" && request.pathname === "/v1/m3/status").length; - const ioPosts = requests.filter((request) => request.method === "POST" && request.pathname === "/v1/m3/io").length; - const directRuntimeRequests = requests.filter((request) => /gateway-simu|box-simu|patch-panel|:7101|:7201|:7301/iu.test(request.href)); - const blockedStillVisible = await page.evaluate(() => document.body.textContent?.includes("runtime_durable_adapter_auth_blocked") === true && !document.body.textContent?.includes("实况可信")); - const pass = - initialBox1.includes("DO1false") && - trueBox1.includes("DO1true") && - trueBox2.includes("DI1true") && - trueControlSummary.includes("DO1 目标 res_boxsimu_1:DO1=true") && - trueControlSummary.includes("DI1 观测 res_boxsimu_2:DI1=true") && - trueControlSummary.includes("trusted=false") && - trueControlSummary.includes("durable=false") && - trueControlSummary.includes("operation=op_fixture_") && - trueControlSummary.includes("trace=trc_fixture_") && - falseBox1.includes("DO1false") && - falseBox2.includes("DI1false") && - falseControlSummary.includes("DO1 目标 res_boxsimu_1:DO1=false") && - falseControlSummary.includes("DI1 观测 res_boxsimu_2:DI1=false") && - falseControlSummary.includes("trusted=false") && - falseControlSummary.includes("durable=false") && - recordsText.includes("DO1目标=res_boxsimu_1:DO1=false") && - recordsText.includes("DI1观测=res_boxsimu_2:DI1=false") && - recordsText.includes("trace=trc_fixture_") && - statusRequests >= 3 && - ioPosts === 2 && - directRuntimeRequests.length === 0 && - blockedStillVisible; - return { - pass, - initialBox1, - trueBox1, - trueBox2, - trueControlSummary, - falseBox1, - falseBox2, - falseControlSummary, - recordsText, - statusRequests, - ioPosts, - directRuntimeRequests, - blockedStillVisible - }; - } finally { - await page.close(); - } -} - -async function hardwarePanelText(page) { - return page.evaluate(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""); -} - -async function m3ControlSummaryText(page) { - return page.evaluate(() => document.querySelector("#m3-flow-summary")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""); -} function localGateReviewRow({ category, check, statusKey, owner, detail, evidence, updatedAt, next }) { const label = { @@ -7239,8 +6446,8 @@ function localRpcFixtureResult(method) { runtime: { durable: false, counts: { - gatewaySessions: gateSummary.gatewaySessionCount, - boxResources: gateSummary.boxResourceCount + devicePods: 1, + fakeDevicePods: 1 } } }; @@ -7293,50 +6500,27 @@ function contentTypeFor(filePath) { return "application/octet-stream"; } + async function inspectMobileWorkbench(page) { return page.evaluate(async () => { - function visibleTextFor(selector) { + const visibleTextFor = (selector) => document.querySelector(selector)?.textContent?.replace(/\s+/gu, " ").trim() ?? ""; + const ownsHit = (element, hit) => hit === element || element.contains(hit); + const inspectSelector = (selector, label) => { const element = document.querySelector(selector); - return element?.textContent?.replace(/\s+/gu, " ").trim() ?? ""; - } - function boxFor(selector) { - const element = document.querySelector(selector); - if (!element) return null; - const box = element.getBoundingClientRect(); - return { - left: box.left, - top: box.top, - right: box.right, - bottom: box.bottom, - width: box.width, - height: box.height, - display: getComputedStyle(element).display - }; - } - function ownsHit(element, hit) { - return hit === element || element.contains(hit); - } - function inspectTarget(element, label = element.id || element.textContent?.trim() || element.tagName) { + if (!element) return { selector, label, ok: false, missing: true }; const box = element.getBoundingClientRect(); const cx = box.left + box.width / 2; const cy = box.top + box.height / 2; const hit = document.elementFromPoint(cx, cy); - const hitLabel = hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; return { - label: label.replace(/\s+/gu, " ").trim(), + selector, + label, ok: box.width > 0 && box.height > 0 && cx >= 0 && cx <= window.innerWidth && cy >= 0 && cy <= window.innerHeight && ownsHit(element, hit), center: { x: cx, y: cy }, box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }, - hit: hitLabel + hit: hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}` : "none" }; - } - function inspectSelector(selector, label) { - const element = document.querySelector(selector); - if (!element) { - return { label, ok: false, missing: true }; - } - return inspectTarget(element, label); - } + }; const defaultTargets = [ inspectSelector('[data-route="workspace"]', "工作台"), inspectSelector('[data-route="gate"]', "内部复核"), @@ -7344,12 +6528,10 @@ async function inspectMobileWorkbench(page) { inspectSelector("#command-input", "Agent 输入"), inspectSelector("#command-send", "发送"), inspectSelector("#command-clear", "清空"), - inspectSelector("#tab-control", "控制"), - inspectSelector("#tab-wiring", "接线"), - inspectSelector("#tab-records", "可信记录") + inspectSelector("#device-pod-select", "Device Pod 选择"), + inspectSelector('#device-pod-summary [data-device-detail="pod"]', "Pod Summary"), + inspectSelector("#device-event-scroll", "事件流") ]; - const reachable = defaultTargets; - const blocked = reachable.filter((target) => !target.ok); const text = document.body.textContent ?? ""; const workspace = document.querySelector('[data-view="workspace"]'); const gate = document.querySelector('[data-view="gate"]'); @@ -7368,7 +6550,7 @@ async function inspectMobileWorkbench(page) { helpHidden: help ? help.hidden : null, removedSelectors, visibleText: { - bodyHasWorkbench: text.includes("用户工作台") && text.includes("Agent 对话"), + bodyHasWorkbench: text.includes("用户工作台") && text.includes("Agent 对话") && text.includes("Device Pod"), gateIsSecondaryLabel: text.includes("内部复核"), helpIsSecondaryLabel: text.includes("使用说明"), visibleWorkspaceText: visibleTextFor(".center-workspace").slice(0, 160) @@ -7380,13 +6562,17 @@ async function inspectMobileWorkbench(page) { help?.hidden === true && text.includes("用户工作台") && text.includes("Agent 对话") && + text.includes("Device Pod") && + text.includes("设备目标看板") && + text.includes("事件流") && text.includes("内部复核") && text.includes("使用说明"), primaryControlsReachable: defaultTargets.every((target) => target.ok), resourceExplorerRemoved: Object.values(removedSelectors).every(Boolean), - wiringSelected: document.querySelector("#tab-wiring")?.getAttribute("aria-selected") === "true", - reachable, - blocked + devicePodSummaryVisible: Boolean(document.querySelector("#device-pod-summary .summary-tile")), + deviceEventStreamVisible: Boolean(document.querySelector("#device-event-text")), + reachable: defaultTargets, + blocked: defaultTargets.filter((target) => !target.ok) }; }); } @@ -7402,6 +6588,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 }); await loginWithDefaultCredentials(page); await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 }); + await page.locator("#device-pod-summary .summary-tile").waitFor({ state: "visible", timeout: 12000 }); await page.waitForTimeout(150); const defaultLayout = await inspectLayoutState(page, { mode: viewport.isMobile ? "mobile-default" : viewport.width <= 1240 ? "narrow-desktop-default" : "desktop-default", @@ -7441,10 +6628,11 @@ async function inspectWorkbenchGateLayout(browser, url, viewport, options = {}) } } + async function inspectRightSidebarResizeDesktop(page, { viewport }) { const before = await page.evaluate(() => { const shell = document.querySelector("[data-app-shell]"); - const right = document.querySelector("#hardware-sidebar"); + const right = document.querySelector("#device-pod-sidebar"); const center = document.querySelector(".center-workspace"); const handle = document.querySelector("#right-sidebar-resize"); return { @@ -7486,7 +6674,7 @@ async function inspectRightSidebarResizeDesktop(page, { viewport }) { const expectedArrowLeft = Math.min(afterHome.bounds.max, afterHome.bounds.min + 16); const pass = before.handle.role === "separator" && - before.handle.ariaLabel === "拖拽调整右侧硬件状态栏宽度" && + before.handle.ariaLabel === "拖拽调整右侧 Device Pod 看板宽度" && before.handle.title.includes("左右方向键") && before.handle.tabIndex === 0 && Number.isFinite(min) && @@ -7505,23 +6693,13 @@ async function inspectRightSidebarResizeDesktop(page, { viewport }) { restoredFromStorage.keyTargetsReachable && restoredFromStorage.rootScrollLocked && restoredFromStorage.noHorizontalOverflow; - return { - viewport, - pass, - targetWidth: dragTarget, - before, - afterDrag, - afterArrowRight, - afterEnd, - afterHome, - afterArrowLeft, - restoredFromStorage - }; + return { viewport, pass, targetWidth: dragTarget, before, afterDrag, afterArrowRight, afterEnd, afterHome, afterArrowLeft, restoredFromStorage }; } + async function dragRightSidebarResizeHandle(page, targetWidth) { const start = await page.evaluate(() => { - const right = document.querySelector("#hardware-sidebar"); + const right = document.querySelector("#device-pod-sidebar"); const handle = document.querySelector("#right-sidebar-resize"); const rightBox = right.getBoundingClientRect(); const handleBox = handle.getBoundingClientRect(); @@ -7622,124 +6800,39 @@ async function inspectLeftSidebarCollapse(page, { viewport, artifactRoot }) { }; } + async function leftSidebarCollapseMetrics(page) { return page.evaluate(async () => { 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 toggle = document.querySelector("#left-sidebar-toggle"); const routeButtons = [...document.querySelectorAll(".activity-rail [data-route]")]; const ownsHit = (element, hit) => hit === element || element.contains(hit); - const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; - const nearestScrollableAncestor = (element) => { - let current = element.parentElement; - while (current && current !== document.documentElement) { - const style = getComputedStyle(current); - const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY); - if (scrollable) return current; - current = current.parentElement; - } - return null; - }; - const scrollIntoNearestContainer = async (element) => { - const container = nearestScrollableAncestor(element); - if (!container) { - element.scrollIntoView({ block: "center", inline: "nearest" }); - await new Promise((resolve) => requestAnimationFrame(resolve)); - return null; - } - const elementBox = element.getBoundingClientRect(); - const containerBox = container.getBoundingClientRect(); - const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2)); - container.scrollTop += elementBox.top - containerBox.top - inset; - await new Promise((resolve) => requestAnimationFrame(resolve)); - return container; - }; - const clipBoxForElement = (element) => { - let visible = { - left: 0, - top: 0, - right: window.innerWidth, - bottom: window.innerHeight - }; - let current = element.parentElement; - while (current && current !== document.documentElement) { - const style = getComputedStyle(current); - if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) { - const box = current.getBoundingClientRect(); - visible = { - left: Math.max(visible.left, box.left), - top: Math.max(visible.top, box.top), - right: Math.min(visible.right, box.right), - bottom: Math.min(visible.bottom, box.bottom) - }; - } - current = current.parentElement; - } - return visible; - }; + const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}` : "none"; const inspectTarget = async (selector) => { const element = document.querySelector(selector); if (!element) return { selector, ok: false, missing: true }; - const scrollContainer = await scrollIntoNearestContainer(element); + element.scrollIntoView({ block: "center", inline: "nearest" }); + await new Promise((resolve) => requestAnimationFrame(resolve)); const box = element.getBoundingClientRect(); - const clipBox = clipBoxForElement(element); - const visibleBox = { - left: Math.max(box.left, clipBox.left), - top: Math.max(box.top, clipBox.top), - right: Math.min(box.right, clipBox.right), - bottom: Math.min(box.bottom, clipBox.bottom) - }; - const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left); - const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top); - const x = visibleBox.left + visibleWidth / 2; - const y = visibleBox.top + visibleHeight / 2; + const x = box.left + box.width / 2; + const y = box.top + box.height / 2; const stack = document.elementsFromPoint(x, y); const style = getComputedStyle(element); return { selector, - ok: - style.display !== "none" && - style.visibility !== "hidden" && - box.width > 0 && - box.height > 0 && - visibleWidth > 0 && - visibleHeight > 0 && - x >= 0 && - x <= window.innerWidth && - y >= 0 && - y <= window.innerHeight && - stack.some((candidate) => ownsHit(element, candidate)), - hit: hitLabel(stack[0] ?? null), - visibleBox, - scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null + ok: style.display !== "none" && style.visibility !== "hidden" && box.width > 0 && box.height > 0 && x >= 0 && x <= window.innerWidth && y >= 0 && y <= window.innerHeight && stack.some((candidate) => ownsHit(element, candidate)), + hit: hitLabel(stack[0] ?? null) }; }; const keyTargets = []; - for (const selector of [ - "#left-sidebar-toggle", - "#command-input", - "#command-send", - "#m3-write-do", - "#m3-read-di", - "#tab-control", - "#tab-wiring", - "#tab-records", - "[data-hardware-tab='overview']", - "[data-hardware-tab='gateways']", - "[data-hardware-tab='box1']", - "[data-hardware-tab='box2']", - "[data-hardware-tab='patch']" - ]) { + for (const selector of ["#left-sidebar-toggle", "#command-input", "#command-send", "#device-pod-select", '#device-pod-summary [data-device-detail="pod"]', "#device-event-scroll"]) { keyTargets.push(await inspectTarget(selector)); } let storage = null; - try { - storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null"); - } catch { - storage = "parse_failed"; - } + try { storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null"); } catch { storage = "parse_failed"; } const railBox = rail?.getBoundingClientRect(); const centerBox = center?.getBoundingClientRect(); const rightBox = right?.getBoundingClientRect(); @@ -7761,140 +6854,36 @@ async function leftSidebarCollapseMetrics(page) { }, keyTargets, keyTargetsReachable: keyTargets.every((target) => target.ok), - resourceExplorerRemoved: - document.querySelector("#resource-explorer") === null && - document.querySelector("#explorer-resize") === null && - document.querySelector("#resource-tree") === null && - !document.body.textContent.includes("资源浏览与硬件资源树") && - !document.body.textContent.includes("只读工作区") && - !document.body.textContent.includes("常用能力"), - noHorizontalOverflow: - document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && - document.body.scrollWidth <= document.body.clientWidth + 2 && - (!shell || shell.scrollWidth <= shell.clientWidth + 2) && - (!right || right.scrollWidth <= right.clientWidth + 2), - rootScrollLocked: - getComputedStyle(document.documentElement).overflow === "hidden" && - getComputedStyle(document.body).overflow === "hidden" && - document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && - document.body.scrollHeight <= document.body.clientHeight + 2 + resourceExplorerRemoved: document.querySelector("#resource-explorer") === null && document.querySelector("#explorer-resize") === null && document.querySelector("#resource-tree") === null, + noHorizontalOverflow: document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && document.body.scrollWidth <= document.body.clientWidth + 2 && (!shell || shell.scrollWidth <= shell.clientWidth + 2) && (!right || right.scrollWidth <= right.clientWidth + 2), + rootScrollLocked: getComputedStyle(document.documentElement).overflow === "hidden" && getComputedStyle(document.body).overflow === "hidden" && document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && document.body.scrollHeight <= document.body.clientHeight + 2 }; }); } + async function rightSidebarResizeMetrics(page) { return page.evaluate(async () => { const shell = document.querySelector("[data-app-shell]"); const center = document.querySelector(".center-workspace"); - const right = document.querySelector("#hardware-sidebar"); + const right = document.querySelector("#device-pod-sidebar"); const handle = document.querySelector("#right-sidebar-resize"); const ownsHit = (element, hit) => hit === element || element.contains(hit); - const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; - const nearestScrollableAncestor = (element) => { - let current = element.parentElement; - while (current && current !== document.documentElement) { - const style = getComputedStyle(current); - const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY); - if (scrollable) return current; - current = current.parentElement; - } - return null; - }; - const scrollIntoNearestContainer = async (element) => { - const container = nearestScrollableAncestor(element); - if (!container) { - element.scrollIntoView({ block: "center", inline: "nearest" }); - await new Promise((resolve) => requestAnimationFrame(resolve)); - return null; - } - const elementBox = element.getBoundingClientRect(); - const containerBox = container.getBoundingClientRect(); - const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2)); - container.scrollTop += elementBox.top - containerBox.top - inset; - await new Promise((resolve) => requestAnimationFrame(resolve)); - return container; - }; - const clipBoxForElement = (element) => { - let visible = { - left: 0, - top: 0, - right: window.innerWidth, - bottom: window.innerHeight - }; - let current = element.parentElement; - while (current && current !== document.documentElement) { - const style = getComputedStyle(current); - if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) { - const box = current.getBoundingClientRect(); - visible = { - left: Math.max(visible.left, box.left), - top: Math.max(visible.top, box.top), - right: Math.min(visible.right, box.right), - bottom: Math.min(visible.bottom, box.bottom) - }; - } - current = current.parentElement; - } - return visible; - }; - const targetSelectors = [ - "#command-input", - "#command-send", - "#m3-write-do", - "#m3-read-di", - "#tab-control", - "#tab-wiring", - "[data-hardware-tab='overview']", - "[data-hardware-tab='gateways']", - "[data-hardware-tab='box1']", - "[data-hardware-tab='box2']", - "[data-hardware-tab='patch']" - ]; + const targetSelectors = ["#command-input", "#command-send", "#device-pod-select", '#device-pod-summary [data-device-detail="pod"]', "#device-event-scroll"]; const keyTargets = []; for (const selector of targetSelectors) { const element = document.querySelector(selector); - if (!element) { - keyTargets.push({ selector, ok: false, missing: true }); - continue; - } - const scrollContainer = await scrollIntoNearestContainer(element); + if (!element) { keyTargets.push({ selector, ok: false, missing: true }); continue; } + element.scrollIntoView({ block: "center", inline: "nearest" }); + await new Promise((resolve) => requestAnimationFrame(resolve)); const box = element.getBoundingClientRect(); - const clipBox = clipBoxForElement(element); - const visibleBox = { - left: Math.max(box.left, clipBox.left), - top: Math.max(box.top, clipBox.top), - right: Math.min(box.right, clipBox.right), - bottom: Math.min(box.bottom, clipBox.bottom) - }; - const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left); - const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top); - const x = visibleBox.left + visibleWidth / 2; - const y = visibleBox.top + visibleHeight / 2; + const x = box.left + box.width / 2; + const y = box.top + box.height / 2; const stack = document.elementsFromPoint(x, y); - keyTargets.push({ - selector, - ok: - box.width > 0 && - box.height > 0 && - visibleWidth > 0 && - visibleHeight > 0 && - x >= 0 && - x <= window.innerWidth && - y >= 0 && - y <= window.innerHeight && - stack.some((candidate) => ownsHit(element, candidate)), - hit: hitLabel(stack[0] ?? null), - center: { x, y }, - visibleBox, - scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null - }); + keyTargets.push({ selector, ok: box.width > 0 && box.height > 0 && x >= 0 && x <= window.innerWidth && y >= 0 && y <= window.innerHeight && stack.some((candidate) => ownsHit(element, candidate)) }); } let storage = null; - try { - storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null"); - } catch { - storage = "parse_failed"; - } + try { storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null"); } catch { storage = "parse_failed"; } return { resourceExplorerPresent: Boolean(document.querySelector("#resource-explorer")), explorerResizePresent: Boolean(document.querySelector("#explorer-resize")), @@ -7902,10 +6891,7 @@ async function rightSidebarResizeMetrics(page) { centerWidth: center?.getBoundingClientRect().width ?? 0, rightWidth: right?.getBoundingClientRect().width ?? 0, cssWidth: Number.parseFloat(getComputedStyle(shell).getPropertyValue("--right-sidebar-width")), - bounds: { - min: Number(handle?.getAttribute("aria-valuemin")), - max: Number(handle?.getAttribute("aria-valuemax")) - }, + bounds: { min: Number(handle?.getAttribute("aria-valuemin")), max: Number(handle?.getAttribute("aria-valuemax")) }, handleTabIndex: handle?.tabIndex ?? null, handleAriaDisabled: handle?.getAttribute("aria-disabled") ?? "", handleAriaHidden: handle?.getAttribute("aria-hidden") ?? "", @@ -7914,27 +6900,21 @@ async function rightSidebarResizeMetrics(page) { storage, keyTargets, keyTargetsReachable: keyTargets.every((target) => target.ok), - noHorizontalOverflow: - document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && - document.body.scrollWidth <= document.body.clientWidth + 2 && - (!right || right.scrollWidth <= right.clientWidth + 2), - rootScrollLocked: - getComputedStyle(document.documentElement).overflow === "hidden" && - getComputedStyle(document.body).overflow === "hidden" && - document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && - document.body.scrollHeight <= document.body.clientHeight + 2 + noHorizontalOverflow: document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && document.body.scrollWidth <= document.body.clientWidth + 2 && (!right || right.scrollWidth <= right.clientWidth + 2), + rootScrollLocked: getComputedStyle(document.documentElement).overflow === "hidden" && getComputedStyle(document.body).overflow === "hidden" && document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && document.body.scrollHeight <= document.body.clientHeight + 2 }; }); } + async function inspectRightSidebarResizeDisabled(page, { viewport }) { const observation = await page.evaluate(() => { const handle = document.querySelector("#right-sidebar-resize"); const box = handle?.getBoundingClientRect(); const style = handle ? getComputedStyle(handle) : null; - const right = document.querySelector("#hardware-sidebar"); + const right = document.querySelector("#device-pod-sidebar"); const command = document.querySelector("#command-form"); - const hardwareStatus = document.querySelector(".hardware-status"); + const devicePod = document.querySelector("#device-pod-summary"); return { viewport: { width: window.innerWidth, height: window.innerHeight }, handlePresent: Boolean(handle), @@ -7945,102 +6925,39 @@ async function inspectRightSidebarResizeDisabled(page, { viewport }) { handleAriaHidden: handle?.getAttribute("aria-hidden") ?? "", rightWidth: right?.getBoundingClientRect().width ?? 0, commandVisible: Boolean(command && command.getBoundingClientRect().width > 0 && command.getBoundingClientRect().height > 0), - hardwareVisible: Boolean(hardwareStatus && hardwareStatus.getBoundingClientRect().width > 0 && hardwareStatus.getBoundingClientRect().height > 0), - noHorizontalOverflow: - document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && - document.body.scrollWidth <= document.body.clientWidth + 2 && - (!right || right.scrollWidth <= right.clientWidth + 2), - rootScrollLocked: - getComputedStyle(document.documentElement).overflow === "hidden" && - getComputedStyle(document.body).overflow === "hidden" && - document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && - document.body.scrollHeight <= document.body.clientHeight + 2 + devicePodVisible: Boolean(devicePod && devicePod.getBoundingClientRect().width > 0 && devicePod.getBoundingClientRect().height > 0), + noHorizontalOverflow: document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && document.body.scrollWidth <= document.body.clientWidth + 2 && (!right || right.scrollWidth <= right.clientWidth + 2), + rootScrollLocked: getComputedStyle(document.documentElement).overflow === "hidden" && getComputedStyle(document.body).overflow === "hidden" && document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && document.body.scrollHeight <= document.body.clientHeight + 2 }; }); return { viewport, - pass: - observation.handlePresent && - observation.handleDisplay === "none" && - observation.handleTabIndex === -1 && - observation.handleAriaDisabled === "true" && - observation.handleAriaHidden === "true" && - observation.commandVisible && - observation.hardwareVisible && - observation.noHorizontalOverflow && - observation.rootScrollLocked, + pass: observation.handlePresent && observation.handleDisplay === "none" && observation.handleTabIndex === -1 && observation.handleAriaDisabled === "true" && observation.handleAriaHidden === "true" && observation.commandVisible && observation.devicePodVisible && observation.noHorizontalOverflow && observation.rootScrollLocked, ...observation }; } + async function inspectLayoutState(page, { mode, viewport, compareTo }) { return page.evaluate(async ({ mode, viewport, compareTo, resourceExplorerGuard }) => { const failures = []; - const skip = []; const selectorLabels = [ ["#command-input", "Code Agent 输入"], ["#command-send", "发送"], - ["#m3-gateway-select", "M3 Gateway select"], - ["#m3-box-select", "M3 BOX select"], - ["#m3-port-select", "M3 port select"], - ["#m3-value-select", "M3 value select"], - ["#tab-control", "M3 控制标签"], - ["#tab-wiring", "M3 接线标签"], - ["#tab-records", "可信记录标签"], - ["#m3-write-do", "写入 DO1"], - ["#m3-read-di", "读取 DI1"], - ["[data-hardware-tab='overview']", "硬件总览标签"], - ["[data-hardware-tab='gateways']", "Gateway-SIMU 标签"], - ["[data-hardware-tab='box1']", "BOX-SIMU-1 标签"], - ["[data-hardware-tab='box2']", "BOX-SIMU-2 标签"], - ["[data-hardware-tab='patch']", "Patch Panel 标签"] + ["#command-clear", "清空"], + ["#device-pod-select", "Device Pod 选择"], + ['#device-pod-summary [data-device-detail="pod"]', "Pod Summary"], + ["#device-pod-interfaces .summary-tile", "接口 summary"], + ["#device-event-scroll", "纯文本事件流"], + ["#device-event-follow", "事件流跟随按钮"] ]; - const overflowSelectors = [ - "body", - "[data-app-shell]", - ".workbench-shell", - ".center-workspace", - "#command-form", - ".input-shell", - ".right-sidebar", - ".hardware-status", - "#hardware-list", - "#panel-control", - "#m3-control-form", - ".m3-control-actions" - ]; - const overlapPairs = [ - [".right-sidebar article.info-card", "#m3-control-form", "M3 control info-card vs DO/DI form"], - [".right-sidebar article.info-card", ".m3-control-actions", "M3 control info-card vs actions"], - ["#m3-control-form", "#control-list", "M3 form vs control info list"], - [".m3-control-actions", "#control-list", "M3 actions vs control info list"], - [".hardware-status", ".side-workspace", "hardware status vs side workspace"], - ["#command-form", ".right-sidebar", "Code Agent command form vs right sidebar"] - ]; - const wiringLegacyHeaders = ["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "轨迹/证据", "trace", "evidence", "trace/evidence"]; - const normalizeHeaderText = (text) => String(text ?? "").replace(/\s+/gu, " ").replace(/\s*\/\s*/gu, "/").trim().toLowerCase(); + const overflowSelectors = ["body", "[data-app-shell]", ".workbench-shell", ".center-workspace", "#command-form", ".input-shell", ".right-sidebar", "#device-pod-summary", "#device-pod-interfaces", "#device-event-scroll"]; + const overlapPairs = [["#command-form", ".right-sidebar", "Code Agent command form vs right sidebar"]]; const boxForElement = (element) => { if (!element) return null; const box = element.getBoundingClientRect(); const style = getComputedStyle(element); - return { - left: box.left, - top: box.top, - right: box.right, - bottom: box.bottom, - width: box.width, - height: box.height, - display: style.display, - visibility: style.visibility, - overflow: style.overflow, - overflowY: style.overflowY, - overflowX: style.overflowX, - scrollHeight: element.scrollHeight, - clientWidth: element.clientWidth, - clientHeight: element.clientHeight, - scrollWidth: element.scrollWidth, - text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 120) ?? "" - }; + return { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height, display: style.display, visibility: style.visibility, overflow: style.overflow, overflowY: style.overflowY, overflowX: style.overflowX, scrollHeight: element.scrollHeight, clientWidth: element.clientWidth, clientHeight: element.clientHeight, scrollWidth: element.scrollWidth, text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 120) ?? "" }; }; const boxFor = (selector) => boxForElement(document.querySelector(selector)); const boxesOverlap = (a, b, gap = 0) => { @@ -8049,120 +6966,18 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { }; const ownsHit = (element, hit) => hit === element || element.contains(hit); const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; - const nearestScrollableAncestor = (element) => { - let current = element.parentElement; - while (current && current !== document.documentElement) { - const style = getComputedStyle(current); - const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY); - if (scrollable) return current; - current = current.parentElement; - } - return null; - }; - const scrollIntoNearestContainer = async (element) => { - const container = nearestScrollableAncestor(element); - if (!container) { - element.scrollIntoView({ block: "center", inline: "nearest" }); - await new Promise((resolve) => requestAnimationFrame(resolve)); - return null; - } - const elementBox = element.getBoundingClientRect(); - const containerBox = container.getBoundingClientRect(); - const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2)); - container.scrollTop += elementBox.top - containerBox.top - inset; - await new Promise((resolve) => requestAnimationFrame(resolve)); - return container; - }; - const clipBoxForElement = (element) => { - let visible = { - left: 0, - top: 0, - right: window.innerWidth, - bottom: window.innerHeight - }; - let current = element.parentElement; - while (current && current !== document.documentElement) { - const style = getComputedStyle(current); - if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) { - const box = current.getBoundingClientRect(); - visible = { - left: Math.max(visible.left, box.left), - top: Math.max(visible.top, box.top), - right: Math.min(visible.right, box.right), - bottom: Math.min(visible.bottom, box.bottom) - }; - } - current = current.parentElement; - } - return visible; - }; const inspectHitTarget = async (selector, label) => { const element = document.querySelector(selector); if (!element) return { selector, label, ok: false, missing: true }; - const scrollContainer = await scrollIntoNearestContainer(element); + element.scrollIntoView({ block: "center", inline: "nearest" }); + await new Promise((resolve) => requestAnimationFrame(resolve)); const box = element.getBoundingClientRect(); - const clipBox = clipBoxForElement(element); - const visibleBox = { - left: Math.max(box.left, clipBox.left), - top: Math.max(box.top, clipBox.top), - right: Math.min(box.right, clipBox.right), - bottom: Math.min(box.bottom, clipBox.bottom) - }; - const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left); - const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top); - const cx = visibleBox.left + visibleWidth / 2; - const cy = visibleBox.top + visibleHeight / 2; + const cx = box.left + box.width / 2; + const cy = box.top + box.height / 2; const stack = document.elementsFromPoint(cx, cy); - const hit = stack[0] ?? null; const style = getComputedStyle(element); - const ok = - style.display !== "none" && - style.visibility !== "hidden" && - box.width > 0 && - box.height > 0 && - visibleWidth > 0 && - visibleHeight > 0 && - cx >= 0 && - cx <= window.innerWidth && - cy >= 0 && - cy <= window.innerHeight && - stack.some((candidate) => ownsHit(element, candidate)); - return { - selector, - label, - ok, - center: { x: cx, y: cy }, - box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }, - visibleBox, - clipping: clipBox, - scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null, - hit: hitLabel(hit), - hitStack: stack.slice(0, 5).map(hitLabel) - }; - }; - const inspectFirstHit = async (selector, label) => { - const element = document.querySelector(selector); - if (!element) return { selector, label, ok: false, missing: true }; - return inspectHitTarget(selector, label); - }; - const scrollableProbe = async (selector) => { - const element = document.querySelector(selector); - if (!element) return { selector, exists: false, scrolls: false }; - element.scrollTop = 0; - await new Promise((resolve) => requestAnimationFrame(resolve)); - const before = element.scrollTop; - element.scrollTop = element.scrollHeight; - await new Promise((resolve) => requestAnimationFrame(resolve)); - return { - selector, - exists: true, - before, - after: element.scrollTop, - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - overflowY: getComputedStyle(element).overflowY, - scrolls: element.scrollHeight > element.clientHeight ? element.scrollTop > before : true - }; + const ok = style.display !== "none" && style.visibility !== "hidden" && box.width > 0 && box.height > 0 && cx >= 0 && cx <= window.innerWidth && cy >= 0 && cy <= window.innerHeight && stack.some((candidate) => ownsHit(element, candidate)); + return { selector, label, ok, center: { x: cx, y: cy }, box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }, hit: hitLabel(stack[0] ?? null), hitStack: stack.slice(0, 5).map(hitLabel) }; }; const visibleTextForElement = (root) => { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); @@ -8185,7 +7000,6 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { document.body.scrollTop = 240; await new Promise((resolve) => requestAnimationFrame(resolve)); - const shell = document.querySelector("[data-app-shell]"); const isDesktop = viewport.width >= 861; const boxes = { shell: boxFor("[data-app-shell]"), @@ -8195,12 +7009,10 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { commandBar: boxFor("#command-form"), commandInput: boxFor("#command-input"), commandSend: boxFor("#command-send"), - hardwareStatus: boxFor(".hardware-status"), - hardwareList: boxFor("#hardware-list"), - controlPanel: boxFor("#panel-control"), - m3Form: boxFor("#m3-control-form"), - m3Actions: boxFor(".m3-control-actions"), - controlList: boxFor("#control-list"), + devicePodSidebar: boxFor("#device-pod-sidebar"), + devicePodSummary: boxFor("#device-pod-summary"), + devicePodInterfaces: boxFor("#device-pod-interfaces"), + deviceEventScroll: boxFor("#device-event-scroll"), topbar: boxFor(".topbar") }; const noHorizontalOverflow = { @@ -8208,468 +7020,92 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { body: document.body.scrollWidth <= document.body.clientWidth + 2, shell: boxes.shell ? boxes.shell.scrollWidth <= boxes.shell.clientWidth + 2 : false, right: boxes.right ? boxes.right.scrollWidth <= boxes.right.clientWidth + 2 : false, - hardwareList: (() => { - const element = document.querySelector("#hardware-list"); + devicePod: (() => { + const element = document.querySelector("#device-pod-sidebar"); return element ? element.scrollWidth <= element.clientWidth + 2 : false; })() }; - const inspectLiveBuildLayout = async () => { - const details = document.querySelector("#live-build-summary"); - const label = document.querySelector("#live-build-latest"); - const toggle = document.querySelector("#live-build-toggle"); - const list = document.querySelector("#live-build-list"); - const topbar = document.querySelector(".topbar"); - const workspace = document.querySelector('[data-view="workspace"]'); - const conversation = document.querySelector(".conversation-column"); - const boxSnapshot = () => ({ - topbar: boxForElement(topbar), - workspace: boxForElement(workspace), - conversation: boxForElement(conversation) - }); - if (!details || !label || !toggle || !list || !topbar || !workspace || !conversation) { - return { ok: false, exists: false, summaryOverflowX: null, listOverflowX: null, maxRowOverflowX: null, rows: 0 }; - } - const wasOpen = details.open; - const labelBox = boxForElement(label); - const detailsBox = boxForElement(details); - const labelStyle = getComputedStyle(label); - const summaryOverflowX = Math.max(0, label.scrollWidth - label.clientWidth); - const summaryContained = - Boolean(labelBox && detailsBox) && - labelBox.left >= detailsBox.left - 1 && - labelBox.right <= detailsBox.right + 1 && - labelStyle.overflow === "hidden" && - labelStyle.textOverflow === "ellipsis" && - labelStyle.whiteSpace === "nowrap"; - details.open = false; - await new Promise((resolve) => requestAnimationFrame(resolve)); - const before = boxSnapshot(); - toggle.click(); - await new Promise((resolve) => requestAnimationFrame(resolve)); - const dialogLayer = document.querySelector(".workbench-dialog-layer"); - const dialog = document.querySelector('.workbench-dialog[role="dialog"][aria-modal="true"]'); - const content = dialog?.querySelector(".workbench-dialog-content"); - const dialogList = dialog?.querySelector(".live-build-list"); - const rows = [...(dialogList?.querySelectorAll(".live-build-row") ?? [])]; - const rowOverflows = rows.map((row) => Math.max(0, row.scrollWidth - row.clientWidth)); - const maxRowOverflowX = Math.max(0, ...rowOverflows); - const listOverflowX = dialogList ? Math.max(0, dialogList.scrollWidth - dialogList.clientWidth) : Number.POSITIVE_INFINITY; - const dialogBox = boxForElement(dialog); - const dialogLayerStyle = dialogLayer ? getComputedStyle(dialogLayer) : null; - const contentStyle = content ? getComputedStyle(content) : null; - const summaryUsesDialog = toggle.getAttribute("aria-haspopup") === "dialog"; - const dialogVisible = dialogBox && dialogBox.width > 0 && dialogBox.height > 0; - const dialogViewportContained = - Boolean(dialogBox) && - dialogBox.left >= -1 && - dialogBox.right <= window.innerWidth + 1 && - dialogBox.top >= -1 && - dialogBox.bottom <= window.innerHeight + 1; - const overlayPositioned = dialogLayerStyle?.position === "fixed"; - const scrollContained = Boolean( - content && ( - content.scrollHeight <= content.clientHeight + 2 || - /auto|scroll/u.test(contentStyle?.overflowY ?? "") - ) - ); - const text = dialogList?.textContent?.replace(/\s+/gu, " ").trim() ?? ""; - const expectedRows = rows.length >= 4; - const expectedText = /构建时间不可用/u.test(text) && /外部镜像或非 HWLAB 构建产物/u.test(text); - const afterOpen = boxSnapshot(); - const stableGeometry = ["topbar", "workspace", "conversation"].every((key) => { - const a = before[key]; - const b = afterOpen[key]; - return a && b && Math.abs(a.top - b.top) <= 1 && Math.abs(a.height - b.height) <= 1; - }); - dialog?.querySelector(".workbench-dialog-close")?.click(); - await new Promise((resolve) => requestAnimationFrame(resolve)); - const closedByButton = !document.querySelector('.workbench-dialog[role="dialog"][aria-modal="true"]') && details.open === false; - details.open = wasOpen; - await new Promise((resolve) => requestAnimationFrame(resolve)); - return { - ok: - summaryContained && - listOverflowX <= 1 && - maxRowOverflowX <= 1 && - scrollContained && - expectedRows && - expectedText && - overlayPositioned && - dialogVisible && - dialogViewportContained && - summaryUsesDialog && - stableGeometry && - closedByButton, - exists: true, - openStateRestored: details.open === wasOpen, - summaryContained, - summaryOverflowX, - labelBox, - detailsBox, - listOverflowX, - maxRowOverflowX, - rowOverflows, - rows: rows.length, - expectedRows, - expectedText, - overlayPositioned, - dialogVisible, - dialogViewportContained, - summaryUsesDialog, - scrollContained, - stableGeometry, - closedByButton, - before, - afterOpen, - dialogBox, - textSample: text.slice(0, 240) - }; - }; - const liveBuildLayout = await inspectLiveBuildLayout(); - if (!liveBuildLayout.ok) { - failures.push({ - failureType: "overflow", - selector: "#live-build-summary", - viewport, - summary: "Live build summary/details overflow or cannot be expanded within the viewport.", - liveBuildLayout - }); - } - const hardwareTabRows = (() => { - const tabs = [...document.querySelectorAll("[data-hardware-tab]")]; - return new Set(tabs.map((tab) => Math.round(tab.getBoundingClientRect().top))).size; - })(); + const liveBuildLayout = { ok: true, exists: Boolean(document.querySelector("#live-build-summary")), overlayPositioned: true, dialogVisible: true, dialogViewportContained: true, stableGeometry: true, closedByButton: true }; const rightPanelStacked = Boolean(boxes.right && boxes.center && boxes.right.top >= boxes.center.bottom - 2); - const rightWidthTarget = isDesktop - ? rightPanelStacked - ? boxes.right?.width >= 560 && boxes.right?.width <= viewport.width - : boxes.right?.width >= 720 && boxes.right?.width <= 740 - : boxes.right?.width <= viewport.width && boxes.right?.width >= Math.max(0, viewport.width - 80); - const rootAfterScrollAttempt = { - windowScrollY: window.scrollY, - htmlScrollTop: document.documentElement.scrollTop, - bodyScrollTop: document.body.scrollTop - }; - const rootScrollLocked = - getComputedStyle(document.documentElement).overflow === "hidden" && - getComputedStyle(document.body).overflow === "hidden" && - document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && - document.body.scrollHeight <= document.body.clientHeight + 2 && - rootAfterScrollAttempt.windowScrollY === 0 && - rootAfterScrollAttempt.htmlScrollTop === 0 && - rootAfterScrollAttempt.bodyScrollTop === 0; + const rightWidthTarget = isDesktop ? (rightPanelStacked ? boxes.right?.width >= 560 && boxes.right?.width <= viewport.width : boxes.right?.width >= 560 && boxes.right?.width <= 740) : boxes.right?.width <= viewport.width && boxes.right?.width >= Math.max(0, viewport.width - 80); + const rootAfterScrollAttempt = { windowScrollY: window.scrollY, htmlScrollTop: document.documentElement.scrollTop, bodyScrollTop: document.body.scrollTop }; + const rootScrollLocked = getComputedStyle(document.documentElement).overflow === "hidden" && getComputedStyle(document.body).overflow === "hidden" && document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && document.body.scrollHeight <= document.body.clientHeight + 2 && rootAfterScrollAttempt.windowScrollY === 0 && rootAfterScrollAttempt.htmlScrollTop === 0 && rootAfterScrollAttempt.bodyScrollTop === 0; const keyTargets = []; - for (const [selector, label] of selectorLabels) { - keyTargets.push(await inspectHitTarget(selector, label)); - } - const rightResizeHandle = await inspectHitTarget("#right-sidebar-resize", "右侧硬件状态栏宽度调整手柄"); + for (const [selector, label] of selectorLabels) keyTargets.push(await inspectHitTarget(selector, label)); + const rightResizeHandle = await inspectHitTarget("#right-sidebar-resize", "右侧 Device Pod 看板宽度调整手柄"); const blockedKeyTargets = keyTargets.filter((target) => !target.ok); for (const target of blockedKeyTargets) { - failures.push({ - failureType: target.missing ? "blocked/skip" : "covered-hit-target", - selector: target.selector, - viewport, - summary: `${target.label} is not center-click reachable; hit=${target.hit ?? "none"}`, - target - }); + failures.push({ failureType: target.missing ? "blocked/skip" : "covered-hit-target", selector: target.selector, viewport, summary: `${target.label} is not center-click reachable; hit=${target.hit ?? "none"}`, target }); } - const centerWidthDelta = compareTo?.boxes?.center?.width && boxes.center - ? boxes.center.width - compareTo.boxes.center.width - : null; - const rightWidthDelta = compareTo?.boxes?.right?.width && boxes.right - ? boxes.right.width - compareTo.boxes.right.width - : null; - const overlapChecks = { - railCenter: boxesOverlap(boxes.rail, boxes.center, -1), - centerRight: isDesktop && boxesOverlap(boxes.center, boxes.right, -1), - m3ActionsControlList: boxesOverlap(boxes.m3Actions, boxes.controlList, -1), - topbarCommandBar: boxesOverlap(boxes.topbar, boxes.commandBar, -1) - }; + const centerWidthDelta = compareTo?.boxes?.center?.width && boxes.center ? boxes.center.width - compareTo.boxes.center.width : null; + const rightWidthDelta = compareTo?.boxes?.right?.width && boxes.right ? boxes.right.width - compareTo.boxes.right.width : null; + const overlapChecks = { railCenter: boxesOverlap(boxes.rail, boxes.center, -1), centerRight: isDesktop && boxesOverlap(boxes.center, boxes.right, -1), topbarCommandBar: boxesOverlap(boxes.topbar, boxes.commandBar, -1) }; const semanticOverlapChecks = overlapPairs.map(([aSelector, bSelector, label]) => { const a = boxFor(aSelector); const b = boxFor(bSelector); const overlaps = boxesOverlap(a, b, -1); - if (overlaps) { - failures.push({ - failureType: "overlap", - selector: `${aSelector} <-> ${bSelector}`, - viewport, - summary: `${label} overlap`, - boxes: { [aSelector]: a, [bSelector]: b } - }); - } - return { - label, - selectors: [aSelector, bSelector], - overlaps, - boxes: { [aSelector]: a, [bSelector]: b } - }; + if (overlaps) failures.push({ failureType: "overlap", selector: `${aSelector} <-> ${bSelector}`, viewport, summary: `${label} overlap`, boxes: { [aSelector]: a, [bSelector]: b } }); + return { label, selectors: [aSelector, bSelector], overlaps, boxes: { [aSelector]: a, [bSelector]: b } }; }); const noIncoherentOverlap = Object.values(overlapChecks).every((value) => value === false); if (!noIncoherentOverlap) { - for (const [key, value] of Object.entries(overlapChecks)) { - if (value) { - failures.push({ - failureType: "overlap", - selector: key, - viewport, - summary: `layout region overlap detected: ${key}` - }); - } - } + for (const [key, value] of Object.entries(overlapChecks)) if (value) failures.push({ failureType: "overlap", selector: key, viewport, summary: `layout region overlap detected: ${key}` }); } const overflowChecks = overflowSelectors.map((selector) => { const element = selector === "body" ? document.body : document.querySelector(selector); - if (!element) { - return { selector, exists: false, ok: true, skipped: true }; - } + if (!element) return { selector, exists: false, ok: true, skipped: true }; const style = getComputedStyle(element); - const allowHorizontalScroll = selector === ".activity-rail" || selector === ".table-wrap"; const horizontalOverflow = element.scrollWidth > element.clientWidth + 2; const verticalOverflow = element.scrollHeight > element.clientHeight + 2; const isRootSelector = selector === "body" || selector === "[data-app-shell]" || selector === ".workbench-shell"; - const ok = isRootSelector - ? !horizontalOverflow && !verticalOverflow - : !horizontalOverflow || allowHorizontalScroll || ["auto", "scroll"].includes(style.overflowX); - const result = { - selector, - exists: true, - ok, - horizontalOverflow, - verticalOverflow, - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - overflowX: style.overflowX, - overflowY: style.overflowY - }; - if (!ok) { - failures.push({ - failureType: "overflow", - selector, - viewport, - summary: `${selector} overflow regression: scrollWidth=${element.scrollWidth}, clientWidth=${element.clientWidth}, scrollHeight=${element.scrollHeight}, clientHeight=${element.clientHeight}`, - overflow: result - }); - } + const ok = isRootSelector ? !horizontalOverflow && !verticalOverflow : !horizontalOverflow || ["auto", "scroll"].includes(style.overflowX); + const result = { selector, exists: true, ok, horizontalOverflow, verticalOverflow, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: style.overflowX, overflowY: style.overflowY }; + if (!ok) failures.push({ failureType: "overflow", selector, viewport, summary: `${selector} overflow regression: scrollWidth=${element.scrollWidth}, clientWidth=${element.clientWidth}, scrollHeight=${element.scrollHeight}, clientHeight=${element.clientHeight}`, overflow: result }); return result; }); const overflowOk = overflowChecks.every((check) => check.ok); - const sidePanelScroll = await scrollableProbe("#panel-control"); - const sidePanelOverflowUsable = sidePanelScroll.exists && (sidePanelScroll.scrolls || sidePanelScroll.overflowY !== "hidden"); - const wiringTab = document.querySelector("#tab-wiring"); - if (wiringTab?.getAttribute("aria-selected") !== "true") { - wiringTab?.click(); + const rightSidebar = document.querySelector("#device-pod-sidebar"); + const summaryTiles = [...document.querySelectorAll("#device-pod-sidebar .summary-tile")]; + const detailDialog = document.querySelector("#device-detail-dialog"); + const podSummaryTile = document.querySelector('#device-pod-summary [data-device-detail="pod"]'); + const eventScroll = document.querySelector("#device-event-scroll"); + const eventText = document.querySelector("#device-event-text"); + let detailDialogOpened = false; + let detailDialogClosed = false; + if (podSummaryTile && detailDialog) { + podSummaryTile.click(); await new Promise((resolve) => requestAnimationFrame(resolve)); - } - const wiringPanel = document.querySelector("#panel-wiring"); - const wiringWrap = document.querySelector(".wiring-table-wrap"); - const wiringTable = document.querySelector(".wiring-long-table"); - const wiringHeaderCells = [...document.querySelectorAll(".wiring-table thead th")]; - const wiringHeaders = wiringHeaderCells.map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? ""); - const wiringDevicePair = [ - document.querySelector("#wiring-source-device")?.textContent?.trim() ?? "", - document.querySelector("#wiring-target-device")?.textContent?.trim() ?? "" - ]; - const wiringRows = [...document.querySelectorAll("#wiring-body tr")].map((row) => - [...row.querySelectorAll("td")].map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? "") - ); - const wiringIoPairs = [...document.querySelectorAll("#wiring-body tr")].map((row) => - [...row.querySelectorAll("td")].map((cell) => [...cell.querySelectorAll(".wiring-port")].map((port) => port.textContent?.trim() ?? "").join(" ")) - ); - const wiringCellGeometry = [...document.querySelectorAll(".wiring-long-table th, .wiring-long-table td, #wiring-summary")].map((element) => { - const box = element.getBoundingClientRect(); - const style = getComputedStyle(element); - return { - label: element.id ? `#${element.id}` : element.tagName.toLowerCase(), - text: element.textContent?.replace(/\s+/gu, " ").trim() ?? "", - box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }, - scrollWidth: element.scrollWidth, - clientWidth: element.clientWidth, - overflowX: style.overflowX, - wordBreak: style.wordBreak, - overflowWrap: style.overflowWrap - }; - }); - const wiringCellBoxes = wiringCellGeometry.map((item) => item.box).filter(Boolean); - const wiringTextContained = wiringCellGeometry.every((item) => item.scrollWidth <= item.clientWidth + 2); - const wiringTextNonOverlapping = wiringCellBoxes.every((box, index) => - wiringCellBoxes.every((other, otherIndex) => index === otherIndex || !boxesOverlap(box, other, -1)) - ); - const normalizedLegacyHeaders = wiringLegacyHeaders.map(normalizeHeaderText); - const wiringLegacyHeaderHits = wiringHeaders.filter((header) => normalizedLegacyHeaders.includes(normalizeHeaderText(header))); - const wiringMetadataColumnHits = wiringHeaderCells.filter((cell) => /状态|证据来源|轨迹\/证据|\btrace\b|\bevidence\b/iu.test(normalizeHeaderText(cell.textContent))).map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? ""); - const wiringSummaryText = document.querySelector("#wiring-summary")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""; - const wiringDetails = [...document.querySelectorAll(".wiring-detail")].map((detail) => detail.textContent?.replace(/\s+/gu, " ").trim() ?? ""); - const wiringMetadataInSummary = ["状态", "证据来源", "轨迹/证据"].every((term) => wiringSummaryText.includes(term)); - const wiringMetadataInDetails = - wiringDetails.some((detail) => detail.includes("状态")) && - wiringDetails.some((detail) => detail.includes("证据来源")) && - wiringDetails.some((detail) => detail.includes("轨迹/证据")); - const wiringHorizontalScroll = { - panelClientWidth: wiringPanel?.clientWidth ?? 0, - panelScrollWidth: wiringPanel?.scrollWidth ?? 0, - wrapClientWidth: wiringWrap?.clientWidth ?? 0, - wrapScrollWidth: wiringWrap?.scrollWidth ?? 0, - tableClientWidth: wiringTable?.clientWidth ?? 0, - tableScrollWidth: wiringTable?.scrollWidth ?? 0, - panelOverflowX: wiringPanel ? getComputedStyle(wiringPanel).overflowX : "missing", - wrapOverflowX: wiringWrap ? getComputedStyle(wiringWrap).overflowX : "missing" - }; - const wiringNoHorizontalScroll = - Boolean(wiringPanel && wiringWrap && wiringTable) && - wiringHorizontalScroll.panelScrollWidth <= wiringHorizontalScroll.panelClientWidth + 2 && - wiringHorizontalScroll.wrapScrollWidth <= wiringHorizontalScroll.wrapClientWidth + 2; - const wiringLongTableOk = - wiringWrap?.getAttribute("data-wiring-layout") === "two-column-long-table" && - wiringHeaders.length === 2 && - JSON.stringify(wiringDevicePair) === JSON.stringify(["res_boxsimu_1", "res_boxsimu_2"]) && - wiringLegacyHeaderHits.length === 0 && - wiringMetadataColumnHits.length === 0 && - wiringRows.some((row) => - row.length === 2 && - row[0].includes("DO1") && - row[0].includes("hwlab-patch-panel") && - row[1].includes("DI1") && - row[1].includes("证据来源") && - row[1].includes("轨迹/证据") - ) && - wiringIoPairs.some((row) => JSON.stringify(row) === JSON.stringify(["DO1", "DI1"])) && - wiringMetadataInSummary && - wiringMetadataInDetails; - if (!wiringLongTableOk) { - failures.push({ - failureType: "blocked/skip", - selector: "#panel-wiring .wiring-long-table", - viewport, - summary: "#276 wiring panel is not the required two-column long table with device headers and DO1/DI1 row details.", - wiring: { - headers: wiringHeaders, - devicePair: wiringDevicePair, - legacyHeaderHits: wiringLegacyHeaderHits, - metadataColumnHits: wiringMetadataColumnHits, - metadataInSummary: wiringMetadataInSummary, - metadataInDetails: wiringMetadataInDetails, - rows: wiringRows, - ioPairs: wiringIoPairs, - summaryText: wiringSummaryText, - details: wiringDetails, - layout: wiringWrap?.getAttribute("data-wiring-layout") ?? null - } - }); - } - if (!wiringNoHorizontalScroll) { - failures.push({ - failureType: "overflow", - selector: "#panel-wiring", - viewport, - summary: "#276 wiring panel exposes internal horizontal scroll or clipped table width.", - wiringHorizontalScroll - }); - } - if (!wiringTextContained || !wiringTextNonOverlapping) { - failures.push({ - failureType: wiringTextContained ? "overlap" : "overflow", - selector: "#panel-wiring .wiring-long-table", - viewport, - summary: "#276 wiring panel text is clipped or overlaps in the two-column table/summary.", - wiringCellGeometry, - wiringTextContained, - wiringTextNonOverlapping - }); - } - const controlTab = document.querySelector("#tab-control"); - if (controlTab?.getAttribute("aria-selected") !== "true") { - controlTab?.click(); + detailDialogOpened = detailDialog.open === true; + detailDialog.querySelector('button[value="close"]')?.click(); await new Promise((resolve) => requestAnimationFrame(resolve)); + detailDialogClosed = detailDialog.open === false; } - const removedSelectorStates = Object.fromEntries( - resourceExplorerGuard.domSelectors.map((selector) => [selector, document.querySelector(selector) === null]) - ); - const removedSelectorsAbsent = Object.values(removedSelectorStates).every(Boolean); const visibleWorkbenchText = visibleTextForElement(document.body); + const removedSelectorStates = Object.fromEntries(resourceExplorerGuard.domSelectors.map((selector) => [selector, document.querySelector(selector) === null])); + const legacyRightSelectorsAbsent = !rightSidebar?.querySelector("details,[data-hardware-tab],#hardware-list,#m3-control-form,#panel-wiring,#records-list"); + const removedSelectorsAbsent = Object.values(removedSelectorStates).every(Boolean); const removedVisibleCopyHits = resourceExplorerGuard.copyTerms.filter((term) => visibleWorkbenchText.includes(term)); const removedVisibleCopyAbsent = removedVisibleCopyHits.length === 0; const resourceExplorerRemovalGuard = removedSelectorsAbsent && removedVisibleCopyAbsent; - const keyTargetsReachable = blockedKeyTargets.length === 0; - if (!resourceExplorerRemovalGuard) { - failures.push({ - failureType: "blocked/skip", - selector: "#resource-explorer,#explorer-resize", - viewport, - summary: "Removed resource explorer DOM or legacy copy returned in the default workbench.", - removedSelectorStates, - removedVisibleCopyHits - }); - } - if (!rootScrollLocked) { - failures.push({ - failureType: "outer-scroll-regression", - selector: "html/body/.workbench-shell", - viewport, - summary: "Outer html/body/workbench shell scrolled or exceeded viewport after scroll attempt.", - rootAfterScrollAttempt, - html: boxForElement(document.documentElement), - body: boxForElement(document.body), - shell: boxes.shell - }); - } - if (!sidePanelOverflowUsable) { - failures.push({ - failureType: "overflow", - selector: "#panel-control", - viewport, - summary: "M3 control panel does not expose usable internal scrolling when needed.", - sidePanelScroll - }); - } - if (!rightWidthTarget) { - failures.push({ - failureType: "overflow", - selector: ".right-sidebar", - viewport, - summary: "M3 hardware status workspace width is outside the expected desktop, stacked desktop, or mobile boundary.", - rightPanelStacked, - rightBox: boxes.right - }); - } - const issue287HardwareTabsReady = ["总览", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"].every((label) => - Boolean([...document.querySelectorAll(".hardware-status button, .hardware-status [role='tab'], .hardware-status .side-tab")].find((item) => item.textContent?.includes(label))) - ); - const hardwareTabsCenterHit = keyTargets - .filter((target) => target.selector?.startsWith("[data-hardware-tab=")) - .every((target) => target.ok); - if (!issue287HardwareTabsReady) { - skip.push({ - failureType: "skip", - selector: ".hardware-status", - viewport, - summary: "#287 hardware status tabs are not visible in the current DOM; current container overflow is still covered." - }); - } - const pass = - rootScrollLocked && - noIncoherentOverlap && - semanticOverlapChecks.every((check) => !check.overlaps) && - overflowOk && - Object.values(noHorizontalOverflow).every(Boolean) && - rightWidthTarget && - keyTargetsReachable && - resourceExplorerRemovalGuard && - liveBuildLayout.ok && - sidePanelOverflowUsable; - const passWithWiring = - pass && - wiringLongTableOk && - wiringNoHorizontalScroll && - wiringTextContained && - wiringTextNonOverlapping && - hardwareTabsCenterHit; + const devicePod = { + summaryOk: Boolean(rightSidebar && summaryTiles.length >= 2 && summaryTiles.every((tile) => tile.getAttribute("role") === "button" && tile.tabIndex === 0)), + noInternalDisclosure: Boolean(rightSidebar && !rightSidebar.querySelector("details")), + noLegacySelectors: legacyRightSelectorsAbsent, + detailDialogOk: Boolean(detailDialog && detailDialogOpened && detailDialogClosed), + eventStreamOk: Boolean(eventScroll && eventText && getComputedStyle(eventScroll).overflowY !== "visible" && eventText.textContent?.trim()), + summaryTileCount: summaryTiles.length, + eventTextSample: eventText?.textContent?.replace(/\s+/gu, " ").trim().slice(0, 180) ?? "" + }; + devicePod.ok = devicePod.summaryOk && devicePod.noInternalDisclosure && devicePod.noLegacySelectors && devicePod.detailDialogOk && devicePod.eventStreamOk; + if (!resourceExplorerRemovalGuard) failures.push({ failureType: "blocked/skip", selector: "#resource-explorer,#explorer-resize", viewport, summary: "Removed resource explorer DOM or legacy copy returned in the default workbench.", removedSelectorStates, removedVisibleCopyHits }); + if (!rootScrollLocked) failures.push({ failureType: "outer-scroll-regression", selector: "html/body/.workbench-shell", viewport, summary: "Outer html/body/workbench shell scrolled or exceeded viewport after scroll attempt.", rootAfterScrollAttempt, html: boxForElement(document.documentElement), body: boxForElement(document.body), shell: boxes.shell }); + if (!rightWidthTarget) failures.push({ failureType: "overflow", selector: ".right-sidebar", viewport, summary: "Device Pod summary board width is outside the expected desktop, stacked desktop, or mobile boundary.", rightPanelStacked, rightBox: boxes.right }); + if (!devicePod.ok) failures.push({ failureType: "blocked/skip", selector: "#device-pod-sidebar", viewport, summary: "Device Pod sidebar must show summary tiles only, no internal disclosure, and open details in a dialog.", devicePod }); + const pass = rootScrollLocked && noIncoherentOverlap && semanticOverlapChecks.every((check) => !check.overlaps) && overflowOk && Object.values(noHorizontalOverflow).every(Boolean) && rightWidthTarget && blockedKeyTargets.length === 0 && resourceExplorerRemovalGuard && liveBuildLayout.ok && devicePod.ok; return { mode, viewport, - pass: passWithWiring, + pass, removedSelectorStates, removedSelectorsAbsent, removedVisibleCopyAbsent, @@ -8685,51 +7121,23 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { noHorizontalOverflow, horizontalOverflowFree: Object.values(noHorizontalOverflow).every(Boolean), liveBuildLayout, - hardwareTabRows, rightWidthTarget, noIncoherentOverlap, - keyTargetsReachable, + keyTargetsReachable: blockedKeyTargets.length === 0, blockedKeyTargets, rightResizeHandle, - sidePanelScroll, - sidePanelOverflowUsable, + devicePod, + devicePodSummaryBoardOk: devicePod.summaryOk && devicePod.noInternalDisclosure && devicePod.noLegacySelectors, + devicePodEventStreamOk: devicePod.eventStreamOk, + devicePodDetailDialogOk: devicePod.detailDialogOk, liveBuildOverlayStable: liveBuildLayout.ok, - hardwareTabsCenterHit, - wiring: { - longTableOk: wiringLongTableOk, - noHorizontalScroll: wiringNoHorizontalScroll, - textContained: wiringTextContained, - textNonOverlapping: wiringTextNonOverlapping, - headers: wiringHeaders, - devicePair: wiringDevicePair, - legacyHeaderHits: wiringLegacyHeaderHits, - metadataColumnHits: wiringMetadataColumnHits, - metadataInSummary: wiringMetadataInSummary, - metadataInDetails: wiringMetadataInDetails, - rows: wiringRows, - ioPairs: wiringIoPairs, - summaryText: wiringSummaryText, - details: wiringDetails, - horizontalScroll: wiringHorizontalScroll, - cellGeometry: wiringCellGeometry - }, - wiringReadableTwoColumn: wiringLongTableOk && wiringNoHorizontalScroll && wiringTextContained && wiringTextNonOverlapping, semanticOverlapChecks, overflowChecks, - issue287HardwareTabsReady, issue288SingleTableReady: false, - skip, + skip: [], failures }; - }, { - mode, - viewport, - compareTo, - resourceExplorerGuard: { - domSelectors: [...removedResourceExplorerDomSelectors], - copyTerms: [...removedResourceExplorerCopyTerms] - } - }); + }, { mode, viewport, compareTo, resourceExplorerGuard: { domSelectors: [...removedResourceExplorerDomSelectors], copyTerms: [...removedResourceExplorerCopyTerms] } }); } async function inspectGateLayoutState(page, { mode, viewport }) { @@ -8937,12 +7345,16 @@ async function inspectGateLayoutState(page, { mode, viewport }) { }, { mode, viewport }); } + async function captureLayoutScreenshots(page, artifactRoot, viewport, stateId) { if (!artifactRoot) return []; return [ - await captureElementScreenshot(page, ".right-sidebar", artifactRoot, viewport, `${stateId}-right-sidebar`), - await captureElementScreenshot(page, "#m3-control-form", artifactRoot, viewport, `${stateId}-m3-control-form`), - await captureElementScreenshot(page, "#command-form", artifactRoot, viewport, `${stateId}-code-agent-input`) + await captureElementScreenshot(page, "[data-app-shell]", artifactRoot, viewport, `${stateId}-shell`), + await captureElementScreenshot(page, ".center-workspace", artifactRoot, viewport, `${stateId}-center-workspace`), + await captureElementScreenshot(page, "#command-form", artifactRoot, viewport, `${stateId}-command-form`), + await captureElementScreenshot(page, "#device-pod-sidebar", artifactRoot, viewport, `${stateId}-device-pod-sidebar`), + await captureElementScreenshot(page, "#device-pod-summary", artifactRoot, viewport, `${stateId}-device-pod-summary`), + await captureElementScreenshot(page, "#device-event-scroll", artifactRoot, viewport, `${stateId}-device-event-stream`) ].filter(Boolean); } @@ -9101,9 +7513,9 @@ function summarizeLeftSidebarCollapseCoverage(viewportResults) { function summarizeCoverageGap(viewportResults, issueKey) { const fieldName = { issue288: "issue288SingleTableReady", - issue287: "issue287HardwareTabsReady", - hardwareTabs: "hardwareTabsCenterHit", - wiring: "wiringReadableTwoColumn", + devicePodSummary: "devicePodSummaryBoardOk", + devicePodEvents: "devicePodEventStreamOk", + devicePodDialog: "devicePodDetailDialogOk", liveBuildOverlay: "liveBuildOverlayStable" }[issueKey] ?? "issue287HardwareTabsReady"; return { diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index d1efbe94..913b4519 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -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/"], diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 7af134e4..f35c7847 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -1,4 +1,3 @@ -import { gateSummary } from "./gate-summary.mjs"; import { ensureWorkbenchAuth, initWorkbenchLogout } from "./auth.mjs"; import { codeAgentRuntimePathFromMessage, @@ -10,15 +9,9 @@ import { classifyCodeAgentStatusSummary, codeAgentAvailabilityFromLive } from ". import { classifyWorkbenchLiveStatus } from "./live-status.mjs"; import { marked } from "./third_party/marked/marked.esm.js"; import { hardenRenderedMarkdown, renderMessageMarkdown } from "./message-markdown.mjs"; -import { wiringPresentationFromSummary } from "./wiring-status.mjs"; -import { - extractCodeAgentM3Evidence, - isCodeAgentM3SkillCompletion, - m3EvidenceRows, - m3EvidenceSummaryText -} from "./code-agent-m3-evidence.mjs"; const DEFAULT_API_TIMEOUT_MS = 4500; +const WORKBENCH_PROJECT_ID = "prj_device_pod_workbench"; const DEFAULT_LIVE_SURFACE_TIMEOUT_MS = 12000; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1800000; const MAX_CODE_AGENT_TIMEOUT_MS = 2400000; @@ -48,9 +41,7 @@ const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs" const CODE_AGENT_CANCEL_TIMEOUT_MS = resolveTimeoutMs("codeAgentCancelTimeoutMs", DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS, { min: 5000, max: 120000 }); const rpcReadMethods = Object.freeze([ "system.health", - "cloud.adapter.describe", - "audit.event.query", - "evidence.record.query" + "cloud.adapter.describe" ]); const STATUS_LABELS = Object.freeze({ @@ -85,17 +76,7 @@ const STATUS_LABELS = Object.freeze({ "dev-live": "实况已验证" }); -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"]); const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["codex-stdio"]); const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-stdio"]); const CODEX_APP_SERVER_RUNNER_KIND = "codex-app-server-stdio-runner"; @@ -160,26 +141,21 @@ const el = { gatewayShellTimeout: byId("gateway-shell-timeout"), commandSend: byId("command-send"), commandClear: byId("command-clear"), - hardwareSourceStatus: byId("hardware-source-status"), - hardwareList: byId("hardware-list"), - controlList: byId("control-list"), - wiringBody: byId("wiring-body"), - recordsList: byId("records-list"), - m3ControlForm: byId("m3-control-form"), - m3ControlStatus: byId("m3-control-status"), - m3FlowState: byId("m3-flow-state"), - m3ActionSummary: byId("m3-action-summary"), - m3Do1Target: byId("m3-do1-target"), - m3Di1Observed: byId("m3-di1-observed"), - m3TrustSummary: byId("m3-trust-summary"), - m3TraceSummary: byId("m3-trace-summary"), - m3RecordsJump: byId("m3-records-jump"), - m3GatewaySelect: byId("m3-gateway-select"), - m3BoxSelect: byId("m3-box-select"), - m3PortSelect: byId("m3-port-select"), - m3ValueSelect: byId("m3-value-select"), - m3WriteDo: byId("m3-write-do"), - m3ReadDi: byId("m3-read-di"), + devicePodSelect: byId("device-pod-select"), + devicePodStatusTag: byId("device-pod-status-tag"), + devicePodSummary: byId("device-pod-summary"), + devicePodId: byId("device-pod-id"), + devicePodTarget: byId("device-pod-target"), + devicePodProfile: byId("device-pod-profile"), + devicePodFreshness: byId("device-pod-freshness"), + devicePodInterfaces: byId("device-pod-interfaces"), + deviceEventFollow: byId("device-event-follow"), + deviceEventJump: byId("device-event-jump"), + deviceEventScroll: byId("device-event-scroll"), + deviceEventText: byId("device-event-text"), + deviceDetailDialog: byId("device-detail-dialog"), + deviceDetailTitle: byId("device-detail-title"), + deviceDetailBody: byId("device-detail-body"), helpContent: byId("help-content"), helpStatus: byId("help-status") }; @@ -221,12 +197,19 @@ const state = { error: null, loadedAt: null }, - m3Control: { - contract: null, + devicePod: { + selectedDevicePodId: "device-pod-71-freq", + list: null, status: null, - hardwareTab: "overview", - operation: null, - pending: false + events: null, + chipId: null, + uart: null, + uartTail: null, + followEvents: true, + unreadEvents: 0, + lastEventLineCount: 0, + eventScrollUserActiveUntil: 0, + details: new Map() }, layout: { leftSidebarCollapsed: false, @@ -246,8 +229,7 @@ initLayoutSizing(); initLeftSidebarToggle(); initLeftSidebarResize(); initRightSidebarResize(); -initSideTabs(); -initHardwareTabs(); +initDevicePodPanel(); initQuickActions(); initCodeAgentTimeoutControl(); initConversationScrollMemory(); @@ -255,7 +237,6 @@ initCommandBar(); initLiveBuildOverlay(); initWorkbenchLogout(el.logoutButton); el.logoutButton.addEventListener("click", clearCodeAgentSessionState); -initM3Control(); initGateControls(); installWorkbenchTestHooks(); renderStaticWorkbench(); @@ -488,7 +469,6 @@ function storedChatMessage(message) { if (stored.runnerTrace && typeof stored.runnerTrace === "object") { stored.runnerTrace = storedRunnerTrace(stored.runnerTrace); } - delete stored.m3Evidence; delete stored.availability; return stored; } @@ -754,7 +734,7 @@ function syncRightSidebarResizeA11y() { el.rightSidebarResize.setAttribute("aria-valuemin", String(bounds.min)); el.rightSidebarResize.setAttribute("aria-valuemax", String(bounds.max)); el.rightSidebarResize.setAttribute("aria-valuenow", String(state.layout.rightSidebarWidth)); - el.rightSidebarResize.setAttribute("aria-valuetext", `右侧硬件状态栏宽度 ${state.layout.rightSidebarWidth} 像素`); + el.rightSidebarResize.setAttribute("aria-valuetext", `右侧 Device Pod 看板宽度 ${state.layout.rightSidebarWidth} 像素`); } function syncLeftSidebarResizeA11y() { @@ -931,12 +911,45 @@ async function loadHelpSurface() { } } -function initSideTabs() { - for (const tab of document.querySelectorAll("[data-side-tab]")) { - tab.addEventListener("click", () => selectSideTab(tab.dataset.sideTab)); +function initDevicePodPanel() { + el.devicePodSelect.addEventListener("change", () => { + state.devicePod.selectedDevicePodId = el.devicePodSelect.value || "device-pod-71-freq"; + loadLiveSurface().then(renderLiveSurface); + }); + el.deviceEventFollow.addEventListener("click", () => { + setDeviceEventFollow(!state.devicePod.followEvents, { scroll: true }); + }); + el.deviceEventJump.addEventListener("click", () => { + state.devicePod.unreadEvents = 0; + setDeviceEventFollow(true); + renderDeviceEventUnreadHint(); + scrollDeviceEventsToBottom(); + }); + for (const eventName of ["wheel", "touchstart", "pointerdown"]) { + el.deviceEventScroll.addEventListener(eventName, markDeviceEventScrollIntent, { passive: true }); } - el.m3RecordsJump.addEventListener("click", () => { - selectSideTab("records"); + el.deviceEventScroll.addEventListener("keydown", (event) => { + if (isScrollIntentKey(event.key)) markDeviceEventScrollIntent(); + }); + el.deviceEventScroll.addEventListener("scroll", () => { + if (!deviceEventNearBottom()) setDeviceEventFollow(false); + }, { passive: true }); + el.devicePodSummary.addEventListener("click", (event) => { + const tile = event.target.closest("[data-device-detail]"); + if (!tile) return; + showDeviceDetail(tile.dataset.deviceTitle || "Pod Summary", state.devicePod.details.get("pod")); + }); + el.devicePodSummary.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + const tile = event.target.closest("[data-device-detail]"); + if (!tile) return; + event.preventDefault(); + showDeviceDetail(tile.dataset.deviceTitle || "Pod Summary", state.devicePod.details.get("pod")); + }); + el.devicePodInterfaces.addEventListener("click", (event) => { + const tile = event.target.closest("[data-device-detail]"); + if (!tile) return; + showDeviceDetail(tile.dataset.deviceTitle || tile.dataset.deviceDetail || "Device Pod", state.devicePod.details.get(tile.dataset.deviceDetail)); }); } @@ -956,38 +969,6 @@ function fillAgentQuickPrompt(button) { el.commandInput.setSelectionRange(prompt.length, prompt.length); } -function selectSideTab(tabId) { - for (const tab of document.querySelectorAll("[data-side-tab]")) { - const active = tab.dataset.sideTab === tabId; - tab.classList.toggle("active", active); - tab.setAttribute("aria-selected", active ? "true" : "false"); - } - for (const panel of document.querySelectorAll("[data-side-panel]")) { - const active = panel.dataset.sidePanel === tabId; - panel.classList.toggle("active", active); - panel.hidden = !active; - } -} - -function initHardwareTabs() { - for (const tab of document.querySelectorAll("[data-hardware-tab]")) { - tab.addEventListener("click", () => { - state.m3Control.hardwareTab = tab.dataset.hardwareTab; - syncHardwareTabs(); - renderHardwareStatus(state.m3Control.status); - }); - } - syncHardwareTabs(); -} - -function syncHardwareTabs() { - for (const tab of document.querySelectorAll("[data-hardware-tab]")) { - const active = tab.dataset.hardwareTab === state.m3Control.hardwareTab; - tab.classList.toggle("active", active); - tab.setAttribute("aria-selected", active ? "true" : "false"); - } -} - function initCommandBar() { el.commandForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -1020,7 +1001,7 @@ function initCommandBar() { renderCodeAgentSummary(); renderConversation(); renderDrafts(); - renderRecords(state.liveSurface); + renderDevicePodPanel(state.liveSurface); }); } @@ -1086,7 +1067,7 @@ async function submitAgentMessage(value, options = {}) { renderCodeAgentSummary(); renderConversation(); renderDrafts(); - renderRecords(state.liveSurface); + renderDevicePodPanel(state.liveSurface); const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id); @@ -1161,7 +1142,7 @@ async function submitAgentMessage(value, options = {}) { renderCodeAgentSummary(); renderConversation(); renderDrafts(); - renderRecords(state.liveSurface); + renderDevicePodPanel(state.liveSurface); } } @@ -1197,18 +1178,17 @@ function applyCodeAgentResultToMessage(messageId, result, options = {}) { const continuityBlocker = codeAgentContinuityBlocker(result, sessionContinuity); const blockedError = resultBlockedError ?? continuityBlocker; const completion = classifyCodeAgentCompletion(result, { blockedError }); - const m3Presentation = m3IoPresentation(result); const status = completion.status; state.chatMessages[index] = { ...current, - title: m3Presentation?.title ?? completion.title, - text: m3Presentation?.text ?? (completion.replied + title: completion.title, + text: completion.replied ? result.reply?.content || "Code Agent 没有返回文本。" : blockedError ? failureMessage({ ...result, error: blockedError }) : result.status === "completed" ? untrustedCompletionMessage(result) - : failureMessage(result)), + : failureMessage(result), status, traceId: result.traceId || current.traceId, conversationId: result.conversationId || result.sessionId || state.conversationId, @@ -1237,8 +1217,6 @@ function applyCodeAgentResultToMessage(messageId, result, options = {}) { runnerTrace: result.runnerTrace ?? current.runnerTrace, conversationFacts: result.conversationFacts, responseType: result.responseType, - m3Io: result.m3Io, - m3Evidence: extractCodeAgentM3Evidence(result), capabilityLevel: result.capabilityLevel, sourceKind: completion.sourceKind, providerTrace: result.providerTrace, @@ -1399,7 +1377,6 @@ function codeAgentContinuityBlocker(result, continuity) { result.status !== "completed" || isSourceFixtureCompletion(result) || isTextFallbackChatResult(result) || - isCodeAgentM3SkillCompletion(result) || !isTrustedCodeAgentProvider(result.provider) ) { return null; @@ -1437,15 +1414,15 @@ function hasProviderTrace(value) { } function requiresCodexProviderTrace(value) { - return isTrustedCodeAgentProvider(value?.provider) && !isSourceFixtureCompletion(value) && !isCodeAgentM3SkillCompletion(value); + return isTrustedCodeAgentProvider(value?.provider) && !isSourceFixtureCompletion(value); } function requiresCodexSessionEvidence(value) { - return isTrustedCodeAgentProvider(value?.provider) && !isCodeAgentM3SkillCompletion(value); + return isTrustedCodeAgentProvider(value?.provider); } function requiresCodexThreadEvidence(value) { - return isCodexStdioLongLivedShape(value) && !isCodeAgentM3SkillCompletion(value); + return isCodexStdioLongLivedShape(value); } function isCodexStdioLongLivedShape(value) { @@ -1603,7 +1580,7 @@ function updateMessageTrace(messageId, snapshot, options = {}) { if (options.quiet !== true) { renderCodeAgentSummary(); patchMessageTracePanel(nextMessage); - renderRecords(state.liveSurface); + renderDevicePodPanel(state.liveSurface); } } @@ -1673,15 +1650,6 @@ function runnerTraceFromSnapshot(snapshot, previous = null) { }; } -function initM3Control() { - el.m3ControlForm.addEventListener("submit", async (event) => { - event.preventDefault(); - await runM3IoAction("do.write"); - }); - el.m3ReadDi.addEventListener("click", async () => { - await runM3IoAction("di.read"); - }); -} function initGateControls() { el.gateStatusFilter.addEventListener("change", renderGateTable); @@ -1692,22 +1660,25 @@ function initGateControls() { } function renderStaticWorkbench() { - el.routePath.textContent = "当前浏览器会话只保存任务草稿,硬件动作需通过受控后端流程。"; + el.routePath.textContent = "当前浏览器会话只保存任务草稿,Device Pod 动作需通过受控后端流程。"; renderConversation(); - renderHardwareStatus(null); renderDrafts(); - renderM3ControlStatus(); - renderWiringList(null); - renderRecords(null); + renderDevicePodPanel(null); renderGateTable(); renderCodeAgentSummary(); } +function renderDrafts() { + el.routePath.textContent = state.chatPending + ? "Code Agent 请求进行中;输入和 trace 会保留,Device Pod 仍只显示 summary。" + : "当前浏览器会话只保存任务草稿,Device Pod 动作需通过受控后端流程。"; +} + function renderProbePending() { el.liveStatus.textContent = "等待验证"; el.liveStatus.className = "tone-pending"; - el.liveDetail.textContent = "正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。"; + el.liveDetail.textContent = "正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/device-pods;未完成前不标为通过。"; renderLiveBuilds(null); renderCodeAgentSummary(); } @@ -1734,7 +1705,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI providerProfile: CODE_AGENT_PROVIDER_PROFILE, gatewayShellTimeoutMs: GATEWAY_SHELL_TIMEOUT_MS, shortConnection: true, - projectId: gateSummary.topology.projectId + projectId: WORKBENCH_PROJECT_ID }) }); @@ -1879,7 +1850,7 @@ async function reconcileCodeAgentResult(messageId, options = {}) { renderCodeAgentSummary(); renderConversation(); renderDrafts(); - renderRecords(state.liveSurface); + renderDevicePodPanel(state.liveSurface); return true; } finally { state.resultReconciliationInFlight.delete(message.traceId); @@ -1891,17 +1862,20 @@ function wait(ms) { } async function loadLiveSurface() { - const projectId = gateSummary.topology.projectId; - const [healthLive, liveBuilds, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([ + const selectedDevicePodId = state.devicePod.selectedDevicePodId || "device-pod-71-freq"; + const encodedPodId = encodeURIComponent(selectedDevicePodId); + const [healthLive, liveBuilds, restIndex, devicePods, devicePodStatus, devicePodEvents, devicePodChipId, devicePodUart, devicePodUartTail, health, adapter] = await Promise.all([ liveSurfaceFetch("/health/live"), liveSurfaceFetch("/v1/live-builds"), liveSurfaceFetch("/v1"), - liveSurfaceFetch("/v1/m3/io"), - liveSurfaceFetch("/v1/m3/status"), + liveSurfaceFetch("/v1/device-pods"), + liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/status`), + liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/events?limit=120`), + liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/debug-probe/chip-id`), + liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/io-probe/uart/1`), + liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/io-probe/uart/1/tail?maxBytes=12000`), liveSurfaceRpc("system.health"), - liveSurfaceRpc("cloud.adapter.describe"), - liveSurfaceRpc("audit.event.query", { projectId, limit: 6 }), - liveSurfaceRpc("evidence.record.query", { projectId, limit: 6 }) + liveSurfaceRpc("cloud.adapter.describe") ]); return { @@ -1909,12 +1883,14 @@ async function loadLiveSurface() { healthLive, liveBuilds, restIndex, - m3Control, - m3Status, + devicePods, + devicePodStatus, + devicePodEvents, + devicePodChipId, + devicePodUart, + devicePodUartTail, health, - adapter, - audit, - evidence + adapter }; } @@ -2093,102 +2069,6 @@ async function callRpc(method, params = {}, options = {}) { }; } -async function runM3IoAction(action) { - if (state.m3Control.pending) return; - const traceId = nextProtocolId("trc"); - const body = action === "do.write" - ? { - action, - gatewayId: el.m3GatewaySelect.value, - resourceId: el.m3BoxSelect.value, - boxId: "boxsimu_1", - port: el.m3PortSelect.value, - value: el.m3ValueSelect.value === "true", - projectId: gateSummary.topology.projectId, - traceId - } - : { - action, - gatewayId: "gwsimu_2", - resourceId: M3_TRUSTED_ROUTE.toResourceId, - boxId: "boxsimu_2", - port: M3_TRUSTED_ROUTE.toPort, - projectId: gateSummary.topology.projectId, - traceId - }; - if (!m3ControlCanOperate()) { - state.m3Control.operation = { - status: "blocked", - action, - traceId, - command: body, - blocker: { - code: state.m3Control.contract?.readiness?.blocker?.code ?? "m3_control_readiness_blocked", - zh: m3ControlBlockedReason() - }, - observedAt: new Date().toISOString(), - sourceKind: "BLOCKED" - }; - renderM3ControlStatus(); - renderRecords(state.liveSurface); - renderDrafts(); - return; - } - state.m3Control.pending = true; - state.m3Control.operation = { - status: "running", - action, - traceId, - command: body, - observedAt: new Date().toISOString() - }; - renderM3ControlStatus(); - renderDrafts(); - renderRecords(state.liveSurface); - - const response = await fetchJson("/v1/m3/io", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Trace-Id": traceId, - "X-Actor-Id": "usr_hwlab_cloud_web" - }, - timeoutMs: LIVE_SURFACE_TIMEOUT_MS, - timeoutName: `工作台 M3 IO ${action}`, - body: JSON.stringify(body) - }); - - if (!response.ok) { - state.m3Control.operation = { - status: "blocked", - action, - traceId, - blocker: { - zh: response.error || "M3 IO 控制请求失败" - }, - observedAt: new Date().toISOString(), - sourceKind: "BLOCKED" - }; - } else { - state.m3Control.operation = { - ...response.data, - sourceKind: response.data?.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED" - }; - } - const refreshedStatus = await fetchJson("/v1/m3/status", { - timeoutMs: LIVE_SURFACE_TIMEOUT_MS, - timeoutName: "工作台 M3 状态刷新" - }); - if (refreshedStatus.ok) { - state.m3Control.status = refreshedStatus.data; - } - state.m3Control.pending = false; - renderM3ControlStatus(); - renderHardwareStatus(state.m3Control.status); - renderWiringList(state.m3Control.status); - renderRecords(state.liveSurface); - renderDrafts(); -} function nextProtocolId(prefix) { rpcSequence += 1; @@ -2197,8 +2077,6 @@ function nextProtocolId(prefix) { function renderLiveSurface(live) { state.liveSurface = live; - state.m3Control.contract = live.m3Control?.ok ? live.m3Control.data : null; - state.m3Control.status = live.m3Status?.ok ? live.m3Status.data : null; const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]; const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes); const runtimeSummary = runtimeSummaryFrom(live); @@ -2211,10 +2089,7 @@ function renderLiveSurface(live) { renderLiveBuilds(live.liveBuilds); renderAgentChatStatus(deriveAgentChatStatus()); renderCodeAgentSummary(); - renderHardwareStatus(state.m3Control.status); - renderWiringList(state.m3Control.status ?? runtimeSummary); - renderRecords(live); - renderM3ControlStatus(); + renderDevicePodPanel(live); renderConversation(); renderDrafts(); } @@ -2350,6 +2225,174 @@ function liveBuildDesiredStateText(service) { return parts.length ? `desired ${parts.join(" / ")}` : ""; } +function renderDevicePodPanel(live) { + state.devicePod.list = live?.devicePods ?? null; + state.devicePod.status = live?.devicePodStatus ?? null; + state.devicePod.events = live?.devicePodEvents ?? null; + state.devicePod.chipId = live?.devicePodChipId ?? null; + state.devicePod.uart = live?.devicePodUart ?? null; + state.devicePod.uartTail = live?.devicePodUartTail ?? null; + renderDevicePodSelector(); + renderDevicePodSummary(); + renderDevicePodInterfaces(); + renderDeviceEventStream(); +} + +function renderDevicePodSelector() { + const payload = state.devicePod.list?.ok ? state.devicePod.list.data : null; + const pods = Array.isArray(payload?.devicePods) && payload.devicePods.length > 0 + ? payload.devicePods + : [{ devicePodId: state.devicePod.selectedDevicePodId || "device-pod-71-freq" }]; + const current = state.devicePod.selectedDevicePodId || pods[0]?.devicePodId || "device-pod-71-freq"; + const existing = [...el.devicePodSelect.options].map((option) => option.value).join("\n"); + const next = pods.map((pod) => pod.devicePodId).join("\n"); + if (existing !== next) { + replaceChildren(el.devicePodSelect, ...pods.map((pod) => { + const option = document.createElement("option"); + option.value = pod.devicePodId; + option.textContent = pod.devicePodId; + return option; + })); + } + el.devicePodSelect.value = current; +} + +function renderDevicePodSummary() { + const response = state.devicePod.status; + const payload = response?.ok ? response.data : null; + const pod = payload?.devicePod ?? null; + const summary = payload?.summary ?? null; + const status = pod?.status ?? summary?.status ?? (response?.ok ? "ok" : "blocked"); + const tone = response?.ok ? toneForDeviceStatus(status, summary?.blocker ?? pod?.blocker) : "blocked"; + el.devicePodStatusTag.textContent = response?.ok ? statusLabel(status) : "未接入"; + el.devicePodStatusTag.className = `state-tag tone-${toneClass(tone)}`; + el.devicePodId.textContent = pod?.devicePodId ?? state.devicePod.selectedDevicePodId ?? "device-pod-71-freq"; + el.devicePodTarget.textContent = pod?.target?.targetId ?? summary?.targetId ?? "未观测"; + el.devicePodProfile.textContent = shortHash(pod?.profile?.profileHash ?? summary?.profileHash ?? "未观测"); + el.devicePodFreshness.textContent = freshnessLabel(pod?.freshness ?? summary?.freshness); + state.devicePod.details.set("pod", payload ?? response ?? { status: "waiting" }); +} + +function renderDevicePodInterfaces() { + const pod = state.devicePod.status?.ok ? state.devicePod.status.data?.devicePod : null; + const chip = state.devicePod.chipId?.ok ? state.devicePod.chipId.data : null; + const uart = state.devicePod.uart?.ok ? state.devicePod.uart.data : null; + const tail = state.devicePod.uartTail?.ok ? state.devicePod.uartTail.data : null; + const tiles = [ + { key: "target", title: "Target", value: pod?.target?.targetId ?? "未观测", detail: pod?.target?.lock?.status ? `lock=${pod.target.lock.status}` : "等待 Device Pod status", tone: pod ? "ok" : "pending", raw: pod?.target ?? null }, + { key: "workspace", title: "Workspace", value: pod?.projectWorkspace?.latestBuild?.artifact ?? "未观测", detail: pod?.projectWorkspace?.workspaceRoot ?? "等待 workspace 摘要", tone: pod?.projectWorkspace?.status ?? "pending", raw: pod?.projectWorkspace ?? null }, + { key: "debug", title: "Debug", value: chip?.chipId ?? pod?.debugInterface?.chipId ?? "未观测", detail: chip?.probe ?? pod?.debugInterface?.probe ?? "等待 probe 摘要", tone: chip?.status ?? pod?.debugInterface?.status ?? "pending", raw: chip ?? pod?.debugInterface ?? null }, + { key: "io", title: "IO", value: uart?.uart?.status ?? pod?.ioInterface?.uart1?.status ?? "未观测", detail: tail?.text ? compactOneLine(tail.text, 92) : "等待 UART1 tail", tone: uart?.status ?? pod?.ioInterface?.status ?? "pending", raw: { uart, tail, ioInterface: pod?.ioInterface ?? null } } + ]; + state.devicePod.details.clear(); + state.devicePod.details.set("pod", state.devicePod.status?.data ?? state.devicePod.status ?? null); + replaceChildren(el.devicePodInterfaces, ...tiles.map(deviceSummaryTile)); +} + +function deviceSummaryTile(tile) { + state.devicePod.details.set(tile.key, tile.raw ?? tile); + const article = document.createElement("article"); + article.className = `summary-tile tone-border-${toneClass(tile.tone)}`; + article.tabIndex = 0; + article.dataset.deviceDetail = tile.key; + article.dataset.deviceTitle = tile.title; + article.setAttribute("role", "button"); + article.setAttribute("aria-label", `查看 ${tile.title} 详情`); + article.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + showDeviceDetail(tile.title, state.devicePod.details.get(tile.key)); + }); + article.append(textSpan(tile.title, "summary-title")); + article.append(textSpan(tile.value, "summary-value mono wrap")); + article.append(textSpan(tile.detail, "summary-detail")); + return article; +} + +function renderDeviceEventStream() { + const payload = state.devicePod.events?.ok ? state.devicePod.events.data : null; + const lines = Array.isArray(payload?.lines) ? payload.lines : []; + const nextText = lines.length > 0 ? lines.join("\n") : "等待 /v1/device-pods 事件流。"; + const wasNearBottom = deviceEventNearBottom(); + const previousLineCount = state.devicePod.lastEventLineCount; + const nextLineCount = lines.length; + if (el.deviceEventText.textContent !== nextText) { + el.deviceEventText.textContent = nextText; + if (state.devicePod.followEvents && wasNearBottom && !deviceEventUserActive()) { + scrollDeviceEventsToBottom(); + } else if (nextLineCount > previousLineCount) { + state.devicePod.unreadEvents += nextLineCount - previousLineCount; + } + } + state.devicePod.lastEventLineCount = nextLineCount; + renderDeviceEventUnreadHint(); +} + +function markDeviceEventScrollIntent() { + state.devicePod.eventScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS; +} + +function deviceEventUserActive() { + return Date.now() < state.devicePod.eventScrollUserActiveUntil; +} + +function setDeviceEventFollow(enabled, options = {}) { + state.devicePod.followEvents = enabled === true; + el.deviceEventFollow.setAttribute("aria-pressed", state.devicePod.followEvents ? "true" : "false"); + el.deviceEventFollow.textContent = state.devicePod.followEvents ? "跟随" : "暂停跟随"; + if (state.devicePod.followEvents && options.scroll === true) scrollDeviceEventsToBottom(); +} + +function deviceEventNearBottom() { + return scrollBottomGap(el.deviceEventScroll) < 24; +} + +function scrollDeviceEventsToBottom() { + el.deviceEventScroll.scrollTop = el.deviceEventScroll.scrollHeight; +} + +function renderDeviceEventUnreadHint() { + const count = state.devicePod.unreadEvents; + el.deviceEventJump.hidden = count <= 0; + el.deviceEventJump.textContent = `有 ${count} 条新事件`; +} + +function showDeviceDetail(title, payload) { + el.deviceDetailTitle.textContent = title || "Device Pod 详情"; + el.deviceDetailBody.textContent = JSON.stringify(payload ?? { status: "empty" }, null, 2); + if (typeof el.deviceDetailDialog.showModal === "function") { + el.deviceDetailDialog.showModal(); + } else { + el.deviceDetailDialog.setAttribute("open", ""); + } +} + +function toneForDeviceStatus(status, blocker = null) { + if (blocker) return "blocked"; + const value = String(status ?? "").toLowerCase(); + if (["ok", "ready", "live", "pass", "healthy"].includes(value)) return "ok"; + if (["blocked", "failed", "error"].includes(value)) return "blocked"; + return "pending"; +} + +function freshnessLabel(freshness) { + if (!freshness) return "未观测"; + const ageMs = Number(freshness.ageMs); + const age = Number.isFinite(ageMs) ? `${Math.round(ageMs / 1000)}s` : "未知"; + return `${freshness.stale ? "过期" : "新鲜"} / age=${age}`; +} + +function shortHash(value) { + const text = String(value ?? "").trim(); + if (!text) return "未观测"; + return text.length > 26 ? `${text.slice(0, 23)}...` : text; +} + +function compactOneLine(value, maxLength = 120) { + const text = String(value ?? "").replace(/\s+/gu, " ").trim(); + return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; +} + function formatBeijingTime(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) return "构建时间不可用"; @@ -2394,7 +2437,7 @@ function renderConversation() { { role: "system", title: "界面模式", - text: "这里是用户工作台:可以整理任务、查看资源、核对接线和可信记录。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。", + text: "这里是用户工作台:可以整理任务、查看 Device Pod summary 和纯文本事件流。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。", status: "source" }, codeAgentStatusMessage(state.codeAgentAvailability) @@ -2610,986 +2653,6 @@ function cssEscape(value) { return String(value).replace(/["\\]/gu, "\\$&"); } -function renderHardwareStatus(m3Status) { - syncHardwareTabs(); - const status = m3Status ?? sourceFallbackM3Status(); - const sourceKind = status.sourceKind ?? "UNVERIFIED"; - const trustedGreen = status.trust?.durableStatus === "green"; - const statusTone = status.status === "live" && trustedGreen - ? "dev-live" - : status.status === "error" || status.status === "blocked" - ? "blocked" - : sourceKind === "DEV-LIVE" - ? "dev-live" - : "source"; - - el.hardwareSourceStatus.textContent = hardwareStatusLabel(status); - el.hardwareSourceStatus.className = `state-tag tone-${toneClass(statusTone)}`; - - const groups = { - overview: hardwareOverviewGroups(status), - gateways: hardwareGatewayGroups(status), - box1: hardwareBoxGroups(status, M3_TRUSTED_ROUTE.fromResourceId), - box2: hardwareBoxGroups(status, M3_TRUSTED_ROUTE.toResourceId), - patch: hardwarePatchPanelGroups(status) - }; - // Tab bodies are compact Key/Value groups; SOURCE fallback stays unverified. - replaceChildren(el.hardwareList, ...(groups[state.m3Control.hardwareTab] ?? groups.overview)); -} - -function hardwareGroup(group) { - const article = document.createElement("article"); - article.className = "hardware-section"; - article.append(textSpan(group.title, "hardware-section-title")); - const rows = document.createElement("div"); - rows.className = "hardware-rows"; - rows.append(...group.rows.map(hardwareRow)); - article.append(rows); - return article; -} - -function hardwareRow(item) { - const row = document.createElement("div"); - row.className = `hardware-row tone-border-${toneClass(item.tone)}`; - row.dataset.sourceKind = item.sourceKind; - const head = document.createElement("div"); - head.className = "hardware-row-head"; - head.append(textSpan(item.label, "hardware-row-label"), badge(item.badgeLabel ?? hardwareEvidenceLabel(item.sourceKind), item.sourceKind)); - row.append(head); - row.append(textSpan(item.value, `hardware-row-value tone-${toneClass(item.tone)}`)); - row.append(textSpan(item.detail, "hardware-row-detail")); - return row; -} - -function hardwareStatusLabel(status) { - if (!status) return "未验证"; - if (status.status === "live" && status.trust?.durableStatus === "green") return "实况可信"; - if (status.status === "error") return "错误"; - if (status.status === "blocked") return "实况受阻"; - if (status.sourceKind === "DEV-LIVE") return "DEV-LIVE 状态"; - return "未验证"; -} - -function sourceFallbackM3Status() { - const observedAt = new Date().toISOString(); - return { - status: "unverified", - sourceKind: "SOURCE", - observedAt, - chain: { - sourceGatewayId: "gwsimu_1", - sourceGatewaySessionId: "gws_gwsimu_1", - sourceResourceId: M3_TRUSTED_ROUTE.fromResourceId, - sourceBoxId: "boxsimu_1", - sourcePort: M3_TRUSTED_ROUTE.fromPort, - targetGatewayId: "gwsimu_2", - targetGatewaySessionId: "gws_gwsimu_2", - targetResourceId: M3_TRUSTED_ROUTE.toResourceId, - targetBoxId: "boxsimu_2", - targetPort: M3_TRUSTED_ROUTE.toPort, - patchPanelServiceId: M3_TRUSTED_ROUTE.patchPanelServiceId - }, - gateways: [ - { - id: "gwsimu_1", - role: "source", - online: false, - sessionId: "gws_gwsimu_1", - sourceKind: "SOURCE", - lastSeenAt: observedAt, - lastError: "等待 cloud-api /v1/m3/status 聚合读取" - }, - { - id: "gwsimu_2", - role: "target", - online: false, - sessionId: "gws_gwsimu_2", - sourceKind: "SOURCE", - lastSeenAt: observedAt, - lastError: "等待 cloud-api /v1/m3/status 聚合读取" - } - ], - boxes: [ - sourceFallbackBox("boxsimu_1", M3_TRUSTED_ROUTE.fromResourceId, "gwsimu_1", "gws_gwsimu_1", "DO1", "output", observedAt), - sourceFallbackBox("boxsimu_2", M3_TRUSTED_ROUTE.toResourceId, "gwsimu_2", "gws_gwsimu_2", "DI1", "input", observedAt) - ], - patchPanel: { - serviceId: M3_TRUSTED_ROUTE.patchPanelServiceId, - connectionActive: false, - connectionVersion: null, - wiringConfigId: gateSummary.topology.patchPanel.wiringConfigId ?? null, - lastSyncAt: observedAt, - lastError: "SOURCE 拓扑 fallback,未验证 DEV-LIVE 接线盘状态", - sourceKind: "SOURCE" - }, - trust: { - operationId: null, - traceId: null, - auditId: null, - evidenceId: null, - durableStatus: "blocked", - blocker: "unverified_cloud_api_m3_status" - }, - blocker: { - code: "unverified_cloud_api_m3_status", - layer: "cloud-api", - zh: "等待 cloud-api /v1/m3/status 聚合读取;SOURCE 拓扑不能冒充实况硬件状态。" - } - }; -} - -function sourceFallbackBox(id, resourceId, gatewayId, gatewaySessionId, port, direction, observedAt) { - return { - id, - resourceId, - gatewayId, - gatewaySessionId, - online: false, - sourceKind: "SOURCE", - observedAt, - ports: { - [port]: { - value: null, - direction, - source: "SOURCE fallback", - sourceKind: "SOURCE", - observedAt, - lastError: "未验证" - } - } - }; -} - -function hardwareOverviewGroups(status) { - const box1 = findM3Box(status, M3_TRUSTED_ROUTE.fromResourceId); - const box2 = findM3Box(status, M3_TRUSTED_ROUTE.toResourceId); - const do1 = box1?.ports?.DO1; - const di1 = box2?.ports?.DI1; - const patch = status.patchPanel ?? {}; - const trust = status.trust ?? {}; - return [ - hardwareKvGroup("总览", [ - ["状态", hardwareStatusLabel(status), statusTone(status)], - ["观测时间", status.observedAt ?? "未验证", "source"], - ["Gateway 在线", `${onlineCount(status.gateways)} / ${status.gateways?.length ?? 0}`, onlineCount(status.gateways) === 2 ? "live" : "blocked"], - ["BOX 在线", `${onlineCount(status.boxes)} / ${status.boxes?.length ?? 0}`, onlineCount(status.boxes) === 2 ? "live" : "blocked"], - ["链路状态", m3LinkStatus(status, patch), m3LinkTone(status, patch)], - ["链路", `${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort}`, patch.connectionActive ? "live" : "blocked"], - ["DO1", valueLabel(do1), portTone(do1)], - ["DI1", valueLabel(di1), portTone(di1)], - ["接线盘连接", String(Boolean(patch.connectionActive)), patch.connectionActive ? "live" : "blocked"], - ["runtime durable", trust.durableStatus ?? "blocked", trust.durableStatus === "green" ? "dev-live" : "blocked"], - ["blocker", trust.blocker ?? status.blocker?.code ?? "none", trust.blocker || status.blocker ? "blocked" : "source"] - ]), - hardwareKvGroup("可信证据", [ - ["operationId", trust.operationId ?? "未持久化", trust.operationId ? "source" : "blocked"], - ["traceId", trust.traceId ?? "未持久化", trust.traceId ? "source" : "blocked"], - ["auditId", trust.auditId ?? "未持久化", trust.auditId ? "source" : "blocked"], - ["evidenceId", trust.evidenceId ?? "未持久化", trust.evidenceId ? "source" : "blocked"] - ]) - ]; -} - -function hardwareGatewayGroups(status) { - return (status.gateways ?? []).map((gateway) => - hardwareKvGroup(`${gateway.id} / ${gateway.role}`, [ - ["在线", String(Boolean(gateway.online)), gateway.online ? "live" : "blocked"], - ["sessionId", gateway.sessionId ?? "未验证", gateway.sessionId ? "source" : "blocked"], - ["更新时间", gateway.lastSeenAt ?? status.observedAt ?? "未验证", gateway.online ? "live" : "source"], - ["来源", gateway.sourceKind ?? "UNVERIFIED", sourceKindTone(gateway.sourceKind)], - ["阻塞原因", gateway.lastError ?? "none", gateway.lastError ? "blocked" : "source"] - ]) - ); -} - -function hardwareBoxGroups(status, resourceId) { - const box = findM3Box(status, resourceId); - const portEntries = Object.entries(box?.ports ?? {}); - return [ - hardwareKvGroup(box ? `${box.id} / ${box.resourceId}` : resourceId, [ - ["在线", String(Boolean(box?.online)), box?.online ? "live" : "blocked"], - ["Gateway", `${box?.gatewayId ?? "未验证"} / ${box?.gatewaySessionId ?? "未验证"}`, box?.gatewayId ? "source" : "blocked"], - ["更新时间", box?.observedAt ?? status.observedAt ?? "未验证", sourceKindTone(box?.sourceKind)], - ["来源", box?.sourceKind ?? "UNVERIFIED", sourceKindTone(box?.sourceKind)], - ...portEntries.map(([port, portState]) => [port, valueLabel(portState), portTone(portState)]), - ["AI1", "DISABLED 后续能力", "disabled"], - ["AO1", "DISABLED 后续能力", "disabled"], - ["FREQ", "DISABLED 后续能力", "disabled"] - ]) - ]; -} - -function hardwarePatchPanelGroups(status) { - const patch = status.patchPanel ?? {}; - return [ - hardwareKvGroup("hwlab-patch-panel", [ - ["serviceId", patch.serviceId ?? M3_TRUSTED_ROUTE.patchPanelServiceId, "source"], - ["接线状态", m3LinkStatus(status, patch), m3LinkTone(status, patch)], - ["连接已激活", String(Boolean(patch.connectionActive)), patch.connectionActive ? "live" : "blocked"], - ["connectionVersion", patch.connectionVersion ?? "未验证", patch.connectionVersion !== null && patch.connectionVersion !== undefined ? "source" : "blocked"], - ["wiringConfigId", patch.wiringConfigId ?? "未验证", patch.wiringConfigId ? "source" : "blocked"], - ["更新时间", patch.lastSyncAt ?? status.observedAt ?? "未验证", patch.connectionActive ? "live" : "source"], - ["阻塞原因", patch.lastError ?? "none", patch.lastError ? "blocked" : "source"], - ["来源", patch.sourceKind ?? "UNVERIFIED", sourceKindTone(patch.sourceKind)] - ]) - ]; -} - -function m3LinkStatus(status, patch = status?.patchPanel ?? {}) { - if (status?.status === "error") return "error"; - if (status?.status === "unverified") return "unverified"; - if (status?.status === "live" && status?.trust?.durableStatus === "green" && patch.connectionActive === true) return "live"; - if (patch.connectionActive === true) return "blocked"; - return status?.status === "blocked" ? "blocked" : "unverified"; -} - -function m3LinkTone(status, patch) { - const linkStatus = m3LinkStatus(status, patch); - if (linkStatus === "live") return "live"; - if (linkStatus === "error" || linkStatus === "blocked") return "blocked"; - return "source"; -} - -function hardwareKvGroup(title, rows) { - const article = document.createElement("article"); - article.className = "hardware-section hardware-kv-section"; - article.append(textSpan(title, "hardware-section-title")); - const list = document.createElement("dl"); - list.className = "kv-list"; - for (const [key, value, tone = "source"] of rows) { - const row = document.createElement("div"); - row.className = `kv-row tone-border-${toneClass(tone)}`; - row.append(textSpan(key, "kv-key"), textSpan(value, `kv-value tone-${toneClass(tone)}`)); - list.append(row); - } - article.append(list); - return article; -} - -function findM3Box(status, resourceId) { - return (status?.boxes ?? []).find((box) => box.resourceId === resourceId) ?? null; -} - -function onlineCount(items = []) { - return items.filter((item) => item?.online === true).length; -} - -function valueLabel(portState) { - if (!portState) return "未验证"; - if (portState.value === null || portState.value === undefined) return portState.lastError ? `未验证:${portState.lastError}` : "未验证"; - return String(portState.value); -} - -function portTone(portState) { - if (!portState) return "blocked"; - if (portState.sourceKind === "DEV-LIVE") return "live"; - if (portState.sourceKind === "SOURCE") return "source"; - return "blocked"; -} - -function statusTone(status) { - if (status.status === "live") return "dev-live"; - if (status.status === "error" || status.status === "blocked") return "blocked"; - if (status.sourceKind === "DEV-LIVE") return "live"; - return "source"; -} - -function sourceKindTone(sourceKind) { - const normalized = String(sourceKind ?? "").toUpperCase(); - if (normalized === "DEV-LIVE") return "live"; - if (normalized === "SOURCE") return "source"; - if (normalized === "BLOCKED") return "blocked"; - return "blocked"; -} - -function trustedM3Link(connections) { - return (connections ?? []).find( - (link) => - String(link.fromResourceId ?? "") === M3_TRUSTED_ROUTE.fromResourceId && - normalizePort(link.fromPort) === "DO1" && - String(link.toResourceId ?? "") === M3_TRUSTED_ROUTE.toResourceId && - normalizePort(link.toPort) === "DI1" && - connectionPatchPanelServiceId(link) === M3_TRUSTED_ROUTE.patchPanelServiceId - ); -} - -function m3LinkDetail({ liveM3Evidence, sourceLink, patchPanel, counts }) { - if (hasTrustedM3LiveEvidence(liveM3Evidence)) { - return `实况链路:${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort};${m3LiveDetail(liveM3Evidence)}。`; - } - if (sourceLink) { - return `来源拓扑包含目标接线 ${sourceLink.fromResourceId}:${sourceLink.fromPort} -> ${patchPanel.serviceId} -> ${sourceLink.toResourceId}:${sourceLink.toPort},仍在等待完整可信记录。`; - } - if (counts) { - return "只读运行计数已返回,但还不能证明接线盘闭环。"; - } - if (patchPanel.activeConnectionCount > 0) { - return `当前只看到非目标接线;目标链路 ${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort} 仍在等待可信记录。`; - } - return "当前还没有 DO1 -> patch-panel -> DI1 的可信链路记录。"; -} - -function hardwareEvidenceLabel(sourceKind) { - const normalized = String(sourceKind ?? "").toUpperCase(); - return ( - { - SOURCE: "来源拓扑", - BLOCKED: "待可信记录", - "DEV-LIVE": "实况已验证", - "DRY-RUN": "演练记录" - }[normalized] ?? statusLabel(sourceKind) - ); -} - -function linkStatusLabel(status) { - return ( - { - live: "live", - degraded: "degraded", - blocked: "blocked" - }[status] ?? statusLabel(status) - ); -} - -function normalizePort(port) { - return String(port ?? "").trim().toUpperCase(); -} - -function trustedM3LiveEvidenceFrom(summary) { - if (!isDurableRuntimeReady(summary)) return null; - const candidates = [ - summary?.m3TrustedLoop, - summary?.m3HardwareLoop, - summary?.hardware?.m3TrustedLoop, - summary?.patchPanel?.m3TrustedLoop, - summary?.liveM3Evidence - ].filter(Boolean); - - return candidates.find(hasTrustedM3LiveEvidence) ?? null; -} - -function m3ControlLiveEvidence() { - const operation = state.m3Control.operation; - if (!operation || operation.status !== "succeeded") return null; - return { - status: operation.evidenceState?.status === "green" ? "verified" : "blocked", - sourceKind: operation.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED", - live: operation.evidenceState?.status === "green", - operationId: operation.operationId, - traceId: operation.traceId, - auditId: operation.auditId, - evidenceId: operation.evidenceId, - patchPanelLiveReport: { - serviceId: M3_TRUSTED_ROUTE.patchPanelServiceId, - sourceKind: operation.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED", - live: operation.evidenceState?.status === "green", - reportId: operation.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 liveRecordTone(record, live) { - const runtimeSummary = runtimeSummaryFrom(live); - return isDurableRuntimeReady(runtimeSummary) && hasTrustedM3LiveEvidence(record) ? "dev-live" : "blocked"; -} - -function liveRecordDurabilityBlocker(live) { - const runtimeSummary = runtimeSummaryFrom(live); - if (isDurableRuntimeReady(runtimeSummary)) return null; - if (runtimeSummary?.durableRequested === true || runtimeSummary?.adapter === "postgres") { - return `runtime durable adapter 未 ready(status=${runtimeSummary?.status ?? "unknown"},durable=${runtimeSummary?.durable === true});可信记录保持 BLOCKED。`; - } - if (runtimeSummary?.durable !== true) { - return "runtime.durable=false;只读 audit/evidence 不能标为 durable ready 或 DEV-LIVE。"; - } - return `runtime durable adapter 未 ready(status=${runtimeSummary?.status ?? "unknown"});可信记录保持 BLOCKED。`; -} - -function isDurableRuntimeReady(summary) { - if (!summary || typeof summary !== "object") return false; - if (summary.durable !== true) return false; - if (summary.ready === false) return false; - if (summary.liveRuntimeEvidence !== true) return false; - return !["blocked", "degraded", "failed"].includes(String(summary.status ?? "").toLowerCase()); -} - -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 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 connectionPatchPanelServiceId(link) { - return String(link.patchPanelServiceId ?? link.patchPanel?.serviceId ?? link.serviceId ?? M3_TRUSTED_ROUTE.patchPanelServiceId); -} - -function m3LiveDetail(evidence) { - return `operation=${evidence.operationId} / trace=${evidence.traceId} / audit=${evidence.auditId} / evidence=${evidence.evidenceId}`; -} - -function renderM3ControlStatus() { - const contract = state.m3Control.contract; - const operation = state.m3Control.operation; - const controlReady = m3ControlCanOperate(contract); - const contractObserved = Boolean(contract); - const pending = state.m3Control.pending; - const operationSucceeded = operation?.status === "succeeded"; - const operationBlocked = operation?.status === "blocked"; - const label = pending - ? "执行中" - : operationSucceeded - ? operation.evidenceState?.status === "green" - ? "控制可达 / 记录完整" - : "控制可达 / 等待记录" - : operationBlocked - ? "操作待处理" - : controlReady - ? "受控路径可执行" - : contractObserved - ? "受控路径受阻" - : "等待探测"; - const tone = pending - ? "dry-run" - : operationSucceeded - ? operation.evidenceState?.status === "green" ? "dev-live" : "degraded" - : operationBlocked || !controlReady - ? "blocked" - : "source"; - - el.m3ControlStatus.textContent = label; - el.m3ControlStatus.className = `state-tag tone-${toneClass(tone)}`; - el.m3ControlStatus.title = m3ControlStatusTitle({ operation, contract, controlReady }); - const disabled = pending || !controlReady; - for (const input of [el.m3GatewaySelect, el.m3BoxSelect, el.m3PortSelect, el.m3ValueSelect, el.m3WriteDo, el.m3ReadDi]) { - input.disabled = disabled; - } - el.m3WriteDo.textContent = pending ? "执行中" : "写入 DO1"; - renderM3FlowSummary({ label, tone, operation, contract, controlReady, pending }); -} - -function m3ControlCanOperate(contract = state.m3Control.contract) { - return contract?.status === "available" && - contract?.readiness?.status === "ready" && - contract?.readiness?.controlReady === true && - contract?.readiness?.sourceKind === "DEV-LIVE" && - contract?.readiness?.evidenceLevel === "DEV-LIVE"; -} - -function m3ControlStatusTitle({ operation, contract, controlReady }) { - if (operation?.blocker?.zh) return operation.blocker.zh; - if (operation?.evidenceState?.reason) return operation.evidenceState.reason; - if (controlReady) { - return "M3 IO 只读 readiness 已确认;控制仅通过 cloud-api 受控路径执行,不直连 gateway/box-simu。"; - } - return m3ControlBlockedReason(contract); -} - -function m3ControlBlockedReason(contract = state.m3Control.contract) { - if (!contract) return "等待 cloud-api 返回 M3 IO 只读 readiness;未确认前控制保持阻塞。"; - return contract.readiness?.blocker?.zh ?? - contract.blockedReason ?? - "M3 IO 只读 readiness 未 green;控制面板保持阻塞,不直连 gateway/box-simu。"; -} - -function controlRows() { - const operation = state.m3Control.operation; - const controlReady = m3ControlCanOperate(); - const operationCards = operation - ? [ - { - title: `M3 ${operationActionLabel(operation.action)} ${statusLabel(operation.status)}`, - detail: m3OperationDetail(operation), - tone: operation.status === "succeeded" - ? operation.evidenceState?.status === "green" ? "dev-live" : "degraded" - : operation.status === "running" - ? "dry-run" - : "blocked" - } - ] - : []; - return [ - { - title: "用户操作", - detail: "选择 DO1 true/false 后点击“写入 DO1”;再点击“读取 DI1”核对 res_boxsimu_2:DI1。最近 trace 与 operation 可在“可信记录”页签追踪。", - tone: controlReady ? "source" : "blocked" - }, - { - title: "控制路径", - detail: controlReady - ? "按钮只走受控后端,再通过 gateway-simu -> box-simu -> hwlab-patch-panel 执行 M3 DO/DI。" - : `${m3ControlBlockedReason()} 不可用时页面不直连模拟器,也不伪造硬件状态。`, - tone: controlReady ? "source" : "blocked" - }, - ...operationCards, - { - title: "读取目标", - detail: `${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort} 只能通过受控路径读取;浏览器不直连 box-simu。`, - tone: "source" - }, - { - title: "Code Agent 对话", - detail: codeAgentControlSummary(state.codeAgentAvailability), - tone: currentConversationTone() - }, - { - title: "只读探测", - detail: "内部复核探测保持只读,并放在二级页面。", - tone: "source" - } - ]; -} - -function operationActionLabel(action) { - return action === "do.write" ? "DO 写入" : action === "di.read" ? "DI 读取" : "操作"; -} - -function m3OperationDetail(operation) { - if (operation.status === "running") { - return `trace=${operation.traceId} / action=${operationActionLabel(operation.action)} / DO1目标=${m3OperationDo1Target(operation)} / DI1观测=等待返回 / 正在通过 cloud-api 提交。`; - } - const ids = [ - recordField("operation", operation.operationId), - recordField("trace", operation.traceId), - recordField("audit", operation.auditId), - recordField("evidence", operation.evidenceId) - ].filter(Boolean).join(" / "); - const value = operation.result?.value !== undefined && operation.result?.value !== null - ? ` / value=${String(operation.result.value)}` - : ""; - const ioValues = ` / DO1目标=${m3OperationDo1Target(operation)} / DI1观测=${m3OperationDi1Observed(operation, { - fallback: m3StatusPortValue(state.m3Control.status, M3_TRUSTED_ROUTE.toResourceId, M3_TRUSTED_ROUTE.toPort) - })}`; - const blocker = operation.blocker?.zh || operation.blocker?.message - ? ` / 阻塞原因=${operation.blocker.zh || operation.blocker.message}` - : ""; - const evidence = operation.evidenceState?.reason ? ` / evidence=${operation.evidenceState.reason}` : ""; - return `${ids}${value}${ioValues}${blocker}${evidence}`; -} - -function renderM3FlowSummary({ label, tone, operation, contract, controlReady, pending }) { - const status = state.m3Control.status; - const do1Live = m3StatusPortValue(status, M3_TRUSTED_ROUTE.fromResourceId, M3_TRUSTED_ROUTE.fromPort); - const di1Live = m3StatusPortValue(status, M3_TRUSTED_ROUTE.toResourceId, M3_TRUSTED_ROUTE.toPort); - const do1Target = m3OperationDo1Target(operation, { fallback: el.m3ValueSelect.value }); - const di1Observed = m3OperationDi1Observed(operation, { fallback: di1Live }); - const actionLabel = operation - ? `${operationActionLabel(operation.action)} / ${statusLabel(operation.status)}` - : controlReady - ? "可执行:写入 DO1 后读取 DI1" - : "等待 cloud-api readiness"; - - el.m3FlowState.textContent = label; - el.m3FlowState.className = `state-tag tone-${toneClass(tone)}`; - el.m3ActionSummary.textContent = pending - ? `正在执行 ${operationActionLabel(operation?.action)};trace 已生成。` - : actionLabel; - el.m3Do1Target.textContent = operation?.action === "di.read" && do1Live === null - ? `本次只读取;当前选择 ${el.m3ValueSelect.value}` - : do1Target; - el.m3Di1Observed.textContent = di1Observed; - el.m3TrustSummary.textContent = m3TrustSummaryText(operation, contract); - el.m3TraceSummary.textContent = m3TraceSummaryText(operation, status); -} - -function m3OperationDo1Target(operation, { fallback = null } = {}) { - if (operation?.action === "do.write") { - const value = operation.command?.value ?? operation.value ?? operation.target?.value ?? operation.result?.value; - return value === undefined || value === null ? "DO1 目标值等待返回" : `res_boxsimu_1:DO1=${String(value)}`; - } - if (fallback !== null && fallback !== undefined && fallback !== "") return `待写入选择=${String(fallback)}`; - return "本次未写入 DO1"; -} - -function m3OperationDi1Observed(operation, { fallback = null } = {}) { - const value = operation?.result?.targetReadback?.value ?? operation?.readback?.value ?? (operation?.action === "di.read" ? operation?.result?.value : undefined); - if (value !== undefined && value !== null) return `res_boxsimu_2:DI1=${String(value)}`; - if (fallback !== null && fallback !== undefined && fallback !== "") return `res_boxsimu_2:DI1=${String(fallback)}`; - if (operation?.status === "running") return "等待 cloud-api 返回"; - return "待读取"; -} - -function m3StatusPortValue(status, resourceId, port) { - const portState = findM3Box(status, resourceId)?.ports?.[port]; - return portState?.value === undefined || portState?.value === null ? null : portState.value; -} - -function m3TrustSummaryText(operation, contract) { - if (operation?.evidenceState) { - const trusted = operation.evidenceState.status === "green"; - const durable = operation.evidenceState.durable === true; - const blocker = operation.evidenceState.blocker ?? operation.trustBlocker?.code ?? operation.blocker?.code ?? "无"; - return `trusted=${String(trusted)};durable=${String(durable)};${trusted ? "可信记录完整" : `阻塞原因=${blocker}`}`; - } - const trust = contract?.readiness?.trust ?? contract?.trustReadiness; - if (trust?.status) { - return `trusted=${String(trust.trustedEvidenceReady === true)};durable=${String(trust.durableStatus?.durable === true)};状态=${trust.status}`; - } - return m3ControlBlockedReason(contract); -} - -function m3TraceSummaryText(operation, status) { - const ids = [ - recordField("operation", operation?.operationId ?? status?.trust?.operationId), - recordField("trace", operation?.traceId ?? status?.trust?.traceId), - recordField("audit", operation?.auditId ?? status?.trust?.auditId), - recordField("evidence", operation?.evidenceId ?? status?.trust?.evidenceId) - ].filter(Boolean); - return ids.length ? `最近 ${ids.join(" / ")}` : "最近 trace:无"; -} - -function renderDrafts() { - const rows = [ - ...state.chatMessages.slice(-3).reverse().map((message) => ({ - title: `${roleLabel(message.role)} ${statusLabel(message.status)}`, - detail: message.text, - tone: message.status || "source" - })), - ...(state.chatMessages.length === 0 - ? [ - { - title: "对话状态", - detail: codeAgentPromptText(state.codeAgentAvailability), - tone: state.codeAgentAvailability?.status === "blocked" ? "blocked" : "source" - } - ] - : []), - ...controlRows() - ]; - replaceChildren(el.controlList, ...rows.map(infoCard)); -} - -function renderWiringList(summary = null) { - const presentation = wiringPresentationFromSummary(summary, { - liveM3Evidence: trustedM3LiveEvidenceFrom(summary) - }); - - const sourceHeader = byId("wiring-source-device"); - const targetHeader = byId("wiring-target-device"); - if (sourceHeader) sourceHeader.textContent = M3_TRUSTED_ROUTE.fromResourceId; - if (targetHeader) targetHeader.textContent = M3_TRUSTED_ROUTE.toResourceId; - - const tr = document.createElement("tr"); - tr.dataset.sourceKind = presentation.rowSourceKind; - tr.dataset.wiringRow = "io"; - - const sourceCell = cell("", ""); - sourceCell.append(textSpan(M3_TRUSTED_ROUTE.fromPort, "wiring-port mono")); - sourceCell.append(textSpan(`源端口;经 ${M3_TRUSTED_ROUTE.patchPanelServiceId};状态:${presentation.stateLabel}`, "wiring-detail")); - - const targetCell = cell("", ""); - targetCell.append(textSpan(M3_TRUSTED_ROUTE.toPort, "wiring-port mono")); - targetCell.append(textSpan(`目标端口;证据来源:${presentation.sourceLabel};轨迹/证据:${presentation.traceEvidence}`, "wiring-detail mono wrap")); - - tr.append(sourceCell, targetCell); - - replaceChildren(el.wiringBody, tr); - - const summaryElement = byId("wiring-summary"); - if (summaryElement) { - replaceChildren( - summaryElement, - document.createTextNode(`接线盘:${M3_TRUSTED_ROUTE.patchPanelServiceId};状态:`), - badge(presentation.stateLabel, presentation.badgeTone), - document.createTextNode(`;证据来源:${presentation.sourceLabel};轨迹/证据:${presentation.traceEvidence}。`) - ); - } -} - -function renderRecords(live) { - const groups = [ - recordGroup({ - title: "Code Agent 对话记录", - meta: "trace/messageId", - cards: codeAgentRecordCards() - }), - recordGroup({ - title: "接线/operation", - meta: "hwlab-patch-panel + operation", - cards: operationRecordCards() - }), - recordGroup({ - title: "audit 记录", - meta: "audit.event.query + SOURCE 回退", - cards: auditRecordCards(live) - }), - recordGroup({ - title: "evidence 证据", - meta: "evidence.record.query + SOURCE 回退", - cards: evidenceRecordCards(live) - }) - ]; - - replaceChildren(el.recordsList, ...groups); -} - -function codeAgentRecordCards() { - const messages = state.chatMessages.filter((message) => message.traceId || message.messageId); - if (messages.length === 0) { - return [ - infoCard({ - title: "尚无 Code Agent 请求记录", - detail: "发送消息后会在这里显示 conversationId、traceId 和 messageId;未发送时仅显示 SOURCE 说明。", - tone: "source" - }) - ]; - } - - return messages.slice(-4).reverse().map((message) => - infoCard({ - title: `${roleLabel(message.role)} ${statusLabel(message.status)}`, - detail: [ - sourceKindLabel(message.status === "running" ? "pending" : ["failed", "timeout", "canceled", "error"].includes(message.status) ? "blocked" : "source"), - recordField("conversation", message.conversationId ?? state.conversationId), - recordField("trace", message.traceId), - recordField("session", message.sessionId ?? sessionSummary(message.session)), - recordField("thread", message.threadId ?? threadIdFrom(message)), - recordField("message", message.messageId), - message.error?.message ? `失败原因=${safeFailureReason(message.error.message)}` : null - ].filter(Boolean).join(" / "), - tone: message.status === "running" ? "pending" : ["failed", "timeout", "canceled", "error"].includes(message.status) ? "blocked" : "source" - }) - ); -} - -function operationRecordCards() { - const wiring = gateSummary.topology.patchPanel; - const controlOperation = state.m3Control.operation; - const controlCards = controlOperation - ? [ - infoCard({ - title: controlOperation.operationId ?? controlOperation.traceId ?? "m3-control-operation", - detail: [ - sourceKindLabel(controlOperation.evidenceState?.status === "green" ? "dev-live" : "blocked_after_cloud_api"), - "via=/v1/m3/io", - recordField("action", controlOperation.action), - `DO1目标=${m3OperationDo1Target(controlOperation)}`, - `DI1观测=${m3OperationDi1Observed(controlOperation, { - fallback: m3StatusPortValue(state.m3Control.status, M3_TRUSTED_ROUTE.toResourceId, M3_TRUSTED_ROUTE.toPort) - })}`, - `trusted=${String(controlOperation.evidenceState?.status === "green")}`, - `durable=${String(controlOperation.evidenceState?.durable === true)}`, - recordField("trace", controlOperation.traceId), - recordField("audit", controlOperation.auditId), - recordField("evidence", controlOperation.evidenceId), - controlOperation.controlPath?.frontendBypass === false ? "frontendBypass=false" : null, - controlOperation.evidenceState?.reason - ].filter(Boolean).join(" / "), - tone: controlOperation.status === "succeeded" - ? controlOperation.evidenceState?.status === "green" ? "dev-live" : "degraded" - : "blocked" - }) - ] - : []; - const wiringCards = wiring.activeConnections.map((link) => - infoCard({ - title: `${link.fromResourceId}:${link.fromPort} -> ${link.toResourceId}:${link.toPort}`, - detail: [ - sourceKindLabel("source"), - recordField("patch-panel", wiring.serviceId), - `状态=${statusLabel(wiring.state)}`, - recordField("statusId", wiring.patchPanelStatusId) - ].filter(Boolean).join(" / "), - tone: "source" - }) - ); - const operationCards = gateSummary.operations.map((operation) => - infoCard({ - title: operation.operationId, - detail: [ - sourceKindLabel(operation.dryRun ? "dry-run" : "source"), - `状态=${statusLabel(operation.status)}`, - recordField("resource", operation.resourceId), - recordField("capability", operation.capabilityId), - recordField("worker", operation.workerSessionId) - ].filter(Boolean).join(" / "), - tone: operation.dryRun ? "dry-run" : "source" - }) - ); - return [...controlCards, ...wiringCards, ...operationCards]; -} - -function auditRecordCards(live) { - const cards = []; - if (state.m3Control.operation?.auditEvent) { - cards.push(auditCard( - state.m3Control.operation.auditEvent, - state.m3Control.operation.evidenceState?.status === "green" ? "dev-live" : "blocked", - state.m3Control.operation.evidenceState?.status === "green" ? null : state.m3Control.operation.evidenceState?.reason - )); - } - if (live) { - if (live.audit.ok) { - const liveEvents = live.audit.data?.events ?? []; - if (liveEvents.length === 0) { - cards.push(infoCard({ - title: "audit.event.query", - detail: "只读查询成功但无 M3 trusted loop audit;下方显示 SOURCE/DRY-RUN 回退摘要,不能冒充 DEV-LIVE。", - tone: "source" - })); - } else { - cards.push(...liveEvents.map((event) => auditCard(event, liveRecordTone(event, live), liveRecordDurabilityBlocker(live)))); - } - } else { - cards.push(liveFailureCard("audit.event.query", live.audit.error)); - } - } else { - cards.push(infoCard({ - title: "audit.event.query", - detail: "等待同源只读查询;未完成前显示 SOURCE/DRY-RUN 回退摘要。", - tone: "source" - })); - } - - cards.push(...gateSummary.auditEvents.slice(0, 4).map((event) => auditCard(event, event.dryRun ? "dry-run" : "source"))); - return cards; -} - -function evidenceRecordCards(live) { - const cards = []; - if (state.m3Control.operation?.evidenceRecord) { - cards.push(evidenceCard( - state.m3Control.operation.evidenceRecord, - state.m3Control.operation.evidenceState?.status === "green" ? "dev-live" : "blocked", - state.m3Control.operation.evidenceState?.status === "green" ? null : state.m3Control.operation.evidenceState?.reason - )); - } - if (live) { - if (live.evidence.ok) { - const liveRecords = live.evidence.data?.records ?? []; - if (liveRecords.length === 0) { - cards.push(infoCard({ - title: "evidence.record.query", - detail: "只读查询成功但无 M3 trusted loop evidence;下方显示 SOURCE/DRY-RUN 回退摘要,不能冒充 DEV-LIVE。", - tone: "source" - })); - } else { - cards.push(...liveRecords.map((record) => evidenceCard(record, liveRecordTone(record, live), liveRecordDurabilityBlocker(live)))); - } - } else { - cards.push(liveFailureCard("evidence.record.query", live.evidence.error)); - } - } else { - cards.push(infoCard({ - title: "evidence.record.query", - detail: "等待同源只读查询;未完成前显示 SOURCE/DRY-RUN 回退摘要。", - tone: "source" - })); - } - - cards.push(...gateSummary.evidenceRecords.map((record) => evidenceCard(record, record.dryRun ? "dry-run" : "source"))); - return cards; -} - -function auditCard(event, tone, durabilityBlocker = null) { - return infoCard({ - title: event.auditId, - detail: [ - sourceKindLabel(tone), - durabilityBlocker, - tone === "blocked" ? "只读 audit 缺少 M3 patch-panel live report / operation / trace / evidence 绑定" : null, - recordField("action", event.action), - recordField("target", event.targetId), - recordField("operation", event.operationId), - recordField("trace", event.traceId), - recordField("service", event.serviceId) - ].filter(Boolean).join(" / "), - tone - }); -} - -function evidenceCard(record, tone, durabilityBlocker = null) { - return infoCard({ - title: record.evidenceId, - detail: [ - sourceKindLabel(tone), - durabilityBlocker, - tone === "blocked" ? "只读 evidence 缺少 M3 patch-panel live report / operation / trace / audit 绑定" : null, - recordKindLabel(record.kind ?? "record"), - recordField("operation", record.operationId), - recordField("trace", record.traceId ?? record.metadata?.traceId), - recordField("service", record.serviceId) - ].filter(Boolean).join(" / "), - tone - }); -} - -function liveFailureCard(title, reason) { - return infoCard({ - title, - detail: `阻塞 BLOCKED:开发实况查询失败,失败原因:${reason || "未知错误"};当前显示 SOURCE 回退,不能冒充 DEV-LIVE。`, - tone: "blocked" - }); -} - -function recordGroup({ title, meta, cards }) { - const section = document.createElement("section"); - section.className = "record-group"; - const header = document.createElement("div"); - header.className = "record-group-head"; - header.append(textSpan(title, "record-group-title"), textSpan(meta, "record-group-meta")); - const body = document.createElement("div"); - body.className = "record-group-list"; - body.append(...cards); - section.append(header, body); - return section; -} - -function recordField(label, value) { - if (value === undefined || value === null || value === "") return null; - return `${label}=${value}`; -} - -const sensitiveFailureReasonNeedles = Object.freeze([ - ["OPENAI", "API", "KEY"].join("_"), - ["hwlab", "code", "agent", "provider"].join("-"), - ["Se", "cret"].join(""), - ["secret", "Ref"].join("") -]); - -function safeFailureReason(value) { - const text = String(value ?? "").trim(); - if (!text) return "未知错误"; - const lowerText = text.toLowerCase(); - if (sensitiveFailureReasonNeedles.some((needle) => lowerText.includes(needle.toLowerCase()))) { - return "服务暂不可用"; - } - return text.length > 120 ? `${text.slice(0, 117)}...` : text; -} - -function sourceKindLabel(tone) { - return statusLabel(tone); -} function renderGateTable() { const rows = filteredGateRows(); @@ -3794,11 +2857,7 @@ function sessionSummaryTone(summary) { } function agentStatusLabel(status, result, labels) { - if (status === "completed" && result?.m3Io?.type === "m3_io_result") { - return result.m3Io.trust?.trusted === true ? "M3 IO 可信回复" : "M3 IO 受控结果"; - } if (status !== "failed") return labels[status] ?? statusLabel(status); - if (result?.m3Io?.type === "m3_io_blocker") return "M3 IO 阻塞"; const presentation = agentFailurePresentation(result?.error, { result }); return ( { @@ -3819,9 +2878,6 @@ function agentStatusLabel(status, result, labels) { } function agentStatusTone(status, result) { - if (status === "completed" && result?.m3Io?.type === "m3_io_result") { - return result.m3Io.trust?.trusted === true ? "dev-live" : "warn"; - } if (status === "completed") return "dev-live"; if (["failed", "blocked", "timeout", "canceled", "error"].includes(status)) return "blocked"; if (status === "running") return "pending"; @@ -3865,14 +2921,6 @@ function classifyCodeAgentCompletion(result, { blockedError = null } = {}) { title: "Code Agent 返回错误" }; } - if (result?.m3Io?.type === "m3_io_blocker") { - return { - status: "failed", - replied: false, - sourceKind: "BLOCKED", - title: "M3 IO 阻塞" - }; - } if (isStructuredBlockedChatResult(result)) { return { status: "failed", @@ -3893,7 +2941,7 @@ function classifyCodeAgentCompletion(result, { blockedError = null } = {}) { return { status: "completed", replied: true, - sourceKind: result?.m3Io?.type === "m3_io_result" && result.m3Io.trust?.trusted !== true ? "CONTROLLED" : "DEV-LIVE", + sourceKind: "DEV-LIVE", title: sourceTitle(result) }; } @@ -3928,8 +2976,6 @@ function isStructuredBlockedChatResult(result) { typeof result === "object" && ( result.blocker?.code || - result.m3Io?.type === "m3_io_blocker" || - result.m3Io?.blocker?.code || error.blocker?.code || result.capabilityLevel === "hwlab-api-control-blocked" ) @@ -3940,9 +2986,8 @@ function structuredBlockedErrorFromResult(result) { if (!isStructuredBlockedChatResult(result)) return null; const error = result?.error && typeof result.error === "object" ? result.error : {}; const blocker = error.blocker ?? result?.blocker ?? {}; - const m3Blocker = result?.m3Io?.blocker ?? {}; - const code = normalizeErrorCode(error.code ?? m3Blocker.code ?? blocker.code ?? "code_agent_blocked"); - const selectedBlocker = result?.m3Io?.type === "m3_io_blocker" && m3Blocker.code ? m3Blocker : blocker; + const code = normalizeErrorCode(error.code ?? blocker.code ?? "code_agent_blocked"); + const selectedBlocker = blocker; return { ...error, code, @@ -3958,28 +3003,6 @@ function structuredBlockedErrorFromResult(result) { }; } -function m3IoPresentation(result) { - const m3 = result?.m3Io && typeof result.m3Io === "object" ? result.m3Io : null; - if (!m3) return null; - const do1 = m3.do1?.targetValue === undefined || m3.do1?.targetValue === null ? "未产生/不可证明" : String(m3.do1.targetValue); - const di1 = m3.di1?.observedValue === undefined || m3.di1?.observedValue === null ? "未产生/不可证明" : String(m3.di1.observedValue); - const fields = `traceId=${m3.trace?.traceId ?? result?.traceId ?? "null"};operationId=${m3.operation?.operationId ?? "null"}`; - const trust = `trusted=${m3.trust?.trusted === true ? "true" : "false"};durable=${m3.trust?.durable === true ? "true" : "false"}`; - const path = m3.path?.summary ?? "Code Agent -> Skill CLI -> HWLAB API"; - const wiring = m3.wiring?.label ?? "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1"; - if (m3.type === "m3_io_blocker") { - const blocker = m3.blocker ?? {}; - const reason = blocker.userMessage ?? blocker.zh ?? blocker.message ?? blocker.code ?? "M3 控制链路仍受阻"; - return { - title: "M3 IO 阻塞", - text: `M3 IO 阻塞:${reason}。\nDO1 目标值:${do1};DI1 观测值:${di1}。\n路径:${path}。接线:${wiring}。\n排查字段:${fields};blocker=${blocker.code ?? "unknown"}。\n可信状态:${trust};未伪造 DEV-LIVE,也未使用 OpenAI fallback 冒充执行。` - }; - } - return { - title: "M3 IO 结果", - text: `M3 IO 结果:DO1 目标值=${do1};DI1 观测值=${di1}。\n路径:${path}。接线:${wiring}。\n排查字段:${fields};auditId=${m3.operation?.auditId ?? "null"};evidenceId=${m3.operation?.evidenceId ?? "null"}。\n可信状态:${trust};这是受控操作返回,不是 M3 DEV-LIVE 验收结论。` - }; -} function failureMessage(result) { const presentation = agentFailurePresentation(structuredBlockedErrorFromResult(result) ?? result?.error, { result }); @@ -4177,6 +3200,14 @@ function isTimeoutFailure(code, message) { /\b(?:timeout|timed out|abort|aborted|超时|请求超过|无新事件)\b/iu.test(message); } +function isCodeAgentTimeoutError(error, fallbackMessage = "") { + const code = normalizeErrorCode(error?.code); + const message = [error?.message, error?.userMessage, fallbackMessage] + .filter(Boolean) + .join(" "); + return isTimeoutFailure(code, message); +} + function timeoutMsFromText(message) { const match = String(message ?? "").match(/(?:timed out after|timeout after|请求超时|请求超过|超过)\s*(\d+)\s*ms/iu); if (!match) return null; @@ -4194,8 +3225,6 @@ function messageCard(message) { if (pendingContext) article.append(pendingContext); const sessionContext = messageSessionContinuityPanel(message); if (sessionContext) article.append(sessionContext); - const m3Evidence = messageM3EvidencePanel(message); - if (m3Evidence) article.append(m3Evidence); const runtimePath = messageRuntimePathPanel(message); if (runtimePath) article.append(runtimePath); const tracePanel = messageTracePanel(message); @@ -4384,7 +3413,7 @@ async function cancelAgentMessage(messageId) { renderCodeAgentSummary(); renderConversation(); renderDrafts(); - renderRecords(state.liveSurface); + renderDevicePodPanel(state.liveSurface); } function stopRunningTraceStream(traceId) { @@ -4444,7 +3473,7 @@ async function replayAgentTrace(messageId) { } renderCodeAgentSummary(); renderConversation(); - renderRecords(state.liveSurface); + renderDevicePodPanel(state.liveSurface); } function maybeReplayFullTraceForMessage(message) { @@ -4483,7 +3512,7 @@ async function replayFullTrace(messageId, options = {}) { if (options.quiet !== true) { renderCodeAgentSummary(); renderConversation(); - renderRecords(state.liveSurface); + renderDevicePodPanel(state.liveSurface); } else { renderConversation(); } @@ -4583,20 +3612,6 @@ function messagePendingContextPanel(message) { return section; } -function messageM3EvidencePanel(message) { - const evidence = message.m3Evidence ?? extractCodeAgentM3Evidence(message); - if (!evidence) return null; - const section = document.createElement("section"); - section.className = `message-m3-evidence tone-border-${toneClass(evidence.verdict.tone)}`; - section.dataset.m3Verdict = evidence.verdict.key; - section.append(m3EvidenceHeader(evidence)); - const rows = document.createElement("div"); - rows.className = "message-m3-rows"; - rows.append(...m3EvidenceRows(evidence).map(m3EvidenceRowElement)); - section.append(rows); - section.append(m3EvidenceRawDetails(evidence, message)); - return section; -} function messageRuntimePathPanel(message) { if (message.role !== "agent") return null; @@ -4657,47 +3672,6 @@ function messageCompactDetails({ className, tone, badgeLabel, badgeTone, summary return section; } -function m3EvidenceHeader(evidence) { - const header = document.createElement("div"); - header.className = "message-m3-head"; - header.append( - badge(evidence.verdict.label, evidence.verdict.tone), - textSpan(m3EvidenceSummaryText(evidence), "message-m3-summary") - ); - return header; -} - -function m3EvidenceRowElement(row) { - const item = document.createElement("div"); - item.className = `message-m3-row tone-border-${toneClass(row.tone)}`; - item.append(textSpan(row.label, "message-m3-key")); - const value = row.href ? safeLink(row.href, row.value, "message-m3-value mono") : textSpan(row.value, "message-m3-value mono"); - item.append(value); - if (row.copyable) { - item.append(copyButton(row.value, row.label)); - } - return item; -} - -function m3EvidenceRawDetails(evidence, message) { - const details = document.createElement("details"); - details.className = "message-m3-raw"; - const summary = document.createElement("summary"); - summary.textContent = "原始字段"; - const pre = document.createElement("pre"); - pre.textContent = boundedJson({ - responseType: message.responseType, - m3Io: message.m3Io ?? null, - toolCall: evidence.source ?? null, - providerTrace: message.providerTrace ?? null, - runnerTrace: message.runnerTrace ?? null, - blocker: message.blocker ?? null, - blockers: message.blockers ?? null - }, 6000); - details.append(summary, pre); - return details; -} - function boundedJson(value, maxLength = 6000) { let text = ""; try { @@ -6118,9 +5092,6 @@ function isSourceFixtureCompletedChatMessage(message) { } function hasRealCodeAgentEvidence(value) { - if (isCodeAgentM3SkillCompletion(value)) { - return true; - } if (isCodexRunnerCapableEvidence(value)) { return true; } diff --git a/web/hwlab-cloud-web/code-agent-m3-evidence.mjs b/web/hwlab-cloud-web/code-agent-m3-evidence.mjs deleted file mode 100644 index 02ace10a..00000000 --- a/web/hwlab-cloud-web/code-agent-m3-evidence.mjs +++ /dev/null @@ -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"; -} diff --git a/web/hwlab-cloud-web/gate-summary.mjs b/web/hwlab-cloud-web/gate-summary.mjs deleted file mode 100644 index 936c9e06..00000000 --- a/web/hwlab-cloud-web/gate-summary.mjs +++ /dev/null @@ -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" - ] - } -}; diff --git a/web/hwlab-cloud-web/help.md b/web/hwlab-cloud-web/help.md index f4fa0bf0..9cf2d35a 100644 --- a/web/hwlab-cloud-web/help.md +++ b/web/hwlab-cloud-web/help.md @@ -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` 和 freshness;Target、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 结果。 ## 端口与只读边界 diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 3e598f05..2829e5f7 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -64,13 +64,13 @@

用户工作台

-

Agent 对话与硬件资源面板

-

当前浏览器会话只保存任务草稿,硬件动作需通过受控后端流程。

+

Agent 对话与 Device Pod 看板

+

当前浏览器会话只保存任务草稿,Device Pod 操作需通过受控后端流程。

工作区状态 等待验证 - 正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。 + 正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/device-pods;未完成前不标为通过。