# v0.2 hwlab-cloud-web 服务规格

`hwlab-cloud-web` 是 `v0.2` 浏览器工作台，运行在 `hwlab-v02` namespace，内部端口 `8080`，公网经 FRP 暴露为 `http://74.48.78.17:19666/`。

Provider API Key 配置入口也归属 Cloud Web：左侧顶级导航必须提供“管理”页面，具体路由、状态展示、API Key 写入表单、验证结果展示和脱敏规则见 [spec-v02-provider-management.md](spec-v02-provider-management.md)。Cloud Web 不直接调用 AgentRun，不保存完整 API Key，也不把 AgentRun token 暴露给浏览器。

## 在系统中的职责划分

- 向用户提供 Cloud Workbench、Code Agent 对话、live status、HWPOD node-ops 右侧面板、trace 展示和帮助内容。
- 只消费 `hwlab-cloud-api`，不直接访问 Postgres、gateway、host/node 资源、FRP、Kubernetes 或 provider Secret。
- 为浏览器提供同源代理，避免前端直接跨域调用内部 ClusterIP。
- Web 登录按 [spec-v02-auth.md](spec-v02-auth.md) 走 Keycloak OIDC；未登录用户进入 Keycloak 登录/注册，callback 后由 cloud-api 发行 24 小时 `hwlab_session`。
- Cloud Web 提供 API key 管理入口，让用户查看默认 API key、创建新 key、revoke 或 regenerate；浏览器日常请求仍使用 Web session，不要求用户手动输入 API key。
- Cloud Web 提供 admin-only Access 页面，让管理员按用户管理 role/status、Code Agent session 可见性和工具 capability；页面只调用 cloud-api `/v1/admin/access*` 同源 API，不直接访问 OpenFGA、Postgres、Kubernetes 或 Keycloak admin API。
- Cloud Web 提供 admin-only Provider 管理页面，让管理员通过 cloud-api `/v1/admin/provider-profiles*` 同源 API 配置 AgentRun provider API Key；页面不得直接访问 AgentRun、Kubernetes Secret、Moon Bridge 或 provider upstream。
- Cloud Web 与 `hwlab-cli client` 必须共享同一组非视觉业务 API。浏览器遇到的 Code Agent continuation、trace/result 和 HWPOD node-ops 问题，必须能通过 `hwlab-cli client` 走同一 `19666` Cloud Web path 复现；不能让 CLI 长期绕到 `19667` Cloud API 后把 Web 路径缺口误判为业务已通过。
- Cloud Web 只承担浏览器 UI 和 `hwlab-cli client` 的同源代理。AgentRun runner 内的 `hwpod` 不走 Cloud Web；runner 使用映射到发起用户的 `HWLAB_API_KEY` 直连 Cloud API，Cloud Web 不保留 HWPOD 运行路由。
- 浏览器启动后必须从 `GET /v1/workbench/workspace` hydrate 账号 workspace；同一个账号在多个浏览器标签页、多个浏览器或 CLI profile 中应看到同一个 `workspaceId`、selected conversation/session/thread、provider profile 和 active trace。浏览器 localStorage 只能作为短期缓存，并必须绑定 actor，不能作为 workspace authority。
- Code Agent session 管理必须全部手动化。Workbench 可以从账号 workspace 恢复“已显式选中”的 session，但不能在普通发送、页面刷新、trace replay、失败恢复或 provider resume 失败时自动创建、滚动或替换 session。没有已选 session 时，composer 必须展示“新建 session/选择 session”的显式动作；session failed/stale/canceled 后必须保留失败证据，继续前由用户显式新建或选择另一个 session。
- 显式 session 的 `providerProfile` 优先于账号 workspace provider profile。Workbench 可以展示 workspace 默认 provider，但对已选 session 发送 turn 时必须使用该 session 的 provider profile；用户想切换 provider 时，应显式创建或选择对应 provider 的 session。AgentRun dispatch、dynamic profile slug 和 nested child `spawn` env-only 继承的权威规则见 [agentrun-code-agent-dispatch.md](agentrun-code-agent-dispatch.md)。
- Cloud Web trace 展示与 `hwlab-cli client agent trace --render web` 必须共享同一套 trace row 纯转换路径。Web 发生 row 顺序错乱、final response 缺失、assistant 消息被吞、tool call 只显示泛化占位或噪声事件淹没时，先用 CLI 输出同一渲染 row 摘要和 noise event count 复现；CLI 可复现说明是 trace row 转换问题，CLI 不可复现再进入 DOM/CSS/滚动状态调查。默认展示应压制 AgentRun backend echo、token/rate-limit/status/terminal echo 等低价值事件，但原始 trace JSON 仍必须保留用于 `--full`/下载排障。
- Cloud Web Code Agent composer 必须无锁：运行中 turn 不得把输入框或发送按钮 disabled。浏览器提交时必须基于已显式选中的 session 工作；没有 session 时返回 `session_required` 并引导用户新建 session，不能自动开 session。存在 active running trace 时，用户显式 steer 动作走 `POST /v1/agent/chat/steer`；空闲且 session usable 时，用户显式发送 turn 走 `POST /v1/agent/chat`。`hwlab-cli client agent composer status` 必须能用同一 policy 输出 `sessionRequired`、`sessionUsable`、`submitMode=turn|steer`、`route` 和 `targetTraceId`。
- Steer 是短连接控制动作，不是等待目标 turn terminal 的长请求。`POST /v1/agent/chat/steer` 在 cloud-api 成功创建 AgentRun `type=steer` command 后必须立即以 HTTP 202 返回，并在响应中暴露 `accepted=true`、`shortConnection=true`、`route=/v1/agent/chat/steer`、`traceId`、`steerTraceId`、`agentRun.runId`、`agentRun.targetCommandId` 和 `agentRun.steerCommandId`。接口不得为了 owner/session/workspace/conversation 持久化写回而延迟 202；这些写回失败只能作为后台可观测问题处理，不能把已 accepted 的 steer 重新标成请求失败。
- Workbench 对 steer submit 的失败分类必须以目标 trace 的真实状态为准：如果 steer POST 发生 timeout/ECONNRESET/transport failure，但目标 trace/result 仍是 running 或其他非 terminal 状态，前端必须释放本次 submit lock、保留原 agent message 的 running/steerable 状态并继续 trace 轮询，不得把原 turn 改成 failed 或展示“Code Agent 请求失败”。只有目标 trace/result、cloud-api 或 AgentRun 返回 terminal failed/blocker/canceled 时，才把目标消息标为相应终态。
- Code Agent result `completed` 只有在同时包含真实 provider/model/trace/conversation 元数据、`providerTrace` 和可展示的 final assistant response 时，才能被 Web 标记为真实完成；`provider=agentrun-v01` 只是执行基础设施标识，不得替代上游 provider/model，也不得把 SOURCE、fixture、echo、mock 或 stub 当成 DEV-LIVE 完成。
- 同一显式 conversation/session 的后续用户消息必须在 AgentRun runner reuse window 有效时复用已存在的 AgentRun run/runner 继续新 command/turn；只有 runner 不可用、已过期或用户显式创建新 session 时才重新 bundle 和启动 runner。每条消息都重新 bundle/runner 属于 v0.2 AgentRun 接入缺口，不能只靠 trace 显示原因当成已完成。
- runner pod 被删、runner Job 重建或旧 lease 失效后的临时恢复，可以显示新的 AgentRun run/job/command identity，但 Web 必须继续以同一个 HWLAB `sessionId` / `threadId` / provider profile 呈现业务会话，并明确区分“同 session 恢复执行壳”和“新业务 session”。只要复用同一 AgentRun `SessionRef`/PVC/thread 且没有拼接历史 prompt，replacement run/job 可以作为 session 持久化恢复证据；它不替代 T2.2 的同 run/runner reuse 目标。
- AgentRun 会话连续性只有一个标准路径：Cloud Web/CLI 提交的 `threadId` 必须经 Cloud API adapter 写入 AgentRun command `payload.threadId` 和 `SessionRef.threadId`。前端、CLI、API 和 AgentRun 的协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。
- Cloud Web 提交 Code Agent turn 时只发送当前用户消息、共享 workspace 的 `conversationId/sessionId/threadId`、workspace revision 和必要运行元数据；不得发送 `conversationContext/messages`，也不得把浏览器历史拼入 prompt。历史消息只用于本地 UI 展示和 trace/inspect 可见性，不能替代 AgentRun/Codex stdio 原生 `thread/resume`。
- **Code Agent Timeout Model（HWLAB #795 final）**：Workbench 不允许把 Code Agent 长任务总耗时当成失败条件，也不得在浏览器侧引入任何 total-timeout / hard cap / `codeAgentTimeoutMs * N` 兜底 / 轮询次数上限 / 按 wall-clock 缩小的 per-poll 窗口。Code Agent turn 的唯一 abort 信号是 **inactivity-timeout**：即 `fetchJson` 的 `timeoutMs`（用户配的 `codeAgentTimeoutMs`）窗口内没有任何新 activity（trace snapshot / user typing / submit kickoff / server 5xx 重试）。一旦 `activityRef.lastActivityAt` 在窗口内被刷新，per-poll 窗口必须维持原值不缩小，外层 `waitForAgentResult` / `pollRunnerTrace` 的 `for(;;)` 循环也没有任何累计计时跳出条件。Runaway 保护的责任在调用方：用户可以走 cancel / steer / 关 tab / 关浏览器 / session cancel，而不是浏览器隐式地按 `4 × totalTimeoutMs` 把活跃 turn 杀掉。AgentRun command/provider/backend 失败后，当前消息可显示 failed/blocker；session 是否仍 usable 必须由 session 状态显式表达。`thread-resume-failed`、provider continuation 失效、运行面中断或用户取消导致 session failed/stale/canceled 时，不得自动清理并滚动到新 session；继续前必须由用户显式创建或选择 session。
- 同一 AgentRun run 复用多条 command 时，Web trace 展示只显示当前 command 归属事件和必要 run 级状态；旧 command 的 assistant/tool/terminal 尾部不能堆到新 command 末尾。取消轮次的可读进展必须作为脱敏 conversation facts 进入 UI/trace/inspect 证据，而不是靠旧 trace 尾部串线让后续轮次“碰巧看到”；这些 facts 不得作为下一轮模型上下文或 prompt 拼接来源。

## Workbench 首屏性能契约

`/` 和 `#/workspace` 是 Cloud Web 的用户业务首屏；性能页、构建检查、本地 mock、CLI-only 请求或 sidecar 存活不能替代该入口。首屏性能目标用于用户体感回归判断，不作为新的发布旧门禁：真实浏览器打开 `http://74.48.78.17:19666/#/workspace` 后，LCP 应稳定低于 `3s`，CLS 应低于 `0.1`，`#command-input` 必须可见且可输入。

首屏 bootstrap 路径必须保持窄路径：

- HTML 在 `/` 或 `#/workspace` 首屏可以在解析期提前发起一次 `GET /auth/workspace-bootstrap?projectId=prj_device_pod_workbench`。React auth/workbench 状态必须优先消费 `window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP` 的 promise；同一首屏不得再额外发起 `/auth/bootstrap`、`/auth/session` 或 `/v1/workbench/workspace` 来重复 hydrate 同一账号 workspace。
- `/auth/workspace-bootstrap` 返回的 user/actor/workspace 只能作为首屏 seed。账号 workspace、session、conversation、provider profile 和 revision authority 仍在 `hwlab-cloud-api` / Postgres；浏览器 localStorage 仍只是 actor-bound cache，不能成为 authority。
- 首屏允许加载当前工作台可见所需的 conversations、device-pods 和 live-builds 摘要，但 trace replay、`events?limit=120`、性能 summary、重型右侧面板、旧事件列表和非当前视口内容必须延后到首屏稳定后、用户展开后或具体业务需要时加载。

历史消息渲染必须优先保证首屏稳定，不得牺牲业务语义：

- 首屏 hydrate 里已经存在的历史 Code Agent 消息先以纯文本 fallback 展示，文本必须按 React 默认规则转义，不能执行 raw HTML。Markdown/GFM renderer 必须通过独立 `MarkdownRenderer` chunk 懒加载，首屏 LCP 观察窗口内不应加载该 chunk。
- 首屏历史 final response 必须带稳定标识并用本地滚动边界约束高度，避免长历史回复继续驱动页面级 LCP/CLS。约束只影响首屏已有历史消息的初始展示，不得删除、截断、改写 conversation history，也不得影响新消息、用户显式打开的详情、trace/result 原始证据或首屏后 Markdown 升级。
- 不得用隐藏历史消息、禁用 composer、删除 Code Agent 结果、减少真实业务能力或把历史 prompt 拼回下一轮消息的方式换取 LCP 数字。历史消息只用于 UI 展示和 trace/inspect 可见性，不能替代 AgentRun/Codex 原生 thread/resume。

测试和门禁只表达当前契约：旧同步 Markdown table 断言、旧 exact-string bundle guard、旧 layout smoke、旧 M3 evidence、旧 DEV/D601 browser gate 和与当前首屏目标无关的历史断言一律拆除，不迁移、不兼容保留。默认 `bun run --cwd web/hwlab-cloud-web check` 只保留轻量源码层保障：早发 workspace bootstrap 被消费、首屏历史 Markdown 延迟、历史 final response 有稳定高度边界、lazy renderer 仍能在升级后支持 Markdown/GFM 且 raw HTML 不执行。真实 LCP/CLS、请求数量和 RUM/Prometheus 改善必须通过专项 live browser closeout 验证。

## 内部架构

## Code Agent Timeout Model 与 Path Unification（与 #795 / #802 同步固化）

权威规则：Cloud Web 的 `state/workbench.ts` `submitMessage` 和 `state/trace-reattach.ts` `useTraceReattach` 必须遵守以下 contract，禁止分裂成 normal / refresh 两套不同代码路径，禁止任何 wall-clock / 轮询次数 / 4× / +60s 兜底来"安全网"式杀掉活跃 turn：

- **单一入口 `subscribeToTrace`**：`state/runner-trace.ts::subscribeToTrace` 是 trace 轮询的唯一入口。`waitForAgentResult` + `pollRunnerTrace` 旧 dual 函数对已经删除（HWLAB #802 收口）。`submitMessage` 在 POST 拿到 202 后调它；`useTraceReattach` 在 `state.workspace.activeTraceId` 变化时调它（路径归一化：refresh 路径也走同一份 active polling，不再是被动显示）。
- **路径归一化**：submit path 和 hydrate path 走同一份 `subscribeToTrace` 逻辑。`onActivity` 在每一次**成功**的 result poll 和 trace poll 上都调（不是只在新 snapshot 上调），所以在正常操作下 per-poll inactivity 窗口**永远不 fire**。
- **失败持久化**：`subscribeToTrace` 的 `onInfrastructureError`（4xx / non-5xx 非 200）会被 `submitMessage` 和 `useTraceReattach` 同时调 `persistConversation`，让 cloud-api 留痕前端超时/失败。这样 F5 之后用户能直接看到前端 fail 上下文，不只是 backend completed。
- **唯一 abort 信号**：per-poll `fetchJson` 的 inactivity-timeout，由 `activityRef` 驱动。`activityRef.lastActivityAt` 在 `totalTimeoutMs` 窗口内被刷新就不 abort。
- **没有 total-timeout / 轮询次数上限**：外层 `for(;;)` 循环没有任何累计计时跳出条件。per-poll `getAgentChatResult(url, totalTimeoutMs, activityRef)` 直接传 `totalTimeoutMs`，**不**随墙钟缩小成 `totalTimeoutMs - (Date.now() - startedAt)`。
- **没有 `Math.max(totalTimeoutMs * N, ...)` 兜底**：之前的 `max(4x, +60s)` 已经按 PR 反馈彻底删除（PR #798）。
- **activity 来源**：`store.recordActivity()`（`App.tsx` 串到 `<CommandBar onTyping>`）+ `submitMessage` `updateActivity()` on submit + `subscribeToTrace` 的 `onActivity` 在每次成功 poll 上调（保证 inactivity 不被误触）。
- **Runaway 保护责任**：用户在 Web 上有 cancel / steer / 关 tab 三个明确逃生口；CLI 上有 `--timeout-ms` 让调用方自行决定。**不**在浏览器侧加隐式硬上限。
- **Hydrate 路径必须 re-attach running trace**：`useTraceReattach` 在 mount / `activeTraceId` 变化时通过 `subscribeToTrace` 主动订阅到 terminal；不在 `useEffect` cleanup 之前 abort；placeholder 消息用 `nextProtocolId("msg")` 创建。
- **POST 失败分类**：cold-start runner / network down → `onInfrastructureError` 走 `persistConversation` fail 分支；不要把"POST 超时"和"backend 5xx"写死成同一字符串。

不变量（用于 #802 / 未来回归测试）：

1. `bun run --cwd web/hwlab-cloud-web check` 全过：`bun run scripts/tsc-check.ts` 严格 React TSX 0 explicit any；`bun test` 5 pass / 0 fail。
2. `scripts/fetchJson-inactivity.test.ts` 含 `elapsed >= 10_000` 断言（10s 持续活动窗口内永远活）。
3. `state/runner-trace.ts` 只能导出 `subscribeToTrace`；`waitForAgentResult` / `pollRunnerTrace` / `TRACE_HARD_CAP_ATTEMPTS` 必须不存在。
4. `state/trace-reattach.ts` 必须存在并被 `workbench.ts` 引用；`workbench.ts` 不再直接 `await subscribeToTrace` 以外的方式做 trace 轮询。
5. live `http://74.48.78.17:19666/app.js` 不含 `Math.max(t*4`、`while (...)` 配 `totalTimeoutMs` 的 `>=` 比较、`> 120` 之类的硬上限 pattern。
6. CLI end-to-end：`hwlab-cli client agent send --wait` 跑通 + refresh 后用 `client agent trace <traceId> --render web` 拿同一份 row。

历史与收敛（蒸馏自 #775 / #777 / #791 / #795 / #797 / #798 / #802 的过程）：

- React 收敛前的旧单页 `fetchJson` 走 inactivity-timeout（`scheduleTimeout` 每秒重算 `remainingMs = timeoutMs - (now - lastActivityAt)`）。React 收敛后 #777 把 `client.ts` 加了 `ActivityRef` 抽象，但 `workbench.ts` / `runner-trace.ts` 没把 ref 串起来，于是 Web UI 退化成 total-timeout，#795 出现"bootshar 9.7s 跑完但 Web 37s 处 timeout"的回归。
- #795 PR #797 修了一版但留了 `max(4x, +60s)` 兜底 cap；PR #798 删 hard-cap 改成 `for(;;)`，但前端 fail 状态机没改（fail 消息只入本地 state，cloud-api 不知情）+ hydrate path 没有 re-attach 主动订阅。
- #802 收口：用 `subscribeToTrace` 一个入口统一 submit + hydrate；`onActivity` 在每次成功 poll 上调让 inactivity 永远不 fire；fail 消息也 `persistConversation`。

权威规则：Cloud Web 的 `state/workbench.ts` `submitMessage` 和 `state/runner-trace.ts` `waitForAgentResult` / `pollRunnerTrace` 必须遵守以下 contract，禁止用任何 wall-clock / 轮询次数 / 4× / +60s 兜底来"安全网"式杀掉活跃 turn：

- **唯一 abort 信号**：per-poll `fetchJson` 的 inactivity-timeout，由 `activityRef` 驱动。`activityRef.lastActivityAt` 在 `totalTimeoutMs` 窗口内被刷新就不 abort。
- **没有 total-timeout**：`waitForAgentResult` 和 `pollRunnerTrace` 的外层循环是 `for (;;)`，没有 `Date.now() - startedAt >= <cap>` 的跳出。per-poll `getAgentChatResult(url, totalTimeoutMs, activityRef)` 直接传 `totalTimeoutMs`，**不**随墙钟缩小成 `totalTimeoutMs - (Date.now() - startedAt)`。
- **没有轮询次数上限**：`TRACE_HARD_CAP_ATTEMPTS` 已删除。"轮询 120 次还没结束"不是 abort 条件；如果上游永远不返回 terminal 且永远不说话，浏览器就持续 poll 到用户 cancel / 关 tab。
- **没有 `Math.max(totalTimeoutMs * N, ...)` 兜底**：之前 round 9 留的 `max(4x, +60s)` 兜底按 PR 反馈彻底删除，理由是它会重新引入 #795 这类"trace 一直在推但被外层 cap 误杀"的回归。
- **activity 来源**：`store.recordActivity()`（`App.tsx` 串到 `<CommandBar onTyping>`）+ `submitMessage` submit kickoff + `pollRunnerTrace` 在新 snapshot 时 mutate ref。`runner-trace.ts` 不直接调 `updateActivity`，避免与 `state/workbench.ts` 的 `submitActivityRef` 重复。
- **Runaway 保护责任**：用户在 Web 上有 cancel / steer / 关 tab 三个明确逃生口；CLI 上有 `--timeout-ms` 让调用方自行决定。**不**在浏览器侧加隐式硬上限。

不变量（用于 #795 / 未来回归测试）：

1. `bun run --cwd web/hwlab-cloud-web check` 全过：`bun run scripts/tsc-check.ts` 严格 React TSX 0 explicit any；`bun test` 5 pass / 0 fail（`scripts/fetchJson-inactivity.test.ts` 3 case + dist-contract 2 case）。
2. `scripts/fetchJson-inactivity.test.ts` 必须包含 "持续 activity > inactivity 窗口" 用例：例如 200ms cadence 推 activity 共 10s，inactivity 窗口 3s，request 必须活到 10s+ 才因 activity stop 而 abort。`elapsed >= 10000` 是不变量；不变量失效即视为本节契约被破坏。
3. live `http://74.48.78.17:19666/app.js` 不能含 `Math.max(t*4`、`while (...)` 配 `totalTimeoutMs` 的 `>=` 比较、`> 120` 之类的硬上限 pattern；如出现即视为本节契约被破坏。

历史与收敛（蒸馏自 #775 / #777 / #791 / #795 的过程）：

- React 收敛前的旧单页 `fetchJson` 走的是 inactivity-timeout（`scheduleTimeout` 每秒重算 `remainingMs = timeoutMs - (now - lastActivityAt)`），`waitForAgentMessageResult` 也按 `idleMs >= CODE_AGENT_TIMEOUT_MS` 走。React 收敛后 #777 把 `client.ts` 加了 `ActivityRef` 抽象，但 `workbench.ts` / `runner-trace.ts` 没把 ref 串起来，于是 Web UI 退化成 total-timeout，#795 出现"bootshar 9.7s 跑完但 Web 37s 处 timeout"的回归。
- #795 PR #797 修了一版，但留了 `max(totalTimeoutMs * 4, totalTimeoutMs + 60s)` 兜底 cap。用户反馈"硬上限等于又引入 #795 同一类回归"，第二轮把 `for(;;)` + 删 `attempt > TRACE_HARD_CAP_ATTEMPTS` + 删 `startedAt` 累计计时 + per-poll 传 `totalTimeoutMs`（不缩小），实现"完全无 total / 轮询上限"。
- Round 10 起 commit / spec / 测试同步固化为本节。

- `web/hwlab-cloud-web/src/App.tsx` 是浏览器端主入口，和 `src/components/**`、`src/state/**`、`src/services/**`、`src/types/**` 共同组成实际 bundle 输入集合，组织 Workbench 状态、Code Agent 会话缓存、trace 轮询和 HWPOD node-ops 面板。
- `internal/dev-entrypoint/http.mjs` 提供静态服务、health 和 HTTP proxy 基础能力。
- `internal/dev-entrypoint/cloud-web-routes.mjs` 定义可代理到 cloud-api 的同源 API route 和认证边界。
- `web/hwlab-cloud-web/auth.ts` 管理工作台登录态、Keycloak redirect/callback 状态和 API key 管理 UI 调用；真正的登录鉴权和用户权限 authority 仍应收敛到 cloud-api。
- `web/hwlab-cloud-web/views/access` 或等价模块实现 Admin Access 页面：ActivityRail 顶层入口、用户列表、权限矩阵、pending diff、OpenFGA readiness/mismatch 展示和保存结果。页面必须复用同源 fetch client 和 auth blocker，不新增直连 OpenFGA client。

## API 接口说明

| 接口 | 说明 |
| --- | --- |
| `GET /` | Cloud Workbench 首屏。 |
| `GET /health`、`GET /health/live` | 返回 cloud-web 自身 health 和 build metadata。 |
| `GET /help` | 返回可用 route 摘要。 |
| `GET /auth/oidc/login`、`GET /auth/oidc/callback`、`GET /auth/session`、`POST /auth/logout` | 同源代理到 cloud-api 的 Keycloak/Web session 入口；登录鉴权最终规格见 [spec-v02-auth.md](spec-v02-auth.md)。 |
| `GET/POST /v1/api-keys...` | 同源代理到 cloud-api 的 API key 管理入口；短期测试允许当前用户重复查看默认 key 明文。 |
| `GET/PATCH/PUT/DELETE /v1/admin/access...` | 同源代理到 cloud-api 的 Admin Access API；用于 Access 页面读取 summary/user matrix、授予/撤销 tool capability 和 role/status。 |
| `GET /v1`、`GET /v1/...` | 同源代理到 `hwlab-cloud-api`；公开的 Code Agent result/trace 轮询按 route policy 处理。 |
| `GET/PATCH /v1/workbench/workspace...` | 同源代理到 cloud-api 的账号 workspace authority，用于 Web/CLI 共享工作区和 revision 冲突保护。 |
| `POST/GET/PATCH /v1/agent/sessions...` | 同源代理到 cloud-api 的显式 Code Agent session 生命周期入口；Web 不在普通 send 中隐式创建 session。 |
| `POST /v1/agent/chat`、`POST /v1/agent/chat/steer`、`POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口；steer 必须走同一个 `19666` Web path，由 cloud-api/AgentRun 判断目标 turn 是否可接收，并在 AgentRun steer command 创建后短连接 202 返回。 |
| `POST /v1/hwpod-node-ops` | 受控同源代理到 cloud-api 的 HWPOD node-ops 入口；用于 Web/CLI 同路径只读 smoke，Code Agent 正式业务入口仍是 runner 内 `hwpod` 直达 Cloud API。 |
| `POST /v1/web-performance` | 浏览器 RUM 上报入口；只允许低基数性能事件和数值，Cloud API 聚合后进入 Prometheus，详见 [spec-v02-observability-monitoring.md](spec-v02-observability-monitoring.md)。 |
| `GET /v1/web-performance/summary` | 性能监控顶级页读取的同源摘要接口；返回低基数 WebUI 体感性能 JSON，包含样本数、route p95、Web Vitals、long task 和错误/超时问题队列，不返回 Prometheus 原始文本或高基数 trace/session/conversation/thread/user 标识。 |
| `POST /v1/m3/io`、`POST /json-rpc` | 同源代理到受控 API；不能绕过 cloud-api 直连硬件服务。 |

## 测试规格

Cloud Web 的默认校验入口是 `bun run --cwd web/hwlab-cloud-web check`。该入口必须在 v0.2 CI 的 `hwlab-cloud-web` 镜像发布前执行，且保持秒级或低十秒级，不引入浏览器、Playwright、公网或真实 provider 依赖。

Browser/layout/live smoke 属于显式专项诊断，不进入默认 Cloud Web check。所有 repo-owned Playwright smoke、layout smoke 和专项截图脚本必须通过 `scripts/src/browser-launcher.mjs` 启动 Chromium；不得直接 `chromium.launch()` 绑定到偶然存在的 Playwright browser cache。launcher 的浏览器来源判定顺序是显式 env（`HWLAB_PLAYWRIGHT_CHROMIUM` / `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` / `CHROMIUM_PATH`）、G14 系统 Chromium（优先 `/snap/bin/chromium`）、最后才是 Playwright cache。诊断入口是 `npm run web:browser:doctor`，输出必须包含 `playwrightPackageSource`、`platform`、`managedBrowser.cacheStatus`、`installCheck.status`、`browser.browserSource`、`browser.executablePath`、`browser.fallbackUsed`、`browser.candidatePaths`、`failureCode` 和 remediation；浏览器不可用、auth bootstrap 未完成和真实 layout 失败不得再统一表现成 `#command-input hidden` 超时。

临时 live DOM closeout probe 也必须使用 repo-owned 入口，不得在 `/tmp` 或 issue 验收脚本里手写 Playwright import/launch。标准入口是 `npm run web:dom-probe -- start --url http://74.48.78.17:19666/ --fresh-session`，它会从 workspace 解析依赖、复用统一 launcher、把 PID/stdout/stderr/result/screenshot 写入 `.state/web-live-dom-probe/`，并立即返回 `status` 短查询命令；`npm run web:dom-probe -- status <jobId>` 用于轮询结果。前端-only DOM/renderer/scroll 验收默认不提交 Code Agent；只有显式 `--message` 才经 UI 提交一条真实消息，并按默认策略尝试取消运行中的 turn。`npm run web:browser:guard` 是最小 grep guard，用于确认 `scripts/`、`tools/`、`web/` 下没有新增绕过 launcher 的直接 Chromium launch 调用，唯一允许文件是 `scripts/src/browser-launcher.mjs`。

WebUI 性能监控 issue 的 live closeout 不能只检查页面可见或 sidecar 存活；必须用 `19666` Web 入口触发真实浏览器 RUM 上报，再按 [spec-v02-observability-monitoring.md](spec-v02-observability-monitoring.md) 查询 `hwlab_webui_*` Prometheus 指标。LCP、Navigation Timing、业务 API timing、Long Task、CLS/INP/FID 近似只表达用户感知性能趋势，不替代 trace/result/inspect 的高基数排障证据。

Workbench 首屏性能 issue 的 closeout 必须用 repo-owned browser launcher / live DOM probe 或其专项扩展访问 `http://74.48.78.17:19666/#/workspace`，并记录 LCP、CLS、command input 可用性和首屏请求清单。若现有探针缺少 LCP/CLS 或 request counting，可先扩展该探针；不要在 issue closeout 里长期手写临时 Playwright launcher 或绑定偶然存在的 Playwright browser cache。

Cloud Web 顶级性能页是观测面，不是业务工作台。访问 `/performance` / `#/performance` 时不得初始化 Workbench store 的 workspace hydrate 或 live refresh，也不得触发 HWPOD node-ops、agent conversations、live-builds、system.health 等 workspace-only 请求。性能页只允许主动请求 `GET /v1/web-performance/summary` 和静态资源；如果需要调查 workspace 或 HWPOD 性能，必须从 workspace 原入口触发样本，而不是让性能页自己制造业务流量。

Live smoke 登录前必须等待前端 auth bootstrap 结束（`body[data-auth-state]` 不再是 `checking`，且 login submit 已可见/可用）再填表；登录后必须断言 URL query 不含 `username` 或 `password`，防止原生 form submit 泄漏凭据并伪装成 layout 超时。

Workbench build summary 的顶部 chip 可以展示 `/v1/live-builds.latest`，但 `latest` 是跨所有 HWLAB runtime service 的最新构建，不是 Cloud Web 专属字段。Cloud Web 布局或 build-time issue 的验收必须打开详情或读取 `services[]` 中 `serviceId=hwlab-cloud-web` 的行，分别核对 build time、env image、actual commit、revision 和 source metadata；不得因为 `latest.serviceId` 指向 `hwlab-cloud-api` 就判定 Web 构建时间缺失。

Cloud Web check 必须先对实际 bundle 输入集合运行 TypeScript 语义检查。语法检查和 Bun build 只能证明源码可解析或可打包，不能稳定发现未绑定标识符；`isRequestTraceEvent is not defined` 这类错误必须由 semantic check 在发布前拦截。实现上可以生成与 dist build 相同顺序的临时 app entry，再执行 `tsc --noEmit` 或等价 TS checker；只跑 `node --check`、`bun build` 或源码字符串断言不满足本规格。

Cloud Web 单元测试必须自动发现并执行 repo-owned `web/hwlab-cloud-web/**/*.test.ts`，不允许只维护硬编码文件清单。`app-trace` 的 trace row/render helper 必须有纯逻辑单测，直接构造 request、setup、commandExecution、assistant markdown 和 completion events，证明 trace 展示路径不会因为漏定义 helper、事件分类漂移或 markdown body 渲染变更而在浏览器运行期崩溃。

Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验，确保实际发布的 `dist/app.js` 来自同一组 TypeScript 输入。默认 check 不恢复旧 quick prompt、旧 layout smoke、旧 M3 evidence、旧 DEV/D601 browser gate 或 Playwright；这些只能作为显式专项诊断，不得进入默认 CI/CD 关键路径。

## T1

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：访问 `http://74.48.78.17:19666/` 和 `/health/live`，确认页面和 health 均来自 v02 cloud-web，而不是 DEV/PROD 端口。

## T1.1

阅读 docs/reference/spec-v02-hwlab-cloud-web.md 和 docs/reference/spec-v02-observability-monitoring.md，然后用真实浏览器或 repo-owned live DOM probe 打开 `http://74.48.78.17:19666/#/workspace`。验收必须记录：LCP `<3s`、CLS `<0.1`、`#command-input` 可见且未 disabled、首屏只有一次 `/auth/workspace-bootstrap?projectId=prj_device_pod_workbench`，并且首屏观察窗口内 `/auth/bootstrap`、`/auth/session`、`/v1/workbench/workspace`、trace replay、`events?limit=120` 和 `MarkdownRenderer` chunk 均未出现。随后用同源 `/v1/web-performance/summary` 和受控 Prometheus 查询确认 `/workspace` WebUI RUM 样本存在；summary 仍处于 warming 时必须如实说明，不能用性能页自身样本替代 workspace 样本。

## T2

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：先通过同源 `19666` 显式创建或选择 Code Agent session，再提交 Code Agent 短连接请求并轮询 result，确认请求经 cloud-web proxy 到 `hwlab-cloud-api`，且 trace 可回放；未创建或选择 session 的普通 send 必须返回 `session_required`，不能自动创建 session。

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：对运行中 Code Agent trace 调用 `hwlab-cli client agent steer <traceId> --message ...`，确认请求走 Cloud Web 同源 `POST /v1/agent/chat/steer`；成功路径必须在短请求内返回 HTTP 202、`accepted=true`、`shortConnection=true` 和 `agentRun.steerCommandId`，再通过原 trace 的 `result/trace --render web` 看到 `agentrun:steer:accepted` 与 `agentrun:steer:command-created`。Web 层不能返回 `serviceId=hwlab-cloud-web` 的 404；目标不存在、非运行中或 runner 拒绝时必须透传 cloud-api/AgentRun 的结构化业务状态，不得把仍 running 的原 turn 误标 failed。

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：未选择 session 时运行 `hwlab-cli client agent composer status` 必须显示 `sessionRequired=true`；显式创建 session 并启动一个真实运行中的 Code Agent turn 后，再运行 `hwlab-cli client agent composer status`，确认输出当前 `sessionId`、`composer.submitMode=steer`、`composer.route=/v1/agent/chat/steer` 和当前 `targetTraceId`；随后运行 `hwlab-cli client agent composer submit --message ...`，确认 CLI 按 Web composer policy 走 steer，但不自动创建或滚动 session。

## T2.1

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：对浏览器暴露的 Code Agent trace 运行 `hwlab-cli client agent trace <traceId> --render web --limit 80`，确认 CLI 与 Web 使用同一 trace row 转换；若 final response 缺失、assistant row 顺序错乱或噪声事件过多，先用 CLI 固定复现再修实现。

## T2.2

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：先显式创建 session，再在同一 conversation/session 连续发送两条 Code Agent 消息，确认第二条复用第一条的 AgentRun `runId` 和 runner `jobName`、生成新的 `commandId`，且不重新 materialize bundle/启动新 runner；result completed 必须包含真实 provider/model/`providerTrace`/trace/conversation 和 final assistant response。复用失败原因只能作为诊断，不作为本测试通过条件；如果 runner pod 已被删除或旧 lease 已失效，replacement run/job 只能作为同 session/PVC/thread 的恢复证据记录，不能算作本测试的 run/runner reuse 通过。如果 session failed/stale，必须显式创建新 session 再继续。

## T2.3

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：对一轮已取消但取消前存在 assistant/tool 进展的 trace 发送同一 conversation/session/thread 后续问题，确认请求和 result 只携带标准 `threadId`，不出现历史 thread 别名字段；`--render web` 输出不得把上一 command 的尾部 assistant/tool/terminal row 堆到新 command 末尾。

## T3

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：打开 Workbench HWPOD node-ops 面板，确认 status/freshness/blocker 显示来自 `/v1/hwpod-node-ops`，未登录或未授权时必须显示认证/授权 blocker，不得把 fixture 或 blocked fallback 写成真实硬件 DEV-LIVE。

## T3.1

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：通过 `19666` Cloud Web 同源 path 对 `/v1/hwpod-node-ops` 提交只读 `node.health` plan，确认与 `19667` Cloud API 的 route policy 对齐；如果 Cloud API 返回业务级 4xx，Cloud Web 也应透传业务错误，不应在 Web 层直接 404。

## T4

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：运行 `bun run --cwd web/hwlab-cloud-web check`，确认输出或日志显示已执行 Cloud Web TypeScript 语义检查、自动发现的单元测试、bundle build 和 dist freshness 校验；不得用只跑 `bun build` 或浏览器手工刷新替代该检查。

## T5

阅读 docs/reference/spec-v02-hwlab-cloud-web.md，然后用 cli 手动测试以下内容：确认 trace 渲染相关单测覆盖 request、setup、commandExecution、assistant markdown 和 completion row；该测试必须能在无浏览器、无 Playwright、无公网、无真实 provider 的环境中执行。

## T6

阅读 docs/reference/spec-v02-hwlab-cloud-web.md 和 docs/reference/spec-v02-openfga-authorization.md，然后用 cli 手动测试以下内容：登录 admin 后打开 Cloud Web Access 页面，确认 ActivityRail 显示 Access 入口，页面加载 `/v1/admin/access/summary` 和用户权限矩阵；普通用户访问同一路由必须显示 authorization blocker。通过页面授予/撤销一次 `tool:hwpod` capability 后，`hwlab-cli client access users inspect <user>` 必须看到同一 effective matrix。

## Session state 持久化与 eviction reset

HWLAB v0.2 Code Agent chat turn 默认走 `sessionStorage=persistent`：每次 turn 由 `code-agent-agentrun-adapter.ts` 在创建 run 之前显式调 `POST /api/v1/sessions`，让 AgentRun v0.1 同步创建 RWO PVC（`agentrun-v01-session-<sessionId>`，1Gi，StorageClass 走 env `AGENTRUN_SESSION_STORAGE_CLASS` 默认 `local-path`）。runner Job manifest 渲染时多挂一个 `agentrun-sessions` volume + `AGENTRUN_SESSION_PVC_NAME` / `_NAMESPACE` / `_MOUNT_PATH` / `AGENTRUN_CODEX_ROLLOUT_SUBDIR` env，使 codex app-server 自己把 rollout JSONL 写进 PVC，跨 runner pod 重建天然 `thread/resume:completed`。

runner pod 或 runner Job 丢失但 PVC 仍存在时，下一轮必须继续使用同一个 HWLAB `sessionId`、标准 `threadId`、session provider profile 和 AgentRun `SessionRef`/PVC 执行 resume。当前临时恢复允许 Cloud API 启动 replacement run/job 作为执行壳；验收必须记录 replacement run/job 与原业务 session 的对应关系，并证明没有通过历史 prompt、messages 或 fake resume 续接。长期目标仍是 runner reuse window 内同一 AgentRun run/runner 多 command。

### Eviction reset UX

当 AgentRun v0.1 上报 `failureKind=session-store-evicted`（PVC 被删或 TTL 到期）时，HWLAB cloud-api 走 reset UX：

- 同 `conversationId`，发新 `sessionId`（`newSessionIdAfterEviction(baseSessionId, traceId)` = `<base>-reset-<trace8>`），`threadId=null` 强制走 `thread/start`。
- 旧 session 的 `storageKind` 已被 AgentRun 标为 `evicted`，HWLAB adapter 不再 reuse 旧 mapping。
- Workbench 把 `errorCode=session_storage_evicted` 归到 `session-blocked` category，UI 文案与 `session_failed` 区分：「Code Agent session 存储已失效（PVC 被回收 / TTL 到期），HWLAB 已为你开新 sessionId，可继续发送下一条消息。」

禁止路径：

- 不允许 fake `thread/resume:completed`（PR #78 已锁定的 v0.1 contract）。
- 不允许 `idleTimeoutMs` 拉成永驻当成本特性。
- 不允许 runner Job 启动后再做 copy/restore（本方案撤掉的路径，禁止复活）。

### 实现情况

| 规格项 | 状态 | 说明 |
| --- | --- | --- |
| 调 POST /api/v1/sessions 同步建 session + PVC | 已实现 | `code-agent-agentrun-adapter.ts::ensureAgentRunSessionPersistent` 默认 `sessionStorage=persistent`；env `HWLAB_CODE_AGENT_AGENTRUN_SESSION_STORAGE=ephemeral` 可退回 metadata-only 模式。 |
| runner Job 直接挂载 PVC | 已实现/已通过 HWLAB v0.2 原入口复测 | runner Job manifest 由 AgentRun v0.1 渲染 per-session PVC 直接挂载；HWLAB 侧以同 session/thread/PVC resume 作为恢复证据，不走 copy/restore。 |
| eviction reset UX | 已实现 | submitAgentRunChatTurn 走两步尝试，第一步在 ensureSession 或 runner-jobs POST 收到 `session-store-evicted` 时用新 sessionId + `threadId=null` 重试。 |
| UX 错误码 `session_storage_evicted` | 目标状态/需专项复测 | `session-store-evicted` 必须归入 `session-blocked`，并向用户区分“PVC 被回收，需要新 session”与普通 provider/backend 失败；不能用 fake resume 或自动无感滚动掩盖。 |
## 规格的实现情况

| 规格项 | 状态 | 说明 |
| --- | --- | --- |
| Workbench 首屏 | 已实现 | 当前页面直接进入工作台，不是 landing page。 |
| Workbench 首屏性能契约 | 已实现 | 首屏通过 early workspace bootstrap、seed 复用、历史 Markdown 延迟升级和历史 final response 稳定高度边界降低 LCP/CLS；关闭性能 issue 前必须用 `19666/#/workspace` 原入口复测。 |
| cloud-api 同源代理 | 已实现 | 受 route policy 控制；HWPOD node-ops POST 必须与 Cloud API route policy 对齐。 |
| Code Agent UI/trace/result | 已实现 | 支持 provider profile、timeout、trace 轮询和取消。 |
| Code Agent 无锁 composer | 已实现 | Web/CLI 共享 composer policy；运行中输入框保持可编辑并自动走 steer。 |
| 账号 workspace hydrate/sync | 已实现 | 启动读取 `account_workspaces`，Code Agent 请求携带 workspace revision，终态再同步 workspace。 |
| HWPOD node-ops 面板 | 已实现 | 当前消费 `/v1/hwpod-node-ops` 的只读健康 plan，展示 node/action/result/blocker 摘要。 |
| Admin Access 授权页面 | 目标状态 | 新增 admin-only ActivityRail 页面，管理 OpenFGA relation/tool capability/role status，并与 CLI `client access` 共用同一路径。 |
| Provider API Key 管理页面 | 已实现 | `#/management` admin-only 管理页面走 `/v1/admin/provider-profiles*` 同源 API 并委托 AgentRun；CLI 同路径入口用于非视觉验收。 |
| 完整多用户 admin/user UI | 未完全实现 | 登录态存在；Keycloak/Web session/API key 需按 spec-v02-auth 收敛，权限 authority 仍需按 spec-user-access 收敛到 cloud-api。 |

