diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 4dba9618..cff7ae0e 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -293,6 +293,11 @@ function runStaticSmoke() { evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods] }); + addCheck(checks, blockers, "api-degraded-default-status", hasApiDegradedDefaultStatus(files), "Default workbench status distinguishes reachable degraded API from fully OK without exposing backend blocker details.", { + blocker: "observability_blocker", + evidence: ["healthLive.data.status === degraded", "API 降级 / 只读模式", "no Gate/diagnostics/blocker copy in default status classifier"] + }); + 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.", { blocker: "runtime_blocker", evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] @@ -1067,6 +1072,18 @@ function hasSameOriginReadOnlyBoundary(app) { ); } +function hasApiDegradedDefaultStatus({ app }) { + const statusBody = functionBody(app, "workbenchApiSurfaceStatus"); + return ( + /function\s+workbenchApiSurfaceStatus\s*\(/u.test(app) && + /live\.healthLive\?\.data\?\.status/u.test(statusBody) && + /healthLiveStatus === "degraded"/u.test(statusBody) && + /API 降级 \/ 只读模式/u.test(statusBody) && + /同源 API 可响应但处于降级状态;当前保持只读查看和本地任务整理。/u.test(statusBody) && + !/blocker|DB live readiness|Gate|诊断|验收|M0-M5|执行轨迹/u.test(statusBody) + ); +} + function hasCodeAgentChatContract({ html, app }) { return ( /id=["']agent-chat-status["']/u.test(html) && @@ -1666,6 +1683,27 @@ async function inspectJourneyUi(page) { })); } +async function inspectDefaultApiStatus(page) { + return page.evaluate(() => { + const liveStatus = document.querySelector("#live-status")?.textContent?.trim() ?? ""; + const liveDetail = document.querySelector("#live-detail")?.textContent?.trim() ?? ""; + const methodText = [...document.querySelectorAll("#method-list .badge")].map((element) => element.textContent?.trim() ?? ""); + const firstScreenText = document.querySelector(".topbar")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""; + const forbidden = /Gate|诊断|验收|BLOCKED|M0-M5|执行轨迹|runtime_durable_adapter_query_blocked|DB live readiness/u; + return { + liveStatus, + liveDetail, + methodText, + firstScreenText, + degradedStatusVisible: + liveStatus === "API 降级 / 只读模式" && + liveDetail === "同源 API 可响应但处于降级状态;当前保持只读查看和本地任务整理。" && + methodText.includes("system.health"), + defaultCopyIsWorkbenchSafe: !forbidden.test(`${liveStatus} ${liveDetail} ${firstScreenText}`) + }; + }); +} + function sanitizeAgentChatBody(body) { if (!body || typeof body !== "object") return null; return { @@ -1728,7 +1766,13 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { 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() === "API 降级 / 只读模式", + null, + { timeout: 8000 } + ); const controls = await inspectJourneyControls(page); + const apiStatus = await inspectDefaultApiStatus(page); await page.locator("#command-input").fill("请用一句话说明当前 HWLAB 工作台可以做什么。"); const responsePromise = page.waitForResponse( (response) => response.url().includes("/v1/agent/chat") && response.request().method() === "POST", @@ -1761,6 +1805,12 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { summary: "Local SOURCE fixture browser smoke can locate the Code Agent input, send button, thread, and right-side evidence panels.", observations: controls }, + { + id: "local-api-degraded-default-status", + status: apiStatus.degradedStatusVisible && apiStatus.defaultCopyIsWorkbenchSafe ? "pass" : "blocked", + summary: "Default workbench status renders reachable degraded /health/live as API degraded read-only without first-screen backend blocker details.", + observations: apiStatus + }, { id: "local-agent-fixture-replied-state", status: pass ? "pass" : "blocked", diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index b864b962..e736ca2d 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -508,27 +508,54 @@ function nextProtocolId(prefix) { function renderLiveSurface(live) { state.liveSurface = live; const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]; - const reachable = coreProbes.some((probe) => probe.ok); + const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes); const runtimeSummary = runtimeSummaryFrom(live); const methods = methodsFrom(live); - el.liveStatus.textContent = reachable ? "只读连接可用" : "离线查看"; - el.liveStatus.className = `tone-${toneClass(reachable ? "dev-live" : "source")}`; + el.liveStatus.textContent = surfaceStatus.label; + el.liveStatus.className = `tone-${toneClass(surfaceStatus.tone)}`; state.codeAgentAvailability = codeAgentAvailabilityFrom(live); - el.liveDetail.textContent = reachable - ? codeAgentAvailabilitySummary() - : "同源 API 暂不可用,当前仍可使用本地草稿和已加载资源。"; + el.liveDetail.textContent = surfaceStatus.detail ?? codeAgentAvailabilitySummary(); renderAgentChatStatus(deriveAgentChatStatus()); renderHardwareStatus(runtimeSummary); renderWiringList(runtimeSummary); renderRecords(live); renderDiagnostics(live); - renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, reachable ? "live" : "degraded"); + renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, surfaceStatus.methodTone); renderConversation(); renderDrafts(); } +function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]) { + const reachable = coreProbes.some((probe) => probe.ok); + if (!reachable) { + return { + label: "离线查看", + tone: "source", + methodTone: "degraded", + detail: "同源 API 暂不可用,当前仍可使用本地草稿和已加载资源。" + }; + } + + const healthLiveStatus = String(live.healthLive?.data?.status ?? "").trim().toLowerCase(); + if (live.healthLive?.ok && healthLiveStatus === "degraded") { + return { + label: "API 降级 / 只读模式", + tone: "degraded", + methodTone: "degraded", + detail: "同源 API 可响应但处于降级状态;当前保持只读查看和本地任务整理。" + }; + } + + return { + label: "只读连接可用", + tone: "dev-live", + methodTone: "live", + detail: null + }; +} + function renderResourceTree() { const items = [ { diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 6d3191c8..d321f629 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -280,6 +280,12 @@ assert.match(distContractScript, /diagnostics\/gate\/index\.html/); 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(functionBody(app, "workbenchApiSurfaceStatus"), /live\.healthLive\?\.data\?\.status/); +assert.match(functionBody(app, "workbenchApiSurfaceStatus"), /healthLiveStatus === "degraded"/); +assert.match(app, /API 降级 \/ 只读模式/); +assert.match(app, /同源 API 可响应但处于降级状态;当前保持只读查看和本地任务整理。/); +assert.doesNotMatch(functionBody(app, "workbenchApiSurfaceStatus"), /blocker|DB live readiness|Gate|诊断|验收|M0-M5|执行轨迹/u); assert.match(html, /method-list/); assert.match(html, /quick-actions/); assert.match(html, /编写任务/);