diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index bb721b95..5ac749c0 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -622,6 +622,7 @@ lanes: HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: zh-CN HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL: 北京时间 HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true" + HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "true" HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false" HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED: "true" HWLAB_CLOUD_WEB_OPENCODE_UPSTREAM_URL: http://opencode-server.hwlab-v03.svc.cluster.local:4096 diff --git a/docs/MDTODO/cloud-web-spa-runtime-recovery.md b/docs/MDTODO/cloud-web-spa-runtime-recovery.md new file mode 100644 index 00000000..30a08b86 --- /dev/null +++ b/docs/MDTODO/cloud-web-spa-runtime-recovery.md @@ -0,0 +1,18 @@ +# Cloud Web SPA 运行面恢复 + +来源:https://github.com/pikasTech/HWLAB/issues/2494 + +目标是在 NC01/v03 的单一 `hwlab-cloud-web` 构建与路由合同内恢复 `/` 和 `/workbench`,不引入运行面补丁或第二路由。 + +## R1 [completed] + +恢复 NC01/v03 Cloud Web SPA,依据 https://github.com/pikasTech/HWLAB/issues/2494 完成根因、修复和 PR 交付,完成任务后将详细报告写入[任务报告](./details/cloud-web-spa-runtime-recovery/R1_Task_Report.md)。 +### R1.1 [completed] + +只读核对运行面 provenance、容器静态目录与应用路由,锁定 https://github.com/pikasTech/HWLAB/issues/2494 根因,完成任务后将详细报告写入[任务报告](./details/cloud-web-spa-runtime-recovery/R1.1_Task_Report.md)。 +### R1.2 [completed] + +在单一镜像与 SPA fallback 合同内实施最小修复,并完成定向源码测试,完成任务后将详细报告写入[任务报告](./details/cloud-web-spa-runtime-recovery/R1.2_Task_Report.md)。 +### R1.3 [completed] + +提交受控非 draft PR,记录自动链验收边界与 https://github.com/pikasTech/HWLAB/issues/2494 证据,完成任务后将详细报告写入[任务报告](./details/cloud-web-spa-runtime-recovery/R1.3_Task_Report.md)。 \ No newline at end of file diff --git a/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1.1_Task_Report.md b/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1.1_Task_Report.md new file mode 100644 index 00000000..bbe91666 --- /dev/null +++ b/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1.1_Task_Report.md @@ -0,0 +1,6 @@ +# R1.1 调查报告 + +- issue:https://github.com/pikasTech/HWLAB/issues/2494 +- NC01/v03 唯一 Web Pod 的 localhost 结果:`/` 与 `/workbench` 为 404,`/health/live` 为 200。 +- `dist/index.html` 实际存在;缺少 `HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED` 导致 HTML 运行配置注入抛错。 +- 静态文件循环宽泛捕获该配置错误并伪装为文件缺失,最终返回 404;健康路径未校验运行配置。 diff --git a/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1.2_Task_Report.md b/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1.2_Task_Report.md new file mode 100644 index 00000000..8e7f3451 --- /dev/null +++ b/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1.2_Task_Report.md @@ -0,0 +1,6 @@ +# R1.2 修复报告 + +- 在 v03 `hwlab-cloud-web` owning YAML 中显式开启独立 refresh replay 功能开关。 +- Web 启动监听前校验完整运行配置,缺配置时 fail-fast,不再产生假健康实例。 +- 静态文件候选只忽略 `ENOENT`/`ENOTDIR`,运行配置错误不再伪装为 404。 +- 定向测试:20/20 通过,覆盖缺少 refresh flag 的启动拒绝、SPA 根路径与深链渲染。 diff --git a/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1.3_Task_Report.md b/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1.3_Task_Report.md new file mode 100644 index 00000000..0032298e --- /dev/null +++ b/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1.3_Task_Report.md @@ -0,0 +1,6 @@ +# R1.3 交付报告 + +- issue:https://github.com/pikasTech/HWLAB/issues/2494 +- 分支:`fix/2494-cloud-web-404` +- PR 合并后只允许 GitHub webhook -> Gitea -> PaC -> Tekton -> GitOps/Argo 自动交付。 +- internal/public 原入口验收由主代理在自动滚动完成后执行;本任务不人工触发或修改运行面。 diff --git a/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1_Task_Report.md b/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1_Task_Report.md new file mode 100644 index 00000000..9e60da22 --- /dev/null +++ b/docs/MDTODO/details/cloud-web-spa-runtime-recovery/R1_Task_Report.md @@ -0,0 +1,5 @@ +# R1 总报告 + +已修复 Cloud Web 将缺失 Kafka refresh 功能开关伪装成静态文件 404、同时健康探针误报正常的问题。修复保持单一 Cloud Web 路由,并把功能开关保留为可组合的 YAML 字段。 + +定向 Node 测试 20/20 通过;线上恢复等待正常 PR 合并后的自动 CI/CD 滚动与 semantic internal/public 验收。 diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index a143a5bf..25f1e254 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -36,6 +36,7 @@ const opencodeTickets = new Map(); export async function serveCloudWeb(options) { installCloudWebProcessErrorHandlers({ serviceId: options.serviceId }); + await validateCloudWebRuntimeConfig(); const server = createCloudWebServer(options); server.listen(options.port, "0.0.0.0", () => { process.stdout.write(JSON.stringify({ @@ -119,7 +120,7 @@ export function createCloudWebServer({ logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, request, error }); return; } - // Try the next root for ordinary filesystem misses. + if (!isStaticFileMiss(error)) throw error; } } @@ -142,6 +143,14 @@ export function createCloudWebServer({ }); } +export async function validateCloudWebRuntimeConfig() { + await runtimeConfigFromEnv(); +} + +function isStaticFileMiss(error) { + return error?.code === "ENOENT" || error?.code === "ENOTDIR"; +} + async function staticResponseBody({ candidate, relativePath, type, request, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) { const body = await readFile(candidate); if (relativePath !== "index.html" || !String(type).startsWith("text/html")) return body; diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index e7713ab0..2e9a4304 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -7,7 +7,7 @@ import path from "node:path"; import test from "node:test"; import { gzipSync } from "node:zlib"; -import { createCloudWebServer, isClientDisconnectError } from "./cloud-web-runtime.mjs"; +import { createCloudWebServer, isClientDisconnectError, validateCloudWebRuntimeConfig } from "./cloud-web-runtime.mjs"; test("cloud web classifies Bun client disconnect ECONNRESET as non-fatal", () => { const error = new Error("aborted"); @@ -16,6 +16,26 @@ test("cloud web classifies Bun client disconnect ECONNRESET as non-fatal", () => assert.equal(isClientDisconnectError(new Error("database migration failed")), false); }); +test("cloud web rejects a missing refresh replay capability before listening", async () => { + const restoreEnv = withEnv({ + HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: "Asia/Shanghai", + HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: "zh-CN", + HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL: "北京时间", + HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true", + HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: undefined, + HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false", + HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED: "true", + HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_ENABLED: "true", + HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_MAX_ENTRIES: "200", + HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_MAX_RETAINED_BYTES: "1048576" + }); + try { + await assert.rejects(validateCloudWebRuntimeConfig(), /HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED/u); + } finally { + restoreEnv(); + } +}); + test("cloud web auth requests are proxied once", async () => { let upstreamRequests = 0; const upstream = createServer((request, response) => { diff --git a/scripts/runtime-realtime-capabilities-config.test.mjs b/scripts/runtime-realtime-capabilities-config.test.mjs index 51e9da48..86b74b8f 100644 --- a/scripts/runtime-realtime-capabilities-config.test.mjs +++ b/scripts/runtime-realtime-capabilities-config.test.mjs @@ -60,7 +60,7 @@ test("v03 YAML keeps pure Kafka realtime capabilities independently composable", assert.equal(cloudApi.env?.HWLAB_SESSION_COOKIE_DOMAIN, undefined, "internal and public origins require host-scoped session cookies"); assert.notEqual(cloudApi.env?.HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID, "hwlab-v03-agentrun-event-bridge"); assert.equal(cloudWeb.env?.HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED, "true"); - assert.equal(cloudWeb.env?.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED, undefined, "UniDesk renderer owns the Web refresh capability value"); + assert.equal(cloudWeb.env?.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED, "true"); assert.equal(cloudWeb.env?.HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED, "false"); assert.equal(cloudWeb.env?.HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED, "true"); });