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 云工作台
-
-
-
+