diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md
index 0d68425f..95b214bb 100644
--- a/docs/reference/cloud-workbench.md
+++ b/docs/reference/cloud-workbench.md
@@ -83,6 +83,24 @@ The frontend may show read-only DEV observations when they are tied to the
exact route or component. It must not upgrade source fixtures, dry-run output,
front-end state, or edge-only health into M3 DEV-LIVE.
+The default workbench runtime label must not expose raw `degraded` as the
+user-facing conclusion. `live-status` maps current probes into four Chinese
+states:
+
+| State | Visible label | Required attribution |
+| --- | --- | --- |
+| Pass | `API 正常` | The exact service and API that proved readiness, normally `hwlab-cloud-api /health/live`. |
+| Error | `API 错误` | Concrete service/API/reason, for example `hwlab-cloud-api /v1/agent/chat provider_http_504` or `hwlab-patch-panel /v1/m3/io m3_wiring_missing`. |
+| Unverified | `等待验证` | The service/API still waiting for observation; it must not be treated as pass. |
+| Read-only | `只读模式` | Explicit permission, fallback, DB persistence, or runtime durable reason, for example `runtime_durable_adapter_schema_blocked`, `runtime_durable_false`, or `fallback_text_chat_only`. |
+
+Raw `degraded` may be retained in internal details, reports, and `/gate`, but
+the main workbench status must translate it into one of these actionable
+states. Real `/v1/agent/chat` failures, provider timeouts, text-chat-only
+fallback, runner blockers, and M3 `cloud-api -> gateway-simu -> box-simu ->
+hwlab-patch-panel` failures must name the responsible microservice/API and the
+error category.
+
如果 `/v1/agent/chat` 返回 `provider_unavailable` 或报告缺少
`OPENAI_API_KEY`,可见状态必须保持 `BLOCKED/credential`。在同源 DEV 请求按
[Code Agent readiness](code-agent-chat-readiness.md) 合同返回 completed
diff --git a/package.json b/package.json
index a1279487..91971fad 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs",
- "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.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 scripts/dev-runtime-postflight.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
+ "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs
index dba0155d..2c2f6e32 100644
--- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs
+++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs
@@ -46,6 +46,7 @@ const requiredWebAssets = Object.freeze([
"index.html",
"styles.css",
"app.mjs",
+ "live-status.mjs",
"runtime.mjs",
"gate-summary.mjs",
"workbench-hardware-panel.mjs",
@@ -54,7 +55,7 @@ const requiredWebAssets = Object.freeze([
"third_party/marked/LICENSE"
]);
-const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.mjs"]);
+const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.mjs", "live-status.mjs"]);
const chineseWorkbenchLabels = Object.freeze([
"HWLAB 云工作台",
@@ -465,9 +466,9 @@ function runStaticSmoke() {
evidence: ["/health/live", "/v1", "/v1/diagnostics/gate", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods]
});
- addCheck(checks, blockers, "api-degraded-default-status", hasApiDegradedDefaultStatus(files), "Default workbench status distinguishes reachable blocked API from fully OK with concrete blocker copy.", {
+ 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: ["healthLive.data.status === degraded", "ready false", "runtime_durable_adapter_query_blocked", "runtime_durable_adapter_auth_blocked", "API 错误 / 只读模式", "可信记录受阻 / 只读模式", "no Gate/diagnostics copy in default status classifier"]
+ evidence: ["API 正常", "API 错误", "等待验证", "只读模式", "hwlab-cloud-api /health/live", "/v1/agent/chat", "/v1/m3/io", "raw degraded kept as internalRawStatuses"]
});
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.", {
@@ -1548,6 +1549,7 @@ function readStaticFiles() {
html: readText("web/hwlab-cloud-web/index.html"),
styles: readText("web/hwlab-cloud-web/styles.css"),
app: readText("web/hwlab-cloud-web/app.mjs"),
+ liveStatus: readText("web/hwlab-cloud-web/live-status.mjs"),
runtime: readText("web/hwlab-cloud-web/runtime.mjs"),
panel: readText("web/hwlab-cloud-web/workbench-hardware-panel.mjs"),
help: readText("web/hwlab-cloud-web/help.md")
@@ -2166,30 +2168,32 @@ function hasSameOriginReadOnlyBoundary(app) {
);
}
-function hasApiDegradedDefaultStatus({ app }) {
+function hasApiLiveStatusAttribution({ app, liveStatus }) {
const statusBody = functionBody(app, "workbenchApiSurfaceStatus");
- const readinessBody = functionBody(app, "workbenchSurfaceReadiness");
- const durableBlockBody = functionBody(app, "hasDurableRuntimeQueryBlock");
return (
/function\s+workbenchApiSurfaceStatus\s*\(/u.test(app) &&
- /function\s+workbenchSurfaceReadiness\s*\(/u.test(app) &&
- /function\s+hasDurableRuntimeQueryBlock\s*\(/u.test(app) &&
- /surface\.durableQueryBlocked/u.test(statusBody) &&
- /surface\.degraded/u.test(statusBody) &&
- /function\s+readinessBooleanValues\s*\(/u.test(app) &&
- /payload\.ready/u.test(functionBody(app, "readinessBooleanValues")) &&
- /payload\.readiness\?\.ready/u.test(functionBody(app, "readinessBooleanValues")) &&
- /readyValues\.includes\(false\)/u.test(readinessBody) &&
- /runtime_durable_adapter_query_blocked/u.test(durableBlockBody) &&
- /runtime_durable_adapter_/u.test(durableBlockBody) &&
- /payload\.runtime\?\.blocker/u.test(durableBlockBody) &&
- /payload\.readiness\?\.durability\?\.blocker/u.test(durableBlockBody) &&
- /blockedLayer === "durability_query"/u.test(durableBlockBody) &&
- /可信记录受阻 \/ 只读模式/u.test(statusBody) &&
- /API 错误 \/ 只读模式/u.test(statusBody) &&
- /apiDurableBlockerDetail\(coreProbes\)/u.test(statusBody) &&
- /apiProbeErrorDetail\(coreProbes\)/u.test(statusBody) &&
- /\/health\/live 可响应,但可信记录持久化尚未就绪:/u.test(app) &&
+ /classifyWorkbenchLiveStatus/u.test(statusBody) &&
+ /export function classifyWorkbenchLiveStatus/u.test(liveStatus) &&
+ /label:\s*"API 正常"/u.test(liveStatus) &&
+ /label:\s*"API 错误"/u.test(liveStatus) &&
+ /label:\s*"等待验证"/u.test(liveStatus) &&
+ /label:\s*"只读模式"/u.test(liveStatus) &&
+ /internalRawStatuses/u.test(liveStatus) &&
+ /classifyCodeAgentProbe/u.test(liveStatus) &&
+ /classifyM3ControlProbe/u.test(liveStatus) &&
+ /classifyM3StatusProbe/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) &&
+ /runtime_durable_adapter_/u.test(liveStatus) &&
+ /fallback_text_chat_only/u.test(liveStatus) &&
+ /provider_http_/u.test(liveStatus) &&
+ !/surface\.degraded/u.test(statusBody) &&
+ !/API 错误 \/ 只读模式/u.test(app) &&
+ !/可信记录受阻 \/ 只读模式/u.test(app) &&
!/API 降级 \/ 只读模式/u.test(app) &&
!/runtime_durable_adapter_query_blocked|DB live readiness|Gate|诊断|验收|M0-M5|执行轨迹/u.test(statusBody)
);
@@ -3486,12 +3490,11 @@ async function inspectDefaultApiStatus(page) {
liveStatus,
liveDetail,
firstScreenText,
- degradedStatusVisible:
- (liveStatus === "可信记录受阻 / 只读模式" || liveStatus === "API 错误 / 只读模式") &&
- (
- liveDetail.startsWith("/health/live 可响应,但可信记录持久化尚未就绪:") ||
- liveDetail === "同源 API 返回 blocked/degraded/failed 或 ready=false;当前保持只读查看和本地任务整理。"
- ),
+ actionableStatusVisible:
+ ["API 正常", "API 错误", "等待验证", "只读模式"].includes(liveStatus) &&
+ /^(通过|错误|等待验证|只读):/u.test(liveDetail) &&
+ /hwlab-[a-z-]+ .*(\/health\/live|\/v1|\/json-rpc|\/v1\/agent\/chat|\/v1\/m3\/io|\/v1\/m3\/status)/u.test(liveDetail) &&
+ !/API 降级|degraded\/read-only|blocked\/degraded\/failed/u.test(`${liveStatus} ${liveDetail}`),
defaultCopyIsWorkbenchSafe: !forbidden.test(`${liveStatus} ${liveDetail} ${firstScreenText}`)
};
});
@@ -3603,7 +3606,7 @@ async function runLocalAgentFixtureSmoke({
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
await page.waitForFunction(
- () => document.querySelector("#live-status")?.textContent?.trim() === "可信记录受阻 / 只读模式",
+ () => document.querySelector("#live-status")?.textContent?.trim() === "只读模式",
null,
{ timeout: 8000 }
);
@@ -3665,9 +3668,9 @@ async function runLocalAgentFixtureSmoke({
observations: controls
},
{
- id: "local-api-degraded-default-status",
- status: apiStatus.degradedStatusVisible && apiStatus.defaultCopyIsWorkbenchSafe ? "pass" : "blocked",
- summary: "Default workbench status renders reachable blocked /health/live as concrete read-only status without first-screen backend route details.",
+ id: "local-api-status-attribution",
+ status: apiStatus.actionableStatusVisible && apiStatus.defaultCopyIsWorkbenchSafe ? "pass" : "blocked",
+ summary: "Default workbench status renders reachable blocked /health/live as read-only with concrete service/API attribution and no raw degraded conclusion.",
observations: apiStatus
},
{
@@ -4373,6 +4376,28 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {}
});
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, {
diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs
index 4b171d08..b49f0651 100644
--- a/web/hwlab-cloud-web/app.mjs
+++ b/web/hwlab-cloud-web/app.mjs
@@ -1,4 +1,5 @@
import { gateSummary } from "./gate-summary.mjs";
+import { classifyWorkbenchLiveStatus } from "./live-status.mjs";
import { marked } from "./third_party/marked/marked.esm.js";
const DEFAULT_API_TIMEOUT_MS = 4500;
@@ -628,9 +629,9 @@ function renderStaticWorkbench() {
}
function renderProbePending() {
- el.liveStatus.textContent = "只读模式";
- el.liveStatus.className = "tone-source";
- el.liveDetail.textContent = "可整理任务、查看资源、核对接线和可信记录;不会触发硬件变更。";
+ el.liveStatus.textContent = "等待验证";
+ el.liveStatus.className = "tone-pending";
+ el.liveDetail.textContent = "正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。";
}
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc")) {
@@ -937,148 +938,19 @@ function renderLiveSurface(live) {
}
function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]) {
- const surface = workbenchSurfaceReadiness(live, coreProbes);
- if (!surface.reachable) {
- return {
- label: "离线查看",
- tone: "source",
- methodTone: "degraded",
- detail: "同源 API 暂不可用,当前仍可使用本地草稿和已加载资源。"
- };
- }
-
- if (surface.durableQueryBlocked) {
- return {
- label: "可信记录受阻 / 只读模式",
- tone: "blocked",
- methodTone: "degraded",
- detail: apiDurableBlockerDetail(coreProbes)
- };
- }
-
- if (surface.degraded) {
- return {
- label: "API 错误 / 只读模式",
- tone: "blocked",
- methodTone: "degraded",
- detail: apiProbeErrorDetail(coreProbes)
- };
- }
-
+ const status = classifyWorkbenchLiveStatus({
+ ...live,
+ healthLive: live.healthLive ?? coreProbes[0],
+ restIndex: live.restIndex ?? coreProbes[1],
+ health: live.health ?? coreProbes[2],
+ adapter: live.adapter ?? coreProbes[3]
+ });
return {
- label: "只读连接可用",
- tone: "dev-live",
- methodTone: "live",
- detail: null
+ ...status,
+ methodTone: status.kind === "pass" ? "live" : status.kind
};
}
-function apiDurableBlockerDetail(coreProbes = []) {
- const payloads = coreProbes
- .filter((probe) => probe?.ok)
- .map((probe) => probe.data)
- .filter((payload) => payload && typeof payload === "object");
- const blocker = safeDurableBlockerLabel(payloads.map(durableRuntimeBlockerCode).find(Boolean));
- return `/health/live 可响应,但可信记录持久化尚未就绪:${blocker};当前只保留只读查看和本地任务整理。`;
-}
-
-function safeDurableBlockerLabel(blocker) {
- const normalized = normalizedStatusValue(blocker);
- if (normalized.includes("auth")) return "runtime durable adapter auth blocked";
- if (normalized.includes("schema")) return "runtime durable adapter schema blocked";
- if (normalized.includes("migration")) return "runtime durable adapter migration blocked";
- if (normalized.includes("ssl")) return "runtime durable adapter ssl blocked";
- if (normalized.includes("query")) return "runtime durable adapter query blocked";
- return "runtime durable adapter blocked";
-}
-
-function apiProbeErrorDetail(coreProbes = []) {
- const failedProbe = coreProbes.find((probe) => probe && probe.ok !== true);
- if (failedProbe?.error?.message) {
- return `同源 API 探测失败:${failedProbe.error.message};当前保持只读查看和本地任务整理。`;
- }
- return "同源 API 返回 blocked/degraded/failed 或 ready=false;当前保持只读查看和本地任务整理。";
-}
-
-function workbenchSurfaceReadiness(live, coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]) {
- const payloads = coreProbes
- .filter((probe) => probe?.ok)
- .map((probe) => probe.data)
- .filter((payload) => payload && typeof payload === "object");
- const statuses = payloads.flatMap(readinessStatusValues).map(normalizedStatusValue);
- const readyValues = payloads.flatMap(readinessBooleanValues);
- return {
- reachable: payloads.length > 0,
- degraded:
- statuses.some((status) => ["blocked", "degraded", "failed"].includes(status)) ||
- readyValues.includes(false),
- durableQueryBlocked: payloads.some(hasDurableRuntimeQueryBlock)
- };
-}
-
-function readinessStatusValues(payload) {
- return [
- payload.status,
- payload.readiness?.status,
- payload.readiness?.durability?.status,
- payload.runtime?.status
- ].filter((value) => value !== undefined && value !== null);
-}
-
-function readinessBooleanValues(payload) {
- return [
- payload.ready,
- payload.readiness?.ready,
- payload.readiness?.durability?.ready,
- payload.runtime?.ready
- ].filter((value) => typeof value === "boolean");
-}
-
-function hasDurableRuntimeQueryBlock(payload) {
- const legacyQueryBlockerCode = "runtime_durable_adapter_query_blocked";
- const codes = [
- ...(Array.isArray(payload.blockerCodes) ? payload.blockerCodes : []),
- ...(Array.isArray(payload.readiness?.blockerCodes) ? payload.readiness.blockerCodes : []),
- ...(Array.isArray(payload.blockers) ? payload.blockers.map((item) => item?.code) : []),
- ...(Array.isArray(payload.readiness?.blockers) ? payload.readiness.blockers.map((item) => item?.code) : []),
- payload.runtime?.blocker,
- payload.readiness?.durability?.blocker
- ].map(normalizedStatusValue);
- const queryResult = normalizedStatusValue(payload.runtime?.connection?.queryResult ?? payload.readiness?.durability?.queryResult);
- const blockedLayer = normalizedStatusValue(payload.runtime?.durabilityContract?.blockedLayer ?? payload.readiness?.durability?.blockedLayer);
- return (
- codes.includes(legacyQueryBlockerCode) ||
- codes.some((code) => code.startsWith("runtime_durable_adapter_") && code.endsWith("_blocked")) ||
- queryResult.endsWith("_blocked") ||
- blockedLayer === "durability_query" ||
- blockedLayer === "durability_auth" ||
- blockedLayer === "durability_schema" ||
- blockedLayer === "durability_migration" ||
- blockedLayer === "durability_ssl"
- );
-}
-
-function durableRuntimeBlockerCode(payload) {
- if (!payload || typeof payload !== "object") return null;
- const candidates = [
- ...(Array.isArray(payload.blockerCodes) ? payload.blockerCodes : []),
- ...(Array.isArray(payload.readiness?.blockerCodes) ? payload.readiness.blockerCodes : []),
- ...(Array.isArray(payload.blockers) ? payload.blockers.map((item) => item?.code) : []),
- ...(Array.isArray(payload.readiness?.blockers) ? payload.readiness.blockers.map((item) => item?.code) : []),
- payload.runtime?.blocker,
- payload.readiness?.durability?.blocker,
- payload.runtime?.connection?.queryResult,
- payload.readiness?.durability?.queryResult,
- payload.runtime?.durabilityContract?.blockedLayer,
- payload.readiness?.durability?.blockedLayer
- ];
- return candidates.find((value) => normalizedStatusValue(value).includes("durable") || normalizedStatusValue(value).endsWith("_blocked")) ?? null;
-}
-
-function normalizedStatusValue(value) {
- return String(value ?? "").trim().toLowerCase();
-}
-
function renderResourceTree() {
const items = [
{
diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html
index 8a2552ee..c8166b46 100644
--- a/web/hwlab-cloud-web/index.html
+++ b/web/hwlab-cloud-web/index.html
@@ -68,8 +68,8 @@
工作区状态
- 只读模式
- 可整理任务、查看资源、核对接线和可信记录;不会触发硬件变更。
+ 等待验证
+ 正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。
diff --git a/web/hwlab-cloud-web/live-status.mjs b/web/hwlab-cloud-web/live-status.mjs
new file mode 100644
index 00000000..e9f735d0
--- /dev/null
+++ b/web/hwlab-cloud-web/live-status.mjs
@@ -0,0 +1,562 @@
+const PASS_STATUSES = Object.freeze(["ok", "ready", "pass", "passed", "healthy", "available", "live"]);
+const ERROR_STATUSES = Object.freeze(["error", "failed", "failure"]);
+const RAW_DEGRADED_STATUSES = Object.freeze(["degraded"]);
+const BLOCKED_STATUSES = Object.freeze(["blocked", "unavailable", "rejected"]);
+const UNVERIFIED_STATUSES = Object.freeze(["", "unknown", "pending", "loading", "probing", "unverified", "not_observed"]);
+const READ_ONLY_REASON_CODES = Object.freeze([
+ "read_only",
+ "read-only",
+ "readonly",
+ "permission",
+ "permissions",
+ "forbidden",
+ "unauthorized",
+ "runtime_durable",
+ "runtime-durable",
+ "durability",
+ "durable",
+ "db_persistence",
+ "persistence",
+ "not-codex-stdio",
+ "not-write-capable",
+ "not-durable-session",
+ "text-chat-only",
+ "fallback:text-chat-only",
+ "security_blocked"
+]);
+
+export function classifyWorkbenchLiveStatus(live = {}) {
+ const probes = collectLiveStatusProbes(live);
+ const reachable = probes.some((probe) => probe.reachable);
+ if (!reachable) {
+ const failedProbe = probes.find((probe) => !probe.reachable) ?? probes[0];
+ return statusResult({
+ kind: "unverified",
+ label: "等待验证",
+ tone: "pending",
+ serviceId: failedProbe?.serviceId ?? "hwlab-cloud-api",
+ apiPath: failedProbe?.apiPath ?? "/health/live",
+ reasonCode: failedProbe?.reasonCode ?? "api_unverified",
+ detail: failedProbe
+ ? `等待验证:${probeAttribution(failedProbe)}。`
+ : "等待同源 API 验证;主工作台不会把未探测状态当作通过。",
+ probes
+ });
+ }
+
+ const errorProbe = probes.find((probe) => probe.kind === "error");
+ if (errorProbe) {
+ return statusResult({
+ kind: "error",
+ label: "API 错误",
+ tone: "blocked",
+ serviceId: errorProbe.serviceId,
+ apiPath: errorProbe.apiPath,
+ reasonCode: errorProbe.reasonCode,
+ traceId: errorProbe.traceId,
+ detail: `错误:${probeAttribution(errorProbe)}。`,
+ probes
+ });
+ }
+
+ const readOnlyProbe = probes.find((probe) => probe.kind === "readonly");
+ if (readOnlyProbe) {
+ return statusResult({
+ kind: "readonly",
+ label: "只读模式",
+ tone: "source",
+ serviceId: readOnlyProbe.serviceId,
+ apiPath: readOnlyProbe.apiPath,
+ reasonCode: readOnlyProbe.reasonCode,
+ traceId: readOnlyProbe.traceId,
+ detail: `只读:${probeAttribution(readOnlyProbe)}。`,
+ probes
+ });
+ }
+
+ const pendingProbe = probes.find((probe) => probe.kind === "unverified");
+ if (pendingProbe) {
+ return statusResult({
+ kind: "unverified",
+ label: "等待验证",
+ tone: "pending",
+ serviceId: pendingProbe.serviceId,
+ apiPath: pendingProbe.apiPath,
+ reasonCode: pendingProbe.reasonCode,
+ traceId: pendingProbe.traceId,
+ detail: `等待验证:${probeAttribution(pendingProbe)}。`,
+ probes
+ });
+ }
+
+ return statusResult({
+ kind: "pass",
+ label: "API 正常",
+ tone: "dev-live",
+ serviceId: "hwlab-cloud-api",
+ apiPath: "/health/live",
+ reasonCode: "ready",
+ detail: "通过:hwlab-cloud-api /health/live ready;关键同源 API 已完成当前工作台只读验证。",
+ probes
+ });
+}
+
+export function collectLiveStatusProbes(live = {}) {
+ return [
+ classifyApiProbe(live.healthLive, {
+ serviceId: "hwlab-cloud-api",
+ apiPath: "/health/live",
+ fallbackReasonCode: "health_live_unverified"
+ }),
+ classifyApiProbe(live.restIndex, {
+ serviceId: "hwlab-cloud-api",
+ apiPath: "/v1",
+ fallbackReasonCode: "rest_index_unverified"
+ }),
+ classifyApiProbe(live.health, {
+ serviceId: "hwlab-cloud-api",
+ apiPath: "/json-rpc system.health",
+ fallbackReasonCode: "system_health_unverified"
+ }),
+ classifyApiProbe(live.adapter, {
+ serviceId: "hwlab-cloud-api",
+ apiPath: "/json-rpc cloud.adapter.describe",
+ fallbackReasonCode: "adapter_describe_unverified"
+ }),
+ classifyCodeAgentProbe(live),
+ classifyM3ControlProbe(live),
+ classifyM3StatusProbe(live)
+ ].filter(Boolean);
+}
+
+function classifyApiProbe(probe, context) {
+ if (!probe) {
+ return unverifiedProbe({
+ ...context,
+ reasonCode: context.fallbackReasonCode,
+ reason: "not_observed"
+ });
+ }
+ if (probe.ok !== true) {
+ return errorProbe({
+ ...context,
+ reasonCode: probe.timeout ? "timeout" : probe.status ? `HTTP ${probe.status}` : context.fallbackReasonCode,
+ reason: probe.error ?? "request_failed",
+ httpStatus: probe.status,
+ traceId: traceIdFrom(probe)
+ });
+ }
+
+ const payload = probe.data;
+ const serviceId = payload?.serviceId ?? context.serviceId;
+ const durableReason = durableRuntimeReason(payload);
+ if (durableReason) {
+ return readOnlyProbe({
+ ...context,
+ serviceId,
+ ...durableReason,
+ rawStatus: rawStatusFrom(payload),
+ traceId: traceIdFrom(payload)
+ });
+ }
+
+ const blocker = firstBlocker(payload);
+ if (blocker) {
+ const reasonCode = String(blocker.code ?? blocker.reason ?? "blocked");
+ const reason = blocker.summary ?? blocker.message ?? blocker.reason ?? reasonCode;
+ if (isReadOnlyReason(reasonCode) || isReadOnlyReason(reason)) {
+ return readOnlyProbe({
+ ...context,
+ serviceId,
+ reasonCode,
+ reason,
+ rawStatus: rawStatusFrom(payload),
+ traceId: traceIdFrom(payload)
+ });
+ }
+ return errorProbe({
+ ...context,
+ serviceId,
+ reasonCode,
+ reason,
+ rawStatus: rawStatusFrom(payload),
+ traceId: traceIdFrom(payload)
+ });
+ }
+
+ const status = normalizedStatus(rawStatusFrom(payload));
+ if (ERROR_STATUSES.includes(status)) {
+ return errorProbe({
+ ...context,
+ serviceId,
+ reasonCode: status,
+ reason: "API returned error status",
+ rawStatus: rawStatusFrom(payload),
+ traceId: traceIdFrom(payload)
+ });
+ }
+ if (RAW_DEGRADED_STATUSES.includes(status) || BLOCKED_STATUSES.includes(status) || readinessFalse(payload)) {
+ return errorProbe({
+ ...context,
+ serviceId,
+ reasonCode: readinessFalse(payload) ? "ready_false_without_blocker" : status,
+ reason: readinessFalse(payload) ? "API returned ready=false without a concrete blocker" : `API returned ${status}`,
+ rawStatus: rawStatusFrom(payload),
+ traceId: traceIdFrom(payload)
+ });
+ }
+ if (UNVERIFIED_STATUSES.includes(status)) {
+ return unverifiedProbe({
+ ...context,
+ serviceId,
+ reasonCode: context.fallbackReasonCode,
+ reason: "status_not_observed",
+ rawStatus: rawStatusFrom(payload),
+ traceId: traceIdFrom(payload)
+ });
+ }
+ return passProbe({
+ ...context,
+ serviceId,
+ reasonCode: status || "ready",
+ reason: "ready",
+ rawStatus: rawStatusFrom(payload),
+ traceId: traceIdFrom(payload)
+ });
+}
+
+function classifyCodeAgentProbe(live = {}) {
+ const availability = live.restIndex?.data?.codeAgent ?? live.healthLive?.data?.codeAgent;
+ if (!availability) {
+ return unverifiedProbe({
+ serviceId: "hwlab-cloud-api",
+ apiPath: "/v1/agent/chat",
+ reasonCode: "code_agent_unverified",
+ reason: "availability_not_observed"
+ });
+ }
+ const serviceId = "hwlab-cloud-api";
+ const apiPath = availability.endpoint ?? "POST /v1/agent/chat";
+ const provider = availability.provider ?? "not_observed";
+ const backend = availability.backend ?? "not_observed";
+ const capability = availability.capabilityLevel ?? availability.runner?.capabilityLevel;
+ const providerStatus = availability.error?.providerStatus ?? availability.providerStatus;
+ if (availability.status === "blocked" || availability.ready === false) {
+ return errorProbe({
+ serviceId,
+ apiPath,
+ reasonCode: providerStatus ? `provider_http_${providerStatus}` : availability.reason ?? availability.blocker ?? "code_agent_blocked",
+ reason: availability.summary ?? `provider=${provider}; backend=${backend}; capability=${capability ?? "unknown"}`,
+ traceId: traceIdFrom(availability),
+ meta: { provider, backend, capability }
+ });
+ }
+ if (capability === "text-chat-only" || availability.runner?.kind === "openai-responses-fallback") {
+ return readOnlyProbe({
+ serviceId,
+ apiPath,
+ reasonCode: "fallback_text_chat_only",
+ reason: `provider=${provider}; backend=${backend}; capability=text-chat-only`,
+ traceId: traceIdFrom(availability),
+ meta: { provider, backend, capability }
+ });
+ }
+ if (availability.runner?.ready === true && availability.runner?.writeCapable === false) {
+ return readOnlyProbe({
+ serviceId,
+ apiPath,
+ reasonCode: "codex_readonly_runner",
+ reason: `runner=${availability.runner.kind ?? "hwlab-readonly-runner"}; capability=${capability ?? "read-only-tools"}`,
+ traceId: traceIdFrom(availability),
+ meta: { provider, backend, capability }
+ });
+ }
+ if (availability.status === "available" || availability.ready === true) {
+ return passProbe({
+ serviceId,
+ apiPath,
+ reasonCode: capability ?? "available",
+ reason: `provider=${provider}; backend=${backend}`,
+ traceId: traceIdFrom(availability),
+ meta: { provider, backend, capability }
+ });
+ }
+ return unverifiedProbe({
+ serviceId,
+ apiPath,
+ reasonCode: "code_agent_unverified",
+ reason: `provider=${provider}; backend=${backend}`,
+ traceId: traceIdFrom(availability),
+ meta: { provider, backend, capability }
+ });
+}
+
+function classifyM3ControlProbe(live = {}) {
+ const probe = live.m3Control;
+ const context = {
+ serviceId: "hwlab-cloud-api",
+ apiPath: "/v1/m3/io",
+ fallbackReasonCode: "m3_io_unverified"
+ };
+ if (!probe) {
+ return unverifiedProbe({ ...context, reasonCode: context.fallbackReasonCode, reason: "not_observed" });
+ }
+ if (probe.ok !== true) {
+ return errorProbe({
+ ...context,
+ reasonCode: probe.timeout ? "timeout" : probe.status ? `HTTP ${probe.status}` : "m3_io_request_failed",
+ reason: probe.error ?? "M3 IO readiness request failed",
+ httpStatus: probe.status,
+ traceId: traceIdFrom(probe)
+ });
+ }
+ const data = probe.data;
+ const readiness = data?.readiness ?? {};
+ if (data?.status === "available" && readiness.status === "ready" && readiness.controlReady === true) {
+ return passProbe({
+ ...context,
+ reasonCode: "m3_io_ready",
+ reason: "cloud-api -> gateway-simu -> box-simu -> hwlab-patch-panel readiness ready",
+ rawStatus: data.status,
+ traceId: traceIdFrom(data)
+ });
+ }
+ const blocker = readiness.blocker ?? data?.blocker;
+ return errorProbe({
+ ...context,
+ serviceId: serviceForM3Layer(blocker?.layer) ?? context.serviceId,
+ reasonCode: blocker?.code ?? data?.status ?? "m3_io_not_ready",
+ reason: blocker?.zh ?? data?.blockedReason ?? "M3 IO readiness is not ready",
+ rawStatus: data?.status,
+ traceId: traceIdFrom(data)
+ });
+}
+
+function classifyM3StatusProbe(live = {}) {
+ const probe = live.m3Status;
+ const context = {
+ serviceId: "hwlab-cloud-api",
+ apiPath: "/v1/m3/status",
+ fallbackReasonCode: "m3_status_unverified"
+ };
+ if (!probe) {
+ return unverifiedProbe({ ...context, reasonCode: context.fallbackReasonCode, reason: "not_observed" });
+ }
+ if (probe.ok !== true) {
+ return errorProbe({
+ ...context,
+ reasonCode: probe.timeout ? "timeout" : probe.status ? `HTTP ${probe.status}` : "m3_status_request_failed",
+ reason: probe.error ?? "M3 status aggregation failed",
+ httpStatus: probe.status,
+ traceId: traceIdFrom(probe)
+ });
+ }
+ const data = probe.data;
+ const blocker = data?.blocker;
+ if (data?.status === "error") {
+ return errorProbe({
+ ...context,
+ serviceId: serviceForM3Layer(blocker?.layer) ?? context.serviceId,
+ reasonCode: blocker?.code ?? "m3_status_error",
+ reason: blocker?.zh ?? data?.summary ?? "M3 status returned error",
+ rawStatus: data.status,
+ traceId: traceIdFrom(data)
+ });
+ }
+ if (data?.status === "blocked" && blocker) {
+ const serviceId = serviceForM3Layer(blocker.layer) ?? (isReadOnlyReason(blocker.code) ? context.serviceId : context.serviceId);
+ const factory = isReadOnlyReason(blocker.code) || isReadOnlyReason(blocker.zh) ? readOnlyProbe : errorProbe;
+ return factory({
+ ...context,
+ serviceId,
+ reasonCode: blocker.code ?? "m3_status_blocked",
+ reason: blocker.zh ?? data.summary ?? "M3 status blocked",
+ rawStatus: data.status,
+ traceId: traceIdFrom(data)
+ });
+ }
+ if (data?.status === "blocked") {
+ return unverifiedProbe({
+ ...context,
+ reasonCode: "m3_status_waiting_trusted_record",
+ reason: data.summary ?? "M3 status is waiting for trusted persistence evidence",
+ rawStatus: data.status,
+ traceId: traceIdFrom(data)
+ });
+ }
+ if (data?.status === "live" || data?.sourceKind === "DEV-LIVE") {
+ return passProbe({
+ ...context,
+ reasonCode: data.status ?? "dev_live",
+ reason: data.summary ?? "M3 status observed through cloud-api",
+ rawStatus: data.status,
+ traceId: traceIdFrom(data)
+ });
+ }
+ return unverifiedProbe({
+ ...context,
+ reasonCode: "m3_status_unverified",
+ reason: data?.summary ?? "M3 status not yet verified",
+ rawStatus: data?.status,
+ traceId: traceIdFrom(data)
+ });
+}
+
+function durableRuntimeReason(payload) {
+ const runtime = payload?.runtime;
+ const durability = payload?.readiness?.durability;
+ const reasonCode = firstNonEmpty(
+ runtime?.blocker,
+ durability?.blocker,
+ runtime?.connection?.queryResult,
+ durability?.queryResult,
+ runtime?.durabilityContract?.blockedLayer,
+ durability?.blockedLayer,
+ blockerCodeStartingWith(payload, "runtime_durable_adapter_")
+ );
+ const blockedLayer = firstNonEmpty(runtime?.durabilityContract?.blockedLayer, durability?.blockedLayer);
+ const runtimeDurable = runtime?.durable === true || durability?.durable === true;
+ const runtimeExplicitlyNonDurable = runtime?.durable === false || durability?.durable === false;
+ const runtimeReady = runtime?.ready === true || durability?.ready === true;
+ const liveRuntimeEvidence = runtime?.liveRuntimeEvidence === true || durability?.liveRuntimeEvidence === true;
+ if (!reasonCode && runtimeDurable && runtimeReady && liveRuntimeEvidence) return null;
+ if (runtimeExplicitlyNonDurable) {
+ return {
+ reasonCode: "runtime_durable_false",
+ reason: `runtime durable=false; adapter=${firstNonEmpty(runtime?.adapter, durability?.adapter, "unknown")}`,
+ layer: "runtime-durable"
+ };
+ }
+ if (
+ reasonCode &&
+ (
+ isReadOnlyReason(reasonCode) ||
+ String(reasonCode).endsWith("_blocked") ||
+ normalizedStatus(blockedLayer).includes("durability")
+ )
+ ) {
+ return {
+ reasonCode,
+ reason: `runtime durable blocked at ${blockedLayer || "durability"}; queryResult=${firstNonEmpty(runtime?.connection?.queryResult, durability?.queryResult, "unknown")}`,
+ layer: blockedLayer || "runtime-durable"
+ };
+ }
+ return null;
+}
+
+function firstBlocker(payload) {
+ const blockers = [
+ ...(Array.isArray(payload?.blockers) ? payload.blockers : []),
+ ...(Array.isArray(payload?.readiness?.blockers) ? payload.readiness.blockers : [])
+ ].filter(Boolean);
+ if (blockers.length > 0) return blockers[0];
+ const code = firstNonEmpty(
+ ...(Array.isArray(payload?.blockerCodes) ? payload.blockerCodes : []),
+ ...(Array.isArray(payload?.readiness?.blockerCodes) ? payload.readiness.blockerCodes : [])
+ );
+ return code ? { code, summary: code } : null;
+}
+
+function blockerCodeStartingWith(payload, prefix) {
+ return [
+ ...(Array.isArray(payload?.blockerCodes) ? payload.blockerCodes : []),
+ ...(Array.isArray(payload?.readiness?.blockerCodes) ? payload.readiness.blockerCodes : [])
+ ].find((code) => String(code ?? "").startsWith(prefix));
+}
+
+function readinessFalse(payload) {
+ return [
+ payload?.ready,
+ payload?.readiness?.ready,
+ payload?.readiness?.durability?.ready,
+ payload?.runtime?.ready
+ ].some((value) => value === false);
+}
+
+function rawStatusFrom(payload) {
+ return payload?.status ?? payload?.readiness?.status ?? payload?.runtime?.status ?? payload?.readiness?.durability?.status ?? "";
+}
+
+function statusResult(result) {
+ return {
+ serviceId: null,
+ apiPath: null,
+ reasonCode: null,
+ traceId: null,
+ internalRawStatuses: result.probes
+ .map((probe) => probe.rawStatus)
+ .filter((status) => status !== undefined && status !== null && String(status).trim() !== ""),
+ ...result
+ };
+}
+
+function passProbe(options) {
+ return probe({ kind: "pass", reachable: true, ...options });
+}
+
+function readOnlyProbe(options) {
+ return probe({ kind: "readonly", reachable: true, ...options });
+}
+
+function errorProbe(options) {
+ return probe({ kind: "error", reachable: Boolean(options.httpStatus ? options.httpStatus >= 200 && options.httpStatus < 600 : true), ...options });
+}
+
+function unverifiedProbe(options) {
+ return probe({ kind: "unverified", reachable: false, ...options });
+}
+
+function probe(options) {
+ return {
+ kind: options.kind,
+ reachable: options.reachable,
+ serviceId: options.serviceId,
+ apiPath: options.apiPath,
+ reasonCode: String(options.reasonCode ?? options.kind),
+ reason: String(options.reason ?? options.reasonCode ?? options.kind),
+ httpStatus: options.httpStatus ?? null,
+ rawStatus: options.rawStatus ?? null,
+ traceId: options.traceId ?? null,
+ meta: options.meta ?? null
+ };
+}
+
+function probeAttribution(probe) {
+ return [
+ `${probe.serviceId} ${probe.apiPath}`,
+ probe.httpStatus ? `HTTP ${probe.httpStatus}` : null,
+ probe.reasonCode,
+ probe.reason && probe.reason !== probe.reasonCode ? probe.reason : null,
+ probe.traceId ? `trace=${probe.traceId}` : null
+ ].filter(Boolean).join(" / ");
+}
+
+function normalizedStatus(value) {
+ return String(value ?? "").trim().toLowerCase();
+}
+
+function isReadOnlyReason(value) {
+ const text = normalizedStatus(value);
+ return READ_ONLY_REASON_CODES.some((code) => text.includes(code));
+}
+
+function traceIdFrom(value) {
+ return firstNonEmpty(value?.traceId, value?.meta?.traceId, value?.request?.traceId, value?.operation?.traceId);
+}
+
+function serviceForM3Layer(layer) {
+ const text = normalizedStatus(layer);
+ if (!text) return null;
+ if (text.includes("patch")) return "hwlab-patch-panel";
+ if (text.includes("gateway")) return "hwlab-gateway-simu";
+ if (text.includes("box") || text.includes("resource")) return "hwlab-box-simu";
+ if (text.includes("runtime") || text.includes("durable")) return "hwlab-cloud-api";
+ return null;
+}
+
+function firstNonEmpty(...values) {
+ for (const value of values) {
+ if (value !== undefined && value !== null && String(value).trim() !== "") return String(value).trim();
+ }
+ return "";
+}
diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs
index 1f5152b1..4f4597ae 100644
--- a/web/hwlab-cloud-web/scripts/check.mjs
+++ b/web/hwlab-cloud-web/scripts/check.mjs
@@ -21,6 +21,7 @@ const requiredFiles = [
"index.html",
"styles.css",
"app.mjs",
+ "live-status.mjs",
"gate-summary.mjs",
"runtime.mjs",
"workbench-hardware-panel.mjs",
@@ -39,6 +40,7 @@ for (const file of requiredFiles) {
const html = fs.readFileSync(path.resolve(rootDir, "index.html"), "utf8");
const styles = fs.readFileSync(path.resolve(rootDir, "styles.css"), "utf8");
const app = fs.readFileSync(path.resolve(rootDir, "app.mjs"), "utf8");
+const liveStatus = fs.readFileSync(path.resolve(rootDir, "live-status.mjs"), "utf8");
const helpMarkdown = fs.readFileSync(path.resolve(rootDir, "help.md"), "utf8");
const buildScript = fs.readFileSync(path.resolve(rootDir, "scripts/build.mjs"), "utf8");
const distContractScript = fs.readFileSync(path.resolve(rootDir, "scripts/dist-contract.mjs"), "utf8");
@@ -432,6 +434,7 @@ assert.match(buildScript, /buildCloudWebDist/);
assert.match(distContractScript, /"help\.md"/);
assert.match(distContractScript, /"third_party\/marked\/marked\.esm\.js"/);
assert.match(distContractScript, /"third_party\/marked\/LICENSE"/);
+assert.match(distContractScript, /"live-status\.mjs"/);
assert.match(distContractScript, /gate\/index\.html/);
assert.match(distContractScript, /diagnostics\/gate\/index\.html/);
assert.match(distContractScript, /help\/index\.html/);
@@ -439,23 +442,30 @@ assert.match(markedLicense, /MarkedJS/);
assert.match(markedLicense, /Permission is hereby granted, free of charge/);
assert.match(html, /live-status/);
assert.match(app, /function workbenchApiSurfaceStatus/);
-assert.match(app, /function workbenchSurfaceReadiness/);
-assert.match(app, /function hasDurableRuntimeQueryBlock/);
-assert.match(functionBody(app, "workbenchApiSurfaceStatus"), /surface\.durableQueryBlocked/);
-assert.match(functionBody(app, "workbenchApiSurfaceStatus"), /surface\.degraded/);
-assert.match(app, /function readinessBooleanValues/);
-assert.match(functionBody(app, "readinessBooleanValues"), /payload\.ready/);
-assert.match(functionBody(app, "readinessBooleanValues"), /payload\.readiness\?\.ready/);
-assert.match(functionBody(app, "workbenchSurfaceReadiness"), /readyValues\.includes\(false\)/);
-assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /runtime_durable_adapter_query_blocked/);
-assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /runtime_durable_adapter_/);
-assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /payload\.runtime\?\.blocker/);
-assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /payload\.readiness\?\.durability\?\.blocker/);
-assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /blockedLayer === "durability_query"/);
-assert.match(app, /可信记录受阻 \/ 只读模式/);
-assert.match(app, /API 错误 \/ 只读模式/);
-assert.match(app, /同源 API 返回 blocked\/degraded\/failed 或 ready=false;当前保持只读查看和本地任务整理。/);
-assert.match(app, /\/health\/live 可响应,但可信记录持久化尚未就绪:/);
+assert.match(app, /classifyWorkbenchLiveStatus/);
+assert.match(liveStatus, /export function classifyWorkbenchLiveStatus/);
+assert.match(liveStatus, /label:\s*"API 正常"/);
+assert.match(liveStatus, /label:\s*"API 错误"/);
+assert.match(liveStatus, /label:\s*"等待验证"/);
+assert.match(liveStatus, /label:\s*"只读模式"/);
+assert.match(liveStatus, /internalRawStatuses/);
+assert.match(liveStatus, /function classifyCodeAgentProbe/);
+assert.match(liveStatus, /function classifyM3ControlProbe/);
+assert.match(liveStatus, /function classifyM3StatusProbe/);
+assert.match(liveStatus, /\/v1\/agent\/chat/);
+assert.match(liveStatus, /\/v1\/m3\/io/);
+assert.match(liveStatus, /\/v1\/m3\/status/);
+assert.match(liveStatus, /hwlab-cloud-api/);
+assert.match(liveStatus, /hwlab-patch-panel/);
+assert.match(liveStatus, /hwlab-gateway-simu/);
+assert.match(liveStatus, /hwlab-box-simu/);
+assert.match(liveStatus, /runtime_durable_adapter_/);
+assert.match(liveStatus, /fallback_text_chat_only/);
+assert.match(liveStatus, /provider_http_/);
+assert.match(liveStatus, /rawStatus/);
+assert.doesNotMatch(functionBody(app, "workbenchApiSurfaceStatus"), /surface\.degraded/);
+assert.doesNotMatch(app, /API 错误 \/ 只读模式/);
+assert.doesNotMatch(app, /可信记录受阻 \/ 只读模式/);
assert.doesNotMatch(app, /API 降级 \/ 只读模式/);
assert.doesNotMatch(functionBody(app, "workbenchApiSurfaceStatus"), /runtime_durable_adapter_query_blocked|DB live readiness|Gate|诊断|验收|M0-M5|执行轨迹/u);
assert.match(html, /quick-actions/);
diff --git a/web/hwlab-cloud-web/scripts/dist-contract.mjs b/web/hwlab-cloud-web/scripts/dist-contract.mjs
index fc75d77e..efa58aac 100644
--- a/web/hwlab-cloud-web/scripts/dist-contract.mjs
+++ b/web/hwlab-cloud-web/scripts/dist-contract.mjs
@@ -9,6 +9,7 @@ export const cloudWebDistRuntimeFiles = Object.freeze([
"index.html",
"styles.css",
"app.mjs",
+ "live-status.mjs",
"gate-summary.mjs",
"runtime.mjs",
"workbench-hardware-panel.mjs",
diff --git a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs
new file mode 100644
index 00000000..2610c648
--- /dev/null
+++ b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs
@@ -0,0 +1,313 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { classifyWorkbenchLiveStatus } from "../live-status.mjs";
+
+const okApi = Object.freeze({
+ ok: true,
+ status: 200,
+ data: {
+ serviceId: "hwlab-cloud-api",
+ status: "ok",
+ ready: true,
+ readiness: {
+ status: "ok",
+ ready: true,
+ durability: {
+ status: "ready",
+ ready: true
+ }
+ },
+ runtime: {
+ status: "ready",
+ durable: true,
+ ready: true,
+ liveRuntimeEvidence: true
+ }
+ }
+});
+
+const okRest = Object.freeze({
+ ok: true,
+ status: 200,
+ data: {
+ serviceId: "hwlab-cloud-api",
+ status: "ok",
+ ready: true,
+ codeAgent: {
+ endpoint: "POST /v1/agent/chat",
+ status: "available",
+ ready: true,
+ provider: "openai-responses",
+ backend: "hwlab-cloud-api/openai-responses",
+ capabilityLevel: "available"
+ }
+ }
+});
+
+const okM3Control = Object.freeze({
+ ok: true,
+ status: 200,
+ data: {
+ status: "available",
+ readiness: {
+ status: "ready",
+ controlReady: true,
+ sourceKind: "DEV-LIVE",
+ evidenceLevel: "DEV-LIVE"
+ }
+ }
+});
+
+const okM3Status = Object.freeze({
+ ok: true,
+ status: 200,
+ data: {
+ status: "live",
+ sourceKind: "DEV-LIVE",
+ summary: "M3 硬件状态与可信持久化均为 DEV-LIVE。"
+ }
+});
+
+test("classifies fully ready workbench runtime as API 正常", () => {
+ const status = classifyWorkbenchLiveStatus({
+ healthLive: okApi,
+ restIndex: okRest,
+ health: okApi,
+ adapter: okApi,
+ m3Control: okM3Control,
+ m3Status: okM3Status
+ });
+
+ assert.equal(status.kind, "pass");
+ assert.equal(status.label, "API 正常");
+ assert.match(status.detail, /hwlab-cloud-api \/health\/live/u);
+ assert.equal(status.internalRawStatuses.includes("degraded"), false);
+});
+
+test("classifies concrete API failure with service path and HTTP status", () => {
+ const status = classifyWorkbenchLiveStatus({
+ healthLive: {
+ ok: false,
+ path: "/health/live",
+ status: 503,
+ error: "/health/live HTTP 503:runtime unavailable"
+ },
+ restIndex: okRest,
+ health: okApi,
+ adapter: okApi,
+ m3Control: okM3Control,
+ m3Status: okM3Status
+ });
+
+ assert.equal(status.kind, "error");
+ assert.equal(status.label, "API 错误");
+ assert.equal(status.serviceId, "hwlab-cloud-api");
+ assert.equal(status.apiPath, "/health/live");
+ assert.equal(status.reasonCode, "HTTP 503");
+ assert.match(status.detail, /错误:hwlab-cloud-api \/health\/live \/ HTTP 503/u);
+});
+
+test("classifies durable runtime degraded as explicit read-only mode", () => {
+ const status = classifyWorkbenchLiveStatus({
+ healthLive: {
+ ok: true,
+ status: 200,
+ data: {
+ serviceId: "hwlab-cloud-api",
+ status: "degraded",
+ ready: false,
+ runtime: {
+ durable: true,
+ ready: false,
+ liveRuntimeEvidence: false,
+ status: "blocked",
+ blocker: "runtime_durable_adapter_schema_blocked",
+ connection: {
+ queryResult: "schema_blocked"
+ }
+ },
+ readiness: {
+ status: "degraded",
+ ready: false,
+ durability: {
+ status: "blocked",
+ ready: false,
+ blocker: "runtime_durable_adapter_schema_blocked",
+ blockedLayer: "schema",
+ queryResult: "schema_blocked"
+ }
+ },
+ blockerCodes: ["runtime_durable_adapter_schema_blocked"]
+ }
+ },
+ restIndex: okRest,
+ health: okApi,
+ adapter: okApi,
+ m3Control: okM3Control,
+ m3Status: okM3Status
+ });
+
+ assert.equal(status.kind, "readonly");
+ assert.equal(status.label, "只读模式");
+ assert.equal(status.reasonCode, "runtime_durable_adapter_schema_blocked");
+ assert.match(status.detail, /只读:hwlab-cloud-api \/health\/live/u);
+ assert.match(status.detail, /schema_blocked/u);
+ assert.deepEqual(status.internalRawStatuses.filter((item) => item === "degraded"), ["degraded"]);
+});
+
+test("classifies missing probes as unverified instead of degraded", () => {
+ const status = classifyWorkbenchLiveStatus({});
+
+ assert.equal(status.kind, "unverified");
+ assert.equal(status.label, "等待验证");
+ assert.match(status.detail, /等待验证/u);
+ assert.doesNotMatch(`${status.label} ${status.detail}`, /降级/u);
+});
+
+test("classifies memory or non-durable runtime as read-only with explicit reason", () => {
+ const status = classifyWorkbenchLiveStatus({
+ healthLive: okApi,
+ restIndex: okRest,
+ health: {
+ ok: true,
+ status: 200,
+ data: {
+ serviceId: "hwlab-cloud-api",
+ status: "degraded",
+ runtime: {
+ adapter: "memory",
+ durable: false
+ }
+ }
+ },
+ adapter: okApi,
+ m3Control: okM3Control,
+ m3Status: okM3Status
+ });
+
+ assert.equal(status.kind, "readonly");
+ assert.equal(status.label, "只读模式");
+ assert.equal(status.reasonCode, "runtime_durable_false");
+ assert.match(status.detail, /runtime durable=false/u);
+});
+
+test("classifies raw degraded without durable/read-only reason as API error", () => {
+ const status = classifyWorkbenchLiveStatus({
+ healthLive: {
+ ok: true,
+ status: 200,
+ data: {
+ serviceId: "hwlab-cloud-api",
+ status: "degraded",
+ ready: false
+ }
+ },
+ restIndex: okRest,
+ health: okApi,
+ adapter: okApi,
+ m3Control: okM3Control,
+ m3Status: okM3Status
+ });
+
+ assert.equal(status.kind, "error");
+ assert.equal(status.label, "API 错误");
+ assert.equal(status.reasonCode, "ready_false_without_blocker");
+ assert.match(status.detail, /ready=false without a concrete blocker/u);
+ assert.equal(status.internalRawStatuses.includes("degraded"), true);
+ assert.doesNotMatch(`${status.label} ${status.detail}`, /API 降级/u);
+});
+
+test("classifies Code Agent provider timeout or fallback with concrete attribution", () => {
+ const providerTimeout = classifyWorkbenchLiveStatus({
+ healthLive: okApi,
+ restIndex: {
+ ok: true,
+ status: 200,
+ data: {
+ serviceId: "hwlab-cloud-api",
+ status: "ok",
+ ready: true,
+ codeAgent: {
+ endpoint: "POST /v1/agent/chat",
+ status: "blocked",
+ ready: false,
+ provider: "openai-responses",
+ backend: "hwlab-cloud-api/openai-responses",
+ error: {
+ providerStatus: 504
+ },
+ summary: "/v1/agent/chat timeout"
+ }
+ }
+ },
+ health: okApi,
+ adapter: okApi,
+ m3Control: okM3Control,
+ m3Status: okM3Status
+ });
+ assert.equal(providerTimeout.kind, "error");
+ assert.equal(providerTimeout.apiPath, "POST /v1/agent/chat");
+ assert.equal(providerTimeout.reasonCode, "provider_http_504");
+ assert.match(providerTimeout.detail, /hwlab-cloud-api POST \/v1\/agent\/chat/u);
+
+ const fallback = classifyWorkbenchLiveStatus({
+ healthLive: okApi,
+ restIndex: {
+ ok: true,
+ status: 200,
+ data: {
+ serviceId: "hwlab-cloud-api",
+ status: "ok",
+ ready: true,
+ codeAgent: {
+ endpoint: "POST /v1/agent/chat",
+ status: "available",
+ ready: true,
+ provider: "openai-responses",
+ backend: "hwlab-cloud-api/openai-responses",
+ capabilityLevel: "text-chat-only"
+ }
+ }
+ },
+ health: okApi,
+ adapter: okApi,
+ m3Control: okM3Control,
+ m3Status: okM3Status
+ });
+ assert.equal(fallback.kind, "readonly");
+ assert.equal(fallback.label, "只读模式");
+ assert.equal(fallback.reasonCode, "fallback_text_chat_only");
+});
+
+test("classifies M3 control blockers by cloud-api/gateway/box/patch-panel layer", () => {
+ const status = classifyWorkbenchLiveStatus({
+ healthLive: okApi,
+ restIndex: okRest,
+ health: okApi,
+ adapter: okApi,
+ m3Control: {
+ ok: true,
+ status: 200,
+ data: {
+ status: "blocked",
+ readiness: {
+ status: "blocked",
+ controlReady: false,
+ blocker: {
+ code: "m3_wiring_missing",
+ layer: "hwlab-patch-panel",
+ zh: "hwlab-patch-panel route unavailable"
+ }
+ }
+ }
+ },
+ m3Status: okM3Status
+ });
+
+ assert.equal(status.kind, "error");
+ assert.equal(status.serviceId, "hwlab-patch-panel");
+ assert.equal(status.apiPath, "/v1/m3/io");
+ assert.equal(status.reasonCode, "m3_wiring_missing");
+ assert.match(status.detail, /hwlab-patch-panel \/v1\/m3\/io/u);
+});