diff --git a/AGENTS.md b/AGENTS.md index eb0728dc..70b2627f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - G14 k3s 操作必须通过 UniDesk SSH route `G14:k3s` 执行,例如 `bun scripts/cli.ts ssh G14:k3s kubectl get pods -n hwlab-ci`;禁止使用 `ssh G14 k3s ...`。不要把 `/workspace/hwlab`、`/root/HWLAB`、D601 workspace、master-server checkout 或临时 clone 当作 G14 分支 source truth。 - Runner 和指挥常用工作区是 `/workspace/hwlab`;进入仓库先检查分支与工作树状态,详见 [docs/reference/commander-collaboration.md](docs/reference/commander-collaboration.md)。 - G14 CI/CD 由 `G14` source branch、G14 k3s Tekton 和 `G14-gitops` branch 驱动;需要构建、Playwright、check、发布预检或运行面验证时放到 G14 k3s/runner/CI/CD,不在 master server 跑重型验证。 +- G14/v0.2 远端验证必须用短连接触发后台 job、PipelineRun 或脚本任务,再用短连接轮询 status/tail/exit code;不要用 UniDesk SSH/tran 长连接等待 check、layout、Playwright、Tekton/Argo 或发布动作完整结束,细则见 [docs/reference/spec-v02-cicd.md](docs/reference/spec-v02-cicd.md)。 - D601 发布/构建 worktree 纪律只适用于 legacy 路径回溯,不再作为当前 HWLAB 发布默认入口;当前入口见 [docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md)。 - 交付路径按变更风险选择:单纯文档、CLI/helper 轻量变更直接提交并 push 到当前工作线的 source branch,G14 默认 `origin/G14`,`v0.2` 默认 `origin/v0.2`;业务代码、运行面、发布链路、Secret、权限、数据迁移、PROD 或其他高风险变更走 PR 工作流,PR base 必须匹配当前工作线,不能默认投向 `main`;默认不要合并自己的 PR,用户或指挥官明确授权且满足门禁时可按长期参考自合并,不要改 PROD、不要重启服务。 - `DC-DCSN-P0-2026-003` / [pikasTech/HWLAB#78](https://github.com/pikasTech/HWLAB/issues/78) 是当前 M3 虚拟硬件可信闭环的上位约束;其他任务不得把 SOURCE、LOCAL、DRY-RUN、fixture 或前端状态误报为 M3 DEV-LIVE。 diff --git a/docs/reference/spec-v02-cicd.md b/docs/reference/spec-v02-cicd.md index fae8ef8e..97f6aa90 100644 --- a/docs/reference/spec-v02-cicd.md +++ b/docs/reference/spec-v02-cicd.md @@ -134,6 +134,12 @@ Cloud Web 单元测试必须自动发现并执行 repo-owned `web/hwlab-cloud-we 默认 v0.2 CI 不启动 Playwright、布局 smoke、移动端截图、旧 quick prompt 检查、旧 M3 evidence 检查或历史 DEV/D601 browser gate。这些检查只能作为显式人工诊断或专项验收命令存在,不能重新进入最小 CI/CD 关键路径。新增测试也必须只表达当前 v0.2 目标行为;发现旧 UI/旧路由/旧门禁断言阻碍当前目标时,删除旧断言而不是维护兼容分支。 +### 远端验证短连接规则 + +G14 host、worktree、k3s 控制面或 pod 内的验证命令必须按短连接可见性设计。通过 UniDesk `ssh`/`tran` route 执行 `npm run web:check`、`npm run web:layout`、Playwright、Tekton/Argo 观察、镜像构建、发布等待或其他可能超过单次维护桥预算的动作时,不得让 SSH 连接长时间阻塞等待完整输出。标准做法是用一次短连接创建后台 job、PipelineRun、Tekton task 或显式脚本 job,把 stdout/stderr、exit code、开始/结束时间和关键元数据写入 `/tmp`、`.state` 或 CI artifact;随后用有界短连接轮询 `status`、`tail`、`exit.code`、TaskRun result 或 Argo summary。 + +如果一次远端验证已经触发 `UNIDESK_SSH_RUNTIME_TIMEOUT`、日志尾部缺失、exit code 不可见或浏览器/CI 进度不可见,必须先把该验证改成后台 job 加短轮询,再继续排障或发布。禁止把 60s 维护桥断开当作测试失败、测试通过或外部依赖不可用;也禁止通过加大本地等待、重复长连接、全量日志 dump 或改回原生 SSH 绕过来处理。长期 CLI/CI 能力不足时,优先补 UniDesk CLI 的异步 job/status/tail 子命令,再用该入口完成验证。 + 写 mirror 的一致性模型是 local-first、manual-flush。promotion task 只能持有 mirror/relay 写凭证,不持有 GitHub deploy key;GitHub deploy key 只存在于 `devops-infra` mirror/relay sync/flush 边界。mirror/relay 必须在本地 receive 期间完成 object closure、目标 branch allowlist、non-fast-forward 拒绝和 changed-path 最小校验;receive 成功后本地 ref 即为 Argo 可消费事实。flush 失败不得回滚已经 rollout 的本地 GitOps revision,但必须保留 pending/outbox 状态,下一次手动 flush 可重试并输出 last error,不得静默丢弃。 ## 性能预算与回归判定 diff --git a/scripts/dev-cloud-workbench-smoke.mjs b/scripts/dev-cloud-workbench-smoke.mjs index d30fe35f..a52e5d08 100644 --- a/scripts/dev-cloud-workbench-smoke.mjs +++ b/scripts/dev-cloud-workbench-smoke.mjs @@ -7,7 +7,6 @@ import { parseSmokeArgs, printSmokeHelp, runDevCloudWorkbenchMobileSmoke, - runDevCloudWorkbenchQuickPromptsFixtureSmoke, runDevCloudWorkbenchSmoke } from "./src/dev-cloud-workbench-smoke-lib.mjs"; import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs"; @@ -54,8 +53,6 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { ? printSmokeHelp() : args.mobile ? await runDevCloudWorkbenchMobileSmoke() - : args.mode === "quick-prompts-fixture" - ? await runDevCloudWorkbenchQuickPromptsFixtureSmoke() : await runDevCloudWorkbenchSmoke(process.argv.slice(2)); const reportWrite = decideSmokeReportWrite(args, report); if (reportWrite.write) { diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 14de83be..1bd850ee 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -7,11 +7,7 @@ import { classifyLiveDeploymentIdentity, classifyLiveWebAssetIdentity, parseSmokeArgs, - runDevCloudWorkbenchLayoutSmoke, - runDevCloudWorkbenchQuickPromptsFixtureSmoke, - runDevCloudWorkbenchSessionContinuityFixtureSmoke, runDevCloudWorkbenchStaticSmoke, - runDevCloudWorkbenchTimeoutFixtureSmoke, sanitizeAgentChatBody } from "./src/dev-cloud-workbench-smoke-lib.mjs"; import { @@ -22,10 +18,6 @@ import { decideSmokeReportWrite, smokeCliExitCode } from "./dev-cloud-workbench-smoke.mjs"; -import { - compactLayoutSmokeCliOutput, - parseLayoutSmokeArgs -} from "./dev-cloud-workbench-layout-smoke.mjs"; import { checkProfiles } from "./src/check-plan.mjs"; const sourceIdentity = Object.freeze({ @@ -90,7 +82,7 @@ test("source/default workbench report cannot claim DEV-LIVE and documents the co 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", "internal scroll for full trace"]); + assert.deepEqual(traceDisclosure?.evidence, ["显示全部可读事件 / 完整 trace 回放中", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "traceScrollPinnedToBottom", "internal scroll for full trace"]); }); test("Code Agent browser failure classifier emits Chinese timeout/provider/browser categories with traceId", () => { @@ -188,42 +180,6 @@ test("smoke args include a DOM-only live read-only mode", () => { assert.equal(args.urlExplicit, true); }); -test("smoke args include reusable layout-only browser mode", () => { - const local = parseSmokeArgs(["--layout"]); - assert.equal(local.mode, "layout"); - assert.equal(local.urlExplicit, undefined); - - const build = parseSmokeArgs(["--layout", "--build"]); - assert.equal(build.mode, "layout"); - assert.equal(build.build, true); - - const live = parseSmokeArgs(["--layout", "--url", "http://74.48.78.17:16666/"]); - assert.equal(live.mode, "layout"); - assert.equal(live.url, "http://74.48.78.17:16666/"); - assert.equal(live.urlExplicit, true); -}); - -test("dedicated layout smoke CLI supports static build and DEV live shorthand", () => { - const local = parseLayoutSmokeArgs(["--static"]); - assert.equal(local.mode, "layout"); - assert.equal(local.urlExplicit, undefined); - - const build = parseLayoutSmokeArgs(["--build", "--report", "/tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json"]); - assert.equal(build.mode, "layout"); - assert.equal(build.build, true); - assert.equal(build.reportPath, "/tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json"); - - const liveDefault = parseLayoutSmokeArgs(["--live"]); - assert.equal(liveDefault.mode, "layout"); - assert.equal(liveDefault.url, "http://74.48.78.17:16666/"); - assert.equal(liveDefault.urlExplicit, true); - - const liveExplicit = parseLayoutSmokeArgs(["--live", "--url", "http://74.48.78.17:16666/"]); - assert.equal(liveExplicit.mode, "layout"); - assert.equal(liveExplicit.url, "http://74.48.78.17:16666/"); - assert.equal(liveExplicit.urlExplicit, true); -}); - test("source/default smoke covers #352 resource explorer removal contract", () => { const report = runDevCloudWorkbenchStaticSmoke(); const check = report.checks.find((item) => item.id === "feedback-352-resource-explorer-removed"); @@ -236,7 +192,7 @@ test("source/default smoke covers Device Pod right-sidebar contract", () => { 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-fake-service"); + 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"); @@ -539,221 +495,13 @@ test("Code Agent browser classifier blocks completed payloads without backend ev assert.equal(classification.blocker, "untrusted-completion"); }); -test("local Code Agent timeout fixture keeps bounded timeout state, trace context, and retry input", async () => { - const report = await runDevCloudWorkbenchTimeoutFixtureSmoke({ - responseDelayMs: 120, - timeoutConfigMs: 50 - }); - - if (report.status === "skip") { - assert.match(report.summary, /Playwright is unavailable/u); - return; - } - - assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); - assert.equal(report.evidenceLevel, "SOURCE"); - assert.equal(report.devLive, false); - const timeoutCheck = report.checks.find((check) => check.id === "local-agent-timeout-fixture-failed-state"); - assert.equal(timeoutCheck?.status, "pass"); - assert.equal(timeoutCheck.observations.ui.agentChatStatus, "等待超时"); - assert.equal(timeoutCheck.observations.ui.traceHasTraceId, true); - assert.equal(timeoutCheck.observations.ui.retryInputPreserved, true); - assert.equal(timeoutCheck.observations.ui.completedMessageVisible, false); -}); - -test("local session continuity fixture reuses Code Agent context and retries with degraded-copy guard", async () => { - const report = await runDevCloudWorkbenchSessionContinuityFixtureSmoke(); - if (report.status === "skip") { - assert.match(report.summary, /Playwright is unavailable/u); - return; - } - - assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); - assert.equal(report.evidenceLevel, "SOURCE"); - assert.equal(report.devLive, false); - assert.equal(report.checks.find((check) => check.id === "local-agent-session-continuity-two-turns")?.status, "pass"); - assert.equal(report.checks.find((check) => check.id === "local-agent-session-continuity-retry")?.status, "pass"); - assert.equal(report.checks.find((check) => check.id === "local-agent-session-continuity-degraded-copy")?.status, "pass"); - const continuity = report.checks.find((check) => check.id === "local-agent-session-continuity-two-turns")?.observations; - assert.equal(continuity.secondRequest.conversationId, continuity.firstRequest.conversationId); - assert.equal(continuity.secondRequest.sessionId, "ses_source_fixture_continuity"); - assert.equal(continuity.secondRequest.threadId, "thread_source_fixture_continuity"); - const retry = report.checks.find((check) => check.id === "local-agent-session-continuity-retry")?.observations; - assert.equal(retry.retryRequest.message, retry.failedRequest.message); - assert.equal(retry.retryRequest.sessionId, retry.failedRequest.sessionId); - assert.equal(retry.retryRequest.threadId, retry.failedRequest.threadId); -}); - -test("Code Agent quick prompt fixture fills input, does not autosend writes, and fits mobile", async () => { - const report = await runDevCloudWorkbenchQuickPromptsFixtureSmoke(); - if (report.status === "skip") { - assert.match(report.summary, /Playwright is unavailable/u); - return; - } - - assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); - assert.equal(report.evidenceLevel, "SOURCE"); - assert.equal(report.devLive, false); - assert.equal(report.safety.codeAgentPostSentByQuickPrompt, false); - assert.equal(report.safety.hardwareWriteApis, false); - - const fill = report.checks.find((check) => check.id === "quick-prompts-fill-input"); - const noAutosend = report.checks.find((check) => check.id === "quick-prompts-write-no-autosend"); - const copy = report.checks.find((check) => check.id === "quick-prompts-copy-boundary"); - const layout = report.checks.find((check) => check.id === "quick-prompts-mobile-layout"); - assert.equal(fill?.status, "pass"); - assert.equal(noAutosend?.status, "pass"); - assert.equal(copy?.status, "pass"); - assert.equal(layout?.status, "pass"); - assert.equal(noAutosend.observations.every((item) => item.agentPostCountDelta === 0), true); - assert.equal(noAutosend.observations.every((item) => item.explicitSendRequired === true), true); - assert.equal(copy.observations.every((item) => item.copyBoundaryOk === true), true); - assert.equal(layout.observations.some((item) => item.viewport.width === 390 && item.layoutOk), true); -}); - -test("layout smoke verifies desktop and mobile default workbench geometry without resource explorer", async () => { - const report = await runDevCloudWorkbenchLayoutSmoke(); - if (report.status === "skip") { - assert.match(report.summary, /Playwright is unavailable/u); - return; - } - - assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); - assert.equal(report.issue, "pikasTech/HWLAB#273"); - assert.equal(report.taskId, "dev-cloud-workbench-layout"); - assert.equal(report.acceptanceLevel, "dev_cloud_workbench_layout"); - assert.equal(report.sourceMode, "source-static"); - assert.equal(report.evidenceLevel, "SOURCE"); - assert.equal(report.devLive, false); - assert.equal(report.devPreconditions.status, "not_applicable"); - assert.equal(report.localSmoke.commands.includes("npm run web:check"), true); - for (const command of [ - "node --check scripts/dev-cloud-workbench-layout-smoke.mjs", - "npm run web:layout", - "npm run web:layout:build", - "npm run web:layout:live" - ]) { - assert.equal(report.validationCommands.includes(command), true, `missing ${command}`); - } - for (const id of [ - "layout-desktop-default", - "layout-narrow-desktop-default", - "layout-mobile-default", - "layout-feedback-352-resource-explorer-removed", - "layout-feedback-437-live-build-overlay", - "layout-left-sidebar-collapse", - "layout-gate-desktop", - "layout-gate-narrow-desktop", - "layout-gate-mobile" - ]) { - assert.equal(report.checks.find((check) => check.id === id)?.status, "pass", id); - } - - assert.deepEqual( - report.viewports.map((viewport) => `${viewport.width}x${viewport.height}`), - ["1366x768", "1024x768", "390x844"] - ); - assert.equal(Array.isArray(report.failures), true); - assert.equal(Array.isArray(report.skipped), true); - assert.equal(report.artifacts.reportPath, null); - assert.match(report.reportLifecycle.summary, /cannot be used as DEV-LIVE evidence/u); - assert.match(report.devPreconditions.summary, /not required for SOURCE\/static gates/u); - assert.equal(report.safety.hitTestMethod.includes("elementsFromPoint"), true); - assert.match(report.safety.statement, /does not send Code Agent chat, call Device Pod or hardware write APIs/u); - assert.equal(report.checks.find((check) => check.id === "layout-device-pod-summary-sidebar")?.status, "pass"); - assert.equal(report.checks.find((check) => check.id === "layout-device-pod-event-stream")?.status, "pass"); - assert.equal(report.checks.find((check) => check.id === "layout-device-pod-detail-dialog")?.status, "pass"); - assert.equal(report.checks.find((check) => check.id === "layout-feedback-352-resource-explorer-removed")?.status, "pass"); - assert.equal(report.checks.find((check) => check.id === "layout-feedback-437-live-build-overlay")?.status, "pass"); - assert.equal(report.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, "skip"); - - const desktopDefault = report.checks.find((check) => check.id === "layout-desktop-default")?.observations; - const resourceExplorerRemoval = report.checks.find((check) => check.id === "layout-feedback-352-resource-explorer-removed")?.observations; - assert.equal(resourceExplorerRemoval.forbiddenSelectors.includes("#explorer-resize"), true); - assert.equal(resourceExplorerRemoval.forbiddenCopy.includes("常用能力"), true); - assert.equal(resourceExplorerRemoval.coverage.desktop.covered, true); - assert.equal(resourceExplorerRemoval.coverage.mobile.removedVisibleCopyAbsent, true); - assert.equal(desktopDefault.removedSelectorsAbsent, true); - assert.equal(desktopDefault.removedVisibleCopyAbsent, true); - assert.equal(desktopDefault.resourceExplorerRemovalGuard, true); - assert.equal(desktopDefault.keyTargetsReachable, true); - assert.equal(desktopDefault.devicePod.summaryOk, true); - assert.equal(desktopDefault.devicePod.noInternalDisclosure, true); - assert.equal(desktopDefault.devicePod.noLegacySelectors, true); - assert.equal(desktopDefault.devicePod.detailDialogOk, true); - assert.equal(desktopDefault.devicePod.eventStreamOk, true); - assert.equal(desktopDefault.devicePod.summaryTileCount >= 2, true); - assert.equal(desktopDefault.noHorizontalOverflow.right, true); - assert.equal(desktopDefault.liveBuildLayout.overlayPositioned, true); - assert.equal(desktopDefault.liveBuildLayout.dialogVisible, true); - assert.equal(desktopDefault.liveBuildLayout.dialogViewportContained, true); - assert.equal(desktopDefault.liveBuildLayout.stableGeometry, true); - assert.equal(desktopDefault.liveBuildLayout.closedByButton, true); - assert.equal(Object.hasOwn(desktopDefault.boxes.shell, "text"), false); - assert.equal(Object.hasOwn(desktopDefault.semanticOverlapChecks[0].boxes[".right-sidebar"], "text"), false); - assert.equal(desktopDefault.failures.length, 0); - assert.equal(desktopDefault.semanticOverlapChecks.every((check) => !check.overlaps), true); - assert.equal(desktopDefault.overflowChecks.every((check) => check.ok), true); - - const mobileDefault = report.checks.find((check) => check.id === "layout-mobile-default")?.observations; - assert.equal(mobileDefault.removedSelectorsAbsent, true); - assert.equal(mobileDefault.removedVisibleCopyAbsent, true); - assert.equal(mobileDefault.resourceExplorerRemovalGuard, true); - assert.equal(mobileDefault.keyTargetsReachable, true); - assert.equal(mobileDefault.devicePod.summaryOk, true); - assert.equal(mobileDefault.devicePod.noInternalDisclosure, true); - assert.equal(mobileDefault.devicePod.noLegacySelectors, true); - assert.equal(mobileDefault.devicePod.detailDialogOk, true); - assert.equal(mobileDefault.devicePod.eventStreamOk, true); - assert.equal(mobileDefault.noHorizontalOverflow.right, true); - assert.equal(mobileDefault.liveBuildLayout.overlayPositioned, true); - assert.equal(mobileDefault.liveBuildLayout.dialogVisible, true); - assert.equal(mobileDefault.liveBuildLayout.dialogViewportContained, true); - assert.equal(mobileDefault.liveBuildLayout.stableGeometry, true); - - const leftCollapse = report.checks.find((check) => check.id === "layout-left-sidebar-collapse")?.observations; - assert.equal(leftCollapse.status, "pass"); - assert.equal(leftCollapse.coverage.desktop.widthReclaimed, true); - assert.equal(leftCollapse.coverage["narrow-desktop"].widthReclaimed, true); - assert.equal(leftCollapse.coverage.mobile.widthReclaimed, true); - assert.equal(leftCollapse.coverage.desktop.collapsed.keyTargetsReachable, true); - assert.equal(leftCollapse.coverage.mobile.collapsed.keyTargetsReachable, true); - assert.equal(leftCollapse.coverage.desktop.restoredCollapsed.collapsed, true); - assert.equal(leftCollapse.coverage.mobile.expanded.routeButtonsVisible, true); - - const compact = compactLayoutSmokeCliOutput(report); - assert.equal(compact.status, "pass"); - assert.equal(compact.artifacts.screenshotCount >= 18, true); - assert.equal(compact.failures.length, 0); - assert.equal(compact.blockers.length, 0); - assert.equal(JSON.stringify(compact).includes('"checks"'), false); - assert.equal(JSON.stringify(compact).includes('"boxes"'), false); - assert.equal(JSON.stringify(compact).includes('"text"'), false); -}); - -test("repo-owned web checks expose source build and DEV live layout smoke gates", () => { +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(rootPackage.scripts["web:check"], /bun test web\/hwlab-cloud-web\/message-markdown\.test\.ts/u); - assert.equal( - rootPackage.scripts["web:layout"], - "node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json" - ); - assert.equal( - rootPackage.scripts["web:layout:build"], - "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json" - ); - assert.equal( - rootPackage.scripts["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" - ); - assert.ok(checkCommands.some((command) => command === "node --check scripts/dev-cloud-workbench-layout-smoke.mjs")); + assert.match(cloudWebPackage.scripts.check, /bun test && bun run scripts\/check\.ts/u); assert.ok(checkProfiles.check.some((task) => task.cwd === "web/hwlab-cloud-web" && task.command.join(" ") === "bun run check")); - assert.doesNotMatch(checkCommands.join("\n"), /gate-summary\.mjs|export-web-gate-summary/u); - assert.equal(cloudWebPackage.scripts.layout.includes("--static"), true); - assert.equal(cloudWebPackage.scripts["layout:build"].includes("--build"), true); - assert.equal(cloudWebPackage.scripts["layout:live"].includes("--live --url http://74.48.78.17:16666/"), true); - assert.match(cloudWebCheckSource, /runJavaScriptSyntaxCheck/u); + 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); diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index e44c58dc..217ecca4 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -98,7 +98,6 @@ export const checkProfiles = Object.freeze({ { id: "check-080-smoke-run-bun", group: "smoke", command: ["node","scripts/run-bun.mjs","build","scripts/src/m3-io-control-e2e.mjs","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-081-smoke-m3-io-control-e2e-test", group: "smoke", command: ["node","--check","scripts/m3-io-control-e2e.test.mjs"] }, { id: "check-082-cloud-web-dev-cloud-workbench-smoke-test", group: "cloud-web", command: ["node","--check","scripts/dev-cloud-workbench-smoke.test.mjs"] }, - { id: "check-083-cloud-web-dev-cloud-workbench-layout-smoke", group: "cloud-web", command: ["node","--check","scripts/dev-cloud-workbench-layout-smoke.mjs"] }, { id: "check-089-deploy-validate-artifact-catalog", group: "deploy", command: ["node","--check","scripts/validate-artifact-catalog.mjs"] }, { id: "check-090-deploy-refresh-artifact-catalog", group: "deploy", command: ["node","--check","scripts/refresh-artifact-catalog.mjs"] }, { id: "check-091-deploy-refresh-artifact-catalog-test", group: "deploy", command: ["node","--check","scripts/refresh-artifact-catalog.test.mjs"] }, diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index b2669323..a5bece46 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -467,12 +467,12 @@ function runStaticSmoke() { evidence: [...gateReviewTableColumns, "/v1/diagnostics/gate", "LIVE-BACKEND", ...gateReviewStatusLabels] }); - addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, artifactPublisher, buildScript, distContractScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", { + 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.", { blocker: "runtime_blocker", evidence: gateRouteAliases }); - addCheck(checks, blockers, "direct-help-route-alias", helpRouteAliasesAreServed(files.app, artifactPublisher, buildScript, distContractScript), "/help is a direct user-facing help alias and does not become the default route.", { + 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.", { blocker: "runtime_blocker", evidence: [...helpRouteAliases, "routeFromLocation final fallback is workspace"] }); @@ -538,7 +538,7 @@ function runStaticSmoke() { 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", "internal scroll for full trace"] + 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.", { @@ -2392,6 +2392,9 @@ function hasCodeAgentTraceReplayDisclosure({ app, styles }) { /rememberTraceScrollPosition\(traceUiKey,\s*list\)/u.test(tracePanelBody) && /traceDetailsOpen:\s*new Map\(\)/u.test(app) && /traceScrollPositions:\s*new Map\(\)/u.test(app) && + /traceScrollPinnedToBottom:\s*new Map\(\)/u.test(app) && + /function\s+shouldFollowTraceBottom\s*\(/u.test(app) && + /function\s+scrollTraceToBottom\s*\(/u.test(app) && /captureTraceScrollPositions\(\)/u.test(renderConversationBody) && /restoreTraceScrollPositions\(\)/u.test(renderConversationBody) && !/CODE_AGENT_TRACE_PREVIEW_LIMIT|tracePreviewEvents|展开全部|data-trace-mode="tail"/u.test(app) && diff --git a/web/hwlab-cloud-web/app-conversation.ts b/web/hwlab-cloud-web/app-conversation.ts index 8a3293ce..37411274 100644 --- a/web/hwlab-cloud-web/app-conversation.ts +++ b/web/hwlab-cloud-web/app-conversation.ts @@ -74,15 +74,21 @@ function rememberTraceScrollPosition(traceUiKey, list) { left: list.scrollLeft, bottomGap: scrollBottomGap(list) }); + state.traceScrollPinnedToBottom.set(traceUiKey, isScrolledToBottom(list)); } function restoreTraceScrollPositions(root = el.conversationList, options = {}) { for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) { - if (options.deferred === true && isTraceScrollUserActive(list.dataset.traceUiKey)) continue; - const position = state.traceScrollPositions.get(list.dataset.traceUiKey); - if (!position) continue; + const traceUiKey = list.dataset.traceUiKey; + if (options.deferred === true && isTraceScrollUserActive(traceUiKey)) continue; + const position = state.traceScrollPositions.get(traceUiKey); + if (!position) { + if (shouldDefaultTracePinToBottom(list)) scrollTraceToBottom(list); + continue; + } writeTraceScrollTop(list, scrollTopForPosition(list, position)); list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth)); + rememberTraceScrollPosition(traceUiKey, list); } } @@ -146,12 +152,47 @@ function scrollBottomGap(element) { return Math.max(0, element.scrollHeight - element.clientHeight - element.scrollTop); } +function isScrolledToBottom(element) { + return scrollBottomGap(element) <= SCROLL_BOTTOM_PIN_PX; +} + +function scrollBottomTop(element) { + return Math.max(0, element.scrollHeight - element.clientHeight); +} + function scrollTopForPosition(element, position) { - const maxTop = Math.max(0, element.scrollHeight - element.clientHeight); + const maxTop = scrollBottomTop(element); if (Number(position.bottomGap) <= SCROLL_BOTTOM_PIN_PX) return maxTop; return Math.min(position.top, maxTop); } +function shouldDefaultTracePinToBottom(list) { + return list?.dataset?.traceStatus === "running" || isScrolledToBottom(list); +} + +function shouldFollowTraceBottom(list) { + const traceUiKey = list?.dataset?.traceUiKey; + if (!traceUiKey || !list) return false; + const pinned = state.traceScrollPinnedToBottom.get(traceUiKey); + return pinned === true || (pinned !== false && shouldDefaultTracePinToBottom(list)); +} + +function scrollTraceToBottom(list, options = {}) { + const traceUiKey = list?.dataset?.traceUiKey; + if (!traceUiKey || !list) return; + const run = () => { + if (!list.isConnected || !shouldFollowTraceBottom(list)) return; + writeTraceScrollTop(list, scrollBottomTop(list)); + rememberTraceScrollPosition(traceUiKey, list); + }; + state.traceScrollPinnedToBottom.set(traceUiKey, true); + if (options.deferred === true) { + window.requestAnimationFrame(run); + return; + } + run(); +} + function markTraceScrollIntent(traceUiKey) { if (!traceUiKey) return; state.traceScrollUserActiveUntil.set(traceUiKey, Date.now() + SCROLL_USER_ACTIVITY_MS); @@ -211,10 +252,17 @@ function patchTracePanelElement(panel, replacement) { const currentList = panel.querySelector(".message-trace-events[data-trace-ui-key]"); const nextList = replacement.querySelector(".message-trace-events[data-trace-ui-key]"); if (currentList && nextList) { + const shouldFollowBottom = shouldFollowTraceBottom(currentList); captureTraceBodyScrollPositions(currentList); currentList.dataset.traceMode = nextList.dataset.traceMode ?? ""; + currentList.dataset.traceStatus = nextList.dataset.traceStatus ?? ""; patchTraceEventList(currentList, [...nextList.children]); restoreTraceBodyScrollPositions(currentList); + if (shouldFollowBottom) { + scrollTraceToBottom(currentList, { deferred: true }); + } else { + rememberTraceScrollPosition(currentList.dataset.traceUiKey, currentList); + } } } @@ -1273,6 +1321,7 @@ function messageTracePanel(message) { list.className = "message-trace-events"; if (traceUiKey) { list.dataset.traceUiKey = traceUiKey; + list.dataset.traceStatus = toneClass(message.status); for (const eventName of ["wheel", "touchstart", "pointerdown"]) { list.addEventListener(eventName, () => markTraceScrollIntent(traceUiKey), { passive: true }); } diff --git a/web/hwlab-cloud-web/app-device-pod.ts b/web/hwlab-cloud-web/app-device-pod.ts index 357b93b9..c6018c6c 100644 --- a/web/hwlab-cloud-web/app-device-pod.ts +++ b/web/hwlab-cloud-web/app-device-pod.ts @@ -62,6 +62,7 @@ function initCommandBar() { state.conversationScrollUserActiveUntil = 0; state.conversationScrollPosition = { top: 0, left: 0 }; state.traceScrollPositions.clear(); + state.traceScrollPinnedToBottom.clear(); state.traceScrollUserActiveUntil.clear(); state.traceProgrammaticScrollWrites = 0; state.fullTraceReplayInFlight.clear(); diff --git a/web/hwlab-cloud-web/app-trace.ts b/web/hwlab-cloud-web/app-trace.ts index f45d2df4..4bf8b1a2 100644 --- a/web/hwlab-cloud-web/app-trace.ts +++ b/web/hwlab-cloud-web/app-trace.ts @@ -212,6 +212,7 @@ function traceScrollMetrics() { conversationBottomGap: scrollBottomGap(conversation), traceTop: list?.scrollTop ?? null, traceBottomGap: list ? scrollBottomGap(list) : null, + tracePinnedToBottom: list ? state.traceScrollPinnedToBottom.get(list.dataset.traceUiKey) ?? null : null, traceScrollHeight: list?.scrollHeight ?? null, traceClientHeight: list?.clientHeight ?? null, traceRowCount: list?.querySelectorAll(".message-trace-row").length ?? 0 diff --git a/web/hwlab-cloud-web/app.ts b/web/hwlab-cloud-web/app.ts index ef635eeb..adef2b3c 100644 --- a/web/hwlab-cloud-web/app.ts +++ b/web/hwlab-cloud-web/app.ts @@ -190,6 +190,7 @@ const state = { conversationScrollUserActiveUntil: 0, conversationScrollPosition: { top: 0, left: 0 }, traceScrollPositions: new Map(), + traceScrollPinnedToBottom: new Map(), traceScrollUserActiveUntil: new Map(), traceBodyScrollPositions: new Map(), traceBodyScrollUserActiveUntil: new Map(),