feat(workbench): share account workspace across web and cli

This commit is contained in:
Codex
2026-06-01 21:26:54 +08:00
parent 3db57429e4
commit b7285b30b1
17 changed files with 1048 additions and 34 deletions
+10
View File
@@ -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/<base-url-hash>/<profile>.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 内 resumeCLI 仍应把 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 <traceId> --message "重试上一条" --provider-profile deepseek --no-wait`,确认 CLI 先访问 `/v1/agent/chat/inspect?traceId=<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 变更不走常驻服务发布流程。 |
@@ -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。 |
@@ -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。 |
+128
View File
@@ -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: {
+365
View File
@@ -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; }
+80
View File
@@ -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,
+26
View File
@@ -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;
@@ -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,
+2
View File
@@ -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";
+1 -1
View File
@@ -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",
+19
View File
@@ -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",
+1
View File
@@ -28,6 +28,7 @@ export const TABLES = Object.freeze([
"hardware_operations",
"audit_events",
"agent_sessions",
"account_workspaces",
"worker_sessions",
"agent_trace_events",
"evidence_records",
+39 -15
View File
@@ -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);
+213 -7
View File
@@ -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<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: 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,
+10 -1
View File
@@ -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);
}
+118 -7
View File
@@ -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);
+8 -3
View File
@@ -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) {