feat(workbench): share account workspace across web and cli
This commit is contained in:
@@ -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 内 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 <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。 |
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -28,6 +28,7 @@ export const TABLES = Object.freeze([
|
||||
"hardware_operations",
|
||||
"audit_events",
|
||||
"agent_sessions",
|
||||
"account_workspaces",
|
||||
"worker_sessions",
|
||||
"agent_trace_events",
|
||||
"evidence_records",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user