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); +});