From 58b06fcd1727a5506c7975750e4ac182fdcbd584 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 3 Jun 2026 17:45:18 +0800 Subject: [PATCH] fix(web): enforce React TypeScript single path --- package.json | 2 +- scripts/dev-cloud-workbench-layout-smoke.mjs | 8 +- scripts/dev-cloud-workbench-smoke.test.mjs | 60 +- .../src/artifact-runtime-readiness-guard.mjs | 25 +- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 404 ++- scripts/src/dev-runtime-postflight.mjs | 2 +- scripts/src/m3-io-control-e2e.mjs | 21 +- tools/hwlab-cli/client.test.ts | 2 +- tools/src/hwlab-cli-lib.ts | 6 +- .../src/hwlab-cli}/composer-policy.ts | 0 tools/src/hwlab-cli/trace-renderer.ts | 229 ++ tools/src/runtime-durable-readiness.ts | 216 ++ web/hwlab-cloud-web/index.html | 4 +- web/hwlab-cloud-web/package.json | 5 +- web/hwlab-cloud-web/scripts/check.ts | 328 +- .../scripts/dist-contract.test.ts | 98 + web/hwlab-cloud-web/scripts/dist-contract.ts | 283 +- web/hwlab-cloud-web/scripts/frontend-guard.ts | 124 - web/hwlab-cloud-web/scripts/tsc-check.ts | 91 +- web/hwlab-cloud-web/src/App.tsx | 123 +- .../src/components/auth/LoginShell.tsx | 47 + .../src/components/auth/LoginView.tsx | 71 - .../src/components/command-bar/CommandBar.tsx | 112 +- .../conversation/ConversationPanel.tsx | 119 + .../conversation/ConversationView.tsx | 21 - .../components/conversation/MessageItem.tsx | 37 - .../components/conversation/MessageList.tsx | 19 - .../device-pod/DeviceDetailDialog.tsx | 39 - .../device-pod/DeviceEventStream.tsx | 39 - .../device-pod/DevicePodInterfaces.tsx | 16 - .../components/device-pod/DevicePodPanel.tsx | 30 - .../device-pod/DevicePodSidebar.tsx | 122 + .../device-pod/DevicePodSummary.tsx | 37 - .../src/components/help/HelpView.tsx | 46 - .../src/components/layout/ActivityRail.tsx | 69 +- .../src/components/layout/AppShell.tsx | 14 - .../src/components/layout/CenterWorkspace.tsx | 18 - .../components/layout/LiveStatusSummary.tsx | 30 - .../src/components/layout/RightSidebar.tsx | 15 - .../src/components/layout/SessionSidebar.tsx | 93 - .../src/components/layout/Workbench.tsx | 37 - .../src/components/layout/WorkspaceView.tsx | 93 - .../components/sessions/SessionSidebar.tsx | 45 + .../src/components/settings/SettingsView.tsx | 29 +- .../src/components/shared/StateTag.tsx | 12 + .../src/components/skills/SkillsView.tsx | 161 +- web/hwlab-cloud-web/src/hooks/useAuth.ts | 141 +- .../src/hooks/useCodeAgentSubmit.ts | 45 - web/hwlab-cloud-web/src/hooks/useDevicePod.ts | 67 - web/hwlab-cloud-web/src/hooks/useSkills.ts | 30 - .../src/hooks/useTracePolling.ts | 30 - web/hwlab-cloud-web/src/hooks/useWorkspace.ts | 32 - .../src/logic/app-session-tabs.ts | 270 -- .../src/logic/app-trace.test.ts | 455 --- web/hwlab-cloud-web/src/logic/app-trace.ts | 1367 -------- .../src/logic/code-agent-facts.test.ts | 360 -- .../src/logic/code-agent-facts.ts | 548 --- .../src/logic/code-agent-status.test.ts | 603 ---- .../src/logic/code-agent-status.ts | 572 --- .../src/logic/composer-policy.test.ts | 93 - .../src/logic/live-status.test.ts | 33 - web/hwlab-cloud-web/src/logic/live-status.ts | 1006 ------ .../src/logic/message-markdown.test.ts | 32 - .../src/logic/message-markdown.ts | 66 - web/hwlab-cloud-web/src/logic/runtime.ts | 54 - .../src/logic/session-tabs.test.ts | 156 - web/hwlab-cloud-web/src/main.tsx | 23 +- web/hwlab-cloud-web/src/services/api/auth.ts | 19 - .../src/services/api/client.ts | 150 +- .../src/services/api/codeAgent.ts | 21 - .../src/services/api/conversations.ts | 9 - .../src/services/api/devicePods.ts | 13 - .../src/services/api/sessions.ts | 17 - .../src/services/api/skills.ts | 9 - .../src/services/api/workspace.ts | 9 - web/hwlab-cloud-web/src/services/auth.ts | 74 - .../src/services/markdown/render.ts | 77 + web/hwlab-cloud-web/src/state/constants.ts | 1 + web/hwlab-cloud-web/src/state/conversation.ts | 94 + web/hwlab-cloud-web/src/state/workbench.ts | 320 ++ .../src/state/workbenchState.tsx | 138 - web/hwlab-cloud-web/src/styles/workbench.css | 163 + web/hwlab-cloud-web/src/types/config.ts | 30 + web/hwlab-cloud-web/src/types/domain.ts | 308 ++ web/hwlab-cloud-web/src/types/index.ts | 78 - .../src/types/react-input.d.ts | 8 + web/hwlab-cloud-web/src/utils.ts | 81 + web/hwlab-cloud-web/styles.css | 3094 ----------------- .../third_party/marked/LICENSE | 44 - .../third_party/marked/marked.esm.js | 76 - web/hwlab-cloud-web/tsconfig.json | 25 +- web/hwlab-cloud-web/vite.config.ts | 15 +- 92 files changed, 2964 insertions(+), 11294 deletions(-) rename {web/hwlab-cloud-web/src/logic => tools/src/hwlab-cli}/composer-policy.ts (100%) create mode 100644 tools/src/hwlab-cli/trace-renderer.ts create mode 100644 tools/src/runtime-durable-readiness.ts create mode 100644 web/hwlab-cloud-web/scripts/dist-contract.test.ts delete mode 100644 web/hwlab-cloud-web/scripts/frontend-guard.ts create mode 100644 web/hwlab-cloud-web/src/components/auth/LoginShell.tsx delete mode 100644 web/hwlab-cloud-web/src/components/auth/LoginView.tsx create mode 100644 web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx delete mode 100644 web/hwlab-cloud-web/src/components/conversation/ConversationView.tsx delete mode 100644 web/hwlab-cloud-web/src/components/conversation/MessageItem.tsx delete mode 100644 web/hwlab-cloud-web/src/components/conversation/MessageList.tsx delete mode 100644 web/hwlab-cloud-web/src/components/device-pod/DeviceDetailDialog.tsx delete mode 100644 web/hwlab-cloud-web/src/components/device-pod/DeviceEventStream.tsx delete mode 100644 web/hwlab-cloud-web/src/components/device-pod/DevicePodInterfaces.tsx delete mode 100644 web/hwlab-cloud-web/src/components/device-pod/DevicePodPanel.tsx create mode 100644 web/hwlab-cloud-web/src/components/device-pod/DevicePodSidebar.tsx delete mode 100644 web/hwlab-cloud-web/src/components/device-pod/DevicePodSummary.tsx delete mode 100644 web/hwlab-cloud-web/src/components/help/HelpView.tsx delete mode 100644 web/hwlab-cloud-web/src/components/layout/AppShell.tsx delete mode 100644 web/hwlab-cloud-web/src/components/layout/CenterWorkspace.tsx delete mode 100644 web/hwlab-cloud-web/src/components/layout/LiveStatusSummary.tsx delete mode 100644 web/hwlab-cloud-web/src/components/layout/RightSidebar.tsx delete mode 100644 web/hwlab-cloud-web/src/components/layout/SessionSidebar.tsx delete mode 100644 web/hwlab-cloud-web/src/components/layout/Workbench.tsx delete mode 100644 web/hwlab-cloud-web/src/components/layout/WorkspaceView.tsx create mode 100644 web/hwlab-cloud-web/src/components/sessions/SessionSidebar.tsx create mode 100644 web/hwlab-cloud-web/src/components/shared/StateTag.tsx delete mode 100644 web/hwlab-cloud-web/src/hooks/useCodeAgentSubmit.ts delete mode 100644 web/hwlab-cloud-web/src/hooks/useDevicePod.ts delete mode 100644 web/hwlab-cloud-web/src/hooks/useSkills.ts delete mode 100644 web/hwlab-cloud-web/src/hooks/useTracePolling.ts delete mode 100644 web/hwlab-cloud-web/src/hooks/useWorkspace.ts delete mode 100644 web/hwlab-cloud-web/src/logic/app-session-tabs.ts delete mode 100644 web/hwlab-cloud-web/src/logic/app-trace.test.ts delete mode 100644 web/hwlab-cloud-web/src/logic/app-trace.ts delete mode 100644 web/hwlab-cloud-web/src/logic/code-agent-facts.test.ts delete mode 100644 web/hwlab-cloud-web/src/logic/code-agent-facts.ts delete mode 100644 web/hwlab-cloud-web/src/logic/code-agent-status.test.ts delete mode 100644 web/hwlab-cloud-web/src/logic/code-agent-status.ts delete mode 100644 web/hwlab-cloud-web/src/logic/composer-policy.test.ts delete mode 100644 web/hwlab-cloud-web/src/logic/live-status.test.ts delete mode 100644 web/hwlab-cloud-web/src/logic/live-status.ts delete mode 100644 web/hwlab-cloud-web/src/logic/message-markdown.test.ts delete mode 100644 web/hwlab-cloud-web/src/logic/message-markdown.ts delete mode 100644 web/hwlab-cloud-web/src/logic/runtime.ts delete mode 100644 web/hwlab-cloud-web/src/logic/session-tabs.test.ts delete mode 100644 web/hwlab-cloud-web/src/services/api/auth.ts delete mode 100644 web/hwlab-cloud-web/src/services/api/codeAgent.ts delete mode 100644 web/hwlab-cloud-web/src/services/api/conversations.ts delete mode 100644 web/hwlab-cloud-web/src/services/api/devicePods.ts delete mode 100644 web/hwlab-cloud-web/src/services/api/sessions.ts delete mode 100644 web/hwlab-cloud-web/src/services/api/skills.ts delete mode 100644 web/hwlab-cloud-web/src/services/api/workspace.ts delete mode 100644 web/hwlab-cloud-web/src/services/auth.ts create mode 100644 web/hwlab-cloud-web/src/services/markdown/render.ts create mode 100644 web/hwlab-cloud-web/src/state/constants.ts create mode 100644 web/hwlab-cloud-web/src/state/conversation.ts create mode 100644 web/hwlab-cloud-web/src/state/workbench.ts delete mode 100644 web/hwlab-cloud-web/src/state/workbenchState.tsx create mode 100644 web/hwlab-cloud-web/src/styles/workbench.css create mode 100644 web/hwlab-cloud-web/src/types/config.ts create mode 100644 web/hwlab-cloud-web/src/types/domain.ts delete mode 100644 web/hwlab-cloud-web/src/types/index.ts create mode 100644 web/hwlab-cloud-web/src/types/react-input.d.ts create mode 100644 web/hwlab-cloud-web/src/utils.ts delete mode 100644 web/hwlab-cloud-web/styles.css delete mode 100644 web/hwlab-cloud-web/third_party/marked/LICENSE delete mode 100644 web/hwlab-cloud-web/third_party/marked/marked.esm.js diff --git a/package.json b/package.json index d08f60ce..a7efa80e 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "dev-runtime:postflight": "node scripts/run-bun.mjs scripts/dev-runtime-postflight.mjs", "dev-runtime:hotfix-audit": "node scripts/dev-runtime-hotfix-audit.mjs", "m3:io:e2e": "node scripts/run-bun.mjs scripts/m3-io-control-e2e.mjs", - "web:layout": "node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json", + "web:layout": "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json", "web:layout:build": "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json", "web:layout:live": "node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-live.json", "gateway:demo:smoke": "node scripts/run-bun.mjs scripts/gateway-outbound-demo-smoke.mjs", diff --git a/scripts/dev-cloud-workbench-layout-smoke.mjs b/scripts/dev-cloud-workbench-layout-smoke.mjs index d2bd20b1..88264dbd 100644 --- a/scripts/dev-cloud-workbench-layout-smoke.mjs +++ b/scripts/dev-cloud-workbench-layout-smoke.mjs @@ -15,12 +15,17 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." export function parseLayoutSmokeArgs(argv) { if (argv.includes("--help")) return { help: true }; const normalized = ["--layout"]; + let liveMode = false; + let buildMode = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--static" || arg === "--source") { + buildMode = true; continue; } + if (arg === "--build") buildMode = true; if (arg === "--live") { + liveMode = true; const next = argv[index + 1]; if (next && !next.startsWith("--")) { normalized.push("--url"); @@ -39,6 +44,7 @@ export function parseLayoutSmokeArgs(argv) { normalized.push(argv[index]); } } + if (!liveMode && !buildMode) normalized.push("--build"); return parseSmokeArgs(normalized); } @@ -101,7 +107,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { ...printSmokeHelp(), command: `node scripts/dev-cloud-workbench-layout-smoke.mjs [--static|--build|--live --url http://74.48.78.17:16666/] [--report ${tempReportPath("dev-cloud-workbench-layout.json")}]`, layoutNotes: [ - "默认 --static 使用 source/static 本地静态服务。", + "默认和 --static 都使用 Vite local-build;源码静态服务路径已废弃。", "--build 会先运行 cd web/hwlab-cloud-web && bun run build,再检查本地 dist 构建产物。", "--live --url http://74.48.78.17:16666/ 只做 DEV live UI 布局/命中/溢出检查,不发送 Code Agent 消息,不调用 Device Pod 或硬件写操作,不等同于硬件验收。", "结构化失败会包含 status、viewport、selector、failureType、artifact screenshot/report path。" diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 1bd850ee..823ac3b2 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -40,6 +40,7 @@ const expectedRuntimeIdentity = Object.freeze({ const rootPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); const cloudWebPackage = JSON.parse(readFileSync(new URL("../web/hwlab-cloud-web/package.json", import.meta.url), "utf8")); const cloudWebCheckSource = readFileSync(new URL("../web/hwlab-cloud-web/scripts/check.ts", import.meta.url), "utf8"); +const cloudWebTscCheckSource = readFileSync(new URL("../web/hwlab-cloud-web/scripts/tsc-check.ts", import.meta.url), "utf8"); test("workbench smoke defaults to SOURCE mode and requires live confirmation before DEV-LIVE provider calls", () => { const defaultArgs = parseSmokeArgs([]); @@ -79,10 +80,9 @@ test("source/default workbench report cannot claim DEV-LIVE and documents the co report.validationCommands.includes("node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-live.json"), false ); - assert.equal(report.checks.find((check) => check.id === "code-agent-long-timeout-contract")?.status, "pass"); - const traceDisclosure = report.checks.find((check) => check.id === "code-agent-trace-replay-disclosure"); - assert.equal(traceDisclosure?.status, "pass"); - assert.deepEqual(traceDisclosure?.evidence, ["显示全部可读事件 / 完整 trace 回放中", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "traceScrollPinnedToBottom", "internal scroll for full trace"]); + assert.equal(report.checks.find((check) => check.id === "react-strict-typescript-entry")?.status, "pass"); + assert.equal(report.checks.find((check) => check.id === "vite-build-and-dist-contract")?.status, "pass"); + assert.equal(report.checks.find((check) => check.id === "legacy-runtime-paths-removed")?.status, "pass"); }); test("Code Agent browser failure classifier emits Chinese timeout/provider/browser categories with traceId", () => { @@ -180,32 +180,30 @@ test("smoke args include a DOM-only live read-only mode", () => { assert.equal(args.urlExplicit, true); }); -test("source/default smoke covers #352 resource explorer removal contract", () => { +test("source/default smoke covers React shell-only and module boundary contracts", () => { const report = runDevCloudWorkbenchStaticSmoke(); - const check = report.checks.find((item) => item.id === "feedback-352-resource-explorer-removed"); - assert.equal(check?.status, "pass"); - assert.equal(check.evidence.includes("aside#resource-explorer absent"), true); - assert.equal(check.evidence.includes("#explorer-resize absent"), true); + assert.equal(report.checks.find((item) => item.id === "react-shell-only-index")?.status, "pass"); + assert.equal(report.checks.find((item) => item.id === "react-module-boundaries")?.status, "pass"); + assert.equal(report.checks.find((item) => item.id === "react-api-state-separation")?.status, "pass"); }); -test("source/default smoke covers Device Pod right-sidebar contract", () => { +test("source/default smoke covers React workbench selectors and layout", () => { const report = runDevCloudWorkbenchStaticSmoke(); - const summary = report.checks.find((item) => item.id === "device-pod-summary-sidebar"); - const events = report.checks.find((item) => item.id === "device-pod-event-stream"); - const service = report.checks.find((item) => item.id === "device-pod-executor-authority"); - assert.equal(summary?.status, "pass"); - assert.equal(events?.status, "pass"); - assert.equal(service?.status, "pass"); - assert.equal(summary.evidence.includes("summary-tile"), true); - assert.equal(summary.evidence.includes("device-detail-dialog"), true); - assert.equal(events.evidence.includes("device-event-text"), true); + const selectors = report.checks.find((item) => item.id === "react-core-workbench-selectors"); + const layout = report.checks.find((item) => item.id === "react-layout-contract"); + assert.equal(selectors?.status, "pass"); + assert.equal(layout?.status, "pass"); + assert.equal(selectors.evidence.includes("#device-pod-sidebar"), true); + assert.equal(layout.evidence.includes("@media (max-width: 720px)"), true); }); -test("source/default smoke covers #288 gate single-table contract", () => { +test("source/default smoke removes legacy vanilla runtime paths", () => { const report = runDevCloudWorkbenchStaticSmoke(); - const check = report.checks.find((item) => item.id === "feedback-288-gate-single-table"); + const check = report.checks.find((item) => item.id === "legacy-runtime-paths-removed"); assert.equal(check?.status, "pass"); - assert.equal(report.checks.some((item) => item.id === "feedback-119-gate-retains-diagnostics"), false); + assert.equal(check.evidence.includes("removed:app.ts"), true); + assert.equal(check.evidence.includes("removed:styles.css"), true); + assert.equal(report.checks.some((item) => item.id === "feedback-288-gate-single-table"), false); }); test("live workbench identity passes only when runtime commit or image tag matches current source", () => { @@ -270,18 +268,18 @@ test("live workbench identity uses deploy desired runtime identity instead of gi test("live web asset identity blocks stale deployed primary assets", () => { const pass = classifyLiveWebAssetIdentity([ { path: "index.html", status: "match" }, - { path: "styles.css", status: "match" }, + { path: "assets/index.css", status: "match" }, { path: "app.js", status: "match" } ]); assert.equal(pass.status, "pass"); const stale = classifyLiveWebAssetIdentity([ { path: "index.html", status: "match" }, - { path: "styles.css", status: "mismatch" }, + { path: "assets/index.css", status: "mismatch" }, { path: "app.js", status: "mismatch" } ]); assert.equal(stale.status, "blocked"); - assert.deepEqual(stale.mismatches, ["styles.css", "app.js"]); + assert.deepEqual(stale.mismatches, ["assets/index.css", "app.js"]); assert.match(stale.summary, /Deployment drift/u); }); @@ -498,13 +496,13 @@ test("Code Agent browser classifier blocks completed payloads without backend ev test("repo-owned web checks keep cloud-web semantic checks out of old browser gates", () => { const checkCommands = checkProfiles.check.map((task) => task.command.join(" ")); assert.match(rootPackage.scripts["web:check"], /cd web\/hwlab-cloud-web && bun run check/u); - assert.match(cloudWebPackage.scripts.check, /bun test && bun run scripts\/check\.ts/u); + assert.match(cloudWebPackage.scripts.check, /bun run scripts\/check\.ts && bun run scripts\/tsc-check\.ts && bun test/u); assert.ok(checkProfiles.check.some((task) => task.cwd === "web/hwlab-cloud-web" && task.command.join(" ") === "bun run check")); assert.doesNotMatch(checkCommands.join("\n"), /dev-cloud-workbench-layout-smoke|gate-summary\.mjs|export-web-gate-summary/u); - assert.match(cloudWebCheckSource, /assertTraceHelpersBound/u); - assert.match(cloudWebCheckSource, /Device Pod summary sidebar/u); - assert.match(cloudWebCheckSource, /device-detail-dialog/u); - assert.match(cloudWebCheckSource, /device-event-text/u); - assert.match(cloudWebCheckSource, /isCodeAgentTimeoutError/u); + assert.match(cloudWebCheckSource, /React module assets present/u); + assert.match(cloudWebTscCheckSource, /explicit any/u); + assert.match(cloudWebTscCheckSource, /--strict/u); + assert.match(cloudWebCheckSource, /legacy runtime path/u); + assert.match(cloudWebCheckSource, /dist bundle built by Vite/u); assert.doesNotMatch(cloudWebCheckSource, /m3-readonly-contract|workbench-hardware-panel-contract|gate-summary\.mjs/u); }); diff --git a/scripts/src/artifact-runtime-readiness-guard.mjs b/scripts/src/artifact-runtime-readiness-guard.mjs index 24266c16..924d13c5 100644 --- a/scripts/src/artifact-runtime-readiness-guard.mjs +++ b/scripts/src/artifact-runtime-readiness-guard.mjs @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { readdirSync, statSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,13 +10,7 @@ import { buildDesiredStatePlan } from "./deploy-desired-state-plan.mjs"; import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs"; const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const cloudWebAppSourceFiles = Object.freeze([ - "web/hwlab-cloud-web/app.ts", - "web/hwlab-cloud-web/app-device-pod.ts", - "web/hwlab-cloud-web/app-conversation.ts", - "web/hwlab-cloud-web/app-trace.ts", - "web/hwlab-cloud-web/app-helpers.ts" -]); +const cloudWebSourceRoot = "web/hwlab-cloud-web/src"; function bunExecutable() { return process.env.HWLAB_BUN_BINARY || process.env.BUN_BINARY || "bun"; @@ -42,10 +37,22 @@ function inspectCloudWebDistFreshness(webRoot) { } async function readCloudWebAppSource(repoRoot) { - const parts = await Promise.all(cloudWebAppSourceFiles.map((file) => readFile(path.join(repoRoot, file), "utf8"))); + const files = collectCloudWebSourceFiles(path.join(repoRoot, cloudWebSourceRoot)); + const parts = await Promise.all(files.map((file) => readFile(file, "utf8"))); return parts.join("\n"); } +function collectCloudWebSourceFiles(dir) { + const files = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) files.push(...collectCloudWebSourceFiles(full)); + else if (/\.(ts|tsx|css)$/u.test(full)) files.push(full); + } + return files.sort(); +} + const defaultArtifactReport = tempReportPath("dev-artifacts.json"); const defaultArtifactCatalog = "deploy/artifact-catalog.dev.json"; const defaultRuntimeReport = tempReportPath("dev-cloud-workbench-live.json"); @@ -1005,7 +1012,7 @@ async function inspectM3IoSourceReadiness(repoRoot) { } const present = matches.length > 0; const sourceReady = contractFiles.length > 0 && - matches.some((file) => cloudWebAppSourceFiles.includes(file)) && + matches.some((file) => file.startsWith(`${cloudWebSourceRoot}/`)) && matches.some((file) => file === "internal/cloud/m3-io-control.ts" || file === "scripts/src/m3-io-control-e2e.mjs"); return { status: present ? (sourceReady ? "pass" : "blocked") : "absent", diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 1560c9d4..2cd714ae 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -19,7 +19,13 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../ const domOnlyReportPath = tempReportPath("dev-cloud-workbench-dom-only.json"); const liveReportPath = tempReportPath("dev-cloud-workbench-live.json"); const webRoot = path.join(repoRoot, "web/hwlab-cloud-web"); -const runtime = readCloudWebRuntime(); +const runtime = Object.freeze({ + endpoints: Object.freeze({ + frontend: "http://74.48.78.17:16666", + api: "http://74.48.78.17:16667", + edge: "http://74.48.78.17:16667" + }) +}); const defaultLiveUrl = "http://74.48.78.17:16666/"; const helpOwner = "codex_1779444232735_1"; const legacyFailureWindowMs = 4500; @@ -253,35 +259,35 @@ const markdownRendererPackages = Object.freeze([ ]); const cloudWebAppSourceFiles = Object.freeze([ - "app.ts", - "app-device-pod.ts", - "app-conversation.ts", - "app-trace.ts", - "app-helpers.ts" -]); - -const cloudWebModuleSourceFiles = Object.freeze([ - "src/services/auth.ts", - "src/logic/code-agent-facts.ts", - "src/logic/code-agent-status.ts", - "src/logic/message-markdown.ts", - "src/logic/live-status.ts", - "src/logic/runtime.ts" + "src/main.tsx", + "src/App.tsx" ]); const requiredWebAssets = Object.freeze([ "index.html", - "styles.css", + "src/styles/workbench.css", ...cloudWebAppSourceFiles, - ...cloudWebModuleSourceFiles, + "src/components/layout/ActivityRail.tsx", + "src/components/auth/LoginShell.tsx", + "src/components/sessions/SessionSidebar.tsx", + "src/components/conversation/ConversationPanel.tsx", + "src/components/command-bar/CommandBar.tsx", + "src/components/device-pod/DevicePodSidebar.tsx", + "src/components/settings/SettingsView.tsx", + "src/components/skills/SkillsView.tsx", + "src/hooks/useAuth.ts", + "src/services/api/client.ts", + "src/services/markdown/render.ts", + "src/state/workbench.ts", + "src/state/conversation.ts", + "src/types/domain.ts", "favicon.svg", "favicon.ico", "help.md", - "third_party/marked/marked.esm.js", - "third_party/marked/LICENSE" + "vite.config.ts" ]); -const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.js"]); +const liveIdentityWebAssets = Object.freeze(["index.html", "assets/index.css", "app.js"]); const playwrightDependencyCommand = "npm install"; const playwrightDependencySummary = @@ -429,175 +435,50 @@ function runStaticSmoke() { const checks = []; const blockers = []; const files = readStaticFiles(); - const source = Object.values(files).join("\n"); - const buildScript = readText("web/hwlab-cloud-web/scripts/build.ts"); - const distContractScript = readText("web/hwlab-cloud-web/scripts/dist-contract.ts"); + const source = `${files.html}\n${files.app}\n${files.styles}\n${files.packageJson}\n${files.tsconfig}\n${files.checkScript}\n${files.tscCheck}`; addCheck(checks, blockers, "static-web-assets", assetsExist(), "Cloud Web source assets are present.", { evidence: requiredWebAssets.map((file) => `web/hwlab-cloud-web/${file}`) }); - addCheck(checks, blockers, "default-workbench-shell", hasDefaultWorkbench(files), "Authenticated route is the VS Code-style Cloud Workbench.", { + addCheck(checks, blockers, "react-shell-only-index", reactShellOnlyIndex(files), "Cloud Web index.html is a shell-only React mount entry.", { blocker: "runtime_blocker", - evidence: ["workspace view exists behind login", "Gate view is hidden by default after auth", ...workbenchMarkers] + evidence: ["
", "/src/main.tsx", "no business DOM in index.html"] }); - addCheck(checks, blockers, "secondary-gate-status-help", secondaryViewsAreNotDefault(files), "Gate/status/diagnostics/help surfaces are not accepted as the default homepage.", { + addCheck(checks, blockers, "react-strict-typescript-entry", reactStrictTypeScriptEntry(files), "React runtime is mounted from TSX under strict TypeScript without explicit any in source.", { blocker: "runtime_blocker", - evidence: ["routeFromLocation final fallback is workspace", "non-workspace data-view sections must be hidden"] + evidence: ["createRoot", "React.StrictMode", "strict=true", "noImplicitAny=true", "tsc-check explicit any scan"] }); - addCheck(checks, blockers, "chinese-workbench-labels", labelsPresent(source, chineseWorkbenchLabels), "Current Chinese workbench labels are present.", { + addCheck(checks, blockers, "react-module-boundaries", reactModuleBoundaries(files), "React modules are split by layout/auth/sessions/conversation/command-bar/device-pod/settings/shared/hooks/services/state/types/styles.", { blocker: "contract_blocker", - evidence: chineseWorkbenchLabels + evidence: requiredWebAssets.filter((file) => file.startsWith("src/")) }); - addCheck(checks, blockers, "feedback-117-default-no-backstage", defaultHomepageHidesBackstage(files), "Default workbench hides Gate, diagnostics, acceptance, BLOCKED, and M0-M5 backstage information.", { + addCheck(checks, blockers, "legacy-runtime-paths-removed", legacyRuntimePathsRemoved(), "Legacy vanilla runtime files are removed and cannot remain as a second frontend path.", { blocker: "runtime_blocker", - evidence: defaultHomepageForbiddenTerms + evidence: legacyRuntimeFiles.map((file) => `removed:${file}`) }); - addCheck(checks, blockers, "feedback-118-complete-nav", hasCompletePrimaryNavigation(files.html), "Primary navigation uses complete Chinese labels and removes the left-rail trusted-records shortcut.", { + addCheck(checks, blockers, "react-core-workbench-selectors", reactCoreWorkbenchSelectors(files), "React runtime renders the authenticated shell, command bar, session sidebar, conversation, Device Pod sidebar, settings, skills, gate, and help selectors.", { blocker: "contract_blocker", - evidence: ["工作台", "内部复核", "使用说明"] + evidence: ["data-app-shell", "#command-input", "#conversation-list", "#device-pod-sidebar", "#logout-button"] }); - addCheck(checks, blockers, "feedback-288-gate-single-table", gateSingleTableContract(files), "Internal review /gate is a single read-only table backed by the live diagnostics aggregation route.", { + addCheck(checks, blockers, "react-api-state-separation", reactApiStateSeparation(files), "API, workspace/session state, conversation mapping, markdown rendering, and UI components are separated.", { blocker: "contract_blocker", - evidence: [...gateReviewTableColumns, "/v1/diagnostics/gate", "LIVE-BACKEND", ...gateReviewStatusLabels] + evidence: ["services/api/client.ts", "state/workbench.ts", "state/conversation.ts", "services/markdown/render.ts"] }); - addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, files.artifactPublisher, buildScript, distContractScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", { + addCheck(checks, blockers, "vite-build-and-dist-contract", viteBuildAndDistContract(files), "Vite build, route aliases, dist freshness, and root app.js output are the only deployable Cloud Web asset path.", { blocker: "runtime_blocker", - evidence: gateRouteAliases + evidence: ["vite.config.ts", "dist-contract.ts", "app.js", "assets/index.css", ...gateRouteAliases, ...helpRouteAliases] }); - addCheck(checks, blockers, "direct-help-route-alias", helpRouteAliasesAreServed(files.app, files.artifactPublisher, buildScript, distContractScript), "/help is a direct user-facing help alias and does not become the default route.", { + addCheck(checks, blockers, "react-layout-contract", reactLayoutContract(files), "React CSS owns desktop, narrow, and 390px mobile layout without page-level scrolling.", { blocker: "runtime_blocker", - evidence: [...helpRouteAliases, "routeFromLocation final fallback is workspace"] - }); - - addCheck(checks, blockers, "device-pod-summary-sidebar", devicePodSummarySidebarContract(files), "Right sidebar is a Device Pod summary board: no internal expand/collapse, summary tiles only, and click/keyboard opens the detail dialog.", { - blocker: "contract_blocker", - evidence: ["device-pod-sidebar", "device-pod-summary", "summary-tile", "device-detail-dialog", "no
in right sidebar"] - }); - - addCheck(checks, blockers, "device-pod-event-stream", devicePodEventStreamContract(files), "Device Pod events render as a simple plain-text stream whose updates do not steal the user's scroll position.", { - blocker: "runtime_blocker", - evidence: ["device-event-scroll", "device-event-text", "setDeviceEventFollow", "deviceEventUserActive", "overscroll-behavior: contain"] - }); - - addCheck(checks, blockers, "device-pod-executor-authority", devicePodExecutorAuthorityContract(files), "The hwlab-device-pod executor and Cloud API compatibility payload expose the Device Pod REST surface without fake fallback.", { - blocker: "runtime_blocker", - evidence: ["hwlab-device-pod", "/v1/device-pods", "/status", "/events", "device-pod-executor-v1"] - }); - - addCheck(checks, blockers, "outer-scroll-contract", hasScrollLockContract(files.styles), "Outer page scrolling is locked and internal workbench panes own scrolling.", { - blocker: "contract_blocker", - evidence: ["html/body overflow hidden", "workbench-shell 100vh/100dvh overflow hidden", ".view overflow auto"] - }); - - addCheck(checks, blockers, "mobile-workbench-layout-contract", hasMobileWorkbenchLayoutContract(files), "390x844 mobile layout keeps the default workbench reachable without a resource explorer drawer.", { - blocker: "runtime_blocker", - evidence: [ - "mobile rail spans full height", - "center workspace and right sidebar share the content column", - "resource explorer drawer selectors are absent" - ] - }); - - addCheck(checks, blockers, "feedback-352-resource-explorer-removed", hasResourceExplorerRemovalContract(files), "Default workbench removes the left resource explorer, quick actions, resource tree, capability cards, and explorer resize handle.", { - blocker: "runtime_blocker", - evidence: [ - "aside#resource-explorer absent", - "#explorer-resize absent", - "resource tree / quick actions / capability cards absent", - "right side keeps a 560-740px Device Pod summary boundary through --right-width" - ] - }); - - addCheck(checks, blockers, "api-live-status-attribution", hasApiLiveStatusAttribution(files), "Default workbench status maps raw API health into pass, error, unverified, or read-only with concrete service/API attribution.", { - blocker: "observability_blocker", - evidence: ["API 正常", "API 错误", "等待验证", "只读模式", "hwlab-cloud-api /health/live", "/v1/agent/chat", "/v1/device-pods", "raw degraded kept as internalRawStatuses"] - }); - - addCheck(checks, blockers, "workbench-live-surface-timeout-contract", hasWorkbenchLiveSurfaceTimeoutContract(files), "Workbench live status uses a dedicated live-surface timeout instead of the 4500ms light API budget.", { - blocker: "runtime_blocker", - evidence: ["DEFAULT_LIVE_SURFACE_TIMEOUT_MS=12000", "liveSurfaceFetch", "liveSurfaceRpc", "/v1/device-pods", "system.health"] - }); - - addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", { - blocker: "runtime_blocker", - evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] - }); - - addCheck(checks, blockers, "code-agent-long-timeout-contract", hasCodeAgentLongTimeoutContract(files), "Code Agent chat uses a dedicated long timeout and does not reuse the old 4500ms API timeout.", { - blocker: "runtime_blocker", - evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"] - }); - - addCheck(checks, blockers, "code-agent-trace-replay-disclosure", hasCodeAgentTraceReplayDisclosure(files), "Trace replay panels auto-replay full trace, avoid compressed-window UI, and preserve open/scroll state while live trace updates.", { - blocker: "observability_blocker", - evidence: ["显示全部可读事件 / 完整 trace 回放中", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "traceScrollPinnedToBottom", "internal scroll for full trace"] - }); - - addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider/stdio blockers without exposing credential internals and only long-lived Codex stdio replies can become full Code Agent completion.", { - blocker: "observability_blocker", - evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard", "default workspace hides credential internals"] - }); - - addCheck(checks, blockers, "code-agent-status-summary", hasCodeAgentStatusSummaryContract(files), "Code Agent area has a compact collapsible status summary for Codex stdio, blockers, trace, and current deployed revision.", { - blocker: "observability_blocker", - evidence: [ - "code-agent-summary", - "fallback-text-chat-only", - "stateless-one-shot", - "read-only-session-tools", - "当前部署 revision" - ] - }); - - addCheck(checks, blockers, "default-workspace-sanitized", defaultWorkspaceIsSanitized(files), "Default user workspace hides raw blocker codes, internal route markers, and credential reference material.", { - blocker: "observability_blocker", - evidence: defaultHomepageForbiddenTerms - }); - - addCheck(checks, blockers, "route-control-stable-dimensions", hasStableRouteControls(files), "Route controls and status tags have stable dimensions, readable wrapping, and usable labels on small screens.", { - blocker: "runtime_blocker", - evidence: ["rail-button min-width/min-height", "state-tag wrapping", "summary-tile wrapping", "mobile trace column"] - }); - - addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed full Code Agent replies expose Codex stdio runnerTrace and reject echo/mock/stub, OpenAI fallback, and local shortcut completions.", { - blocker: "observability_blocker", - evidence: requiredCodeAgentEvidenceTerms - }); - - addCheck(checks, blockers, "code-agent-conversation-ux-states", hasCodeAgentConversationUxStates(files), "Code Agent thread renders trusted completed, SOURCE fixture, failed, and blocked provider states as distinct conversation replies with bounded trace.", { - blocker: "observability_blocker", - evidence: [ - "DEV-LIVE 回复", - "SOURCE 回复", - "等待超时/API 错误/后端失败", - "服务受阻 or legacy BLOCKED 凭证缺口", - "message-trace/details/events" - ] - }); - - addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(`${files.html}\n${files.app}\n${files.liveStatus}`), "Workbench frontend source does not expose hardware write APIs or live mutation commands.", { - blocker: "safety_blocker", - evidence: forbiddenWritePatterns.map(String) - }); - - addCheck(checks, blockers, "source-not-dev-live", sourceEvidenceNotDevLive(source), "SOURCE/LOCAL/DRY-RUN/fixture evidence is not promoted to DEV-LIVE.", { - blocker: "observability_blocker", - evidence: ["SOURCE remains source-level", "Device Pod compatibility fallback is blocked", "no DEV-LIVE claim from fixture data"] - }); - - const help = inspectHelpContract(files); - addCheck(checks, blockers, "help-md-contract", help.status, help.summary, { - blocker: help.blocker, - owner: helpOwner, - observations: help.observations + evidence: ["html/body overflow hidden", "@media (max-width: 720px)", "390px mobile grid", "event stream overscroll contained"] }); return baseReport({ @@ -607,10 +488,7 @@ function runStaticSmoke() { blockers, evidenceLevel: "SOURCE", devLive: false, - help: { - status: help.status, - ...help.observations - }, + help: inspectReactHelpContract(files), safety: staticSafety() }); } @@ -889,7 +767,7 @@ async function runLiveDomOnlySmoke(args) { export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { const useLiveUrl = args.live === true || args.urlExplicit === true; - const layoutSourceMode = useLiveUrl ? "dev-live" : args.build === true ? "local-build" : "source-static"; + const layoutSourceMode = useLiveUrl ? "dev-live" : "local-build"; const artifactRoot = path.resolve( repoRoot, args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp()) @@ -978,8 +856,11 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { try { browser = await launchChromium(chromium); const checks = []; + const screenshots = []; for (const viewport of layoutViewports) { - checks.push(await inspectMinimalWorkbenchLayout(browser, url, viewport)); + const check = await inspectMinimalWorkbenchLayout(browser, url, viewport, { artifactRoot }); + checks.push(check); + screenshots.push(...(check.artifacts?.screenshots ?? [])); } const blockers = checks .filter((check) => check.status !== "pass") @@ -1030,7 +911,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { devLive: false, summary: useLiveUrl ? "Live browser layout smoke verifies that the workbench opens and core controls are visible." - : "Static local browser layout smoke verifies that the workbench opens and core controls are visible; detailed visual acceptance remains manual/live.", + : "Vite local-build browser layout smoke verifies that the workbench opens and core controls are visible; detailed visual acceptance remains manual/live.", refs: ["pikasTech/HWLAB#273"], viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })), checks, @@ -1039,7 +920,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { skipped, artifacts: { screenshotDir: artifactRoot, - screenshots: [], + screenshots, reportPath: args.reportPath ?? null }, safety: layoutSafety() @@ -1259,7 +1140,7 @@ function layoutReportLifecycle(useLiveUrl, status) { deprecatedEndpoint: null, summary: useLiveUrl ? `DEV live layout smoke ${status}; this is UI layout/clickability evidence only and not hardware acceptance.` - : `Repository source/static layout smoke ${status}; this runs without public DEV dependency and cannot be used as DEV-LIVE evidence.` + : `Repository Vite local-build layout smoke ${status}; this runs without public DEV dependency and cannot be used as DEV-LIVE evidence.` }; } @@ -1294,11 +1175,11 @@ function layoutLocalSmoke(status) { "npm run web:layout:build" ], evidence: [ - "web:check runs the SOURCE/static layout smoke as a repo-owned frontend gate.", - "web:layout runs the source/static Playwright geometry, hit-target, overflow, and outer-scroll checks without public DEV dependency.", - "web:layout:build rebuilds local dist and checks the built Cloud Web assets." + "web:check runs strict React TypeScript, module-boundary, dist freshness, and unit checks as the repo-owned frontend gate.", + "web:layout runs the Vite local-build Playwright geometry and core-control visibility checks without public DEV dependency.", + "web:layout:build is the same Vite local-build browser smoke entry." ], - summary: "SOURCE/static and local-build layout checks classify UI overlap, covered hit targets, overflow, and outer-scroll regressions as layout blockers." + summary: "Vite local-build layout checks classify hidden core controls and browser startup regressions as layout blockers." }; } @@ -1511,14 +1392,14 @@ function addCheck(checks, blockers, id, result, summary, options = {}) { function readStaticFiles() { return { html: readText("web/hwlab-cloud-web/index.html"), - styles: readText("web/hwlab-cloud-web/styles.css"), - auth: readText("web/hwlab-cloud-web/src/services/auth.ts"), + styles: readText("web/hwlab-cloud-web/src/styles/workbench.css"), app: readCloudWebAppSource(), - codeAgentFacts: readText("web/hwlab-cloud-web/src/logic/code-agent-facts.ts"), - codeAgentStatus: readText("web/hwlab-cloud-web/src/logic/code-agent-status.ts"), - messageMarkdown: readText("web/hwlab-cloud-web/src/logic/message-markdown.ts"), - liveStatus: readText("web/hwlab-cloud-web/src/logic/live-status.ts"), - runtime: readText("web/hwlab-cloud-web/src/logic/runtime.ts"), + packageJson: readText("web/hwlab-cloud-web/package.json"), + tsconfig: readText("web/hwlab-cloud-web/tsconfig.json"), + checkScript: readText("web/hwlab-cloud-web/scripts/check.ts"), + tscCheck: readText("web/hwlab-cloud-web/scripts/tsc-check.ts"), + distContract: readText("web/hwlab-cloud-web/scripts/dist-contract.ts"), + viteConfig: readText("web/hwlab-cloud-web/vite.config.ts"), help: readText("web/hwlab-cloud-web/help.md"), artifactPublisher: readText("scripts/artifact-publish.mjs"), cloudApiServer: readText("internal/cloud/server.ts"), @@ -1531,19 +1412,123 @@ function readStaticFiles() { }; } +const legacyRuntimeFiles = Object.freeze([ + "app.ts", + "app-device-pod.ts", + "app-conversation.ts", + "app-helpers.ts", + "app-skills.ts", + "app-session-tabs.ts", + "app-trace.ts", + "auth.ts", + "code-agent-facts.ts", + "code-agent-status.ts", + "composer-policy.ts", + "live-status.ts", + "message-markdown.ts", + "runtime.ts", + "styles.css", + "web-types.d.ts" +]); + +function reactShellOnlyIndex({ html }) { + return /
<\/div>/u.test(html) && + /src="\/src\/main\.tsx"/u.test(html) && + !/workbench-shell|device-pod-sidebar|command-input|conversation-list|session-tabs/u.test(html); +} + +function reactStrictTypeScriptEntry({ app, tsconfig, tscCheck }) { + return /createRoot/u.test(app) && + /React\.StrictMode/u.test(app) && + /"strict"\s*:\s*true/u.test(tsconfig) && + /"noImplicitAny"\s*:\s*true/u.test(tsconfig) && + /"noUncheckedIndexedAccess"\s*:\s*true/u.test(tsconfig) && + /explicit any/u.test(tscCheck) && + !/(^|[^A-Za-z0-9_])(?:as\s+any|:\s*any\b|Array\s*<\s*any\s*>|<\s*any\s*>)/u.test(app); +} + +function reactModuleBoundaries() { + return requiredWebAssets + .filter((file) => file.startsWith("src/")) + .every((file) => fs.existsSync(path.join(webRoot, file))); +} + +function legacyRuntimePathsRemoved() { + return legacyRuntimeFiles.every((file) => !fs.existsSync(path.join(webRoot, file))); +} + +function reactCoreWorkbenchSelectors({ app }) { + return [ + "data-app-shell", + "id=\"command-input\"", + "id=\"conversation-list\"", + "id=\"device-pod-sidebar\"", + "id=\"device-event-scroll\"", + "id=\"logout-button\"", + "data-view=\"gate\"", + "data-view=\"help\"", + "SkillsView", + "SettingsView" + ].every((term) => app.includes(term)); +} + +function reactApiStateSeparation({ app }) { + return [ + "services/api/client.ts", + "state/workbench.ts", + "state/conversation.ts", + "services/markdown/render.ts" + ].every((file) => fs.existsSync(path.join(webRoot, "src", file))) && + /fetchJson/u.test(app) && + /useWorkbenchStore/u.test(app) && + /renderMessageMarkdown/u.test(app) && + /selectConversation/u.test(app); +} + +function viteBuildAndDistContract({ packageJson, distContract, viteConfig }) { + return /"build"\s*:\s*"bun run scripts\/build\.ts"/u.test(packageJson) && + /vite.*build/u.test(distContract) && + /entryFileNames:\s*"app\.js"/u.test(viteConfig) && + /assets\/index\.css/u.test(distContract) && + gateRouteAliases.every((route) => distContract.includes(`${route.replace(/^\//u, "")}/index.html`)) && + helpRouteAliases.every((route) => distContract.includes(`${route.replace(/^\//u, "")}/index.html`)); +} + +function reactLayoutContract({ styles }) { + return /html, body, #root \{[^}]*overflow:\s*hidden;/su.test(styles) && + /\.workbench-shell \{[^}]*overflow:\s*hidden;/su.test(styles) && + /@media \(max-width: 720px\)/u.test(styles) && + /grid-template-columns:\s*56px\s+minmax\(0,\s*1fr\)/u.test(styles) && + /\.device-event-scroll\s*\{[\s\S]*?overscroll-behavior:\s*contain;/u.test(styles); +} + +function inspectReactHelpContract({ app, help }) { + return { + status: /data-view="help"/u.test(app) && /fetchText\("\/help\.md"/u.test(app) && help.trim().length > 0 ? "pass" : "blocked", + route: "/help", + source: "web/hwlab-cloud-web/help.md" + }; +} + function readText(relativePath) { return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); } function readCloudWebAppSource() { - return cloudWebAppSourceFiles.map((file) => readText(`web/hwlab-cloud-web/${file}`)).join("\n"); + return collectCloudWebSourceFiles(path.join(webRoot, "src")) + .map((file) => fs.readFileSync(file, "utf8")) + .join("\n"); } -function readCloudWebRuntime() { - const source = fs.readFileSync(path.join(webRoot, "src/logic/runtime.ts"), "utf8"); - const match = source.match(/export const runtime\s*=\s*([\s\S]*?);\s*$/u); - if (!match) throw new Error("Unable to parse web/hwlab-cloud-web/src/logic/runtime.ts"); - return Function(`return (${match[1]});`)(); +function collectCloudWebSourceFiles(dir) { + const files = []; + for (const entry of fs.readdirSync(dir)) { + const full = path.join(dir, entry); + const stat = fs.statSync(full); + if (stat.isDirectory()) files.push(...collectCloudWebSourceFiles(full)); + else if (/\.(ts|tsx|css)$/u.test(full)) files.push(full); + } + return files.sort(); } function resolveBunCommand() { @@ -1775,21 +1760,13 @@ function compareLiveAsset(assetPath, live, sourceText) { } async function buildCloudWebAppBundleText() { - const tmpDir = fs.mkdtempSync(path.join("/tmp", "hwlab-cloud-web-live-asset-")); - const entryPath = path.join(webRoot, ".app-entry.generated.ts"); - try { - fs.writeFileSync(entryPath, `${readCloudWebAppSource()}\n`); - execFileSync(resolveBunCommand(), ["build", entryPath, "--target", "browser", "--format", "esm", "--outfile", path.join(tmpDir, "app.js")], { - cwd: repoRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 30000 - }); - return fs.readFileSync(path.join(tmpDir, "app.js"), "utf8"); - } finally { - fs.rmSync(entryPath, { force: true }); - fs.rmSync(tmpDir, { recursive: true, force: true }); - } + execFileSync(resolveBunCommand(), ["run", "build"], { + cwd: webRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 60000 + }); + return fs.readFileSync(path.join(webRoot, "dist/app.js"), "utf8"); } function hashText(value) { @@ -2563,6 +2540,7 @@ function findHelpMarkdownFiles() { } function findMarkdownRenderers() { + const renderService = readText("web/hwlab-cloud-web/src/services/markdown/render.ts"); const packages = ["package.json", "web/hwlab-cloud-web/package.json"] .filter((file) => fs.existsSync(path.join(repoRoot, file))) .flatMap((file) => { @@ -2571,11 +2549,8 @@ function findMarkdownRenderers() { }); const source = `${readCloudWebAppSource()}\n${readText("web/hwlab-cloud-web/index.html")}`; const imported = markdownRendererPackages.filter((name) => new RegExp(`from\\s+["']${escapeRegExp(name)}["']|import\\(["']${escapeRegExp(name)}["']\\)`, "u").test(source)); - const vendoredMarked = /from\s+["']\.\/third_party\/marked\/marked\.esm\.js["']/u.test(source) && - fs.existsSync(path.join(repoRoot, "web/hwlab-cloud-web/third_party/marked/marked.esm.js")) && - fs.existsSync(path.join(repoRoot, "web/hwlab-cloud-web/third_party/marked/LICENSE")); const renderers = [...packages, ...imported].filter((name) => markdownRendererPackages.includes(name)); - if (vendoredMarked) renderers.push("marked:vendored"); + if (/export function renderMessageMarkdown/u.test(renderService) && /escapeHtml/u.test(renderService)) renderers.push("react-render-service"); return [...new Set(renderers)].sort(); } @@ -3527,7 +3502,16 @@ async function inspectAuthFixtureViewport(browser, url, viewport) { } async function loginWithDefaultCredentials(page) { - const loginVisible = await page.locator("#login-shell").evaluate((element) => element.hidden === false).catch(() => false); + await page.waitForFunction( + () => document.querySelector("#command-input") !== null || document.querySelector("#login-username") !== null, + null, + { timeout: 12000 } + ).catch(() => {}); + const loginVisible = await page.evaluate(() => { + const login = document.querySelector("#login-shell"); + const username = document.querySelector("#login-username"); + return Boolean(login && username && login.hidden === false); + }); if (!loginVisible) return; await page.locator("#login-username").fill(defaultAuthCredentials.username); await page.locator("#login-password").fill(defaultAuthCredentials.password); @@ -6061,7 +6045,7 @@ async function inspectWorkbenchGateLayout(browser, url, viewport, options = {}) } } -async function inspectMinimalWorkbenchLayout(browser, url, viewport) { +async function inspectMinimalWorkbenchLayout(browser, url, viewport, options = {}) { const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1, @@ -6093,12 +6077,14 @@ async function inspectMinimalWorkbenchLayout(browser, url, viewport) { observations.conversationVisible && observations.rightSidebarVisible && observations.rightSidebarToggleVisible; + const screenshots = [await captureElementScreenshot(page, "[data-app-shell]", options.artifactRoot, viewport, `minimal-${viewport.id}-shell`)].filter(Boolean); return { id: `layout-minimal-${viewport.id}`, status: pass ? "pass" : "blocked", viewport: { id: viewport.id, width: viewport.width, height: viewport.height }, summary: `${viewport.id} workbench opens after auth and exposes core conversation plus Device Pod controls.`, observations, + artifacts: { screenshots }, failures: pass ? [] : [ { selector: "[data-app-shell], #command-input, #conversation-list, #device-pod-sidebar, #right-sidebar-toggle", diff --git a/scripts/src/dev-runtime-postflight.mjs b/scripts/src/dev-runtime-postflight.mjs index 5c199cd0..ac8bac16 100644 --- a/scripts/src/dev-runtime-postflight.mjs +++ b/scripts/src/dev-runtime-postflight.mjs @@ -29,7 +29,7 @@ function bunExecutable() { function classifyRuntimeDurableReadiness(payload = {}, options = {}) { const script = [ - "import { classifyRuntimeDurableReadiness } from './web/hwlab-cloud-web/live-status.ts';", + "import { classifyRuntimeDurableReadiness } from './tools/src/runtime-durable-readiness.ts';", "const input = JSON.parse(await Bun.stdin.text());", "const output = classifyRuntimeDurableReadiness(input.payload, input.options);", "process.stdout.write(JSON.stringify(output));" diff --git a/scripts/src/m3-io-control-e2e.mjs b/scripts/src/m3-io-control-e2e.mjs index cc70175d..fd0f4b87 100644 --- a/scripts/src/m3-io-control-e2e.mjs +++ b/scripts/src/m3-io-control-e2e.mjs @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { readdirSync, statSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -228,7 +229,7 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } = readRepoText(root, "internal/cloud/server.ts"), readRepoText(root, "internal/cloud/json-rpc.ts"), readRepoText(root, "internal/cloud/m3-io-control.ts"), - Promise.all(["web/hwlab-cloud-web/app.ts","web/hwlab-cloud-web/app-device-pod.ts","web/hwlab-cloud-web/app-conversation.ts","web/hwlab-cloud-web/app-trace.ts","web/hwlab-cloud-web/app-helpers.ts"].map((file) => readRepoText(root, file))).then((parts) => parts.join("\n")), + readCloudWebReactSource(root), readRepoText(root, "web/hwlab-cloud-web/index.html"), readRepoText(root, "scripts/artifact-publish.mjs"), readRepoText(root, "internal/dev-entrypoint/cloud-web-routes.mjs") @@ -325,6 +326,24 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } = ]; } +async function readCloudWebReactSource(root) { + const sourceRoot = path.join(root, "web/hwlab-cloud-web/src"); + const files = listCloudWebSourceFiles(sourceRoot); + const parts = await Promise.all(files.map((file) => readFile(file, "utf8"))); + return parts.join("\n"); +} + +function listCloudWebSourceFiles(dir) { + const files = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) files.push(...listCloudWebSourceFiles(full)); + else if (/\.(ts|tsx|css)$/u.test(full)) files.push(full); + } + return files.sort(); +} + export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", artifactPublisherSource = "", cloudWebRouteSource = "" }) { const source = `${appSource}\n${htmlSource}`; const fetchTargets = literalFetchTargets(appSource); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 917dc865..04270ac1 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -1207,7 +1207,7 @@ test("hwlab-cli client agent trace can render with the Web trace row path", asyn assert.equal(result.exitCode, 0); assert.equal(result.payload.body.render, "web"); - assert.equal(result.payload.body.renderer, "web/hwlab-cloud-web/app-trace:traceDisplayRows"); + assert.equal(result.payload.body.renderer, "tools/src/hwlab-cli/trace-renderer:traceDisplayRows"); assert.equal(result.payload.body.sourceEventCount, 3); assert.ok(result.payload.body.renderedRowCount >= 1); assert.ok(result.payload.body.rows.some((row: any) => /助手最后一条消息/u.test(row.header))); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index ed233215..dbe494ce 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { computeCodeAgentComposerState, isCodeAgentSessionUnusableStatus } from "../../web/hwlab-cloud-web/composer-policy.ts"; -import { traceDisplayRows, traceNoiseEventCount } from "../../web/hwlab-cloud-web/app-trace.ts"; +import { computeCodeAgentComposerState, isCodeAgentSessionUnusableStatus } from "./hwlab-cli/composer-policy.ts"; +import { traceDisplayRows, traceNoiseEventCount } from "./hwlab-cli/trace-renderer.ts"; import { resolveRuntimeEndpoint, runtimeEndpointVisibility, sameRuntimeEndpointScope } from "./runtime-endpoint-resolver.ts"; const VERSION = "0.2.0-client"; @@ -2301,7 +2301,7 @@ function webTraceRenderBody(traceObject: any, parsed: ParsedArgs) { traceId: traceObject?.traceId ?? null, status: traceObject?.status ?? null, render: "web", - renderer: "web/hwlab-cloud-web/app-trace:traceDisplayRows", + renderer: "tools/src/hwlab-cli/trace-renderer:traceDisplayRows", sourceEventCount: traceObject?.eventCount ?? events.length, renderedRowCount: rows.length, returnedRowCount: rowTail.length, diff --git a/web/hwlab-cloud-web/src/logic/composer-policy.ts b/tools/src/hwlab-cli/composer-policy.ts similarity index 100% rename from web/hwlab-cloud-web/src/logic/composer-policy.ts rename to tools/src/hwlab-cli/composer-policy.ts diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts new file mode 100644 index 00000000..75f9afd7 --- /dev/null +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -0,0 +1,229 @@ +export interface TraceEventRow { + rowId: string; + seq: number | null; + tone: "ok" | "blocked" | "warn" | "source"; + header: string; + body: string | null; + terminal?: boolean; + bodyFormat?: "markdown"; +} + +type TraceEvent = Record; + +export function traceDisplayRows(trace: Record = {}, events: TraceEvent[] = []): TraceEventRow[] { + const rows: TraceEventRow[] = []; + let requestRendered = false; + let setupRendered = false; + let completionRendered = false; + for (const event of events) { + if (!event || isNoisyTraceEvent(event)) continue; + if (isRequestTraceEvent(event)) { + if (!requestRendered) { + rows.push(traceRequestSummaryRow(event)); + requestRendered = true; + } + continue; + } + if (isSetupTraceEvent(event)) { + if (!setupRendered) { + rows.push(traceSetupSummaryRow(event)); + setupRendered = true; + } + continue; + } + if (isTerminalAssistantTraceEvent(event)) { + rows.push(traceAssistantSummaryRow(trace, event)); + completionRendered = true; + continue; + } + if (isCompletionTraceEvent(event)) { + if (!completionRendered) { + rows.push(traceCompletionSummaryRow(trace, event)); + completionRendered = true; + } + continue; + } + rows.push(traceDisplayRow(trace, event)); + } + return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event)); +} + +export function traceNoiseEventCount(events: TraceEvent[] = []): number { + return Array.isArray(events) ? events.filter((event) => isNoisyTraceEvent(event)).length : 0; +} + +function traceRequestSummaryRow(event: TraceEvent): TraceEventRow { + const prompt = cleanTraceText(event.promptSummary ?? event.prompt ?? ""); + return { + rowId: `trace-request:${event.seq ?? "accepted"}`, + seq: numberOrNull(event.seq), + tone: traceEventTone(event), + header: `${traceClock(event.createdAt)} 请求接受${prompt ? `,提示词:"${compactTraceOneLine(prompt, 120)}"` : ""}`, + body: null + }; +} + +function traceSetupSummaryRow(event: TraceEvent): TraceEventRow { + const label = String(event.label ?? ""); + const parts = [ + /session:reused|thread\/resume/iu.test(label) ? "会话复用" : "会话就绪", + /prompt:sent|sent prompt/iu.test(label) ? "提示词已发送" : null, + /turn[:/]start|turn:started|turn\/start/iu.test(label) ? "轮次开始" : null + ].filter(Boolean).join(","); + return { + rowId: `trace-setup:${event.seq ?? "session-turn"}`, + seq: numberOrNull(event.seq), + tone: "source", + header: `${traceClock(event.createdAt)} ${parts || "会话准备完成"}`, + body: null + }; +} + +function traceAssistantSummaryRow(trace: Record, event: TraceEvent): TraceEventRow { + const text = cleanTraceText(event.message ?? event.outputSummary ?? assistantStreamText(trace, event) ?? ""); + return { + rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`, + seq: numberOrNull(event.seq), + tone: "ok", + header: `${traceClock(event.createdAt)} 助手最后一条消息,轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))})`, + terminal: true, + body: text || null, + bodyFormat: "markdown" + }; +} + +function traceCompletionSummaryRow(trace: Record, event: TraceEvent): TraceEventRow { + return { + rowId: `trace-completion:${event.seq ?? "turn"}`, + seq: numberOrNull(event.seq), + tone: traceEventTone(event), + header: `${traceClock(event.createdAt)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))})`, + body: null + }; +} + +function traceDisplayRow(trace: Record, event: TraceEvent): TraceEventRow { + const label = readableTraceLabel(event); + return { + rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`, + seq: numberOrNull(event.seq), + tone: traceEventTone(event), + header: `${traceClock(event.createdAt)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${traceStatusToken(event)} ${label}`.trim(), + body: traceDisplayBody(event) + }; +} + +function assistantStreamText(trace: Record, event: TraceEvent): string { + const streams = Array.isArray(trace.assistantStreams) ? trace.assistantStreams.filter(isRecord) : []; + const itemId = nonEmptyString(event.itemId ?? event.messageId ?? event.id); + const matched = itemId ? streams.find((stream) => [stream.itemId, stream.messageId, stream.id].map(nonEmptyString).includes(itemId)) : null; + return nonEmptyString(matched?.text ?? streams.at(-1)?.text) ?? ""; +} + +function isRequestTraceEvent(event: TraceEvent): boolean { + const label = String(event.label ?? ""); + return label === "agentrun:request:accepted" || label === "request:accepted-short-connection" || label === "request:accepted" || label === "request:received"; +} + +function isSetupTraceEvent(event: TraceEvent): boolean { + const label = String(event.label ?? ""); + return /^session:|^stdio:|^prompt:|^thread:|^turn:started|^turn:start|turn\/start|codex turn|tool:codex-app-server/iu.test(label) || + /^agentrun:backend:(codex-app-server-starting|initialize:completed|thread\/start|thread\/resume|thread\/started|turn\/start|turn\/started)/iu.test(label); +} + +function isCompletionTraceEvent(event: TraceEvent): boolean { + const label = String(event.label ?? ""); + return label.startsWith("turn:completed") || label.startsWith("result:completed") || label === "agentrun:backend:turn/completed" || label === "agentrun:result:completed"; +} + +function isTerminalAssistantTraceEvent(event: TraceEvent): boolean { + const label = String(event.label ?? ""); + if (event.terminal === true || label === "assistant:completed") return true; + if (label === "agentrun:assistant:message") return event.replyAuthority === true || event.final === true; + return event.type === "assistant_message" && (event.status === "completed" || event.final === true); +} + +function isNoisyTraceEvent(event: TraceEvent): boolean { + const label = String(event.label ?? ""); + if (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isCompletionTraceEvent(event) || isTerminalAssistantTraceEvent(event)) return false; + if (/token_count|outputDelta:chunk/iu.test(label)) return true; + if (/^agentrun:backend:(run-created|command-created|runner-job-created|thread\/status\/changed|thread\/tokenUsage\/updated|account\/rateLimits\/updated|remoteControl\/status\/changed|configWarning|codex-app-server-closed|session-updated|command-terminal|item\/agentMessage:(started|completed)|thread\/goal\/cleared)$/u.test(label)) return true; + return event.type === "event" && !event.outputSummary && !event.message && !event.errorCode; +} + +function readableTraceLabel(event: TraceEvent): string { + const label = String(event.label ?? `${event.type ?? "event"}:${event.status ?? "observed"}`); + if (label === "request:accepted-short-connection") return "submit short-connection"; + if (label === "request:accepted") return "request accepted"; + if (label.startsWith("turn:completed")) return "turn completed"; + if (label.startsWith("result:completed")) return "result ready"; + return label.replace(/^tool:/u, "").replace(/^assistant:/u, "assistant ").replace(/:completed$/u, " completed").replace(/:started$/u, " started"); +} + +function traceDisplayBody(event: TraceEvent): string | null { + const lines = [ + event.command ? `command=${compactTraceOneLine(event.command, 900)}` : null, + event.stdoutSummary ? `stdout:\n${compactTraceOneLine(event.stdoutSummary, 1200)}` : null, + event.stderrSummary ? `stderr:\n${compactTraceOneLine(event.stderrSummary, 800)}` : null, + event.outputSummary, + event.promptSummary, + event.message, + event.chunk + ].map(cleanTraceText).filter(Boolean); + return lines.length > 0 ? lines.join("\n").slice(0, 1400) : null; +} + +function traceStatusToken(event: TraceEvent): string { + const status = String(event.status ?? ""); + if (status === "completed" || status === "succeeded") return "ok"; + if (["failed", "blocked", "error", "timeout", "canceled"].includes(status) || event.errorCode) return "fail"; + if (["started", "running", "accepted"].includes(status)) return "run"; + return status || "event"; +} + +function traceEventTone(event: TraceEvent): TraceEventRow["tone"] { + const status = traceStatusToken(event); + if (status === "ok") return "ok"; + if (status === "fail") return "blocked"; + if (status === "run") return "warn"; + return "source"; +} + +function traceClock(value: unknown): string { + const date = new Date(String(value ?? "")); + return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19); +} + +function traceRelativeMs(trace: Record, event: TraceEvent): number { + if (typeof event.elapsedMs === "number") return event.elapsedMs; + const start = Date.parse(String(trace.startedAt ?? trace.createdAt ?? "")); + const current = Date.parse(String(event.createdAt ?? "")); + return Number.isNaN(start) || Number.isNaN(current) ? 0 : Math.max(0, current - start); +} + +function formatTraceDuration(ms: number): string { + const seconds = Math.floor(Math.max(0, Number(ms) || 0) / 1000); + return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), seconds % 60].map((part) => String(part).padStart(2, "0")).join(":"); +} + +function cleanTraceText(value: unknown): string { + return String(value ?? "").replace(/\u0000/gu, "").replace(/\r\n|\r/gu, "\n").replace(/[ \t]{2,}/gu, " ").replace(/\n{3,}/gu, "\n\n").trim(); +} + +function compactTraceOneLine(value: unknown, limit = 220): string { + const text = cleanTraceText(value).replace(/\s+/gu, " "); + return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`; +} + +function numberOrNull(value: unknown): number | null { + return Number.isInteger(value) ? Number(value) : null; +} + +function nonEmptyString(value: unknown): string | null { + const text = String(value ?? "").trim(); + return text ? text : null; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object"); +} diff --git a/tools/src/runtime-durable-readiness.ts b/tools/src/runtime-durable-readiness.ts new file mode 100644 index 00000000..ed1045c1 --- /dev/null +++ b/tools/src/runtime-durable-readiness.ts @@ -0,0 +1,216 @@ +const readyQueryResults = Object.freeze(["durable_readiness_ready", "runtime_durable_ready", "query_ready", "readiness_ready", "ready", "ok", "pass", "passed"]); +const blockedQueryResults = Object.freeze>({ + driver_missing: "runtime_durable_adapter_driver_missing", + ssl_negotiation_blocked: "runtime_durable_adapter_ssl_blocked", + auth_blocked: "runtime_durable_adapter_auth_blocked", + schema_blocked: "runtime_durable_adapter_schema_blocked", + migration_blocked: "runtime_durable_adapter_migration_blocked", + query_blocked: "runtime_durable_adapter_query_blocked", + not_ready: "runtime_durable_adapter_query_blocked" +}); +const gateLayers = Object.freeze(["ssl", "auth", "schema", "migration", "durability"]); + +export function classifyRuntimeDurableReadiness(payload: Record = {}, options: { requirePostgresAdapter?: boolean } = {}) { + const runtime = record(payload.runtime); + const readiness = record(payload.readiness); + const durability = record(readiness.durability); + const db = record(payload.db); + const dbRuntimeReadiness = record(db.runtimeReadiness); + const gates = record(runtime.gates ?? durability.gates); + const gateBlocker = firstBlockedRuntimeGate(gates); + const queryResult = firstNonEmpty(record(runtime.connection).queryResult, durability.queryResult, dbRuntimeReadiness.queryResult); + const queryBlocker = runtimeDurableBlockerFromQueryResult(queryResult); + const readyQueryResult = isRuntimeDurableReadyQueryResult(queryResult); + const explicitDurabilityReady = durabilityReadySignal({ runtime, durability, dbRuntimeReadiness, gateBlocker, queryResult, readyQueryResult, queryBlocker }); + const directBlocker = firstNonEmpty( + explicitDurabilityReady ? "" : runtime.blocker, + durability.blocker, + dbRuntimeReadiness.blocker, + explicitDurabilityReady ? "" : blockerCodeStartingWith(payload, "runtime_durable_adapter_") + ); + const reasonCode = firstNonEmpty(directBlocker, gateBlocker?.blocker, queryBlocker); + const blockedLayer = firstNonEmpty( + record(runtime.durabilityContract).blockedLayer, + durability.blockedLayer, + dbRuntimeReadiness.blockedLayer, + gateBlocker?.layer, + runtimeDurableLayerFromBlocker(reasonCode), + runtimeDurableLayerFromQueryResult(queryResult) + ); + const adapter = firstNonEmpty(runtime.adapter, durability.adapter, dbRuntimeReadiness.adapter, "unknown"); + const runtimeDurable = runtime.durable === true || durability.durable === true || dbRuntimeReadiness.durable === true; + const runtimeExplicitlyNonDurable = runtime.durable === false || durability.durable === false || dbRuntimeReadiness.durable === false; + const runtimeReady = runtime.ready === true || durability.runtimeReady === true || dbRuntimeReadiness.ready === true; + const durabilityReady = durability.ready === true || dbRuntimeReadiness.ready === true || runtimeReady; + const liveRuntimeEvidence = runtime.liveRuntimeEvidence === true || durability.liveRuntimeEvidence === true || dbRuntimeReadiness.liveRuntimeEvidence === true; + const rawStatus = rawStatusFrom(payload); + const postgresRequired = options.requirePostgresAdapter === true; + const ready = (!postgresRequired || adapter === "postgres") && runtimeDurable && runtimeReady && durabilityReady && liveRuntimeEvidence && !reasonCode && !blockedLayer && (!queryResult || readyQueryResult); + + if (ready) { + return { + status: "ready", + ready: true, + classification: "runtime_durable_ready", + reasonCode: "runtime_durable_ready", + reason: `runtime durable ready; adapter=${adapter}; queryResult=${queryResult || "not_observed"}`, + impact: "Device Pod readiness checks may rely on durable runtime evidence when persistence readiness is green.", + safeNextAction: "Continue with repo-owned DEV CD postflight; do not read or print Secret values.", + retryable: true, + readonly: false, + evidence: runtimeDurableEvidence({ adapter, runtimeDurable, runtimeReady, durabilityReady, liveRuntimeEvidence, blockedLayer: null, queryResult, readyQueryResult, reasonCode: null, gates, rawStatus, postgresRequired }) + }; + } + + const nonReadyReasonCode = firstNonEmpty( + reasonCode, + postgresRequired && adapter !== "postgres" ? "runtime_durable_adapter_not_postgres" : "", + runtimeExplicitlyNonDurable ? "runtime_durable_false" : "", + runtimeDurable ? "" : "runtime_durable_adapter_missing", + runtimeReady ? "" : "runtime_durable_runtime_not_ready", + liveRuntimeEvidence ? "" : "runtime_durable_live_evidence_missing", + blockedRuntimeStatus(rawStatus) ? rawStatus : "", + "runtime_durable_adapter_not_ready" + ); + const layer = firstNonEmpty(blockedLayer, runtimeDurableLayerFromBlocker(nonReadyReasonCode), runtimeExplicitlyNonDurable ? "runtime-durable" : "", "runtime-durable"); + return { + status: "blocked", + ready: false, + classification: nonReadyReasonCode, + reasonCode: nonReadyReasonCode, + reason: runtimeDurableBlockedReason({ reasonCode: nonReadyReasonCode, adapter, layer, queryResult }), + impact: "Device Pod readiness remains source-level while durable runtime evidence is blocked.", + safeNextAction: runtimeDurableSafeNextAction(nonReadyReasonCode, layer), + retryable: true, + readonly: true, + evidence: runtimeDurableEvidence({ adapter, runtimeDurable, runtimeReady, durabilityReady, liveRuntimeEvidence, blockedLayer: layer, queryResult, readyQueryResult, reasonCode: nonReadyReasonCode, gates, rawStatus, postgresRequired }) + }; +} + +function durabilityReadySignal(input: { runtime: Record; durability: Record; dbRuntimeReadiness: Record; gateBlocker: { layer: string; blocker: string } | null; queryResult: string; readyQueryResult: boolean; queryBlocker: string }): boolean { + if (input.gateBlocker || input.queryBlocker) return false; + if (input.queryResult && !input.readyQueryResult) return false; + const ready = input.durability.ready === true || input.dbRuntimeReadiness.ready === true || record(input.runtime.durabilityContract).ready === true; + if (!ready) return false; + return !firstNonEmpty(input.durability.blocker, input.dbRuntimeReadiness.blocker, record(input.runtime.durabilityContract).blocker, input.durability.blockedLayer, input.dbRuntimeReadiness.blockedLayer, record(input.runtime.durabilityContract).blockedLayer); +} + +function firstBlockedRuntimeGate(gates: Record): { layer: string; blocker: string } | null { + for (const layer of gateLayers) { + const gate = record(gates[layer]); + const status = normalized(gate.status); + if (gate.ready === false || status === "blocked" || status === "failed") return { layer, blocker: firstNonEmpty(gate.blocker, blockerForLayer(layer)) }; + } + return null; +} + +function blockerForLayer(layer: string): string { + if (layer === "ssl") return "runtime_durable_adapter_ssl_blocked"; + if (layer === "auth") return "runtime_durable_adapter_auth_blocked"; + if (layer === "schema") return "runtime_durable_adapter_schema_blocked"; + if (layer === "migration") return "runtime_durable_adapter_migration_blocked"; + return "runtime_durable_adapter_query_blocked"; +} + +function runtimeDurableLayerFromBlocker(blocker: string): string { + const text = normalized(blocker); + if (text.includes("ssl")) return "ssl"; + if (text.includes("auth")) return "auth"; + if (text.includes("schema")) return "schema"; + if (text.includes("migration")) return "migration"; + if (text.includes("query")) return "durability_query"; + if (text.includes("missing") || text.includes("unconfigured") || text.includes("driver")) return "adapter"; + return ""; +} + +function runtimeDurableLayerFromQueryResult(queryResult: string): string { + const text = normalized(queryResult); + if (!text || isRuntimeDurableReadyQueryResult(text)) return ""; + if (text.includes("ssl")) return "ssl"; + if (text.includes("auth")) return "auth"; + if (text.includes("schema")) return "schema"; + if (text.includes("migration")) return "migration"; + if (text.includes("query")) return "durability_query"; + if (text.includes("driver")) return "adapter"; + return ""; +} + +function runtimeDurableBlockerFromQueryResult(queryResult: string): string { + const text = normalized(queryResult); + if (!text || isRuntimeDurableReadyQueryResult(text)) return ""; + return blockedQueryResults[text] ?? ""; +} + +function isRuntimeDurableReadyQueryResult(queryResult: string): boolean { + return readyQueryResults.includes(normalized(queryResult)); +} + +function runtimeDurableEvidence(input: { adapter: string; runtimeDurable: boolean; runtimeReady: boolean; durabilityReady: boolean; liveRuntimeEvidence: boolean; blockedLayer: string | null; queryResult: string; readyQueryResult: boolean; reasonCode: string | null; gates: Record; rawStatus: unknown; postgresRequired: boolean }) { + return { + adapter: input.adapter, + durable: input.runtimeDurable, + runtimeReady: input.runtimeReady, + durabilityReady: input.durabilityReady, + liveRuntimeEvidence: input.liveRuntimeEvidence, + blocker: input.reasonCode, + blockedLayer: input.blockedLayer, + queryResult: input.queryResult || null, + readyQueryResult: input.readyQueryResult, + rawStatus: input.rawStatus || null, + postgresRequired: input.postgresRequired, + gates: Object.fromEntries(gateLayers.map((layer) => { + const gate = record(input.gates[layer]); + return [layer, { checked: gate.checked === true, ready: gate.ready === true, status: gate.status ?? "unknown", blocker: gate.blocker ?? null }]; + })), + secretMaterialRead: false + }; +} + +function runtimeDurableBlockedReason(input: { reasonCode: string; adapter: string; layer: string; queryResult: string }): string { + if (input.reasonCode === "runtime_durable_false") return `runtime durable=false; adapter=${input.adapter}; queryResult=${input.queryResult || "not_observed"}`; + if (input.reasonCode === "runtime_durable_adapter_not_postgres") return `runtime durable adapter is not postgres; adapter=${input.adapter}; queryResult=${input.queryResult || "not_observed"}`; + return `runtime durable blocked at ${input.layer || "runtime-durable"}; blocker=${input.reasonCode}; queryResult=${input.queryResult || "unknown"}`; +} + +function runtimeDurableSafeNextAction(reasonCode: string, layer: string): string { + const text = normalized(`${reasonCode} ${layer}`); + if (text.includes("auth")) return "Repair DEV DB auth/SecretRef metadata through repo-owned provisioning, then rerun postflight without printing Secret values."; + if (text.includes("schema") || text.includes("migration")) return "Run repo-owned DEV runtime provisioning and migration, then rerun postflight."; + if (text.includes("ssl")) return "Align DEV DB SSL mode with the repo-owned DEV contract, then rerun postflight."; + if (text.includes("query")) return "Inspect durable runtime query readiness, preserve evidence persistence, and rerun postflight."; + if (text.includes("postgres")) return "Run DEV with HWLAB_CLOUD_RUNTIME_ADAPTER=postgres and DB URLs injected only through SecretRefs."; + return "Restore durable runtime readiness through repo-owned DEV CD steps, then rerun postflight."; +} + +function rawStatusFrom(payload: Record): unknown { + return payload.status ?? record(payload.readiness).status ?? record(payload.runtime).status ?? record(record(payload.readiness).durability).status ?? ""; +} + +function blockerCodeStartingWith(payload: Record, prefix: string): string { + const readiness = record(payload.readiness); + return [...array(payload.blockerCodes), ...array(readiness.blockerCodes)].map(String).find((code) => code.startsWith(prefix)) ?? ""; +} + +function blockedRuntimeStatus(value: unknown): boolean { + return ["error", "failed", "failure", "blocked", "unavailable", "rejected", "degraded"].includes(normalized(value)); +} + +function record(value: unknown): Record { + return value && typeof value === "object" ? value as Record : {}; +} + +function array(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function firstNonEmpty(...values: unknown[]): string { + for (const value of values) { + const text = String(value ?? "").trim(); + if (text) return text; + } + return ""; +} + +function normalized(value: unknown): string { + return String(value ?? "").trim().toLowerCase(); +} diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 0c7cd4b2..bafd07ed 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -5,11 +5,9 @@ HWLAB 云工作台 - -
- +
diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 9117e08c..9c0b3271 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -4,12 +4,11 @@ "private": true, "type": "module", "scripts": { - "check": "bun run scripts/check.ts && bun run scripts/frontend-guard.ts && bun run scripts/tsc-check.ts && bun test", + "check": "bun run scripts/check.ts && bun run scripts/tsc-check.ts && bun test", "check:tsc": "bun run scripts/tsc-check.ts", "check:tsc-strict": "bun run scripts/tsc-check.ts --strict", - "check:guard": "bun run scripts/frontend-guard.ts", "build": "bun run scripts/build.ts", - "layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json", + "layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json", "layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json", "layout:live": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-live.json" }, diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 154c5c90..e5f4d98a 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -1,283 +1,103 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; -import { assertCloudWebDistFresh, buildCloudWebDist } from "./dist-contract.ts"; +import { assertCloudWebDistFresh, buildCloudWebDist, readCloudWebAppSource } from "./dist-contract.ts"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = path.resolve(rootDir, "../.."); -const requiredSourceFiles = Object.freeze([ +const requiredFiles = Object.freeze([ + "index.html", "src/main.tsx", "src/App.tsx", - "src/components/auth/LoginView.tsx", - "src/components/layout/Workbench.tsx", - "src/components/layout/AppShell.tsx", "src/components/layout/ActivityRail.tsx", - "src/components/layout/SessionSidebar.tsx", - "src/components/layout/CenterWorkspace.tsx", - "src/components/layout/WorkspaceView.tsx", - "src/components/layout/RightSidebar.tsx", - "src/components/conversation/ConversationView.tsx", - "src/components/conversation/MessageList.tsx", - "src/components/conversation/MessageItem.tsx", + "src/components/auth/LoginShell.tsx", + "src/components/sessions/SessionSidebar.tsx", + "src/components/conversation/ConversationPanel.tsx", "src/components/command-bar/CommandBar.tsx", - "src/components/device-pod/DevicePodPanel.tsx", - "src/components/device-pod/DevicePodSummary.tsx", - "src/components/device-pod/DevicePodInterfaces.tsx", - "src/components/device-pod/DeviceEventStream.tsx", - "src/components/device-pod/DeviceDetailDialog.tsx", - "src/components/skills/SkillsView.tsx", + "src/components/device-pod/DevicePodSidebar.tsx", "src/components/settings/SettingsView.tsx", - "src/components/help/HelpView.tsx", + "src/components/skills/SkillsView.tsx", "src/hooks/useAuth.ts", - "src/hooks/useWorkspace.ts", - "src/hooks/useDevicePod.ts", - "src/hooks/useSkills.ts", - "src/hooks/useTracePolling.ts", - "src/hooks/useCodeAgentSubmit.ts", "src/services/api/client.ts", - "src/services/api/auth.ts", - "src/services/api/sessions.ts", - "src/services/api/conversations.ts", - "src/services/api/codeAgent.ts", - "src/services/api/devicePods.ts", - "src/services/api/skills.ts", - "src/services/api/workspace.ts", - "src/services/auth.ts", - "src/state/workbenchState.tsx", - "src/logic/composer-policy.ts", - "src/logic/code-agent-facts.ts", - "src/logic/code-agent-status.ts", - "src/logic/live-status.ts", - "src/logic/message-markdown.ts", - "src/logic/app-trace.ts", - "src/logic/app-session-tabs.ts", - "src/logic/runtime.ts", - "vite.config.ts", - "styles.css", + "src/state/workbench.ts", + "src/types/domain.ts", + "src/styles/workbench.css", "favicon.svg", "favicon.ico", - "help.md", - "third_party/marked/marked.esm.js", - "third_party/marked/LICENSE", + "help.md" ]); -for (const file of requiredSourceFiles) { - const filePath = path.resolve(rootDir, file); - if (!fs.existsSync(filePath)) throw new Error(`missing web asset: ${file}`); +for (const file of requiredFiles) { + if (!fs.existsSync(path.resolve(rootDir, file))) throw new Error(`missing React web asset: ${file}`); } -console.log("hwlab-cloud-web check: react/typescript source tree present"); +console.log("hwlab-cloud-web check: React module assets present"); const html = readWeb("index.html"); -const styles = readWeb("styles.css"); -const traceSource = readWeb("src/logic/app-trace.ts"); -const messageMarkdown = readWeb("src/logic/message-markdown.ts"); -const sessionTabs = readWeb("src/logic/app-session-tabs.ts"); -const composerPolicy = readWeb("src/logic/composer-policy.ts"); -const liveStatus = readWeb("src/logic/live-status.ts"); -const codeAgentFacts = readWeb("src/logic/code-agent-facts.ts"); -const codeAgentStatus = readWeb("src/logic/code-agent-status.ts"); -const appEntry = readWeb("src/main.tsx"); -const appComponent = readWeb("src/App.tsx"); -const workbench = readWeb("src/components/layout/Workbench.tsx"); -const skillsView = readWeb("src/components/skills/SkillsView.tsx"); -const settingsView = readWeb("src/components/settings/SettingsView.tsx"); -const helpView = readWeb("src/components/help/HelpView.tsx"); -const commandBar = readWeb("src/components/command-bar/CommandBar.tsx"); -const devicePodPanel = readWeb("src/components/device-pod/DevicePodPanel.tsx"); -const messageItem = readWeb("src/components/conversation/MessageItem.tsx"); -const composerClient = readWeb("src/services/api/client.ts"); -const authService = readWeb("src/services/auth.ts"); -const authApi = readWeb("src/services/api/auth.ts"); -const stateContext = readWeb("src/state/workbenchState.tsx"); -const distContractScript = readWeb("scripts/dist-contract.ts"); +const appSource = readCloudWebAppSource(rootDir); +const css = readWeb("src/styles/workbench.css"); -assertReactHtmlShell(html); -assertReactEntry(appEntry); -assertReactAppComposition(appComponent); -assertReactComponentSplit(workbench, skillsView, settingsView, helpView); -assertReactComposerContract(composerPolicy, workbench, commandBar); -assertReactCodeAgentRoutes(authService, composerClient); -assertReactSkillsContract(skillsView, styles); -assertReactDevicePodContract(devicePodPanel, styles); -assertReactLiveStatusContract(liveStatus); -assertReactCodeAgentFacts(codeAgentFacts, codeAgentStatus); -assertReactMarkdownContract(messageItem, messageMarkdown); -assertReactSessionTabsContract(sessionTabs, workbench); -assertReactStateShape(stateContext); -assertReactTestCoverage(traceSource, messageMarkdown, sessionTabs, composerPolicy, liveStatus, codeAgentFacts, codeAgentStatus); -assertTraceHelpersBound(traceSource); -assertDevicePodEvidenceSummaryDoesNotCrash(); -console.log("hwlab-cloud-web check: react/typescript contracts verified"); +assert.match(html, /
<\/div>/u, "index.html must only expose React mount root"); +assert.match(html, /src="\/src\/main\.tsx"/u, "index.html must load React TSX entry"); +assert.doesNotMatch(html, /workbench-shell|device-pod-sidebar|command-input|conversation-list|session-tabs/u, "index.html must not contain business DOM shell"); +for (const removed of ["app.ts", "app-device-pod.ts", "app-conversation.ts", "app-helpers.ts", "app-skills.ts", "styles.css", "web-types.d.ts"]) { + assert.equal(fs.existsSync(path.join(rootDir, removed)), false, `${removed} must not remain as a legacy runtime path`); +} +assertIncludes(appSource, "createRoot", "React root must be used"); +assertIncludes(appSource, "React.StrictMode", "React StrictMode must wrap the app"); +assertIncludes(appSource, "useWorkbenchStore", "state hook must own workbench state"); +assertIncludes(appSource, "fetchJson", "API client must own fetch calls"); +assertIncludes(appSource, "DevicePodSidebar", "Device Pod sidebar component must exist"); +assertIncludes(appSource, "CommandBar", "command bar component must exist"); +assertIncludes(appSource, "SkillsView", "skills component must exist"); +assertIncludes(appSource, "SettingsView", "settings component must exist"); +assertIncludes(appSource, "id=\"command-input\"", "React runtime must render command input selector"); +assertIncludes(appSource, "id=\"conversation-list\"", "React runtime must render conversation list selector"); +assertIncludes(appSource, "id=\"device-pod-sidebar\"", "React runtime must render Device Pod sidebar selector"); +assertIncludes(appSource, "id=\"device-event-scroll\"", "React runtime must render Device Pod event stream selector"); +assertIncludes(appSource, "id=\"logout-button\"", "React runtime must render logout selector"); +assert.doesNotMatch(appSource, /document\.getElementById\((?!["']root["'])|querySelector\(["']#(?:command-input|conversation-list|device-pod-sidebar)/u, "major UI must not be driven by global DOM element lookups"); +assert.doesNotMatch(appSource, /\bconst\s+el\s*=/u, "global el map must not return"); + +const sourceFiles = collectSourceFiles(path.join(rootDir, "src")); +for (const file of sourceFiles) { + const rel = path.relative(rootDir, file); + const lines = fs.readFileSync(file, "utf8").split("\n").length; + assert.ok(lines <= 400, `${rel} exceeds 400 lines (${lines}); split the React module`); +} + +for (const directory of ["components/layout", "components/auth", "components/sessions", "components/conversation", "components/command-bar", "components/device-pod", "components/settings", "components/skills", "hooks", "services/api", "state", "types", "styles"]) { + assert.ok(fs.existsSync(path.join(rootDir, "src", directory)), `missing React module boundary src/${directory}`); +} +assert.match(css, /@media \(max-width: 720px\)/u, "mobile layout contract must be explicit"); +assert.match(css, /grid-template-columns:\s*56px\s+minmax\(0,\s*1fr\)/u, "390px mobile layout must not keep three squeezed columns"); +assert.match(css, /\.device-event-scroll\s*\{[\s\S]*?overscroll-behavior:\s*contain;/u, "event stream scroll must be contained"); +assert.match(css, /\.session-tabs\s*\{[\s\S]*?overflow-x:\s*hidden;/u, "session sidebar must not horizontally scroll"); + +const cloudApiServer = readRepo("internal/cloud/server.ts"); +const deployJson = readRepo("deploy/deploy.json"); +assertIncludes(cloudApiServer, "/v1/device-pods", "cloud-api must expose Device Pod REST routes"); +assertIncludes(cloudApiServer, "/v1/skills", "cloud-api must expose Skills REST routes"); +assertIncludes(deployJson, "hwlab-device-pod", "deploy manifest must include Device Pod service"); await buildCloudWebDist(rootDir); -console.log("hwlab-cloud-web check: vite dist bundle built"); +console.log("hwlab-cloud-web check: dist bundle built by Vite"); await assertCloudWebDistFresh(rootDir); console.log("hwlab-cloud-web check: dist freshness verified"); +console.log("hwlab-cloud-web check ok: React + strict TypeScript module boundaries and runtime selectors are present"); -for (const oldAsset of ["code-agent-m3-evidence.mjs", "wiring-status.mjs", "workbench-hardware-panel.mjs", "app.ts", "app-conversation.ts", "app-device-pod.ts", "app-skills.ts", "app-helpers.ts", "auth.ts"]) { - if (fs.existsSync(path.resolve(rootDir, oldAsset))) { - throw new Error(`legacy web runtime asset still present: ${oldAsset}`); +function readWeb(relativePath: string): string { return fs.readFileSync(path.resolve(rootDir, relativePath), "utf8"); } +function readRepo(relativePath: string): string { return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8"); } +function assertIncludes(source: string, term: string, message: string): void { assert.ok(source.includes(term), message); } +function collectSourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir)) { + const full = path.join(dir, entry); + const stat = fs.statSync(full); + if (stat.isDirectory()) out.push(...collectSourceFiles(full)); + else if (/\.(ts|tsx|css)$/u.test(full)) out.push(full); } + return out; } -console.log("hwlab-cloud-web check: legacy asset cleanup verified"); - -console.log("hwlab-cloud-web check ok: React + TypeScript SPA, Vite dist, module boundary contracts and tests are present"); - -function readWeb(relativePath) { - return fs.readFileSync(path.resolve(rootDir, relativePath), "utf8"); -} - -function assertReactHtmlShell(source) { - assert.match(source, /
]*data-cloud-web-root/u, "index.html must mount React into #root"); - assert.doesNotMatch(source, /class="login-shell"/u, "index.html must not inline the legacy login shell"); - assert.doesNotMatch(source, /class="workbench-shell"/u, "index.html must not inline the legacy workbench shell"); - assert.doesNotMatch(source, /id="login-form"/u, "index.html must not inline the legacy login form"); - assert.doesNotMatch(source, /id="command-form"/u, "index.html must not inline the legacy command form"); - assert.doesNotMatch(source, /id="device-pod-sidebar"/u, "index.html must not inline the legacy device pod sidebar"); - assert.match(source, /", + "", + "" + ].join("\n")); + write(rootDir, "vite.config.ts", [ + "export default {", + " build: {", + " outDir: 'dist',", + " emptyOutDir: true,", + " rollupOptions: { output: { entryFileNames: 'app.js', chunkFileNames: 'assets/[name].js', assetFileNames: 'assets/[name][extname]' } }", + " }", + "};", + "" + ].join("\n")); + write(rootDir, "src/main.tsx", "import './styles/workbench.css';\nimport { App } from './App';\nconsole.log('fixture-app', App());\n"); + write(rootDir, "src/App.tsx", "export function App() { return 'fixture-app'; }\n"); + write(rootDir, "src/styles/workbench.css", ".fixture-shell { color: #0f766e; }\n"); + write(rootDir, "favicon.svg", "\n"); + write(rootDir, "help.md", "# fixture help\n"); + return rootDir; +} + +function write(rootDir: string, relativePath: string, text: string): void { + const filePath = path.join(rootDir, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, text); +} + +function read(filePath: string): string { + return fs.readFileSync(filePath, "utf8"); +} diff --git a/web/hwlab-cloud-web/scripts/dist-contract.ts b/web/hwlab-cloud-web/scripts/dist-contract.ts index 1fa0215f..aa4549da 100644 --- a/web/hwlab-cloud-web/scripts/dist-contract.ts +++ b/web/hwlab-cloud-web/scripts/dist-contract.ts @@ -1,96 +1,89 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; export const cloudWebDistPath = "web/hwlab-cloud-web/dist"; export const cloudWebDistBuildCommand = "cd web/hwlab-cloud-web && bun run build"; export const cloudWebDistFreshnessCommand = cloudWebDistBuildCommand; -export const cloudWebAppEntry = "web/hwlab-cloud-web/src/main.tsx"; -export const cloudWebStaticSourceFiles = Object.freeze([ - "help.md", - "favicon.ico", - "third_party/marked/marked.esm.js", - "third_party/marked/LICENSE", -]); -export const cloudWebDistRuntimeFiles = Object.freeze([ - "index.html", - "app.js", - "help.md", - "favicon.ico", - "third_party/marked/marked.esm.js", - "third_party/marked/LICENSE", -]); -export const cloudWebDistAliasFiles = Object.freeze([ - "gate/index.html", - "diagnostics/gate/index.html", - "help/index.html", - "skills/index.html", -]); +export const cloudWebAppSourceFiles = Object.freeze(["src/main.tsx", "src/App.tsx"]); +export const cloudWebDistRuntimeFiles = Object.freeze(["index.html", "app.js", "assets/index.css", "assets/favicon.svg", "help.md"]); +export const cloudWebDistAliasFiles = Object.freeze(["gate/index.html", "diagnostics/gate/index.html", "help/index.html", "skills/index.html"]); -const VITE_OUTPUTS = Object.freeze(["index.html", "app.js"]); -const VITE_ASSET_DIR = "app-assets"; +type FreshnessStatus = "pass" | "blocked"; -function absolutePath(rootDir, relativePath) { +interface DistFileStatus { + kind: "runtime" | "alias"; + path: string; + expectedPath: string; + distPath: string; + status: "match" | "missing_expected" | "missing_dist" | "mismatch"; +} + +interface DistFreshness { + status: FreshnessStatus; + distPath: string; + buildCommand: string; + freshnessCommand: string; + files: DistFileStatus[]; + mismatches: string[]; +} + +function absolutePath(rootDir: string, relativePath: string): string { return path.resolve(rootDir, relativePath); } -function exists(filePath) { +function exists(filePath: string): boolean { return fs.existsSync(filePath); } -export async function buildCloudWebDist(rootDir) { +export function readCloudWebAppSource(rootDir: string): string { + return collectSourceFiles(path.join(rootDir, "src")).map((file) => fs.readFileSync(file, "utf8")).join("\n"); +} + +export async function buildCloudWebDist(rootDir: string): Promise { const distRoot = absolutePath(rootDir, "dist"); - fs.rmSync(distRoot, { recursive: true, force: true }); - fs.mkdirSync(distRoot, { recursive: true }); - await runViteBuild(rootDir, distRoot); - - for (const relativePath of cloudWebStaticSourceFiles) { - const distPath = absolutePath(distRoot, relativePath); - fs.mkdirSync(path.dirname(distPath), { recursive: true }); - fs.copyFileSync(absolutePath(rootDir, relativePath), distPath); - } - - const builtIndex = absolutePath(distRoot, "index.html"); - for (const aliasPath of cloudWebDistAliasFiles) { - const distPath = absolutePath(distRoot, aliasPath); - fs.mkdirSync(path.dirname(distPath), { recursive: true }); - fs.copyFileSync(builtIndex, distPath); - } - + runViteBuild(rootDir, distRoot); + finalizeGeneratedDist(rootDir, distRoot); return distRoot; } -export async function inspectCloudWebDistFreshness(rootDir) { +export async function inspectCloudWebDistFreshness(rootDir: string): Promise { const distRoot = absolutePath(rootDir, "dist"); - const files = []; - const mismatches = []; + const expectedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hwlab-cloud-web-expected-")); + const expectedDist = path.join(expectedRoot, "dist"); + const files: DistFileStatus[] = []; + const mismatches: string[] = []; - for (const relativePath of cloudWebStaticSourceFiles) { - await compareFile({ - files, - mismatches, - kind: "static", - relativePath, - sourcePath: absolutePath(rootDir, relativePath), - distPath: absolutePath(distRoot, relativePath), - }); - } + try { + runViteBuild(rootDir, expectedDist); + finalizeGeneratedDist(rootDir, expectedDist); - await compareViteBundle({ files, mismatches, rootDir, distRoot }); + for (const relativePath of cloudWebDistRuntimeFiles) { + await compareFile({ + files, + mismatches, + kind: "runtime", + relativePath, + expectedPath: path.join(expectedDist, relativePath), + distPath: path.join(distRoot, relativePath) + }); + } - const builtIndex = absolutePath(distRoot, "index.html"); - for (const aliasPath of cloudWebDistAliasFiles) { - await compareFile({ - files, - mismatches, - kind: "alias", - relativePath: aliasPath, - sourcePath: builtIndex, - distPath: absolutePath(distRoot, aliasPath), - }); + for (const aliasPath of cloudWebDistAliasFiles) { + await compareFile({ + files, + mismatches, + kind: "alias", + relativePath: aliasPath, + expectedPath: path.join(expectedDist, aliasPath), + distPath: path.join(distRoot, aliasPath) + }); + } + } finally { + fs.rmSync(expectedRoot, { recursive: true, force: true }); } return { @@ -99,131 +92,91 @@ export async function inspectCloudWebDistFreshness(rootDir) { buildCommand: cloudWebDistBuildCommand, freshnessCommand: cloudWebDistFreshnessCommand, files, - mismatches, + mismatches }; } -export function cloudWebDistFreshnessMessage(distFreshness) { +export function cloudWebDistFreshnessMessage(distFreshness: { mismatches: string[] }): string { const mismatches = distFreshness.mismatches.length > 0 ? distFreshness.mismatches.join(", ") : "unknown"; - return [ - `Cloud Web dist is stale or missing before image build: ${mismatches}.`, - `Run ${cloudWebDistBuildCommand} before publishing ${cloudWebDistPath}.`, - ].join(" "); + return [`Cloud Web dist is stale or missing before image build: ${mismatches}.`, `Run ${cloudWebDistBuildCommand} before publishing ${cloudWebDistPath}.`].join(" "); } -export async function assertCloudWebDistFresh(rootDir) { +export async function assertCloudWebDistFresh(rootDir: string): Promise { const distFreshness = await inspectCloudWebDistFreshness(rootDir); - if (distFreshness.status !== "pass") { - throw new Error(cloudWebDistFreshnessMessage(distFreshness)); - } + if (distFreshness.status !== "pass") throw new Error(cloudWebDistFreshnessMessage(distFreshness)); return distFreshness; } -async function compareFile({ files, mismatches, kind, relativePath, sourcePath, distPath }) { - const entry = { kind, path: relativePath, sourcePath, distPath, status: "match" }; - if (!exists(sourcePath)) { - entry.status = "missing_source"; - entry.reason = "source file is missing"; - } else if (!exists(distPath)) { - entry.status = "missing_dist"; - entry.reason = "dist file is missing"; - } else { - const [sourceText, distText] = await Promise.all([readFile(sourcePath, "utf8"), readFile(distPath, "utf8")]); - if (sourceText !== distText) { - entry.status = "mismatch"; - entry.reason = "dist file differs from source"; - } - } - if (entry.status !== "match") mismatches.push(relativePath); - files.push(entry); +function runViteBuild(rootDir: string, outDir: string): void { + ensureWebDeps(rootDir); + const rootLocalVite = path.join(rootDir, "node_modules", ".bin", "vite"); + const packageLocalVite = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "node_modules", ".bin", "vite"); + const localVite = exists(rootLocalVite) ? rootLocalVite : exists(packageLocalVite) ? packageLocalVite : null; + const command = localVite ?? "npx"; + const args = localVite + ? ["build", "--outDir", outDir, "--emptyOutDir"] + : ["--yes", "vite@5.4.10", "build", "--outDir", outDir, "--emptyOutDir"]; + const result = spawnSync(command, args, { cwd: rootDir, encoding: "utf8" }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.status !== 0) throw new Error(`Vite build failed with exit ${result.status ?? "unknown"}`); } -async function compareViteBundle({ files, mismatches, rootDir, distRoot }) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hwlab-cloud-web-vite-")); - try { - await runViteBuild(rootDir, tmpDir); - for (const viteOutput of VITE_OUTPUTS) { - await compareFile({ - files, - mismatches, - kind: "vite", - relativePath: viteOutput, - sourcePath: absolutePath(tmpDir, viteOutput), - distPath: absolutePath(distRoot, viteOutput), - }); - } - const tmpAssets = path.join(tmpDir, VITE_ASSET_DIR); - const distAssets = path.join(distRoot, VITE_ASSET_DIR); - const tmpAssetFiles = fs.existsSync(tmpAssets) ? listFiles(tmpAssets) : []; - const distAssetFiles = fs.existsSync(distAssets) ? listFiles(distAssets) : []; - const seen = new Set([...tmpAssetFiles, ...distAssetFiles]); - for (const rel of seen) { - const a = path.join(tmpAssets, rel); - const b = path.join(distAssets, rel); - if (!exists(a) || !exists(b)) { - mismatches.push(`app-assets/${rel}`); - files.push({ kind: "vite-asset", path: `app-assets/${rel}`, sourcePath: a, distPath: b, status: "mismatch", reason: "vite asset missing" }); - continue; - } - const [aText, bText] = await Promise.all([readFile(a, "utf8"), readFile(b, "utf8")]); - if (aText !== bText) { - mismatches.push(`app-assets/${rel}`); - files.push({ kind: "vite-asset", path: `app-assets/${rel}`, sourcePath: a, distPath: b, status: "mismatch", reason: "vite asset drifted" }); - } - } - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); +function finalizeGeneratedDist(rootDir: string, distRoot: string): void { + const helpSource = absolutePath(rootDir, "help.md"); + if (exists(helpSource)) fs.copyFileSync(helpSource, path.join(distRoot, "help.md")); + for (const aliasPath of cloudWebDistAliasFiles) { + const distPath = path.join(distRoot, aliasPath); + fs.mkdirSync(path.dirname(distPath), { recursive: true }); + fs.copyFileSync(path.join(distRoot, "index.html"), distPath); } } -function listFiles(dir) { - return fs.readdirSync(dir).flatMap((entry) => { +async function compareFile(input: { files: DistFileStatus[]; mismatches: string[]; kind: "runtime" | "alias"; relativePath: string; expectedPath: string; distPath: string }): Promise { + const entry: DistFileStatus = { + kind: input.kind, + path: input.relativePath, + expectedPath: input.expectedPath, + distPath: input.distPath, + status: "match" + }; + + if (!exists(input.expectedPath)) entry.status = "missing_expected"; + else if (!exists(input.distPath)) entry.status = "missing_dist"; + else { + const [expectedText, distText] = await Promise.all([readFile(input.expectedPath, "utf8"), readFile(input.distPath, "utf8")]); + if (expectedText !== distText) entry.status = "mismatch"; + } + + if (entry.status !== "match") input.mismatches.push(input.relativePath); + input.files.push(entry); +} + +function collectSourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir)) { const full = path.join(dir, entry); const stat = fs.statSync(full); - if (stat.isDirectory()) return listFiles(full).map((sub) => `${entry}/${sub}`); - return [entry]; - }); + if (stat.isDirectory()) out.push(...collectSourceFiles(full)); + else if (/\.(ts|tsx|css)$/u.test(full)) out.push(full); + } + return out.sort(); } -async function runViteBuild(rootDir, outDir) { - await ensureWebDeps(rootDir); - const localVite = path.join(rootDir, "node_modules", ".bin", "vite"); - const command = fs.existsSync(localVite) ? localVite : "npx"; - const args = fs.existsSync(localVite) - ? ["build", "--outDir", outDir, "--emptyOutDir", "false"] - : ["--yes", "vite@5.4.10", "build", "--outDir", outDir, "--emptyOutDir", "false"]; - await new Promise((resolve, reject) => { - const child = spawn(command, args, { cwd: rootDir, stdio: ["ignore", "inherit", "inherit"] }); - child.on("error", reject); - child.on("exit", (code) => { - if (code === 0) resolve(); - else reject(new Error(`vite build exited with code ${code}`)); - }); - }); -} - -async function ensureWebDeps(rootDir) { +function ensureWebDeps(rootDir: string): void { const lockPath = path.join(rootDir, "bun.lock"); const nodeModulesPath = path.join(rootDir, "node_modules"); if (!fs.existsSync(lockPath)) return; if (fs.existsSync(path.join(nodeModulesPath, "vite")) && fs.existsSync(path.join(nodeModulesPath, "react"))) return; - await new Promise((resolve, reject) => { - const child = spawn("bun", ["install", "--frozen-lockfile"], { cwd: rootDir, stdio: ["ignore", "inherit", "inherit"] }); - child.on("error", reject); - child.on("exit", (code) => { - if (code === 0) resolve(); - else reject(new Error(`bun install exited with code ${code}`)); - }); - }); + const result = spawnSync("bun", ["install", "--frozen-lockfile"], { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "inherit", "inherit"] }); + if (result.status !== 0) throw new Error(`bun install exited with code ${result.status ?? "unknown"}`); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const args = process.argv.slice(2); const rootIndex = args.indexOf("--root"); - const rootDir = rootIndex >= 0 ? path.resolve(args[rootIndex + 1]) : path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - if (args.includes("--build")) { - await buildCloudWebDist(rootDir); - } + const rootDir = rootIndex >= 0 ? path.resolve(args[rootIndex + 1] ?? ".") : path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + if (args.includes("--build")) await buildCloudWebDist(rootDir); const freshness = await inspectCloudWebDistFreshness(rootDir); process.stdout.write(`${JSON.stringify(freshness, null, args.includes("--pretty") ? 2 : 0)}\n`); } diff --git a/web/hwlab-cloud-web/scripts/frontend-guard.ts b/web/hwlab-cloud-web/scripts/frontend-guard.ts deleted file mode 100644 index b459c963..00000000 --- a/web/hwlab-cloud-web/scripts/frontend-guard.ts +++ /dev/null @@ -1,124 +0,0 @@ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -// Frontend module boundary guard for the React + TypeScript migration -// (HWLAB #756). Enforces: -// - no single frontend source file (src/**/*.tsx? and *.tsx in the -// migration tree) exceeds 400 lines unless explicitly grandfathered -// - main.tsx, App.tsx, and components.tsx are not bloated into a single -// mega file -// - required module directories exist (layout, auth, sessions, -// conversation, command-bar, device-pod, skills, settings, help, -// shared, hooks, services/api, services/workspace, state, types, -// logic, styles) -// - no legacy vanilla DOM markers survive in the new src/ tree -// (global el map, document.getElementById for primary UI, legacy -// app-*.ts files imported from src/) - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const srcDir = path.resolve(rootDir, "src"); - -const SOFT_LIMIT = 400; -const ABSOLUTE_LIMIT = 1500; -const GRANDFATHERED = new Set([ - "src/logic/app-trace.ts", - "src/logic/live-status.ts", - "src/logic/code-agent-facts.ts", - "src/logic/code-agent-status.ts", -]); -const GRANDFATHERED_REASON = Object.freeze({ - "src/logic/app-trace.ts": "Pure-logic trace row builder used by CLI + Web; tested by src/logic/app-trace.test.ts. Splittable but the 400-line guideline is a soft target for migration-era pure logic.", - "src/logic/live-status.ts": "Pure-logic live status classifier for workbench/device pod/m3 probes; tested by src/logic/live-status.test.ts. Left in one file to keep the public probe taxonomy stable for the CLI/Web renderer.", - "src/logic/code-agent-facts.ts": "Pure-logic code agent facts classifier (provider/backend/runner/protocol/implementation/tool calls); tested by src/logic/code-agent-facts.test.ts.", - "src/logic/code-agent-status.ts": "Pure-logic code agent status summarizer (availability, deployment revision, capability level); tested by src/logic/code-agent-status.test.ts.", -}); - -const REQUIRED_MODULE_DIRS = Object.freeze([ - "components", - "components/auth", - "components/layout", - "components/conversation", - "components/command-bar", - "components/device-pod", - "components/skills", - "components/settings", - "components/help", - "hooks", - "services", - "services/api", - "logic", - "types", -]); - -const ENTRY_FILES = Object.freeze(["src/main.tsx", "src/App.tsx"]); -const ENTRY_HARD_LIMIT = 200; - -const LEGACY_BANNED_TOKENS = Object.freeze([ - "function byId(", - "const el = {", - "el.loginShell", -]); - -function walk(dir, out = []) { - if (!fs.existsSync(dir)) return out; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.name.startsWith(".") && entry.name !== ".gitkeep") continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full, out); - else if (/\.(ts|tsx)$/u.test(entry.name)) out.push(full); - } - return out; -} - -const files = walk(srcDir); -assert.ok(files.length > 0, "frontend guard: src/ must contain at least one .ts(x) file"); - -const relativeFiles = files.map((file) => path.relative(rootDir, file).split(path.sep).join("/")); - -for (const dir of REQUIRED_MODULE_DIRS) { - const full = path.resolve(srcDir, dir); - assert.ok(fs.existsSync(full) && fs.statSync(full).isDirectory(), `frontend guard: required module directory missing: src/${dir}`); -} -console.log(`frontend guard: ${REQUIRED_MODULE_DIRS.length} required module directories present`); - -for (const rel of relativeFiles) { - const full = path.resolve(rootDir, rel); - const text = fs.readFileSync(full, "utf8"); - const lines = text.split("\n").length; - if (rel.endsWith(".test.ts") || rel.endsWith(".test.tsx")) { - continue; - } - const entryHardLimit = ENTRY_FILES.includes(rel) ? ENTRY_HARD_LIMIT : null; - if (entryHardLimit !== null) { - assert.ok(lines <= entryHardLimit, `frontend guard: entry file ${rel} must stay <= ${entryHardLimit} lines (currently ${lines})`); - continue; - } - if (GRANDFATHERED.has(rel)) { - assert.ok(lines <= ABSOLUTE_LIMIT, `frontend guard: grandfathered file ${rel} must stay <= ${ABSOLUTE_LIMIT} lines (currently ${lines}); reason: ${GRANDFATHERED_REASON[rel]}`); - continue; - } - assert.ok(lines <= SOFT_LIMIT, `frontend guard: file ${rel} exceeds the ${SOFT_LIMIT}-line soft target (${lines} lines); split it or add it to GRANDFATHERED with a reason`); -} -console.log(`frontend guard: ${relativeFiles.length} src files within ${SOFT_LIMIT}-line target (grandfathered: ${[...GRANDFATHERED].join(", ") || "none"})`); - -for (const rel of relativeFiles) { - const text = fs.readFileSync(path.resolve(rootDir, rel), "utf8"); - for (const banned of LEGACY_BANNED_TOKENS) { - assert.ok(!text.includes(banned), `frontend guard: legacy vanilla DOM marker in ${rel}: ${banned}`); - } -} -console.log("frontend guard: no legacy vanilla DOM markers in src/"); - -for (const legacyFile of ["app.ts", "app-conversation.ts", "app-device-pod.ts", "app-skills.ts", "app-helpers.ts", "app-trace.ts", "app-session-tabs.ts", "auth.ts"]) { - const topLevel = path.resolve(rootDir, legacyFile); - assert.ok(!fs.existsSync(topLevel), `frontend guard: legacy file must not remain at top level: ${legacyFile}`); -} -console.log("frontend guard: no legacy app-*.ts / auth.ts at top level"); - -assert.ok(fs.existsSync(path.resolve(rootDir, "vite.config.ts")), "frontend guard: vite.config.ts must exist"); -assert.ok(fs.existsSync(path.resolve(rootDir, "tsconfig.json")), "frontend guard: tsconfig.json must exist"); -console.log("frontend guard: vite + tsconfig present"); - -console.log("frontend guard ok: React + TypeScript module boundary contracts hold"); diff --git a/web/hwlab-cloud-web/scripts/tsc-check.ts b/web/hwlab-cloud-web/scripts/tsc-check.ts index db895cb5..d748fde9 100644 --- a/web/hwlab-cloud-web/scripts/tsc-check.ts +++ b/web/hwlab-cloud-web/scripts/tsc-check.ts @@ -3,70 +3,51 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -// TSC type-check entry. 背景见 docs/dist-contract 与 web-types.d.ts: -// build 阶段由 dist-contract.ts:buildAppBundle 把 7 个 .ts 源文件拼成单一虚拟 -// 模块后交给 Bun.build;本脚本走 tsc --noEmit 校验源码(不参与 runtime bundle), -// 是 web:check 在 #751 之后新增的并行 type gate。 -// -// Gate 行为: -// - 项目源码(含 web-types.d.ts)里出现显式 :A / 注解 → 立刻 exit 2 -// (#751 硬要求 0 explicit A;node_modules / dist / 隐藏目录不在扫描范围; -// A 占位符由运行时拼出,避免本文件被自身 detector 命中) -// - 0 explicit A + tsc 通过 → exit 0 -// - 0 explicit A + tsc 报类型错 → exit 0 但打印 warning(issue #751 后续轮次跟踪; -// 当前剩余的 300+ property access 错属于 Web/ElMap state 之外的局部隐式类型推导 -// 缺口,每一类都会单独立 issue 跟踪,#751 不阻塞 build) -// --strict 模式额外做 strict 回归,让新增 implicit A 立刻冒出来 - -const A = "a" + "n" + "y"; - const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceFiles = collectSourceFiles(path.join(rootDir, "src")); +let explicitAnyCount = 0; +for (const file of sourceFiles) { + const text = readFileSync(file, "utf8"); + const stripped = stripComments(text); + const matches = [ + ...stripped.matchAll(/:\s*any\b/gu), + ...stripped.matchAll(/<\s*any\s*>/gu), + ...stripped.matchAll(/as\s+any\b/gu), + ...stripped.matchAll(/Array\s*<\s*any\s*>/gu) + ]; + if (matches.length > 0) { + explicitAnyCount += matches.length; + console.error(` ${path.relative(rootDir, file)}: ${matches.length} explicit any usage(s)`); + } +} +if (explicitAnyCount > 0) { + console.error(`hwlab-cloud-web tsc-check: ${explicitAnyCount} explicit any usage(s) in React source`); + process.exit(2); +} -function collectSourceFiles(dir) { - const out = []; +const tscBin = path.join(rootDir, "node_modules/.bin/tsc"); +const tscArgs = ["--noEmit", "--project", path.join(rootDir, "tsconfig.json"), "--strict"]; +const tscResult = spawnSync(tscBin, tscArgs, { cwd: rootDir, encoding: "utf8" }); +if (tscResult.stdout) process.stdout.write(tscResult.stdout); +if (tscResult.stderr) process.stderr.write(tscResult.stderr); +if (tscResult.status !== 0) { + console.error(`hwlab-cloud-web tsc-check: strict TypeScript failed with exit ${tscResult.status ?? "unknown"}`); + process.exit(tscResult.status ?? 2); +} +console.log("hwlab-cloud-web tsc-check: passed (strict React TSX, 0 explicit any)"); + +function collectSourceFiles(dir: string): string[] { + const out: string[] = []; for (const entry of readdirSync(dir)) { if (entry === "node_modules" || entry === "dist" || entry.startsWith(".")) continue; const full = path.join(dir, entry); const stat = statSync(full); if (stat.isDirectory()) out.push(...collectSourceFiles(full)); - else if (full.endsWith(".ts")) out.push(full); + else if (/\.(ts|tsx)$/u.test(full)) out.push(full); } return out; } -const sourceFiles = collectSourceFiles(rootDir); -let aCount = 0; -for (const file of sourceFiles) { - if (file === __filename) continue; - const text = readFileSync(file, "utf8"); - const colonA = (text.match(new RegExp(": " + A + "\\b", "g")) ?? []).length; - const angleA = (text.match(new RegExp("<" + A + ">", "g")) ?? []).length; - if (colonA + angleA > 0) { - console.error(` ${path.relative(rootDir, file)}: ${colonA} colon-form + ${angleA} angle-form`); - aCount += colonA + angleA; - } +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/(^|[^:])\/\/.*$/gmu, "$1"); } - -if (aCount > 0) { - console.error(`hwlab-cloud-web tsc-check: ${aCount} explicit untyped annotation(s) in project source (issue #751: must be 0)`); - process.exit(2); -} - -const tscBin = path.join(rootDir, "node_modules/.bin/tsc"); -const tscArgs = ["--noEmit", "--project", path.join(rootDir, "tsconfig.json")]; -if (process.argv.includes("--strict")) tscArgs.push("--strict"); -const tscResult = spawnSync(tscBin, tscArgs, { cwd: rootDir, encoding: "utf8" }); -if (tscResult.stdout) process.stdout.write(tscResult.stdout); -if (tscResult.stderr) process.stderr.write(tscResult.stderr); - -if (tscResult.status === 0) { - console.log("hwlab-cloud-web tsc-check: passed (0 explicit untyped, 0 type errors)"); - process.exit(0); -} - -const errorCount = ((tscResult.stdout ?? "").match(/error TS\d+/g) ?? []).length; -console.warn( - `hwlab-cloud-web tsc-check: ${errorCount} residual type error(s) ` + - `(tracked by issue #751 follow-ups; web:check continues). 0 explicit untyped in project source.` -); -process.exit(0); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index e6a302b6..b3028a28 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -1,50 +1,103 @@ -import { useEffect, useState } from "react"; +import type { ReactElement } from "react"; +import { useEffect, useMemo, useState } from "react"; -import { useAuthGate } from "./hooks/useAuth.ts"; -import { LoginView } from "./components/auth/LoginView.tsx"; -import { Workbench } from "./components/layout/Workbench.tsx"; +import { useAuth } from "./hooks/useAuth"; +import { useWorkbenchStore } from "./state/workbench"; +import type { RouteId } from "./types/domain"; +import { LoginShell } from "./components/auth/LoginShell"; +import { CommandBar } from "./components/command-bar/CommandBar"; +import { ConversationPanel } from "./components/conversation/ConversationPanel"; +import { DevicePodSidebar } from "./components/device-pod/DevicePodSidebar"; +import { ActivityRail } from "./components/layout/ActivityRail"; +import { SessionSidebar } from "./components/sessions/SessionSidebar"; +import { SettingsView } from "./components/settings/SettingsView"; +import { SkillsView } from "./components/skills/SkillsView"; +import { fetchText } from "./services/api/client"; -const AUTH_CHECKING = "checking"; -const AUTH_AUTHENTICATED = "authenticated"; - -export function App() { - const auth = useAuthGate(); - const [authState, setAuthState] = useState(AUTH_CHECKING); +export function App(): ReactElement { + const auth = useAuth(); + const store = useWorkbenchStore(); + const [route, setRoute] = useState(() => routeFromLocation()); + const [leftCollapsed, setLeftCollapsed] = useState(false); + const [rightCollapsed, setRightCollapsed] = useState(false); + const [help, setHelp] = useState("加载中"); useEffect(() => { - if (typeof document === "undefined") return; - document.body.dataset.authState = authState; - }, [authState]); + const listener = (): void => setRoute(routeFromLocation()); + window.addEventListener("hashchange", listener); + return () => window.removeEventListener("hashchange", listener); + }, []); useEffect(() => { - if (auth.status === "authenticated") { - setAuthState(AUTH_AUTHENTICATED); - } else if (auth.status === "unauthenticated") { - setAuthState("unauthenticated"); - } else { - setAuthState(AUTH_CHECKING); - } - }, [auth.status]); + if (route !== "help") return; + void fetchText("/help.md", { timeoutMs: 8000 }).then((response) => setHelp(response.ok ? response.data ?? "" : `帮助内容加载失败:${response.error ?? "unknown"}`)); + }, [route]); - if (auth.status === "checking" || auth.status === "authenticating") { - return ; + const modelLabel = useMemo(() => store.state.providerProfile === "codex-api" ? "Codex API" : store.state.providerProfile === "minimax-m3" ? "MiniMax-M3" : "DeepSeek", [store.state.providerProfile]); + + function navigate(nextRoute: RouteId): void { + window.location.hash = `/${nextRoute}`; + setRoute(nextRoute); } - if (auth.status === "unauthenticated") { - return ; - } - return ; + + if (auth.authState === "checking") return ; + if (auth.authState === "login") return ; + + return ( +
+ setLeftCollapsed((value) => !value)} /> + void store.createSession()} onDelete={() => void store.deleteCurrentSession()} onSelect={(tab) => void store.selectConversation(tab)} /> +
+ ); } -function BootSplash({ label }) { +function AuthLoadingShell(): ReactElement { return ( -
-
-
-

HWLAB

-

云工作台启动中

-

{label}

-
+
+
+

HWLAB

+

正在检查登录状态

+

正在读取服务器会话。

); } + +function GateView(): ReactElement { + return ( +
+

内部二级入口

内部复核

等待 live 后端
+
+
{["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"].map((head) => )}
{head}
正在读取 live 后端聚合。
+
+ ); +} + +function HelpView({ help }: { help: string }): ReactElement { + return ( +
+

内部说明

使用说明

已就绪
{help}
+
+ ); +} + +function routeFromLocation(): RouteId { + const hash = window.location.hash.replace(/^#\/?/u, ""); + if (hash === "skills" || hash === "gate" || hash === "help" || hash === "settings" || hash === "workspace") return hash; + const path = window.location.pathname.replace(/\/+$/u, "") || "/"; + if (path === "/help") return "help"; + if (path === "/skills") return "skills"; + if (path === "/gate" || path === "/diagnostics/gate") return "gate"; + return "workspace"; +} diff --git a/web/hwlab-cloud-web/src/components/auth/LoginShell.tsx b/web/hwlab-cloud-web/src/components/auth/LoginShell.tsx new file mode 100644 index 00000000..2d297109 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/auth/LoginShell.tsx @@ -0,0 +1,47 @@ +import type { ReactElement } from "react"; +import { FormEvent, useState } from "react"; + +interface LoginShellProps { + error: string | null; + onLogin(username: string, password: string): Promise; +} + +export function LoginShell({ error, onLogin }: LoginShellProps): ReactElement { + const [username, setUsername] = useState("admin"); + const [password, setPassword] = useState("hwlab2026"); + const [pending, setPending] = useState(false); + + async function submit(event: FormEvent): Promise { + event.preventDefault(); + setPending(true); + try { + await onLogin(username, password); + } finally { + setPending(false); + } + } + + return ( +
+
+
+

HWLAB

+

云工作台登录

+

请输入账号和密码。

+
+
+ + + + +
+
+
+ ); +} diff --git a/web/hwlab-cloud-web/src/components/auth/LoginView.tsx b/web/hwlab-cloud-web/src/components/auth/LoginView.tsx deleted file mode 100644 index 7a07fe89..00000000 --- a/web/hwlab-cloud-web/src/components/auth/LoginView.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState } from "react"; - -export function LoginView({ auth }) { - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [localError, setLocalError] = useState(null); - - const busy = auth.status === "authenticating"; - const errorMessage = localError ?? auth.error; - - async function handleSubmit(event) { - event.preventDefault(); - if (busy) return; - setLocalError(null); - if (!username.trim() || !password) { - setLocalError("请输入用户名和密码。"); - return; - } - const result = await auth.signIn(username.trim(), password); - if (!result.ok) { - setLocalError(result.error ?? "登录失败。"); - } - } - - return ( -
-
-
-

HWLAB

-

云工作台登录

-

请输入账号和密码。

-
-
- - - - -
-
-
- ); -} diff --git a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx index 3e683023..9b57a39d 100644 --- a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx +++ b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx @@ -1,53 +1,81 @@ -import { useState } from "react"; +import type { ReactElement } from "react"; +import { FormEvent, KeyboardEvent, useState } from "react"; -export function CommandBar({ draft, setDraft, composer, pending, onSubmit }) { - const disabled = composer?.disabled === true; - const reason = composer?.disabledReason ?? null; - const placeholder = reason === "session_required" - ? "请先创建或选择一个 session" - : reason === "session_not_usable" - ? "当前 session 不可用,请新建或选择其他 session" - : "向 Code Agent 发送指令…"; +import type { ProviderProfile } from "../../types/domain"; - async function handleSubmit(event) { +interface CommandBarProps { + disabled: boolean; + providerProfile: ProviderProfile; + codeAgentTimeoutMs: number; + gatewayShellTimeoutMs: number; + onProviderProfile(profile: ProviderProfile): void; + onCodeAgentTimeout(value: number): void; + onGatewayShellTimeout(value: number): void; + onSubmit(value: string): Promise; + onClear(): void; +} + +export function CommandBar(props: CommandBarProps): ReactElement { + const [value, setValue] = useState(""); + const [pending, setPending] = useState(false); + + async function submit(event: FormEvent): Promise { event.preventDefault(); - if (!draft.trim() || disabled || pending) return; - await onSubmit(draft.trim()); + const next = value.trim(); + if (!next || props.disabled || pending) return; + setPending(true); + try { + await props.onSubmit(next); + setValue(""); + } finally { + setPending(false); + } + } + + function keyDown(event: KeyboardEvent): void { + if (event.key !== "Enter" || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return; + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); } return ( -
-