diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 8ff34617..7101e7bf 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -19,7 +19,10 @@ - 输出默认是 JSON;任何失败都要有 `ok:false`、`action`、`status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`。 - `device-pod-cli`/`hwpod` 的 `job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head`、`grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。 - Code Agent 交互必须默认暴露 `traceId`、`resultUrl`、终态和 assistant 回复文本摘要;不能要求用户先拉全量 trace 再手工查找回复。 +- CLI 本地登录态必须支持 `--profile NAME` 隔离,同一 base URL 下不同 profile 写入 `.state/hwlab-cli/profiles//.json`。切换到其他账号再切回原账号时,`client workbench restore/status` 必须从服务端账号 workspace 恢复之前的 `workspaceId`、`conversationId`、`sessionId`、`threadId`、`activeTraceId` 和 revision,而不是只依赖本地文件。 +- `client workbench restore/status/watch/reset` 是账号 workspace 的非视觉入口:`restore/status` 对应 `GET /v1/workbench/workspace`,`watch` 对应 `/events?afterRevision=`,`reset --confirm` 对应服务端 reset。输出必须显示 workspace revision、selected conversation/session、active trace 和本地 state file,且不得保存 password、session token 原文以外的 Secret 值。 - `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--from-trace`、`--conversation-id`、`--session-id`、`--thread-id` 和 `--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。 +- `client agent send` 默认先恢复账号 workspace,再向 `/v1/agent/chat` 发送 `workspaceId` 和 `expectedWorkspaceRevision`;服务端接受后 CLI 保存新的 workspace revision,终态轮询后再 PATCH workspace 清理终态 `activeTraceId`。只有显式 `--no-workspace` 才跳过这一默认恢复路径。 - AgentRun v0.1 短连接 runner 不保证历史 Web/Codex `threadId` 可在新 runner Job 内 resume;CLI 仍应把 Web 提交的 `threadId` 原样送到 Cloud Web API,以便验证 adapter 是否正确把它降级为 `requestedThreadId` 元数据,而不是让 runner 执行旧 thread resume。 - `client harness`、`client harness-ops` 和 `client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。 - harness 系列命令只调用 Cloud Web/Code Agent 同源 API,不创建镜像、Job、常驻服务,也不执行 hot-sync、kubectl cp 或硬编码 namespace/pod 的运行面写路径。 @@ -39,6 +42,7 @@ | --- | --- | --- | | `hwlab-cli client auth login` | `POST /auth/login` | 使用账号密码登录 Cloud Web,同步保存 cookie。 | | `hwlab-cli client auth session` | `GET /auth/session` | 恢复当前 actor/session。 | +| `hwlab-cli client auth profiles` | 本地状态读取 | 列出同一 base URL 下的本地 profile state,用于账号切换可见性。 | | `hwlab-cli client auth logout` | `POST /auth/logout` | 撤销 server session 并清理本地 cookie。 | | `hwlab-cli client device-pods list` | `GET /v1/device-pods` | 对应右侧 Device Pod 列表。 | | `hwlab-cli client device-pods status POD` | `GET /v1/device-pods/{pod}/status` | 对应 Device Pod summary/status。 | @@ -55,6 +59,7 @@ | `hwlab-cli client harness wait/result/trace` | `GET /v1/agent/chat/result/{trace}`、`GET /trace/{trace}` | 轮询或读取一次 Code Agent 结果和 trace;单次 wait 最长 60 秒。 | | `hwlab-cli client harness audit` | `GET /v1/agent/chat/trace/{trace}` 或本地 trace file | 只输出工具摩擦信号,辅助发现应补的主 CLI/device-pod 操作;不是运行时 gate。 | | `hwlab-cli client workbench summary` | `/health/live`、`/v1`、`/v1/live-builds`、`/v1/device-pods*` | 汇总 Cloud Workbench 非视觉功能面。 | +| `hwlab-cli client workbench restore/status/watch/reset` | `GET/PATCH /v1/workbench/workspace*` | 恢复、观察或重置账号级共享 workspace,支持 Web/CLI 和多 profile 共享同一账号状态。 | | `hwlab-cli client rpc METHOD [--full]` | `POST /json-rpc` | 像 Web `callRpc` 一样自动生成 `id`、`traceId` 和 `meta`,覆盖 `system.health`、`cloud.adapter.describe` 等 JSON-RPC 非视觉能力。 | | `hwlab-cli client request METHOD /path [--full]` | Cloud Web 同源相对路径 | 覆盖 `/v1/access/status`、`/v1/setup/status`、`/v1/diagnostics/gate`、`/v1/m3/status`、`/v1/m3/io` 等低频或新增 WEB API;`/json-rpc` 优先使用 `client rpc`。 | @@ -78,6 +83,10 @@ 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:对一个来自 Cloud Web 的失败 trace 运行 `client agent send --from-trace --message "重试上一条" --provider-profile deepseek --no-wait`,确认 CLI 先访问 `/v1/agent/chat/inspect?traceId=`,再提交 `/v1/agent/chat`,输出包含 `replay.source`、`continuation.replayedFromTrace`、`conversationId/sessionId/threadId/retryOf`,且不输出 cookie、token 或 secret 原文。 +## T3A + +阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:分别用 `--profile admin-a` 和 `--profile admin-a-second` 登录同一个账号,运行 `client workbench restore/status`,确认两个 profile 看到相同 `workspaceId` 和 revision;再用 `--profile other-user` 登录另一个账号,确认 workspace 不同;最后切回 `admin-a`,确认原 workspace、conversation/session/thread 仍能恢复。该验收必须使用真实 `http://74.48.78.17:19666`,不能 mock。 + ## T4 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client request GET /v1/access/status`、`client request GET /v1/diagnostics/gate` 和 `client rpc system.health`,确认它们全部走 `19666` Cloud Web 同源 API,返回 JSON route/httpStatus/body,且 `client request` 传入绝对 URL 会被拒绝。 @@ -106,5 +115,6 @@ | Gateway transport 压测 | 已实现 | `client gateway pressure` 只作为显式短连接诊断入口,覆盖大输出、timeout 和并发超容量的结构化返回。 | | Device Pod job output 紧凑输出 | 已实现 | `hwpod job output` 默认省略嵌套 dispatch,`--full` 才展开完整 payload,防止 Code Agent 通过 shell pipe 压输出。 | | 本地 cookie session | 目标状态 | `.state/hwlab-cli/session.json` 只保存 cookie/session 摘要。 | +| 账号 profile 与共享 workspace | 已实现 | `--profile` 隔离本地登录态,`client workbench` 通过服务端 `account_workspaces` 恢复同账号共享 workspace。 | | 镜像/Service/Job template | 已废弃 | 相关 deploy、GitOps、artifact 和 Tekton 口径必须删除。 | | PR/CI/CD/worktree 流程 | 已废弃 | CLI 变更不走常驻服务发布流程。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index ddf03360..a58b0244 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -13,6 +13,7 @@ - `cmd/hwlab-cloud-api/main.ts` 负责启动 HTTP server、解析端口和 Code Agent timeout。 - `internal/cloud/server.ts` 负责 HTTP route、REST/RPC bridge、health、live-builds、device-pod authority、gateway poll/result 和 Code Agent chat。 - `internal/cloud/access-control.ts` 负责 `/auth/*`、admin/user、device pod profile/grant、device job lifecycle 和 Code Agent owner binding。 +- `internal/cloud/access-control.ts` 也是账号 workspace authority:`account_workspaces` 记录同一账号的 Workbench 当前 workspace、selected conversation/session、active trace、provider profile 和 revision。 - `internal/db/runtime-store.ts` 和 `internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。 - `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。 - 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。 @@ -31,6 +32,7 @@ | `GET /auth/session`、`POST /auth/login`、`POST /auth/logout` | v0.2 本地用户 session 入口。 | | `GET /v1/auth/session`、`GET /v1/users/me`、`GET /v1/access/status`、`GET /v1/setup/status` | v0.2 用户/session/setup 的 REST 状态和兼容入口;响应不得暴露 password hash、session token 原文或 Secret 值。 | | `POST /v1/admin/users`、`POST/PUT /v1/admin/device-pods`、`POST/DELETE /v1/admin/device-pod-grants...` | `admin` 管理用户、device pod profile 和 grant 的入口。 | +| `GET/PATCH /v1/workbench/workspace`、`POST /v1/workbench/workspace/{id}/reset`、`GET /events` | 账号级共享 workspace authority;所有读写按 ownerUserId 隔离,写入使用 revision 防冲突,active trace 用于阻止同账号并发 Code Agent turn。 | | `GET /v1/m3/status`、`POST /v1/m3/io` | M3 只读/受控 IO 入口;写操作必须有明确 approval。 | | `GET /v1/diagnostics/gate`、`GET /v1/live-builds` | 诊断和 live build inventory。 | | `GET /v1/gateway/sessions`、`POST /v1/gateway/poll`、`POST /v1/gateway/result` | gateway 主动出站注册、取任务和回传结果。 | @@ -63,4 +65,5 @@ | gateway outbound poll/result | 已实现 | 支持 gateway 主动轮询和 `hardware.invoke.shell` 分发。 | | device-pod 正式权限/profile/job | 部分实现 | profile/grant/list/status/job 持久化在 cloud-api;用户态 probe GET 已收敛为只读 job;已提供内部 gateway dispatch route 供 `hwlab-device-pod` executor 下发到 device-host-cli,无在线 gateway/device-host-cli 时返回 blocker。 | | v0.2 admin/user 权限模型 | 部分实现 | `/auth/*`、admin user/device-pod/grant API 和 Code Agent owner binding 已接入;生产 bootstrap 依赖 SecretRef。 | +| 账号共享 workspace authority | 已实现 | `account_workspaces` 持久化同账号 Web/CLI 共享 workspace,支持 revision 冲突、账号隔离、active trace 并发保护和 reset。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index 1134f691..adb01468 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -8,6 +8,7 @@ - 只消费 `hwlab-cloud-api`,不直接访问 Postgres、gateway、device-pod Service、FRP、Kubernetes 或 provider Secret。 - 为浏览器提供同源代理,避免前端直接跨域调用内部 ClusterIP。 - Cloud Web 与 `hwlab-cli client` 必须共享同一组非视觉业务 API。浏览器遇到的 Code Agent continuation、trace/result、device-pod list/status 和 device-pod job 问题,必须能通过 `hwlab-cli client` 走同一 `19666` Cloud Web path 复现;不能让 CLI 长期绕到 `19667` Cloud API 后把 Web 路径缺口误判为业务已通过。 +- 浏览器启动后必须从 `GET /v1/workbench/workspace` hydrate 账号 workspace;同一个账号在多个浏览器标签页、多个浏览器或 CLI profile 中应看到同一个 `workspaceId`、selected conversation/session/thread、provider profile 和 active trace。浏览器 localStorage 只能作为短期缓存,并必须绑定 actor,不能作为 workspace authority。 ## 内部架构 @@ -24,6 +25,7 @@ | `GET /health`、`GET /health/live` | 返回 cloud-web 自身 health 和 build metadata。 | | `GET /help` | 返回可用 route 摘要。 | | `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 /v1/agent/chat`、`POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口。 | | `POST /v1/device-pods/...` | 受控同源代理到 cloud-api 的 Device Pod job/操作入口;只要 Cloud API 已提供对应能力,Cloud Web 不能只代理 list/status 而让 job POST 在 `19666` 返回 404。 | | `POST /v1/m3/io`、`POST /json-rpc` | 同源代理到受控 API;不能绕过 cloud-api 直连硬件服务。 | @@ -69,6 +71,7 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验, | Workbench 首屏 | 已实现 | 当前页面直接进入工作台,不是 landing page。 | | cloud-api 同源代理 | 已实现 | 受 route policy 控制;device-pod job POST 必须与 Cloud API route policy 对齐。 | | Code Agent UI/trace/result | 已实现 | 支持 provider profile、timeout、trace 轮询和取消。 | +| 账号 workspace hydrate/sync | 已实现 | 启动读取 `account_workspaces`,Code Agent 请求携带 workspace revision,终态再同步 workspace。 | | device-pod 面板 | 未完全实现 | 当前主要消费 fake/只读 device-pod payload。 | | 完整多用户 admin/user UI | 未完全实现 | 登录态存在,权限 authority 仍需按 spec-user-access 收敛到 cloud-api。 | diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 401ae878..bb326286 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -7,6 +7,117 @@ import { createCloudApiServer } from "./server.ts"; const INTERNAL_TOKEN = "test-internal-token"; +test("workbench workspace is account-scoped, persistent, and gates active agent turns", async () => { + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, + now: () => "2026-06-01T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + const adminCookie = adminLogin.cookie; + const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws", password: "alice-pass" }, adminCookie); + const bob = await postJson(port, "/v1/admin/users", { username: "bob-ws", password: "bob-pass" }, adminCookie); + assert.equal(alice.status, 201); + assert.equal(bob.status, 201); + + const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws", password: "alice-pass" }); + const bobLogin = await postJson(port, "/auth/login", { username: "bob-ws", password: "bob-pass" }); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(workspace.status, 200); + assert.equal(workspace.body.contractVersion, "workbench-workspace-v1"); + assert.match(workspace.body.workspace.workspaceId, /^wsp_/u); + assert.equal(workspace.body.workspace.revision, 1); + assert.equal(workspace.body.workspace.selectedConversationId, null); + + const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue655_shared", { + projectId: "prj_device_pod_workbench", + sessionId: "ses_issue655_shared", + threadId: "thread-issue-655", + sessionStatus: "active", + lastTraceId: "trc_issue655_previous", + messages: [{ role: "user", text: "persist me", traceId: "trc_issue655_previous" }] + }, aliceLogin.cookie); + assert.equal(conversation.status, 200); + assert.equal(conversation.body.conversation.conversationId, "cnv_issue655_shared"); + + const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { + projectId: "prj_device_pod_workbench", + expectedRevision: 1, + selectedConversationId: "cnv_issue655_shared", + selectedAgentSessionId: "ses_issue655_shared", + activeTraceId: "trc_issue655_active", + providerProfile: "deepseek", + sessionStatus: "running", + threadId: "thread-issue-655", + messages: [{ role: "agent", status: "running", traceId: "trc_issue655_active" }], + updatedByClient: "test-suite" + }, aliceLogin.cookie); + assert.equal(update.status, 200); + assert.equal(update.body.workspace.revision, 2); + assert.equal(update.body.workspace.selectedConversationId, "cnv_issue655_shared"); + assert.equal(update.body.workspace.selectedConversation.conversationId, "cnv_issue655_shared"); + assert.equal(update.body.workspace.activeTraceId, "trc_issue655_active"); + assert.equal(update.body.workspace.secretMaterialStored, false); + + const stale = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { + expectedRevision: 1, + selectedConversationId: "cnv_issue655_shared", + updatedByClient: "test-suite" + }, aliceLogin.cookie); + assert.equal(stale.status, 409); + assert.equal(stale.body.error.code, "workspace_revision_conflict"); + assert.equal(stale.body.workspace.revision, 2); + + const bobRead = await getJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/events?afterRevision=0`, bobLogin.cookie); + assert.equal(bobRead.status, 404); + assert.equal(bobRead.body.error.code, "workbench_workspace_not_found"); + + const aliceRelogin = await postJson(port, "/auth/login", { username: "alice-ws", password: "alice-pass" }); + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceRelogin.cookie); + assert.equal(restored.status, 200); + assert.equal(restored.body.workspace.workspaceId, workspace.body.workspace.workspaceId); + assert.equal(restored.body.workspace.selectedConversationId, "cnv_issue655_shared"); + assert.equal(restored.body.workspace.revision, 2); + + const events = await getJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/events?afterRevision=1`, aliceRelogin.cookie); + assert.equal(events.status, 200); + assert.equal(events.body.status, "changed"); + assert.equal(events.body.latestRevision, 2); + + const busy = await postJson(port, "/v1/agent/chat", { + message: "should be blocked by active workspace turn", + traceId: "trc_issue655_next", + conversationId: "cnv_issue655_shared", + sessionId: "ses_issue655_shared", + workspaceId: workspace.body.workspace.workspaceId, + expectedWorkspaceRevision: 2, + shortConnection: true + }, aliceRelogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_issue655_next" }); + assert.equal(busy.status, 409); + assert.equal(busy.body.error.code, "workspace_agent_busy"); + assert.equal(busy.body.activeTraceId, "trc_issue655_active"); + assert.equal(busy.body.workspaceRevision, 2); + + const reset = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/reset`, { + confirm: true, + updatedByClient: "test-suite" + }, aliceRelogin.cookie); + assert.equal(reset.status, 200); + assert.equal(reset.body.reset, true); + assert.equal(reset.body.workspace.activeTraceId, null); + assert.equal(reset.body.workspace.selectedConversationId, null); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api access control grants visible device pods and requires device-pod executor", async () => { let directGatewayDispatches = 0; const gatewayRegistry = { @@ -1159,6 +1270,23 @@ async function putJson(port, path, body, cookie = null, extraHeaders = {}) { }; } +async function patchJson(port, path, body, cookie = null, extraHeaders = {}) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: "PATCH", + headers: { + "content-type": "application/json", + ...(cookie ? { cookie } : {}), + ...extraHeaders + }, + body: JSON.stringify(body) + }); + return { + status: response.status, + body: await response.json(), + cookie: response.headers.get("set-cookie") + }; +} + async function getJson(port, path, cookie = null, extraHeaders = {}) { const response = await fetch(`http://127.0.0.1:${port}${path}`, { headers: { diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 58d84851..a53a08ed 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -71,6 +71,27 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ `CREATE INDEX IF NOT EXISTS idx_agent_sessions_owner ON agent_sessions(owner_user_id)`, `CREATE INDEX IF NOT EXISTS idx_agent_sessions_conversation ON agent_sessions(conversation_id)`, `CREATE INDEX IF NOT EXISTS idx_agent_sessions_last_trace ON agent_sessions(last_trace_id)`, + `CREATE TABLE IF NOT EXISTS account_workspaces ( + id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL REFERENCES users(id), + project_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT 'Default Workbench', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + is_default BOOLEAN NOT NULL DEFAULT true, + selected_conversation_id TEXT, + selected_agent_session_id TEXT, + selected_device_pod_id TEXT, + active_trace_id TEXT, + provider_profile TEXT, + workspace_json TEXT NOT NULL DEFAULT '{}', + revision INTEGER NOT NULL DEFAULT 1, + updated_by_session_id TEXT, + updated_by_client TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_account_workspaces_default ON account_workspaces(owner_user_id, project_id) WHERE is_default = TRUE AND status = 'active'`, + `CREATE INDEX IF NOT EXISTS idx_account_workspaces_owner ON account_workspaces(owner_user_id, project_id, updated_at DESC)`, `CREATE TABLE IF NOT EXISTS device_pods ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', @@ -637,6 +658,191 @@ class AccessController { return sendJson(response, 200, { ok: true, status: "stored", contractVersion: "agent-conversations-v1", conversation }); } + async getWorkbenchWorkspace(request, response, url) { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + const projectId = textOr(url.searchParams.get("projectId"), "prj_device_pod_workbench"); + const workspace = await this.store.getOrCreateDefaultWorkspace?.({ + ownerUserId: auth.actor.id, + projectId, + now: this.now() + }); + return sendJson(response, 200, await this.workbenchWorkspacePayload(workspace, auth.actor)); + } + + async updateWorkbenchWorkspace(request, response, workspaceId) { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + if (!safeWorkspaceId(workspaceId)) { + return sendJson(response, 400, errorPayload("invalid_workspace_id", "workspaceId must start with wsp_", 400)); + } + const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role }); + if (!current) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404)); + const body = await jsonBody(request); + const expectedRevision = Number.parseInt(String(body.expectedRevision ?? body.revision ?? ""), 10); + if (Number.isInteger(expectedRevision) && expectedRevision > 0 && expectedRevision !== current.revision) { + return sendJson(response, 409, { + ok: false, + status: "revision_conflict", + error: { code: "workspace_revision_conflict", message: "Workbench workspace revision changed; reload and retry." }, + expectedRevision, + workspace: await this.publicWorkbenchWorkspace(current, auth.actor) + }); + } + const selectedConversationId = textOr(body.selectedConversationId ?? body.conversationId, current.selectedConversationId ?? ""); + if (selectedConversationId) { + const visible = await this.visibleConversationForActor(auth.actor, selectedConversationId, body.projectId ?? current.projectId); + if (!visible) return sendJson(response, 403, errorPayload("workspace_conversation_forbidden", "Selected conversation is not visible to the current actor", 403)); + } + const selectedDevicePodId = textOr(body.selectedDevicePodId ?? body.devicePodId, current.selectedDevicePodId ?? ""); + if (selectedDevicePodId && !(await this.store.getVisibleDevicePod(auth.actor, selectedDevicePodId))) { + return sendJson(response, 403, errorPayload("workspace_device_pod_forbidden", "Selected device pod is not visible to the current actor", 403)); + } + const workspace = await this.store.updateWorkspace?.({ + workspaceId, + ownerUserId: auth.actor.id, + actorRole: auth.actor.role, + projectId: textOr(body.projectId, current.projectId), + name: textOr(body.name, current.name), + status: body.status === "archived" ? "archived" : "active", + selectedConversationId, + selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId) || current.selectedAgentSessionId, + selectedDevicePodId, + activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null, + providerProfile: textOr(body.providerProfile, current.providerProfile ?? ""), + patch: normalizeWorkspacePatch(body, auth.actor), + updatedBySessionId: currentSessionIdFromAuth(auth), + updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"), + now: this.now() + }); + return sendJson(response, 200, await this.workbenchWorkspacePayload(workspace, auth.actor)); + } + + async selectWorkbenchConversation(request, response, workspaceId) { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + if (!safeWorkspaceId(workspaceId)) return sendJson(response, 400, errorPayload("invalid_workspace_id", "workspaceId must start with wsp_", 400)); + const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role }); + if (!current) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404)); + const body = await jsonBody(request); + const conversationId = textOr(body.conversationId, "") || `cnv_${randomUUID()}`; + if (!safeConversationIdLocal(conversationId)) return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400)); + const existing = await this.visibleConversationForActor(auth.actor, conversationId, body.projectId ?? current.projectId); + if (!existing && body.create !== true) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404)); + const sessionId = safeAgentSessionId(body.sessionId ?? existing?.sessionId) || `ses_${conversationId.slice(4)}`; + if (!existing) { + await this.recordAgentSessionOwner({ + ownerUserId: auth.actor.id, + sessionId, + projectId: current.projectId, + agentId: "hwlab-code-agent", + status: "active", + conversationId, + threadId: textOr(body.threadId, null), + traceId: textOr(body.lastTraceId ?? body.traceId, null), + session: normalizeConversationSnapshot({ ...body, snapshot: { source: "workbench-select-conversation" } }, auth.actor) + }); + } + const workspace = await this.store.updateWorkspace?.({ + workspaceId, + ownerUserId: auth.actor.id, + actorRole: auth.actor.role, + selectedConversationId: conversationId, + selectedAgentSessionId: sessionId, + activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null, + patch: normalizeWorkspacePatch({ ...body, selectedConversationId: conversationId, sessionId }, auth.actor), + updatedBySessionId: currentSessionIdFromAuth(auth), + updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"), + now: this.now() + }); + return sendJson(response, 200, await this.workbenchWorkspacePayload(workspace, auth.actor)); + } + + async resetWorkbenchWorkspace(request, response, workspaceId) { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + if (!safeWorkspaceId(workspaceId)) return sendJson(response, 400, errorPayload("invalid_workspace_id", "workspaceId must start with wsp_", 400)); + const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role }); + if (!current) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404)); + const body = await jsonBody(request); + if (body.confirm !== true && body.confirmReset !== true) { + return sendJson(response, 400, errorPayload("workspace_reset_confirmation_required", "Reset requires confirm=true", 400)); + } + const workspace = await this.store.updateWorkspace?.({ + workspaceId, + ownerUserId: auth.actor.id, + actorRole: auth.actor.role, + selectedConversationId: null, + selectedAgentSessionId: null, + selectedDevicePodId: null, + activeTraceId: null, + providerProfile: null, + patch: { resetAt: this.now(), resetBy: publicActor(auth.actor), messages: [] }, + replaceJson: true, + updatedBySessionId: currentSessionIdFromAuth(auth), + updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"), + now: this.now() + }); + return sendJson(response, 200, { ...(await this.workbenchWorkspacePayload(workspace, auth.actor)), reset: true }); + } + + async workbenchWorkspaceEvents(request, response, url, workspaceId) { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + const workspace = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role }); + if (!workspace) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404)); + const afterRevision = Number.parseInt(String(url.searchParams.get("afterRevision") ?? ""), 10) || 0; + const changed = workspace.revision > afterRevision; + return sendJson(response, 200, { + ok: true, + status: changed ? "changed" : "unchanged", + contractVersion: "workbench-workspace-v1", + afterRevision, + latestRevision: workspace.revision, + events: changed ? [{ type: "workspace", revision: workspace.revision, updatedAt: workspace.updatedAt }] : [], + workspace: changed ? await this.publicWorkbenchWorkspace(workspace, auth.actor) : null + }); + } + + async workbenchWorkspacePayload(workspace, actor) { + return { + ok: true, + status: "succeeded", + contractVersion: "workbench-workspace-v1", + actor: publicActor(actor), + workspace: await this.publicWorkbenchWorkspace(workspace, actor) + }; + } + + async publicWorkbenchWorkspace(workspace, actor) { + const conversation = workspace?.selectedConversationId + ? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId) + : null; + const selectedDevicePod = workspace?.selectedDevicePodId + ? await this.store.getVisibleDevicePod(actor, workspace.selectedDevicePodId) + : null; + return publicWorkbenchWorkspace(workspace, { conversation, selectedDevicePod }); + } + + async visibleConversationForActor(actor, conversationId, projectId = "") { + if (!safeConversationIdLocal(conversationId)) return null; + const sessions = await this.store.listAgentSessionsForUser?.({ + ownerUserId: actor.id, + actorRole: actor.role, + ownerScoped: true, + conversationId, + projectId: textOr(projectId, ""), + limit: 20, + now: this.now() + }) ?? []; + return conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; + } + async requireGrantTargets({ devicePodId, userId }) { const pod = await this.store.getDevicePod(devicePodId); if (!pod || pod.status !== "active") { @@ -1126,6 +1332,7 @@ class MemoryAccessStore { this.leases = new Map(); this.jobs = new Map(); this.agentSessions = new Map(); + this.workspaces = new Map(); } async countUsers() { return this.users.size; } @@ -1229,6 +1436,35 @@ class MemoryAccessStore { .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))) .slice(0, limit); } + async getOrCreateDefaultWorkspace(input = {}) { + const ownerUserId = textOr(input.ownerUserId, ""); + const projectId = textOr(input.projectId, "prj_device_pod_workbench"); + const existing = [...this.workspaces.values()].find((workspace) => workspace.ownerUserId === ownerUserId && workspace.projectId === projectId && workspace.isDefault === true && workspace.status === "active") ?? null; + if (existing) return existing; + const now = input.now ?? this.now(); + const workspace = normalizeWorkspaceRecord({ + id: defaultWorkspaceId(ownerUserId, projectId), + ownerUserId, + projectId, + now + }, null, now, { create: true }); + this.workspaces.set(workspace.id, workspace); + return workspace; + } + async getWorkspaceForUser(input = {}) { + const workspace = this.workspaces.get(textOr(input.workspaceId, "")) ?? null; + if (!workspace) return null; + if (input.actorRole === "admin" || workspace.ownerUserId === input.ownerUserId) return workspace; + return null; + } + async updateWorkspace(input = {}) { + const current = await this.getWorkspaceForUser(input); + if (!current) return null; + const now = input.now ?? this.now(); + const workspace = normalizeWorkspaceRecord(input, current, now, { replaceJson: input.replaceJson === true }); + this.workspaces.set(workspace.id, workspace); + return workspace; + } } class PostgresAccessStore extends MemoryAccessStore { @@ -1339,6 +1575,40 @@ class PostgresAccessStore extends MemoryAccessStore { const result = await this.query(sql, params); return result.rows.map(pgAgentSession); } + async getOrCreateDefaultWorkspace(input = {}) { + await this.ensureSchema(); + const ownerUserId = textOr(input.ownerUserId, ""); + const projectId = textOr(input.projectId, "prj_device_pod_workbench"); + const found = await this.query("SELECT * FROM account_workspaces WHERE owner_user_id = $1 AND project_id = $2 AND is_default = TRUE AND status = 'active' ORDER BY updated_at DESC LIMIT 1", [ownerUserId, projectId]); + const existing = pgWorkspace(found.rows?.[0]); + if (existing) return existing; + const now = input.now ?? this.now(); + const workspace = normalizeWorkspaceRecord({ id: defaultWorkspaceId(ownerUserId, projectId), ownerUserId, projectId, now }, null, now, { create: true }); + const result = await this.query("INSERT INTO account_workspaces (id, owner_user_id, project_id, name, status, is_default, selected_conversation_id, selected_agent_session_id, selected_device_pod_id, active_trace_id, provider_profile, workspace_json, revision, updated_by_session_id, updated_by_client, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT (id) DO UPDATE SET updated_at = account_workspaces.updated_at RETURNING *", workspaceParams(workspace)); + return pgWorkspace(result.rows?.[0]) ?? workspace; + } + async getWorkspaceForUser(input = {}) { + await this.ensureSchema(); + const workspaceId = textOr(input.workspaceId, ""); + const params = [workspaceId]; + let sql = "SELECT * FROM account_workspaces WHERE id = $1"; + if (input.actorRole !== "admin") { + params.push(textOr(input.ownerUserId, "")); + sql += " AND owner_user_id = $2"; + } + sql += " LIMIT 1"; + const result = await this.query(sql, params); + return pgWorkspace(result.rows?.[0]); + } + async updateWorkspace(input = {}) { + await this.ensureSchema(); + const current = await this.getWorkspaceForUser(input); + if (!current) return null; + const now = input.now ?? this.now(); + const workspace = normalizeWorkspaceRecord(input, current, now, { replaceJson: input.replaceJson === true }); + const result = await this.query("UPDATE account_workspaces SET project_id=$3, name=$4, status=$5, is_default=$6, selected_conversation_id=$7, selected_agent_session_id=$8, selected_device_pod_id=$9, active_trace_id=$10, provider_profile=$11, workspace_json=$12, revision=$13, updated_by_session_id=$14, updated_by_client=$15, updated_at=$17 WHERE id=$1 AND ($18 = 'admin' OR owner_user_id=$2) RETURNING *", [...workspaceParams(workspace), textOr(input.actorRole, "user")]); + return pgWorkspace(result.rows?.[0]); + } } function parseDevicePodPath(pathname) { @@ -1411,6 +1681,8 @@ function boundedOutputMaxBytes(searchParams) { return Math.min(Math.max(Number.p function boundedDurationMs(value, fallback = 1000) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : fallback, 1), 60000); } function uartTailArgs(searchParams, uartId) { return { uartId, durationMs: boundedDurationMs(searchParams.get("durationMs") ?? searchParams.get("duration-ms")), maxBytes: boundedOutputMaxBytes(searchParams) }; } function safeAgentSessionId(value) { const text = textOr(value, ""); return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; } +function safeTraceIdLocal(value) { const text = textOr(value, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; } +function safeWorkspaceId(value) { return /^wsp_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); } function leaseTokenFromRequest(request, body) { return textOr(body?.leaseToken, "") || textOr(getHeader(request, "x-hwlab-device-lease-token"), ""); } function sessionExpiresSoon(expiresAt, now) { const expiresMs = Date.parse(String(expiresAt ?? "")); @@ -1464,6 +1736,98 @@ function normalizeAgentSessionOwnerRecord(input, existing, now) { return { id: i function agentSessionIdForRecord(sessionId, traceId) { const sessionText = textOr(sessionId, ""); if (/^ses_[A-Za-z0-9_.:-]+$/u.test(sessionText)) return sessionText; const traceText = textOr(traceId, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(traceText) ? `ses_pending_${traceText.slice(4)}` : null; } function safeConversationIdLocal(value) { return /^cnv_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); } function boundedListLimit(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : 20, 1), 100); } +function defaultWorkspaceId(ownerUserId, projectId) { return `wsp_${sha256(`${ownerUserId}:${projectId}`).slice(0, 24)}`; } +function currentSessionIdFromAuth(auth) { return textOr(auth?.session?.id, null); } +function normalizeWorkspacePatch(body = {}, actor = null) { + const workspace = normalizeObject(body.workspace ?? body.workspaceJson ?? body.snapshot ?? body); + return { + ...workspace, + selectedConversationId: textOr(body.selectedConversationId ?? body.conversationId ?? workspace.selectedConversationId, workspace.selectedConversationId ?? null), + selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId ?? workspace.selectedAgentSessionId) || workspace.selectedAgentSessionId, + selectedDevicePodId: textOr(body.selectedDevicePodId ?? body.devicePodId ?? workspace.selectedDevicePodId, workspace.selectedDevicePodId ?? null), + activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId ?? workspace.activeTraceId) || null, + providerProfile: textOr(body.providerProfile ?? workspace.providerProfile, workspace.providerProfile ?? null), + messages: Array.isArray(body.messages) ? body.messages.slice(-50).map(redactConversationMessage).filter(Boolean) : Array.isArray(workspace.messages) ? workspace.messages : undefined, + actor: actor ? publicActor(actor) : undefined, + secretMaterialStored: false, + valuesRedacted: true + }; +} +function normalizeWorkspaceRecord(input = {}, existing = null, now = new Date().toISOString(), { create = false, replaceJson = false } = {}) { + const patch = normalizeObject(input.patch); + const workspaceJson = replaceJson ? patch : { ...normalizeObject(existing?.workspace), ...patch }; + return { + id: textOr(input.workspaceId ?? input.id, existing?.id ?? (create ? defaultWorkspaceId(input.ownerUserId, input.projectId) : "")), + ownerUserId: textOr(input.ownerUserId, existing?.ownerUserId ?? ""), + projectId: textOr(input.projectId, existing?.projectId ?? "prj_device_pod_workbench"), + name: textOr(input.name, existing?.name ?? "Default Workbench"), + status: input.status === "archived" ? "archived" : existing?.status ?? "active", + isDefault: input.isDefault === false ? false : existing?.isDefault ?? true, + selectedConversationId: nullableText(input.selectedConversationId, existing?.selectedConversationId), + selectedAgentSessionId: nullableText(input.selectedAgentSessionId, existing?.selectedAgentSessionId), + selectedDevicePodId: nullableText(input.selectedDevicePodId, existing?.selectedDevicePodId), + activeTraceId: nullableText(input.activeTraceId, existing?.activeTraceId), + providerProfile: nullableText(input.providerProfile, existing?.providerProfile), + workspace: workspaceJson, + revision: (Number(existing?.revision) || 0) + (existing ? 1 : 1), + updatedBySessionId: nullableText(input.updatedBySessionId, existing?.updatedBySessionId), + updatedByClient: nullableText(input.updatedByClient, existing?.updatedByClient), + createdAt: existing?.createdAt ?? now, + updatedAt: now + }; +} +function nullableText(value, fallback = null) { + if (value === null) return null; + const text = textOr(value, ""); + return text || fallback || null; +} +function publicWorkbenchWorkspace(workspace, { conversation = null, selectedDevicePod = null } = {}) { + if (!workspace) return null; + return { + workspaceId: workspace.id, + ownerUserId: workspace.ownerUserId, + projectId: workspace.projectId, + name: workspace.name, + status: workspace.status, + isDefault: workspace.isDefault, + revision: workspace.revision, + selectedConversationId: workspace.selectedConversationId, + selectedAgentSessionId: workspace.selectedAgentSessionId, + selectedDevicePodId: workspace.selectedDevicePodId, + activeTraceId: workspace.activeTraceId, + providerProfile: workspace.providerProfile, + selectedConversation: conversation, + selectedDevicePod: selectedDevicePod ? publicDevicePod(selectedDevicePod) : null, + workspace: redactedWorkspaceJson(workspace.workspace), + updatedBySessionId: workspace.updatedBySessionId, + updatedByClient: workspace.updatedByClient, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + valuesRedacted: true, + secretMaterialStored: false + }; +} +function redactedWorkspaceJson(value = {}) { + const workspace = normalizeObject(value); + return pruneEmpty({ + selectedConversationId: textOr(workspace.selectedConversationId, ""), + selectedAgentSessionId: textOr(workspace.selectedAgentSessionId, ""), + selectedDevicePodId: textOr(workspace.selectedDevicePodId, ""), + activeTraceId: textOr(workspace.activeTraceId, ""), + providerProfile: textOr(workspace.providerProfile, ""), + sessionStatus: textOr(workspace.sessionStatus, ""), + lastTraceId: textOr(workspace.lastTraceId, ""), + messages: Array.isArray(workspace.messages) ? workspace.messages.slice(-50).map(redactConversationMessage).filter(Boolean) : undefined, + actor: workspace.actor && typeof workspace.actor === "object" ? publicActor(workspace.actor) : undefined, + updatedAt: textOr(workspace.updatedAt, ""), + resetAt: textOr(workspace.resetAt, ""), + secretMaterialStored: false, + valuesRedacted: true + }); +} +function workspaceParams(workspace) { + return [workspace.id, workspace.ownerUserId, workspace.projectId, workspace.name, workspace.status, workspace.isDefault, workspace.selectedConversationId, workspace.selectedAgentSessionId, workspace.selectedDevicePodId, workspace.activeTraceId, workspace.providerProfile, stableJson(workspace.workspace ?? {}), workspace.revision, workspace.updatedBySessionId, workspace.updatedByClient, workspace.createdAt, workspace.updatedAt]; +} function normalizeConversationSnapshot(body = {}, actor = null) { const snapshot = normalizeObject(body.snapshot ?? body); const messagesSource = Array.isArray(body.messages) ? body.messages : snapshot.messages; @@ -1560,6 +1924,7 @@ function pgDevicePod(row) { return { id: row.id, name: row.name, status: row.sta function pgLease(row) { return row ? { devicePodId: row.device_pod_id, holderSessionId: row.holder_session_id, holderUserId: row.holder_user_id, leaseTokenHash: row.lease_token_hash, createdAt: row.created_at, expiresAt: row.expires_at, releasedAt: row.released_at } : null; } function pgJob(row) { return row ? { id: row.id, devicePodId: row.device_pod_id, ownerUserId: row.owner_user_id, status: row.status, intent: row.intent, args: parseJson(row.args_json, {}), reason: row.reason, traceId: row.trace_id, operationId: row.operation_id, output: parseJson(row.output_json, {}), blocker: normalizeBlocker(parseJson(row.blocker_json, null)), createdAt: row.created_at, updatedAt: row.updated_at, completedAt: row.completed_at } : null; } function pgAgentSession(row) { return row ? { id: row.id, projectId: row.project_id, agentId: row.agent_id, status: row.status, startedAt: row.started_at, endedAt: row.ended_at, ownerUserId: row.owner_user_id, conversationId: row.conversation_id, threadId: row.thread_id, lastTraceId: row.last_trace_id, session: parseJson(row.session_json, {}), updatedAt: row.updated_at } : null; } +function pgWorkspace(row) { return row ? { id: row.id, ownerUserId: row.owner_user_id, projectId: row.project_id, name: row.name, status: row.status, isDefault: row.is_default !== false, selectedConversationId: row.selected_conversation_id, selectedAgentSessionId: row.selected_agent_session_id, selectedDevicePodId: row.selected_device_pod_id, activeTraceId: row.active_trace_id, providerProfile: row.provider_profile, workspace: parseJson(row.workspace_json, {}), revision: Number(row.revision ?? 1), updatedBySessionId: row.updated_by_session_id, updatedByClient: row.updated_by_client, createdAt: row.created_at, updatedAt: row.updated_at } : null; } function jobParams(job) { return [job.id, job.devicePodId, job.ownerUserId, job.status, job.intent, stableJson(job.args), job.reason, job.traceId, job.operationId, stableJson(job.output), stableJson(job.blocker ?? {}), job.createdAt, job.updatedAt, job.completedAt]; } function parseJson(value, fallback) { if (!value) return fallback; if (typeof value === "object") return value; try { return JSON.parse(String(value)); } catch { return fallback; } } function normalizeBlocker(value) { return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? value : null; } diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index c9987457..654817f6 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -83,6 +83,8 @@ export async function handleCodeAgentChatHttp(request, response, options) { ownerUserId: options.actor?.id ?? params.ownerUserId, ownerRole: options.actor?.role ?? params.ownerRole }; + const workspaceClaim = await claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response }); + if (workspaceClaim?.blocked) return; if (codeAgentChatShortConnectionRequested(request, params, options)) { submitCodeAgentChatTurn({ @@ -97,6 +99,8 @@ export async function handleCodeAgentChatHttp(request, response, options) { shortConnection: true, controlSemantics: "submit-and-poll", traceId, + workspaceId: workspaceClaim?.workspace?.id ?? null, + workspaceRevision: workspaceClaim?.workspace?.revision ?? null, conversationId: safeConversationId(params.conversationId) || null, sessionId: safeSessionId(params.sessionId) || null, traceUrl, @@ -775,6 +779,82 @@ async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options } } +async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, response } = {}) { + const workspaceId = safeWorkspaceId(params.workspaceId); + if (!workspaceId || !options.accessController?.store || !options.actor) return null; + const store = options.accessController.store; + const workspace = await store.getWorkspaceForUser?.({ workspaceId, ownerUserId: options.actor.id, actorRole: options.actor.role }); + if (!workspace) { + sendJson(response, 404, { + ok: false, + status: "failed", + error: { code: "workbench_workspace_not_found", message: "Workbench workspace is not visible to the current actor" }, + workspaceId + }); + return { blocked: true }; + } + const expectedRevision = Number.parseInt(String(params.expectedWorkspaceRevision ?? params.workspaceRevision ?? ""), 10); + if (Number.isInteger(expectedRevision) && expectedRevision > 0 && workspace.revision !== expectedRevision) { + sendJson(response, 409, { + ok: false, + status: "revision_conflict", + error: { code: "workspace_revision_conflict", message: "Workbench workspace revision changed; restore workspace before submitting a new turn." }, + workspaceId, + expectedRevision, + workspaceRevision: workspace.revision + }); + return { blocked: true }; + } + const activeTraceId = safeTraceId(workspace.activeTraceId); + const activeResult = activeTraceId ? options.codeAgentChatResults?.get(activeTraceId) ?? null : null; + if (activeTraceId && (!activeResult || activeResult.status === "running")) { + sendJson(response, 409, { + ok: false, + status: "workspace_agent_busy", + error: { code: "workspace_agent_busy", message: "Workbench workspace already has an active Code Agent turn; follow the active trace instead of opening a new conversation." }, + workspaceId, + workspaceRevision: workspace.revision, + activeTraceId, + resultUrl: `/v1/agent/chat/result/${encodeURIComponent(activeTraceId)}`, + traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(activeTraceId)}` + }); + return { blocked: true }; + } + const updated = await store.updateWorkspace?.({ + workspaceId, + ownerUserId: options.actor.id, + actorRole: options.actor.role, + selectedConversationId: safeConversationId(params.conversationId) || workspace.selectedConversationId, + selectedAgentSessionId: safeSessionId(params.sessionId) || workspace.selectedAgentSessionId, + activeTraceId: traceId, + providerProfile: textValue(params.providerProfile) || workspace.providerProfile, + patch: { + selectedConversationId: safeConversationId(params.conversationId) || workspace.selectedConversationId, + selectedAgentSessionId: safeSessionId(params.sessionId) || workspace.selectedAgentSessionId, + activeTraceId: traceId, + providerProfile: textValue(params.providerProfile) || workspace.providerProfile, + sessionStatus: "running", + updatedAt: new Date().toISOString(), + source: "code-agent-submit", + valuesRedacted: true, + secretMaterialStored: false + }, + updatedBySessionId: options.authSession?.id ?? null, + updatedByClient: textValue(params.updatedByClient ?? params.client) || "code-agent-chat", + now: new Date().toISOString() + }); + return { workspace: updated ?? workspace }; +} + +function safeWorkspaceId(value) { + const text = textValue(value); + return /^wsp_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; +} + +function textValue(value) { + return String(value ?? "").trim(); +} + function codeAgentSessionOwnerEvidence(payload = {}, params = {}) { return { provider: payload.provider ?? null, diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 04c69c50..d0ac62a4 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -330,6 +330,32 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/workbench/workspace" && request.method === "GET") { + await options.accessController.getWorkbenchWorkspace(request, response, url); + return; + } + + const workbenchWorkspaceMatch = url.pathname.match(/^\/v1\/workbench\/workspace\/([^/]+)(?:\/(events|reset|select-conversation))?$/u); + if (workbenchWorkspaceMatch && !workbenchWorkspaceMatch[2] && (request.method === "PATCH" || request.method === "PUT")) { + await options.accessController.updateWorkbenchWorkspace(request, response, decodeURIComponent(workbenchWorkspaceMatch[1])); + return; + } + + if (workbenchWorkspaceMatch && workbenchWorkspaceMatch[2] === "select-conversation" && request.method === "POST") { + await options.accessController.selectWorkbenchConversation(request, response, decodeURIComponent(workbenchWorkspaceMatch[1])); + return; + } + + if (workbenchWorkspaceMatch && workbenchWorkspaceMatch[2] === "reset" && request.method === "POST") { + await options.accessController.resetWorkbenchWorkspace(request, response, decodeURIComponent(workbenchWorkspaceMatch[1])); + return; + } + + if (workbenchWorkspaceMatch && workbenchWorkspaceMatch[2] === "events" && request.method === "GET") { + await options.accessController.workbenchWorkspaceEvents(request, response, url, decodeURIComponent(workbenchWorkspaceMatch[1])); + return; + } + if (url.pathname === "/v1/access/status" || url.pathname === "/v1/setup/status") { await options.accessController.handleAccessStatusRoute(request, response, url); return; diff --git a/internal/db/migrations/0001_cloud_core_skeleton.sql b/internal/db/migrations/0001_cloud_core_skeleton.sql index 30afaa09..ba7ca171 100644 --- a/internal/db/migrations/0001_cloud_core_skeleton.sql +++ b/internal/db/migrations/0001_cloud_core_skeleton.sql @@ -122,6 +122,28 @@ ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS updated_at TEXT; CREATE INDEX IF NOT EXISTS idx_agent_sessions_owner ON agent_sessions(owner_user_id); CREATE INDEX IF NOT EXISTS idx_agent_sessions_conversation ON agent_sessions(conversation_id); +CREATE TABLE IF NOT EXISTS account_workspaces ( + id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL REFERENCES users(id), + project_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT 'Default Workbench', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + is_default BOOLEAN NOT NULL DEFAULT true, + selected_conversation_id TEXT, + selected_agent_session_id TEXT, + selected_device_pod_id TEXT, + active_trace_id TEXT, + provider_profile TEXT, + workspace_json TEXT NOT NULL DEFAULT '{}', + revision INTEGER NOT NULL DEFAULT 1, + updated_by_session_id TEXT, + updated_by_client TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_account_workspaces_default ON account_workspaces(owner_user_id, project_id) WHERE is_default = TRUE AND status = 'active'; +CREATE INDEX IF NOT EXISTS idx_account_workspaces_owner ON account_workspaces(owner_user_id, project_id, updated_at DESC); + CREATE TABLE IF NOT EXISTS worker_sessions ( id TEXT PRIMARY KEY, agent_session_id TEXT, diff --git a/internal/db/runtime-store.ts b/internal/db/runtime-store.ts index d3a875fc..5bf5c14a 100644 --- a/internal/db/runtime-store.ts +++ b/internal/db/runtime-store.ts @@ -45,6 +45,7 @@ const postgresCountTables = Object.freeze([ "audit_events", "evidence_records", "agent_sessions", + "account_workspaces", "device_pods", "device_pod_grants", "device_leases", @@ -1765,6 +1766,7 @@ function toCountKey(table) { if (table === "audit_events") return "auditEvents"; if (table === "evidence_records") return "evidenceRecords"; if (table === "agent_sessions") return "agentSessions"; + if (table === "account_workspaces") return "accountWorkspaces"; if (table === "device_pods") return "devicePods"; if (table === "device_pod_grants") return "devicePodGrants"; if (table === "device_leases") return "deviceLeases"; diff --git a/internal/db/schema.test.ts b/internal/db/schema.test.ts index a5d2eba7..37ddb7e4 100644 --- a/internal/db/schema.test.ts +++ b/internal/db/schema.test.ts @@ -69,7 +69,7 @@ test("protocol record guards catch schema drift before runtime writes", () => { assertProtocolRecord("gatewaySession", { gatewaySessionId: "gws_01J00000000000000000000000", projectId: "prj_01J00000000000000000000000", - serviceId: "hwlab-gateway-simu", + serviceId: "hwlab-gateway", gatewayId: "gtw_01J00000000000000000000000", endpoint: "http://127.0.0.1:7101", status: "connected", diff --git a/internal/db/schema.ts b/internal/db/schema.ts index 005bc8fe..5e25a693 100644 --- a/internal/db/schema.ts +++ b/internal/db/schema.ts @@ -102,6 +102,25 @@ export const CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS = Object.freeze({ "session_json", "updated_at" ]), + account_workspaces: Object.freeze([ + "id", + "owner_user_id", + "project_id", + "name", + "status", + "is_default", + "selected_conversation_id", + "selected_agent_session_id", + "selected_device_pod_id", + "active_trace_id", + "provider_profile", + "workspace_json", + "revision", + "updated_by_session_id", + "updated_by_client", + "created_at", + "updated_at" + ]), device_pods: Object.freeze([ "id", "name", diff --git a/internal/protocol/index.mjs b/internal/protocol/index.mjs index b1b68ca7..2ec2babf 100644 --- a/internal/protocol/index.mjs +++ b/internal/protocol/index.mjs @@ -28,6 +28,7 @@ export const TABLES = Object.freeze([ "hardware_operations", "audit_events", "agent_sessions", + "account_workspaces", "worker_sessions", "agent_trace_events", "evidence_records", diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 83173642..2b5d8a4e 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -223,19 +223,33 @@ test("hwlab-cli client agent send submits async and polls result", async () => { ], { fetchImpl: async (url, init) => { calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); + if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_device_pod_workbench")) { + return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_test", revision: 3, selectedConversationId: "cnv_test", selectedAgentSessionId: "ses_test", workspace: { threadId: "thread-test" } } }), { status: 200 }); + } if (String(url).endsWith("/v1/agent/chat")) { return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_test", resultUrl: "/v1/agent/chat/result/trc_test" }), { status: 202 }); } + if (String(url).endsWith("/v1/workbench/workspace/wsp_test")) { + return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_test", revision: 5, selectedConversationId: "cnv_test", selectedAgentSessionId: "ses_test", workspace: { threadId: "thread-test", sessionStatus: "completed" } } }), { status: 200 }); + } return new Response(JSON.stringify({ status: "completed", traceId: "trc_test", conversationId: "cnv_test", reply: { role: "assistant", content: "hi" } }), { status: 200 }); }, sleep: async () => {} }); assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[0].init.headers.prefer, "respond-async"); - assert.equal(calls[0].body.shortConnection, true); - assert.equal(calls[1].url, "http://web.test/v1/agent/chat/result/trc_test"); + assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench"); + assert.equal(calls[1].url, "http://web.test/v1/agent/chat"); + assert.equal(calls[1].init.headers.prefer, "respond-async"); + assert.equal(calls[1].body.shortConnection, true); + assert.equal(calls[1].body.workspaceId, "wsp_test"); + assert.equal(calls[1].body.expectedWorkspaceRevision, 3); + assert.equal(calls[1].body.sessionId, "ses_test"); + assert.equal(calls[1].body.threadId, "thread-test"); + assert.equal(calls[2].url, "http://web.test/v1/agent/chat/result/trc_test"); + assert.equal(calls[3].url, "http://web.test/v1/workbench/workspace/wsp_test"); + assert.equal(Object.hasOwn(calls[3].body, "activeTraceId"), true); + assert.equal(calls[3].body.activeTraceId, null); assert.equal(result.payload.traceId, "trc_test"); assert.equal(result.payload.result.body.status, "completed"); assert.equal(result.payload.result.body.assistantText, "hi"); @@ -271,6 +285,9 @@ test("hwlab-cli client agent send preserves Web continuation fields", async () = cwd, fetchImpl: async (url, init) => { calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); + if (String(url).includes("/v1/workbench/workspace?")) { + return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_continue", revision: 7 } }), { status: 200 }); + } return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_web_continue", resultUrl: "/v1/agent/chat/result/trc_web_continue" }), { status: 202 }); }, stdinText: "fallback stdin", @@ -278,12 +295,13 @@ test("hwlab-cli client agent send preserves Web continuation fields", async () = }); assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[0].body.message, "看看有什么device-pod能用?"); - assert.equal(calls[0].body.conversationId, "cnv_web_continue"); - assert.equal(calls[0].body.sessionId, "ses_web_continue"); - assert.equal(calls[0].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); - assert.equal(calls[0].body.retryOf, "trc_previous"); + assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench"); + assert.equal(calls[1].url, "http://web.test/v1/agent/chat"); + assert.equal(calls[1].body.message, "看看有什么device-pod能用?"); + assert.equal(calls[1].body.conversationId, "cnv_web_continue"); + assert.equal(calls[1].body.sessionId, "ses_web_continue"); + assert.equal(calls[1].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(calls[1].body.retryOf, "trc_previous"); assert.equal(result.payload.continuation.webEquivalent, true); assert.equal(result.payload.continuation.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); assert.equal(result.payload.continuation.retryOf, "trc_previous"); @@ -320,6 +338,9 @@ test("hwlab-cli client agent send can replay continuation from an existing trace session: { conversationId: "cnv_source", sessionId: "ses_source", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } }), { status: 200 }); } + if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_device_pod_workbench")) { + return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_replay", revision: 4 } }), { status: 200 }); + } return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_replay_web", resultUrl: "/v1/agent/chat/result/trc_replay_web" }), { status: 202 }); }, sleep: async () => {} @@ -327,11 +348,14 @@ test("hwlab-cli client agent send can replay continuation from an existing trace assert.equal(result.exitCode, 0); assert.equal(calls[0].url, "http://web.test/v1/agent/chat/inspect?traceId=trc_source_web"); - assert.equal(calls[1].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[1].body.conversationId, "cnv_source"); - assert.equal(calls[1].body.sessionId, "ses_source"); - assert.equal(calls[1].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); - assert.equal(calls[1].body.retryOf, "trc_source_web"); + assert.equal(calls[1].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench"); + assert.equal(calls[2].url, "http://web.test/v1/agent/chat"); + assert.equal(calls[2].body.conversationId, "cnv_source"); + assert.equal(calls[2].body.sessionId, "ses_source"); + assert.equal(calls[2].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(calls[2].body.retryOf, "trc_source_web"); + assert.equal(calls[2].body.workspaceId, "wsp_replay"); + assert.equal(calls[2].body.expectedWorkspaceRevision, 4); assert.equal(result.payload.continuation.replayedFromTrace, "trc_source_web"); assert.equal(result.payload.replay.source, "cloud-web:/v1/agent/chat/inspect"); assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 1734c96b..66b6adfa 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; const VERSION = "0.2.0-client"; @@ -14,6 +14,7 @@ const DEFAULT_GATEWAY_SESSION_ID = "gws_D601_F103"; const DEFAULT_GATEWAY_RESOURCE_ID = "res_windows_host"; const DEFAULT_GATEWAY_CAPABILITY_ID = "cap_windows_cmd_exec"; const DEFAULT_GATEWAY_PROJECT_ID = "prj_mvp_topology"; +const DEFAULT_WORKBENCH_PROJECT_ID = "prj_device_pod_workbench"; type EnvLike = Record; type ParsedArgs = Record & { _: string[] }; @@ -84,14 +85,20 @@ function help() { jobTemplate: false, usage: [ "hwlab-cli client auth login --base-url URL --username USER --password-env HWLAB_PASSWORD", + "hwlab-cli client auth login --profile NAME --base-url URL --username USER --password-env HWLAB_PASSWORD", "hwlab-cli client auth status", "hwlab-cli client auth session", + "hwlab-cli client auth profiles", "hwlab-cli client device-pods list", "hwlab-cli client device-pods status device-pod-71-freq", "hwlab-cli client runtime routes", "hwlab-cli client gateway sessions", "hwlab-cli client gateway pressure --gateway-session-id gws_D601_F103 --api-base-url http://API:19667", "hwlab-cli client workbench summary --pod-id device-pod-71-freq", + "hwlab-cli client workbench restore --profile NAME", + "hwlab-cli client workbench status --profile NAME", + "hwlab-cli client workbench watch --profile NAME --after-revision REVISION", + "hwlab-cli client workbench reset --profile NAME --confirm", "hwlab-cli client request GET /v1/access/status [--full]", "hwlab-cli client rpc system.health [--full]", "hwlab-cli client agent send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile deepseek --timeout-ms 120000", @@ -110,6 +117,7 @@ async function authCommand(context: any) { const subcommand = context.rest[0] || "session"; if (subcommand === "login") return authLogin(context); if (subcommand === "status") return authStatus(context); + if (subcommand === "profiles") return authProfiles(context); if (subcommand === "session") { const localSession = sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })); const response = await requestJson({ ...context, method: "GET", path: "/auth/session" }); @@ -133,6 +141,28 @@ async function authStatus(context: any) { }); } +async function authProfiles(context: any) { + const dir = profileStateDir(context.parsed, context.cwd ?? process.cwd()); + let entries: string[] = []; + try { + entries = await readdir(dir); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + const profiles = []; + for (const entry of entries.filter((name) => name.endsWith(".json")).sort()) { + const profile = entry.slice(0, -5); + const state = await readSessionState({ parsed: { ...context.parsed, profile }, env: context.env, cwd: context.cwd ?? process.cwd() }); + profiles.push({ profile, ...sessionStateSummary(state) }); + } + return ok("client.auth.profiles", { + baseUrl: baseUrl(context.parsed, context.env), + profileDir: dir, + profiles, + profileCount: profiles.length + }); +} + async function authLogin(context: any) { const { parsed, env } = context; const username = text(parsed.username ?? env.HWLAB_USERNAME) || "admin"; @@ -775,9 +805,10 @@ async function agentSend(context: any) { if (!message) throw cliError("message_required", "client agent send requires --message or stdin text"); const replay = await resolveAgentReplayContext(context); const traceId = text(parsed.traceId) || makeId("trc"); - const conversationId = text(parsed.conversationId) || replay.conversationId || makeId("cnv"); - const sessionId = text(parsed.sessionId) || replay.sessionId; - const threadId = text(parsed.threadId) || replay.threadId; + const workspaceState = parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true }); + const conversationId = text(parsed.conversationId) || replay.conversationId || text(workspaceState?.selectedConversationId) || makeId("cnv"); + const sessionId = text(parsed.sessionId) || replay.sessionId || text(workspaceState?.selectedAgentSessionId); + const threadId = text(parsed.threadId) || replay.threadId || text(workspaceState?.threadId); const retryOf = text(parsed.retryOf) || replay.retryOf; const continuation = agentContinuationSummary({ conversationId, @@ -796,7 +827,10 @@ async function agentSend(context: any) { providerProfile: text(parsed.providerProfile) || "deepseek", gatewayShellTimeoutMs: numberOption(parsed.gatewayShellTimeoutMs), shortConnection: true, - projectId: text(parsed.projectId) || "prj_device_pod_workbench" + projectId: text(parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, + workspaceId: text(parsed.workspaceId) || text(workspaceState?.workspaceId), + expectedWorkspaceRevision: numberOption(parsed.expectedWorkspaceRevision) ?? workspaceState?.revision, + updatedByClient: "hwlab-cli" }); const accepted = await requestJson({ ...context, @@ -813,10 +847,12 @@ async function agentSend(context: any) { if (!isHttpSuccess(accepted)) { return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary }); } + await saveAcceptedAgentWorkspaceState(context, accepted.body, { traceId, conversationId, sessionId, threadId, providerProfile: requestBody.providerProfile }); if (parsed.noWait === true) { - return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary, waited: false }); + return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary, workspace: workspaceSummaryFromSession(await loadStoredState({ parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), waited: false }); } const result = await pollAgentResult(context, traceId, accepted.body); + await saveCompletedAgentWorkspaceState(context, result.response?.body, { traceId, conversationId, sessionId, threadId, providerProfile: requestBody.providerProfile }); return ok("client.agent.send", { status: result.final ? "succeeded" : "timeout", route: route("POST", "/v1/agent/chat"), @@ -826,6 +862,7 @@ async function agentSend(context: any) { replay: replay.summary, accepted: compactBody(accepted.body), result: result.response ? compactResponse(result.response) : null, + workspace: workspaceSummaryFromSession(await loadStoredState({ parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), polls: result.polls, timeoutMs: result.timeoutMs, resultUrl: result.resultPath @@ -853,6 +890,9 @@ async function pollAgentResult(context: any, traceId: string, acceptedBody: any) async function workbenchCommand(context: any) { const subcommand = context.rest[0] || "summary"; + if (subcommand === "restore" || subcommand === "status") return workbenchRestoreCommand(context, subcommand); + if (subcommand === "watch") return workbenchWatchCommand(context); + if (subcommand === "reset") return workbenchResetCommand(context); if (subcommand !== "summary") throw cliError("unsupported_workbench_command", `unsupported workbench command: ${subcommand}`, { subcommand }); const podId = text(context.parsed.podId) || "device-pod-71-freq"; const encodedPodId = encodeURIComponent(podId); @@ -879,6 +919,38 @@ async function workbenchCommand(context: any) { return ok("client.workbench.summary", { status: failed ? "degraded" : "succeeded", baseUrl: baseUrl(context.parsed, context.env), devicePodId: podId, probes }, failed ? "degraded" : "succeeded"); } +async function workbenchRestoreCommand(context: any, subcommand: string) { + const workspace = await restoreWorkbenchWorkspace(context, { quiet: false }); + return ok(`client.workbench.${subcommand}`, { + baseUrl: baseUrl(context.parsed, context.env), + stateFile: stateFile(context.parsed, context.cwd ?? process.cwd()), + workspace, + localSession: sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })) + }); +} + +async function workbenchWatchCommand(context: any) { + const session = await loadSession({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }); + const workspaceId = text(context.parsed.workspaceId) || text(session?.workspace?.workspaceId); + if (!workspaceId) throw cliError("workspace_restore_required", "workbench watch requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" }); + const afterRevision = numberOption(context.parsed.afterRevision) ?? (Number(session?.workspace?.revision ?? 0) || 0); + const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/events?afterRevision=${encodeURIComponent(String(afterRevision))}`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + if (response.ok && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); + return responsePayload("client.workbench.watch", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) }); +} + +async function workbenchResetCommand(context: any) { + if (context.parsed.confirm !== true) throw cliError("workspace_reset_confirmation_required", "workbench reset requires --confirm", { flag: "--confirm" }); + const session = await loadSession({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }); + const workspaceId = text(context.parsed.workspaceId) || text(session?.workspace?.workspaceId); + if (!workspaceId) throw cliError("workspace_restore_required", "workbench reset requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" }); + const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/reset`; + const response = await requestJson({ ...context, method: "POST", path: pathName, body: { confirm: true, updatedByClient: "hwlab-cli" } }); + if (response.ok && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); + return responsePayload("client.workbench.reset", response, context, { route: route("POST", pathName), body: responseBodyForCli(response.body, context.parsed) }); +} + async function requestCommand(context: any) { const method = requiredText(context.rest[0], "method").toUpperCase(); const pathName = normalizeRequestPath(requiredText(context.rest[1], "path")); @@ -1157,6 +1229,11 @@ async function loadSession({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvL return (await readSessionState({ parsed, env, cwd })).session; } +async function loadStoredState({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) { + const state = await readSessionState({ parsed, env, cwd }); + return state.stored ?? state.session ?? null; +} + async function readSessionState({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) { const file = stateFile(parsed, cwd); const expectedBaseUrl = baseUrl(parsed, env); @@ -1197,6 +1274,7 @@ async function readSessionState({ parsed, env, cwd }: { parsed: ParsedArgs; env: user: safeUser(stored?.user), expiresAt: textOrNull(stored?.expiresAt), updatedAt: textOrNull(stored?.updatedAt), + stored, session: usable ? stored : null }; } @@ -1213,7 +1291,24 @@ async function clearSession(context: any) { } function stateFile(parsed: ParsedArgs, cwd: string) { - return path.resolve(cwd, text(parsed.stateFile) || ".state/hwlab-cli/session.json"); + const explicit = text(parsed.stateFile); + if (explicit) return path.resolve(cwd, explicit); + const profile = profileName(parsed); + if (profile) return path.join(profileStateDir(parsed, cwd), `${profile}.json`); + return path.resolve(cwd, ".state/hwlab-cli/session.json"); +} + +function profileName(parsed: ParsedArgs) { + const value = text(parsed.profile); + if (!value) return ""; + if (!/^[A-Za-z0-9_.:-]+$/u.test(value)) { + throw cliError("invalid_profile_name", "profile may only contain letters, numbers, dot, underscore, colon, and dash", { profile: value }); + } + return value; +} + +function profileStateDir(parsed: ParsedArgs, cwd: string) { + return path.resolve(cwd, ".state/hwlab-cli/profiles", sha256(baseUrl(parsed, {})).slice(0, 16)); } function cookieFromResponse(response: any) { @@ -1336,6 +1431,115 @@ function authNextCommands(authState: any) { ]; } +async function restoreWorkbenchWorkspace(context: any, { quiet = false } = {}) { + const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID; + const pathName = `/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`; + const response = await requestJson({ ...context, method: "GET", path: pathName, timeoutMs: numberOption(context.parsed.restoreTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); + if (!isHttpSuccess(response) || response.body?.ok === false) { + if (quiet) return null; + throw cliError("workspace_restore_failed", "failed to restore workbench workspace", { httpStatus: response.status, body: compactApiBody(response.body) }); + } + const workspace = normalizeWorkbenchWorkspace(response.body?.workspace); + await saveWorkspaceState(context, response.body?.workspace); + return workspace; +} + +async function saveAcceptedAgentWorkspaceState(context: any, acceptedBody: any, fallback: any) { + const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {}; + const existing = session.workspace && typeof session.workspace === "object" ? session.workspace : {}; + const workspace = clean({ + ...existing, + workspaceId: text(acceptedBody?.workspaceId) || text(existing.workspaceId), + revision: numberOption(acceptedBody?.workspaceRevision) ?? existing.revision, + selectedConversationId: text(fallback.conversationId) || text(existing.selectedConversationId), + selectedAgentSessionId: text(fallback.sessionId) || text(existing.selectedAgentSessionId), + activeTraceId: text(acceptedBody?.traceId) || text(fallback.traceId), + providerProfile: text(fallback.providerProfile) || text(existing.providerProfile), + updatedByClient: "hwlab-cli", + updatedAt: new Date().toISOString() + }); + await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace }); +} + +async function saveCompletedAgentWorkspaceState(context: any, resultBody: any, fallback: any) { + const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {}; + const existing = session.workspace && typeof session.workspace === "object" ? session.workspace : {}; + const workspaceId = text(existing.workspaceId); + if (!workspaceId) return; + const status = text(resultBody?.status); + const terminal = status && status !== "running"; + const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`; + const body = clean({ + projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, + expectedRevision: numberOption(existing.revision), + selectedConversationId: text(resultBody?.conversationId) || text(fallback.conversationId) || text(existing.selectedConversationId), + selectedAgentSessionId: text(resultBody?.sessionId) || text(fallback.sessionId) || text(existing.selectedAgentSessionId), + activeTraceId: terminal ? undefined : text(resultBody?.traceId) || text(fallback.traceId), + providerProfile: text(fallback.providerProfile) || text(existing.providerProfile), + sessionStatus: status || undefined, + threadId: text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId), + lastTraceId: text(resultBody?.traceId) || text(fallback.traceId), + messages: resultBody ? compactAgentMessagesForWorkspace(resultBody, fallback) : undefined, + updatedByClient: "hwlab-cli" + }); + if (terminal) body.activeTraceId = null; + const response = await requestJson({ + ...context, + method: "PATCH", + path: pathName, + body, + timeoutMs: DEFAULT_TIMEOUT_MS + }); + if (response.ok && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); +} + +async function saveWorkspaceState(context: any, workspaceBody: any) { + const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {}; + await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace: normalizeWorkbenchWorkspace(workspaceBody), updatedAt: context.now() }); +} + +function normalizeWorkbenchWorkspace(workspace: any) { + if (!workspace || typeof workspace !== "object") return null; + const workspaceJson = workspace.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {}; + return clean({ + workspaceId: text(workspace.workspaceId), + ownerUserId: text(workspace.ownerUserId), + projectId: text(workspace.projectId), + revision: numberOption(workspace.revision), + selectedConversationId: text(workspace.selectedConversationId ?? workspaceJson.selectedConversationId), + selectedAgentSessionId: text(workspace.selectedAgentSessionId ?? workspaceJson.selectedAgentSessionId), + selectedDevicePodId: text(workspace.selectedDevicePodId ?? workspaceJson.selectedDevicePodId), + activeTraceId: text(workspace.activeTraceId ?? workspaceJson.activeTraceId), + providerProfile: text(workspace.providerProfile ?? workspaceJson.providerProfile), + sessionStatus: text(workspaceJson.sessionStatus), + threadId: text(workspaceJson.threadId), + updatedAt: text(workspace.updatedAt), + updatedByClient: text(workspace.updatedByClient), + selectedConversation: workspace.selectedConversation ? compactApiBody(workspace.selectedConversation) : undefined, + valuesRedacted: workspace.valuesRedacted === true, + secretMaterialStored: workspace.secretMaterialStored === true + }); +} + +function workspaceSummaryFromSession(session: any) { + return normalizeWorkbenchWorkspace(session?.workspace); +} + +function compactAgentMessagesForWorkspace(resultBody: any, fallback: any) { + return [{ + id: makeId("msg"), + role: "agent", + title: "Code Agent result", + text: assistantText(resultBody) || text(resultBody?.error?.message) || text(resultBody?.status), + status: text(resultBody?.status) || "unknown", + traceId: text(resultBody?.traceId) || text(fallback.traceId), + conversationId: text(resultBody?.conversationId) || text(fallback.conversationId), + sessionId: text(resultBody?.sessionId) || text(fallback.sessionId), + threadId: text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId), + updatedAt: new Date().toISOString() + }]; +} + function compactProbe(method: string, pathName: string, response: any) { return { ok: isHttpSuccess(response) && response.body?.ok !== false, httpStatus: response.status, route: route(method, pathName), body: compactApiBody(response.body) }; } @@ -1562,6 +1766,8 @@ function compactBody(body: any) { conversationId: body.conversationId, sessionId: body.sessionId, threadId: body.threadId, + workspaceId: body.workspaceId, + workspaceRevision: body.workspaceRevision, accepted: body.accepted, shortConnection: body.shortConnection, resultUrl: body.resultUrl, diff --git a/web/hwlab-cloud-web/app-device-pod.ts b/web/hwlab-cloud-web/app-device-pod.ts index c6018c6c..c691d705 100644 --- a/web/hwlab-cloud-web/app-device-pod.ts +++ b/web/hwlab-cloud-web/app-device-pod.ts @@ -179,12 +179,15 @@ async function submitAgentMessage(value, options = {}) { if (result.availability) { state.codeAgentAvailability = result.availability; } + if (result.workspaceId) state.workspaceId = result.workspaceId; + if (Number.isInteger(result.workspaceRevision)) state.workspaceRevision = result.workspaceRevision; if (!["completed", "source"].includes(updatedMessage?.status)) { el.commandInput.value = value; syncCommandInputHeight(); } renderAgentChatStatus(updatedMessage?.status, state.chatMessages[index]); renderCodeAgentSummary(); + persistCodeAgentSessionState(); } catch (error) { stopTraceStream(); if (state.canceledTraces.has(traceId)) return; @@ -226,6 +229,7 @@ async function submitAgentMessage(value, options = {}) { syncCommandInputHeight(); renderAgentChatStatus(failedStatus, state.chatMessages[index]); renderCodeAgentSummary(); + persistCodeAgentSessionState(); } finally { if (state.currentRequest?.traceId === traceId) { state.currentRequest = null; @@ -800,7 +804,10 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI providerProfile: CODE_AGENT_PROVIDER_PROFILE, gatewayShellTimeoutMs: GATEWAY_SHELL_TIMEOUT_MS, shortConnection: true, - projectId: WORKBENCH_PROJECT_ID + projectId: WORKBENCH_PROJECT_ID, + workspaceId: state.workspaceId, + expectedWorkspaceRevision: state.workspaceRevision, + updatedByClient: "cloud-web" }) }); @@ -811,6 +818,8 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI const error = agentErrorFromHttpResponse(response, traceId); throw error; } + if (response.data?.workspaceId) state.workspaceId = response.data.workspaceId; + if (Number.isInteger(response.data?.workspaceRevision)) state.workspaceRevision = response.data.workspaceRevision; if (response.status === 202 || response.data?.shortConnection === true) { return waitForAgentMessageResult(traceId, response.data); } diff --git a/web/hwlab-cloud-web/app.ts b/web/hwlab-cloud-web/app.ts index adef2b3c..61bb988d 100644 --- a/web/hwlab-cloud-web/app.ts +++ b/web/hwlab-cloud-web/app.ts @@ -173,15 +173,19 @@ const el = { let activeWorkbenchDialog = null; -await ensureWorkbenchAuth({ +const workbenchAuthSession = await ensureWorkbenchAuth({ loginShell: el.loginShell, appShell: el.shell }); +const workbenchActorId = nonEmptyString(workbenchAuthSession?.user?.id ?? workbenchAuthSession?.actor?.id ?? workbenchAuthSession?.user?.username); const state = { conversationId: null, sessionId: null, threadId: null, + workspaceId: null, + workspaceRevision: null, + workspaceUpdatedAt: null, codeAgentAvailability: null, chatMessages: [], traceStreams: new Map(), @@ -408,6 +412,10 @@ function restoreCodeAgentSessionState() { payload = null; } if (!payload || payload.version !== CODE_AGENT_SESSION_STORAGE_VERSION) return; + if (workbenchActorId && payload.actorId && payload.actorId !== workbenchActorId) { + clearCodeAgentSessionState(); + return; + } const updatedAtMs = Number(payload.updatedAtMs); if (!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs > CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS) { clearCodeAgentSessionState(); @@ -416,6 +424,9 @@ function restoreCodeAgentSessionState() { state.conversationId = nonEmptyString(payload.conversationId); state.sessionId = nonEmptyString(payload.sessionId); state.threadId = nonEmptyString(payload.threadId); + state.workspaceId = nonEmptyString(payload.workspaceId); + state.workspaceRevision = Number.isInteger(payload.workspaceRevision) ? payload.workspaceRevision : null; + state.workspaceUpdatedAt = nonEmptyString(payload.workspaceUpdatedAt); state.sessionStatus = nonEmptyString(payload.sessionStatus); state.chatMessages = Array.isArray(payload.chatMessages) ? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean) @@ -477,7 +488,11 @@ function persistCodeAgentSessionState() { function codeAgentSessionSnapshotPayload() { return { version: CODE_AGENT_SESSION_STORAGE_VERSION, + actorId: workbenchActorId, updatedAtMs: Date.now(), + workspaceId: state.workspaceId, + workspaceRevision: state.workspaceRevision, + workspaceUpdatedAt: state.workspaceUpdatedAt, conversationId: state.conversationId, sessionId: state.sessionId, threadId: state.threadId, @@ -487,19 +502,21 @@ function codeAgentSessionSnapshotPayload() { } async function hydrateAccountCodeAgentSessionState() { - const response = await fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}&limit=1`, { + const response = await fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}`, { timeoutMs: Math.min(API_TIMEOUT_MS, 5000), - timeoutName: "Code Agent account conversation hydrate" + timeoutName: "Workbench workspace hydrate" }); if (!response.ok) return; - const conversation = response.data?.defaultConversation ?? (Array.isArray(response.data?.conversations) ? response.data.conversations[0] : null); - if (!conversation) { + applyWorkbenchWorkspace(response.data?.workspace); + const conversation = response.data?.workspace?.selectedConversation ?? null; + const payload = conversation + ? accountConversationToStoredSessionPayload(conversation) + : workspaceToStoredSessionPayload(response.data?.workspace); + if (!payload) { accountConversationHydrated = true; if (state.conversationId) persistCodeAgentSessionState(); return; } - const payload = accountConversationToStoredSessionPayload(conversation); - if (!payload) return; const shouldReplace = !state.conversationId || Number(payload.updatedAtMs) > latestLocalCodeAgentSessionUpdatedAt(); if (!shouldReplace) { accountConversationHydrated = true; @@ -509,6 +526,9 @@ async function hydrateAccountCodeAgentSessionState() { state.conversationId = nonEmptyString(payload.conversationId); state.sessionId = nonEmptyString(payload.sessionId); state.threadId = nonEmptyString(payload.threadId); + state.workspaceId = nonEmptyString(payload.workspaceId) ?? state.workspaceId; + state.workspaceRevision = Number.isInteger(payload.workspaceRevision) ? payload.workspaceRevision : state.workspaceRevision; + state.workspaceUpdatedAt = nonEmptyString(payload.workspaceUpdatedAt) ?? state.workspaceUpdatedAt; state.sessionStatus = nonEmptyString(payload.sessionStatus); state.chatMessages = Array.isArray(payload.chatMessages) ? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean) @@ -522,6 +542,41 @@ async function hydrateAccountCodeAgentSessionState() { window.setTimeout(refreshRestoredCodeAgentTraces, 0); } +function applyWorkbenchWorkspace(workspace) { + if (!workspace || typeof workspace !== "object") return null; + state.workspaceId = nonEmptyString(workspace.workspaceId) ?? state.workspaceId; + state.workspaceRevision = Number.isInteger(workspace.revision) ? workspace.revision : state.workspaceRevision; + state.workspaceUpdatedAt = nonEmptyString(workspace.updatedAt) ?? state.workspaceUpdatedAt; + state.conversationId = nonEmptyString(workspace.selectedConversationId) ?? nonEmptyString(workspace.workspace?.selectedConversationId) ?? state.conversationId; + state.sessionId = nonEmptyString(workspace.selectedAgentSessionId) ?? nonEmptyString(workspace.workspace?.selectedAgentSessionId) ?? state.sessionId; + state.sessionStatus = nonEmptyString(workspace.workspace?.sessionStatus) ?? state.sessionStatus; + CODE_AGENT_PROVIDER_PROFILE = normalizeCodeAgentProviderProfile(workspace.providerProfile ?? workspace.workspace?.providerProfile ?? CODE_AGENT_PROVIDER_PROFILE); + syncCodeAgentTimeoutControl(); + if (workspace.selectedDevicePodId) state.devicePod.selectedDevicePodId = workspace.selectedDevicePodId; + return workspace; +} + +function workspaceToStoredSessionPayload(workspace) { + if (!workspace || typeof workspace !== "object") return null; + const workspaceJson = workspace.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {}; + const conversationId = nonEmptyString(workspace.selectedConversationId ?? workspaceJson.selectedConversationId); + if (!conversationId) return null; + const updatedAtMs = Date.parse(workspace.updatedAt ?? workspaceJson.updatedAt ?? ""); + return { + version: CODE_AGENT_SESSION_STORAGE_VERSION, + actorId: workbenchActorId, + updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : Date.now(), + workspaceId: nonEmptyString(workspace.workspaceId), + workspaceRevision: Number.isInteger(workspace.revision) ? workspace.revision : null, + workspaceUpdatedAt: nonEmptyString(workspace.updatedAt), + conversationId, + sessionId: nonEmptyString(workspace.selectedAgentSessionId ?? workspaceJson.selectedAgentSessionId), + threadId: nonEmptyString(workspaceJson.threadId), + sessionStatus: nonEmptyString(workspaceJson.sessionStatus), + chatMessages: Array.isArray(workspaceJson.messages) ? workspaceJson.messages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT) : [] + }; +} + function accountConversationToStoredSessionPayload(conversation) { if (!conversation || typeof conversation !== "object") return null; const conversationId = nonEmptyString(conversation.conversationId); @@ -552,6 +607,7 @@ function persistAccountCodeAgentSessionState(payload) { if (!accountConversationHydrated) return; if (!payload.conversationId) return; accountConversationPersistPending = true; + if (state.chatPending) return; if (accountConversationPersistInFlight) return; void flushAccountCodeAgentSessionState(); } @@ -583,12 +639,57 @@ async function flushAccountCodeAgentSessionState() { } }) }); + await persistWorkbenchWorkspace(payload); } } finally { accountConversationPersistInFlight = false; } } +async function persistWorkbenchWorkspace(payload) { + if (!payload.conversationId) return; + if (!state.workspaceId) { + const response = await fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}`, { + timeoutMs: Math.min(API_TIMEOUT_MS, 5000), + timeoutName: "Workbench workspace hydrate before persist" + }); + if (!response.ok) return; + applyWorkbenchWorkspace(response.data?.workspace); + } + if (!state.workspaceId) return; + const response = await fetchJson(`/v1/workbench/workspace/${encodeURIComponent(state.workspaceId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + timeoutMs: Math.min(API_TIMEOUT_MS, 5000), + timeoutName: "Workbench workspace persist", + body: JSON.stringify({ + projectId: WORKBENCH_PROJECT_ID, + expectedRevision: state.workspaceRevision, + selectedConversationId: payload.conversationId, + selectedAgentSessionId: payload.sessionId, + selectedDevicePodId: state.devicePod.selectedDevicePodId, + activeTraceId: activeTraceIdFromStoredMessages(payload.chatMessages), + providerProfile: CODE_AGENT_PROVIDER_PROFILE, + sessionStatus: payload.sessionStatus, + threadId: payload.threadId, + lastTraceId: latestTraceIdFromStoredMessages(payload.chatMessages), + messages: payload.chatMessages, + workspace: { + updatedAt: new Date(payload.updatedAtMs).toISOString(), + sessionStatus: payload.sessionStatus, + threadId: payload.threadId, + source: "cloud-web-account-workspace-sync" + }, + updatedByClient: "cloud-web" + }) + }); + if (response.status === 409 && response.data?.workspace) { + applyWorkbenchWorkspace(response.data.workspace); + return; + } + if (response.ok) applyWorkbenchWorkspace(response.data?.workspace); +} + function latestTraceIdFromStoredMessages(messages) { for (const message of [...(messages ?? [])].reverse()) { const traceId = nonEmptyString(message?.traceId ?? message?.runnerTrace?.traceId); @@ -597,6 +698,16 @@ function latestTraceIdFromStoredMessages(messages) { return null; } +function activeTraceIdFromStoredMessages(messages) { + for (const message of [...(messages ?? [])].reverse()) { + const status = String(message?.status ?? "").toLowerCase(); + if (status !== "running") continue; + const traceId = nonEmptyString(message?.traceId ?? message?.runnerTrace?.traceId); + if (traceId) return traceId; + } + return null; +} + function clearCodeAgentSessionState() { if (codeAgentSessionPersistTimer !== null) { window.clearTimeout(codeAgentSessionPersistTimer); diff --git a/web/hwlab-cloud-web/auth.ts b/web/hwlab-cloud-web/auth.ts index 4c1a393d..4149dc89 100644 --- a/web/hwlab-cloud-web/auth.ts +++ b/web/hwlab-cloud-web/auth.ts @@ -107,7 +107,7 @@ async function fetchServerSession() { return { available: true, authenticated: response.ok && payload.authenticated === true, - user: safeUser(payload.user), + user: safeUser(payload.user ?? payload.actor), expiresAt: stringOrNull(payload.expiresAt) }; } catch { @@ -186,7 +186,7 @@ async function attemptServerLogin(username, password) { return { available: true, authenticated: response.ok && payload.authenticated === true, - user: safeUser(payload.user), + user: safeUser(payload.user ?? payload.actor), expiresAt: stringOrNull(payload.expiresAt) }; } catch { @@ -269,7 +269,12 @@ function required(root, selector) { function safeUser(user) { const username = typeof user?.username === "string" && user.username.trim() ? user.username.trim() : DEFAULT_AUTH_USERNAME; - return { username }; + return { + id: stringOrNull(user?.id), + username, + displayName: stringOrNull(user?.displayName), + role: stringOrNull(user?.role) + }; } function stringOrNull(value) {