docs(research): Gateway 化改造工作计划与范围复杂度评估
Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com> Co-authored-by: Codex powered by ChatGPT-6-Astra <codex@example.com>
This commit is contained in:
parent
b9ea6584dd
commit
6e043389b9
211
cache_research/gateway/astrion_audit/astrion_gateway_gap.md
Normal file
211
cache_research/gateway/astrion_audit/astrion_gateway_gap.md
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
# Astrion Gateway 视角现状盘点(只读审计)
|
||||||
|
|
||||||
|
> 审计范围:工作区根目录 `<workspace>`
|
||||||
|
> 审计时间:2026-09-07(代码快照以当日工作区为主)
|
||||||
|
> 审计方式:只读分析,未修改任何文件
|
||||||
|
> 分析视角:把 Flask server 渐进升级为 "Runtime/Gateway"——session/run/approval/event 的唯一 owner,
|
||||||
|
> Web/CLI/Desktop/Android 仅作状态投影。以下结论均以具体文件/函数/类名佐证。
|
||||||
|
>
|
||||||
|
> 说明:本报告为“现状盘点 + 差距清单”,属于**静态代码分析结论**,未做运行时验证;
|
||||||
|
> 涉及“重启丢失”“断线行为”等结论源自代码路径推演,标注了不确定度。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. API 表面清单
|
||||||
|
|
||||||
|
### 1.1 蓝图注册(REST 面)
|
||||||
|
|
||||||
|
注册点:`server/app_legacy.py:298-310`(`app.register_blueprint(...)`),共 12 个蓝图:
|
||||||
|
|
||||||
|
| 蓝图 | 定义文件 | 覆盖类别 | 代表性端点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `auth_bp` | `server/auth.py` | 登录/注册/会话/CSRF | `POST /login`(L123)、`POST /host-login`(L223)、`POST /register`(L291)、`GET /api/csrf-token`(L109)、`GET /api/session-status`(L369) |
|
||||||
|
| `files_bp` | `server/files.py` | 文件浏览/上传下载 | `GET /api/files`、`GET/POST /api/gui/files/text` |
|
||||||
|
| `admin_bp` | `server/admin.py` | 管理后台/策略/API 用户 | `GET /api/admin/dashboard`、`GET/POST /api/admin/api-users`、`GET /api/admin/api-users/<u>/token` |
|
||||||
|
| `conversation_bp` | `server/conversation.py` | 对话 CRUD/草稿/压缩/版本化 | `GET/POST /api/conversations`(L571/L639)、`GET /api/conversations/<id>/messages`(L1137)、`GET/PUT /api/input-draft`(L431/L457)、`/api/conversations/<id>/versioning*` |
|
||||||
|
| `chat_bp` | `server/chat/{approval,permission,settings,terminal,files,misc}.py` | 审批/模式/设置/终端/杂项 | `GET/POST /api/permission-mode`(permission.py L138/152)、`GET/POST /api/work-mode`(L404/420)、`GET /api/plan-approvals/pending`(approval.py L92)、`GET /api/socket-token`(terminal.py L64)、`GET /api/gui/monitor_snapshot`(misc.py L62) |
|
||||||
|
| `usage_bp` | `server/usage.py` | 用量配额 | `GET /api/usage` |
|
||||||
|
| `status_bp` | `server/status/{base,app,docker,file_open,git,host_workspace,sandbox}.py` | 健康/状态/项目/工作区/沙箱 | `GET /api/health`(base.py L69)、`GET /api/status`(L90)、`GET /api/projects`(docker.py L91)、`GET /api/host/workspaces`(host_workspace.py L57)、`GET /api/sandbox/status`(sandbox.py L30) |
|
||||||
|
| `tasks_bp` | `server/tasks/{api,skills,media}.py` | **REST 任务轮询主线** | `POST /api/tasks`(api.py L111)、`GET /api/tasks/<id>?from=idx`(L229,事件轮询核心)、`POST /api/tasks/<id>/cancel`(L284)、`GET /api/conversations/<id>/running-status`(L56,对账接口)、`POST /api/tasks/<id>/runtime_guidance`(L305) |
|
||||||
|
| `api_v1_bp` | `server/api_v1.py` | 面向 API/CLI 的 v1(Bearer token) | `GET /tools`(L24)、`POST /workspaces/<ws>/messages`(L254)、`GET /workspaces/<ws>/conversations`(L346)、`GET /tasks/<id>`(L415)、`GET /models`(L718,**无鉴权**)、`GET /health`(L742,无鉴权) |
|
||||||
|
| `multi_agent_bp` | `server/multi_agent.py` | 多智能体角色/设置/对话创建 | `POST /api/multiagent/conversations`(L288)、`GET /api/multiagent/active_sub_agents`(L483) |
|
||||||
|
| `workflow_page_bp` / `workflow_runtime_bp` | `server/workflow_page.py` / `server/workflow_runtime_api.py` | 工作流页面与运行时 | `POST /api/workflow/activate`、`GET /api/workflow/status`、`GET/PUT/DELETE /api/workflows/<name>` |
|
||||||
|
| `conversation_bootstrap_bp` | `server/conversation_bootstrap.py` | 对话首屏恢复 | `GET /api/conversations/<id>/bootstrap` |
|
||||||
|
|
||||||
|
**REST 为主**:AGENTS.md §1 明示 "REST 任务轮询为主,Socket.IO 主要用于兼容与实时辅助通道";`server/tasks/api.py` 注释同("将聊天任务与 WebSocket 解耦,支持后台运行与轮询")。
|
||||||
|
|
||||||
|
### 1.2 Socket.IO 事件面
|
||||||
|
|
||||||
|
注册点:`server/socket_handlers.py`(全部 `@socketio.on(...)`)。Socket=连接面,**不是业务消息主通道**:
|
||||||
|
|
||||||
|
- **客户端→服务端**:`connect`(L20,携带 `socket_token` 认证)、`disconnect`(L79)、`stop_task`(L130)、`terminal_subscribe`(L154)、`terminal_unsubscribe`(L199)、`get_terminal_output`(L208)、`send_message`(L241,**已废弃**,直接返回 `DEPRECATED` 提示)、`client_chunk_log`(L338)、`client_stream_debug_log`(L358)。
|
||||||
|
- **服务端→客户端(房间规则,见 §4)**:
|
||||||
|
- 用户房间广播:`user_{username}`(任务/流事件、通知),`user_{username}_terminal`(终端事件)
|
||||||
|
- 终端专属房:`user_{username}_terminal_{session_name}`(订阅某个持久终端)
|
||||||
|
- `terminal_subscribers` 全局房(`app_legacy.py:terminal_broadcast`,L1039-1048)
|
||||||
|
- 全局广播(不带房间):`token_update` / `todo_updated` / `edited_files_updated`
|
||||||
|
|
||||||
|
**业务事件类型**(emit 点见 §2):
|
||||||
|
- 流式:`thinking_start/thinking_chunk/thinking_end`、`text_start/text_chunk/text_end`、`tool_intent/tool_preparing/tool_status/tool_start/update_action`、`api_request_start`、`stream_reset`、`error`、`task_stopped`
|
||||||
|
- 任务:`user_message`、`task_complete`、`system_message`、`quota_notice/quota_exceeded`
|
||||||
|
- 审批:`tool_approval_required/tool_approval_resolved`、`plan_approval_required/plan_approval_resolved`
|
||||||
|
- 系统:`system_ready`、`status_update`、`conversation_changed`、`conversation_list_update`、`conversation_resolved`、`conversation_loaded`、`token_update`、`context_warning`、`todo_updated`、`edited_files_updated`、`terminal_list_update`、`terminal_started`、`terminal_history`、`terminal_output_history`、`compression_state/compression_finished`、`trim_memory_message` 等
|
||||||
|
|
||||||
|
结论:**API 面 = 12 个蓝图 REST(其中 tasks_bp 是轮询主线)+ 1 个 Socket.IO 辅助通道**;消息发送与进度事件均已收敛到 `POST/GET /api/tasks` 轮询模型,Socket 通道退化为“实时补充 + 终端 + 系统广播”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 事件通道现状
|
||||||
|
|
||||||
|
### 2.1 agent loop 进度事件的两条出口
|
||||||
|
|
||||||
|
**单一 sender 抽象**:任务线程内定义局部 `sender(event_type, data)`,把【写入事件流】与【Socket 实时推送】合并为一个动作:
|
||||||
|
|
||||||
|
- 主任务:`server/tasks/models.py::TaskManager._run_chat_task` 内 `def sender`(L907-931):
|
||||||
|
1. `self._append_event(rec, event_type, data)`——写入任务事件流(供轮询)
|
||||||
|
2. `socketio.emit(event_type, data, room=f"user_{username}")`——同键实时推送
|
||||||
|
- Socket 兼容链路 `server/socket_handlers.py::handle_message` 里的 `send_to_client/send_with_activity`(L306/L324,仅发 socket、不写事件流)——**但该入口已废弃**。
|
||||||
|
- 通知池轮询器 `server/chat_flow_task_main.py::poll_completion_notifications`(L960 附近)与 `poll_multi_agent_notifications`(L1115 附近)各自内联 `sender`(只 socket,不写 task events;其中前置通知的**消息本身**随后续任务经 `_dispatch_completion_user_notice`/`inject_multi_agent_master_message` 注入历史/事件流,见 AGENTS.md §11.3)。
|
||||||
|
- 标题后台生成:`server/chat_flow_helpers.py::generate_conversation_title_background`(L100 起)——找到 running task 时 `task_manager._append_event(running_task, 'conversation_changed', ...)`(L105-120);**若没有 running task 则只走 socket 广播(L130-138),REST-only 客户端收不到**。
|
||||||
|
|
||||||
|
### 2.2 事件流存储(idx/offset 机制)
|
||||||
|
|
||||||
|
实现文件:**`server/tasks/models.py`**:
|
||||||
|
|
||||||
|
- 存储:`TaskRecord.events: deque(maxlen=20000)`(L82),纯**进程内内存**,随任务存活;`TaskManager.cleanup_old_tasks(max_age_seconds=3600)`(L108)1 小时清理已完成任务。
|
||||||
|
- 序号:`TaskRecord.next_event_idx`(L92),`_append_event`(L762-782)为每条事件分配单调递增 `idx`(从 0 起,`{idx, type, data, ts}`)。**序号是 per-task 的,任务之间无全局序号**。
|
||||||
|
- 消费:`TaskManager.get_events_since(rec, offset)`(L226-234)在锁内快照后过滤 `e["idx"] >= offset`。
|
||||||
|
- API:`GET /api/tasks/<task_id>?from=<offset>`(`server/tasks/api.py::get_task_api` L229)→ 返回 `events + next_offset`(=最后一条 idx+1;无新事件则原样返回 offset)。
|
||||||
|
- 并发保护:追加与快照都在 `threading.Lock` 下,注释明确说明 deque 并发迭代会 `RuntimeError`(L227-231)。
|
||||||
|
|
||||||
|
### 2.3 断线重连后客户端如何追数据(三层机制)
|
||||||
|
|
||||||
|
1. **事件流偏移继续**:Web 前端 `static/src/stores/task.ts` 每 250ms 轮询 `?from=next_offset`(L167-177,`pollingIntervalMs: 250`);CLI `cli/src/api.ts::pollTask`(L233)+ `cli/src/App.tsx::pollTask`(L491-516,700ms 间隔)。轮询与 socket 相互独立,socket 断线期间错过的块由下一次轮询补齐。
|
||||||
|
2. **对账接口兜底**:`GET /api/conversations/<id>/running-status`(`server/tasks/api.py` L56-108)聚合主 task/子智能体/后台命令/多智能体四类“正在运行”状态;前端每 2.5s 调一次 `startRunningStateReconcile`(`static/src/app/methods/taskPolling/probe.ts` L47-120)。若发现服务端有活动主任务而本地没在轮询 → `resumeTask(main_task_id, { resetOffset: true })` **从 0 全量重放事件**。
|
||||||
|
3. **前端去重**:`_processedEventIndices: Set`(`lifecycle.ts` L105-120 附近)以 `task_id:idx` 为 key 去重,重放事件第二次出现直接跳过。
|
||||||
|
4. **Socket 重连**:`static/src/composables/useLegacySocket.ts` — `reconnect_attempt` 时重新取一次性 socket-token(L588-597),`connect` 后 `resetAllStates` + `scheduleHistoryReload`(L599-624)从 REST 重拉对话历史。
|
||||||
|
|
||||||
|
**结论**:
|
||||||
|
- 有“每任务单调 idx”,**没有全局事件序号**;socket 事件不携带 idx,两条通道的合并去重完全靠前端(轮询模式甚至直接跳过 socket 流事件,`useLegacySocket.ts`:`if (ctx.usePollingMode && !ctx.waitingForSubAgent) return;`)。
|
||||||
|
- 事件流是**任务作用域 + 内存 + 1h TTL + 20000 条上限**,任务结束后即失去可追性;重启服务后所有事件流丢失,客户端只能靠对话文件重建最终态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 状态归属清单(“真状态”在哪)
|
||||||
|
|
||||||
|
| 状态类别 | 真状态位置 | 佐证(文件:函数/行) |
|
||||||
|
|---|---|---|
|
||||||
|
| **对话历史(持久)** | 文件:`{data_dir}/conversations/{workspace_id}/{conversation_id}.json` + `index.json` | `utils/conversation_manager/base.py` L46/L54-55(conversations_root/index_file)、`metadata_mixin.py` L57(`f"{conversation_id}.json"`)、`crud_mixin.py` L298/L589(save/load_conversation) |
|
||||||
|
| **对话历史(运行时缓存)** | 每对话一个 WebTerminal 实例的 `context_manager.conversation_history` 内存列表 | `core/web_terminal.py` L673(`conversation_count: len(self.context_manager.conversation_history)`) |
|
||||||
|
| **终端缓存(key 设计)** | `server/state.py::user_terminals` 进程 dict,key=`username::workspace_id::conversation_id`(不传 conversation_id 时两段 key) | `server/context.py::_make_terminal_key` L57-70、`get_user_resources` L226-236 等;24h TTL 回收 `context.py` L856-935(`CONVERSATION_TERMINAL_TTL_SECONDS`) |
|
||||||
|
| **任务状态** | `server/tasks/models.py::TaskManager._tasks` 进程内 dict(`threading.Lock` 保护),TaskRecord 含 status/events/next_event_idx;1h 清理 | `models.py` L101-107(TaskManager.__init__)、L108(cleanup_old_tasks);**进程重启丢失** |
|
||||||
|
| **审批状态(tool / user-question / plan)** | 三个**进程内单例**:`server/state.py` L30-32 `tool_approval_manager / user_question_manager / plan_approval_manager`;各自 `_items` dict + lock;绑定 `username + conversation_id` | `modules/tool_approval_manager.py` L11-30(create_request 字段)、`modules/plan_approval_manager.py` L22-58、`modules/user_question_manager.py`;**重启丢失** |
|
||||||
|
| **权限模式 / 执行环境 / 网络权限** | WebTerminal 实例属性(`current_permission_mode`、`current_execution_mode`、`host_network_permission`、`pending_*` 排队态);对话 metadata 持久化(`pre_plan_*`、`pre_readonly_execution_mode` 等);个性化默认值 | `core/main_terminal_parts/tools_policy.py` L236-330(get/set/switch_work_mode、set_permission_mode);`core/main_terminal.py` L290-326(get_pending_runtime_modes / queue_*);AGENTS.md §10.6 |
|
||||||
|
| **work_mode(plan/ask/execute)** | 同上:terminal 实例 `current_work_mode` + 对话 metadata `work_mode` + 个性化 `default_work_mode` | `tools_policy.py` L256-330;`server/chat/permission.py` L404-420(`GET/POST /api/work-mode`,运行中 409) |
|
||||||
|
| **子智能体任务** | `SubAgentManager`(`modules/sub_agent/manager.py` L52-105)挂在对话级 WebTerminal 上:内存 `tasks`/`_running_tasks`/`conversation_agents` + 文件 `{data_dir}/sub_agents.json`、`{data_dir}/sub_agent_tasks/` | manager.py L63-64(state_file/base_dir)、L105-122(_load_state/reconcile/restore) |
|
||||||
|
| **多智能体会话运行态** | `GLOBAL_MULTI_AGENT_STATES: Dict[conversation_id, MultiAgentState]` **进程级注册表**(特意从 manager 实例属性提升,根治多副本分裂) | `modules/multi_agent/state.py` L668-677(注册表 + RLock),L262-277(MultiAgentState.__init__:conversation_id/pending_master_messages),L605-612(to_snapshot) |
|
||||||
|
| **模型选择** | 对话 metadata `model_key` 为权威(对话级 terminal 加载恢复)+ terminal 实例 `model_key` + flask session `model_key`(仅工作区级) | `core/web_terminal.py` L503-508(加载对话模式)、L272-315(create 路径);`core/main_terminal.py` L105-106(默认模型) |
|
||||||
|
| **personalization** | 文件:每工作区 `{data_dir}/personalization.json`(`default_run_mode`/`default_permission_mode`/`default_work_mode`/`review_agents` 等) | `modules/personalization_manager.py` L36(`PERSONALIZATION_FILENAME`)、L196(load_personalization_config);`server/chat/settings.py` L279-327(`GET/POST /api/personalization`) |
|
||||||
|
| **运行时模式(fast/thinking/deep)** | terminal 实例 `run_mode`/`thinking_mode`/`reasoning_effort` + 会话同步 + drift 通知基线 | `core/web_terminal.py` L618-660(get_status);`server/chat_flow_task_main.py` L1702-1732(collect_runtime_mode_drift) |
|
||||||
|
| **monitor 快照** | `server/monitor.py` 进程内缓存 `MONITOR_SNAPSHOT_CACHE`(上限 120) | `server/monitor.py` L10-60 |
|
||||||
|
|
||||||
|
总评:**“真状态”分散在 4 层**——①进程内内存对象(terminal、task_manager、三个 approval manager、GLOBAL_MULTI_AGENT_STATES、usage trackers、socket token/stop flags)、②运行态文件(conversation/*.json、sub_agents.json、personalization.json、settings.json)、③Flask session(登录态、workspace_id、model_key 等)、④前端本地(渲染消息列表、输入草稿 `input-draft` 文件在 `server/conversation.py` L298-327、对话类型 localStorage)。目前没有一个“系统级 owner”统一持有 session/run/approval/event。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 多客户端假设排查
|
||||||
|
|
||||||
|
### 4.1 Socket.IO room / 命名空间的使用(无 conversation 级隔离)
|
||||||
|
|
||||||
|
- 房间全部以**用户**为粒度:`user_{username}`、`user_{username}_terminal`、`user_{username}_terminal_{session_name}`、全局 `terminal_subscribers`(`server/socket_handlers.py` L62-73、L154-198;`server/app_legacy.py` L1039-1048)。
|
||||||
|
- **没有 per-conversation room/命名空间**。对话级终端事件靠 `server/context.py::_wrap_callback_with_conversation_id`(L72-86)把 `conversation_id` 塞进事件 payload,由**前端自行过滤**(`useLegacySocket.ts` 每个 handler 开头 `if (data?.conversation_id && data?.conversation_id !== ctx.currentConversationId) return;`)。
|
||||||
|
- 这意味着服务端不知道“哪个客户端正在看哪个对话”,广播只能“发给全用户,客户端自己挑”。多客户端(尤其不同对话的多标签页)会收到彼此对话的全部事件流量。
|
||||||
|
|
||||||
|
### 4.2 终端会话绑定
|
||||||
|
|
||||||
|
- socket 事件处理都通过 `get_terminal_for_sid` → `get_user_resources(username, conversation_id=...)` 解析终端(`server/socket_handlers.py` L156/L211);`terminal_subscribe` 带 `conversation_id` 只是用于“选对终端实例”,房间名仍是用户级。
|
||||||
|
- 持久终端(`TerminalManager`)挂在**对话级 WebTerminal** 上,key=`username::workspace_id::conversation_id`(§3)——**同对话的多个客户端共享同一个 WebTerminal 实例**,这方向正确,但该实例是多写者可变对象:`attach_user_broadcast(terminal, username)` 在**每个请求**都会重绑回调(`context.py` L509 等),多端并发请求会互相重置 terminal 上的会话级状态(已用 conversation-bound 保护模型,但模式/message_callback 仍是共享可变面)。
|
||||||
|
|
||||||
|
### 4.3 socket token 发放互踩(多客户端缺陷)
|
||||||
|
|
||||||
|
`server/chat/terminal.py::issue_socket_token`(L62-80):先 `prune_socket_tokens()`,然后**清空该用户名下所有旧 pending token**,再发一个新 token(45s TTL,`SOCKET_TOKEN_TTL_SECONDS`)。同一用户两个客户端几乎同时请求 socket-token 并握手时,后发请求会让先发 token 失效,导致先连的 socket 认证失败。单用户单端假设的残留。
|
||||||
|
|
||||||
|
### 4.4 断线即停任务假设
|
||||||
|
|
||||||
|
`server/socket_handlers.py::handle_disconnect`(L77-125):只有“同用户**没有其他活跃连接**且**没有 REST running 任务**”时才置停标志/取消任务。这隐含“socket 是任务生命周期的一部分”的旧假设;当前主链路任务实际由 REST 发起(task_id 级 stop flag,`server/state.py::make_stop_keys` L140-155),socket 侧的 stop 只是兼容索引。Gateway 化后任务必须与任何客户端连接解耦。
|
||||||
|
|
||||||
|
### 4.5 审批弹窗状态
|
||||||
|
|
||||||
|
- 审批请求体绑定 `username + conversation_id`(非 sid):`modules/tool_approval_manager.py::create_request` L16-45 —— 方向上利于任意同用户端审批(CLI 也能通过事件流 `tool_approval_required` 渲染审批 UI,`cli/src/App.tsx` L503-512)。
|
||||||
|
- 但**等待方**是任务线程对进程内存 dict 的轮询(`server/chat_flow_tool_loop.py::_wait_for_tool_approval` L232-244、`_wait_for_plan_approval` L294-303),审批状态无文件/DB 持久化,**服务重启后 pending 审批全部丢失**,等待中的任务线程只能得到 `approval_missing` 拒绝结果。
|
||||||
|
- 前端弹窗 UI(`PlanApprovalDialog.vue` 等)是各端本地渲染;多端同时打开同一对话时,审批事件会广播给所有端,谁先 answer 谁生效(同 username 校验,`tool_approval_manager.py::decide` L78-91),无“审批被某端认领”的概念。
|
||||||
|
|
||||||
|
### 4.6 stream 进行中的对话切换
|
||||||
|
|
||||||
|
前端已做双向防护(这是现状中做得最完整的部分):
|
||||||
|
- `static/src/app/methods/taskPolling/lifecycle.ts`:conversation_id 不匹配丢弃(L50-90)、task_id 不匹配丢弃(L96-133)、`_processedEventIndices` 去重(L105-120)。
|
||||||
|
- `static/src/stores/task.ts` L223-236:轮询在途响应若发现 `currentTaskId` 已变则整包丢弃(stale-response-ignored)。
|
||||||
|
- `static/src/app/methods/conversation/action.ts` L99:“创建新对话只切换视图,不再取消后台任务;停止当前轮询避免事件串写”。
|
||||||
|
- 回到正在运行的对话时由 2.5s 对账循环 `resumeTask(resetOffset: true)` 全量重放(§2.3)。
|
||||||
|
|
||||||
|
### 4.7 其它隐含“单客户端”的点
|
||||||
|
|
||||||
|
- **输入草稿**:`/api/input-draft` 以 username/workspace 为粒度写文件(`server/conversation.py::_resolve_input_draft_path` L298-309),多端同用户共用会互相覆盖。
|
||||||
|
- **`active_polling_tasks: Dict[conversation_id, bool]`**(`server/state.py` L39)仍以“一个对话一个轮询者”建模。
|
||||||
|
- **CSS/前端状态**:`currentConversationType`/`newConversationType`/`sidebarConversationType` 等 localStorage 键(AGENTS.md §11.0)都是“每浏览器”视角,与服务器状态无关(对投影架构不构成阻塞,但说明端状态模型是单端假设的)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 认证现状
|
||||||
|
|
||||||
|
| 链路 | 实现 | 佐证 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Web 用户体系** | Flask session(cookie)+ `login_nonce` 双因子:`is_logged_in()` 要求 `session.username` 且 nonce 在 `state.active_login_nonces` 集合内;邮箱+密码 `user_manager.authenticate`;IP/账号维度限流与锁定(5 次/300s) | `server/auth_helpers.py` L15-28;`server/auth.py::login` L123-221;`server/security.py` L101-155 |
|
||||||
|
| **host-login** | `POST /host-login`:仅 `TERMINAL_SANDBOX_MODE=host` 且回环地址(127.0.0.1/::1/localhost)可调;免密创建 admin 会话 `username=host, role=admin, host_mode=True` | `server/auth.py::host_login` L224-270 |
|
||||||
|
| **CSRF** | `X-CSRF-Token` header(或表单字段)比对 session 内 token;`requires_csrf_protection`:`/api/*` 前缀全部受检(`CSRF_PROTECTED_PREFIXES`),`/api/v1/*` 与 `Bearer` 请求豁免;`/login /register /logout /host-login` 受检 | `server/security.py` L157-190;`server/state.py` L103-108(常量) |
|
||||||
|
| **API token(/api/v1)** | `/api/v1/*` 全部 `api_token_required`:`Authorization: Bearer <token>` → SHA256 匹配 `api_user_manager` 存储的 `token_sha256` → 写入 session + `g.api_username`;**两个例外** `GET /api/v1/models`、`GET /api/v1/health` 无鉴权 | `server/api_auth.py` L12-54;`server/api_v1.py` L718/L742(无装饰器) |
|
||||||
|
| **socket 认证** | `GET /api/socket-token` 发一次性 token(45s TTL,绑定 username + User-Agent fingerprint);connect 时 `consume_socket_token` 一次性消费,失败则 disconnect | `server/chat/terminal.py` L64-80;`server/security.py` L191-214;`server/socket_handlers.py::handle_connect` L20-73 |
|
||||||
|
| **CLI 用的链** | host-login(无凭证,回环)→ 拿到 session cookie + CSRF token → 全部走 **Web API(非 /api/v1)**:`/api/status`、`/api/tasks` 轮询、`/host/workspaces` 等;REST 轮询 700ms | `cli/src/api.ts` L38-52(fetchCsrf)、L96(hostLogin)、L226-239(createTask/pollTask)、L310(默认 8091) |
|
||||||
|
|
||||||
|
要点:ClI(及未来 Desktop)复用“会话 cookie + CSRF”的**宿主态**,不是独立的运行时身份;`/api/v1` Bearer token 体系与 Web/CLI 会话体系**并存但互不相通**(API 用户有自己的工作区目录 `api/users/`,见 AGENTS.md §1.5.1)。Android 是 WebView 壳(`android-webview-app/`,Brige 走 `$BASE_URL`),认证跟随 Web 前端会话。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 差距清单(距“任意客户端投影同一状态”还差什么)
|
||||||
|
|
||||||
|
按严重度排序:
|
||||||
|
|
||||||
|
1. **无统一事件序号 / 无事件总线**。事件 idx 是 per-task 的(`TaskRecord.next_event_idx`,`server/tasks/models.py` L762-782),socket 事件不带 idx;任务之间、通道之间无法对齐全局时间序。Gateway 需要对话级(或全局)持久化事件日志 + 全局 seq/游标,客户端按游标订阅/追平。
|
||||||
|
2. **事件流是任务作用域、内存态、会蒸发**。`deque(maxlen=20000)` + 1h cleanup(`server/tasks/models.py` L108/L82):任务结束/进程重启后事件流即失,后续客户端只能靠对话文件重建“最终态”,无法重放“过程”。断线追赶完全依赖前端“从 0 重放 + 去重”的脆机制(`probe.ts::resumeTask(resetOffset)`)。
|
||||||
|
3. **审批/提问/计划批准 = 进程内存 + 绑定 username+conversation_id(无端认领)**。`tool/user_question/plan_approval_manager` 三个单例(`server/state.py` L30-32)重启即失;等待侧在任务线程轮询内存 dict(`server/chat_flow_tool_loop.py` L232-244)。Gateway 需要把 approval 做成持久化实体 + 审计 + 多端可见的“认领/副作用”语义;目前任何同用户端可 answer,但无“哪个端在展示、谁决定”的记录。
|
||||||
|
4. **状态真源分四层、无唯一 owner**(§3 总评)。尤其工作区级 vs 对话级 WebTerminal、`GLOBAL_MULTI_AGENT_STATES`(key=conversation_id)、task_manager、session 四处都可写“运行模式/模型/任务”相关状态,未来任一 owner 化都要先收敛。
|
||||||
|
5. **终端缓存 key 已按对话隔离(好),但实例是共享可变对象**。`username::workspace_id::conversation_id`(`server/context.py::_make_terminal_key`)让同对话多端共享实例;但每请求 `attach_user_broadcast` 重绑回调 + `current_conversation_id`/`model_key`/模式属性可变,多端并发读写下仍有覆盖面(主任务写入已由 `server/main_task_gate.py` 单写者门闸保护,见 AGENTS.md §12——这是 gateway 唯一 owner 的一个雏形)。
|
||||||
|
6. **广播粒度是 user 房间,无 conversation 级订阅/投影**。服务端无法回答“谁在看对话 X”;`_wrap_callback_with_conversation_id` + 前端过滤是补丁不是投影。Gateway 需要 conversation 级 topic/room + 客户端显式订阅。
|
||||||
|
7. **socket-token 发放互踩**(§4.3):`issue_socket_token` 清空同用户旧 token,多标签/多端并发建连会互相踢。
|
||||||
|
8. **“停止/活动”仍假设任务绑定连接**:`handle_disconnect` 的 `has_other_connection` / REST running 任务判断(`server/socket_handlers.py` L77-125);Gateway 下任务生命周期必须独立于任何连接。
|
||||||
|
9. **REST-only 客户端存在事件盲区**:标题更新无 running task 时只走 socket(`server/chat_flow_helpers.py` L130-138);`token_update/todo_updated/edited_files_updated` 是全局广播不一定进任务事件流(terminal_broadcast,`app_legacy.py` L1039-1048)。
|
||||||
|
10. **认证两套并存**:session+CSRF(Web/CLI/host-login)与 Bearer token(/api/v1)不互通;真要“多端以同一身份投影”,需要统一身份/会话模型(token 化 + 会话复用到 socket)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 现状速查表
|
||||||
|
|
||||||
|
| # | 项 | 现状一句话 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 传输主线 | REST 250ms(Web)/700ms(CLI) 轮询 `GET /api/tasks/<id>?from=idx`;Socket.IO 是兼容/辅助通道(`tasks/api.py`、`socket_handlers.py`) |
|
||||||
|
| 2 | 事件序号 | 仅 per-task `next_event_idx`(0 起,deque 20000 上限);**无全局序号**;socket 事件无 idx(`tasks/models.py` L762-782) |
|
||||||
|
| 3 | 断线/换端追赶 | 2.5s `running-status` 对账 → `resumeTask(resetOffset=true)` 从 0 重放 + 前端 `task_id:idx` 去重(`probe.ts`、`lifecycle.ts`) |
|
||||||
|
| 4 | 对话历史 | 文件 `{data}/conversations/{ws}/{conv}.json` 权威;每对话 WebTerminal 内存缓存,24h TTL 回收(`conversation_manager/base.py`、`context.py` L856) |
|
||||||
|
| 5 | 任务状态 | `TaskManager` 进程内 dict,1h 清理、重启丢失(`tasks/models.py` L101-108) |
|
||||||
|
| 6 | 审批状态 | `tool/user_question/plan_approval_manager` 三个进程内单例,绑定 username+conversation_id,重启丢失(`state.py` L30-32) |
|
||||||
|
| 7 | 子智能体/多智能体 | SubAgentManager 挂对话级 terminal(内存+sub_agents.json);MultiAgentState 走进程级 `GLOBAL_MULTI_AGENT_STATES`(key=conv_id)(`sub_agent/manager.py`、`multi_agent/state.py` L668) |
|
||||||
|
| 8 | 模式(work/permission/execution) | terminal 实例属性 + 对话 metadata + personalization.json 默认值;运行中切换用 pending 队列(`tools_policy.py`、`main_terminal.py` L290-326) |
|
||||||
|
| 9 | 认证 | Web=session+login_nonce+CSRF;host-login=回环免密 admin;/api/v1=Bearer token;socket=一次性 token 45s(`auth.py`、`api_auth.py`、`security.py`) |
|
||||||
|
| 10 | 多客户端 | 同用户同对话共享同一 WebTerminal;广播按 user 房间、前端按 conversation_id 过滤;socket-token 并发互踩、无 conversation 级订阅(`socket_handlers.py`、`chat/terminal.py` L64-80) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 附录:本报告结论的不确定度声明
|
||||||
|
- “状态分布”“事件序号”“认证实现”等结论来自**直接代码阅读**(证据如上),确定度最高。
|
||||||
|
- “重启丢失”“断线追数行为”“多标签互踩”等结论是**代码路径推演**,未经运行时复现验证;其中“重启丢失”由类定义(无文件落盘)可基本确证,“socket token 互踩”依赖时序竞争,标注为**很大概率**。
|
||||||
|
- 任务要求只读盘点,未运行服务;如需确认运行时行为,应以实测为准。
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
# Astrion `server/` 196 个 API 端点职责分类盘点报告
|
||||||
|
|
||||||
|
> 任务:运行时边界整理(Gateway/RuntimeService)前置普查 —— 回答「196 个端点里到底有多少需要动」。
|
||||||
|
> 分析方法:只读代码分析(grep 全部 `@<bp>.route` 装饰器后逐个读取端点函数体),未修改任何文件。
|
||||||
|
> 统计口径:以 Flask 路由注册(Methods 合并计数)为准,共 **196** 个,与已知分布完全一致(逐文件核验过)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 结论速览(先给答案)
|
||||||
|
|
||||||
|
| 类别 | 数量 | 占 196 的比例 | 是否要动 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **T1 任务受理**(创建/启动一轮 Agent 任务) | **3** | **1.5%** | ✅ 必须迁移到 RuntimeService 公共入口 |
|
||||||
|
| **T2 任务控制**(取消/停止/审批回答/提问回答) | **13** | **6.6%** | ✅ 必须迁移(收敛为公共控制接口) |
|
||||||
|
| **T3 任务观察**(轮询状态/事件流 idx/offset) | **14** | **7.1%** | ⚠️ 可不迁移:只读 REST 语义,HTTP 适配层保留即可 |
|
||||||
|
| **C CRUD**(会话/工作区/配置/文件/页面) | **131** | **66.8%** | ❌ 不动 |
|
||||||
|
| **S 状态查询**(系统状态/git/docker/usage) | **20** | **10.2%** | ❌ 不动 |
|
||||||
|
| **A 认证管理**(登录/token/用户/API 用户) | **15** | **7.7%** | ❌ 不动 |
|
||||||
|
| **合计** | **196** | 100% | — |
|
||||||
|
|
||||||
|
**核心答案:真正需要「Gateway 化」的最低必要集合 = T1 + T2 = 16 个端点,占 196 的 8.2%。**
|
||||||
|
若把任务观察(T3,共享事件流读取协议)也纳入公共接口设计边界,则为 30 个端点(15.3%),
|
||||||
|
但 T3 中绝大多数只是读内存任务记录/磁盘状态,**可以不动**——观察类本来就是 REST 语义本身,
|
||||||
|
Gateway 只需在 T1 受理时返回 task_id,CLI/定时任务各自复用 T3 的轮询协议即可。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R1 范围)**:此分类用于估算 HTTP 改动面,不能据此判断运行时职责是否已经收敛。保留查询端点/轮询协议与提供内部查询接口不冲突;定时触发器不应被迫通过 HTTP 才能观察任务。迁移完成标准应来自状态责任表和调用链验收,而非固定端点数量。
|
||||||
|
|
||||||
|
10 个 socketio 事件中,与任务运行耦合的仅 2 个:`send_message`(T1,已废弃短路)与 `stop_task`(T2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 分类统计总表
|
||||||
|
|
||||||
|
### 1.1 类别 × 数量 × 文件分布
|
||||||
|
|
||||||
|
| 类别 | 数量 | 分布(文件:数量) |
|
||||||
|
|---|---|---|
|
||||||
|
| **T1 任务受理** | 3 | api_v1.py:1,tasks/api.py:1,workflow_runtime_api.py:1 |
|
||||||
|
| **T2 任务控制** | 13 | tasks/api.py:5,conversation.py:3,chat/approval.py:3,api_v1.py:1,workflow_runtime_api.py:1 |
|
||||||
|
| **T3 任务观察** | 14 | conversation.py:4,tasks/api.py:3,chat/approval.py:3,api_v1.py:1,workflow_runtime_api.py:1,multi_agent.py:1,conversation_bootstrap.py:1 |
|
||||||
|
| **C CRUD** | 131 | conversation.py:26,api_v1.py:19,admin.py:17,multi_agent.py:11,chat/permission.py:10,workflow_page.py:6,status/host_workspace.py:6,chat/settings.py:5,files.py:5,status/docker.py:5,status/file_open.py:4,chat/files.py:3,chat/misc.py:2,status/sandbox.py:1,status/app.py:1,auth.py:7,app_legacy.py:2,tasks/skills.py:1 |
|
||||||
|
| **S 状态查询** | 20 | status/base.py:4,status/git.py:2,status/sandbox.py:2,chat/misc.py:3,status/docker.py:1,status/app.py:1,usage.py:1,admin.py:3,auth.py:1,api_v1.py:1,chat/terminal.py:1 |
|
||||||
|
| **A 认证管理** | 15 | auth.py:8,admin.py:6,chat/terminal.py:1 |
|
||||||
|
| **合计** | **196** | 26 个文件(与任务给定的分布完全一致) |
|
||||||
|
|
||||||
|
### 1.2 按文件展开
|
||||||
|
|
||||||
|
| 文件 | 总数 | T1 | T2 | T3 | C | S | A |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| conversation.py | 33 | 0 | 3 | 4 | 26 | 0 | 0 |
|
||||||
|
| admin.py | 26 | 0 | 0 | 0 | 17 | 3 | 6 |
|
||||||
|
| api_v1.py | 23 | 1 | 1 | 1 | 19 | 1 | 0 |
|
||||||
|
| auth.py | 16 | 0 | 0 | 0 | 7 | 1 | 8 |
|
||||||
|
| multi_agent.py | 12 | 0 | 0 | 1 | 11 | 0 | 0 |
|
||||||
|
| chat/permission.py | 10 | 0 | 0 | 0 | 10 | 0 | 0 |
|
||||||
|
| tasks/api.py | 9 | 1 | 5 | 3 | 0 | 0 | 0 |
|
||||||
|
| workflow_page.py | 6 | 0 | 0 | 0 | 6 | 0 | 0 |
|
||||||
|
| status/host_workspace.py | 6 | 0 | 0 | 0 | 6 | 0 | 0 |
|
||||||
|
| status/docker.py | 6 | 0 | 0 | 0 | 5 | 1 | 0 |
|
||||||
|
| chat/approval.py | 6 | 0 | 3 | 3 | 0 | 0 | 0 |
|
||||||
|
| files.py | 5 | 0 | 0 | 0 | 5 | 0 | 0 |
|
||||||
|
| chat/settings.py | 5 | 0 | 0 | 0 | 5 | 0 | 0 |
|
||||||
|
| chat/misc.py | 5 | 0 | 0 | 0 | 2 | 3 | 0 |
|
||||||
|
| status/file_open.py | 4 | 0 | 0 | 0 | 4 | 0 | 0 |
|
||||||
|
| status/base.py | 4 | 0 | 0 | 0 | 0 | 4 | 0 |
|
||||||
|
| workflow_runtime_api.py | 3 | 1 | 1 | 1 | 0 | 0 | 0 |
|
||||||
|
| status/sandbox.py | 3 | 0 | 0 | 0 | 1 | 2 | 0 |
|
||||||
|
| chat/files.py | 3 | 0 | 0 | 0 | 3 | 0 | 0 |
|
||||||
|
| status/git.py | 2 | 0 | 0 | 0 | 0 | 2 | 0 |
|
||||||
|
| status/app.py | 2 | 0 | 0 | 0 | 1 | 1 | 0 |
|
||||||
|
| chat/terminal.py | 2 | 0 | 0 | 0 | 0 | 1 | 1 |
|
||||||
|
| app_legacy.py | 2 | 0 | 0 | 0 | 2 | 0 | 0 |
|
||||||
|
| usage.py | 1 | 0 | 0 | 0 | 0 | 1 | 0 |
|
||||||
|
| tasks/skills.py | 1 | 0 | 0 | 0 | 1 | 0 | 0 |
|
||||||
|
| conversation_bootstrap.py | 1 | 0 | 0 | 1 | 0 | 0 | 0 |
|
||||||
|
| **合计** | **196** | **3** | **13** | **14** | **131** | **20** | **15** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. T1 / T2 / T3 类端点详细清单
|
||||||
|
|
||||||
|
> 内部调用均指**端点函数体最终触达的运行时函数**。所有任务受理/控制最终收敛到
|
||||||
|
> `server/tasks/models.py` 的单例 `task_manager`(TaskManager):
|
||||||
|
> `create_chat_task` 创建 TaskRecord 并 spawn 后台线程 `_run_chat_task` → `chat_flow.run_chat_task_sync` 执行;
|
||||||
|
> 控制类调 `cancel_task / enqueue_runtime_guidance / enqueue_runtime_pending_message / remove_runtime_pending_message / promote_runtime_pending_to_guidance`;
|
||||||
|
> 观察类调 `get_task / get_events_since / list_tasks / get_conversation_running_status`。
|
||||||
|
> 这正是「公共任务入口(RuntimeService)」要收敛的核心面。
|
||||||
|
|
||||||
|
### 2.1 T1 任务受理类(3 个)—— 必须 Gateway 化
|
||||||
|
|
||||||
|
| # | 路径 | 方法 | 文件:行 | 内部调用 | 迁移复杂度 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| T1-1 | `/api/tasks` | POST | tasks/api.py:111 `create_task_api` | `task_manager.create_chat_task(...)`(前置:goal 状态清理、`_build_skill_context_messages` 技能上下文构建、conversation 补建、`_normalize_media/files_payload` 媒体归一化) | **中**:受理本身已是 task_manager 薄封装,但 HTTP 层有 ~100 行参数归一化/技能上下文/补建对话逻辑可下沉给 RuntimeService 统一处理(CLI/定时任务同样需要) |
|
||||||
|
| T1-2 | `/api/v1/workspaces/<id>/messages` | POST | api_v1.py:254 `send_message_api` | `ensure_conversation_loaded` + prompt/personalization 应用到 `terminal.context_manager` + `apply_personalization_preferences` + `task_manager.create_chat_task(...)` | **中**:prompt/personalization 文件校验与应用属于 HTTP 适配层职责(可保留),核心受理改为调用 RuntimeService |
|
||||||
|
| T1-3 | `/api/workflow/activate` | POST | workflow_runtime_api.py:38 `api_activate_workflow` | `activate_workflow()`(modules/workflow_flow)→ `try_acquire_main_task_gate` 门闸 → `task_manager.create_chat_task(..., message_source="workflow", session_data=含 gate_token)` | **大**:工作流激活语义(会话补建/模式继承/msg_index 游标)+ 主任务门闸 token 移交任务线程,逻辑最重;建议 HTTP 层保留状态机编排,仅把 `create_chat_task` 调用统一切到 RuntimeService 入口 |
|
||||||
|
|
||||||
|
### 2.2 T2 任务控制类(13 个)—— 必须 Gateway 化
|
||||||
|
|
||||||
|
| # | 路径 | 方法 | 文件:行 | 内部调用 | 迁移复杂度 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| T2-1 | `/api/tasks/<task_id>/cancel` | POST | tasks/api.py:284 `cancel_task_api` | `task_manager.cancel_task()` + `GoalStateManager.mark_stopped(REASON_USER_CANCEL)` 停止对话残留目标 | **小** |
|
||||||
|
| T2-2 | `/api/v1/tasks/<task_id>/cancel` | POST | api_v1.py:444 `cancel_task_api_v1` | `task_manager.cancel_task()` | **小** |
|
||||||
|
| T2-3 | `/api/tasks/<task_id>/runtime_guidance` | POST | tasks/api.py:305 `enqueue_runtime_guidance_api` | `task_manager.enqueue_runtime_guidance()`(向运行中任务注入引导消息) | **小** |
|
||||||
|
| T2-4 | `/api/tasks/<task_id>/runtime_queue` | POST | tasks/api.py:333 `enqueue_runtime_queue_message_api` | `task_manager.enqueue_runtime_pending_message()`(运行中追问入队) | **小** |
|
||||||
|
| T2-5 | `/api/tasks/<task_id>/runtime_queue/<message_id>` | DELETE | tasks/api.py:361 `delete_runtime_queue_message_api` | `task_manager.remove_runtime_pending_message()` | **小** |
|
||||||
|
| T2-6 | `/api/tasks/<task_id>/runtime_queue/<message_id>/guide` | POST | tasks/api.py:383 `guide_runtime_queue_message_api` | `task_manager.promote_runtime_pending_to_guidance()` | **小** |
|
||||||
|
| T2-7 | `/api/workflow/deactivate` | POST | workflow_runtime_api.py:210 `api_deactivate_workflow` | `deactivate_workflow_by_user()`;若池中有通知且门闸空闲则再 `task_manager.create_chat_task(...)` 派发一轮 | **中**:控制 + 条件受理二合一,建议保留编排、收敛 Task 调用 |
|
||||||
|
| T2-8 | `/api/sub_agents/stop_all` | POST | conversation.py:2059 `stop_all_sub_agents` | `sub_agent_manager.soft_stop_all_agents()` / `sub_agent_manager.terminate_sub_agent()`(多智能体软停 / 传统模式终结) | **小** |
|
||||||
|
| T2-9 | `/api/sub_agents/<task_id>/terminate` | POST | conversation.py:2108 `terminate_sub_agent` | `sub_agent_manager.terminate_sub_agent(task_id=...)` | **小** |
|
||||||
|
| T2-10 | `/api/background_commands/<command_id>/cancel` | POST | conversation.py:2222 `cancel_background_command` | `background_command_manager.cancel_command(command_id)` | **小** |
|
||||||
|
| T2-11 | `/api/user-questions/<question_id>/answer` | POST | chat/approval.py:63 `answer_user_question` | `user_question_manager.answer(...)`(回答喂回运行中任务工具循环) | **小** |
|
||||||
|
| T2-12 | `/api/plan-approvals/<approval_id>/answer` | POST | chat/approval.py:107 `answer_plan_approval` | `plan_approval_manager.answer(...)`(计划通过后工具循环切 execute 模式) | **小** |
|
||||||
|
| T2-13 | `/api/tool-approvals/<approval_id>/decision` | POST | chat/approval.py:150 `decide_tool_approval` | `tool_approval_manager.decide(...)` | **小** |
|
||||||
|
|
||||||
|
### 2.3 T3 任务观察类(14 个)—— 可不迁移(只读,HTTP 适配层保留)
|
||||||
|
|
||||||
|
| # | 路径 | 方法 | 文件:行 | 内部调用 | 迁移复杂度 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| T3-1 | `/api/tasks` | GET | tasks/api.py:34 `list_tasks_api` | `task_manager.list_tasks()`(任务列表/状态过滤) | 小(不需迁移) |
|
||||||
|
| T3-2 | `/api/conversations/<cid>/running-status` | GET | tasks/api.py:56 | `task_manager.get_conversation_running_status()` 聚合主任务+子智能体+后台命令+多智能体 | 小 |
|
||||||
|
| T3-3 | `/api/tasks/<task_id>` | GET | tasks/api.py:229 `get_task_api` | `task_manager.get_task()` + `get_events_since(rec, offset)`(**事件流 idx/offset 轮询协议**) | 小 |
|
||||||
|
| T3-4 | `/api/v1/tasks/<task_id>` | GET | api_v1.py:415 `get_task_events` | `task_manager.get_task()` + `get_events_since(rec, offset)`(同一协议) | 小 |
|
||||||
|
| T3-5 | `/api/workflow/status` | GET | workflow_runtime_api.py:295 | `WorkflowStateManager.progress_snapshot()` | 小 |
|
||||||
|
| T3-6 | `/api/sub_agents` | GET | conversation.py:1892 `list_sub_agents` | `sub_agent_manager.get_overview()` + 通知去重计算 | 小 |
|
||||||
|
| T3-7 | `/api/sub_agents/<task_id>/activity` | GET | conversation.py:2001 | 读 `progress.jsonl` 活动记录(含 limit 上限) | 小 |
|
||||||
|
| T3-8 | `/api/background_commands` | GET | conversation.py:2139 | `background_command_manager.list_records()` | 小 |
|
||||||
|
| T3-9 | `/api/background_commands/<command_id>` | GET | conversation.py:2184 | `background_command_manager.get_record_with_output()`(实时输出) | 小 |
|
||||||
|
| T3-10 | `/api/user-questions/pending` | GET | chat/approval.py:48 | `user_question_manager.list_pending()` | 小 |
|
||||||
|
| T3-11 | `/api/plan-approvals/pending` | GET | chat/approval.py:92 | `plan_approval_manager.list_pending()` | 小 |
|
||||||
|
| T3-12 | `/api/tool-approvals/pending` | GET | chat/approval.py:135 | `tool_approval_manager.list_pending()` | 小 |
|
||||||
|
| T3-13 | `/api/multiagent/active_sub_agents` | GET | multi_agent.py:483 | `sub_agent_manager.get_multi_agent_state()`(子智能体实例+token 统计) | 小 |
|
||||||
|
| T3-14 | `/api/conversations/<cid>/bootstrap` | GET | conversation_bootstrap.py:146 | 聚合 meta+messages+`task_manager.get_conversation_running_status()`+task_replay(纯只读) | 小 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. socketio 事件盘点(10 个)
|
||||||
|
|
||||||
|
| # | 事件 | 文件:行 | 职责 | 分类 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| S1 | `connect` | socket_handlers.py:24 | socket_token 握手鉴权、绑定 user/terminal | A |
|
||||||
|
| S2 | `disconnect` | socket_handlers.py:76 | 清理 sid→user 映射、stop flag 生命周期 | A |
|
||||||
|
| S3 | `stop_task` | socket_handlers.py:128 | 置 sid 级 stop flag,指挥运行中任务停流(唯一 WS 任务控制通道) | **T2** |
|
||||||
|
| S4 | `terminal_subscribe` | socket_handlers.py:152 | 订阅真实终端输出流 | S/C |
|
||||||
|
| S5 | `terminal_unsubscribe` | socket_handlers.py:198 | 取消订阅 | S/C |
|
||||||
|
| S6 | `get_terminal_output` | socket_handlers.py:211 | 拉取终端输出片段 | S |
|
||||||
|
| S7 | `send_message` | socket_handlers.py:240 | **WS 聊天入口(已废弃)**:函数体已短路返回 `DEPRECATED`,死代码保留紧急回退(原路径:`start_chat_task` → 任务线程);任务受理已全部改走 REST `/api/tasks` | **T1(废弃)** |
|
||||||
|
| S8 | `client_chunk_log` | socket_handlers.py:372 | 前端分块渲染日志上报(限流写盘) | C |
|
||||||
|
| S9 | `client_stream_debug_log` | socket_handlers.py:386 | 前端流调试日志上报 | C |
|
||||||
|
| S10 | `send_command` | conversation.py:2462 | 系统命令 `clear/status/terminals`(只读/清空历史,无 Agent 执行);REST 对应 `/api/commands` | C |
|
||||||
|
|
||||||
|
**WS 侧结论**:10 个事件中仅 `stop_task`(T2,活跃)与 `send_message`(T1,已废弃)与任务运行时耦合,且 `send_message` 已是死代码。
|
||||||
|
事件流观察(task/terminal output)由 WS 直接转发 HTTP 轮询结果,无需 Gateway 化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 结论:真正需要「Gateway 化」的端点占比与理由
|
||||||
|
|
||||||
|
### 4.1 数字结论
|
||||||
|
|
||||||
|
- **必须迁移到 RuntimeService 公共入口(受理 + 控制)= T1(3) + T2(13) = 16 个 = 8.2%**
|
||||||
|
- 其中 T1 仅 **3 个(1.5%)**:`POST /api/tasks`、`POST /api/v1/.../messages`、`POST /api/workflow/activate`。
|
||||||
|
- 这 16 个端点的函数体**最终都调用同一批 task_manager / sub_agent_manager / background_command_manager / approval 方法**,
|
||||||
|
即「运行时控制面」已经收敛在几个单例方法上——Gateway 化的实质就是把这几个方法提升为 RuntimeService 的公共入口,
|
||||||
|
Web / CLI / 定时任务复用,HTTP 端点从「直接调 manager」改为「调 RuntimeService」。
|
||||||
|
- **可迁移但不强制 = T3(14) = 7.1%**:纯只读。轮询协议(task_id + from/offset + events)本身就是 REST 语义,
|
||||||
|
CLI/定时任务可直接复用相同协议,HTTP 适配层保留即可;未来若要统一查询接口可让 RuntimeService 暴露 `get_task_events`,但**不是本次改造的必要条件**。
|
||||||
|
- **完全不需要动 = C(131) + S(20) + A(15) = 166 = 84.7%**:CRUD、状态查询、认证管理与 Agent 运行时零耦合。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R1 配置耦合)**:“零耦合”与下文灰区说明不一致。窄核验确认 `server/chat/permission.py:166–185`、`:265–284`、`:352–368` 分别排队修改运行中的权限、执行环境和网络权限;work-mode 则在 `:420–445` 拒绝运行中切换。可保留 HTTP 路由,但这些状态修改/校验规则需要明确归属。建议理解为“多数无需改动外部接口,涉及运行态的内部调用按契约评审”,不是排除在运行时审查之外。
|
||||||
|
|
||||||
|
### 4.2 剩余 166 个端点为何可以不动
|
||||||
|
|
||||||
|
1. **C(131)**:会话/工作区/文件/配置/工作流定义/多智能体角色与设置的增删改查,全部是磁盘读写 + terminal 状态读写,
|
||||||
|
不触发、不控制、不观察任何 Agent 任务执行。典型如 `POST /api/conversations`(有活跃任务时只建视图文件,不启动任务)、
|
||||||
|
`POST /api/multiagent/conversations`(仅建对话并写 metadata,不发派 agent)、`PUT /api/workflows/<name>`(WORKFLOW.md 落盘)。
|
||||||
|
唯一接近灰区的是 `POST /api/conversations/<id>/compress`(深层压缩会同步 `asyncio.run(run_deep_compression)` 调用 LLM,
|
||||||
|
但**不创建 task_manager 任务、不经事件流、无状态轮询**,属于 HTTP 层可同步执行的对话维护操作,判定为 C 并建议改造时单独评审)。
|
||||||
|
2. **S(20)**:status/docker/git/usage/sandbox/health 等只读查询,返回宿主环境与系统状态,与任务运行时无关。
|
||||||
|
3. **A(15)**:登录/注册/CSRF/socket-token/API 用户管理/二级口令,纯认证域。
|
||||||
|
4. **页面/静态路由**(已计入 C):`/`、`/new`、`/workflows`、`/multiagent/*`、`/admin/*` 等 17 个 SPA 入口与静态资源路由,只是 `send_static_file`。
|
||||||
|
5. **灰区说明(已统计进 C)**:`chat/permission.py` 的 3 个模式切换 POST(permission-mode / execution-mode / network-permission)在任务运行期间会把切换**入队(queue_permission_mode_change 等 pending 机制)**由工具循环消费,对运行中任务有延迟影响;但它们的本质是**配置写入**(空闲时立即生效、运行中排队后生效),与「取消/停止/审批」这类任务控制操作性质不同,故判 C。若 RuntimeService 需要支持「运行中改权限」,仅需把这 3 个方法也纳入公共入口的可选能力,不构成本次改造的必要条件。
|
||||||
|
|
||||||
|
### 4.3 改造建议要点(基于分类的推论)
|
||||||
|
|
||||||
|
- RuntimeService 首批公共方法最小集:`create_task(受理)`、`cancel_task`、`enqueue_runtime_guidance`、`enqueue_runtime_pending_message(+remove/promote)`、
|
||||||
|
以及可选的 `get_task_events`(观察,供 CLI 复用)。
|
||||||
|
- `chat/approval.py` 的 3 个 answer/decision(T2)与 `conversation.py` 的 3 个 stop/terminate/cancel(T2)走同一批 manager 方法,
|
||||||
|
属于同一控制面,建议一并收敛(或至少让 CLI 具备「提交审批答复」能力时复用)。
|
||||||
|
- workflow 激活/停用的状态机编排(门闸 token、会话补建、通知派发)建议保留在 HTTP 层,只下沉 Task 创建调用,控制迁移风险。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R2 过渡边界)**:认可作为分步迁移手段。长期职责应让工作流服务承接激活、会话补建、门闸移交与失败回滚,HTTP 只解析请求和映射响应;若暂不迁移,应标注剩余兼容依赖。同样,API 入口中的模型/偏好应用等业务规则是否留在路由,应以非 HTTP 调用是否需要复用来判断。
|
||||||
|
|
||||||
|
### 4.4 方法与口径声明
|
||||||
|
|
||||||
|
- 全部 196 个端点经 `grep -E "@[a-zA-Z_]+\.route"` 逐文件提取并核对数量(26 文件合计 196,与任务给定的分布逐文件一致)。
|
||||||
|
- 分类依据为端点函数体实际行为(阅读每一处路由函数),非凭路径猜——凡能触发 `task_manager.create_chat_task` / 后台线程 / 任务控制方法者才归入 T1/T2。
|
||||||
|
- WS 侧 `send_message` 已确认函数体首行即短路返回废弃提示,其 T1 归类基于历史职责(死代码保留的回退分支),已单独标注。
|
||||||
@ -0,0 +1,240 @@
|
|||||||
|
# 支撑链路耦合点盘点:事件推送 / 审批 / 取消 / 保存
|
||||||
|
|
||||||
|
- 任务:为「运行时边界整理(RuntimeService 公共任务入口)」评估四条支撑链路的复用可行性
|
||||||
|
- 性质:**只读静态代码分析**(基于当前 checkout 源码阅读与 grep,未做运行复现;行号均指当前文件版本)
|
||||||
|
- 范围:server/、modules/、utils/、core/
|
||||||
|
- 结论确定性说明:文中「确认」指源码直接可见;「推断」指由代码结构推导、未运行时验证;「待核验」指存在多解释、需复现确认。未特别标注处为源码直接确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 四条链路一图快照
|
||||||
|
|
||||||
|
| 链路 | 核心载体 | 耦合标识符 | 等待/反馈机制 | 关键文件:行号 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 事件推送 | `TaskRecord.events`(有界 deque maxlen=20000)+ 每任务 `next_event_idx` | task_id(idx+轮询)、username(socket 房间)、conversation_id(事件补全) | 拉:GET /api/tasks/<id>?from=offset;推:socket room `user_{username}` | tasks/models.py:86/:762、context.py:33 |
|
||||||
|
| 审批/提问 | `ToolApprovalManager` / `PlanApprovalManager` / `UserQuestionManager`(纯内存存储) | username + conversation_id(manager 键);approval_id/question_id(等待键) | 执行侧 `asyncio.sleep` 轮询(0.2~0.3s,默认超时 3600s);回答走 REST 写 manager | chat_flow_tool_loop.py:232、chat/approval.py |
|
||||||
|
| 取消 | `state.stop_flags`(Dict[client_sid→entry]) | task_id(REST 任务即 client_sid);socket 场景为 request.sid | 标志位轮询(100ms 工具期)+ 硬取消(asyncio task.cancel) | state.py:135、models.py:245、chat_flow.py:178 |
|
||||||
|
| 保存 | `ContextManager` → conversation 文件(merge-on-save) | conversation_id + message_id(合并键);对话文件路径 | 每消息 append → auto_save(持 `_io_lock` 原子写) | message_mixin.py:127、crud_mixin.py:335 |
|
||||||
|
|
||||||
|
关键结论先行:**四条链路均以「任务记录/内存对象」为中心,标识符语义基本收敛(task_id / username / conversation_id),无对 Flask request / socket sid 的硬依赖(执行线程用 test_request_context 包装属「软依赖」,可剥离);但审批/提问链路在无人值守下会阻塞直到 3600s 超时,且超时语义是「拒绝该工具、任务继续」而非「结束任务」,需改造。总体判定:事件/取消/保存三条可直接复用(需适配层),审批链路需改造。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 事件推送链路
|
||||||
|
|
||||||
|
### 1.1 事件如何产生与编号
|
||||||
|
|
||||||
|
- 每个 REST 任务 = 一个 `TaskRecord`,事件存于 `self.events: deque(maxlen=20000)`(**有界**,超出丢弃最旧;注释说明 1000→20000 是为刷新恢复时前端从事件流重建长流式输出)——tasks/models.py:86。
|
||||||
|
- 事件编号:`TaskRecord.next_event_idx`(初始 0,models.py:95)单调递增;`_append_event`(models.py:762-783)在全局锁内取出 idx → `rec.next_event_idx += 1` → 追加 `{"idx", "type", "data", "ts"}`,并在 `data` 上 `setdefault(task_id / conversation_id / workspace_id)`。**idx 是 task 级单调序号,无全局唯一性需求**,客户端按 offset 断点续读,因此 idx 语义只对「同一 task 的事件流」成立(任务清理后失效,前端靠对账接口兜底)。
|
||||||
|
- 执行期事件来源分两路(都汇到同一个 recorder):
|
||||||
|
1. `_run_chat_task` 内部定义的 `sender(event_type, data)`(models.py:907-928):追加事件 + `socketio.emit(event_type, data, room=f"user_{username}")`;
|
||||||
|
2. 执行链内部大量直接调 `sender(...)`(chat_flow_task_main.py 的 thinking_chunk/text_chunk/tool_preparing/update_action/system_message/task_complete 等,见 chat_flow_task_main.py:1478-1540 的 sender 包装);
|
||||||
|
3. token_update 等 context_manager 回调事件:`_run_chat_task` 在运行期间把 `terminal.context_manager._web_terminal_callback` 临时切到任务 `sender`(models.py:935-936),结束后恢复(models.py:1011-1013)。
|
||||||
|
- **补齐 conversation_id 的层次**(双重包装):
|
||||||
|
- 对话级 terminal 广播:`_wrap_callback_with_conversation_id`(context.py:75-90)为 dict 事件 `setdefault(conversation_id)`;
|
||||||
|
- 任务执行链:`handle_task_with_sender` 内层 sender(chat_flow_task_main.py:1516-1546)为全部事件补 conversation_id,为 error/quota_exceeded/task_stopped/task_complete 补 task_id/client_sid。
|
||||||
|
- 因此事件流里 conversation_id **不是权威来源**(只是为前端定位/过滤而注入),权威在 conversation 文件侧。
|
||||||
|
|
||||||
|
### 1.2 事件如何到达 socketio 客户端
|
||||||
|
|
||||||
|
- 广播目标 = 用户房间 `user_{username}`,见 context.py:33-42(`make_terminal_callback`)与 models.py:926、:1048。socket 前端 connect 时 `join_room(f"user_{username}")`(socket_handlers.py:45)。
|
||||||
|
- **房间粒度是「用户」,不是「对话」也不是「任务」**:同用户全部对话的事件都发到同一房间,前端按 payload 的 conversation_id 过滤、按 task_id 区分任务流。
|
||||||
|
- Web 聊天(socket 模式)另有 `send_to_client` 直发 `room=request.sid`(socket_handlers.py:315-322),此为单连接定向广播,与 REST 任务的用户房间广播是两条独立路径。
|
||||||
|
|
||||||
|
### 1.3 客户端轮询端点如何按 offset 读
|
||||||
|
|
||||||
|
- `GET /api/tasks/<task_id>?from=<offset>`(tasks/api.py:231-287):`get_events_since(rec, offset)`(models.py:226-235,锁内 O(n) 浅拷贝快照再过滤 `e["idx"] >= offset`)→ `next_offset = events[-1]["idx"] + 1`。
|
||||||
|
- 前端 task 轮询 250ms 一次、带 `X-Task-Poll` 头与 `from` 游标(static/src/stores/task.ts:64/:156),另有 running-status 对账接口(tasks/api.py:38-89)作正确性兜底。
|
||||||
|
|
||||||
|
### 1.4 耦合点清单(事件链路)
|
||||||
|
|
||||||
|
| # | 耦合对象 | 位置 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| E1 | **task_id** | models.py:71(:TaskRecord.task_id)、:762-770(_append_event 写入 data)、api.py:231-287(poll 寻址)、models.py:940(client_sid=rec.task_id) | 事件 idx/缓冲/轮询游标全部挂在 TaskRecord 上,一切以 task_id 寻址 |
|
||||||
|
| E2 | **username(socket 房间)** | context.py:38、models.py:926/:1048、socket_handlers.py:45 | socket 推送按 `user_{username}` 房间;离线时无人接收(事件仍落 deque) |
|
||||||
|
| E3 | **conversation_id(事件补全,非权威)** | context.py:75-90、chat_flow_task_main.py:1516-1546、models.py:765-769 | 事件内注入用;权威在对话文件 |
|
||||||
|
| E4 | **terminal + conversation 配对(回调临时劫持)** | models.py:935-936/:1011-1013(set_web_terminal_callback 切换/恢复) | REST 任务运行期独占该对话级 terminal 的 context 回调;对话级隔离下安全,但新调用方若复用同一对话级 terminal 需注意「同一时刻一个主任务」约束(main_task_gate) |
|
||||||
|
| E5 | 有界 deque 容量/清理 | models.py:86(maxlen=20000)、:108-128(cleanup_old_tasks 终态>3600s 清理) | 事件只存活于任务生命周期,重启/清理后消失;前端需对账恢复(todo:probe.ts 对账属前端侧) |
|
||||||
|
| E6 | 事件类型契约 | chat_flow_task_main.py 各 sender 调用点、socket 前端监听 | 事件 type 字符串是前后端隐式契约(无 schema/版本),新增事件类型要与前端联调 |
|
||||||
|
|
||||||
|
### 1.5 新调用方(如定时任务触发器)能否直接复用?
|
||||||
|
|
||||||
|
**判定:可直接复用,适配点在「构造 TaskRecord + 会话上下文」。**
|
||||||
|
|
||||||
|
- 链路本身不依赖 HTTP request:`_run_chat_task` 里的 `test_request_context`(models.py:790-797)只用于从 `rec.session_data` 回填 Flask session 以兼容下游读取,属于可剥离的软耦合(阶段二已计划消除,models.py:785 注释确认「只包装不算解耦」)。
|
||||||
|
- 已有非 Web 触发先例:完成通知链(chat_flow_task_main.py:613 派发 `create_chat_task` + `session_data["main_task_gate_token"]` 移交门闸)、workflow_runtime_api.py:184/:264、api_v1.py:320——它们服务的对象仍是「用户在线场景」。
|
||||||
|
- **新调用方必须提供/生成的耦合键**:`username`(principal)、`workspace_id`、`conversation_id`(可让运行时补建)、`task_id`(uuid,事件/取消/轮询共用)。不需要 terminal_id——terminal 由 `get_user_resources(username, workspace_id, conversation_id)` 派生(对话级缓存键 = `username::workspace_id::conversation_id`,context.py:62-71)。
|
||||||
|
- 需要适配/确认的两点:
|
||||||
|
1. 事件仍会 `socketio.emit` 到 `user_{username}` 房间——用户离线时无影响(无人接收,deque 仍在),但用户在线时会收到定时任务的事件(**前台干扰**:需要确认产品预期,或给事件加 source 字段让前端过滤);
|
||||||
|
2. 与在线聊天任务的并发约束:`create_chat_task` 的单对话互斥(models.py:158-177)已覆盖 chat 类型;通知链已演示通过 `main_task_gate_token` 预占门闸的模式,定时任务的会话策略(独立会话 or 复用会话)需显式定义后才能确定复用模式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 审批链路(tool 审批 / plan 审批 / ask_user 提问)
|
||||||
|
|
||||||
|
### 2.1 pending/answer 机制(三个 manager 同构)
|
||||||
|
|
||||||
|
- `ToolApprovalManager`(modules/tool_approval_manager.py,92 行):`create_request`(:17-40)生成 `approval_id = approval_{uuid}`,条目含 username / conversation_id / task_id / tool_call_id / tool_name / arguments / preview,status=pending;`decide`(:56-92)把 status 置 approved/rejected。**纯内存 dict + threading.Lock,无等待原语、无 TTL、无清理**。
|
||||||
|
- `PlanApprovalManager`(modules/plan_approval_manager.py):`create_request`(:25-64)存计划文档内容(截断 20000 字符);`answer`(:88-100)置 approved/rejected + comment。同样纯内存。
|
||||||
|
- `UserQuestionManager`(modules/user_question_manager.py):`create_question`(:70-121)支持 batch(batch_id/batch_index/batch_total,前端一次弹多问);`answer`(:137-176)支持 option / free_text / dismissed。
|
||||||
|
- 三者都提供 `list_pending(username, conversation_id)`——**关联键是 username + conversation_id**。
|
||||||
|
|
||||||
|
### 2.2 等待/回答如何回到执行链路
|
||||||
|
|
||||||
|
- **等待原语不是 asyncio.Event,而是 asyncio.sleep 轮询**:
|
||||||
|
- `_wait_for_tool_approval`(chat_flow_tool_loop.py:232-263):每 0.2s `tool_approval_manager.get(approval_id)` 看 status;**默认超时 3600s**,超时返回 decision=rejected + code=approval_timeout;
|
||||||
|
- `_wait_for_user_questions`(:265-292):0.2s 轮询多问题批量等到全部 answered,超时置 timeout;
|
||||||
|
- `_wait_for_plan_approval`(:294-309):0.3s 轮询,超时返回 status=timeout。
|
||||||
|
- 调用点:plan :435、ask_user :574、tool :883 与 :1153(:883 走 `run_auto_approval`——若权限模式为 auto_approval,则由 `ApprovalAgent` 审核决定,modules/auto_approval_service.py:56-105,期间仍可人工接管 `_manual_takeover`)。**各调用点均未传 timeout_seconds,一律吃默认 3600s**。
|
||||||
|
- **前端如何收到审批请求**:执行链路 `sender('tool_approval_required' / 'plan_approval_required' / 'user_questions_required', ...)`(chat_flow_tool_loop.py:429/:569/:851/:1121),事件汇入任务事件流(REST 轮询可见)+ socket 用户房间。另有 REST 端点轮询 pending:`/api/tool-approvals/pending`、`/api/user-questions/pending`、`/api/plan-approvals/pending`(server/chat/approval.py:51/:95/:138)。
|
||||||
|
- **回答如何回到执行链路**:REST 端点 `/decision`、`/answer`(chat/approval.py:67/:111/:154)只是「写 manager 条目」;执行侧的 sleep 轮询下一次 tick 读到 status 变化即继续。**没有事件总线回环、没有回调**——执行线程与回答线程仅通过 manager 内存对象耦合。
|
||||||
|
|
||||||
|
### 2.3 耦合点清单(审批链路)
|
||||||
|
|
||||||
|
| # | 耦合对象 | 位置 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A1 | **approval_id / question_id(等待键)** | chat_flow_tool_loop.py:232-309;manager get() | 等待循环只认 id,轮询 manager |
|
||||||
|
| A2 | **username + conversation_id(授权与归属)** | manager list_pending/decide/answer(三处 username 校验);chat/approval.py | 回答端点校验当前登录用户==条目 username;前端按 conversation_id 过滤 |
|
||||||
|
| A3 | **task_id 字段(实际恒 None)** | chat_flow_tool_loop.py:411/:545/:845/:1115(`getattr(web_terminal, "task_id", None)`) | 全仓 grep 未发现 `web_terminal.task_id` 的赋值点(core/web_terminal.py 无此属性)→ 审批条目里的 task_id 目前**始终为 None**(高置信推断,未排除动态 setattr 路径);关联任务仅靠事件流里的 task_id 补全(_append_event setdefault),manager 本身不按 task_id 检索 |
|
||||||
|
| A4 | **执行线程阻塞挂起** | 等待循环捕获不到 stop 标志(见 2.4) | 主任务线程在等待期间处于 sleep 轮询,软取消不可中断等待 |
|
||||||
|
| A5 | **manager 内存常驻,无 TTL/清理** | modules/tool_approval_manager.py、plan_approval_manager.py、user_question_manager.py | 未决条目永久留在 `_items`(无 reaper);长时间运行会累积 |
|
||||||
|
| A6 | **REST 端点依赖 @with_terminal** | chat/approval.py(装饰器) | 回答/列等待办的端点需会话上下文;`with_terminal` 内部按 session 归属取 terminal |
|
||||||
|
|
||||||
|
### 2.4 无人值守场景(定时任务)的具体改造点
|
||||||
|
|
||||||
|
现状行为(确认):
|
||||||
|
1. 无人回答 → 等待循环跑满 3600s 才按「超时=拒绝该工具」放行,**任务继续执行**(并不结束任务);
|
||||||
|
2. 期间用户若点了停止:REST 取消会 `loop.call_soon_threadsafe(task.cancel)` **硬取消**,CancelledError 会打断 sleep 轮询 → 任务收尾成 stopped(这条路径可用,但依赖「有人发起取消」);socket 的软 stop 标志(仅 set flag)**不会**打断审批等待——下一次工具调用的行首检查(:600)才生效;
|
||||||
|
3. `auto_approval` 权限模式(若已配置)由审核智能体决策,天然不依赖人在线——这是无人值守可复用的现成机制,但仅覆盖「需要审批」的工具,且默认各用户是否启用取决于权限策略配置。
|
||||||
|
|
||||||
|
改造点(按最小改动排序):
|
||||||
|
|
||||||
|
1. **给等待循环注入可配置超时**(必须):`_wait_for_tool_approval / _wait_for_user_questions / _wait_for_plan_approval` 均已有 `timeout_seconds` 参数,但调用点(:435/:574/:883/:1153)没传。改造 = 从 `TaskRecord.session_data` 传入(如 `{scheduled: True, approval_timeout_seconds: N}`),经 `handle_task_with_sender → handle_task_with_sender args → _execute_tool_calls_impl` 透传。无人值守建议超时远小于 3600s(如 60~300s)。
|
||||||
|
2. **定义超时语义**(需要产品决策,当前语义=拒绝该工具继续跑):定时任务场景两种候选——(a) 超时=拒绝该工具、任务继续(现状,风险:任务在无监督下继续下一步);(b) 超时=结束整个任务(需在等待返回后检查 `timeout` 状态并向运行循环抛「终止」信号,或直接复用取消链路 `task_manager.cancel_task`)。方案不同,改动位置不同:前者零改动(只调参),后者要动 chat_flow_tool_loop 的返回路径。
|
||||||
|
3. **等待循环内增加 stop 检查**(推荐顺带):与工具执行期 100ms 停止轮询(:1018-1045)对齐,在三个 `_wait_*` 循环里轮询 `get_stop_flag`,让软取消也能中断等待——这同时修复「在线用户停止按钮在审批等待期间无效」的现状 gap(确认:现状软 stop 确实无法中断等待)。
|
||||||
|
4. **manager 条目 TTL/清理**(工程债):未决条目需按创建时间清理(与任务 cleanup 类似),避免定时任务多次触发后 pending 条目膨胀;超时/取消时应把条目置终态(目前只有 decide/answer 能置终态,等待方超时不回写 manager——`approval_timeout`/`timeout` 只存在于返回值)。
|
||||||
|
5. **定时任务的会话策略决定审批是否可达**:若定时任务复用用户既有对话,用户上线时仍可在该对话看到审批请求(事件进用户房间 + pending 端点);若用户长期离线,则依赖超时/auto_approval。建议首版:定时任务会话默认不产生人工审批(策略层在派发前预检权限模式,需要审批的工具直接失败重试或跳过),把「no-human-approval」作为定时任务会话的硬约束写进会话策略。
|
||||||
|
6. 仅当需要「审批请求在离线后也能被用户补答」时才考虑持久化 manager(当前全是内存态,重启即丢)——阶段三路线已把「无人在线遇审批/提问按超时结束,不扩大权限」列为原则,与上述 1/2 一致。
|
||||||
|
|
||||||
|
### 2.5 复用判定(审批链路)
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R5 产品策略)**:§2.4 的“禁止人工审批”“60–300s 超时”等均为待讨论选项,不是定时任务的技术前提。拒绝当前工具后继续运行仍受权限限制,不自动等于不安全;用户也可能希望任务等自己上线回答。工具审批、计划审批和用户提问应分别确定超时处理。客户端离线但服务仍运行时,现有 pending 可保留;跨服务重启保留记录与恢复等待执行是另外两层需求。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R6 取消与条目状态)**:§2.4 所称“停止按钮无效”应限定为只置软停止标志的路径;本报告 §3.1 已说明标准按钮使用 REST 硬取消,可打断等待。条目生命周期应先定义超时/取消终态与迟到回答处理,再设置保留期清理,不能只按 TTL 删除仍被等待的 pending。task_id 恒 None 保持“静态疑点”定性,待运行时关联验证。
|
||||||
|
|
||||||
|
- **不可直接复用**(无人值守语义不成立):等待循环机制本身与任务执行深度内嵌(定义在执行链工具循环里),但**存储 manager 可复用**(与 Web 前端共用 pending/answer 数据),需新增:超时策略注入 + 超时语义决策 + stop 检查 + 条目清理。
|
||||||
|
- 计划审批/提问同理:都是「模型工具阻塞等人工」,无人值守下按同一套超时策略处理;auto_approval 分支(ApprovalAgent)是唯一现成的无人工决策路径,但只覆盖 tool 审批(plan/ask_user 无 agent 替代)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 取消链路
|
||||||
|
|
||||||
|
### 3.1 停止按钮到任务取消的完整链路
|
||||||
|
|
||||||
|
- **REST(统一入口)**:前端停止按钮现在的标准路径 = `POST /api/tasks/<task_id>/cancel`(tasks/api.py:289-320)→ `task_manager.cancel_task(username, task_id)`(models.py:245-322):
|
||||||
|
1. 按 task_id 取记录(校验 username 归属);
|
||||||
|
2. 若 stop_flags entry 里还持有 asyncio loop/task 引用 → `loop.call_soon_threadsafe(task.cancel)` **硬取消**;
|
||||||
|
3. 置 `stop_flags[task_id] = {'stop': True, ...}` + `rec.stop_requested = True`;
|
||||||
|
4. 停止该对话目标模式(GoalStateManager);
|
||||||
|
5. 丢弃 runtime_guidance_queue,status 置 `cancel_requested`(**秒级瞬态**,_run_chat_task 收尾定终态)。
|
||||||
|
- **Socket(旧路径,语义=停最近任务)**:`stop_task`(socket_handlers.py:128-151)`get_stop_flag(request.sid, username)` 置 stop 标志;disconnect 兜底(:96-118)仅在「无其他连接且无 REST 运行任务」时 stop + hard cancel。
|
||||||
|
- **运行期检查点**(全部 `include_user=False`,只查任务级键):
|
||||||
|
- 流式输出循环:chat_flow_stream_loop.py:59/:287;
|
||||||
|
- 主任务循环:chat_flow_task_main.py:996/:1140/:2058;
|
||||||
|
- 工具循环:chat_flow_tool_loop.py:600(每个 tool_call 前)、:1018-1045(工具执行中 100ms 轮询并 `tool_task.cancel()`);
|
||||||
|
- 重试延迟:chat_flow_task_support.py:568-582(wait_retry_delay);
|
||||||
|
- 入口处预读:models.py:813(stop_hint)。
|
||||||
|
- **收尾**:`_run_chat_task` finally(models.py:1018-1049):canceled_flag 为真 → status 一律 `stopped` + `task_stopped` 事件(含 has_running_sub_agents/has_running_background_commands 让前端决定停止按钮显隐);:1088 `stop_flags.pop(rec.task_id)` 清理。
|
||||||
|
- **后台任务/子智能体如何响应**:主任务只停主智能体(models.py:245-249 注释明确);后台命令与子智能体有独立 API(/api/sub_agents/stop_all、/api/background_commands/stop_all);`_cleanup_background_tasks`(models.py:633-682)负责把相关后台命令置取消状态。
|
||||||
|
|
||||||
|
### 3.2 取消寻址依据
|
||||||
|
|
||||||
|
- **寻址依据 = `username + task_id`**(REST cancel 校验两条);底层 stop_flags 的**任务级 key 就是 client_sid(REST 任务下即 task_id)**,用户级 `user:{username}` 仅是 socket 索引,不参与运行期判定(state.py:135-159、记忆 stop_flags_per_task_isolation 确认)。
|
||||||
|
- 不依赖 terminal_id;conversation_id 只用于副作用(停目标模式)。
|
||||||
|
|
||||||
|
### 3.3 耦合点清单(取消链路)
|
||||||
|
|
||||||
|
| # | 耦合对象 | 位置 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | **task_id(=stop_flags 键)** | state.py:135-159、models.py:245-322、chat_flow.py:178 | 停止状态唯一真相;REST 任务 task_id 即 client_sid |
|
||||||
|
| C2 | **asyncio loop/task 引用** | chat_flow.py:166-179(entry 持有 loop+task)、models.py:281-287(硬取消用) | 每个任务独立事件循环(process_message_task 内 `asyncio.new_event_loop`);硬取消 = loop.call_soon_threadsafe(task.cancel) |
|
||||||
|
| C3 | **checkpoint 散点** | 见 3.1 运行期检查点 | 软停止靠各检查点 100ms~每轮粒度生效 |
|
||||||
|
| C4 | `cancel_requested` 瞬态语义 | models.py:309-319、:1018-1035 | 活跃集合成员判定(pending/running/cancel_requested),收尾统一置 stopped |
|
||||||
|
| C5 | 后台任务独立性 | models.py:633-682、socket_handlers/API 层 | 停止主任务不自动停后台;公共服务需保留这两类独立控制入口 |
|
||||||
|
|
||||||
|
### 3.4 公共入口要支持取消需保留什么
|
||||||
|
|
||||||
|
- **判定:可直接复用**(REST cancel 路径对调用方透明——只要持有 task_id)。
|
||||||
|
- 需保留/注意:
|
||||||
|
1. **task_id 必须回传并持久化给发起方**(定时任务 Occurrence 记录 run_id=task_id,取消才可寻址);
|
||||||
|
2. 保留 `process_message_task` 的「独立事件循环 + entry 持有 loop/task」模式——硬取消依赖它;公共入口若换线程池/事件循环策略,需保持该契约;
|
||||||
|
3. 保留任务级 stop 键语义(不能让定时任务与同用户在线任务共享 stop 键);`cancel_task` 已是 task_id 精确取消,天然满足;
|
||||||
|
4. 移除/包装对 `session` 的依赖后,`cancel_task` 中取 terminal 的副作用路径(get_user_resources)要用显式上下文调用(它已按 rec.workspace_id/conversation_id 构造,仅需注入 username 上下文)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 保存链路(对话历史写入)
|
||||||
|
|
||||||
|
### 4.1 保存保护机制(三层)
|
||||||
|
|
||||||
|
- **merge-on-save(防缩减)**:`save_conversation`(crud_mixin.py:298-360,merge 自 :335)默认把磁盘旧消息与内存新消息**按 message_id 合并**(`_merge_messages_by_id`,:252-296:磁盘独有保留、内存独有追加、无 message_id 防御性跳过),并带守卫断言——merge 后消息数若缩减且非 `allow_shrink`(仅检查点恢复豁免)→ **拒绝保存**(:355-360)。
|
||||||
|
- **I/O 锁**:`_save_conversation_file`(crud_mixin.py:194-203)与 `_update_index`(:205-244 读-改-写整体持锁)共用 `self._io_lock = threading.RLock()`(base.py:47)——锁在 ConversationManager 实例上,**同一对话同一 manager 内的并发写被串行化**(跨进程/跨实例不覆盖)。
|
||||||
|
- **原子替换**:`_atomic_write_json`(index_mixin.py:104-133):同目录 NamedTemporaryFile(唯一前缀)+ json.dump + flush/fsync + `replace_with_retry`(Windows 持锁退避),失败转存 `.last_failed.tmp` 留证。
|
||||||
|
|
||||||
|
### 4.2 任务执行写对话历史的入口是否唯一收敛
|
||||||
|
|
||||||
|
- **执行链写入口收敛**:任务执行内全部走 `web_terminal.context_manager.add_conversation(...)`(message_mixin.py:127-229):主任务各路径 chat_flow_task_main.py:1611/:1641/:1657/:1670/:1832/:2000/:2346;工具循环 chat_flow_tool_loop.py:674/:743/:784/:826/:916/:1068/:1186/:1402;注入路径 chat_flow_task_support.py:121/:455。`add_conversation` 每次 append 后**立即 `auto_save_conversation()`**(:229 → conversation_mixin.py:385-414 → crud_mixin.save_conversation),即每条消息落盘一次。
|
||||||
|
- **但存在执行链之外的第二批写者**(同一 conversation 文件的其他写入口,均复用同一 save_conversation 保护,但语义不同):
|
||||||
|
- 用户设置/恢复:server/chat/settings.py:63/:164、server/conversation.py:1573、core/main_terminal_parts/commands.py:142/:189/:391(CLI 命令交互)、core/web_terminal.py:308(terminal 内部管理)、edit_summary.py:203、compression_mixin.py:203(压缩续接)、todo_annotation_mixin.py:131。
|
||||||
|
- 因此:**「任务执行写历史」收敛于 context_manager,但「conversation 文件写」不是全局唯一收敛**——存在多处直接调 save_conversation/auto_save 的外部写者。保护正确性靠:merge-on-save + 每对话 manager 级 _io_lock + 对话级主任务门闸(main_task_gate.py,一个对话同一时刻一个主任务)三者叠加。
|
||||||
|
- 风险点:`_io_lock` 是 manager 实例级,**两个不同 ConversationManager 实例写同一对话文件**(如对话级 terminal 与工作区级服务实例双持同一对话)时锁不互斥;代码已有对策(对话级隔离 + create_chat_task 单对话互斥 + 主任务门闸),但这是「并行写保护依赖多道防线而非单一锁」的现状,新入口必须遵守同一套防线(见 4.3)。
|
||||||
|
|
||||||
|
### 4.3 耦合点清单(保存链路)
|
||||||
|
|
||||||
|
| # | 耦合对象 | 位置 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| S1 | **conversation_id(文件/目录定位)** | conversation_manager 各 load/save | 保存寻址键 |
|
||||||
|
| S2 | **message_id(合并键)** | crud_mixin.py:252-296、message_mixin.py:142 | `_generate_message_id()` 生成;合并去重依赖其唯一性与新消息必带 |
|
||||||
|
| S3 | **manager 实例级 `_io_lock` (RLock)** | base.py:47、crud_mixin.py:194-203/:205-244 | 读-改-写互斥;跨实例不互斥 |
|
||||||
|
| S4 | **对话级主任务门闸** | main_task_gate.py(process_message_task 统一获取/释放,chat_flow.py:139/:258) | 对话单写者约束,与保存锁互补 |
|
||||||
|
| S5 | **ContextManager 实例与对话绑定** | context.py:62-71(缓存键含 conversation_id) | 每对话独立 terminal/context_manager 是并发写隔离的前提 |
|
||||||
|
| S6 | **自动保存频率** | message_mixin.py:229(每消息 auto_save) | 长流式回复高频落盘;性能与原子写并存的既定取舍 |
|
||||||
|
|
||||||
|
### 4.4 新调用方复用是否有额外前提
|
||||||
|
|
||||||
|
- **判定:可直接复用**(写入口已收敛、保护机制完善),前提:
|
||||||
|
1. 必须走「对话级 terminal + 该对话专属 ContextManager」路径(`get_user_resources(username, workspace_id, conversation_id)`),不要落到工作区级服务实例,否则双持同一对话时锁不互斥(S3 边界);
|
||||||
|
2. 必须遵守主任务门闸:一次性主任务在 process_message_task 统一获取;若新调用方是「轮询器预占→移交 token」模式,按通知链现有模式(session_data["main_task_gate_token"],chat_flow_task_main.py:613-620)复用;
|
||||||
|
3. 定时任务的会话策略(使用独立会话 or 复用既有对话)决定消息写入哪个 conversation_id;若复用用户对话,注意与用户在线任务的互斥(create_chat_task 单对话互斥会直接拒绝并发 chat 任务,models.py:158-177——**这是现成防线,保持即可**)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 结论:复用现有链路的总体可行性评估
|
||||||
|
|
||||||
|
1. **事件链路:直接复用(需适配层)**。核心载体 TaskRecord(deque+idx)对调用方不可见,调用方只需 `create_chat_task` 拿到 task_id 即可轮询/取消;socket 推送按用户房间,离线无影响。适配点:消除对 Flask session 的隐式读取(test_request_context 包装)、定义定时任务事件的来源标识(防前台干扰)。
|
||||||
|
2. **取消链路:直接复用**。task_id 精确取消已达成「寻址=username+task_id」,不依赖 terminal/conversation(conversation 仅副作用);保留「独立事件循环+entry 持 loop/task」即可支持硬取消。公共入口需把 task_id 持久化到 Occurrence。
|
||||||
|
3. **保存链路:直接复用,前提明确**。写入口收敛于 `context_manager.add_conversation`,merge-on-save + 锁 + 门闸三道防线可迁移;新调用方必须(1)走对话级 terminal、(2)遵守主任务门闸、(3)会话策略决定 conversation_id。注意保存链路存在执行链之外的次要写者(设置/压缩/CLI),它们共用同一保护,非执行链耦合。
|
||||||
|
4. **审批/提问链路:需改造**。存储 manager 可复用,但等待循环(1)默认超时 3600s 且调用点不传参、(2)超时语义=拒绝工具继续跑而未结束任务、(3)等待期间软停止无效、(4)manager 条目无 TTL。无人值守改造点按 §2.4:注入超时参数(session_data 透传)、定义超时语义(拒绝继续 or 结束任务)、等待循环加 stop 检查、条目清理、会话策略禁止人工审批或依赖 auto_approval 分支。
|
||||||
|
|
||||||
|
**总体**:以 `create_chat_task + run_chat_task_sync` 为骨架抽 RuntimeService 的方案成立——事件/取消/保存三链的标识符语义(task_id/username/conversation_id)已收敛且耦合点可枚举;审批链路是唯一需要先改造语义(超时)再复用的链路。改造顺序建议:审批超时策略 → 公共入口 → 定时任务派发(与 gateway_runtime_work_plan 三阶段路线一致)。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|实施顺序)**:现有审批语义可在阶段二解耦上下文时先保持兼容;无人值守超时策略在阶段三实现前确定即可,不必阻塞公共入口整理。三条可复用支撑链也需核验异常回退是否仍有任务记录、事件和取消关联,不能把“可复用”理解为无需验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:主要引用文件清单
|
||||||
|
|
||||||
|
- server/tasks/models.py(TaskRecord/deque/idx/清理、_append_event、sender、cancel_task、_run_chat_task 收尾)
|
||||||
|
- server/tasks/api.py(poll 端点、cancel 端点、running-status 对账)
|
||||||
|
- server/context.py(make_terminal_callback、attach_user_broadcast、_wrap_callback_with_conversation_id、get_user_resources)
|
||||||
|
- server/state.py(stop_flags/set/get/clear)
|
||||||
|
- server/chat_flow.py(process_message_task、run_chat_task_sync)
|
||||||
|
- server/chat_flow_task_main.py(handle_task_with_sender、sender 包装、通知链 create_chat_task)
|
||||||
|
- server/chat_flow_tool_loop.py(三个 _wait_*、审批事件发送、工具期停止轮询)
|
||||||
|
- server/chat_flow_stream_loop.py / server/chat_flow_task_support.py(运行期停止检查点)
|
||||||
|
- server/socket_handlers.py(connect/disconnect/stop_task)
|
||||||
|
- server/chat/approval.py(三个 pending/answer REST 端点)
|
||||||
|
- server/main_task_gate.py(对话级主任务门闸)
|
||||||
|
- modules/tool_approval_manager.py / plan_approval_manager.py / user_question_manager.py / auto_approval_service.py
|
||||||
|
- utils/context_manager/message_mixin.py、conversation_mixin.py
|
||||||
|
- utils/conversation_manager/crud_mixin.py、index_mixin.py、base.py
|
||||||
@ -0,0 +1,266 @@
|
|||||||
|
# Flask 隐式上下文依赖盘点(session / request / g / has_request_context)
|
||||||
|
|
||||||
|
- 分析日期:2026-09-07(子智能体 #2 静态只读分析,未修改任何文件)
|
||||||
|
- 分析范围:`server/`、`core/`、`modules/`、`utils/`(仅 `*.py`,排除注释行)
|
||||||
|
- 目的:评估「运行时边界整理」改造范围——区分**任务执行链路上的依赖(必须消除/显式化)**与**纯 HTTP 适配层的依赖(可保留)**
|
||||||
|
- 前置参考:`.astrion/memory/gateway_runtime_work_plan.md`(阶段二明确要求消除执行链路对隐式 Flask session 的读取,`server/tasks/models.py:785` 的 test_request_context 模式"只包装不算解耦")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 总体结论(先说结果)
|
||||||
|
|
||||||
|
**任务执行链路对 Flask 隐式上下文的依赖属于「浅层」**,但依赖**集中在两个枢纽函数**上,是显式化的主战场:
|
||||||
|
|
||||||
|
1. **入口**:`server/tasks/models.py::create_chat_task`(直接读 `session` 做快照)+ `_run_chat_task`(用 `test_request_context()` 把后台线程包进隐式上下文,再调 `get_user_resources`)。
|
||||||
|
2. **资源获取**:`server/context.py::get_user_resources`(及其内部 `_apply_workspace_personalization_preferences`、admin policy 应用),用 `has_request_context()` 守卫读写 session 的 host_mode / workspace_id / run_mode / thinking_mode / model_key / is_api_user。
|
||||||
|
|
||||||
|
**关键发现:真正的执行体是干净的。**
|
||||||
|
|
||||||
|
| 层 | Flask 隐式上下文使用 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `core/`(main_terminal*.py、web_terminal.py、main_terminal_parts/) | **零** | `session` 同名变量全部是「终端会话/容器句柄」(ContainerHandle)或局部 dict,与 Flask 无关 |
|
||||||
|
| `modules/` | **零** | `request` 两处是 stdlib `urllib.request` 与 stdin 协议局部变量;`session` 全是 `container_session` |
|
||||||
|
| `utils/` | **零** | 无任何 Flask 导入;`session.get` 命中是格式化函数的 dict 参数 |
|
||||||
|
| `server/` 的非任务文件 | 大量(约 190 行) | 几乎全部在 HTTP 路由处理器/认证层内(A 类,可保留) |
|
||||||
|
| `server/` 的任务链路文件 | **极少且集中在 2 个文件** | `tasks/models.py`(15 行)+ `context.py`(31 行)|
|
||||||
|
|
||||||
|
**特别地**:`server/chat_flow*.py` 全部 9 个文件(含任务执行主体 `chat_flow_task_main.py`、`run_chat_task_sync`)在函数体内 **零** session/request/has_request_context 使用——只有 import 行。任务线程在 `test_request_context` 包裹退出后,后续整条执行链路都不再触碰 Flask 隐式上下文。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R3 依赖口径)**:窄核验支持 core/modules/utils 无直接 Flask 导入,但直接搜索不能证明所有间接调用都无上下文依赖。“浅层”描述的是依赖位置,不等于改动风险低;get_user_resources 的身份和资源分支需要行为验证,不能以删除 import 或零关键词命中代替验收。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 分类统计表
|
||||||
|
|
||||||
|
### 1.1 模式命中总量
|
||||||
|
|
||||||
|
| 模式 | 总命中 | A 类 | B 类 | C 类 | 假阳性(非 Flask) |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `from flask import session/request/g/…` | 37 个文件 | 34 文件 | 2 文件(tasks/models.py、context.py)+ auth_helpers.py | — | core/modules/utils 0 |
|
||||||
|
| `session[...]` / `session.get/pop/clear` 等 | ~202 行 | ~186 行 | ~16 行(见 §2) | 0 | core/commands.py 7、utils/tool_result_formatter 5 |
|
||||||
|
| `request.`(args/json/headers 等) | 214 处 | 212 处 | 0 | 0 | modules/container_file_proxy.py:430(stdin dict)、modules/sandbox_setup_manager.py:454(urllib) |
|
||||||
|
| `has_request_context(` | ~17 处 | 0 | ~17 处(全部在 context.py) | 0 | 0 |
|
||||||
|
| `test_request_context(` | 1 处 | 0 | 1 处(tasks/models.py:794) | 0 | 0 |
|
||||||
|
| `g.`(flask.g) | 1 处 | 1 处(api_auth.py:44) | 0 | 0 | 0 |
|
||||||
|
| `current_user` | 0 | — | — | — | 项目用自研 session 认证(auth_helpers.py) |
|
||||||
|
| 认证装饰器(@login_required/@api_login_required/@admin_*) | 266 处 | 266 处 | 0 | 0 | — |
|
||||||
|
| @with_terminal(自定义资源注入装饰器) | 83 处 | 83 处 | 0 | 0 | 装饰器本身(context.py)读 request,见 §2.2 |
|
||||||
|
|
||||||
|
> 说明:`@with_terminal` 是**适配层装饰器**(context.py:685),它读 `request.args` 解析 conversation_id 并调 `get_user_resources`;83 处使用点全部是路由函数。
|
||||||
|
|
||||||
|
### 1.2 按目录分布
|
||||||
|
|
||||||
|
| 目录 | Flask 导入文件数 | session 读写行 | request. 行 | has/test_request_context | 判定 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| server/ | 35 | ~190 | 212 | 17 / 1 | A 类为主 + B 类集中在 tasks/models.py、context.py |
|
||||||
|
| core/ | 0 | 0(同名终端会话变量非 Flask) | 0 | 0 | 干净 |
|
||||||
|
| modules/ | 0 | 0(container_session) | 0(假阳性) | 0 | 干净 |
|
||||||
|
| utils/ | 0 | 0(dict 参数) | 0 | 0 | 干净 |
|
||||||
|
|
||||||
|
### 1.3 按文件的 session 使用量(Flask 真命中,排除假阳性)
|
||||||
|
|
||||||
|
| 文件 | session 读写行数 | 类别 | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| server/auth.py | 46 | A | 登录/登出/会话状态路由 + 认证辅助函数(get_current_user 等,供路由使用) |
|
||||||
|
| server/context.py | 31 | **B(枢纽)** | get_user_resources、personalization、ensure_conversation_loaded、with_terminal |
|
||||||
|
| server/status/host_workspace.py | 21 | A | 路由 |
|
||||||
|
| server/api_v1.py | 20 | A | 路由 |
|
||||||
|
| server/status/docker.py | 19 | A | 路由 |
|
||||||
|
| server/conversation.py | 16 | A | 路由处理器 + 3 个被路由调用的工具函数(见 §2.3) |
|
||||||
|
| server/tasks/models.py | 15 | **B(入口+线程)** | create_chat_task 快照(9)+ _run_chat_task 上下文填充(6) |
|
||||||
|
| server/status/base.py | 13 | A | 路由/状态采集 |
|
||||||
|
| server/app_legacy.py | 10 | A | 遗留路由 |
|
||||||
|
| server/chat/settings.py、auth_helpers.py、security.py、api_auth.py、tasks/api.py、tasks/skills.py、multi_agent.py、admin.py、conversation_bootstrap.py、chat/permission.py、status/sandbox.py | 1~7 | A | 路由 / 认证 / CSRF / API 认证中间件 |
|
||||||
|
| core/main_terminal_parts/commands.py | 7 | 假阳性 | 终端会话 dict(list_terminals 结果) |
|
||||||
|
| utils/tool_result_formatter/terminal.py | 5 | 假阳性 | dict 参数 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. B 类依赖详细清单(任务执行链路,必须消除或显式化)
|
||||||
|
|
||||||
|
### 2.1 强耦合点(无条件、无守卫)
|
||||||
|
|
||||||
|
#### B-1 `server/tasks/models.py:785-812` — `_run_chat_task` 的 test_request_context 包装 ★核心
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 为后台线程构造最小请求上下文,填充 session
|
||||||
|
from server.app import app as flask_app
|
||||||
|
with flask_app.test_request_context():
|
||||||
|
try:
|
||||||
|
for k, v in (rec.session_data or {}).items():
|
||||||
|
if v is not None:
|
||||||
|
session[k] = v # 785-800:把快照灌回隐式 session
|
||||||
|
if session.get("host_mode"): # 799-804:host 模式对齐 workspace_id
|
||||||
|
session["workspace_id"] = workspace_id
|
||||||
|
session["host_workspace_id"] = session.get("host_workspace_id") or workspace_id
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
terminal, workspace = get_user_resources(username, workspace_id=workspace_id, conversation_id=rec.conversation_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 文件:行号 | server/tasks/models.py:794(`with flask_app.test_request_context():`)、798-807(session 填充与 get_user_resources) |
|
||||||
|
| 读取/写入字段 | 写 session:`session_data` 全量快照(username/role/is_api_user/host_mode/host_workspace_id/workspace_id/run_mode/thinking_mode/model_key,随后续 get_user_resources 读取);读 session:`host_mode`、`host_workspace_id`、`workspace_id` |
|
||||||
|
| 用途 | ① 为后台线程伪造请求上下文,使 `get_user_resources`(含其内部 `get_current_user_record()/get_current_user_role()` 等无守卫 session 读)不抛 "Working outside of request context";② 把快照中的工作区定位配置(host_mode/workspace_id)还原给隐式 session |
|
||||||
|
| 显式化难度 | **中**。难点不在本函数(删除 wrapper 很容易),而在被调用的 `get_user_resources`(B-4)必须改为接收显式 principal + 会话快照参数。改造完成后此 wrapper 整体删除,任务线程直接以显式参数调资源获取 |
|
||||||
|
| 备注 | 这是项目记忆点名"只包装不算解耦"的现场。`with` 块在 `get_user_resources` 返回后**立即退出**,异常被 `except Exception: pass` 吞掉(session 填充失败静默降级为空快照)——隐式依赖还带来"失败不可见"问题 |
|
||||||
|
|
||||||
|
#### B-2 `server/tasks/models.py:180-203` — `create_chat_task` 直接读 Flask session
|
||||||
|
|
||||||
|
```python
|
||||||
|
if session_data is not None:
|
||||||
|
snapshot = dict(session_data)
|
||||||
|
...
|
||||||
|
try: # 185-193:有显式快照时仅 setdefault 兜底(已包 try/except)
|
||||||
|
snapshot.setdefault("host_mode", session.get("host_mode"))
|
||||||
|
if snapshot.get("host_mode"):
|
||||||
|
snapshot.setdefault("host_workspace_id", session.get("host_workspace_id") or workspace_id)
|
||||||
|
except Exception: ...
|
||||||
|
else: # 194-203:无显式快照时全量直读 session
|
||||||
|
try:
|
||||||
|
record.session_data = {
|
||||||
|
"username": session.get("username"),
|
||||||
|
"role": session.get("role"),
|
||||||
|
"is_api_user": session.get("is_api_user"),
|
||||||
|
"host_mode": session.get("host_mode"),
|
||||||
|
"host_workspace_id": session.get("host_workspace_id") or workspace_id,
|
||||||
|
"workspace_id": workspace_id,
|
||||||
|
"run_mode": session.get("run_mode"),
|
||||||
|
"thinking_mode": session.get("thinking_mode"),
|
||||||
|
"model_key": session.get("model_key"),
|
||||||
|
...
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
record.session_data = {}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 文件:行号 | server/tasks/models.py:185-193(setdefault 兜底)、194-203(全量直读分支) |
|
||||||
|
| 读取字段 | username、role、is_api_user、host_mode、host_workspace_id、workspace_id、run_mode、thinking_mode、model_key |
|
||||||
|
| 用途 | 将 HTTP 会话上下文快照进 TaskRecord.session_data,供后台线程还原(认证身份 + 用户配置 + 工作区定位) |
|
||||||
|
| 调用方 | 全部是 HTTP 路由处理器(chat_flow_task_main.py:613/1416、tasks/api.py:200、api_v1.py:320、workflow_runtime_api.py:184/264),当前均天然有请求上下文 → 直读不报错 |
|
||||||
|
| 显式化难度 | **小~中**。公共任务入口的契约应改为「强制接收显式 session_data/principal 快照」,函数内禁止 fallback 读 session;当前 `except Exception` 已兜底,改造只需删除 `else` 分支并把 setdefault 兜底改为纯快照合并。注意:今后若定时任务(阶段三)直接调 create_chat_task,走的就是 else 分支会崩或静默空快照——必须在入口层消除 |
|
||||||
|
| 备注 | 此函数即项目记忆阶段二要抽的「公共任务入口」基座,是本次改造首当其冲的文件 |
|
||||||
|
|
||||||
|
### 2.2 枢纽函数(守卫型,任务链路与 HTTP 共用)
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|调用方修正)**:上一节 B-2 的“调用方全部是 HTTP 路由、均有请求上下文”与调用点子报告不一致:`chat_flow_task_main.py` 的完成通知和多智能体 idle 派发来自后台轮询,主要依靠显式 session_data。它们恰好证明非 HTTP 调用已存在,不能将无请求上下文仅视为未来定时器场景。
|
||||||
|
|
||||||
|
#### B-3 `server/context.py::get_user_resources`(L221-535)★核心
|
||||||
|
|
||||||
|
任务线程依赖的**唯一资源获取入口**,内部用 `has_request_context()` 守卫读写 session:
|
||||||
|
|
||||||
|
| 行号 | 操作 | 字段 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 239 | 读(守卫) | `host_mode` | host 工作区模式判定 |
|
||||||
|
| 246-247 | 读(守卫) | `host_workspace_id` / `workspace_id` | host 模式下工作区选择(多工作区并行) |
|
||||||
|
| 368-369 | 读(守卫) | `run_mode` / `thinking_mode` | 新建 terminal 时恢复用户运行模式配置 |
|
||||||
|
| 390-394 / 400-402 | 写(守卫) | run_mode / thinking_mode / workspace_id / host_workspace_id | 新建/复用 terminal 后回写 session 同步 |
|
||||||
|
| 440-441 | 写(守卫) | `model_key` | admin policy 禁用模型时回写 |
|
||||||
|
| 462 | 读(守卫) | `is_api_user` | 路由到 api_user_manager vs user_manager |
|
||||||
|
| 473 | 读(守卫) | `workspace_id` | 常规用户工作区选择 |
|
||||||
|
| 495-496 | 读(守卫) | `run_mode` / `thinking_mode` | 新建 terminal 恢复配置(常规分支) |
|
||||||
|
| 531-535 | 写(守卫) | run_mode / thinking_mode / model_key / workspace_id | 新建 terminal 后回写 |
|
||||||
|
| 407 / 465-466 / 520-521 | 读(**无守卫**) | `get_current_user_record()` / `get_current_user_role()` → session.username/role | admin policy 应用、user_role 赋值 |
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 用途 | 认证(record/role → admin policy,user_role);用户配置(run_mode/thinking_mode/model_key);工作区定位(workspace_id/host_workspace_id/host_mode);API 用户识别(is_api_user) |
|
||||||
|
| 为什么任务链路依赖它 | `_run_chat_task` 调它时必须已有请求上下文(否则 407/465/520 行无守卫读崩)→ 因此 B-1 的 test_request_context 是为它服务的 |
|
||||||
|
| 显式化难度 | **中**。改造路径:函数签名增加 `principal`(username + user_record + role + is_api_user)与 `session_snapshot`(workspace_id/host_workspace_id/host_mode/run_mode/thinking_mode/model_key)参数;所有 `session.get(...) if has_request_context() else ...` 分支改为 `snapshot.get(...)`;所有回写(`if has_request_context() and update_session`)在适配层路由中保留、在纯任务路径删除。涉及分支较多(host/docker/api 三套),建议先建参数化版本再逐个替换调用方 |
|
||||||
|
| 调用方分布 | HTTP 路由约 170+ 处(含 @with_terminal 83 处、各路由直接调用)+ 任务线程 1 处(tasks/models.py:809) |
|
||||||
|
|
||||||
|
#### B-4 `server/context.py::_apply_workspace_personalization_preferences`(L116-178)
|
||||||
|
|
||||||
|
| 行号 | 操作 | 字段 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 121-122 | 读(守卫) | `model_key` | 恢复会话级模型选择(不覆盖对话绑定模型) |
|
||||||
|
| 175-178 | 写(守卫) | run_mode / thinking_mode / model_key | 应用偏好后回写 session |
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 调用链 | `get_user_resources` 末尾调用(L450、L540)→ 任务线程经 B-1 的 wrapper 进入 |
|
||||||
|
| 显式化难度 | **小**。增加显式 `session_model: Optional[str]` / `update_session` 参数即可;守卫分支改为参数判断。任务线程经 wrapper 时读到的是灌入快照的 session,语义等价于显式参数 |
|
||||||
|
|
||||||
|
#### B-5 `server/context.py::ensure_conversation_loaded`(L750-810)
|
||||||
|
|
||||||
|
| 行号 | 操作 | 字段 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 761-763、796-799 | 写(守卫 has_request_context) | run_mode / thinking_mode / model_key | 加载/新建对话后把 terminal 最新模式回写 session |
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 调用方 | `chat_flow_task_main.py` 导入并在任务受理路径调用;也被路由使用 |
|
||||||
|
| 关键点 | 任务线程中该函数在 `test_request_context` 块**之外**执行 → `has_request_context()` 为 False → 写操作自然跳过,不崩溃。但**语义上仍是隐式耦合**(回写是"刷新用户会话"的副作用语义,应在适配层做或在改造中明确删除) |
|
||||||
|
| 显式化难度 | **小**。把 session 回写上移到 HTTP 路由处理器;或参数化 `update_session` |
|
||||||
|
|
||||||
|
#### B-6 `server/auth_helpers.py:35-49` — 认证辅助被任务链路间接使用
|
||||||
|
|
||||||
|
| 函数 | 行号 | 字段 | 用途 | 何时被任务链路触发 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `get_current_username` | 35-36 | session.username | 用户身份 | get_user_resources 传了显式 username → 不触发;但不传时会触发 |
|
||||||
|
| `get_current_user_record` | 38-40 | session.username → user_manager | 用户记录 → admin policy | **get_user_resources 内 L407/465 无守卫调用 → 任务线程必触发** |
|
||||||
|
| `get_current_user_role` | 42-49 | session.role | 角色 | get_user_resources 内 L466/520 触发 |
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 显式化难度 | **中**。这些函数本身是 HTTP 适配层工具(保留),但 get_user_resources 内部的调用必须改为接收显式 `record`/`role`(create_chat_task 快照里已有 username/role,可在线程入口恢复 record 快照) |
|
||||||
|
| 备注 | 认证装饰器 `login_required`(L17-25) / `api_login_required`(L27-32) / `admin_*` 属于纯适配层 → A 类,保留 |
|
||||||
|
|
||||||
|
### 2.3 C 类说明(需看调用方——核查后均归 A)
|
||||||
|
|
||||||
|
| 位置 | 函数 | 读的字段 | 调用方核查结果 | 归类 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| server/conversation.py:275-276 | `_is_host_mode_request` | session.host_mode | 仅被路由处理器链调用(versioning 作用域判断等);任务链路不调 server/conversation.py | **A**(可保留) |
|
||||||
|
| server/conversation.py:298-306 | `_resolve_input_draft_path` | host_workspace_id / workspace_id | 仅 get_input_draft/upsert_input_draft 两个路由调用 | **A** |
|
||||||
|
| server/conversation.py:529-540 | `_resolve_target_terminal_for_workspace` | workspace_id | 仅 get_conversations/create_conversation/load_conversation 等路由调用 | **A** |
|
||||||
|
| server/context.py:685-719 | `with_terminal` 装饰器 | request.args/request.is_json/get_json + get_current_username | 83 处全部是路由函数 | **A**(装饰器本身是适配层) |
|
||||||
|
| server/socket_handlers.py | `request` 42 处 | request.args/json/sid | socketio 事件处理器(flask-socketio 提供请求上下文,属于 Web 实时适配层) | **A** |
|
||||||
|
| core/main_terminal_parts/commands.py:658-665 | `session["is_running"]` 等 | 终端会话 dict | `list_terminals()` 返回结果,非 Flask | **假阳性** |
|
||||||
|
| modules/container_file_proxy.py:427-430 | `request.get("payload")` | stdin JSON dict | 子进程协议局部变量 | **假阳性** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 特别检查:core/main_terminal.py 与 main_terminal_parts/ 的「session」
|
||||||
|
|
||||||
|
任务描述要求核查 WebTerminal 的 session 属性。结论:**WebTerminal 没有 `self.session` 属性**,任务中遇到的所有 `session` 都不是 Flask:
|
||||||
|
|
||||||
|
| 位置 | 形式 | 真实对象 |
|
||||||
|
|---|---|---|
|
||||||
|
| core/main_terminal.py:117-139 | `self.container_session`(构造参数注入) | `ContainerHandle`(容器句柄,来自 modules/user_container_manager) |
|
||||||
|
| core/main_terminal.py:248-251 `_apply_container_session` | 参数 `session: ContainerHandle` | 容器句柄:读 `session.mode` / `session.mount_path` |
|
||||||
|
| core/main_terminal.py:497-509 `update_container_session` | 参数 `session` | 容器切换入口,透传给 terminal_manager/terminal_ops/file_manager/mcp_client_manager/sub_agent_manager |
|
||||||
|
| core/main_terminal_parts/commands.py:552-557 | `session = getattr(self, "container_session", None)` | 容器句柄:读 `session.mode` / `container_name` / `sandbox_bin` |
|
||||||
|
| core/main_terminal_parts/commands.py:658-665 | `for session in result["sessions"]` | `list_terminals()` 返回的会话快照 dict |
|
||||||
|
| core/main_terminal_parts/tools_execution.py:129-130,177-178;tools_definition/base.py:94-95;context/mode.py:155 | `getattr(self, "container_session", None)` / `_session` | 容器句柄:判定 docker mode |
|
||||||
|
| modules/terminal_manager.py:157,173-175;toolbox_container.py:52-54,94-97;file_manager/base.py:81-82 | `session.container_name/mount_path` | 容器句柄 |
|
||||||
|
|
||||||
|
**结论**:任务执行体(core/、modules/)对"会话"的引用全部是**显式对象属性**(`terminal.container_session`、dict 参数),与 Flask 的 `session`/`request` 零关联。任务链路唯一的隐式依赖只存在于「入口 + 资源获取」(§2),底层执行引擎无需任何改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 改造范围建议(供主智能体决策,不实施)
|
||||||
|
|
||||||
|
按「依赖深度由浅入深」排序:
|
||||||
|
|
||||||
|
1. **入口契约**(B-2):`create_chat_task` 强制显式 `session_data`,删除 `else` 直读 session 分支 + setdefault 兜底 → **小改动**,立即解除"公共入口 = 必须 HTTP 上下文"的耦合。
|
||||||
|
2. **资源获取参数化**(B-3/B-4,配套 B-1/B-6):`get_user_resources` 增加 `principal` + `session_snapshot` 显式参数,内部 `has_request_context` 分支改为快照分支;`_apply_workspace_personalization_preferences` 参数化 → **中改动**,是本次改造的主体。
|
||||||
|
3. **删除 test_request_context 包装**(B-1):步骤 2 完成后直接删除,任务线程全程无隐式上下文 → 验证点:`_run_chat_task` 不再 import flask。
|
||||||
|
4. **回写副作用清理**(B-5):`ensure_conversation_loaded` 的 session 回写上移到适配层(或用参数关掉),避免任务链路产生"写 session"语义。
|
||||||
|
5. **适配层保留**:auth.py、security.py(CSRF)、api_auth.py(g.api_username)、chat/*、status/*、conversation.py 路由、auth_helpers 装饰器、@with_terminal、socket_handlers.py —— 全部 A 类,不动。
|
||||||
|
|
||||||
|
**依赖深度评估:浅层**。命中点统计:B 类真正的"无条件耦合"仅 2 处(tasks/models.py 的创建入口与线程包装),守卫型耦合集中在 context.py 一个文件;执行链路 9 个 chat_flow 文件 + core/* + modules/* + utils/* 全部干净。显式化的关键是「快照 + principal 显式传递」,无需动 Agent loop。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 附:B 类一键核对清单(改造后可回归验证)
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R4 验收范围)**:以下搜索可作辅助检查,不应要求整个 server/context.py 零 has_request_context:HTTP 适配层保留合法请求上下文与本报告的适配层保留原则一致。应验证已迁移的公共执行路径不再依赖请求上下文,并覆盖 host/Web/API 资源选择、显式参数与默认值优先级、正常与异常入口。
|
||||||
|
|
||||||
|
- [ ] `grep -rn "test_request_context" server core modules utils` → 0 命中
|
||||||
|
- [ ] `grep -rn "session\[" server/tasks/models.py` → 0 命中(入口与线程)
|
||||||
|
- [ ] `grep -rn "has_request_context" server/context.py` → 0 命中(改显式参数后)
|
||||||
|
- [ ] `_run_chat_task` 线程主体:`from flask` / `session` 0 引用
|
||||||
|
- [ ] `create_chat_task` 无 `session_data` 参数时拒绝受理(而不是静默读 session 或 try/except 吞错)
|
||||||
102
cache_research/gateway/eval_summary.md
Normal file
102
cache_research/gateway/eval_summary.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# Gateway 化改造范围与复杂度评估(汇总)
|
||||||
|
|
||||||
|
> 日期:2026-09-07
|
||||||
|
> 依据:主智能体核心链路精读 + 4 个子智能体只读盘点(静态分析,未做运行复现)
|
||||||
|
> 子报告:eval_task_entry_points/、eval_flask_context_deps/、eval_endpoint_classification/、eval_event_approval_coupling/
|
||||||
|
> 对照计划:../gateway_work_plan.md(三阶段路线)
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|阅读说明)**:以下保留原始评估,通过注释标出修正和设计边界。总体认可三阶段路线:阶段二是范围可控、少数枢纽风险较高的重构,阶段三是主要新增工作量;静态分析与端点计数不能替代行为验收。对照计划实际位于同目录 `gateway_work_plan.md`。下一步建议编写 `docs/runtime_contract.md`,不再扩大端点普查。
|
||||||
|
|
||||||
|
## 0. 核心结论(直接回答「工作量是否非常大」)
|
||||||
|
|
||||||
|
**「196 个 API 端点需要 Gateway 化」是误解。** 逐一读完 196 个端点函数体后确认:
|
||||||
|
|
||||||
|
| 类别 | 数量 | 占比 | 是否迁移 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| T1 任务受理 | 3 | 1.5% | ✅ 必须(POST /api/tasks、/api/v1/.../messages、/api/workflow/activate) |
|
||||||
|
| T2 任务控制 | 13 | 6.6% | ✅ 必须(cancel×2、runtime_guidance/queue×4、workflow deactivate、sub_agents/background 停止×3、审批回答×3) |
|
||||||
|
| T3 任务观察 | 14 | 7.1% | ⚠️ 可不动(只读 REST,轮询协议天然可复用) |
|
||||||
|
| C CRUD | 131 | 66.8% | ❌ 不动 |
|
||||||
|
| S 状态查询 | 20 | 10.2% | ❌ 不动 |
|
||||||
|
| A 认证管理 | 15 | 7.7% | ❌ 不动 |
|
||||||
|
|
||||||
|
**必须迁移的最低集合 = 16 个端点(8.2%)**,且它们全部收敛到同一批 manager 单例方法(task_manager / sub_agent_manager / background_command_manager / 三个 approval manager)。Gateway 化的实质 = **把这几个 manager 方法提升为 RuntimeService 公共入口**,HTTP 端点从「直接调 manager」改为「调 RuntimeService」,而不是改写端点本身。
|
||||||
|
|
||||||
|
范围边界(按 R1 修订):16 个是当前分类下的受理/控制端点集合,**不是架构改造完成的充分条件**。`server/chat/permission.py` 的权限/执行环境/网络权限变更端点在任务运行中会向运行态排队生效,必须进入阶段一的状态责任表;其余 166 个端点应描述为「多数可保持 HTTP 兼容不动」,不能概括为与运行时零耦合。查询端点可保留原 URL,但 RuntimeService 应同时暴露内部查询接口(如 `get_task_events`),后台调用方(CLI/定时任务)不必为观察任务再发 HTTP 请求。新增门面也不等于全部写入口已收敛。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R1 范围)**:16 个是当前分类下的受理/控制端点集合,不是架构改造完成的充分条件。`server/chat/permission.py` 的权限、执行环境和网络权限变更会向运行态排队,必须进入状态责任表;其余端点应描述为“多数可保持 HTTP 兼容”,不能概括为与运行时零耦合。查询端点可保留原 URL,但后台调用方应能通过内部接口查询,不必为了观察任务再发 HTTP 请求。新增门面也不等于全部写入口已经收敛。
|
||||||
|
|
||||||
|
迁移复杂度分布(16 个,按端点子报告明细表核对):**12 小 / 3 中 / 1 大**(唯一的大项 = workflow activate,因门闸 token 移交 + 状态机编排)。过渡方案是只下沉 Task 创建调用、编排留 HTTP 层;按 R2,会话补建、激活、门闸移交和失败回滚属于业务流程,长期应按复用需求下沉到工作流服务供非 HTTP 入口调用(无需全部塞入 RuntimeService)。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R2 计数与编排)**:11 + 3 + 1 = 15;按端点子报告详细表应为 **12 小 / 3 中 / 1 大 = 16**,这仍是定性估算。Workflow 编排留在 HTTP 层只适合作为过渡:会话补建、激活、门闸移交和失败回滚属于业务流程,应按复用需求下沉到工作流服务,供非 HTTP 入口调用;无需全部塞入 RuntimeService。
|
||||||
|
|
||||||
|
Socket.IO 侧:10 个事件中仅 `stop_task`(T2)活跃;`send_message` 已短路废弃(死代码),聊天主交互全走 REST。
|
||||||
|
|
||||||
|
## 1. 关键利好:代码现状比预期更适合改造
|
||||||
|
|
||||||
|
1. **任务链路 Flask 依赖是「浅层入口型」**:B 类(必须消除)仅 6 项,集中在 2 个文件——`server/tasks/models.py`(15 行)+ `server/context.py`(31 行)。执行体完全干净:`core/`、`modules/`、`utils/` 零 Flask 依赖;9 个 `chat_flow*.py` 文件函数体内零 session 使用;WebTerminal 无 `self.session` 属性(同名变量全是容器句柄)。
|
||||||
|
2. **执行链已是单一收敛的(限正常受理路径)**:5 类来源、6 处调用位置共用 `create_chat_task → 线程 → _run_chat_task → run_chat_task_sync → process_message_task → handle_task_with_sender` 一条链。例外(按 R3 补充):`chat_flow_task_main.py:645-675` 存在异常回退——任务创建失败后直接 `handle_task_with_sender`,**绕过 TaskRecord 登记**(无事件 deque、不可按 task_id 轮询/取消);该回退在外围轮询器预占门闸保护下运行,不能仅凭此认定并发写入 bug,但**迁移验收必须覆盖回退路径的记录、事件、取消和门闸生命周期**。socket `send_message`(socket_handlers.py:259)已短路返回 DEPRECATED,属死代码,与该活跃回退是两回事。另按 R3:`core/modules/utils` 无直接 Flask 导入已确认,但不能外推为所有间接调用都不依赖请求上下文,需回归验证兜底。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R3 依赖与异常路径)**:本轮窄核验确认 `core/modules/utils` 无直接 Flask 导入,但不能外推为所有间接调用都不依赖请求上下文。“无旁路”须限定为正常受理路径:`server/chat_flow_task_main.py:645–675` 在任务创建失败后直接执行 `handle_task_with_sender`,绕过新的 TaskRecord 登记;外围轮询器有预占门闸,因此不能仅凭回退认定并发写入 bug。迁移验收必须覆盖异常回退的记录、事件、取消和门闸生命周期。Socket `send_message` 在 `server/socket_handlers.py:259` 已短路返回,应与该活跃回退分开。“5 个调用点”实际是 5 类来源、6 处调用位置。
|
||||||
|
3. **签名已大部分显式化**:`create_chat_task` 15 个参数、`process_message_task(terminal, message, sender, workspace, username, gate_token...)` 已是显式签名。
|
||||||
|
4. **门闸是独立干净组件**(main_task_gate.py 73 行,挂 terminal 对象,零 Flask 依赖),直接复用。
|
||||||
|
5. **支撑链路 3/4 可直接复用**:事件(task_id+idx+用户房间)、取消(username+task_id 寻址+硬取消)、保存(merge-on-save + I/O 锁 + 门闸三道防线)——耦合键语义已收敛为 task_id/username/conversation_id,无 terminal_id 耦合。
|
||||||
|
|
||||||
|
## 2. 真实工作量构成
|
||||||
|
|
||||||
|
### 阶段一:固定契约(文档为主)
|
||||||
|
- 产出 `docs/runtime_contract.md` + 状态责任表 + 概念对齐 + 调用方迁移表 + 回归用例
|
||||||
|
- **复杂度:低**。不写生产代码,但需要精读现状(本次盘点已完成大部分素材积累)
|
||||||
|
|
||||||
|
### 阶段二:公共任务入口(核心改造)
|
||||||
|
| 改动项 | 位置 | 复杂度 |
|
||||||
|
|---|---|---|
|
||||||
|
| 新建 RuntimeContext + RuntimeService 接口。RuntimeContext 按 R4 分三层:**可信身份与资源范围**(username/workspace_id/host_mode/host_workspace_id/is_api_user/role)/ **本次任务参数**(run_mode/thinking_mode/model_key/message 等)/ **内部执行信息**(门闸 token、通知回滚数据——**不得成为普通客户端可提交字段**);明确默认值、对话配置与本次覆盖的解析优先级,避免仅将 session_data 大字典换名 | 新文件 | 小 |
|
||||||
|
| create_chat_task 快照显式化(删除 session 直读 else 分支与 setdefault 兜底,无上下文时拒绝受理而非静默空快照) | models.py:177-209 | 中 |
|
||||||
|
| 拆除 test_request_context 桥 | models.py:794-810 | 中 |
|
||||||
|
| **get_user_resources 参数化**(host/docker/api 三分支,is_api_user/host_mode 选错即静默串工作区) | context.py:221-535 | **中~大(最高风险)** |
|
||||||
|
| 迁移 6 个调用点到 RuntimeContext | tasks/api.py、api_v1.py、workflow_runtime_api.py×2、chat_flow_task_main.py×2 | 各小~中 |
|
||||||
|
| 审批超时参数透传(调用点补传 timeout_seconds) | chat_flow_tool_loop.py:435/574/883/1153 | 小 |
|
||||||
|
| 配套:`_apply_workspace_personalization_preferences` 参数化、`ensure_conversation_loaded` 回写上移、auth_helpers record/role 显式化 | context.py、auth_helpers.py | 小 |
|
||||||
|
| 回归测试(同对话并发/跨对话隔离/保存不丢消息/取消/审批重复回答/偏移恢复) | test/ | 中 |
|
||||||
|
|
||||||
|
- **复杂度:中**。触及生产代码约 6-8 个文件(估算),每处改动有明确的回退策略(先加显式参数变体、保留兼容期、再拆桥——两步法)。按 R4 补充:资源解析 `get_user_resources` 有约 170+ 调用处(含 83 处 @with_terminal 装饰器路径),影响面需通过 host/Web/API 身份 × 不同会话 × 不同默认值来源的回归验证覆盖,不宜承诺每处改动都小。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R4 上下文设计与工作量)**:9 字段是旧 session 依赖的搬迁清单,不是最终领域模型。至少区分“可信身份与资源范围”“本次任务参数”“内部执行信息”;门闸 token、通知回滚等内部信息不得成为普通客户端可提交字段。明确默认值、对话配置与本次覆盖的解析优先级,避免仅将 session_data 大字典换名。6–8 个文件属于估算,资源解析有约 170+ 调用处(含装饰器路径,见上下文子报告),影响面需通过 host/Web/API 身份、不同会话、不同默认值来源的回归验证;不宜承诺每处改动都小。
|
||||||
|
|
||||||
|
### 阶段三:定时任务(净新增子系统)
|
||||||
|
| 新增项 | 说明 | 复杂度 |
|
||||||
|
|---|---|---|
|
||||||
|
| Schedule/Occurrence 持久化 | 选型文件或 SQLite(先列原子更新/唯一性/查询/恢复要求),走运行态路径 | 中 |
|
||||||
|
| 调度器循环 | 单活动所有权防多进程重复派发;tick 扫描到期 Occurrence | 中 |
|
||||||
|
| 幂等与恢复 | 触发标识 = schedule_id+计划时间点;崩溃窗口对账;重启恢复计划与记录 | 中~大 |
|
||||||
|
| 审批无人值守语义 | 超时注入 + 超时语义决策(拒绝工具继续 vs 结束任务,**产品决策**)+ 等待循环 stop 检查 + 条目 TTL | 中 |
|
||||||
|
| 触发记录查询端点 | 新增 T3 类端点(旧任务清理/重启后仍能解释触发结果) | 小 |
|
||||||
|
| 前端 UI | 计划管理界面(创建/暂停/恢复/删除/触发历史) | 中 |
|
||||||
|
|
||||||
|
- **复杂度:中~大**。全新代码,但可与阶段二解耦验证(可控时钟 + 执行替身)
|
||||||
|
|
||||||
|
## 3. 风险与难点排序
|
||||||
|
|
||||||
|
1. **get_user_resources 参数化**(阶段二)——host_mode / is_api_user 分支选错会**静默串工作区**,是全改造最高风险点。缓解:先加显式参数变体与 web 路径并存,逐个调用方迁移。
|
||||||
|
2. **门闸 token 移交语义**(阶段二)——「预占→session_data 移交→线程认领→finally 释放/失败回滚」是跨线程隐式协议,RuntimeContext 必须原样承载。
|
||||||
|
3. **`task_type="notice"` 互斥豁免**(阶段二)——多智能体/完成通知链路依赖它跳过单对话互斥,重排互斥规则会引发并发回退。
|
||||||
|
4. **审批超时语义**(阶段三,按 R5 修订)——拒绝一个工具后继续运行不自动意味着不安全(后续动作仍受权限约束);等待人工、到期终止、拒绝当前动作后继续是不同产品策略,「定时任务禁止人工审批」不是必选技术条件;工具审批、计划审批、用户提问应分别定义超时含义。**该决策不阻塞阶段二**(阶段二上下文重构保持原有语义即可先行)。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R5 审批产品边界)**:拒绝一个工具后继续运行不自动意味着不安全,后续动作仍受权限约束。等待人工、到期终止、拒绝当前动作后继续是不同产品策略,不能把“定时任务禁止人工审批”当作必选技术条件。工具审批、计划审批、用户提问也应分别定义超时含义。超时策略可在阶段三明确,不必阻塞保持原有语义的阶段二上下文重构。
|
||||||
|
5. **事件前台干扰**(阶段三)——定时任务事件会推到在线用户的 socket 房间,需加 source 字段或确认产品预期。
|
||||||
|
6. **三套身份取数来源统一**(阶段二)——web session / token session / web_terminal 属性,语义等价但路径不同,需收敛为 `RuntimeContext.from_*` 构造族。
|
||||||
|
|
||||||
|
## 4. 附带发现(与改造无直接依赖,建议独立处理)
|
||||||
|
|
||||||
|
- **疑似 bug(静态疑点,待运行时验证)**:审批条目的 `task_id` 字段实际恒 None——`getattr(web_terminal, "task_id", None)`(chat_flow_tool_loop.py:411/545/845/1115)全仓无赋值点。高置信推断,未排除动态 setattr;按 R6 应验证运行时载荷后定性。影响:审批无法按 task_id 检索关联。
|
||||||
|
- **静默降级隐患**:`create_chat_task` 无 session_data 时 `except Exception` 吞错后得到空快照——阶段三定时任务若直调旧入口会静默丢身份。阶段二的显式化会顺带消除。
|
||||||
|
- **审批等待期间软停止无效(按 R6 限定范围)**:标准停止按钮走 REST 硬取消,**可以**打断审批等待;缺口仅限「仅设置软停止标志」的路径(socket `stop_task` 软 stop),下一次工具调用行首检查才生效。另需跟进:超时/取消后 pending 条目的终态更新、审批与 Run 的关联;条目清理不能只靠 TTL 删除仍有合法等待者的请求。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R6 取消与生命周期)**:支撑链报告 §3.1 明确标准停止按钮走 REST 硬取消,能够打断审批等待;缺口应限定为仅设置软停止标志的路径,不能描述成所有停止按钮失效。另需跟进超时/取消后的 pending 终态更新和审批与 Run 的关联;task_id 恒 None 目前仍是静态疑点,应验证运行时载荷后定性。条目清理不能只靠 TTL 删除仍有合法等待者的请求。
|
||||||
|
|
||||||
|
## 5. 建议实施顺序
|
||||||
|
|
||||||
|
1. 阶段一契约文档(本次盘点报告可直接作为素材底稿)
|
||||||
|
2. 阶段二两步走:① RuntimeContext(三层分离)+ 显式受理签名(保留 test_request_context 兼容兜底)→ ② get_user_resources 参数化后拆桥。**审批超时语义决策不阻塞本阶段**(保持原有语义,仅建立参数透传机制)
|
||||||
|
3. 阶段三定时任务(可控时钟 + 执行替身先行验证,再接真实入口;审批/提问/计划三类超时含义在本阶段分别定义)
|
||||||
@ -0,0 +1,248 @@
|
|||||||
|
# create_chat_task 调用点盘点与「显式运行时上下文」迁移难度评估
|
||||||
|
|
||||||
|
- 分析范围:`server/` 目录(只读分析,未修改任何文件)
|
||||||
|
- 对象:`TaskManager.create_chat_task`(`server/tasks/models.py:132`)的全部 5 处真实调用点
|
||||||
|
- 目标:评估「抽 RuntimeService 公共入口(显式运行上下文)」的改造范围与难度
|
||||||
|
- 结论可信度:代码级事实(函数/行号/参数)经逐一阅读,**百分百确定**;迁移难度与工作量为分析师判断(清晰标注为估计)
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|证据口径)**:本报告为静态分析,“百分百确定”不适用于可达性、间接依赖和完整行为。下表按 5 类来源列出 6 处调用;数量应统一按“类别”或“代码位置”表述。下面对 Socket 活跃性的判断已有交叉核验修正。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 一张图看懂执行链(5 个调用点共用)
|
||||||
|
|
||||||
|
```
|
||||||
|
调用点(5 处)
|
||||||
|
└─> task_manager.create_chat_task(...) models.py:132
|
||||||
|
├─ ① 参数归一化 + 单对话互斥检查 models.py:151-172
|
||||||
|
├─ ② 构造 TaskRecord + session 快照 models.py:174-209 ← 隐式上下文在这里被固化
|
||||||
|
└─ ③ threading.Thread(_run_chat_task).start() models.py:212 ← 每个任务一个 daemon 线程
|
||||||
|
└─ _run_chat_task(rec, images, videos, files) models.py:785-1096
|
||||||
|
├─ test_request_context + 灌 session + get_user_resources models.py:794-810 ← 唯一 Flask 隐式依赖
|
||||||
|
├─ ensure_conversation_loaded / 模式覆盖 ...
|
||||||
|
└─ run_chat_task_sync(...) models.py:985
|
||||||
|
└─ process_message_task(...) chat_flow.py:133(run_chat_task_sync = process_message_task,chat_flow.py:280)
|
||||||
|
└─ loop.create_task(handle_task_with_sender(...)) chat_flow.py:159-166
|
||||||
|
└─ handle_task_with_sender(...) chat_flow_task_main.py:1478 ← 真正的执行主循环
|
||||||
|
└─ 尾部按需 spawn 两个通知轮询线程(socketio.start_background_task)L2643 / L2695
|
||||||
|
```
|
||||||
|
|
||||||
|
**要点**:5 处调用点中**没有任何一处直接 spawn `handle_task_with_sender`**——全部经由
|
||||||
|
`create_chat_task → 线程 → _run_chat_task → run_chat_task_sync → process_message_task → asyncio.create_task(handle_task_with_sender)`。
|
||||||
|
只有两处例外(非本次 5 点范围,但相关):
|
||||||
|
- `chat_flow_task_main.py:664`:`_dispatch_completion_user_notice` 在 `create_chat_task` 抛异常时的**回退路径**,用 `asyncio.create_task(handle_task_with_sender(...))` 直接在当前事件循环执行;
|
||||||
|
- `socket_handlers.py:345` → `start_chat_task`(chat_flow.py:267,`socketio.start_background_task(process_message_task, ...)`):WebSocket 实时消息**绕开 task_manager**、直连执行链的并行路径。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R3 可达性修正)**:`server/socket_handlers.py:259` 已直接返回 `DEPRECATED`,上述 Socket 路径属于不可达遗留代码,不应计为当前活跃入口。完成通知异常回退则仍可达(`server/chat_flow_task_main.py:645–675`),会绕过新的任务记录;外围已有门闸预占,不能仅据此断言并发写入错误。迁移时应明确异常回退的受理记录、取消关联和门闸收尾,而非默认原样保留直接执行即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 五个调用点逐一分析
|
||||||
|
|
||||||
|
### 1.1 调用点①:`server/tasks/api.py:200`(create_task_api,POST /api/tasks)
|
||||||
|
|
||||||
|
| 维度 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 触发来源 | **Web HTTP POST 请求**(Flask 路由,真实 request context 内)。`@tasks_bp.route("/api/tasks")`(L110)、`@api_login_required`(L111)、`@rate_limited("chat_task_create",30,60)`(L112)、`def create_task_api`(L114) |
|
||||||
|
| 隐式上下文信息 | ① `username = get_current_username()`(L115)= `session["username"]`(auth_helpers.py:42-44);② `workspace_id = session.get("workspace_id") or "default"`(L116);③ **未传 `session_data`** → `create_chat_task` 内部直接快照真实 session(models.py:194-205):`username / role / is_api_user / host_mode / host_workspace_id / workspace_id / run_mode / thinking_mode / model_key`;④ 请求体全来自 `request.get_json()`(L117) |
|
||||||
|
| 传给 create_chat_task 的参数 | L200-212:`username, workspace_id, message, images, conversation_id, videos=, model_key=, thinking_mode=, run_mode=, max_iterations=, message_source=, goal_mode=, skill_context_messages=, files=` |
|
||||||
|
| 其中来自隐式上下文的参数 | `username`(session)、`workspace_id`(session)、以及整个 session 快照(models.py:194-205,即 `record.session_data`) |
|
||||||
|
| handle_task_with_sender 触发方式 | 不自接 spawn:`create_chat_task`(L212 线程)→ `_run_chat_task` → `run_chat_task_sync` → `process_message_task` → `loop.create_task(handle_task_with_sender)`(chat_flow.py:159-166) |
|
||||||
|
| 迁移难点 | **小~中**。请求体字段已全部显式;只需把「session 快照」(models.py:194-205)改为构造显式上下文对象。注意 `get_current_username()` 不保证非空(None 时 create_chat_task 会照常登记,属既有行为,需在公共入口统一校验)。`role / is_api_user / host_mode` 三字段必须从 session 取出传入,否则后台线程会退化为网页默认语义 |
|
||||||
|
|
||||||
|
### 1.2 调用点②:`server/api_v1.py:320`(send_message_api,POST /workspaces/\<workspace_id\>/messages)
|
||||||
|
|
||||||
|
| 维度 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 触发来源 | **Web HTTP POST 请求**(Bearer Token API)。`@api_v1_bp.route(...)`(L256)、`@api_token_required`(L257)、`@rate_limited("api_v1_send_msg",20,60)`(L258)、`def send_message_api`(L259) |
|
||||||
|
| 隐式上下文信息 | ① `api_token_required`(api_auth.py:47-49)把 **`session["username"]`、`session["role"]="api"`、`session["is_api_user"]=True`** 写入 session(复用现有上下文/工作区逻辑);② `username = session.get("username")`(L260);③ `workspace_id` 来自 URL 路径(`_resolve_workspace` L221-225 → `state.api_user_manager.ensure_workspace`);④ **未传 `session_data`** → `create_chat_task` 快照 session(models.py:194-205),其中 `is_api_user=True、role="api"` 是 API 语义的关键字段 |
|
||||||
|
| 传给 create_chat_task 的参数 | L320-329:`username, workspace_id=ws.workspace_id, message, images, conversation_id, model_key=, thinking_mode=, run_mode=, max_iterations=`(**无 session_data、无 files/videos**) |
|
||||||
|
| 其中来自隐式上下文的参数 | `username`(session)、`role/is_api_user/host_mode/host_workspace_id/workspace_id/run_mode/thinking_mode/model_key`(session 快照,models.py:194-205) |
|
||||||
|
| handle_task_with_sender 触发方式 | 同 1.1(线程 → run_chat_task_sync → loop.create_task) |
|
||||||
|
| 迁移难点 | **中**。最大风险:`is_api_user=True / role="api"` 必须显式进入运行时上下文,否则后台任务线程内 `get_user_resources`(context.py:462)会把 API 用户当网页用户走 `user_manager` → 工作区解析错乱或抛错。另外 API 用户的 `get_user_resources` 要求 `workspace_id` 非空(context.py:465-468),本入口已满足。`terminal.user_role="api"`(context.py:525/538)还会影响配额/角色语义,上下文必须携带 `role` |
|
||||||
|
|
||||||
|
### 1.3 调用点③:`server/workflow_runtime_api.py`(工作流入口,2 处子调用)
|
||||||
|
|
||||||
|
**3a:L184(api_activate_workflow,POST /api/workflow/activate)**
|
||||||
|
|
||||||
|
| 维度 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 触发来源 | **Web HTTP POST 请求**(slash 菜单激活)。route(L38)、`@api_login_required`(L39)、`@with_terminal`(L40)、`def`(L41)。真实 request context 内 |
|
||||||
|
| 隐式上下文信息 | ① `username`:`@with_terminal`(context.py:685-711)内 `get_current_username()`=session;② `terminal/workspace`:`@with_terminal` 内 `get_user_resources`(context.py:699)——该函数自身大量读 session(`host_mode` L239、`host_workspace_id/workspace_id` L246-247、`is_api_user` L462、`run_mode/thinking_mode` L368-369、`model_key` L122);③ `session_data` **显式构造**(L168-183:username、message_source="workflow"、main_task_gate_token、auto_user_message_event、auto_user_message_payload)——但**未含 host_mode** → `create_chat_task` L185-187 仍从真实 session `setdefault("host_mode", session.get("host_mode"))` 读取 |
|
||||||
|
| 传给 create_chat_task 的参数 | L184-192:`username, workspace_id(workspace.workspace_id), prompt, [], conversation_id, message_source="workflow", session_data=session_data` |
|
||||||
|
| 其中来自隐式上下文的参数 | `username`(session);`session_data["host_mode"]`(create_chat_task L185 从真实 session 补);gate token 预占在真实 request context 内完成(try_acquire_main_task_gate) |
|
||||||
|
| handle_task_with_sender 触发方式 | 同主链路(create_chat_task → 线程) |
|
||||||
|
| 迁移难点 | **小~中**。已约 80% 显式化。剩余:host_mode 的 session 读取(L185)、以及「门闸 token 预占 → 随 session_data 移交 → 任务线程认领释放」(main_task_gate.py)的隐式语义必须保留在运行时上下文中 |
|
||||||
|
|
||||||
|
**3b:L264(api_deactivate_workflow,POST /api/workflow/deactivate)**
|
||||||
|
|
||||||
|
| 维度 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 触发来源 | **Web HTTP POST 请求**(用户主动"停止工作流")。route(L210)、`@api_login_required`(L211)、`@with_terminal`(L212)、`def`(L213)。内部先 `wsm.poll_notices()` 取柔性通知;**仅当主任务门闸可预占**(try_acquire_main_task_gate 成功)时立即派发一轮任务,否则通知留池(多一轮轮询器/工具循环消费) |
|
||||||
|
| 隐式上下文信息 | 同 3a:username/terminal/workspace 来自 `@with_terminal`;`session_data` 显式(L251-262,含 main_task_gate_token),host_mode 由 create_chat_task L185 从真实 session 补 |
|
||||||
|
| 传给 create_chat_task 的参数 | L264-272:`username, workspace_id, notice_text, [], conversation_id, message_source="workflow", session_data=session_data` |
|
||||||
|
| 其中来自隐式上下文的参数 | `username`(session);host_mode(L185 补) |
|
||||||
|
| handle_task_with_sender 触发方式 | 同主链路 |
|
||||||
|
| 迁移难点 | **小~中**。与 3a 同模式;额外注意派发失败时的 `restore_notices` 回滚与门闸释放语义(L277-303)——显式化改造不得吞掉这些副作用 |
|
||||||
|
|
||||||
|
### 1.4 调用点④:`server/chat_flow_task_main.py:613`(完成通知派发)
|
||||||
|
|
||||||
|
| 维度 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 触发来源 | **内部后台轮询线程(任务完成通知派发链)**。`_dispatch_completion_user_notice`(L521,def)被 `poll_completion_notifications`(L961,L1064 处 await 调用)触发;该轮询器由 `handle_task_with_sender` 尾部(L2643)`socketio.start_background_task(run_completion_poll)` 在**独立线程**中启动(自带 asyncio 新事件循环 run_until_complete,5s 间隔轮询、最长 1h)。启动条件:主任务结束时检测到子智能体/后台命令仍在运行或有待通知项/工作流待通知(L2610-2626)。**触发源头仍是用户某次会话产生的后台工作者完成事件**,但执行时点与主请求已完全解耦 |
|
||||||
|
| 隐式上下文信息 | **无 Flask session/request 依赖**(后台线程内无请求上下文)。`session_data` 由 `web_terminal` 属性显式构造(L570-576):`username / role=web_terminal.user_role / is_api_user=(user_role=="api") / host_mode=(workspace.username=="host") / host_workspace_id / workspace_id / run_mode / thinking_mode / model_key`。唯一残留:create_chat_task L185 的 `session.get("host_mode")` 在无上下文时抛 RuntimeError、被 except 吞掉后走显式值——**无副作用但属隐式残留** |
|
||||||
|
| 传给 create_chat_task 的参数 | L613-623:`username, workspace_id, user_message, [], conversation_id, model_key=session_data.get(...), thinking_mode=session_data.get(...), run_mode=session_data.get(...), session_data=session_data`(含 main_task_gate_token(L584,轮询器预占移交)、auto_user_message_event、auto_user_message_payload、preceding_user_notices) |
|
||||||
|
| 其中来自隐式上下文的参数 | 无(全部来自 web_terminal/workspace 已显式值) |
|
||||||
|
| handle_task_with_sender 触发方式 | 主链路(create_chat_task → 线程)。**另有回退**:L664 `asyncio.create_task(handle_task_with_sender(...))`,当 create_chat_task 抛异常时在当前轮询事件循环内直接执行(L663-674) |
|
||||||
|
| 迁移难点 | **小(五处中最顺)**。已全显式;只需把「web_terminal/workspace 属性 → session_data」的构造提取为共享 helper(如 `RuntimeContext.from_terminal(terminal, workspace, username, ...)`),并保留「gate token 移交 + 回退直接执行」两条语义 |
|
||||||
|
|
||||||
|
### 1.5 调用点⑤:`server/chat_flow_task_main.py:1416`(多智能体 idle 派发)
|
||||||
|
|
||||||
|
| 维度 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 触发来源 | **内部后台轮询线程(多智能体 pending 消息派发)**。`_dispatch_multi_agent_idle_messages`(L1247,def)被 `poll_multi_agent_notifications`(L1101,L1189 处 await 调用)触发;该轮询器由 handle_task_with_sender 尾部(L2695)`socketio.start_background_task(run_ma_poll)` 在**独立线程**中启动。仅多智能体模式;主对话空闲且 `MultiAgentState` 有 pending_master_messages 时 drain 并触发新一轮工作(与调用点④的轮询器完全分离,避免竞争单工作区互斥,见 L2646 注释) |
|
||||||
|
| 隐式上下文信息 | **无 Flask session/request 依赖**。`session_data` 显式构造(L1351-1360,同调用点④模式);`task_type="notice"`(L1427)用于**绕过普通 chat 任务的单对话互斥**(models.py:160-172 只对 task_type=="chat" 做互斥) |
|
||||||
|
| 传给 create_chat_task 的参数 | L1416-1428:`username, workspace_id, last["text"], [], conversation_id, model_key=session_data.get(...), thinking_mode=session_data.get(...), run_mode=session_data.get(...), session_data=session_data, task_type="notice"` |
|
||||||
|
| 其中来自隐式上下文的参数 | 无(显式) |
|
||||||
|
| handle_task_with_sender 触发方式 | 主链路(create_chat_task → 线程)。异常时直接 raise 回轮询器由调用方处理(L1429-1437) |
|
||||||
|
| 迁移难点 | **小**。注意保留 `task_type="notice"` 的互斥豁免语义与 `preceding_user_notices` 回放(L1398-1411);无 gate token 参与 |
|
||||||
|
|
||||||
|
### 1.6 汇总表
|
||||||
|
|
||||||
|
| # | 文件:行 | 入口函数 | 触发来源 | 是否有隐式 Flask 依赖 | 显式化程度 | 迁移难度 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| ① | tasks/api.py:200 | create_task_api | Web HTTP POST | 强(username/workspace_id/session 快照 L194-205) | 低(无 session_data) | 小~中 |
|
||||||
|
| ② | api_v1.py:320 | send_message_api | Web HTTP POST(Bearer token) | 强(session["username"/"role"/"is_api_user"]、session 快照) | 低(无 session_data) | **中** |
|
||||||
|
| ③a | workflow_runtime_api.py:184 | api_activate_workflow | Web HTTP POST | 中(username;host_mode 补读 L185;gate token 隐式移交) | 高(session_data 显式) | 小~中 |
|
||||||
|
| ③b | workflow_runtime_api.py:264 | api_deactivate_workflow | Web HTTP POST | 中(同上;+通知池/门闸回滚) | 高(session_data 显式) | 小~中 |
|
||||||
|
| ④ | chat_flow_task_main.py:613 | _dispatch_completion_user_notice | 内部后台轮询线程(socketio.start_background_task,L2643) | 极弱(仅 L185 异常吞掉的 session.get 残留) | 极高(全显式) | **小** |
|
||||||
|
| ⑤ | chat_flow_task_main.py:1416 | _dispatch_multi_agent_idle_messages | 内部后台轮询线程(L2695) | 极弱(同上) | 极高(全显式 + task_type="notice") | **小** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `create_chat_task` 函数本身与执行链路的关系
|
||||||
|
|
||||||
|
**位置**:`server/tasks/models.py:132-215`(TaskManager 方法)。
|
||||||
|
|
||||||
|
**签名**(L132-149):
|
||||||
|
```python
|
||||||
|
def create_chat_task(self, username, workspace_id, message, images, conversation_id,
|
||||||
|
videos=None, model_key=None, thinking_mode=None, run_mode=None,
|
||||||
|
max_iterations=None, session_data=None, message_source=None,
|
||||||
|
goal_mode=False, skill_context_messages=None, files=None,
|
||||||
|
task_type="chat") -> TaskRecord
|
||||||
|
```
|
||||||
|
|
||||||
|
**做了什么**(按顺序):
|
||||||
|
1. **参数归一化**(L151-158):run_mode 白名单校验(fast/thinking/deep,非法即抛 ValueError);task_type 归一化(默认 "chat")。
|
||||||
|
2. **单对话互斥**(L160-172):`task_type=="chat"` 时,同一对话(业务 id 去 `conv_` 前缀后比对)存在 `status ∈ {pending, running}` 且 task_type=="chat" 的任务 → 抛 `RuntimeError`(前端 409)。完成通知/多智能体派发用 `task_type="notice"` 豁免。
|
||||||
|
3. **构造 TaskRecord**(L173-175):生成 uuid task_id(L173),`TaskRecord(...)`(L174)初始 status="pending"。
|
||||||
|
4. **session 快照**(L177-209)——**隐式上下文核心**:
|
||||||
|
- `session_data is not None`(显式分支,工作流/通知/多智能体调用):L178-191,`setdefault workspace_id/message_source/goal_mode/skill_context_messages`,并 **仍读 session**:L185 `snapshot.setdefault("host_mode", session.get("host_mode"))`、L187 `host_workspace_id ← session.get(...) or workspace_id`(无请求上下文时抛 RuntimeError 被 except 吞掉,走显式值)。
|
||||||
|
- `session_data is None`(隐式分支,Web/API v1 调用):L193-205,从**真实 Flask session** 快照 `username / role / is_api_user / host_mode / host_workspace_id / workspace_id / run_mode / thinking_mode / model_key`(均 session.get,可能为 None)+ message_source/goal_mode/skill_context_messages。
|
||||||
|
- 快照结果存 `record.session_data`,后台线程据此**重建** session。
|
||||||
|
5. **登记 + 起线程**(L210-214):锁内注册 `self._tasks[task_id]`;`threading.Thread(target=self._run_chat_task, args=(record, images, videos or [], files or []), daemon=True)`;status="running";`thread.start()`;返回 record。
|
||||||
|
|
||||||
|
**与执行链路的关系**:`create_chat_task` 是 **「受理(互斥)+ 快照(隐式上下文固化)+ 执行(spawn 线程)」三合一的公共受理入口**。
|
||||||
|
线程内 `_run_chat_task`(L785-1096)完成:重建 session(L794-810)→ 解析终端/工作区 → 加载对话 → 覆盖模型/模式 → 注入 user_message 事件 → 挂 sender(事件入队 + socketio 推送)→ `run_chat_task_sync`(= `process_message_task`,chat_flow.py:280/133)→ asyncio `handle_task_with_sender`(chat_flow.py:159-166)→ 结束态处理(stopped/succeeded + work_timer + gate 兜底释放)。
|
||||||
|
因此「把 5 个调用点收敛到 RuntimeService」在实现上等价于:**保留 create_chat_task 的受理/执行骨架,把第 4 步的 session 快照替换为显式上下文对象**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `test_request_context` 依赖清单(需要显式化的字段列表)
|
||||||
|
|
||||||
|
**唯一位置**:`server/tasks/models.py:794-810`(`server/` 与 `modules/` 全域 grep 仅此一处)。
|
||||||
|
|
||||||
|
**包装结构**:
|
||||||
|
```
|
||||||
|
792 # 为后台线程构造最小请求上下文,填充 session
|
||||||
|
793 from server.app import app as flask_app
|
||||||
|
794 with flask_app.test_request_context():
|
||||||
|
795 try:
|
||||||
|
796 for k, v in (rec.session_data or {}).items(): # 把快照灌回 session
|
||||||
|
797 if v is not None:
|
||||||
|
798 session[k] = v
|
||||||
|
799 if session.get("host_mode"):
|
||||||
|
800 session["workspace_id"] = workspace_id
|
||||||
|
801 session["host_workspace_id"] = session.get("host_workspace_id") or workspace_id
|
||||||
|
802-807 write_host_workspace_debug(...)
|
||||||
|
808 except Exception:
|
||||||
|
809 pass
|
||||||
|
810 terminal, workspace = get_user_resources(username, workspace_id=workspace_id, conversation_id=rec.conversation_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么需要它**:`_run_chat_task` 跑在**裸 `threading.Thread`** 里,没有 Flask 请求上下文。而 `get_user_resources`(context.py:221)内部以 `has_request_context()` 为开关读取 session:
|
||||||
|
- L239 `host_mode_session = bool(session.get("host_mode"))` → 决定是否走**宿主机多工作区解析路径**;
|
||||||
|
- L246-247 `session.get("host_workspace_id") / session.get("workspace_id")` → 宿主机路径下 workspace 选择兜底;
|
||||||
|
- L462 `is_api_user = bool(session.get("is_api_user"))` → 决定走 `api_user_manager` 还是 `user_manager`(**最关键,错选会串工作区**);
|
||||||
|
- L368-369 `session.get('run_mode') / session.get('thinking_mode')` → 新建 terminal 的默认档;
|
||||||
|
- L122/L175-178 `session.get("model_key")`(`_apply_workspace_personalization_preferences`)→ 恢复会话模型;
|
||||||
|
- auth_helpers.py:51-52 `get_current_user_role` 读 `session["role"]` → 决定 `terminal.user_role`(context.py:525/538,影响配额/角色语义)。
|
||||||
|
|
||||||
|
若去掉该包装,`has_request_context()` 恒 False → host_mode、is_api_user 恒 False → 宿主机多用户、API token 用户的任务会解析到错误工作区,或直接 NoWorkspaceError。**它本质上是「把调用时点(请求上下文内)的身份/偏好搬运到异步执行时点(后台线程)的桥」。**
|
||||||
|
|
||||||
|
**包装体内读取/写入的 Flask 隐式状态**:
|
||||||
|
| 项 | 位置 | 方向 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| session[\*](全部 session_data 键) | models.py:796-798 | 写 | 重建请求上下文 |
|
||||||
|
| session["workspace_id"] | models.py:800 | 写 | host_mode 下回写 |
|
||||||
|
| session["host_workspace_id"] | models.py:801 | 写+读 | host_mode 下回写 |
|
||||||
|
| session["host_mode"] | context.py:239 | 读 | 宿主机路径选择 |
|
||||||
|
| session["host_workspace_id"] | context.py:246 | 读 | 宿主机 workspace 兜底 |
|
||||||
|
| session["workspace_id"] | context.py:247 | 读 | 同上 |
|
||||||
|
| session["run_mode"] | context.py:368 | 读 | 新建 terminal 默认档 |
|
||||||
|
| session["thinking_mode"] | context.py:369 | 读 | 同上 |
|
||||||
|
| session["is_api_user"] | context.py:462 | 读 | api vs web 资源管理器 |
|
||||||
|
| session["model_key"] | context.py:122 | 读 | 恢复会话模型 |
|
||||||
|
| session["role"] | auth_helpers.py:51 | 读 | terminal.user_role |
|
||||||
|
|
||||||
|
**消除它需要显式传入的字段清单(RuntimeContext 最小字段集,9 项)**:
|
||||||
|
1. `username`(已显式:rec.username,L787)
|
||||||
|
2. `workspace_id`(已显式:rec.workspace_id,L788;get_user_resources L810 已显式传参)
|
||||||
|
3. `host_mode: bool` ← 现读 session_data["host_mode"];决定宿主机路径
|
||||||
|
4. `host_workspace_id: Optional[str]` ← 现读 session_data;宿主机多工作区兜底
|
||||||
|
5. `is_api_user: bool` ← 现读 session_data;**决定 api_user_manager / user_manager**(最高风险)
|
||||||
|
6. `role: str` ← 现读 session_data;写入 terminal.user_role
|
||||||
|
7. `run_mode: Optional[str]` ← 现读 session_data;新建 terminal 默认档
|
||||||
|
8. `thinking_mode: Optional[bool]` ← 现读 session_data;同上
|
||||||
|
9. `model_key: Optional[str]` ← 现读 session_data;恢复会话模型
|
||||||
|
|
||||||
|
**附注(不需要显式化的)**:`message_source / goal_mode / skill_context_messages / main_task_gate_token / auto_user_message_event / auto_user_message_payload / preceding_user_notices` 已由调用方经 `session_data` 显式传入,可原样并入 RuntimeContext,只是换一个容器。
|
||||||
|
|
||||||
|
> **审阅注释(2026-09-07|R4 职责分组)**:上述清单说明迁移时不能丢字段,不表示应全部并入一个公共 RuntimeContext。区分可信身份/资源范围、本次任务参数、内部执行信息;门闸 token 与通知回滚信息保持内部生成和传递,不能由普通客户端任意提交。迁移契约还需定义默认值、对话配置和请求覆盖的解析优先级,避免只替换字典名称。
|
||||||
|
|
||||||
|
**改造路径**:把 models.py:794-810 替换为:
|
||||||
|
`context = RuntimeContext.from_session_snapshot(rec.session_data)`(或直接由调用方传入)
|
||||||
|
`terminal, workspace = get_user_resources_explicit(username, workspace_id, conversation_id, runtime_context=context)`
|
||||||
|
并在 context.py 中为 `get_user_resources` 增加显式参数变体(web 路径保持不变)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 总体结论
|
||||||
|
|
||||||
|
### 4.1 收敛为公共入口需要动多少处
|
||||||
|
|
||||||
|
| 面 | 位置 | 改动性质 | 量级 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 调用方(5 处 6 个函数位置) | tasks/api.py:200、api_v1.py:320、workflow_runtime_api.py:184 与 264、chat_flow_task_main.py:613 与 1416 | 把「各自取 session / 拼 session_data」改为构造统一 RuntimeContext 对象 + 调公共入口 | 6 处,各 **小**(合计小~中) |
|
||||||
|
| 受理入口 | models.py:132-215(create_chat_task) | 第 4 步 session 快照(L177-209)显式化;暴露公共入口签名(显式上下文入参) | **中** |
|
||||||
|
| 后台执行桥 | models.py:794-810(test_request_context) | 拆除,改传显式上下文调用资源解析 | **中** |
|
||||||
|
| 资源解析 | context.py:221 `get_user_resources` 及其内部 8-10 处 `session` 读取(L239/246/247/368/369/462/122 + auth_helpers role) | 增加显式上下文参数变体,has_request_context 分支与显式分支并存 | **中~大**(最高风险) |
|
||||||
|
| 关联但不在本 5 点内 | socket_handlers.py:345 → start_chat_task(socket 直连执行链);chat_flow_task_main.py:664(通知回退直接执行 handle_task_with_sender);main_task_gate 门闸移交/认领/释放语义 | 若要「全入口收敛」,socket 实时路径也需接入公共入口(否则公共入口不覆盖主交互通道) | 另计,**中** |
|
||||||
|
|
||||||
|
### 4.2 核心难点排序(按风险/工作量)
|
||||||
|
|
||||||
|
1. **`get_user_resources` 的 session 隐式读取参数化**(context.py)
|
||||||
|
这是拆除 test_request_context 的前提,也是风险最高的点:`host_mode`(宿主机多工作区)与 `is_api_user`(API vs Web 资源管理器)两处分支选错即**静默串工作区**。需要新增显式上下文变体、保持 web 路径不动,改动面涉及其调用链上 `_apply_workspace_personalization_preferences`、`get_current_user_role` 等。
|
||||||
|
2. **API 身份字段(is_api_user / role)的正确传递**(api_v1.py 入口)
|
||||||
|
调用点②是唯一没有 session_data 的 API 来源;快照逻辑(models.py:194-205)移到显式上下文后,必须保证这两个字段不丢、不误判,否则 API 用户后台任务退化为网页语义。
|
||||||
|
3. **main_task_gate 门闸移交语义**(workflow_runtime_api / 通知轮询链)
|
||||||
|
「预占 → token 随 session_data 移交 → 任务线程认领 → finally 释放 / 失败回滚(restore_notices)」是跨线程的隐式协议,RuntimeContext 必须原样承载 token 与失败回滚语义。
|
||||||
|
4. **`task_type="notice"` 互斥豁免与单对话互斥的语义保持**(chat_flow_task_main.py:1416)
|
||||||
|
多智能体 idle 派发依赖 notice 跳过 chat 互斥(models.py:160-172);若公共入口重排互斥规则,需防止完成通知/多智能体链路的并发回退。
|
||||||
|
5. **各入口身份取数的三套来源统一**(web session / token session / web_terminal 属性)
|
||||||
|
①读真实 session、②读 token 注入的 session、④⑤读 web_terminal 属性——三者语义等价但取数路径不同,需收敛为同一个 `RuntimeContext.from_*` 构造 helper,避免迁移后行为漂移。
|
||||||
|
|
||||||
|
### 4.3 一句话总结
|
||||||
|
|
||||||
|
5 个调用点本身**都不难迁**(④⑤已全显式、①②只需打包 session 字段、③已 80% 显式),真正的大头在 `server/tasks/models.py` 的快照 + `test_request_context`(唯一隐式桥)与 `server/context.py` 的资源解析显式化;若把 socket 实时路径也纳入收敛,则为 6 个入口、整体工作量**中~大**,建议分两步:先做「RuntimeContext 对象 + 显式受理签名(保留 test_request_context 兜底期兼容)」,再拆 `get_user_resources` 的 session 读取。
|
||||||
148
cache_research/gateway/gateway_work_plan.md
Normal file
148
cache_research/gateway/gateway_work_plan.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# Astrion 运行时边界整理与定时任务工作清单
|
||||||
|
|
||||||
|
> 更新日期:2026-09-07
|
||||||
|
> 状态:修订后的设计与实施建议,尚未实施。
|
||||||
|
> 原题为「Astrion Gateway 化工作清单」,保留文件名便于既有引用。
|
||||||
|
> 依据:用户对目标的澄清、三份研究报告,以及本轮三个 Luna 子智能体的只读核查。
|
||||||
|
> 本轮核查为静态代码分析,没有运行并发、断线或进程重启复现;下文区分已有机制、待核验风险与拟新增能力。
|
||||||
|
|
||||||
|
## 0. 改造目标与范围
|
||||||
|
|
||||||
|
本次目标是让 Astrion 的内部职责、状态修改规则和任务入口更清晰,使后续功能沿稳定边界扩展。当前明确的新功能场景是**定时任务**。
|
||||||
|
|
||||||
|
Gateway 是长期架构方向;近期交付是在现有进程中整理运行时服务边界,让 Web、CLI 和定时触发器复用任务受理、执行与控制逻辑。验收依据是新增入口所需理解和修改的范围,以及现有行为是否得到保留。
|
||||||
|
|
||||||
|
近期主线:
|
||||||
|
|
||||||
|
1. 固定已有状态边界和正确性保障。
|
||||||
|
2. 抽出接收显式运行上下文的公共任务入口。
|
||||||
|
3. 通过定时任务验证该入口,并补齐调度所需的持久记录和生命周期。
|
||||||
|
|
||||||
|
事件持久化、传输替换、公开 SDK、设备配对和远程执行分别评估,不构成本次完成的前置条件。
|
||||||
|
|
||||||
|
### 参考资料的使用边界
|
||||||
|
|
||||||
|
- 原始愿景:`.astrion/user_upload/Astrion_Architecture_Product_Roadmap_Review_1.md`,重点 §10–14、§35、§37–38。
|
||||||
|
- 原始盘点:`astrion_audit/astrion_gateway_gap.md`。其中“无唯一 owner”“补丁不是投影”“恢复机制脆弱”等结论须结合下表理解,不能直接当作未修复故障。
|
||||||
|
- 外部研究:`opencode_study/opencode_architecture.md`、`openclaw_study/openclaw_gateway.md`。参考其职责分离、契约、幂等和恢复设计,不要求复制其部署、存储或认证方案;外部实现未在本轮重新核验。
|
||||||
|
|
||||||
|
## 1. 现状:已有保障、剩余耦合与待核验事项
|
||||||
|
|
||||||
|
以下路径相对仓库根目录;行号是本轮读取时的定位锚点,后续以符号为准。
|
||||||
|
|
||||||
|
| 类别 | 当前证据 | 对改造的含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 已有:对话级资源隔离 | `server/context.py:58` 按 username/workspace/conversation 生成终端 key | 保留每对话资源边界;多端共享同一对话资源本身合理 |
|
||||||
|
| 已有:主任务门闸 | `server/chat_flow.py:139` 获取门闸,`:258` 在 finally 释放;实现位于 `server/main_task_gate.py` | 公共入口复用同一执行裁决,不新增平行门闸 |
|
||||||
|
| 已有:对话保存保护 | `utils/conversation_manager/crud_mixin.py:335` 按 message_id 合并、防止意外缩减;`:194` 使用 I/O 锁;`index_mixin.py:104` 原子替换文件 | 将保存语义纳入契约;不能因存在内存和文件副本就断言写入竞争未解决 |
|
||||||
|
| 已有:客户端恢复 | `static/src/stores/task.ts:156` 偏移轮询;`static/src/app/methods/taskPolling/probe.ts:73` 对账恢复;同目录 `lifecycle.ts:137` 按 task_id/idx 去重 | 保留恢复闭环和过期响应过滤;当前轮询是增量事件读取,不是每轮全量读取 |
|
||||||
|
| 已有:审批单次决定 | `modules/tool_approval_manager.py:65` 在锁内裁决 pending,已决定时返回现状 | 复用决定规则;记录谁决定与提前认领审批是不同需求 |
|
||||||
|
| 耦合:执行依赖 Web 环境 | `server/tasks/models.py:785` 后台任务建立 test_request_context、回填 session 后获取资源和执行 | 优先抽出显式上下文,减少隐式请求状态依赖 |
|
||||||
|
| 耦合:写入口分散 | 任务 manager、terminal、conversation manager 各有职责和直接调用方 | 按状态明确权威、写入口和缓存更新;新增门面不是完成所有权收敛的证明 |
|
||||||
|
| 边界:过程记录为内存态 | `server/tasks/models.py:75` 有界事件 deque,`:108` 清理旧任务,`:762` 分配任务级 idx;审批 manager 为进程内实例 | 不能承诺服务重启续跑或无限期回放;也不能说任务一结束事件就立即丢失 |
|
||||||
|
| 优化候选:用户级广播 | `server/context.py:75` 给事件补 conversation_id,由客户端筛选 | 当前也是投影方案;对话级订阅用于减少无关流量和明确受众,不是 Owner 正确性的必选前提 |
|
||||||
|
| 新能力:用户定时任务 | 本轮搜索只发现任务清理、idle reaper 等维护定时器,未发现用户 Schedule 实体与到期派发链路 | 单独设计调度状态与记录,复用现有任务执行 |
|
||||||
|
|
||||||
|
保留两个有界核验项,避免将静态推断直接升级为故障结论:
|
||||||
|
|
||||||
|
- `server/tasks/models.py:157` 的“检查运行中任务→创建记录”与最终执行门闸分属两处。核验并发请求是否产生重复任务记录;不能据此断言会并发修改同一对话。
|
||||||
|
- 原报告提到 `server/chat/terminal.py::issue_socket_token` 发放竞争。本轮未重新复现;若仍存在,作为独立的小范围缺陷处理,不捆绑整个运行时改造。
|
||||||
|
|
||||||
|
## 2. 阶段一:固定契约与已有不变量
|
||||||
|
|
||||||
|
**目标:明确状态属于谁、新入口应该调用哪里。**
|
||||||
|
|
||||||
|
- [ ] 产出 `docs/runtime_contract.md`,先描述内部服务契约;暂不强制公开网络协议、握手或实体全面改名。
|
||||||
|
- [ ] 建立状态责任表:每项列明权威来源、允许的修改方、持久化入口、缓存刷新、并发裁决和失效条件。内存/文件/客户端缓存可以共存,权威关系必须明确。
|
||||||
|
- [ ] 对齐概念:Session 对应现有 conversation,Run 对应一轮主任务,Schedule 表示计划,Occurrence 表示某次到期触发,Event 表示变化通知。子 agent、后台命令与主任务的关系单独说明,不直接把所有现有 task 等同主 Run。
|
||||||
|
- [ ] 审批与用户提问保留不同语义。可共享 ID、关联、等待和回答的基础设施,但提问答案不能被当作工具执行授权。
|
||||||
|
- [ ] 明确公共入口所需的 principal、workspace、conversation、模型/运行配置与事件输出接口;默认值由明确的解析步骤产生。
|
||||||
|
- [ ] 建立调用方迁移表:Web/CLI 对应 API、Workflow 激活、通知派发、定时触发。每项标注上下文来源、门闸获取/释放和取消传播。
|
||||||
|
- [ ] 围绕修改范围保留或补充回归用例:同对话并发、不同对话隔离、保存不丢消息、取消、审批重复回答、偏移恢复和过期响应过滤。
|
||||||
|
|
||||||
|
**完成标准:**目标入口的状态修改路径和执行裁决可定位;已有保障成为明确约束,未验证风险有独立记录。
|
||||||
|
|
||||||
|
## 3. 阶段二:抽出显式上下文与公共任务入口
|
||||||
|
|
||||||
|
**目标:无浏览器也能通过受控入口启动、观察和停止一轮任务。**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Web / CLI 适配层 定时触发器 Workflow / 通知派发
|
||||||
|
\ | /
|
||||||
|
公共任务受理与控制入口
|
||||||
|
|
|
||||||
|
现有执行门闸与 Agent 执行
|
||||||
|
|
|
||||||
|
现有保存、审批、事件和取消链路
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 以 `TaskManager.create_chat_task` 及执行链路为基础抽出服务接口。`RuntimeService` 可作为名称候选;文件位置按真实职责确定,避免把所有 manager 和状态塞入一个新类。
|
||||||
|
- [ ] 服务接口至少覆盖提交、查询和取消;审批回答复用现有 manager,通过明确关联接入服务层。会话创建按需要复用现有服务,第一条验证链路可使用已有会话。
|
||||||
|
- [ ] HTTP 参数解析、Cookie/CSRF/Bearer 验证放在适配层,向服务层传入可信 principal 和经校验的资源范围。不能接受客户端或 Schedule payload 自报的 role 作为授权依据。
|
||||||
|
- [ ] 消除目标执行链路对隐式 Flask session 的读取。迁移期可在明确的兼容适配层保留旧上下文,但须列出剩余依赖;只把 test_request_context 包进新方法不算完成解耦。
|
||||||
|
- [ ] 复用门闸、取消、审批和保存规则;分别定义任务受理去重与实际执行互斥,防止重复启动或门闸泄漏。
|
||||||
|
- [ ] Web 任务入口先接入服务,再逐条迁移 Workflow/通知等调用方;每次明确哪些旧写路径已封闭。不能把“门面转发成功”写成“全部状态唯一 Owner 已完成”。
|
||||||
|
- [ ] 内部错误采用稳定状态/错误码,HTTP 状态码由适配层映射;事件先适配现有 idx/offset 和 sender,不同时重写客户端。
|
||||||
|
|
||||||
|
**完成标准:**不创建浏览器会话、不伪造 HTTP 请求,测试能通过显式身份和资源上下文启动一次受控任务、读取结果并取消;同对话重入仍受门闸保护;现有 Web/CLI 行为兼容。执行可使用可控模型/工具替身验证,无需真实外部副作用。
|
||||||
|
|
||||||
|
## 4. 阶段三:定时任务纵向落地
|
||||||
|
|
||||||
|
**目标:时钟成为公共任务入口的另一个调用方。**
|
||||||
|
|
||||||
|
本节是待实施设计,不代表当前已有调度器。具体 UI 和默认行为在实现前确认,下列保守默认作为讨论起点。
|
||||||
|
|
||||||
|
### 4.1 计划与触发记录
|
||||||
|
|
||||||
|
- [ ] Schedule 至少保存:ID、所属 principal、目标 workspace、会话策略、提示词/任务配置、时间规则、时区、启用状态和配置版本。明确夏令时重复/不存在时刻的处理。
|
||||||
|
- [ ] 支持创建、暂停、恢复和删除计划。建议暂停/删除只影响未来触发,已受理的 Run 另行取消;最终行为须明确。
|
||||||
|
- [ ] 明确使用已有会话还是每次新建会话;首版可只实现一种,须说明上下文累积、目标删除和工作区失效时的行为。
|
||||||
|
- [ ] 模型配置确定“创建时固定”还是“触发时解析”;权限在触发时按当前有效授权重新校验,不保存可绕过权限变更的长期授权快照。
|
||||||
|
- [ ] 每个 Occurrence 有稳定触发标识,例如 schedule_id + 计划时间点;保存所用配置版本、受理状态、run_id 和终态。计划编辑后的未来触发身份规则须明确。
|
||||||
|
|
||||||
|
### 4.2 重复、重叠与停机
|
||||||
|
|
||||||
|
- [ ] 为 Occurrence 登记和 Run 受理定义持久幂等规则:同一触发重试返回同一受理结果,相同键不同参数拒绝;记录保留期覆盖允许的重试窗口。
|
||||||
|
- [ ] 明确“登记后未启动”“已启动但关联未写完”等崩溃窗口。登记/受理应原子提交或具备可验证的恢复对账;不能只用内存 TTL 承诺跨重启不重复执行。
|
||||||
|
- [ ] 选定单个活动调度器的启动与所有权规则,防止重载器或多个服务进程重复派发;不要求因此引入分布式基础设施。
|
||||||
|
- [ ] 同会话已有任务或上一轮仍在运行时,定义 skip/queue/parallel 策略。建议首版跳过并记录原因,沿用会话门闸,不默默增加无限队列。
|
||||||
|
- [ ] 定义服务关闭期间错过触发的策略。建议首版记录错过并等待下个未来时点,不集中补发;服务需运行才会触发,本阶段不包含操作系统唤醒/开机自启。
|
||||||
|
- [ ] 重启后恢复 Schedule 和触发记录;上一进程未确认完成的 Run 标为中断或结果待核验,不显示仍在正常运行,不自动重做可能已产生副作用的动作。
|
||||||
|
- [ ] 触发去重仅保证任务受理规则,不承诺任意外部工具副作用 exactly-once。无法确认的执行结果进入显式待核验状态。
|
||||||
|
|
||||||
|
### 4.3 审批、存储与验收
|
||||||
|
|
||||||
|
- [ ] 无人在线时仍保持原有权限限制:遇到审批/提问按明确超时等待,超时结束或中断本轮并记录原因,不自动扩大权限。等待中的任务也遵守重叠策略。
|
||||||
|
- [ ] 审批关联当前 Run/会话,复用现有 pending/answer 链路。重启后旧请求不能被当作仍有执行现场的有效审批;持久审批记录与继续执行分别设计。
|
||||||
|
- [ ] 为 Schedule/Occurrence/必要的运行摘要选择持久化方案,先列出原子更新、唯一性、查询和恢复要求,再决定文件或 SQLite。数据走运行态路径解析,不写源码树;不强制迁移全部对话历史。
|
||||||
|
- [ ] 沿用当前任务事件读取;补充触发记录查询,使旧内存任务被清理或重启后仍能解释触发结果。日志记录 schedule_id/occurrence_id/run_id,复用既有设施。
|
||||||
|
|
||||||
|
**完成标准:**可控时钟与执行替身验证一次触发、重复触发、任务重叠、计划暂停/恢复、目标失效、无人审批超时、停机错过触发及重启对账;真实入口验证不依赖浏览器。明确只恢复计划与记录,不承诺从任意执行位置续跑。
|
||||||
|
|
||||||
|
## 5. 后续独立决策:有明确需求再启动
|
||||||
|
|
||||||
|
| 决策 | 启动条件 | 决策前必须补充的内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| SSE / WebSocket / 保持轮询 | 现有延迟、连接数或带宽不能满足目标,或新增双向交互需求 | 部署 worker 模型、代理缓冲、重连、慢消费者处理;传输更换不等于状态正确性提升 |
|
||||||
|
| 对话级订阅 | 需要减少无关广播、精确控制受众 | 订阅与资源授权分别校验,区分任务/会话事件与用户级通知 |
|
||||||
|
| durable 事件与快照恢复 | 需要超出内存保留窗口的过程追溯、跨重启生命周期查询 | 提交时机、序号作用域、快照边界、日志裁剪、缺口处理与 schema 版本 |
|
||||||
|
| 审批持久化与可恢复执行 | 需要重启后继续等待并执行原动作 | 重建上下文和待执行动作、权限重校验、过期请求处理及结果不确定性;恢复 pending 记录不能实现续跑 |
|
||||||
|
| TS 类型 / SDK 生成 | 对外契约或多客户端类型维护成为实际成本 | 选一个 schema 权威源、兼容策略与生成检查;不强制新网络握手 |
|
||||||
|
| 身份体系整理 | 跨凭证访问同一资源或统一授权成为需求 | 统一 principal/resource/authorization;各适配层可保留不同凭证机制,不能直接合并 Web/API 用户数据空间 |
|
||||||
|
| 设备配对 / Remote Worker | 明确需要多设备接入或远程执行 | 执行契约、连接身份、所有权转移及故障语义,独立设计验收 |
|
||||||
|
|
||||||
|
若进入事件恢复改造,必须遵守以下边界:
|
||||||
|
|
||||||
|
1. 快照注明覆盖的事件水位 N,并与同一状态版本一致;续传从 N 之后开始。快照生成、订阅和历史读取之间不得有漏事件窗口,重复投递仍需幂等应用。
|
||||||
|
2. durable 事件只承诺已提交记录可恢复;live delta 是否恢复单独定义。运行中未完成的 Item 需要当前内容快照、覆盖式更新或不完整标记,不能只依赖最终 completed 事件。
|
||||||
|
3. 状态和事件写入需要一致的提交/恢复规则。对话 JSON、事件 JSONL、审批 JSONL 不天然构成一致快照与日志。
|
||||||
|
4. 连接序号、任务偏移和持久会话序号不能混用;连接重建、历史裁剪或会话重置时给出明确的重新同步规则。
|
||||||
|
5. JSONL 与 SQLite 都需定义恢复、保留和迁移方案。选择依据是事务与查询边界,不承诺以后可低成本平移。
|
||||||
|
|
||||||
|
## 6. 改造完成的判断
|
||||||
|
|
||||||
|
- 新增任务来源只需构造显式上下文、校验目标并调用公共入口,无需复制 Web 聊天启动流程。
|
||||||
|
- 已迁移路径有明确状态权威与写入规则,现有门闸、保存和客户端恢复保护得到保留。
|
||||||
|
- 定时任务有可查询的计划、触发记录和终态;重复、重叠、权限变化与停机行为可解释。
|
||||||
|
- 各阶段可独立验证;只为当前边界迁移做必要的接口调整,不同时重写 Agent loop、前端状态管理或部署拓扑。
|
||||||
|
- 每阶段说明实际迁移的入口、剩余兼容依赖和验证结果。完成门面、生成架构图或更换传输本身不算完成改造。
|
||||||
744
cache_research/gateway/openclaw_protocol.txt
Normal file
744
cache_research/gateway/openclaw_protocol.txt
Normal file
@ -0,0 +1,744 @@
|
|||||||
|
[OpenClawDocs](/)
|
||||||
|
|
||||||
|
[**View as Markdown**View this page as plain text↗](/gateway/protocol.md)[**Open in ChatGPT**Ask questions about this page↗](https://chatgpt.com/?hints=search&q=Read%20from%20https%3A%2F%2Fdocs.openclaw.ai%2Fgateway%2Fprotocol.md%20so%20I%20can%20ask%20questions%20about%20it.)[**Open in Claude**Ask questions about this page↗](https://claude.ai/new?q=Read%20from%20https%3A%2F%2Fdocs.openclaw.ai%2Fgateway%2Fprotocol.md%20so%20I%20can%20ask%20questions%20about%20it.)
|
||||||
|
|
||||||
|
Gateway
|
||||||
|
|
||||||
|
# Gateway protocol
|
||||||
|
|
||||||
|
Gateway & OpsGateway
|
||||||
|
|
||||||
|
The Gateway WS protocol is the single control plane and node transport for OpenClaw. Operator and node clients (CLI, web UI, macOS app, iOS/Android nodes, headless nodes) connect over WebSocket and declare a **role** and **scope** at handshake time.
|
||||||
|
|
||||||
|
## npm packages
|
||||||
|
|
||||||
|
The verified stable package release is `2026.8.1`. Follow [Install the packages](/gateway/clients#install-the-packages) for exact-version commands and compatibility guidance. Package release versions are separate from the wire protocol version and the root `openclaw` CLI release.
|
||||||
|
|
||||||
|
* [`@openclaw/gateway-protocol`](https://www.npmjs.com/package/@openclaw/gateway-protocol) publishes the schemas, validators, TypeScript types, lightweight frame and error helpers, and version constants. Its tarball includes the generated [`protocol.schema.json`](https://unpkg.com/@openclaw/gateway-protocol@2026.8.1/protocol.schema.json) machine-readable contract as a downloadable file, not an exported import subpath.
|
||||||
|
* [`@openclaw/gateway-client`](https://www.npmjs.com/package/@openclaw/gateway-client) publishes the reference Node client and a browser-safe entry at `@openclaw/gateway-client/browser`.
|
||||||
|
|
||||||
|
For application lifecycle guidance, see [Building a Gateway client](https://docs.openclaw.ai/gateway/clients). For apps that supervise the Gateway as a child process, see [Embedding OpenClaw](https://docs.openclaw.ai/gateway/embedding).
|
||||||
|
|
||||||
|
## Transport and framing
|
||||||
|
|
||||||
|
* WebSocket, text frames, JSON payloads.
|
||||||
|
* First frame **must** be a `connect` request.
|
||||||
|
* Pre-connect frames are capped at 64 KiB (`MAX_PREAUTH_PAYLOAD_BYTES`). After handshake, follow `hello-ok.policy.maxPayload` and `hello-ok.policy.maxBufferedBytes`. With diagnostics enabled, oversized inbound frames and slow outbound buffers emit `payload.large` events before the gateway closes or drops the frame. These events carry `surface`, byte sizes, limits, and a safe reason code, never message bodies, attachment contents, raw frame bytes, tokens, cookies, or secrets.
|
||||||
|
* The Gateway offers `permessage-deflate`. Peers that negotiate it (browsers, `ws` clients) receive frames of 4 KiB and up compressed; smaller frames such as streaming deltas stay raw. Context takeover is disabled in both directions, so each frame compresses independently. Peers that do not offer the extension are unaffected. Payload limits apply to the inflated size.
|
||||||
|
|
||||||
|
Frame shapes:
|
||||||
|
|
||||||
|
* Request: `{type:"req", id, method, params, traceparent?}`
|
||||||
|
* Response: `{type:"res", id, ok, payload|error}`
|
||||||
|
* Event: `{type:"event", event, payload, seq?, stateVersion?}`
|
||||||
|
|
||||||
|
After authentication, a client may include a W3C `traceparent` string on each request frame. The Gateway continues a valid value as a child trace context for that request. Missing or syntactically malformed values within the 128-character field limit keep the default fresh request trace and do not fail the RPC; longer values make the request frame invalid. The initial `connect` request never establishes trace context for later frames. Use a separate `traceparent` for each logical request on a long-lived connection; do not treat the WebSocket itself as one trace.
|
||||||
|
|
||||||
|
Response errors use `{ code, message, details?, retryable?, retryAfterMs? }`. Authenticated operator requests share a bounded queue for starting RPC handlers. When waiting capacity is exhausted, the Gateway returns retryable `UNAVAILABLE` before the method runs; retry within the request's budget. Started requests complete concurrently, so responses can arrive out of order.
|
||||||
|
|
||||||
|
Ordinary UI/SDK requests may outlive a socket disconnect, but cannot start a handler in a retiring Gateway instance. Shutdown fences new request entry and joins pending handler loading and authorization before releasing their runtime. Already-started methods retain their own shutdown behavior; shutdown does not wait for every RPC to finish. Exact pending node progress and result replies remain available during node cleanup, until transport shutdown seals entry.
|
||||||
|
|
||||||
|
Clients should branch on `code` and `details.code`; `message` remains human-readable and can change except where a compatibility note says otherwise. Method-level authorization failures use top-level `code: "FORBIDDEN"` with structured missing-scope details:
|
||||||
|
|
||||||
|
* Missing scope: `{ code: "MISSING_SCOPE", missingScope, requiredScopes }`. `requiredScopes` is the complete known scope set for the requested operation. The legacy `missing scope:` message is retained for older clients.
|
||||||
|
|
||||||
|
Clients should read `details` first and use the legacy message only as a compatibility fallback. `readMissingScopeError` and `readMissingScopeErrorDetails` are exported from `@openclaw/gateway-protocol/gateway-error-details`; the browser-safe gateway client re-exports them from `@openclaw/gateway-client/browser`.
|
||||||
|
|
||||||
|
The schemas are exported as `GatewayErrorDetailsSchema`, `MissingScopeErrorDetailsSchema` from `@openclaw/gateway-protocol/schema`. HTTP scope failures mirror the `MISSING_SCOPE` object under `error.details` and use HTTP status `403`.
|
||||||
|
|
||||||
|
Side-effecting methods require idempotency keys (see schema).
|
||||||
|
|
||||||
|
## Gateway-controlled WebRTC Talk
|
||||||
|
|
||||||
|
`talk.client.create` accepts the additive capability `gateway-control-v1`. OpenAI GA Realtime requires resolvable Platform API-key authentication for this mode. Native GPT-Live retains its configured ChatGPT OAuth or Platform API-key authentication. A successful result includes `clientControl: { owner: "gateway" }`, a 60-second single-use Gateway broker token in `clientSecret`, and the relative `offerUrl: "/plugins/openai/realtime/calls"`.
|
||||||
|
|
||||||
|
The client sends only `application/sdp` to that route with the broker token. It must not create a provider control data channel. The Gateway creates the call, attaches the provider sideband before returning the answer SDP, and owns tool, transcript, steering, cancellation, and close lifecycle. Clients that omit the capability retain the existing browser session behavior. A Gateway or configured authentication path that cannot provide the requested owner returns `UNAVAILABLE`; it never downgrades the request to client-owned control.
|
||||||
|
|
||||||
|
Clients must close their local media peer if the Gateway connection is lost or a `talk.event` for their current `voiceSessionId` contains `talkEvent.type: "session.closed"`. Ignore terminal events for other calls; a recoverable `session.error` alone is not a close notification.
|
||||||
|
|
||||||
|
## Handshake
|
||||||
|
|
||||||
|
Gateway sends a pre-connect challenge:
|
||||||
|
|
||||||
|
Device-auth clients use the challenge `ts` as `connect.params.device.signedAt`. For WebSocket challenges, `ts` must be a non-negative integer. Clients that explicitly support Gateways from before `connect.challenge` existed may use local time only when no challenge arrives; a received challenge with an absent or malformed `ts` is invalid.
|
||||||
|
|
||||||
|
Client replies with `connect`:
|
||||||
|
|
||||||
|
Gateway responds with `hello-ok`:
|
||||||
|
|
||||||
|
`server`, `features`, `snapshot`, `policy`, and `auth` are all required by `HelloOkSchema` (`packages/gateway-protocol/src/schema/frames.ts`). `auth` reports the negotiated role and the current socket's effective authorization scopes even when no device token is issued (shape above). `deviceToken`, when present, is the primary reusable credential for the same device and role. `controlUiUrl` optionally advertises the Gateway's configured public Control UI origin and base path for shareable links, independent of the client's tunnel or development-server address. It is omitted when `gateway.publicOrigin` is unset or the Control UI is disabled. It contains no credentials and grants no access. `policy.attachments` is optional (older gateways omit it) and advertises the decoded-size ceilings chat attachments face on `chat.send`, `sessions.send`, and session-creation initial turns:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `maxBytes` | Largest decoded size accepted for a single attachment (`agents.defaults.mediaMaxMb`, default 20 MB) |
|
||||||
|
| `maxImageBytes` | Largest decoded size accepted for a single image: `min(maxBytes, 6 MB agent-hydration cap)` |
|
||||||
|
|
||||||
|
Validating before send:
|
||||||
|
|
||||||
|
1. Check each file's decoded size against `maxImageBytes` for images and `maxBytes` for everything else.
|
||||||
|
2. Serialize the whole request and check its encoded size against `policy.maxPayload`. `policy.attachments` is a per-attachment ceiling, never a promise the frame fits: attachments travel as base64, so a 20 MB file is about 26.7 MB on the wire and exceeds the default 25 MiB frame limit on its own.
|
||||||
|
3. Treat the server as authoritative for everything else. Accepted MIME types and per-message handling are deliberately not advertised because they depend on the entrypoint, the resolved model, and payload sniffing. The gateway can return a typed rejection, while text-only model runs can omit additional images after their offload cap and still complete the request.
|
||||||
|
4. Re-read the values on every reconnect. They are a connection-time snapshot, so a live `mediaMaxMb` edit reaches existing connections only after they reconnect.
|
||||||
|
|
||||||
|
`pluginSurfaceUrls` is optional and maps plugin surface names (e.g. `canvas`) to scoped hosted URLs; it may expire, so nodes call `node.pluginSurface.refresh` with `{ "surface": "canvas" }` for a fresh entry. The deprecated `canvasHostUrl` / `canvasCapability` / `node.canvas.capability.refresh` path is not supported; use plugin surfaces. The `sessions.observer.ask` method was removed; use `sessions.companion.ask`. The snapshot's optional `appliedConfigHash` is the resolved source-config revision accepted by the active Gateway runtime. Clients can compare it with `config.get.configRevisionHash` to determine whether a newer saved config still needs a restart. `config.get.hash` remains the raw root-file revision used by config write conflict guards.
|
||||||
|
|
||||||
|
The snapshot's optional `controlUiIdentityUrl` advertises the active Gateway's HTTPS dashboard URL when it uses trusted-proxy or Tailscale Serve identity. Operator clients can open this URL for personal browser sign-in instead of forwarding shared device credentials. The URL includes the Control UI base path; clients must use normal HTTPS trust instead of native TLS pins and must not send native connection tokens or passwords to it. Re-read it from each authenticated hello snapshot and discard it when that connection closes. If the managed Serve route exits or is replaced, the Gateway closes connections that received its identity URL with code `1012`; reconnect to discover the current route.
|
||||||
|
|
||||||
|
`openclaw.setup.verify` additionally checks the Gateway's current application and restart state before and after its live inference probe. It returns `{ ok: false, status: "unavailable", error }` while saved settings are not active, restart work remains, or the verified runtime changes during the probe. Clients should preserve the selected model and retry after application or restart finishes. Standalone CLI verification still tests saved configuration without requiring a running Gateway.
|
||||||
|
|
||||||
|
While the gateway is still finishing startup sidecars, `connect` can return a retryable `UNAVAILABLE` error with `details.reason: "startup-sidecars"` and `retryAfterMs`. Retry within your connection budget instead of treating it as a terminal handshake failure.
|
||||||
|
|
||||||
|
When a device token is issued, `hello-ok.auth` adds it:
|
||||||
|
|
||||||
|
Built-in QR/setup-code bootstrap is a mobile handoff path. A successful baseline setup-code connect returns a primary node token plus one bounded operator token:
|
||||||
|
|
||||||
|
This operator handoff is bounded on purpose: enough to start the mobile operator loop and native setup, with `operator.write` satisfying Talk sessions and `operator.talk.secrets` covering Talk config reads, but no pairing-mutation scopes and no `operator.admin`. Broader pairing/admin access needs a separate approved pairing or token flow. Persist `hello-ok.auth.deviceTokens` only when bootstrap auth ran over a trusted transport (`wss://` or loopback/local pairing).
|
||||||
|
|
||||||
|
Trusted local backend clients (`client.id: "gateway-client"`, `client.mode: "backend"`) may omit `device` on direct loopback connections when authenticating with the shared gateway token/password. This path is reserved for internal control-plane RPCs (e.g. subagent session updates) and avoids stale CLI/device pairing baselines blocking local backend work. The exception also applies when that backend supplies a signed device identity: it does not create a pairing record, so an unpaired identity receives no device token. Remote, browser-origin, node, and non-backend clients follow their normal pairing and scope-upgrade policies. Device-token authentication still validates the existing token's role and scopes before any local-backend pairing exception.
|
||||||
|
|
||||||
|
### Worker role and closed protocol
|
||||||
|
|
||||||
|
Workers use a closed protocol through either the public `/__openclaw__/worker` WebSocket path on the main TLS endpoint or the dedicated loopback ingress reached through the gateway-owned, host-key-pinned SSH tunnel. The route selects worker mode before reading frames, so it never dispatches general auth, node events, operator RPCs, or plugin methods. Public admission shares the main per-client pre-auth budget and authentication rate limiter; its wire errors collapse credential and environment details to `admission-rejected`, while trusted gateway diagnostics retain the internal reason. A strict `connect` verifies a hash-at-rest, short-lived credential bound to the environment, bundle hash, owner epoch, RPC-set version, expiry, and one nullable session; it separately checks the current version and feature set. Success returns minimal `worker-hello-ok`; feature negotiation is independent of the general protocol version. Frames stay under 64 KiB, except a negotiated `worker.inference.start` frame may be up to 25 MiB. The closed allowlist contains `worker.heartbeat`, `worker.transcript.commit`, `worker.live-event`, `worker.inference.start`, and `worker.inference.cancel`.
|
||||||
|
|
||||||
|
For an identity-audited attached run, the live turn capability can record the credential, build, owner-epoch, and placement checks as one enforced admission receipt. The receipt contains none of the credential, build hashes, tokens, environment id, or session id. Worker operation rows and placement state remain their authoritative owners; successful connection is not an action-success receipt.
|
||||||
|
|
||||||
|
Transcript commits use owner-epoch fencing, a gateway-owned session binding, base-leaf compare-and-swap, and durable sequence replay; the gateway generates transcript entry and parent IDs through the normal session writer. Ownership and expiry are rechecked on each RPC.
|
||||||
|
|
||||||
|
### Client capabilities
|
||||||
|
|
||||||
|
Operator clients may advertise optional capabilities in `connect.params.caps`:
|
||||||
|
|
||||||
|
* `tool-events`: accepts structured tool lifecycle events.
|
||||||
|
* `inline-widgets`: can render hosted inline widget tool results.
|
||||||
|
|
||||||
|
Client capabilities describe the connected client, not authorization. Agent tools may declare required capabilities; the Gateway omits those tools unless every requirement appears in the originating client's `caps`. Channel-originated runs have no Gateway client capabilities, so capability-gated tools are unavailable even when tool policy explicitly allows them.
|
||||||
|
|
||||||
|
### Node connect example
|
||||||
|
|
||||||
|
Nodes declare capability claims at connect time:
|
||||||
|
|
||||||
|
* `caps`: high-level categories such as `camera`, `canvas`, `screen`, `location`, `voice`, `talk`.
|
||||||
|
* `commands`: command allowlist for invoke.
|
||||||
|
* `permissions`: granular toggles (e.g. `screen.record`, `camera.capture`).
|
||||||
|
|
||||||
|
The gateway treats these as claims and enforces server-side allowlists.
|
||||||
|
|
||||||
|
## Roles and scopes
|
||||||
|
|
||||||
|
For the full operator scope model, approval-time checks, and shared-secret semantics, see [Operator scopes](/gateway/operator-scopes).
|
||||||
|
|
||||||
|
Roles:
|
||||||
|
|
||||||
|
* `operator`: control-plane client (CLI/UI/automation).
|
||||||
|
* `node`: capability host (camera/screen/canvas/system.run).
|
||||||
|
* `worker`: cloud execution host on the dedicated, closed worker protocol.
|
||||||
|
|
||||||
|
Operator scopes (`src/gateway/operator-scopes.ts`), the full closed set:
|
||||||
|
|
||||||
|
* `operator.read`
|
||||||
|
* `operator.write`
|
||||||
|
* `operator.admin`
|
||||||
|
* `operator.approvals`
|
||||||
|
* `operator.questions`
|
||||||
|
* `operator.pairing`
|
||||||
|
* `operator.talk`
|
||||||
|
* `operator.talk.secrets`
|
||||||
|
|
||||||
|
`operator.write` continues to satisfy `operator.talk` for compatibility with existing clients. Voice-device setup can issue the narrower Talk grant without general Gateway write access.
|
||||||
|
|
||||||
|
`talk.config` with `includeSecrets: true` requires `operator.talk.secrets` (or `operator.admin`). When secrets are included, read the active Talk provider credential from `talk.resolved.config.apiKey`; `talk.providers..apiKey` stays source-shaped and may be a SecretRef object or a redacted string.
|
||||||
|
|
||||||
|
Plugin-registered gateway RPC methods may request their own operator scope, but these reserved core prefixes always resolve to `operator.admin` (`src/shared/gateway-method-policy.ts`): `config.*`, `exec.approvals.*`, `wizard.*`, `update.*`.
|
||||||
|
|
||||||
|
Method scope is only the first gate. Some slash commands reached through `chat.send` apply stricter command-level checks: persistent `/config set` and `/config unset` writes require `operator.admin` even for gateway clients that already hold a lower operator scope.
|
||||||
|
|
||||||
|
`node.pair.approve` has an extra approval-time scope check on top of the base method scope (`operator.pairing`), based on the pending request's declared `commands` (`src/infra/node-pairing-authz.ts`):
|
||||||
|
|
||||||
|
| Declared commands | Required scopes |
|
||||||
|
| --- | --- |
|
||||||
|
| none | `operator.pairing` |
|
||||||
|
| ordinary commands | `operator.pairing` + `operator.write` |
|
||||||
|
| includes `system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `browser.proxy.upload.v1`, `fs.listDir`, or `system.execApprovals.get/set` | `operator.pairing` + `operator.admin` |
|
||||||
|
|
||||||
|
In this table, `fs.listDir` is the node command relayed through `node.invoke`. The top-level Gateway `fs.listDir` RPC needs `operator.write` for workspace-contained host browsing and `operator.admin` when `nodeId` is present. Pass directory paths exactly as returned by `fs.listDir`: whitespace in directory names, including trailing spaces, is significant.
|
||||||
|
|
||||||
|
### Caps/commands/permissions (node)
|
||||||
|
|
||||||
|
Nodes declare capability claims at connect time:
|
||||||
|
|
||||||
|
* `caps`: high-level capability categories such as `camera`, `canvas`, `screen`, `location`, `voice`, and `talk`.
|
||||||
|
* `commands`: command allowlist for invoke.
|
||||||
|
* `permissions`: granular toggles (e.g. `screen.record`, `camera.capture`).
|
||||||
|
|
||||||
|
The Gateway treats these as **claims** and enforces server-side allowlists. Connected nodes can publish optional agent-visible plugin or MCP tool descriptors with `node.pluginTools.update` after a successful connect or reconnect. Headless node hosts restart to apply declarative MCP inventory changes. This update method is the only publication path; plugin tool descriptors are not accepted in `connect` params. Each descriptor must use a provider-safe tool `name` and name a `command` in the node's current command allowlist. The Gateway trusts descriptor metadata from the paired node, filters descriptors outside the approved command surface, removes them when the node disconnects, and rejects operator attempts to mutate another node's catalog. Set `gateway.nodes.pluginTools.enabled: false` to ignore node-published descriptors.
|
||||||
|
|
||||||
|
Connected node hosts publish their complete skill replacement catalog with `node.skills.update`. This node-role method is the only node skill publication path; skills are not accepted in `connect` params. Each descriptor contains a safe name, description, and bounded `SKILL.md` content. The Gateway parses that content with the normal skills loader, includes it in agent skill snapshots while the node is connected, and removes it on disconnect. Set `gateway.nodes.allowSkills: false` to ignore node-published skills.
|
||||||
|
|
||||||
|
## Presence
|
||||||
|
|
||||||
|
* `system-presence` returns entries keyed by device identity, including `deviceId`, `roles`, and `scopes`, so UIs can show one row per device even when it connects as both operator and node.
|
||||||
|
* `node.list` includes optional `lastSeenAtMs` and `lastSeenReason`. Connected nodes report current connection time with reason `connect`; paired nodes can also report durable background presence via a trusted node event.
|
||||||
|
|
||||||
|
Native macOS nodes can also send authenticated `node.presence.activity` events with bounded input idle time. The Gateway derives activity timestamps on its own clock, exposes the freshest connected Mac through `node.list` and `node.describe`, and broadcasts `node.presence` updates to read-scoped clients. The app sends `{ "action": "clear" }` when the user disables activity sharing; the Gateway clears timestamps only for that exact authenticated node connection. Gateways that predate this acknowledged action return it as unhandled, so the Mac node reconnects once and lets disconnect cleanup remove the old connection state. See [Active computer presence](/nodes/presence) for selection, privacy, model context, and notification-routing behavior.
|
||||||
|
|
||||||
|
### Node host stats
|
||||||
|
|
||||||
|
Connected CLI node hosts and the macOS app's shared node-host worker send a resource snapshot immediately after connecting, then every 60 seconds. They call `node.event` with `event: "node.host.stats"` and an object `payload` (or its JSON encoding in `payloadJSON`):
|
||||||
|
|
||||||
|
`cpuCount` is an integer from 1 to 4096. Optional `loadAverage` contains the 1-, 5-, and 15-minute averages, each finite and between 0 and 100000. Windows has no load average; hosts omit the field when all three readings are zero. Memory and disk values are non-negative integer bytes, with free or available bytes no greater than their total. Disk fields appear together only when the host can read capacity for the volume containing its home directory, independent of the worker's current directory.
|
||||||
|
|
||||||
|
The Gateway accepts updates only from the current node connection and stamps `updatedAtMs` with its own receipt time; nodes never send a timestamp. Successful updates appear as `hostStats` in `node.list` and `node.describe` and broadcast `node.hostStats` with `{ nodeId, hostStats }` to read-scoped operators, using `dropIfSlow: true`. Stats are operator-facing and do not update model-visible node context. When received, the Gateway persists the snapshot as `lastHostStats` on the paired node record. Disconnecting or reconnecting without a new snapshot leaves the previous value intact. `node.list` and `node.describe` use live session stats while connected and project the saved snapshot as `hostStats` while offline, keeping its original `updatedAtMs` so clients can show the last-known age.
|
||||||
|
|
||||||
|
The structured `node.event` result uses `reason: "updated"`, `"stale_connection"`, or `"invalid_payload"`. An older Gateway may return `handled: false`; the node continues at the normal cadence without an immediate retry.
|
||||||
|
|
||||||
|
### Node background alive event
|
||||||
|
|
||||||
|
Nodes call `node.event` with `event: "node.presence.alive"` to record that a paired node was alive during a background wake, without marking it connected:
|
||||||
|
|
||||||
|
`trigger` is a closed enum: `background`, `silent_push`, `bg_app_refresh`, `significant_location`, `manual`, `connect`. Unknown values normalize to `background` (`src/shared/node-presence.ts`). The event only persists for authenticated node device sessions; device-less or unpaired sessions return `handled: false`.
|
||||||
|
|
||||||
|
Successful gateways return a structured result:
|
||||||
|
|
||||||
|
Older gateways may return only `{ "ok": true }` for `node.event`; treat that as an acknowledged RPC, not durable presence persistence.
|
||||||
|
|
||||||
|
## Broadcast event scoping
|
||||||
|
|
||||||
|
Server-pushed broadcast events are scope-gated so pairing-scoped or node-only sessions do not passively receive session content (`src/gateway/server-broadcast.ts`):
|
||||||
|
|
||||||
|
* Chat, agent, and tool-result frames (streamed `agent` events, tool-result events) require at least `operator.read`. Sessions without it skip these frames entirely.
|
||||||
|
* Plugin-defined `plugin.*` broadcasts are gated to `operator.write` or `operator.admin` by default; explicit entries such as `plugin.approval.requested` / `plugin.approval.resolved` use `operator.approvals` instead.
|
||||||
|
* Status/transport events (`heartbeat`, `presence`, `tick`, connect/disconnect lifecycle) stay unrestricted so transport health is observable to every authenticated session.
|
||||||
|
* Unknown broadcast event families are scope-gated by default (fail-closed) unless a registered handler explicitly relaxes them.
|
||||||
|
|
||||||
|
Each client connection keeps its own per-client sequence number, so broadcasts stay monotonically ordered on that socket even when different clients see different scope-filtered subsets of the event stream.
|
||||||
|
|
||||||
|
`hello-ok.features.capabilities` advertises additive wire contracts. Native clients send `sessionKey` in `chat.metadata` only when `session-scoped-chat-metadata` is present; otherwise they retain the agent-only request supported by stable `v2026.7.1-2`. That older response describes agent-wide availability, not a session's selected profile. Retire this negotiation only when the minimum supported Gateway contract guarantees session-scoped metadata. Method or event presence alone is insufficient.
|
||||||
|
|
||||||
|
## RPC method families
|
||||||
|
|
||||||
|
`hello-ok.features.methods` is a conservative discovery list built from `src/gateway/server-methods-list.ts` plus loaded plugin/channel method exports — it is not a generated dump of every method, and some methods (for example `push.test`, `web.login.start`, `web.login.wait`, `sessions.usage`) are intentionally excluded from discovery even though they are real, callable methods. Treat this as feature discovery, not a full enumeration of `src/gateway/server-methods/*.ts`.
|
||||||
|
|
||||||
|
System and identity
|
||||||
|
|
||||||
|
* `health` returns the cached or freshly probed gateway health snapshot.
|
||||||
|
* `diagnostics.stability` returns the recent bounded diagnostic stability recorder: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, session ids. No chat text, webhook bodies, tool outputs, raw request/response bodies, tokens, cookies, or secrets. Requires `operator.read`.
|
||||||
|
* `status` returns the `/status`-style gateway summary; sensitive fields only for admin-scoped operator clients.
|
||||||
|
* `gateway.identity.get` returns the gateway device identity used by relay and pairing flows.
|
||||||
|
* `system-presence` returns the current presence snapshot for connected operator/node devices.
|
||||||
|
* `system-event` appends a system event and can update/broadcast presence context.
|
||||||
|
* `last-heartbeat` returns the latest persisted heartbeat event.
|
||||||
|
* `set-heartbeats` toggles heartbeat processing on the gateway.
|
||||||
|
* `gateway.restart.preflight` is a deprecated, read-only compatibility preview of restart-specific active work. It does not close admission, create a suspension lease, or provide the atomic full-work fence of `gateway.suspend.prepare`; new restart flows should call `gateway.restart.request`.
|
||||||
|
* `gateway.suspend.prepare` creates a short cooperative-suspension lease only when tracked Gateway work is idle. While prepared, authenticated WebSocket connects remain available, but only `gateway.suspend.*` and an exact targeted non-safe `gateway.restart.request` may run; safe and untargeted restarts remain fenced. `gateway.suspend.status` checks the lease, and `gateway.suspend.resume` releases it after thaw or an aborted host operation.
|
||||||
|
Models and usage
|
||||||
|
|
||||||
|
* `models.list` returns the runtime-allowed model catalog. See "`models.list` views" below.
|
||||||
|
* `usage.status` returns provider usage windows/remaining quota summaries. Clients advertising `usage-refreshing` receive an immediate `refreshing: true` placeholder on a cold cache and must refetch on a bounded schedule; other callers block for the cold provider read.
|
||||||
|
* `usage.cost` returns aggregated cost usage summaries for a date range. Pass `agentId` for one agent, or `agentScope: "all"` to aggregate configured agents.
|
||||||
|
* `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only for an explicit live embedding provider ping. Pass `{ "agentId": "agent-id" }` to scope Dreaming store stats to one agent workspace; omitting it aggregates configured Dreaming workspaces.
|
||||||
|
* `doctor.memory.dreamDiary`, `doctor.memory.backfillDreamDiary`, `doctor.memory.resetDreamDiary`, `doctor.memory.resetGroundedShortTerm`, `doctor.memory.repairDreamingArtifacts`, and `doctor.memory.dedupeDreamDiary` accept optional `{ "agentId": "agent-id" }`; omitted, they operate on the configured default agent workspace.
|
||||||
|
* `sessions.usage` returns per-session usage summaries. Pass `agentId` for one agent, or `agentScope: "all"` to list configured agents together. Both usage methods accept `mode: "specific"` with an IANA `timeZone` for DST-aware calendar-day boundaries and buckets. `utcOffset` remains supported for older clients and as a fallback when the Gateway runtime does not recognize the requested zone.
|
||||||
|
* `sessions.usage.timeseries` returns timeseries usage for one session.
|
||||||
|
* `sessions.usage.logs` returns usage log entries for one session.
|
||||||
|
Channels and login helpers
|
||||||
|
|
||||||
|
* `channels.status` returns built-in + bundled channel/plugin status summaries.
|
||||||
|
* `channels.start` (`operator.admin`) starts one channel account runtime without re-authenticating. Params `{ channel, accountId? }`; omitted `accountId` selects the default account. Responds `{ channel, accountId, started, outcome }`, with `started` true only when the resulting runtime snapshot reports `running: true`. `outcome` carries the account lifecycle decision: `{ status: "handed-off" }`, `{ status: "retry", reason }`, or `{ status: "skipped", reason }`. The RPC is a manual override of automatic-start suppression; no `manual` parameter is accepted. This is not a provider-connectivity check; see [Per-account recovery](/cli/channels#per-account-recovery-non-destructive) for reasons and recovery guidance.
|
||||||
|
* `channels.stop` (`operator.admin`) stops one channel account runtime without clearing auth state. Params `{ channel, accountId? }`; omitted `accountId` selects the default account. Responds `{ channel, accountId, stopped }`, with `stopped` true when the resulting runtime snapshot does not report `running: true`. Unlike `channels.logout`, it retains the account's credentials.
|
||||||
|
* `channels.logout` logs out a specific channel/account where the channel supports it.
|
||||||
|
* `web.login.start` starts a QR/web login flow. Params include optional `{ channel, accountId, force, timeoutMs, verbose }`. When `channel` is present, the Gateway normalizes its canonical id or alias and dispatches only to that installed channel plugin. Omitting `channel` preserves the legacy behavior of selecting the first loaded QR-capable provider. A provider may return an opaque `sessionKey` with its QR response.
|
||||||
|
* `web.login.wait` waits for that flow to complete and starts the channel on success. Params include optional `{ channel, accountId, sessionKey, timeoutMs, currentQrDataUrl }`. Use the same `channel` as `web.login.start` and pass its returned `sessionKey` through unchanged so the provider can correlate the wait request with the QR session. Omitting `channel` retains the same legacy provider fallback as `web.login.start`.
|
||||||
|
* `push.test` sends a test APNs push to a registered iOS node.
|
||||||
|
* `voicewake.get` returns the stored wake-word triggers.
|
||||||
|
* `voicewake.set` updates wake-word triggers and broadcasts the change.
|
||||||
|
Plugin management
|
||||||
|
|
||||||
|
* `plugins.list` (`operator.read`) returns the installed plugin inventory plus locally curated official picks, diagnostics, and whether the current install mode allows mutations.
|
||||||
|
* `plugins.search` (`operator.read`) searches installable ClawHub code-plugin and bundle-plugin families. Pass non-empty `query` and optional `limit` from 1 to 100.
|
||||||
|
* `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId, acknowledgeInstallPolicyWarning? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeInstallPolicyWarning? }`. When install policy returns `warn`, the error `details` include `installPolicyCode: "install_policy_warning_acknowledgement_required"`, the target, reason, and optional findings. After review, retrying the same action with `acknowledgeInstallPolicyWarning: true` approves every warning in that install invocation; each warning is freshly evaluated before installation continues. `block` and policy failures remain terminal. ClawHub installs preserve Gateway trust and integrity checks. Successful installs require a Gateway restart.
|
||||||
|
* `plugins.setEnabled` (`operator.admin`) changes one installed plugin's enabled policy with `{ pluginId, enabled }`. The response includes the updated catalog entry, restart metadata, and any slot-selection warnings.
|
||||||
|
* `plugins.uninstall` (`operator.admin`) removes one externally installed plugin with `{ pluginId }`: config references, the install record, and managed files. Bundled plugins cannot be uninstalled, only disabled. The response lists the removal actions and always requires a Gateway restart.
|
||||||
|
Messaging and logs
|
||||||
|
|
||||||
|
* `send` is the direct outbound-delivery RPC for channel/account/thread-targeted sends outside the chat runner.
|
||||||
|
* `logs.tail` returns the configured gateway file-log tail with cursor/limit and max-byte controls.
|
||||||
|
Operator terminal
|
||||||
|
|
||||||
|
* `terminal.open` starts a host PTY for an explicit `agentId` or the default agent and returns the resolved agent, working directory, shell, and confinement state. Passing `sessionKey` binds the PTY to that exact agent session and attaches the calling connection as its first viewer; omitting it creates a connection-owned operator terminal.
|
||||||
|
* `terminal.input` and `terminal.resize` operate on sessions owned by the calling connection and agent-owned sessions where that connection is an attached viewer. `terminal.close` kills a connection-owned session, but only detaches the calling viewer from an established agent-owned session. For a new session-bound Control UI terminal, the initiating viewer's close or disconnect discards the PTY until the browser or exact-session agent first adopts it through an authorized operation.
|
||||||
|
* `terminal.upload` accepts one base64 file up to 16 MiB, stages it in a private 24-hour temporary directory on the session's Gateway or paired-node host, and returns the absolute path. The caller must still paste or otherwise use that path; the RPC never writes terminal input or executes a command.
|
||||||
|
* `terminal.data` and `terminal.exit` events stream to the connection owner and attached viewers. Conversation-owned terminals remain persistent. The agent-facing `terminal` tool can list, read, resize, or close only terminals an operator opened for its exact session; it cannot open terminals. Agent input follows effective session and exec policy: `full` (YOLO) sends immediately, `guarded` and `workspace` (including accept-only or Guardian-reviewed flows) require explicit one-time approval of that exact input, and `read-only` or `deny` blocks it.
|
||||||
|
* Connection-owned sessions whose connection drops are detached, not killed: they stay reattachable for `gateway.terminal.detachedSessionTimeoutSeconds` (default 300; `0` restores kill-on-disconnect) while recent output accumulates in a bounded server-side buffer. Established agent-owned sessions likewise survive viewer disconnect.
|
||||||
|
* `terminal.list` returns attachable sessions. `terminal.attach` returns the replay buffer and either rebinds a connection-owned session (tmux-style take-over — a previous live owner receives `terminal.exit` with reason `detached`) or adds the connection as a viewer of an agent-owned session.
|
||||||
|
* Every terminal method requires `operator.admin`; `gateway.terminal.enabled` is on by default and refuses every method when set to `false`. Fully sandboxed agents are refused, and an agent policy change closes existing and in-flight PTYs, detached ones included.
|
||||||
|
Talk and TTS
|
||||||
|
|
||||||
|
* `talk.catalog` returns the read-only Talk provider catalog for speech, streaming transcription, and realtime voice: canonical provider ids, registry aliases, labels, configured state, an optional group-level `ready` result, exposed model/voice ids, canonical modes, transports, brain strategies, and realtime audio/capability flags, without returning provider secrets or mutating global config. Current gateways set `ready` after applying runtime provider selection; treat its absence as unverified on older gateways.
|
||||||
|
* `talk.config` returns the effective Talk config payload; `includeSecrets` requires `operator.talk.secrets` (or `operator.admin`).
|
||||||
|
* `talk.session.create` (`operator.talk`) creates a gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, non-admin callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`.
|
||||||
|
* `talk.session.appendAudio` appends base64 PCM input audio to gateway-owned realtime relay and transcription sessions.
|
||||||
|
* `talk.session.cancelOutput` stops assistant audio output, primarily for VAD-gated barge-in in gateway relay sessions. Send the current `talk.event.turnId`; the result is `applied`, `stale`, or `idle`.
|
||||||
|
* `talk.session.submitToolResult` completes a provider tool call emitted by a gateway-owned realtime relay session. The request waits for any asynchronous completion signal exposed by the provider bridge; failed submissions keep the linked run active and do not emit a successful tool-result event. Pass `options: { willContinue: true }` for interim tool output or `options: { suppressResponse: true }` when the provider bridge advertises suppression support and the result should not start another response.
|
||||||
|
* `talk.session.steer` sends active-run voice control into a gateway-owned agent-backed Talk session: `{ sessionId, text, mode? }`, where `mode` is `status`, `steer`, `cancel`, or `followup`; omitted mode is classified from the spoken text. It selects only work bound to that logical voice call, not another call sharing the connection and agent session.
|
||||||
|
* `talk.session.close` closes a gateway-owned relay, transcription, or managed-room session and emits terminal Talk events.
|
||||||
|
* `talk.mode` sets/broadcasts the current Talk mode state for WebChat/Control UI clients.
|
||||||
|
* `talk.client.create` creates or resumes a client-owned realtime provider session using `webrtc` or `provider-websocket` while the gateway owns credentials, instructions, tool policy, and the returned `voiceSessionId`. Clients pass `sessionKey` and reuse `voiceSessionId` when replacing the provider transport during one call. Clients that negotiate `gateway-control-v1` keep WebRTC media direct but move the provider control channel and tool lifecycle to the Gateway.
|
||||||
|
* `talk.client.transcript` appends one finalized `{ role, text }` item to the normal agent session. The required `entryId` is idempotent within `voiceSessionId`; retries do not duplicate transcript messages.
|
||||||
|
* `talk.client.close` closes the logical voice session after pending transcript writes. Closing is idempotent and may deliver a mutation-only call digest to the session's last non-WebChat channel.
|
||||||
|
* `talk.client.toolCall` lets client-owned realtime transports forward provider tool calls to gateway policy. The first supported tool is `openclaw_agent_consult`; clients get `runId`, `agentId`, and canonical `agentSessionKey` and wait for normal chat lifecycle events before submitting the provider-specific tool result. Use the returned target for `chat.abort` and `chat.history`; keep the original key for voice-session requests. Voice-bound high-impact actions return `VOICE_CONFIRMATION_REQUIRED:` until a later finalized user utterance explicitly confirms that exact final execution action and the next consult supplies the `confirmationId`; policy or hook rewrites require confirmation again.
|
||||||
|
* `talk.client.steer` sends session-scoped active-run voice control for client-owned realtime transports. The gateway resolves owned active work from `sessionKey`, without a voice call ID, and returns a structured accepted/rejected result instead of silently dropping steering. Provider-attached Gateway controls are call-scoped instead.
|
||||||
|
* `talk.event` is the single Talk event channel for realtime, transcription, STT/TTS, managed-room, telephony, and meeting adapters.
|
||||||
|
* `talk.speak` synthesizes speech through the active Talk speech provider.
|
||||||
|
* `tts.status` returns TTS enabled state, active provider, fallback providers, and provider config state.
|
||||||
|
* `tts.providers` returns the visible TTS provider inventory.
|
||||||
|
* `tts.enable` and `tts.disable` toggle TTS prefs state.
|
||||||
|
* `tts.setProvider` updates the preferred TTS provider.
|
||||||
|
* `tts.convert` runs one-shot text-to-speech conversion.
|
||||||
|
* `tts.speak` (`operator.write`) renders non-empty `text` with the configured general TTS provider chain and returns one whole clip inline as `audioBase64`, plus `provider` and optional `outputFormat`, `mimeType`, and `fileExtension` metadata. Unlike `tts.convert`, it does not return a Gateway-local path; unlike `talk.speak`, it does not require a Talk provider. Text above `tts.maxTextLength` returns `INVALID_REQUEST`; synthesis failures return `UNAVAILABLE`.
|
||||||
|
Secrets, config, update, and wizard
|
||||||
|
|
||||||
|
* `secrets.reload` re-resolves active SecretRefs and atomically publishes owner-aware runtime state. Eligible owner failures can publish as cold or stale degradation with `warningCount`; strict or unmapped failures reject the reload and preserve the active snapshot.
|
||||||
|
* `secrets.resolve` resolves command-target secret assignments for a specific command/target set.
|
||||||
|
* `secrets.store.list` (`operator.admin`) returns team-scoped metadata and values only for `kind: "env"` entries. `kind: "secret"` entries use a distinct result shape with no value field; there is no reveal method.
|
||||||
|
* `secrets.store.set` and `secrets.store.delete` (`operator.admin`) create/update or soft-delete one team-scoped entry. After a successful write, the Gateway refreshes the active secrets runtime only when the name is referenced by a `store` SecretRef in the active source config.
|
||||||
|
* `config.get` returns the current on-disk config snapshot, raw root-file `hash`, resolved `configRevisionHash`, and optional `appliedConfigHash` for the resolved revision accepted by the active Gateway runtime.
|
||||||
|
* `config.set` writes a validated config payload.
|
||||||
|
* `config.patch` merges a partial config update. Destructive array replacement requires the affected path in `replacePaths`; nested arrays under array entries use `[]` paths such as `agents.entries.*.skills`.
|
||||||
|
* `config.apply` validates + replaces the full config payload.
|
||||||
|
* `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, generation metadata, plugin + channel schema metadata when loadable. It includes `title` / `description` metadata from the same labels/help text as the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists.
|
||||||
|
* `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` (`src/config/schema.ts`) and mirrors the gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`.
|
||||||
|
* `update.run` runs the gateway update flow and schedules a restart only if the update succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates and supervised git-checkout updates from the control plane use a detached managed-service handoff instead of replacing the package tree or mutating checkout/build output inside the live gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`. A second concurrent `update.run` handled by the same Gateway process returns `ok: false` with `result.reason: "managed-service-handoff-already-running"` and `handoff.status: "already-running"`; its continuation is not accepted, so the caller can retry after the active update completes. Standalone CLI updaters and replacement Gateway processes are outside this process-local guard. Unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. Unavailable means OpenClaw lacks a safe supervisor boundary or durable service identity, such as `OPENCLAW_SYSTEMD_UNIT` for systemd. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted gateway and writes the final `ok` sentinel.
|
||||||
|
* `update.status` refreshes and returns the latest update restart sentinel, including the post-restart running version when available.
|
||||||
|
* `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC.
|
||||||
|
Agent and workspace helpers
|
||||||
|
|
||||||
|
* `agents.list` returns gateway-visible agent entries, including effective model/runtime metadata and optional semantic `kind` (`agent` or `system`). Entries with recorded creation provenance also include `createdVia` (`operator`, `agent`, or `claw`), nullable `creatorAgentId`, and millisecond `createdAt`; entries without provenance omit those fields. Clients advertise the `agent-kind` handshake capability to receive the complete typed roster; clients without it keep the legacy selector-safe roster without system rows. Kind-aware clients exclude `system` rows from ordinary selectors while retaining them in diagnostic views. Older v4 gateways may return rows without `kind`.
|
||||||
|
* `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring.
|
||||||
|
* `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent.
|
||||||
|
* `audit.activity.list` returns the versioned metadata-only activity ledger; `audit.run.inspect` discovers execution ids or inspects one exact execution identity context; `audit.list` remains the compatibility-safe run/tool RPC.
|
||||||
|
* `agents.workspace.list` and `agents.workspace.get` (`operator.read`) expose read-only, paginated browsing of an agent's workspace directory for clients in the trusted operator domain described in [Operator scopes](/gateway/operator-scopes). Requests accept workspace-relative paths only; reads stay confined to the realpathed workspace root (symlink and hardlink escapes rejected), size-capped, and limited to UTF-8 text plus common image types (base64). Responses do not expose the host workspace path. There are no write operations in this namespace.
|
||||||
|
* `transcripts.list` (`operator.read`) lists durable meeting captures newest first. Optional `limit` accepts 1–200 (default 50); `providerId` filters the source. The `sessions` result includes selectors, provider/source locators, times, active state, utterance counts, participants, summary availability, optional model/heuristic provenance, and an overview preview capped at 280 characters. Source locators expose only `providerId`, `accountId`, `guildId`, `channelId`, and `meetingUrl`, never free-form metadata.
|
||||||
|
* `transcripts.get` (`operator.read`) accepts `selector` and optional `includeUtterances`. It returns the session and stored summary, including its canonical Markdown; requested utterances are sanitized and bounded by the capture limit of 2,000. Missing summaries omit `summary` rather than generating notes. Both transcript methods read across one trusted Gateway domain, like `agents.workspace.*`; separate domains are required for reader isolation. They do not export files or change capture state. See [Transcripts CLI](/cli/transcripts#gateway-and-control-ui-reads).
|
||||||
|
* `tasks.list`, `tasks.get`, and `tasks.cancel` expose the gateway task ledger to SDK and operator clients. See [Task ledger RPCs](#task-ledger-rpcs) below.
|
||||||
|
* `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side.
|
||||||
|
* `environments.list` and `environments.status` (`operator.read`) remain available without cloud-worker profiles and preserve gateway-local and node environment discovery. `environments.list` also accepts an optional `runtimeId` from callers with `operator.write`. That request adds one Gateway-owned `requiredNodeCommand` result to each connected node when the runtime requires a node command. Its closed state is `invocable`, `pending-approval`, `undeclared`, or `unauthorized`; it never exposes the node's full pending declaration. Node environments include the durable `sessionHost` identity used to keep a known offline host visible, while current connected inventory is authoritative over that history. Missing identity means false. Exact bounded `{ total, available }` worker slots are live-only and omitted offline; worker-turn admission consumes a slot, while node-backed remote-exec does not. Configured profile summaries expose their bounded, canonically ordered `executionModes` array plus the existing singular `executionMode` primary/default display projection. Current clients select profiles only by membership in `executionModes`. Configured cloud workers and durable records left by earlier profiles add `worker` metadata with `providerId`, optional `leaseId`, `state`, `ageMs`, optional `idleMs`, and `attachedSessionIds`. Worker lifecycle states are `requested`, `provisioning`, `bootstrapping`, `ready`, `attached`, `idle`, `draining`, `destroying`, `destroyed`, `failed`, and `orphaned`. A connected node may also include `workerBundle: { status: "installed", version }` or `workerBundle: { status: "missing" }`. This optional observation is reconnect-scoped and reports validation of one Gateway-retained bundle; it is not launch authority. The public result never exposes the bundle hash, Gateway namespace, node filesystem path, receipt, or protocol-feature details.
|
||||||
|
* `environments.create` (`{ profileId, idempotencyKey }`) provisions an environment from a configured plugin provider profile; retries with the same key reuse the durable operation. Direct creation without a session does not select an execution mode, so the provider uses its intentional default; Crabbox prepares `worker-turn`. `environments.destroy` (`{ environmentId }`) requests idempotent teardown of a durable worker environment. Both require `operator.admin`, are control-plane writes, and return the same environment summary shape used by status responses.
|
||||||
|
* `worker.desktop.observe` (`{ environmentId, control? }`, `operator.admin`) starts or reuses the environment's desktop forward and returns `{ transport, wsPath, expiresAtMs, control, vncPassword? }`. `wsPath` carries a single-use 60-second token for the Gateway's desktop observer WebSocket; reconnecting requires a fresh observe call. Environments with an observable desktop advertise `worker.desktop: true` in `environments.list`. The method is advertised only when the `cloudWorkers.desktop` lab is enabled. See [Cloud workers](/gateway/cloud-workers#desktop-interactive).
|
||||||
|
* `agent.identity.get` returns the effective assistant identity for an agent or session.
|
||||||
|
* `agent.wait` waits for a run to finish and returns the terminal snapshot when available.
|
||||||
|
Session control
|
||||||
|
|
||||||
|
* `sessions.list` returns the current session index, including per-row `agentRuntime` metadata when an agent runtime backend is configured. `hasActiveRun` is the authoritative aggregate direct-session activity fact. When projected, `activeRunIds` is the complete exact active set; an empty array proves the session is idle. If aggregate activity is true while the field is omitted, another runtime owner is active but its exact identities are unavailable. Snapshot omission means identities unavailable. On incremental events, omission means no change, `null` is the event-only tombstone that clears cached exact IDs to unavailable, and an array replaces the cache. Clients correlate only exact IDs they own locally or received from requests, history, or events and never select the first list entry as an owner. When cloud-worker placement is enabled or durable recovery state exists, session rows also include a closed `placement` state (`local`, `requested`, `provisioning`, `syncing`, `starting`, `active`, `draining`, `reconciling`, `reclaimed`, or `failed`) plus state-specific environment, owner-epoch, workspace, bundle, ACK-cursor, or recovery fields. Active placements may include an advisory `diskSpace` sample with `status` (`ok`, `warning`, or `critical`), `availableBytes`, `totalBytes`, and `observedAtMs`. An active paired-device placement also includes `runner: { kind: "device", status: "available" | "offline", deviceId? }`; `deviceId` names the paired device hosting the placement (the selected host for `autoDevice` dispatch), and non-device placements omit the field. This availability is process-current, derived from the exact active environment binding and reconnect-scoped node-runner proof, and starts offline after Gateway restart until that runner reconnects. Inventory changes emit `sessions.changed` so clients refresh the canonical row. Rows carry ownership projections — write-once `createdActor`, the mutable `owner` (actor plus `assignedBy`/`assignedAt`), a bounded `participants` list (owner excluded, up to 4 actors), and the full `participantCount`; actor display labels and avatars are resolved from current profiles and agent identities at read time. Pass `creatorId` to filter by immutable `createdActor.id`; pass `ownerId` to filter by the current assignable owner, falling back to `createdActor` when no owner is assigned. The complete `owners` facet is independent of pagination and remains unfiltered by either query, so clients can render the full owner picker. Authenticated callers can pass `involvingMe: true` to keep only sessions the caller owns or has prompted, evaluated against the full participant history (profile-backed human participants only).
|
||||||
|
* `sessions.subscribe` enables session change events for the current WebSocket client and accepts the same parameters as `sessions.list` to return an initial list in the same response. Empty `{}` parameters return only the subscription acknowledgment. The subscription ends when that client disconnects. See [Session list bootstrap](/gateway/protocol#session-list-bootstrap).
|
||||||
|
* `sessions.messages.subscribe` and `sessions.messages.unsubscribe` toggle transcript/message event subscriptions for one session. Pass `includeApprovals: true` to also receive sanitized `session.approval` lifecycle events for approvals whose persisted audience includes that exact session and whose reviewer binding authorizes the subscribing client. The subscribe response then includes a bounded pending `approvalReplay`; it is authoritative when `truncated` is false. The opt-in is per subscribe call, not sticky: re-subscribing to the same session without `includeApprovals: true` removes an existing approval subscription. In addition to normal session-read authority, this opt-in requires `operator.admin`, or `operator.approvals` on a paired device.
|
||||||
|
* `sessions.preview` returns bounded transcript previews for specific session keys.
|
||||||
|
* `sessions.describe` returns one gateway session row for an exact session key.
|
||||||
|
* `sessions.github.options`, `sessions.github.publish`, `sessions.github.status`, and `sessions.github.confirm` accept optional `agentId` alongside `sessionKey`. Carry the selected session's agent through all four calls, especially for the shared key `global`, which does not identify its owner. An explicit agent must be configured and match any agent-qualified session key; malformed, unknown, or conflicting owners return `INVALID_REQUEST` before publication. Tool-originated publication remains bound to the tool caller's session and agent.
|
||||||
|
* `sessions.resolve` resolves or canonicalizes a session target by key, raw session ID, label, Control UI short ID, or `reference: { key, slug? }`. A reference searches visible active and archived sessions: its exact canonical key wins, then an optional display-name slug is matched against UUID-backed sessions. Reference discovery retains session-list visibility rules; the separate `key` selector retains exact-key read semantics. Ambiguous references and short IDs return at most ten candidates as a successful RPC result. Set `allowMissing: true` to receive `{ ok: false }` when no session matches.
|
||||||
|
* `sessions.create` creates a new session entry. When sandbox containment applies, local `cwd` and project paths are checked against the selected agent's canonical workspace: aliases inside it are accepted, and symlinks resolving outside it are rejected. Optional `model`, `contextWindow`, and `thinkingLevel` values persist the initial model, advertised context-window choice, and reasoning overrides atomically; optional `category` assigns the session to a custom group and registers that group when first used. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. Without `worktreeName`, OpenClaw derives a readable name from the session label or generated first-message title, then falls back to a crustacean-themed name; names already occupied by another owner, local branch, or unmanaged path receive a numeric suffix. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key).
|
||||||
|
* `sessions.dispatch` moves an authorized local OpenClaw or Codex session with a live, registry-owned session managed worktree to a paired device or configured cloud profile. Pass `{ key, deviceId, agentId? }` for an explicit device, `{ key, autoDevice: true, agentId? }` for automatic paired-device selection, `{ key, profileId, machineClass?, agentId? }` for an explicit profile, or `{ key, agentId? }` to look up the managed worktree's normalized origin in `cloudWorkers.projectProfiles`. These target modes are mutually exclusive and explicit targets take precedence over project-profile lookup. Automatic selection ranks worker-slot runtimes by available slots and then device ID; runtimes without worker slots use device ID order. If a candidate becomes ineligible during dispatch, up to three ranked candidates are attempted; other errors are not retried. Explicit and automatic device dispatch require `operator.write`; explicit-profile and project-profile dispatch require `operator.admin`. A missing origin, unmatched mapping, or mapping to an unconfigured profile returns a typed `INVALID_REQUEST` without provisioning or falling back to another target. Malformed params use the write scope before schema validation. A missing cloud profile hides only cloud targets; eligible paired-device dispatch remains available. Dispatch closes local turn admission before draining active work and returns only after placement reaches `active`, with worker-child ownership for `worker-turn` or Gateway-owned harness execution for `remote-exec`. Arbitrary plain directories are not dispatchable; after admission, the workspace transport may use manifest mirroring if the managed worktree's Git metadata later becomes unavailable. SSH fallback candidates rotate only for idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed and are not replayed. Dispatch is one-way; worker-to-local pull-back is not part of this RPC.
|
||||||
|
* `sessions.reclaim` (`operator.write`) safely stops a session placement by key. It waits for an in-flight dispatch, drains admitted work, reconciles active workspace changes, and retries pending failed-environment teardown through the placement owner. Callers never need raw environment-destroy authority.
|
||||||
|
* `sessions.move` moves an authorized active session to the Gateway, a paired device, or a configured profile. Gateway and device targets require `operator.write`; profile targets require `operator.admin`; malformed targets use the write scope before schema validation. The caller supplies the exact observed generation, environment, and owner epoch; session authorization and those source facts are revalidated before the move commits. Ordinary moves always reconcile the source. Only a Gateway target may add `abandonSource: true`, and only when the exact source is a currently offline paired-device placement. That durable decision force-fences and destroys the remote owner, skips remote workspace reconciliation, and continues from the last Gateway-synced state without replay; unsynced files and in-flight work may be lost. Available, unknown, profile, and other-worker sources reject explicit abandonment.
|
||||||
|
* `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). The read-scoped list result is intentionally path-free. `sessions.groups.defaults` and `sessions.groups.update` require `operator.write` and read or replace one custom group's optional working-directory and worktree defaults. Non-admin callers can save only directories inside a configured agent workspace; other absolute Gateway paths require `operator.admin`. Membership stays on each session's `category` field; rename and delete update member sessions server-side. `sessions.groups.put` replaces only the name list and order, and rejects dropping a group that still has member sessions — delete it explicitly first. Dropping a group participates in the same member-session authorization as delete.
|
||||||
|
* `sessions.send` sends a message into an existing session.
|
||||||
|
* `sessions.steer` is a deprecated alias for `chat.send` with `queueMode: "interrupt"`; removal follows the protocol deprecation policy.
|
||||||
|
* `sessions.abort` aborts active work for a session. Pass `key` plus optional `runId`, or `runId` alone for active runs the gateway can resolve to a session. Supplying `runId` keeps cancellation scoped to that run. Set `clearQueued: true` on a key-only non-global request to also discard followup and lane queues owned by that session. Existing callers that omit `clearQueued` preserve those queues. The literal `global` key keeps the existing agent-qualified `chat.abort` ownership rules and does not perform non-global followup or lane cleanup.
|
||||||
|
* `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. `contextWindow` accepts only an id advertised by the selected model's `contextWindows` array; `null` restores `contextWindowDefault`. Session organization fields and the per-session `model` override require `operator.write`; thinking, fast, verbose, trace, reasoning, and other privileged overrides require `operator.admin`. Only an admin model selection can persist as the configured agent default. Archive and restore patches require the caller-observed `sessionId` from `sessions.list` or `sessions.describe` as `expectedSessionId`; missing or changed targets fail without materializing or mutating a replacement. With `archived: true`, the Gateway protects agent main sessions (including `global` when global scope is configured) and the `unknown` sentinel; for every other real session it first fences new admission, cancels exact-session active, pending, queued, reply, embedded, and worker work, and waits for admission and runtime terminal-persistence drains before committing `archivedAt`. A cancellation, drain, or persistence failure returns retryable `UNAVAILABLE` and leaves the session unarchived. `sessions.patchMany` carries `expectedSessionId` per target, prepares archive targets in input order inside the same batch lifecycle fence, and returns ordered per-target outcomes. Spawn lineage (`spawnedBy`, `spawnedWorkspaceDir`, `spawnedCwd`, `spawnDepth`, `subagentRole`, `subagentControlScope`) is no longer publicly patchable; those facts are written once by trusted creation paths, and requests that still send them are rejected.
|
||||||
|
* `sessions.assignOwner` (`operator.write`) reassigns the session's mutable owner to a person or configured agent (`{ key, owner: { type, id } }`). It requires an identified caller (authenticated profile or trusted agent identity), authorizes by session visibility, and records `assignedBy`/`assignedAt` on the row's `owner` field. The write-once `createdActor` and creator-anchored sharing authority are unchanged; see [Multi-user mode](/concepts/multi-user#assigning-an-owner).
|
||||||
|
* `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance.
|
||||||
|
* `sessions.get` returns the full stored session row.
|
||||||
|
* Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. Its `sessionInfo` uses the same aggregate `hasActiveRun` and optional complete-exact `activeRunIds` semantics as `sessions.list`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`...`, `...`, `...`, `...`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows (exact `NO_REPLY` / `no_reply`) are omitted, and oversized rows can be replaced with placeholders. Tail responses can include an opaque `deltaCursor`. Pass it back as `cursor` to `chat.history` or `chat.startup` instead of `offset` or `messageId`. A successful catch-up returns `{ kind: "delta", messages, deltaCursor, sessionInfo }`; replay each `messages` entry through the same reducer as a live `session.message` payload. `{ kind: "reset" }` means the cursor is invalid, stale, belongs to another session, crossed a reset or compaction, or is too far behind; fetch a normal tail page. Catch-up never returns a partial page or continuation: more than 200 raw events or the 1 MB payload budget resets to a tail fetch.
|
||||||
|
* `chat.message.get` is the additive bounded full-message reader for a single visible transcript entry. Pass `sessionKey`, optional `agentId` when session selection is agent-scoped, and a transcript `messageId` previously surfaced through `chat.history`; the gateway returns the same display-normalized projection without the lightweight history truncation cap when the stored entry is still available and not oversized.
|
||||||
|
* `chat.toolTitles` is deprecated. It validates the existing bounded request shape and returns `{ titles: {}, disabled: true }` so older clients stop requesting titles. It makes no model calls and does not access the old title cache. Current Control UI clients display descriptions supplied with tool calls automatically.
|
||||||
|
* `chat.send` accepts one-turn `fastMode: "auto"` to use fast mode for model calls started before the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds (`DEFAULT_FAST_MODE_AUTO_ON_SECONDS`) and can be configured per model with `agents.defaults.models["/"].params.fastAutoOnSeconds`. A `chat.send` caller can pass one-turn `fastAutoOnSeconds` to override the cutoff for that request. Pass `queueMode` (`steer`, `followup`, `collect`, or `interrupt`) to override the stored queue mode for this request only; explicit Control UI steer actions use `queueMode: "steer"`. Interrupt mode captures and aborts the session's current admitted turn, waits for that exact owner to settle, then starts the new turn; an idle session starts normally. A steer send targets the selected session's current state: the Gateway atomically injects the message into that session's direct active run, or starts a new turn when the session is idle. Activity in descendant subagent sessions never makes the selected session busy for this decision. `expectedLeafEntryId` is an independent transcript-branch compare-and-swap for non-steer interactive sends: pass the displayed branch leaf (or deliberate `null` for an authoritative empty transcript) and the send rejects with `details.reason: "active-leaf-changed"` if another client switched transcript branches first; steer sends ignore it.
|
||||||
|
* `chat.send`, `sessions.send`, and initial-turn `sessions.create` acknowledgments report admission separately from transcript persistence. Optional `messageSeq` is the one-based position from an actual committed user-turn receipt; it is absent while the input exists only in pending custody. `status: "started"` and `runStarted: true` alone do not establish a transcript row. Reconcile provisional input by its submission identity against accepted custody or canonical transcript identity, never a predicted position or matching content.
|
||||||
|
* `sessions.create.fastMode` accepts `true`, `false`, or `"auto"` and persists that speed override before the initial turn starts.
|
||||||
|
* `sessions.title.prepare` (`{ agentId, message, model?, catalogId?, incognito? }`, `operator.write`, rate-limited as a control-plane write) returns `{ title }` from the selected agent's utility model only, without creating or renaming a session; it returns `title: null` for incognito, empty, slash-command, or unavailable-utility input and never falls back to the primary model. A client passes a ready result as `sessions.create.displayName`: a presentation title stored like a generated first-message title, so it is not unique, never claims `label`, and is ignored when adopting an existing key.
|
||||||
|
Device pairing and device tokens
|
||||||
|
|
||||||
|
* `device.pair.list` returns pending and approved paired devices.
|
||||||
|
* `device.pair.setupCode` creates a mobile setup code and, by default, a PNG QR data URL. It requires `operator.admin` and is intentionally omitted from advertised discovery. Current gateways include an opaque non-secret `setupId`, authoritative `expiresAtMs`, `setupCode`, optional `qrDataUrl`, `gatewayUrl`, the non-secret `auth` label, `urlSource`, and the issued `access` level (`full`, `limited`, or `node`). Older protocol-v4 gateways omit `setupId` and `expiresAtMs`, so separately shipped clients must treat those lifecycle fields as optional. The `setupId` is independent from the bootstrap credential and is not embedded in the setup code.
|
||||||
|
* `device.pair.setupStatus` reconciles one setup credential the caller already issued (`{ setupId }`). It requires `operator.admin`, is omitted from advertised discovery, and returns either `{ completion }` after the credential-bearing response finishes or `{ deliveryUncertain }` when the bearer was retired but response delivery could not be confirmed. Both use the same non-secret payload as their corresponding events. When both fields are absent, the gateway holds no retained outcome for that `setupId`.
|
||||||
|
* `device.pair.approve`, `device.pair.reject`, and `device.pair.remove` manage device-pairing records.
|
||||||
|
* `device.pair.rename` assigns an operator label (`{ deviceId, label }`) that is preferred over the client-reported display name and survives device repair or re-approval.
|
||||||
|
* `device.token.rotate` rotates a paired device token within its approved role and caller scope bounds.
|
||||||
|
* `device.token.revoke` revokes a paired device token within its approved role and caller scope bounds.
|
||||||
|
|
||||||
|
The setup code embeds a short-lived bootstrap credential. Clients must not log or persist it beyond the pairing flow.
|
||||||
|
|
||||||
|
Pairing-scoped clients receive `device.pair.setup.completed` only after the exact setup handoff has delivered its credentials. Its payload is `{ setupId, deviceId, deviceName?, access, ts }`; it never includes the bootstrap credential or token-derived identifiers.
|
||||||
|
|
||||||
|
If the response closes before delivery can be confirmed, the gateway keeps the bearer retired and emits `device.pair.setup.deliveryUncertain` instead of success. The presenting client should offer the operator a path to inspect or remove the paired device and generate a new setup code.
|
||||||
|
|
||||||
|
The gateway records an uncertain outcome when it consumes the bearer, then promotes it to completion only after response delivery finishes. Operator event frames are best effort and drop for slow subscribers rather than closing their socket. A client that displayed a setup code must therefore call `device.pair.setupStatus` before presenting the code as expired. Outcomes are retained past the credential's own expiry.
|
||||||
|
|
||||||
|
Node pairing, invoke, and pending work
|
||||||
|
|
||||||
|
* `node.pair.list`, `node.pair.approve`, `node.pair.reject`, and `node.pair.remove` cover node capability approvals. `node.pair.request` and `node.pair.verify` were removed in 2026.7 together with the standalone node pairing store; pending requests are created by the Gateway during node connects.
|
||||||
|
* `node.list` and `node.describe` return known/connected node state.
|
||||||
|
* `node.rename` updates a paired node label.
|
||||||
|
* `node.invoke` forwards a command to a connected node.
|
||||||
|
* `node.invoke.result` returns the result for an invoke request. A node may return `NODE_NOT_READY` only when lifecycle cleanup prevented execution, before calling a command handler or emitting progress. The Gateway retries this rejection up to four times within the original invoke deadline, rechecking the connection, pairing, and command authorization at each dispatch. General `UNAVAILABLE` errors, disconnects, timeouts, and failures after progress are not retried.
|
||||||
|
* `mcp.tools.call.v1` is the headless node-host command for calling a configured node-local MCP tool. It is carried through `node.invoke`, requires the node to declare the command, and remains subject to pairing approval and `gateway.nodes.commands.deny`.
|
||||||
|
* `node.event` carries node-originated events back into the gateway.
|
||||||
|
* `node.pluginTools.update` is the only publication path for replacing the connected node's agent-visible plugin/MCP tool descriptors; `connect` params do not carry them.
|
||||||
|
* `node.pending.pull` and `node.pending.ack` are the connected-node queue APIs.
|
||||||
|
* `node.pending.enqueue` and `node.pending.drain` manage durable pending work for offline/disconnected nodes.
|
||||||
|
Approval families
|
||||||
|
|
||||||
|
* `approval.history` returns newest-first terminal approvals retained for 30 days for exec, plugin, and system-agent requests (scope `operator.approvals`). It supports cursor pagination plus an optional kind filter; pending approvals are not history rows. Treat each cursor as an opaque server token and return the exact value without padding, rewriting, or adding fields.
|
||||||
|
* `approval.get` and `approval.resolve` are the kind-agnostic durable approval methods (scope `operator.approvals`). `approval.get` returns a sanitized pending or retained terminal projection with a stable `urlPath`; `approval.resolve` accepts the canonical approval id, an explicit `kind`, and a decision, applies first-answer-wins resolution, and always returns the recorded canonical result.
|
||||||
|
* `exec.approval.request`, `exec.approval.get`, `exec.approval.list`, and `exec.approval.resolve` cover one-shot exec approval requests plus pending approval lookup/replay. They are protocol-boundary adapters over the same durable approval registry.
|
||||||
|
* `exec.approval.waitDecision` waits on one pending exec approval and returns the final decision (or `null` on timeout).
|
||||||
|
* `exec.approvals.get` and `exec.approvals.set` manage gateway exec approval policy snapshots.
|
||||||
|
* `exec.approvals.node.get` and `exec.approvals.node.set` manage node-local exec approval policy via node relay commands.
|
||||||
|
* `plugin.approval.request`, `plugin.approval.list`, `plugin.approval.waitDecision`, and `plugin.approval.resolve` cover plugin-defined approval flows.
|
||||||
|
Control UI commands
|
||||||
|
|
||||||
|
* `ui.command` lets an `operator.write` caller send typed layout and navigation commands to connected Control UI clients that advertise the `ui-commands` capability.
|
||||||
|
* Commands cover pane split/close/focus, sidebar visibility, terminal/browser panel visibility and dock, and session navigation.
|
||||||
|
* Protocol v1 intentionally fans out to every connected capable Control UI. If none is connected, the request fails with `UNAVAILABLE` instead of pretending the layout changed.
|
||||||
|
Automation, skills, and tools
|
||||||
|
|
||||||
|
* Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.get`, `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work.
|
||||||
|
* `cron.run` remains an enqueue-style RPC for manual runs. Clients that need completion semantics should read the returned `runId` and poll `cron.runs`.
|
||||||
|
* `cron.runs` accepts an optional non-empty `runId` filter so clients can follow one queued manual run without racing against other history entries for the same job.
|
||||||
|
* Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`, `tools.invoke`. See [Operator helper methods](#operator-helper-methods) below.
|
||||||
|
|
||||||
|
### Session list bootstrap
|
||||||
|
|
||||||
|
Call `sessions.subscribe` with a non-empty `sessions.list` parameter object, such as `{ limit: 60, ownerFirst: true }`, to subscribe and load the initial roster in one request. A successful WebSocket response has the payload `{ subscribed: true, list }`, where `list` is the normal `SessionsListResult`. Calling with `{}` preserves the acknowledgment-only response `{ subscribed: true }` and does not read a snapshot. List parameters select the snapshot; they do not filter the connection's session event subscription.
|
||||||
|
|
||||||
|
The Gateway registers the subscription before projecting the list. Clients must listen for `sessions.changed` before making the request: events can arrive while the snapshot is being built. Reconcile those events with the response and issue a trailing `sessions.list` refresh when needed, including when an event only invalidates the cached list. Reconnects require a new subscription and snapshot.
|
||||||
|
|
||||||
|
Both methods accept `ownerFirst: true` to prepend up to 60 matching viewer-owned rows (or `limit`, when smaller) to the normal first page, deduplicated by session key. This applies only when `offset` is zero or omitted; later pages use normal pagination. Owned rows must pass the same visibility and list filters as the shared page. The Gateway resolves the viewer from the authenticated connection; no client-supplied identity selects these rows. Without an authenticated viewer identity, or when `ownerFirst` is false or omitted, the list uses normal ordering.
|
||||||
|
|
||||||
|
The shared page still determines `limitApplied`, `offset`, `nextOffset`, `hasMore`, and `totalCount`. Prepended rows can make `sessions.length` and `count` exceed the shared page size. Use `nextOffset` to advance and deduplicate rows by session key across pages; do not derive the next offset from the displayed row count.
|
||||||
|
|
||||||
|
### Common event families
|
||||||
|
|
||||||
|
* `chat`: UI chat updates such as `chat.inject` and other transcript-only chat events. In protocol v4, delta payloads carry `deltaText`; `message` remains the cumulative assistant snapshot. Non-prefix replacements set `replace=true` and use `deltaText` as the replacement text. Failed runs (`state: "error"`) may include `errorDetail` alongside the coarse `errorKind` and human-readable `errorMessage`. This closed object has seven optional fields: `provider`, `model`, `failoverReason`, `providerRuntimeFailureKind`, `providerErrorType`, `httpStatus`, and `providerErrorMessagePreview`. Strings are capped at 300 characters; `httpStatus` is an integer from 100 through 599. Details come from the failed attempt's sanitized provider observation, not from reparsing the user-facing message. The preview is credential-redacted and may be shorter than the protocol cap. Raw bodies, raw previews, and diagnostic hashes are never included in `errorDetail`. Runs without provider observations omit it; successful and canceled events do not carry it. This is an additive protocol-v4 field.
|
||||||
|
* `session.message`, `session.operation`, `session.tool`: transcript, in-flight session operation, and event-stream updates for a subscribed session.
|
||||||
|
* `session.approval`: sanitized pending and terminal approval truth for an explicitly opted-in exact-session subscriber. Child approvals use the persisted ancestor audience; events never mutate transcripts or wake agents.
|
||||||
|
* `session.observer`: safe live session headline and status digest. A model-authored preamble can update the headline immediately; utility-model assessments replace it later when available. Web, iOS, and Android use the same run-scoped digest. Clients show its headline or inspector link only while the digest's exact `runId` is present in `activeRunIds`.
|
||||||
|
* `sessions.changed`: session index or metadata changed. Active-run fields use the same aggregate and complete-exact semantics as `sessions.list`; `activeRunIds: null` clears cached exact identities to unavailable, omission leaves the cache unchanged, and an array replaces it. Delete notifications from `sessions.delete` and incognito reset carry the removed generation's `sessionId`, without a current-row snapshot. Clients must not delete a replacement with a different ID. A key-only delete event or a rowless global notification invalidates the canonical session list; it does not identify the current generation as deleted.
|
||||||
|
* `presence`: system presence snapshot updates.
|
||||||
|
* `tick`: periodic keepalive/liveness event.
|
||||||
|
* `health`: gateway health snapshot update.
|
||||||
|
* `heartbeat`: heartbeat event stream update.
|
||||||
|
* `cron`: cron run/job change event.
|
||||||
|
* `shutdown`: gateway shutdown notification.
|
||||||
|
* `node.pair.requested` / `node.pair.resolved`: node pairing lifecycle.
|
||||||
|
* `node.invoke.request`: node invoke request broadcast.
|
||||||
|
* `device.pair.requested` / `device.pair.resolved`: paired-device approval lifecycle.
|
||||||
|
* `device.pair.setup.completed`: exact setup-code handoff completion, scoped to `operator.pairing`.
|
||||||
|
* `device.pair.setup.deliveryUncertain`: replay-safe setup-code retirement whose credential response delivery could not be confirmed, scoped to `operator.pairing`.
|
||||||
|
* `voicewake.changed`: wake-word trigger config changed.
|
||||||
|
* `config.changed`: a config write persisted (payload carries the config path, the new snapshot hash, and a timestamp — never config content). Operator-read scoped; clients refresh via `config.get`.
|
||||||
|
* `skills.changed`: connectivity, the skill catalog, config, or eligibility changed after the gateway invalidated its skills snapshot. The payload's `reason` is `watch`, `watch-targets`, `manual`, `remote-node`, `config-change`, or `workshop`. Operator-read scoped; clients refresh via `skills.status`.
|
||||||
|
* `exec.approval.requested` / `exec.approval.resolved`: exec approval lifecycle.
|
||||||
|
* `plugin.approval.requested` / `plugin.approval.resolved`: plugin approval lifecycle.
|
||||||
|
|
||||||
|
### Node helper methods
|
||||||
|
|
||||||
|
Nodes may call `skills.bins` to fetch the current list of skill executables for auto-allow checks.
|
||||||
|
|
||||||
|
### Node exec lifecycle events
|
||||||
|
|
||||||
|
Nodes report `system.run` lifecycle through the node-role `node.event` RPC with `event: "exec.started"`, `"exec.finished"`, or `"exec.denied"`. These are not the operator `exec.approval.*` broadcasts and do not use the retired TCP bridge.
|
||||||
|
|
||||||
|
The RPC accepts a JSON string in `payloadJSON` or an object in `payload`. A string `payloadJSON` takes precedence when both are supplied. For example:
|
||||||
|
|
||||||
|
Current headless nodes include `sessionKey`, `runId`, and `host: "node"`. Additional fields are:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `command` | Raw or formatted command text. |
|
||||||
|
| `exitCode`, `timedOut` | Process completion code and timeout flag. |
|
||||||
|
| `success` | Producer result flag, not the notification-gating predicate. |
|
||||||
|
| `output` | Bounded combined stdout, stderr, and error text. |
|
||||||
|
| `reason` | Denial reason for `exec.denied`. |
|
||||||
|
| `suppressNotifyOnExit` | Suppress this invocation's system notification. |
|
||||||
|
|
||||||
|
Echo the correlation fields forwarded with `system.run`; neither an ID nor the payload's `host` field grants authority. The Gateway matches the authenticated node and connection, run ID, and session key when the invocation binds one. Unmatched events return `handled: false` with `reason: "unmatched_exec_event"` and produce no system notification. A narrow legacy macOS-client path may match a missing or mismatched run ID only to one unambiguous invocation on that connection/session; new clients must send the issued run ID.
|
||||||
|
|
||||||
|
`exec.started` retains the authorization record; `exec.finished` and `exec.denied` consume it before notification filtering. `tools.exec.notifyOnExit: false` or `suppressNotifyOnExit: true` suppresses notifications. Denied events never enqueue a system event or wake agent work. Finished events notify only for timeout, nonzero or unknown exit code, or nonempty compacted output; successful exit 0 with no output stays quiet. Finished notifications with a run ID are deduplicated by canonical session and run ID. A heartbeat wake is requested only after a system event is queued.
|
||||||
|
|
||||||
|
Node event delivery is best-effort, not a durable completion ledger.
|
||||||
|
|
||||||
|
## Audit ledger RPC
|
||||||
|
|
||||||
|
`audit.activity.list` gives operator clients a stable newest-first view of agent run, tool action, inbound-message, and terminal outbound-message metadata. It requires `operator.read`. Queries exclude records older than 30 days, and the shared SQLite ledger is capped at 100,000 records. Expired rows are deleted during Gateway startup, hourly maintenance, and later writes. See [Audit history](/gateway/audit) for the data model and privacy semantics.
|
||||||
|
|
||||||
|
* Params: optional exact `agentId`, `sessionKey`, or `runId`; optional `kind` (`"agent_run"`, `"tool_action"`, or `"message"`); optional `status` (`"started"`, `"succeeded"`, `"failed"`, `"cancelled"`, `"timed_out"`, `"blocked"`, or `"unknown"`); optional message `direction` (`"inbound"` or `"outbound"`) and exact `channel`; optional inclusive `after` / `before` Unix-millisecond bounds; optional `limit` from `1` to `500`; and optional string `cursor` from the preceding page.
|
||||||
|
* Result: `{ "events": AuditActivityEventV1[], "nextCursor"?: string }`.
|
||||||
|
|
||||||
|
The named V1 result union has separate agent-run, tool-action, inbound-message, and outbound-message schemas. The `eventType` discriminator is respectively `agent_run`, `tool_action`, `inbound_message`, or `outbound_message`; `kind` and message `direction` remain available for filtering and display. Every event has integer `schemaVersion: 1`. Message identity references use the exact `hmac-sha256:v1:<32 hex key id>:<64 hex digest>` format; a channel-sender actor id uses the same format.
|
||||||
|
|
||||||
|
All variants require `eventType`, `schemaVersion`, `eventId`, `sequence`, `sourceSequence`, `occurredAt`, `kind`, `action`, `status`, `actor`, and `redaction`. Variant fields are:
|
||||||
|
|
||||||
|
| `eventType` | Required fields | Optional fields |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `agent_run` | `agentId`, `runId`; `kind: "agent_run"` | `sessionKey`, `sessionId`, `errorCode` |
|
||||||
|
| `tool_action` | `agentId`, `runId`; `kind: "tool_action"` | `sessionKey`, `sessionId`, `toolCallId`, `toolName`, `errorCode` |
|
||||||
|
| `inbound_message` | `direction: "inbound"`, `channel`, `conversationKind`, `outcome` | `agentId`, `runId`, `durationMs`, `resultCount`, identity references, `reasonCode`, `errorCode` |
|
||||||
|
| `outbound_message` | `direction: "outbound"`, `channel`, `conversationKind`, `outcome` | `agentId`, `runId`, `durationMs`, `resultCount`, identity references, `reasonCode`, `deliveryKind`, `failureStage`, `errorCode` |
|
||||||
|
|
||||||
|
The closed message enums are:
|
||||||
|
|
||||||
|
* `conversationKind`: `direct`, `group`, `channel`, or `unknown`.
|
||||||
|
* Inbound `outcome`: `completed`, `skipped`, or `failed`; optional `reasonCode`: `duplicate`, `reply_operation_active`, `reply_operation_aborted`, `fast_abort`, `plugin_bound_handled`, `plugin_bound_unavailable`, `plugin_bound_declined`, `plugin_bound_error`, `before_dispatch_handled`, `acp_dispatch_completed`, `acp_dispatch_failed`, `acp_dispatch_empty`, or `acp_dispatch_aborted`.
|
||||||
|
* Outbound `outcome`: `sent`, `suppressed`, `failed`, or `unknown`; optional `reasonCode`: `cancelled_by_message_sending_hook`, `cancelled_by_reply_payload_sending_hook`, `empty_after_message_sending_hook`, `empty_after_reply_payload_sending_hook`, or `no_visible_payload`. An adapter that returns no platform identity is `unknown`, because the external side effect cannot be disproved.
|
||||||
|
* `deliveryKind`: `text`, `media`, or `other`; `failureStage`: `platform_send`, `queue`, or `unknown`.
|
||||||
|
|
||||||
|
Terminal fields are correlated, not independently optional:
|
||||||
|
|
||||||
|
| Variant | Terminal mapping |
|
||||||
|
| --- | --- |
|
||||||
|
| Agent run | `started` has no `errorCode`; each non-success finished status requires its matching `run_*` code. |
|
||||||
|
| Tool action | `started` and succeeded have no `errorCode`; each other finished status requires its matching `tool_*` code. |
|
||||||
|
| Inbound message | succeeded = `completed`; blocked = `skipped`; failed = `failed` plus `message_processing_failed`. `reasonCode`, when present, must belong to that terminal family. |
|
||||||
|
| Outbound message | succeeded = `sent`; blocked = `suppressed` plus `reasonCode`; failed = `failed` plus `errorCode` and `failureStage`; unknown = `unknown` plus `failureStage`. |
|
||||||
|
|
||||||
|
Each activity event includes a stable event id, monotonic ledger sequence, source event sequence, timestamp, actor, action, status, integer `schemaVersion: 1`, and `redaction: "metadata_only"`. Run and tool records require agent and run provenance and may include session provenance. Message records may include agent and run ids, but intentionally never include `sessionKey` or `sessionId`; the `sessionKey` query filter therefore applies to run and tool rows only. Tool events may include tool call id and tool name.
|
||||||
|
|
||||||
|
The activity ledger returns `message.inbound.processed` and `message.outbound.finished` records and adds direction, channel, conversation kind, normalized outcome, and optional delivery kind, failure stage, duration, result count, reason code, and installation-local keyed account/conversation/message/target pseudonyms. These pseudonyms aid correlation but are not anonymization: the state database contains their key, while RPC and CLI exports do not. The ledger does not store prompts, message bodies, tool arguments, tool results, command output, or raw error text. Run/tool `sessionKey` values remain raw correlation metadata and can embed platform account or peer ids; message records omit session keys.
|
||||||
|
|
||||||
|
For inbound rows, `durationMs` measures core dispatch through its terminal and `resultCount` counts finalized queued tool, block, and reply payloads. For outbound rows, `durationMs` spans delivery ownership through acknowledgement, dead letter, or reconciliation (including queued wait time), and `resultCount` counts identified physical platform sends. `deliveryKind`, when present, describes the effective payload after hooks and rendering; suppressed or crash-ambiguous rows omit it.
|
||||||
|
|
||||||
|
Current message coverage includes accepted inbound messages that reach core dispatch, including core duplicate/terminal outcomes. Outbound coverage writes replay-safe queue and platform-start records to a lazy owner-native companion and one terminal activity row per original logical reply payload that reaches shared durable delivery; run inspection merges those sources. Chunking and adapter fan-out are aggregated in terminal `resultCount`. Ambiguous sends reach a terminal only after acknowledgement, dead letter, or reconciliation. Plugin-local and direct-send paths that bypass those shared boundaries are not yet covered. The bounded process-owned async queue is best-effort and may drop records on saturation, terminal persistence failure, or shutdown timeout, so this surface is not a lossless compliance archive.
|
||||||
|
|
||||||
|
Recording is on by default and controlled by [`logging.audit.enabled`](/gateway/configuration-reference#audit). Message recording is separately controlled by `logging.audit.messages` and defaults to `"off"`. When recording is disabled, `audit.activity.list` keeps serving records written earlier until they expire.
|
||||||
|
|
||||||
|
`audit.run.inspect` also requires `operator.read`. Its closed request selects exactly one `executionId` for exact inspection or one `runId` for bounded execution discovery. One run match resolves directly; multiple matches return an explicit `ambiguous` result with at most 50 candidates and require exact execution selection. Decision pages contain at most 100 receipts. Execution identity collection is separately off by default and requires `logging.audit.executionIdentity: true` plus an enabled audit ledger after Gateway restart. Missing best-effort evidence never proves that a run did not occur.
|
||||||
|
|
||||||
|
For a selected run, decision receipts merge terminal outbound activity with owner-native `queued` and `platform_started` progress. Progress is attribution-only, lives in the lazy companion store, and is not part of the `audit.activity.list` result schema.
|
||||||
|
|
||||||
|
The shipped `audit.list` request, result, and `AuditEvent` schemas remain unchanged and return only agent-run and tool-action records. New operator clients should call `audit.activity.list` when the Gateway advertises it. Older Gateways may report either `unknown method: audit.activity.list` or, because authorization preceded method lookup in shipped versions, `missing scope: operator.admin` to a read-scoped request. Treat the latter as method absence only when the method was not advertised. A client may then retry `audit.list` only when its filters do not require message kind, direction, or channel support.
|
||||||
|
|
||||||
|
Use [`openclaw audit`](/cli/audit) for text queries and bounded JSON exports.
|
||||||
|
|
||||||
|
## Task ledger RPCs
|
||||||
|
|
||||||
|
Operator clients inspect and cancel gateway background task records through the task ledger RPCs (`packages/gateway-protocol/src/schema/tasks.ts`). These return sanitized task summaries, not raw runtime state.
|
||||||
|
|
||||||
|
* `tasks.list` requires `operator.read`.
|
||||||
|
+ Params: optional `status` (`"queued"`, `"running"`, `"completed"`, `"failed"`, `"cancelled"`, or `"timed_out"`) or an array of those statuses, optional `agentId`, optional `sessionKey`, optional `limit` from `1` to `500`, optional string `cursor`, and optional `sortBy` (`"updatedAt"` or `"endedAt"`). Ordering is descending; omitted `sortBy` uses last activity. Use `"endedAt"` with terminal status filters when page membership must reflect completion order. Legacy terminal rows without a stored `endedAt` use their recorded terminal activity time, then creation time, as the canonical completion timestamp before pagination.
|
||||||
|
+ Result: `{ "tasks": TaskSummary[], "nextCursor"?: string }`.
|
||||||
|
* `tasks.get` requires `operator.read`.
|
||||||
|
+ Params: `{ "taskId": string }`.
|
||||||
|
+ Result: `{ "task": TaskSummary }`.
|
||||||
|
+ Missing task ids return the gateway not-found error shape.
|
||||||
|
* `tasks.cancel` requires `operator.write`.
|
||||||
|
+ Params: `{ "taskId": string, "reason"?: string }`.
|
||||||
|
+ Result: `{ "found": boolean, "cancelled": boolean, "reason"?: string, "task"?: TaskSummary }`.
|
||||||
|
+ `found` reports whether the ledger had a matching task. `cancelled` reports whether the runtime accepted or recorded cancellation.
|
||||||
|
|
||||||
|
`TaskSummary` includes `id`, `status`, and optional metadata: `kind`, `runtime`, `title`, `agentId`, `sessionKey`, `childSessionKey`, `ownerKey`, `runId`, `taskId`, `flowId`, `parentTaskId`, `sourceId`, timestamps, progress, terminal summary, and sanitized error text. `agentId` identifies the agent executing the task; `sessionKey` and `ownerKey` preserve requester and control context.
|
||||||
|
|
||||||
|
## Operator helper methods
|
||||||
|
|
||||||
|
* `commands.list` (`operator.read`) fetches the runtime command inventory for an agent.
|
||||||
|
+ `agentId` is optional; omit it to read the default agent workspace.
|
||||||
|
+ `scope` controls which surface the primary `name` targets: `text` returns the primary text command token without the leading `/`; `native` and the default `both` path return provider-aware native names when available.
|
||||||
|
+ `textAliases` carries exact slash aliases such as `/model` and `/m`.
|
||||||
|
+ `nativeName` carries the provider-aware native command name when one exists.
|
||||||
|
+ `provider` is optional and only affects native naming plus native plugin command availability.
|
||||||
|
+ `includeArgs=false` omits serialized argument metadata from the response.
|
||||||
|
* `tools.catalog` (`operator.read`) fetches the runtime tool catalog for an agent. The response includes grouped tools and provenance metadata:
|
||||||
|
+ `source`: `core` or `plugin`
|
||||||
|
+ `pluginId`: plugin owner when `source="plugin"`
|
||||||
|
+ `optional`: whether a plugin tool is optional
|
||||||
|
* `tools.effective` (`operator.read`) fetches the runtime-effective tool inventory for a session.
|
||||||
|
+ `sessionKey` is required.
|
||||||
|
+ The gateway derives trusted runtime context from the session server-side instead of accepting caller-supplied auth or delivery context.
|
||||||
|
+ The response is a session-scoped server-derived projection of the active inventory, including core, plugin, channel, and already-discovered MCP server tools.
|
||||||
|
+ `tools.effective` is read-only for MCP: it may project a warm session MCP catalog through the final tool policy, but does not create MCP runtimes, connect transports, or issue `tools/list`. If no matching warm catalog exists, the response may include a notice such as `mcp-not-yet-connected`, `mcp-not-yet-listed`, or `mcp-stale-catalog`.
|
||||||
|
+ Effective tool entries use `source="core"`, `source="plugin"`, `source="channel"`, or `source="mcp"`.
|
||||||
|
* `tools.invoke` (`operator.write`) invokes one available tool through the same gateway policy path as `/tools/invoke`.
|
||||||
|
+ `name` is required. `args`, `sessionKey`, `agentId`, `confirm`, and `idempotencyKey` are optional.
|
||||||
|
+ If both `sessionKey` and `agentId` are present, the resolved session agent must match `agentId`.
|
||||||
|
+ Owner-only core wrappers such as `cron`, `gateway`, and `nodes` require owner/admin identity (`operator.admin`) even though `tools.invoke` itself is `operator.write`.
|
||||||
|
+ The response is an SDK-facing envelope with `ok`, `toolName`, optional `output`, and typed `error` fields. Approval or policy refusals return `ok:false` in the payload rather than bypassing the gateway tool policy pipeline.
|
||||||
|
* `skills.status` (`operator.read`) fetches the visible skill inventory for an agent.
|
||||||
|
+ `agentId` is optional; omit it to read the default agent workspace.
|
||||||
|
+ The response includes eligibility, missing requirements, config checks, and sanitized install options without exposing raw secret values.
|
||||||
|
* `skills.search` and `skills.detail` (`operator.read`) return ClawHub discovery metadata.
|
||||||
|
* `skills.upload.begin`, `skills.upload.chunk`, and `skills.upload.commit` (`operator.admin`) stage a private skill archive before installing it. This is a separate admin upload path for trusted clients, not the normal ClawHub skill install flow, and is disabled by default unless `skills.install.allowUploadedArchives` is enabled.
|
||||||
|
+ `skills.upload.begin({ kind: "skill-archive", slug, sizeBytes, sha256?, force?, idempotencyKey? })` creates an upload bound to that slug and force value.
|
||||||
|
+ `skills.upload.chunk({ uploadId, offset, dataBase64 })` appends bytes at the exact decoded offset.
|
||||||
|
+ `skills.upload.commit({ uploadId, sha256? })` verifies the final size and SHA-256. Commit only finalizes the upload; it does not install the skill.
|
||||||
|
+ Uploaded skill archives are zip archives containing a `SKILL.md` root. The archive's internal directory name never selects the install target.
|
||||||
|
* `skills.install` (`operator.admin`) has three modes:
|
||||||
|
+ ClawHub mode: `{ source: "clawhub", slug, version?, force? }` installs a skill folder into the default agent workspace `skills/` directory.
|
||||||
|
+ Upload mode: `{ source: "upload", uploadId, slug, force?, sha256?, timeoutMs? }` installs a committed upload into the default agent workspace `skills/` directory. The slug and force value must match the original `skills.upload.begin` request. Rejected unless `skills.install.allowUploadedArchives` is enabled; the setting does not affect ClawHub installs.
|
||||||
|
+ Gateway installer mode: `{ name, installId, timeoutMs? }` runs a declared `metadata.openclaw.install` action on the gateway host. Older clients may still send `dangerouslyForceUnsafeInstall`; this field is deprecated, accepted only for protocol compatibility, and ignored. Use `security.installPolicy` for operator-owned install decisions.
|
||||||
|
* `skills.update` (`operator.admin`) has two modes:
|
||||||
|
+ ClawHub mode updates one tracked slug or all tracked ClawHub installs in the default agent workspace. Updates that would replace a skill directory whose installed files no longer match the recorded install digests are refused; the per-skill failure in `details.results` carries `code: "force_required"`. Retry with the optional `force: true` parameter to replace such a skill anyway.
|
||||||
|
+ Config mode patches `skills.entries.` values such as `enabled`, `apiKey`, and `env`.
|
||||||
|
|
||||||
|
### `models.list` views
|
||||||
|
|
||||||
|
`models.list` accepts an optional `view` parameter (`src/agents/model-catalog-visibility.ts`):
|
||||||
|
|
||||||
|
* Omitted or `"default"`: if `agents.defaults.modelPolicy.allow` is configured, the response is the allowed catalog, including dynamically discovered models for `provider/*` entries. Otherwise the response is the full gateway catalog.
|
||||||
|
* `"configured"`: picker-sized behavior. If `agents.defaults.modelPolicy.allow` is configured, it still wins, including provider-scoped discovery for `provider/*` entries. Without an allowlist, the response uses explicit `models.providers..models` entries, falling back to the full catalog only when no configured model rows exist.
|
||||||
|
* `"provider-config"`: source-authored `models.providers.*.models` inventory, independent of picker allowlists. Rows include public model capabilities and route-aware availability, but omit provider endpoints, auth material, and runtime request configuration.
|
||||||
|
* `"all"`: full gateway catalog, bypassing `agents.defaults.modelPolicy.allow`. Use for diagnostics/discovery UIs, not normal model pickers.
|
||||||
|
|
||||||
|
Two optional controls separate automatic reads from operator-requested discovery:
|
||||||
|
|
||||||
|
* `preparedOnly: true` reuses the current prepared catalog or a completed catalog for that runtime generation without starting provider discovery. Control UI startup and polling use this mode.
|
||||||
|
* `refresh: true` replaces a completed full catalog when the selected view requires discovery. Concurrent refreshes share one build; a failed refresh leaves the previous completed catalog available and returns the failure to the caller.
|
||||||
|
|
||||||
|
`preparedOnly: true` and `refresh: true` are mutually exclusive because one forbids discovery while the other requests it.
|
||||||
|
|
||||||
|
## Exec approvals
|
||||||
|
|
||||||
|
* When an exec request needs approval, the gateway broadcasts `exec.approval.requested`.
|
||||||
|
* Operator clients resolve by calling `exec.approval.resolve` (requires `operator.approvals`).
|
||||||
|
* For `host=node`, `exec.approval.request` must include `systemRunPlan` (canonical `argv`/`cwd`/`rawCommand`/session metadata). Requests missing `systemRunPlan` are rejected.
|
||||||
|
* After approval, forwarded `node.invoke system.run` calls reuse that canonical `systemRunPlan` as the authoritative command/cwd/session context.
|
||||||
|
* If a caller mutates `command`, `rawCommand`, `cwd`, `agentId`, or `sessionKey` between prepare and the final approved `system.run` forward, the gateway rejects the run instead of trusting the mutated payload.
|
||||||
|
|
||||||
|
## Agent delivery fallback
|
||||||
|
|
||||||
|
* `agent` requests can include `deliver=true` to request outbound delivery.
|
||||||
|
* `bestEffortDeliver=false` (the default) keeps strict behavior: unresolved or internal-only delivery targets return `INVALID_REQUEST`.
|
||||||
|
* `bestEffortDeliver=true` allows fallback to session-only execution when no external deliverable route can be resolved (for example internal/webchat sessions or ambiguous multi-channel configs).
|
||||||
|
* Final `agent` results may include `result.deliveryStatus` when delivery was requested, using the same `sent`, `suppressed`, `partial_failed`, and `failed` statuses documented for [`openclaw agent --json --deliver`](/cli/agent#json-delivery-status).
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
* `PROTOCOL_VERSION`, `MIN_CLIENT_PROTOCOL_VERSION`, `MIN_NODE_PROTOCOL_VERSION`, and `MIN_PROBE_PROTOCOL_VERSION` live in `packages/gateway-protocol/src/version.ts`.
|
||||||
|
* Clients send `minProtocol` + `maxProtocol`. Operator and UI clients must include the current protocol in that range; current clients and servers run protocol v4.
|
||||||
|
* Authenticated clients with both `role: "node"` and `client.mode: "node"` may use the N-1 node protocol (currently v3). Lightweight restart probes use the same N-1 window. Device auth, pairing, scopes, command policy, and exec approvals are unchanged by this compatibility window. Plugin-owned node capabilities and commands are withheld until the node upgrades to the current protocol because their hosted surfaces are not part of the N-1 contract.
|
||||||
|
* Schemas and models are generated from TypeBox definitions:
|
||||||
|
+ `pnpm protocol:gen`
|
||||||
|
+ `pnpm protocol:gen:swift`
|
||||||
|
+ `pnpm protocol:check`
|
||||||
|
|
||||||
|
### Client constants
|
||||||
|
|
||||||
|
The reference client implementation lives in `packages/gateway-client/src/` (OpenClaw wraps it via the thin `src/gateway/client.ts` facade). These defaults are stable across protocol v4 and are the expected baseline for third-party clients.
|
||||||
|
|
||||||
|
| Constant | Default | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` |
|
||||||
|
| `MIN_CLIENT_PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` |
|
||||||
|
| `MIN_NODE_PROTOCOL_VERSION` | `3` | `packages/gateway-protocol/src/version.ts` |
|
||||||
|
| `MIN_PROBE_PROTOCOL_VERSION` | `3` | `packages/gateway-protocol/src/version.ts` |
|
||||||
|
| Request timeout (per RPC) | `30_000` ms | `packages/gateway-client/src/client.ts` (`requestTimeoutMs`) |
|
||||||
|
| Preauth / connect-challenge timeout | `15_000` ms | `packages/gateway-client/src/timeouts.ts` (`OPENCLAW_HANDSHAKE_TIMEOUT_MS` env can raise the paired server/client budget) |
|
||||||
|
| Initial reconnect backoff | `1_000` ms | `packages/gateway-client/src/client.ts` (`GATEWAY_RECONNECT_POLICY`) |
|
||||||
|
| Max reconnect backoff | `30_000` ms | `packages/gateway-client/src/client.ts` (`GATEWAY_RECONNECT_POLICY`) |
|
||||||
|
| Fast-retry clamp after device-token close | `250` ms | `packages/gateway-client/src/client.ts` |
|
||||||
|
| Force-stop grace before `terminate()` | `250` ms | `FORCE_STOP_TERMINATE_GRACE_MS` |
|
||||||
|
| `stopAndWait()` default timeout | `1_000` ms | `STOP_AND_WAIT_TIMEOUT_MS` |
|
||||||
|
| Default tick interval (pre `hello-ok`) | `30_000` ms | `packages/gateway-client/src/client.ts` |
|
||||||
|
| Tick-timeout close | code `4000` when silence exceeds `tickIntervalMs * 2` | `packages/gateway-client/src/client.ts` |
|
||||||
|
| `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` |
|
||||||
|
| Chat attachment ceiling | `agents.defaults.mediaMaxMb`, default 20 MB decoded | `src/gateway/chat-attachment-policy.ts` |
|
||||||
|
| Chat attachment image ceiling | `min(attachment ceiling, 6 MB)` | `src/gateway/chat-attachment-policy.ts`, `packages/media-core/src/constants.ts` |
|
||||||
|
|
||||||
|
The server advertises the effective `policy.tickIntervalMs`, `policy.maxPayload`, `policy.maxBufferedBytes`, and `policy.attachments` in `hello-ok`; clients should honor those values rather than the pre-handshake defaults or hardcoded attachment sizes.
|
||||||
|
|
||||||
|
The reference client lets finite requests own their configured deadline when every pending request has one. An `expectFinal` request without a finite `timeoutMs`, any request with `timeoutMs: null`, or a mix of finite and unbounded requests keeps the tick watchdog active. If inbound events and responses remain silent past the tick-timeout threshold, the client closes the socket with code `4000`, rejects every pending request, and reconnects. It does not replay rejected requests after reconnecting.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
* Shared-secret gateway auth uses `connect.params.auth.token` or `connect.params.auth.password`, depending on the configured `gateway.auth.mode` (`"none" | "token" | "password" | "trusted-proxy"`).
|
||||||
|
* Identity-bearing modes such as Tailscale Serve (`gateway.auth.allowTailscale: true`) or non-loopback `gateway.auth.mode: "trusted-proxy"` satisfy the connect auth check from request headers instead of `connect.params.auth.*`.
|
||||||
|
* Private-ingress `gateway.auth.mode: "none"` skips shared-secret connect auth entirely; do not expose that mode on public/untrusted ingress.
|
||||||
|
* After pairing, the gateway issues a device token scoped to the connection role + approved grant, returned in `hello-ok.auth.deviceToken`. Clients should persist it with `hello-ok.auth.scopes` after a successful connect when the token is new or different from the stored token.
|
||||||
|
* `hello-ok.auth.scopes` is the current socket's live authority and matches the scopes enforced by RPC dispatch.
|
||||||
|
* When `hello-ok.auth.deviceToken` exactly matches the token already stored for the same gateway, device, client, and role, preserve that record's stored scopes instead of replacing them with a narrower live scope set. A newly issued or rotated token uses `hello-ok.auth.scopes`; its approved grant matches that connection when it is issued.
|
||||||
|
* Reconnecting with that stored device token should also reuse the stored approved scope set for that token. This preserves read/probe/status access already granted and avoids silently collapsing reconnects to a narrower implicit admin-only scope.
|
||||||
|
* Client-side connect auth assembly (`selectConnectAuth` in `packages/gateway-client/src/client.ts`):
|
||||||
|
+ `auth.password` is orthogonal and always forwarded when set.
|
||||||
|
+ `auth.token` is populated in priority order: explicit shared token first, then an explicit `deviceToken`, then a stored per-device token (keyed by `deviceId` + `role`).
|
||||||
|
+ `auth.bootstrapToken` is sent only when none of the above resolved `auth.token`. A shared token or any resolved device token suppresses it.
|
||||||
|
+ Auto-promotion of a stored device token on the one-shot `AUTH_TOKEN_MISMATCH` retry is gated to trusted endpoints only: loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://` without pinning does not qualify.
|
||||||
|
* Built-in setup-code bootstrap returns the primary node `hello-ok.auth.deviceToken` plus a bounded operator token in `hello-ok.auth.deviceTokens` for trusted mobile handoff. The operator token includes `operator.talk.secrets` for native Talk configuration reads, but excludes pairing-mutation scopes and `operator.admin`.
|
||||||
|
* `hello-ok.auth.deviceTokens` contains only additional bootstrap-handoff tokens. Do not use it as metadata for the primary `deviceToken` reconnect record.
|
||||||
|
* While a non-baseline setup-code bootstrap waits for approval, `PAIRING_REQUIRED` details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`, and `pauseReconnect: false`. Keep reconnecting with the same bootstrap token until the request is approved or the token becomes invalid.
|
||||||
|
* Persist `hello-ok.auth.deviceTokens` only when the connect used bootstrap auth on a trusted transport such as `wss://` or loopback/local pairing.
|
||||||
|
* If a client supplies an explicit `deviceToken` or explicit `scopes`, that caller-requested scope set remains authoritative for the live connection and is reported in `hello-ok.auth.scopes`; cached token-grant scopes are only reused when the client is reusing the stored per-device token.
|
||||||
|
* Device tokens can be rotated/revoked via `device.token.rotate` and `device.token.revoke` (requires `operator.pairing`). Rotating or revoking a node or other non-operator role also requires `operator.admin`.
|
||||||
|
* `device.token.rotate` returns rotation metadata. It echoes the replacement bearer token only for same-device calls already authenticated with that device token, so token-only clients can persist their replacement before reconnecting. Shared/admin rotations do not echo the bearer token.
|
||||||
|
* Token issuance, rotation, and revocation stay bounded to the approved role set recorded in that device's pairing entry; token mutation cannot expand or target a device role that pairing approval never granted.
|
||||||
|
* For paired-device token sessions, device management is self-scoped unless the caller also has `operator.admin`: non-admin callers can manage only the operator token for their own device entry. Node and other non-operator token management is admin-only, even for the caller's own device.
|
||||||
|
* `device.token.rotate` and `device.token.revoke` also check the target operator token scope set against the caller's current session scopes. Non-admin callers cannot rotate or revoke a broader operator token than they already hold.
|
||||||
|
* Auth failures include `error.details.code` plus recovery hints:
|
||||||
|
+ `error.details.canRetryWithDeviceToken` (boolean)
|
||||||
|
+ `error.details.recommendedNextStep`: one of `retry_with_device_token`, `update_auth_configuration`, `update_auth_credentials`, `wait_then_retry`, `review_auth_configuration` (`packages/gateway-protocol/src/connect-error-details.ts`).
|
||||||
|
* Client behavior for `AUTH_TOKEN_MISMATCH`:
|
||||||
|
+ Trusted clients may attempt one bounded retry with a cached per-device token.
|
||||||
|
+ If that retry fails, stop automatic reconnect loops and surface operator action guidance.
|
||||||
|
* `AUTH_SCOPE_MISMATCH` means the device token was recognized but does not cover the requested role/scopes. Do not present this as a bad token; prompt the operator to re-pair or approve the narrower/broader scope contract.
|
||||||
|
|
||||||
|
## Device identity and pairing
|
||||||
|
|
||||||
|
* Nodes should include a stable device identity (`device.id`) derived from a keypair fingerprint.
|
||||||
|
* Gateways issue tokens per device + role.
|
||||||
|
* Pairing approvals are required for new device IDs unless local auto-approval is enabled.
|
||||||
|
* Pairing auto-approval is centered on direct local loopback connects.
|
||||||
|
* OpenClaw also has a narrow backend/container-local self-connect path for trusted shared-secret helper flows.
|
||||||
|
* Same-host tailnet or LAN connects are still treated as remote for pairing and require approval.
|
||||||
|
* WS clients normally include `device` identity during `connect` (operator + node). The only device-less operator exceptions are explicit trust paths:
|
||||||
|
+ successful `gateway.auth.mode: "trusted-proxy"` operator Control UI auth.
|
||||||
|
+ direct-loopback `gateway-client` backend RPCs on the reserved internal helper path.
|
||||||
|
* Omitting device identity has scope consequences. When a device-less operator connection is allowed through an explicit trust path, OpenClaw still clears self-declared scopes to an empty set unless that path has a named scope-preservation exception. Scope-gated methods then fail with `missing scope`.
|
||||||
|
* The reserved direct-loopback `gateway-client` backend helper path preserves scopes only for internal local control-plane RPCs; custom backend IDs do not receive this exception.
|
||||||
|
* All connections must sign the server-provided `connect.challenge` nonce.
|
||||||
|
|
||||||
|
### Device auth migration diagnostics
|
||||||
|
|
||||||
|
For legacy clients that still use pre-challenge signing behavior, `connect` returns `DEVICE_AUTH_*` detail codes under `error.details.code` with a stable `error.details.reason`.
|
||||||
|
|
||||||
|
Common migration failures:
|
||||||
|
|
||||||
|
| Message | details.code | details.reason | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | Client omitted `device.nonce` (or sent blank). |
|
||||||
|
| `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | Client signed with a stale/wrong nonce. |
|
||||||
|
| `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | Signature payload does not match v2 payload. |
|
||||||
|
| `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | Signed timestamp is outside allowed skew. |
|
||||||
|
| `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id` does not match public key fingerprint. |
|
||||||
|
| `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | Public key format/canonicalization failed. |
|
||||||
|
|
||||||
|
Migration target:
|
||||||
|
|
||||||
|
* Always wait for `connect.challenge`.
|
||||||
|
* Use `connect.challenge.payload.ts` as `connect.params.device.signedAt`.
|
||||||
|
* Sign the v2 payload that includes the server nonce.
|
||||||
|
* Send the same nonce in `connect.params.device.nonce`.
|
||||||
|
* Preferred signature payload is `v3` (`buildDeviceAuthPayloadV3` in `packages/gateway-client/src/device-auth.ts`), which binds `platform` and `deviceFamily` in addition to device/client/role/scopes/token/nonce fields.
|
||||||
|
* Legacy `v2` signatures remain accepted for compatibility, but paired-device metadata pinning still controls command policy on reconnect.
|
||||||
|
|
||||||
|
## TLS and pinning
|
||||||
|
|
||||||
|
* TLS is supported for WS connections (`gateway.tls` config).
|
||||||
|
* Clients may optionally pin the gateway cert fingerprint via `gateway.remote.tlsFingerprint` or CLI `--tls-fingerprint`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This protocol exposes the full gateway API: status, channels, models, chat, agent, sessions, nodes, approvals, and more. The exact surface is defined by the TypeBox schemas re-exported from `packages/gateway-protocol/src/schema.ts`.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
* [Building a Gateway client](https://docs.openclaw.ai/gateway/clients)
|
||||||
|
* [Embedding OpenClaw](https://docs.openclaw.ai/gateway/embedding)
|
||||||
|
* [Gateway runbook](/gateway)
|
||||||
|
|
||||||
|
Was this useful?
|
||||||
|
|
||||||
|
Open issue
|
||||||
|
|
||||||
|
On this page
|
||||||
|
|
||||||
|
## On this page
|
||||||
|
|
||||||
|
Responses are generated using AI and may contain mistakes.
|
||||||
|
|
||||||
|
|
||||||
383
cache_research/gateway/openclaw_study/openclaw_gateway.md
Normal file
383
cache_research/gateway/openclaw_study/openclaw_gateway.md
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
# openclaw Gateway 架构研究报告
|
||||||
|
|
||||||
|
> 研究对象:openclaw 源码仓库(只读),用于为 Python Agent 项目 **Astrion** 的 "gateway 化" 改造提供借鉴。
|
||||||
|
> 研究方式:以文档要点为线索,直接在源码中定位实现并给出佐证(文件路径 + 关键代码)。
|
||||||
|
> 所有路径相对仓库根 `<local-clones>/openclaw`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Gateway 进程结构
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
单一长驻守护进程:`openclaw.mjs`(二进制入口)→ CLI 路由 `gateway` 命令 → `startGatewayServer()` 拉起 HTTP/WS 服务。WS server 用 `noServer: true` 模式挂在 HTTP server 上,由统一的 upgrade 路由分配连接归属(core WS / 插件 / worker / desktop 流)。每条连接由一个 `ws-connection.ts` handler 全权管理(预认证预算、connect 握手门禁、消息分发、keepalive、慢消费者关闭),连接注册进全局 `GatewayClientRegistry`(一个 `Set<GatewayWsClient>`)。方法分发通过 `GatewayMethodRegistry`(方法名 → handler/scope 的路由表)。
|
||||||
|
|
||||||
|
### 关键文件与代码证据
|
||||||
|
|
||||||
|
**入口链:**
|
||||||
|
- `openclaw.mjs` → `src/entry.ts`(`runCliWithExitFinalization`)→ `src/cli/run-main.ts`(`arg !== "gateway"` 分支,L130;`import("./gateway-cli/run-command.js")` L182)→ `src/cli/gateway-cli/run.ts`(L687 `const { startGatewayServer } = await loadServerModule()`;L1131 `return await startGatewayServer(port, {...})`)。
|
||||||
|
- `src/gateway/server.ts`:公开入口,默认端口 `18789`,动态 import 真正的实现:
|
||||||
|
```ts
|
||||||
|
export async function startGatewayServer(port = 18789, opts: GatewayServerOptions = {}) {
|
||||||
|
const mod = await loadServerStart();
|
||||||
|
return await mod.startGatewayServerCore(port, { ...opts, startupStartedAt });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `src/gateway/server-start.ts`:`startGatewayServerCore()` → `createGatewayKernel(port, opts)`(`server-kernel.ts`)→ `createGatewayHttpTransport()`(`server-runtime-state.ts`)→ `finishGatewayStartup()`(`server-startup-finish.ts`,在其中 `attachGatewayWsHandlers(...)`,L147-170)。
|
||||||
|
|
||||||
|
**WS server 组织:**
|
||||||
|
- `src/gateway/server-runtime-state.ts` L303-304:`const wss = new NpmWebSocketServer({ noServer: true, ... })`,HTTP server 的 `upgrade` 事件统一路由。
|
||||||
|
- `src/gateway/server-http-upgrades.ts`:`httpServer.on("upgrade", ...)`(L221)按路径/来源分派;core 路径最终 `wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); })`(L163-167)。
|
||||||
|
- `src/gateway/server/ws-connection.ts`:`attachGatewayWsConnectionHandler()`(L121)注册 `wss.on("connection", ...)`,每连接:
|
||||||
|
- 生成 `connId = randomUUID()`;预认证预算 `preauthConnectionBudget`;握手超时 `resolvePreauthHandshakeTimeoutMs`;
|
||||||
|
- keepalive:`src/gateway/websocket-keepalive.ts` —— 每 25s `socket.ping()`,错过 pong 判死;
|
||||||
|
- `send(obj)` 检查 `socket.bufferedAmount > MAX_BUFFERED_BYTES`(50MB)拒绝/关闭;
|
||||||
|
- 慢消费者:`server-broadcast.ts` 中 `slow && opts?.dropIfSlow` 则丢弃但**消耗 seq**;否则 `close(1008, "slow consumer")`。
|
||||||
|
- 连接注册表:`src/gateway/server/client-registry.ts` —— `class GatewayClientRegistry extends Set<GatewayWsClient>`,支持按 connId 索引。
|
||||||
|
- 连接状态/存在感:`src/gateway/server/client-presence.ts`、`presence-events.ts`(presence 快照广播)。
|
||||||
|
|
||||||
|
**帧校验与分发路由表:**
|
||||||
|
- 首帧门禁:`src/gateway/server/ws-connection/message-handler.ts`(`handleMessage`)——未认证连接只接受 `{type:"req", method:"connect"}` 且 `validateConnectParams` 通过,否则 `close(1008, "invalid handshake")`;成功后进入 `authenticated-request-dispatch.ts`。
|
||||||
|
- 方法路由表:`src/gateway/methods/registry.ts` —— `createGatewayMethodRegistry(inputs)` 把描述符(含 handler)存 `byName: Map<string, GatewayMethodDescriptor>`,重复方法名直接抛错;`getHandler(name)` / `getScope(name)` 供分发使用。核心方法策略表在 `src/gateway/methods/core-descriptors.ts`(`CORE_GATEWAY_METHOD_SPECS`,每行 `[name, family, scope, since, policy]`)。
|
||||||
|
- 分发执行:`src/gateway/server-methods.ts` 的 `handleGatewayRequest` —— `authorizeGatewayMethod()`(role/scope 校验,L148 起)→ 注册表取 handler → 执行;请求进入时先 `validateRequestFrame`,非法即回 `res {ok:false, error}`。
|
||||||
|
- 方法组装:`src/gateway/server-core-runtime.ts` L407 `createGatewayMethodRegistry(...)` 汇聚 core handlers + `extraHandlers`;`server-startup-finish.ts` 把 `getMethodRegistry`、`gatewayMethods`(方法名清单)、`events`(事件清单)注入 WS 层,hello-ok 里下发给客户端。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
- 用 "HTTP server + WebSocketServer(noServer) + 统一 upgrade 路由" 的组织方式,天然支持未来把插件/worker/控制面放在不同 WS 路径上,且共享同一生命周期(shutdown drain、连接预算)。
|
||||||
|
- 方法路由表(名称 → handler → scope → since)与 handlers 分离注册,是 Astrion 在 Python 侧可以照抄的骨架:一个 `@method("sessions.send", scope="write")` 式装饰器注册表,启动时查重。
|
||||||
|
- 预认证预算 + 慢消费者策略(dropIfSlow 或 close 1008)值得直接抄:单网关面对大量控制面客户端时这是稳健性的关键。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 协议实现(connect / req / res / event / seq / stateVersion / 重连恢复 / 事件不重放)
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
- 线协议:WS 文本帧 JSON;**首帧必须是 `connect` 请求**;服务端在连接建立后先推送 `connect.challenge` 事件(含一次性 nonce),客户端用设备 Ed25519 私钥签名 nonce 后再发 connect。
|
||||||
|
- `req/res` 配对靠客户端生成的 `id`;`event` 帧带 `seq`(每连接单调,服务端生成)与可选 `stateVersion`(presence/health 两个单调计数器)。
|
||||||
|
- **事件不重放**:服务端不缓存事件;客户端靠 seq 间隙检测(`seq > lastSeq+1`)触发 `onGap`,UI 的做法是直接重连,重连后通过 hello-ok 拿到**全量 snapshot**。重连期间 `lastSeq` 被重置为新 generation。
|
||||||
|
- `stateVersion` 用于"有缺口就刷新状态":hello-ok 的 snapshot 带 `stateVersion`,事件帧可选携带,客户端可据此判断 presence/health 子树是否过期并定向刷新。
|
||||||
|
|
||||||
|
### 关键文件与代码证据
|
||||||
|
|
||||||
|
**帧 schema(TypeBox):** `packages/gateway-protocol/src/schema/frames.ts`
|
||||||
|
```ts
|
||||||
|
export const RequestFrameSchema = closedObject({
|
||||||
|
type: Type.Literal("req"), id: NonEmptyString, method: NonEmptyString,
|
||||||
|
params: Type.Optional(Type.Unknown()), traceparent: Type.Optional(...),
|
||||||
|
});
|
||||||
|
export const ResponseFrameSchema = closedObject({
|
||||||
|
type: Type.Literal("res"), id: NonEmptyString, ok: Type.Boolean(),
|
||||||
|
payload: Type.Optional(Type.Unknown()), error: Type.Optional(ErrorShapeSchema),
|
||||||
|
});
|
||||||
|
export const EventFrameSchema = closedObject({
|
||||||
|
type: Type.Literal("event"), event: NonEmptyString, payload: Type.Optional(Type.Unknown()),
|
||||||
|
seq: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
|
stateVersion: Type.Optional(StateVersionSchema),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
`ConnectParamsSchema`(同文件)含 `client{id, version, mode, ...}`、`device{id, publicKey, signature, signedAt, nonce}`、`auth{token, bootstrapToken, deviceToken, password, ...}`;`HelloOkSchema` 含 `features{methods, events}`、`snapshot: SnapshotSchema`、`auth{deviceToken, role, scopes}`、`policy{maxPayload, ...}`。
|
||||||
|
|
||||||
|
**challenge 握手(服务端):** `src/gateway/server/ws-connection.ts` L349-354 —— 连接建立即发:
|
||||||
|
```ts
|
||||||
|
const connectNonce = randomUUID();
|
||||||
|
if (connectionKind === "gateway") {
|
||||||
|
send({ type: "event", event: "connect.challenge", payload: { nonce: connectNonce, ts: Date.now() } });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
首帧门禁:`message-handler.ts`(见 §1)。
|
||||||
|
|
||||||
|
**challenge 签名(客户端):** `packages/gateway-client/src/device-auth.ts`
|
||||||
|
```ts
|
||||||
|
export function buildDeviceAuthPayload(params): string {
|
||||||
|
return ["v2", params.deviceId, params.clientId, params.clientMode, params.role,
|
||||||
|
scopes.join(","), String(params.signedAtMs), token ?? "", params.nonce].join("|");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
客户端 `protocol-client.ts` `handleMessage` 里拦截 `connect.challenge`,取出 `nonce`/`ts` 后 `sendConnect(socket, generation)`,`buildConnectPlan({nonce, challengeTs})` 组装签名后的 connect 参数。
|
||||||
|
|
||||||
|
**服务端验签:** `src/gateway/server/ws-connection/connect-device-proof.ts` —— `verifyGatewayConnectDeviceProof()`:
|
||||||
|
- `derivedId = deriveDeviceIdFromPublicKey(device.publicKey)`,必须等于 `device.id`;
|
||||||
|
- `Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS (2min)` → 拒绝;
|
||||||
|
- `device.nonce !== context.handler.connectNonce` → 拒绝;
|
||||||
|
- `resolveDeviceSignaturePayloadVersion()`(`handshake-auth-helpers.ts` L297-340)用 v3/v2 两种 payload 分别 `verifyDeviceSignature(publicKey, payload, signature)`,全失败 → 拒绝。
|
||||||
|
|
||||||
|
**seq 生成(服务端):** `src/gateway/server-broadcast.ts`
|
||||||
|
```ts
|
||||||
|
const clientSeq = new WeakMap<GatewayWsClient, number>();
|
||||||
|
...
|
||||||
|
const nextSeq = (clientSeq.get(c) ?? 0) + 1;
|
||||||
|
frame = frameWithSequence(base, nextSeq, payloadFragment); // {"type":"event","event":...,"seq":N,...}
|
||||||
|
clientSeq.set(c, nextSeq);
|
||||||
|
```
|
||||||
|
关键注释(L490):`// Consume the seq for the dropped frame so the client's gap detector sees the loss instead of a silently thinner stream.`(dropIfSlow 时仍 `clientSeq.set(c, nextSeq)`)。L596-599:`// Targeted frames ride the same per-client sequence as fanout frames...`。
|
||||||
|
|
||||||
|
**stateVersion(服务端):** `packages/gateway-protocol/src/schema/snapshot.ts`
|
||||||
|
```ts
|
||||||
|
/** Monotonic version counters for snapshot subtrees. */
|
||||||
|
export const StateVersionSchema = closedObject({ presence: Type.Integer(), health: Type.Integer() });
|
||||||
|
```
|
||||||
|
`src/gateway/server/health-state.ts`:模块级 `let presenceVersion = 1; let healthVersion = 1;`,`incrementPresenceVersion()` / health 刷新时 `healthVersion += 1`;`buildGatewaySnapshot()` 把 `stateVersion: { presence: presenceVersion, health: healthVersion }` 放进 snapshot。广播时 `opts.stateVersion` 可序列化进事件帧(`server-broadcast.ts` `serializeFrameField("stateVersion", opts.stateVersion)`)。
|
||||||
|
|
||||||
|
**客户端 seq 检测 + 事件不重放:** `packages/gateway-client/src/protocol-client.ts`
|
||||||
|
```ts
|
||||||
|
private lastSeq: number | null = null;
|
||||||
|
private connect(): void {
|
||||||
|
...
|
||||||
|
this.lastSeq = null; // Outer event sequences belong to one WebSocket generation.
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```ts
|
||||||
|
const seq = typeof parsed.seq === "number" ? parsed.seq : null;
|
||||||
|
if (seq !== null) {
|
||||||
|
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
|
||||||
|
const expected = this.lastSeq + 1;
|
||||||
|
this.invoke("gap", () => this.opts.onGap?.({ expected, received: seq }));
|
||||||
|
...
|
||||||
|
}
|
||||||
|
this.lastSeq = seq;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`ui/src/app/gateway-store.ts`(Control UI 消费端):`onGap: ({ expected, received }) => { ...setSnapshot(...); if (isCurrentClient(nextClient)) connect(); }` —— **检测到缺口就整体重连**,重连的 hello-ok 带全量 snapshot,完成"刷新状态"。
|
||||||
|
|
||||||
|
**重连恢复:** `protocol-client.ts` `handleClose()` → `opts.resolveClose(context)`(客户端策略)→ 若 `decision.retry` 则 `scheduleReconnect(decision.reconnectDelayMs ?? connectFailure?.reconnectDelayMs, retryAfterMs)`;`RetrySupervisor` 初始 1s、×2、上限 30s(`client.ts` 的 `reconnect: { initialMs: 1_000, multiplier: 2, maxMs: 30_000 }`),服务器可通过 `retryable + retryAfterMs` 施加更长的退避。`generation` 每次 `connect()` 递增,旧 socket 的迟到帧被 `isActive(socket, generation)` 丢弃(防止重连竞态混帧)。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
- 协议要显式区分"握手期"与"认证后",并强制"首帧 connect";challenge→签名→hello 三步式认证能同时防重放(nonce)和防中间人(签名绑定 deviceId+clientId+role+scopes+token+nonce)。
|
||||||
|
- "事件不重放 + seq 间隙检测 + 断线重连全量 snapshot" 是**无状态客户端投影**范式的核心,Astrion 的 gateway 可以完全照搬:服务端只做 `seq = per-connection counter`,客户端重连后向 `snapshot` 类方法全量拉取,避免服务端维护事件缓存与游标。
|
||||||
|
- `stateVersion` 用"子树单调计数器"(presence/health)而非全局版本号,客户端可精确知道哪个子树过期。Astrion 可扩展为 `{sessions, agents, runs, presence}` 多组计数器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Session 状态归属
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
真状态(sessions/transcript/runs)**全部归 gateway 进程所有**:默认存文件(每 agent 一个 `sessions.json`),也支持 SQLite(`session.store` 配置,transcript 存 `transcript_events` 表);共享状态(设备身份、配对、token)存 `state/openclaw.sqlite`。多客户端只是"投影":通过 `sessions.list/describe/catalog` 读取、`sessions.subscribe / sessions.messages.subscribe` 订阅、`sessions.changed / session.message` 事件增量更新。存在 CAS/etag 机制:session entry 的 `lifecycleRevision`(randomUUID,每次变更轮换)+ 写操作的 `expectedLifecycleRevision`/`expectedSessionId`(乐观锁);transcript 层还有 `expectedLeafEntryId` 分支叶 CAS。
|
||||||
|
|
||||||
|
### 关键文件与代码证据
|
||||||
|
|
||||||
|
**存储实现:**
|
||||||
|
- `src/config/sessions/paths.ts`:`resolveDefaultSessionStorePath(agentId) = <stateDir>/agents/<id>/sessions/sessions.json`;`resolveSessionStorePathForScope()`(`session-store-path.ts`)支持 `storePath` 覆盖与 `sqlite:` 前缀。
|
||||||
|
- `src/config/sessions/session-accessor.sqlite-transcript-store.ts`:SQLite transcript 实现,`createTranscriptEventInserter()` 插入 `transcript_events` 表(含 `seq`、`eventJson`、`createdAt`);配套 `session-transcript-index.fs.ts`(文件索引,`seq` 由 index+1 生成)。
|
||||||
|
- 共享 SQLite:`src/state/openclaw-state-db.paths.ts` —— `<stateDir>/state/openclaw.sqlite`,存设备身份/配对/token(见 §5)。
|
||||||
|
|
||||||
|
**会话行与 lifecycleRevision(etag):**
|
||||||
|
- `src/config/sessions/session-accessor.sqlite-entry-store.ts` L442:`previousEntry.lifecycleRevision === normalizedEntry.lifecycleRevision`(写前比对/冲突检测)。
|
||||||
|
- `session-accessor.sqlite-message-cut.ts` L300:`currentEntry.lifecycleRevision !== params.expectedState.lifecycleRevision` → 拒绝;L577:`lifecycleRevision: params.forked ? randomUUID() : params.currentEntry.lifecycleRevision`。
|
||||||
|
- `session-accessor.sqlite-transcript-write-guard.ts` L24:`entry.lifecycleRevision === scope.expectedLifecycleRevision && ...` —— transcript 写入围栏。
|
||||||
|
|
||||||
|
**CAS 参数(协议侧):** `packages/gateway-protocol/src/schema/sessions-patch.ts`
|
||||||
|
```ts
|
||||||
|
export const SessionsPatchParamsSchema = closedObject({
|
||||||
|
key: NonEmptyString, agentId: Type.Optional(NonEmptyString),
|
||||||
|
/** Reject the mutation if the session was reset or replaced before it commits. */
|
||||||
|
expectedSessionId: Type.Optional(NonEmptyString),
|
||||||
|
expectedLifecycleRevision: Type.Optional(NonEmptyString),
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
`logs-chat.ts` `ChatSendParamsSchema` 里 `expectedLeafEntryId`(`Transcript-branch CAS ... the client's displayed branch leaf`)、`expectedSessionRoutingContract` 等。
|
||||||
|
|
||||||
|
**多客户端投影:**
|
||||||
|
- 读取:`src/gateway/server-methods/sessions-read.ts` L196 `"sessions.list"`(含 store_load / materialization / sharing / active_run_flags 等阶段,并把结果按客户端权限过滤)。
|
||||||
|
- 订阅:`sessions.subscribe`、`sessions.messages.subscribe/unsubscribe`(`core-descriptors.ts` 方法表:`sessions.subscribe`, `sessions.messages.subscribe`, `sessions.messages.unsubscribe`, `sessions.viewers.set`)。
|
||||||
|
- 变更广播:`src/gateway/server-methods/session-change-event.ts` —— `context.broadcastToConnIds("sessions.changed", eventPayload, connIds, ...)`,按 sessionKey/agentId 计算受众(`resolvePrivateSessionEventBroadcastScope`),只发给订阅了该 session 的连接。
|
||||||
|
- 会话事件带 transcript 消息级 seq:`src/gateway/server-session-events.ts`(`messageSeq = stored.seq`)→ `session-transcript-message.ts` 把 `params.messageSeq` 投射为事件 `seq`。
|
||||||
|
- 订阅注册表:`src/gateway/server-chat-state.ts`(`SessionMessageSubscriberRegistry`),广播端用 `sessionMessageSubscribers.get(sessionKey)` 判断该连接是否订阅后才推 `session.message` 等(`server-broadcast.ts` `requiresSessionSubscription` 分支)。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
- 采用"Gateway 单所有者 + 客户端投影"模型:客户端拿到的只是 hello snapshot + 增量事件拼接的镜像,写操作一律走 RPC 并在 gateway 内做冲突检测——Astrion 应把 session/run 状态收敛进 gateway 进程,控制面完全不落盘。
|
||||||
|
- `lifecycleRevision + expectedLifecycleRevision` 的乐观锁范式非常轻量;Astrion 可在 Python 侧实现为每个 session 一个 `revision: UUID`,所有 patch/send 带 `expected_revision`。
|
||||||
|
- 事件广播的"受众过滤"(按 sessionKey + 订阅者集合)值得借鉴,避免把私密 transcript 广播给未订阅连接。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 幂等与去重
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
副作用方法(sessions.create、sessions.send、channels.send、node.invoke 等)在参数 schema 层强制 `idempotencyKey`;服务端实现分两类:
|
||||||
|
1. `sessions.create`:**内存级去重缓存**(按 principal/device 分组的 Map,TTL 5 分钟,容量上限),同时去重**并发 inflight**(同 key 共享同一个 Promise)并校验"同 key 同参数"(params 的 sha256)。
|
||||||
|
2. 消息类(chat send / source reply / channel 消息):幂等键由 `runId + 投递指纹 + 操作 id` 组合,去重靠**扫描已持久化 transcript 中同 idempotencyKey 的 message** + FIFO 租约互斥。
|
||||||
|
|
||||||
|
### 关键文件与代码证据
|
||||||
|
|
||||||
|
**sessions.create(内存去重缓存):** `src/gateway/server-methods/session-create-idempotency.ts`
|
||||||
|
```ts
|
||||||
|
const sessionCreatesByContext = new WeakMap<GatewayRequestContext, Map<string, Map<string, SessionCreateEntry>>>();
|
||||||
|
// owner = principal ? `principal:${principal}` : `device:${deviceId}`
|
||||||
|
// requestIdentity = sha256(stableStringify(request.params))
|
||||||
|
const existing = entries?.get(idempotencyKey);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.requestIdentity !== requestIdentity) → INVALID_REQUEST "idempotency key was reused with different parameters"
|
||||||
|
... scope/authorization 变化也拒绝
|
||||||
|
const result = existing.state.kind === "completed" ? existing.state.result : await existing.state.work; // inflight 去重
|
||||||
|
request.respond(result.ok, ..., { ...result.meta, cached: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 容量:DEDUPE_MAX=1000(server-constants.ts),超限返回 UNAVAILABLE
|
||||||
|
```
|
||||||
|
TTL:`packages/gateway-protocol/src/schema/sessions-create.ts` —— `export const SESSION_CREATE_IDEMPOTENCY_RETENTION_MS = 5 * 60_000;`(仅 ok 结果保留缓存,失败即释放)。
|
||||||
|
|
||||||
|
**消息类幂等键构造:** `src/agents/tools/message-tool-idempotency.ts`
|
||||||
|
```ts
|
||||||
|
export function buildMessageToolDeliveryFingerprint(params) {
|
||||||
|
const canonical = JSON.stringify(canonicalizeMessageToolIdempotencyValue({ action, params: stripEnvelope(params.params) }));
|
||||||
|
return sha256Base64UrlPrefix(canonical, 24);
|
||||||
|
}
|
||||||
|
export function buildMessageToolAutogeneratedIdempotencyKey({ runId, deliveryFingerprint, operationId }) {
|
||||||
|
return `${runId}:message-tool:${deliveryFingerprint}:${operationId}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**消息去重(持久化扫描 + 租约):** `src/gateway/internal-source-reply-persistence.ts`
|
||||||
|
```ts
|
||||||
|
const lease = leaseKey ? internalSourceReplyPersistenceLeases.reserve([leaseKey]) : undefined; // FIFO 租约,并发互斥
|
||||||
|
await lease?.wait();
|
||||||
|
// 先扫描:completePersistedInternalSourceReply() → findTranscriptEvent(scope, event =>
|
||||||
|
// message?.idempotencyKey === params.idempotencyKey && isOpenClawDeliveryMirrorAssistantMessage(message))
|
||||||
|
```
|
||||||
|
transcript 写入层支持显式去重模式:`session-accessor.sqlite-transcript-store.ts` 中 `idempotencyKeyMode?: "dedupe" | "preserve-owner" | "relocate-owner"`,以及 `idempotencyLookup: "scan"`。
|
||||||
|
|
||||||
|
**node.invoke(节点侧队列去重):** `src/gateway/node-runtime-state.ts` L83 `const existing = queue.find((entry) => entry.idempotencyKey === params.idempotencyKey);`——执行节点侧用内存队列按 key 去重。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
- 协议 schema 层把 `idempotencyKey` 设为必填(非空字符串),客户端重试时必须复用同一 key,这是网关化系统在网络抖动下不产生重复副作用的根基。
|
||||||
|
- "内存 Map + TTL + 容量上限 + inflight 共享 Promise" 是极简且正确的服务端去重缓存实现;Astrion 在 Python 可用 `dict + asyncio.Future` 复刻,注意同样要校验"同 key 同参数"(hash 参数)与授权元数据不变。
|
||||||
|
- 消息类侧采用"幂等键进 transcript + 落盘扫描"的持久化去重,比纯内存缓存更抗网关重启;Astrion 若把 transcript 落盘,可把 idempotencyKey 作为消息的唯一索引来做幂等。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 设备配对与认证
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
- 每次 gateway WS 连接建立后,服务器先发 `connect.challenge`(一次性 nonce + ts);客户端用设备 Ed25519 私钥对 `v2/v3` 拼接载荷签名后放进 connect 的 `device{id, publicKey, signature, signedAt, nonce}`。
|
||||||
|
- 服务端验签:`device.id` 必须由 publicKey 派生、签名时间在 2 分钟窗口内、nonce 必须等于该连接下发的 nonce、Ed25519 签名必须通过。
|
||||||
|
- 配对流程:`device.pair.requested/resolved` 事件 + `device.pair.approve/reject` 方法(`PAIRING_SCOPE` 权限);配对记录(pending/paired)与 bootstrap token 都持久化在共享 SQLite(`state/openclaw.sqlite`)。
|
||||||
|
- 设备 token:连接成功(授权)后 `ensureDeviceToken` 颁发/轮换,hello-ok 返回 `deviceToken`;支持 `device.token.rotate/revoke` 主动吊销,吊销会 invalidate 已连接客户端(close 4001)。
|
||||||
|
- bootstrap token 是短时效一次性凭据(首次配对用),兑换后才有完整 profile。
|
||||||
|
|
||||||
|
### 关键文件与代码证据
|
||||||
|
|
||||||
|
**challenge 下发:** `src/gateway/server/ws-connection.ts` L349-354(见 §2)。
|
||||||
|
|
||||||
|
**验签:** `src/gateway/server/ws-connection/connect-device-proof.ts`(见 §2)。
|
||||||
|
|
||||||
|
**配对存储:** `src/infra/device-pairing-store.ts` —— SQLite 表 `device_pairing_paired` / `device_pairing_pending` / `device_bootstrap_tokens`(`openOpenClawStateDatabase`,`state/openclaw-state-db.js`);`DevicePairingStoreState = { pendingById, pairedByDeviceId }`,写事务持锁(`withDevicePairingLock`)。
|
||||||
|
- 路径:`state/openclaw-state-db.paths.ts` —— `resolveOpenClawStateSqlitePath() = <stateDir>/state/openclaw.sqlite`。
|
||||||
|
|
||||||
|
**配对请求/审批:** `src/infra/device-pairing.ts`(`requestDevicePairing`,事件 `device.pair.requested`),`src/infra/device-pairing-approval.ts`(`approveDevicePairing`,支持 auto-approve、并发协调、scope 基线校验);`src/gateway/server/ws-connection/connect-device-pairing.ts`(`authorizeGatewayConnectDevice`,把 pending 转 paired 并下发 scopes)。
|
||||||
|
|
||||||
|
**token 颁发/轮换/吊销:** `src/infra/device-pairing-tokens.ts`
|
||||||
|
```ts
|
||||||
|
export type RotateDeviceTokenDenyReason = "unknown-device-or-role" | "missing-approved-scope-baseline"
|
||||||
|
| "scope-outside-approved-baseline" | "caller-missing-scope";
|
||||||
|
// ensureDeviceToken / rotateDeviceToken / revokeDeviceToken 均在配对锁内读写 paired record 的 tokens
|
||||||
|
```
|
||||||
|
token 生成:`src/infra/pairing-token.ts` —— `randomBytes(32).toString("base64url")`,`verifyPairingToken` 用常数时间比较(`safeEqualSecret`)。
|
||||||
|
|
||||||
|
**bootstrap token:** `src/infra/device-bootstrap.ts` —— `generatePairingToken()` 颁发(TTL `DEVICE_BOOTSTRAP_TOKEN_TTL_MS`),一次性兑换 `redeemDeviceBootstrapTokenProfile`;兑换入口在 `sendGatewayHello`(`connect-hello.ts`)的 `authMethod === "bootstrap-token"` 分支。
|
||||||
|
|
||||||
|
**吊销联动:** `src/gateway/server/ws-connection/authenticated-request-dispatch.ts`
|
||||||
|
```ts
|
||||||
|
const DEVICE_CREDENTIAL_INVALIDATING_METHODS = new Set([
|
||||||
|
"device.pair.remove", "device.token.rotate", "device.token.revoke", "node.pair.remove",
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
这些方法成功后,携带旧凭据的连接被 `invalidateGatewayPolicyClient(...close(4001, "client invalidated: ..."))` 踢下线。
|
||||||
|
|
||||||
|
**设备身份(gateway 自身):** `src/infra/device-identity.ts` —— Ed25519 身份存共享 SQLite(`device_identity` 表),`deriveDeviceIdFromPublicKey`、`signEd25519Payload`、`verifyEd25519Signature`(`ed25519-signature.ts`)。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
- "服务器先发 challenge 再收 connect" 的次序很关键:nonce 必须由服务器生成,客户端签名后回传,才能防重放。
|
||||||
|
- "deviceId 由公钥派生"(无注册中心也能识别同一物理设备)+"配对审批+token 轮换/吊销+踢线" 是完整闭环:Astrion 可先在本地做"首台设备自动 approved"(`autoApproveNewDeviceScopes`),再扩展审批流。
|
||||||
|
- token 落 SQLite + 常数时间比较是低成本高安全性的做法,Python 端(secrets.compare_digest)同样适用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 协议 codegen(TypeBox → JSON Schema → Swift)
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
TypeBox schema 本身就是 JSON Schema 兼容对象;每 feature 一个 `protocol-schema-fragment-*.ts`,合成唯一注册表 `ProtocolSchemas`;运行时校验用 TypeBox/compile 惰性编译出 validator;**Swift 模型由脚本直接吃 TypeBox schema 对象(当作 JSON Schema 遍历)生成** `GatewayModels.swift`,CI 用 `--check` 比对防止漂移。`closedObject` 用 `additionalProperties:false` 强化封闭性,并打隐藏 symbol 标记以保留名义身份。
|
||||||
|
|
||||||
|
### 关键文件与代码证据
|
||||||
|
|
||||||
|
**TypeBox 定义:** `packages/gateway-protocol/src/schema/frames.ts` / `snapshot.ts` / 各 feature fragment;原始类型在 `primitives.ts`(`NonEmptyString = Type.String({minLength:1})`);封闭对象工厂 `closed-object.ts`:
|
||||||
|
```ts
|
||||||
|
export function closedObject<Properties extends TProperties>(properties: Properties) {
|
||||||
|
const schema = Type.Object(properties, { additionalProperties: false });
|
||||||
|
Object.defineProperty(schema, identityKey, { value: Symbol("closedObject") }); // 隐藏 symbol,不入 JSON
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注册表合成:** `schema/protocol-schemas.ts` —— `ProtocolSchemas = composeProtocolSchemaFragments([...16 个 fragment])`;`schema/protocol-schema-composer.ts` 遍历合成并**拒绝重复 key**。
|
||||||
|
|
||||||
|
**JSON Schema → 运行时 validator:** `protocol-validator.ts`
|
||||||
|
```ts
|
||||||
|
import { Compile, type Validator as TypeBoxValidator } from "typebox/compile";
|
||||||
|
compiled ??= Compile(schema as never); // 惰性编译
|
||||||
|
```
|
||||||
|
`validator-registry.ts` 给每个协议类型导出 `validateXxx = compile(S.XxxSchema)`,服务端/客户端共用。
|
||||||
|
|
||||||
|
**Swift 生成链路:** `scripts/protocol-gen-swift.ts`
|
||||||
|
- 导入 `ProtocolSchemas` 与 `ErrorCodes`(`packages/gateway-protocol/src/schema/error-codes.js`);
|
||||||
|
- 把每个 TypeBox/JSON Schema 对象经 `stableJson()` / `schemaSignature()` 归一化后**直接当作 JSON Schema 遍历**(读 `properties/required/items/enum/anyOf/oneOf/patternProperties` 等),生成 Swift struct/class;
|
||||||
|
- 输出写到 `apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift`;header 注明 "Generated by scripts/protocol-gen-swift.ts — do not edit by hand";
|
||||||
|
- `--check` 模式用于 CI 校验产物与当前 schema 一致;配套 `scripts/format-swift.sh`。
|
||||||
|
- Schema 对象在 JS 侧还用于 `GatewayFrameSchema` 判别联合(`discriminator: "type"`),供 quicktype/codegen 产出更紧的类型。
|
||||||
|
|
||||||
|
**协议版本:** `packages/gateway-protocol/src/version.ts` —— `PROTOCOL_VERSION = 4`, `MIN_CLIENT_PROTOCOL_VERSION = 4`, `MIN_NODE_PROTOCOL_VERSION = 3`;connect 请求带 `minProtocol/maxProtocol` 区间,hello-ok 回显服务器当前版本(`connect-hello.ts`)。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
- "单一 schema 源 →(服务端校验器 / 客户端模型 / 文档)" 的单一事实源值得照搬。Astrion 若用 Python,可选择 Pydantic v2(`model_json_schema()` 直接产 JSON Schema)+ JS 客户端侧校验或生成 TS 类型;关键是**禁止手工再写一遍协议类型**。
|
||||||
|
- "封闭对象(additionalProperties:false)"强制了前向兼容纪律:客户端发未知字段直接被拒绝,服务器加字段必须走 optional + 版本协商。
|
||||||
|
- 一对协议版本区间(min/max)+ hello-ok 回显版本,让新旧客户端/节点共存变得可管理,Astrion 的 gateway 协议应从一开始就带版本协商。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 方法与事件目录
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
方法以"名称.类别"扁平命名,核心方法在 `CORE_GATEWAY_METHOD_SPECS` 一张策略表里集中声明(名称、family、scope、since 版本、是否 control-plane write / advertise / startup);插件方法由插件注册表附加。事件由 `GATEWAY_EVENTS` 清单声明,广播时按 `EVENT_SCOPE_GUARDS` 做 scope 过滤,部分事件要求 session 订阅。
|
||||||
|
|
||||||
|
### 关键文件
|
||||||
|
|
||||||
|
**方法:** `src/gateway/methods/core-descriptors.ts`(`CORE_GATEWAY_METHOD_SPECS`,核心方法约 400+ 个),`src/gateway/methods/registry.ts`(注册表),`src/gateway/server-methods/core-handlers.ts`(handlers 挂载)。
|
||||||
|
|
||||||
|
**事件:** `src/gateway/server-methods-list.ts`(`GATEWAY_EVENTS`),`src/gateway/server-broadcast.ts`(`EVENT_SCOPE_GUARDS` + `SESSION_SUBSCRIPTION_EVENTS`)。
|
||||||
|
|
||||||
|
### 方法类别(按 family 聚合统计)
|
||||||
|
- **会话与运行**(最大族):`sessions.*` 57+(list/get/describe/create/send/abort/patch/reset/delete/compact/recover/fork/rewind/subscribe/viewers/goal/groups/branches/usage/diff/files/compaction…);`runs` 相关融入 send/abort/subscribe。
|
||||||
|
- **节点/worker**:`node.*` 19(pair.list/approve/reject/remove、list/describe、invoke、pending.pull/ack/enqueue/drain、runnerInventory.update、pluginTools/skills.update、event…)。
|
||||||
|
- **设备**:`device.*` 9(pair.list/approve/reject/remove/rename、token.rotate/revoke、scopes.requestUpgrade/waitUpgrade)。
|
||||||
|
- **agent/模型/技能**:`agents.*`(list/create/update/delete/files/workspace)、`models.*`、`skills.*`(36 项,含建议接受)。
|
||||||
|
- **配置与运维**:`config.get/set/apply/patch/schema`、`health`、`status`、`diagnostics.*`、`doctor.*`、`logs.tail`、`update.*`、`migrations.*`、`gateway.*`(重启/suspend)、`cron.*`(10)、`channels.*`(7)、`secrets.*`、`worktrees.*`、`projects.*`、`desktop.*`。
|
||||||
|
- **人机交互/审批**:`exec.approval.*` / `exec.approvals.*`(11)、`question.*`(5)、`plugin.approval.*`、`openclaw.approval.*`、`talk.*`(16,语音)。
|
||||||
|
- **系统 agent 引导**:`openclaw.chat/history/setup.*/changes.list`、`wizard.*`。
|
||||||
|
- **工具/媒体/周边**:`tools.*`(7)、`terminal.*`(7)、`board.*`(9)、`canvas.*`、`portal.*`、`progressCard.*`、`tts.*`(9)、`push.*`(7)、`users.*`(20)、`voicewake.*`、`mentions.*`、`messages.*`、`assistant.*`、`attach.*`、`artifacts.*`、`audit.*`、`mcp.*`(7)、`conversations.*`(4)等。
|
||||||
|
|
||||||
|
### 事件类别(GATEWAY_EVENTS,约 90 个)
|
||||||
|
- 握手:`connect.challenge`。
|
||||||
|
- 会话/聊天:`sessions.changed`、`chat`、`chat.metadata.changed`、`ui.command`、`session.message/observer/operation/sharing/typing/tool/suggestion/approval`。
|
||||||
|
- 存在与健康:`presence`、`health`、`heartbeat`、`tick`、`shutdown`、`gateway.suspension`。
|
||||||
|
- 节点/设备:`node.pair.requested/resolved`、`node.presence`、`node.hostStats`、`node.invoke.*`(cancel/input/request)、`device.pair.*`(changed/requested/resolved/setup.completed/deliveryUncertain)。
|
||||||
|
- 审批/提问:`exec.approval.requested/resolved`、`question.requested/resolved`、`plugin.approval.*`、`openclaw.approval.*`。
|
||||||
|
- 系统:`cron`、`task`、`task.suggestion`、`update.available`、`update run changed`、`config.changed`、`skills.changed`、`users.prefs.changed`、`mentions.changed`、`voicewake.*`、`talk.mode/event`、`terminal.data/exit`、`portal.changed`、`progressCard.changed`、`controlUi.sessionPullRequests.changed`、`plugins.controlUi.changed`、`sessions.catalog.host`。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
- "一张集中策略表(方法名 + scope + since + 属性)+ 注册表查重 + hello-ok 下发 methods/events 白名单" 让能力发现(feature discovery)成为协议一等公民。Astrion 可在 hello 响应里下发 `features.methods/events`,客户端据此决定 UI 能力。
|
||||||
|
- 事件统一走 `EVENT_SCOPE_GUARDS` 过滤矩阵(event → 需要的 scope 列表),比在 handler 里各自检查权限更不易漏;Python 侧可做一个装饰器 `@event("session.message", requires="read")` 并集中注册。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## openclaw 设计要点速查表(≤10 行)
|
||||||
|
|
||||||
|
1. **单 Gateway 长驻**:HTTP+WS(noServer) 统一 upgrade 路由;每连接一个 handler(认证门禁/keepalive/慢消费/预算),连接注册表 = `Set<GatewayWsClient>`。
|
||||||
|
2. **协议**:WS 文本 JSON;首帧必须 `connect`;`{type:req,id,method,params}` → `{type:res,id,ok,payload|error}`;事件 `{type:event,event,payload,seq?,stateVersion?}`;协议版本 = 4(min/max 协商)。
|
||||||
|
3. **认证**:连接先收 `connect.challenge{nonce,ts}`,客户端用设备 Ed25519 私钥签名 `v2|deviceId|clientId|mode|role|scopes|signedAt|token|nonce` 回传;服务端验 id 派生、2min 时间窗、nonce、签名。
|
||||||
|
4. **事件不重放**:seq 为每连接单调计数(被丢弃的帧也消耗 seq);客户端 `seq > lastSeq+1` → `onGap` → 整体重连;重连 hello-ok 带**全量 snapshot**,`stateVersion{presence,health}` 定子树版本。
|
||||||
|
5. **会话状态归 Gateway**:sessions.json(每 agent)或 SQLite transcript(`transcript_events` 带 seq);共享状态在 `state/openclaw.sqlite`;客户端只投影(sessions.list/subscribe + sessions.changed 事件)。
|
||||||
|
6. **CAS/乐观锁**:session `lifecycleRevision=UUID` 每次变更轮换;写操作带 `expectedLifecycleRevision/expectedSessionId/expectedLeafEntryId`。
|
||||||
|
7. **幂等**:副作用方法必填 `idempotencyKey`;sessions.create 用内存 Map(TTL 5min、容量 1000、inflight 同 key 共享 Promise、sha256 参数校验);消息类用"RunId:指纹:opId"键 + transcript 扫描去重 + FIFO 租约。
|
||||||
|
8. **配对/租约吊销**:bootstrap token(32B base64url、短时效一次性)→ 配对审批(pending/paired 表)→ `ensureDeviceToken` 轮换;rotate/revoke/pair.remove 使旧连接 close(4001)。
|
||||||
|
9. **Schema 单一源**:TypeBox schema(closedObject=additionalProperties:false)→ 惰性编译 validator + `scripts/protocol-gen-swift.ts` 直读 schema 生成 `GatewayModels.swift`(CI --check 防漂移)。
|
||||||
|
10. **方法/事件目录**:核心方法集中在 `CORE_GATEWAY_METHOD_SPECS`(name/family/scope/since)一张表;事件在 `GATEWAY_EVENTS` + `EVENT_SCOPE_GUARDS` 过滤矩阵;hello-ok 下发 `features.methods/events` 供客户端能力发现。
|
||||||
294
cache_research/gateway/opencode_study/opencode_architecture.md
Normal file
294
cache_research/gateway/opencode_study/opencode_architecture.md
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
# opencode server/client 架构研究报告
|
||||||
|
|
||||||
|
> 研究对象:opencode 仓库(版本 1.18.29,克隆位于 `<local-clones>/opencode`,只读)
|
||||||
|
> 研究目的:为 Astrion(Python Agent 项目)的 "gateway 化" 改造提供借鉴
|
||||||
|
> 研究方法:源码阅读,所有结论均附文件路径与关键代码证据(行号以本次阅读时为准)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总览(30 秒版)
|
||||||
|
|
||||||
|
opencode 有两个并存的 HTTP 服务层:
|
||||||
|
|
||||||
|
1. **主服务(packages/opencode,TUI 实际使用)**:Effect `HttpApi` 定义 + `HttpRouter.serve` 运行在 Node http server 上,路由分 Root(`/global/*`、`/control/*`)、Instance(`/session/*`、`/tui/*`、`/event` 等)、v2 protocol(`/api/*`)、Public(UI 静态资源)。OpenAPI spec 从 Effect HttpApi 定义**代码生成**(`bun dev generate` → `openapi.json` → `@hey-api/openapi-ts` → TS SDK)。
|
||||||
|
2. **精简实验服务(packages/server + packages/protocol)**:同样是 Effect HttpApi,把协议定义下沉到 `@opencode-ai/protocol`,server 只注入 middleware/handler 实现,挂载 `/api/event`、`/api/session/*` 等 v2 路由。
|
||||||
|
|
||||||
|
核心设计:**TUI 是 server 的瘦客户端**,通过生成的 SDK + SSE(`/global/event`、`/event`)消费事件;server 通过事件总线反向驱动 TUI(`/tui/*`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Server 架构:框架、入口、路由、OpenAPI、SDK 生成
|
||||||
|
|
||||||
|
### 1.1 用的是什么框架
|
||||||
|
|
||||||
|
**不是 Hono,也不是 Elysia,而是 Effect 生态的 `HttpApi` / `HttpRouter`**(Effect 自带的声明式 HTTP 框架,`effect/unstable/httpapi`),底层走 `@effect/platform-node` 的 `NodeHttpServer`(node:http)。
|
||||||
|
|
||||||
|
证据:
|
||||||
|
|
||||||
|
- `packages/server/src/api.ts`:`import { HttpApi, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"`,`HttpApi.make("server").add(...)` 组装所有 group。
|
||||||
|
- `packages/server/src/routes.ts`:`HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" })`;`webHandler()` 用 `HttpRouter.toWebHandler(...)` 导出 fetch handler。
|
||||||
|
- `packages/opencode/src/server/server.ts`:`HttpRouter.serve(HttpApiApp.createRoutes(opts), ...)` + `NodeHttpServer.layer(() => server, ...)`(createServer 来自 `node:http`)。
|
||||||
|
- 全局搜 `hono` 仅命中 server.ts 一处注释性匹配,无 Hono 依赖。
|
||||||
|
|
||||||
|
### 1.2 入口在哪、谁调用它
|
||||||
|
|
||||||
|
三个入口,同一套实现:
|
||||||
|
|
||||||
|
| 入口 | 文件 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `opencode serve`(headless) | `packages/opencode/src/cli/cmd/serve.ts` | `Server.listen(opts)`,监听 `--port`(默认 4096)/`--hostname` |
|
||||||
|
| TUI 进程内嵌 worker | `packages/opencode/src/cli/tui/worker.ts` | worker 内 `Server.listen(input)`(`rpc.server` 方法)或纯内嵌 `Server.Default().app.fetch`(`rpc.fetch`) |
|
||||||
|
| 独立精简 server | `packages/cli/src/commands/handlers/serve.ts` | `HttpRouter.serve(createRoutes(password), ...)`,`opencode serve`(老 CLI) |
|
||||||
|
|
||||||
|
`packages/opencode/src/server/server.ts` 的 `listen()` 实现端口回退:`startWithPortFallback` 先试 4096,失败再随机端口。
|
||||||
|
|
||||||
|
### 1.3 路由如何组织
|
||||||
|
|
||||||
|
**分组(HttpApiGroup)+ 累积式组装**,两层:
|
||||||
|
|
||||||
|
- `packages/opencode/src/server/routes/instance/httpapi/api.ts`:
|
||||||
|
- `RootHttpApi` = Control + ControlPlane + Global(`/global/*`)
|
||||||
|
- `InstanceHttpApi` = Config/Experimental/File/Instance/Mcp/Project/Question/Permission/Provider/Session/Sync/Tui/Workspace(`/session/*`、`/tui/*`、`/permission/*` 等)
|
||||||
|
- `OpenCodeHttpApi` = Root + Event(`/event`) + Instance + Server(`/api/*`) + PtyConnect(WS)
|
||||||
|
- 每个 group 一个文件,如 `groups/session.ts`、`groups/permission.ts`、`groups/tui.ts`、`groups/event.ts`;handler 对应 `handlers/*.ts`。
|
||||||
|
|
||||||
|
路由路径以组内常量定义(如 `groups/session.ts` 中 `SessionPaths = { permissions: "/session/:sessionID/permissions/:permissionID", ... }`)。
|
||||||
|
|
||||||
|
### 1.4 OpenAPI spec 是手写还是代码生成?
|
||||||
|
|
||||||
|
**纯代码生成**。Effect HttpApi 的每个 `HttpApiEndpoint` 用 `Schema` 声明入参/出参/错误,用 `OpenApi.annotations` 附加 summary/description/identifier,Effect 在运行时把整个 Api 编译成 OpenAPI 文档:
|
||||||
|
|
||||||
|
- `packages/opencode/src/server/server.ts`:`export async function openapi() { return OpenApi.fromApi(PublicApi) }`
|
||||||
|
- schema 定义集中在 `packages/schema/src`(zod 风格由 Effect `Schema` 实现,无 TypeBox/zod):`session.ts`、`session-message.ts`、`session-event.ts`、`permission.ts`、`tui-event.ts` 等。
|
||||||
|
- 产物:`packages/sdk/openapi.json`(openapi 3.1.0,188 个 operationId)。
|
||||||
|
|
||||||
|
### 1.5 SDK 如何从 spec 生成
|
||||||
|
|
||||||
|
`packages/sdk/js/script/build.ts` 全流程:
|
||||||
|
|
||||||
|
1. `bun dev generate > openapi.json`:调用 CLI generate 命令(`packages/opencode/src/cli/cmd/generate.ts`)→ `Server.openapi()` 输出 spec,并给每个 operation 注入 `x-codeSamples`。
|
||||||
|
2. `@hey-api/openapi-ts`(`createClient`)生成:
|
||||||
|
- `src/v2/gen/types.gen.ts`(TS 类型)
|
||||||
|
- `src/v2/gen/sdk.gen.ts`(`OpencodeClient` 实例化 SDK,`paramsStructure: "flat"`)
|
||||||
|
- `src/v2/gen/client/*`(fetch client,baseUrl 默认 `http://localhost:4096`)
|
||||||
|
3. 若干手工 patch(session.history 分页类型 string→number、SSE 泛型 bug)。
|
||||||
|
4. `bun prettier` + `bun tsc` 校验。
|
||||||
|
|
||||||
|
SDK 对外暴露:`packages/sdk/js/src/v2/client.ts`(`createOpencodeClient`,支持自定义 fetch、`x-opencode-directory` 头路由到指定目录),`packages/sdk/js/src/v2/server.ts`(`createOpencodeServer` 用 cross-spawn 拉起 `opencode serve` 子进程)、`process.ts`。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
|
||||||
|
- 如果 Astrion 也要"HTTP API + 生成 SDK",可以用类似双轨:自己手写或代码生成 OpenAPI v3.1,再接入 openapi-typescript / openapi-generator / hey-api 生成 TS SDK(Python 侧可用 openapi-python-client)。
|
||||||
|
- "协议包(protocol)与实现(server)分离 + handler 注入"的分层(`@opencode-ai/protocol` 定义 group 与 error,`@opencode-ai/server` 注入 middleware/handler)值得借鉴,便于多入口复用同一协议。
|
||||||
|
- OpenAPI 元数据(summary/description)直接写在 schema 附近,文档与代码同源,避免过期。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 事件系统:/event、/global/event、事件总线、序号与重连
|
||||||
|
|
||||||
|
### 2.1 两条事件通路
|
||||||
|
|
||||||
|
**A) 全局总线 `/global/event`(RootHttpApi,GlobalApi)**
|
||||||
|
|
||||||
|
- 总线本体:`packages/opencode/src/bus/global.ts` —— 单例 Node `EventEmitter`(`GlobalBus.emit("event", {...})`),事件带 `directory/project/workspace/payload`。
|
||||||
|
- SSE 出口:`packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts` 的 `eventResponse()`:`Stream.callback` 把 `GlobalBus.on("event")` 转成 Effect Stream,先发 `server.connected`,10 秒心跳,`Stream.pipeThroughChannel(Sse.encode())`。
|
||||||
|
- 事件源:`packages/opencode/src/event-v2-bridge.ts` —— `events.listen(...)` 把 core EventV2 的每个事件转发到 GlobalBus(`payload: {id, type, properties: data}`),durable 事件额外发一条 `{type:"sync", syncEvent:{...}}`。
|
||||||
|
|
||||||
|
**B) 实例流 `/event`(InstanceHttpApi,EventApi)**
|
||||||
|
|
||||||
|
- `packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts`:`events.listen` 全量订阅 EventV2,然后按 `event.location.directory === instance.directory` **在服务端过滤**(`WorkspaceRoutingMiddleware` 用 `directory` query/`x-opencode-directory` 头选中实例),发 `server.connected` + 10s 心跳,遇 `server.instance.disposed` 关闭流。
|
||||||
|
|
||||||
|
**C) v2 精简流 `/api/event`(packages/server)**
|
||||||
|
|
||||||
|
- `packages/server/src/handlers/event.ts`:`EventV2.allBounded(events, 256)`(有界 dropping 队列,容量 256,溢出即断开报错),发 `server.connected`(类型由 `OpenCodeEvent` union 合一),15s 心跳。
|
||||||
|
|
||||||
|
### 2.2 事件类型
|
||||||
|
|
||||||
|
由 schema 中 `Event.define` 声明,按 manifest 汇总(`packages/schema/src/event-manifest.ts`):
|
||||||
|
|
||||||
|
- **session 事件(v2 增量式)**:`packages/schema/src/session-event.ts` —— `session.next.prompted / prompt.admitted / context.updated / synthetic / shell.started|ended / step.started|ended|failed / text.started|delta|ended / reasoning.* / tool.input.*|called|progress|success|failed / retried / compaction.* / revert.*` 等约 30 种。Delta 类事件(text.delta、reasoning.delta、tool.input.delta)是 **live-only、不入库**;Ended 类是 replayable 边界。
|
||||||
|
- **v1 事件(面向现有 TUI)**:`packages/schema/src/v1/session.ts` 的 `PartDelta`/`MessageUpdated` 等 + `permission.asked/replied`(v1)与 `permission.v2.asked/replied`(v2)、`question.asked/replied`、`tui.*`(`tui-event.ts`)、`server.connected`(`server-event.ts`)、插件/集成事件等。
|
||||||
|
- 事件体统一形状:`{ id: "evt_xx", type, data/properties, location?, durable?: {aggregateID, seq, version}, metadata? }`(`packages/schema/src/event.ts`)。
|
||||||
|
|
||||||
|
### 2.3 序号 / offset / 重放语义
|
||||||
|
|
||||||
|
**核心机制在 `packages/core/src/event.ts`(EventV2 service)+ `packages/core/src/event/sql.ts`(SQLite):**
|
||||||
|
|
||||||
|
- 表 `event_sequence(aggregate_id PK, seq, owner_id)` 与 `event(id PK, aggregate_id, seq, type(版本化), data JSON)`,`uniqueIndex(aggregate_id, seq)`。
|
||||||
|
- `publish()`:若是 durable 事件 → **事务内** 更新 seq(`latest+1`)、先跑 projectors(`project` 注册的投影回调,和写库同事务)、再插 EventTable;非 durable 事件只走内存 PubSub。
|
||||||
|
- 事件有**每聚合(per-aggregate,如 sessionID)单调 seq** 与 **version(schema 演进版本)**;type 落库时是版本化串(`EventV2.versionedType(type, version)`)。
|
||||||
|
- `durable({aggregateID, after})` 流:先 `readAfter(aggregateID, after)` **从 SQLite 重放 seq > after 的历史事件**,再 `Stream.concat` 内存 pubsub 的实时事件(`subscribeDurable` 用 per-aggregate 的 sliding PubSub 唤醒读库)。→ **断线重连 = 重放历史 + 续传实时**,这是"重放事件"路线。
|
||||||
|
- v1 老的那套 `/global/event` 与 `/event` 是 **live-only、无 seq 无重放**。
|
||||||
|
|
||||||
|
**对外的重放/追平接口(v2)**:
|
||||||
|
|
||||||
|
- `GET /api/session/:sessionID/event?after=N` → `StreamSse(SessionEvent.Durable)`,"Replay durable events after an aggregate sequence, then continue with new durable events"(`packages/protocol/src/groups/session.ts`)。
|
||||||
|
- `GET /api/session/:sessionID/history?limit&after` → 分页读 durable 事件(SessionHistory)。
|
||||||
|
- 多 agent 协同:`/sync/replay`、`/sync/history`(`packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts`)返回 `{ aggregateID: lastKnownSeq } → seq 之后的事件`,用于客户端从同步点追平。
|
||||||
|
|
||||||
|
### 2.4 客户端断线后怎么追数据
|
||||||
|
|
||||||
|
- **TUI 主路径(v1 事件)**:`packages/tui/src/context/sdk.tsx` 里 `startSSE()` 调 `sdk.global.event()`,断了之后**指数退避重连**(1s→30s),**不重放** —— 因为 `/global/event` 无历史;TUI 靠**重新拉取状态**补齐:重连后重新 GET session/messages/status,并(实验性)`sync.start()` 开启工作区同步。
|
||||||
|
- **v2 路径(durable 事件)**:带 `after` seq 的订阅天然支持重放续传(2.3)。
|
||||||
|
- 结论:**"轻事件总线(全量广播、可丢、无 seq)+ 有 seq 的 durable 事件(SQLite 持久化、可重放)+ 客户端主动拉状态" 三层组合**,视客户端对可靠性的要求选层。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
|
||||||
|
- Python 侧实现 SSE 事件总线很简单(asyncio Queue + EventEmitter 等价物),关键是**给事件加 durable 序号并落库**,提供 `after=N` 重放订阅,才能让弱网/多客户端可靠追平。
|
||||||
|
- 把"live-only delta"与"durable 终值"分离(text.delta 不入库、text.ended 入库可重放)是很好的降本设计。
|
||||||
|
- 服务端按 `directory/workspace` 过滤事件(而非客户端过滤),减少带宽,Astrion 可按 workspace/agent 维度订阅。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Session 状态归属:存储、真状态、消息/part 模型
|
||||||
|
|
||||||
|
### 3.1 存哪
|
||||||
|
|
||||||
|
**SQLite(bun:sqlite + drizzle-orm + Effect 封装)**,单库文件:
|
||||||
|
|
||||||
|
- `packages/core/src/database/database.ts`:`PRAGMA journal_mode=WAL; synchronous=NORMAL; busy_timeout=5000; foreign_keys=ON`,库文件 `Global.Path.data/opencode.db`(`opencode-${channel}.db`)。
|
||||||
|
- 表都在 `packages/core/src/session/sql.ts`:`session`、`message`、`part`、`session_message`(v2 投影)、`session_input`、`todo`、`session_context_epoch`;事件表在 `packages/core/src/event/sql.ts`。
|
||||||
|
- 老 v1 的会话(TUI 现行 API 用的 `SessionService`)读写 SQLite 的 `session/message/part` 表;新的 v2 由 `SessionProjector`(`packages/core/src/session/projector.ts`)把 durable 事件投影进 `session_message` 表。
|
||||||
|
|
||||||
|
### 3.2 谁拥有"真状态"
|
||||||
|
|
||||||
|
**Server(core 进程内)**:Session(`packages/opencode/src/session/session.ts` + `packages/core/src/v1/session`)和 SessionV2(`packages/core/src/session`,事件溯源风格)都在 server 进程的 Effect 服务里;**消息/part 以 SQLite 为持久真源,事件总线只广播变化;客户端(TUI/SDK)是只读投影**。v2 的"真状态"本质是 durable 事件日志(SQLite),投影表/API 视图都是派生物 —— 事件即真相。所有权通过 `EventSequenceTable.owner_id` 表达(`claim(aggregateID, ownerID)`,`replay` 支持 `strictOwner`),多进程共享 session 时只能有一个 owner 追加事件。
|
||||||
|
|
||||||
|
### 3.3 数据模型长什么样
|
||||||
|
|
||||||
|
- **v1 message**(`packages/schema/src/v1/session.ts`):`MessageID = "msg_..."`,message = `{ id, sessionID, time:{created}, role, ... }`;**Part** 是 tagged union:`Text / Subtask / Reasoning / File / Tool / StepStart / StepFinish / Snapshot / Patch / Agent / Retry / Compaction`(`export const Part = Schema.Union([...])`,discriminator "type")。SQLite `message.data`/`part.data` 以 JSON 存。
|
||||||
|
- **v2 session_message**(`packages/schema/src/session-message.ts`):`SessionMessage.ID = "msg_..."`,tagged union:`agent-switched / model-switched / user / synthetic / system / shell / step-start / step-finish / reasoning / text / tool / ...`,带 `time:{created}`、`metadata`。投影表 `session_message(id, session_id, type, seq, data JSON)`,`uniqueIndex(session_id, seq)` —— **消息有每会话 seq,和事件 seq 对应**。
|
||||||
|
- **v2 session_input**(`packages/core/src/session/input.ts` + `sql.ts`):输入先 `admitted_seq` 落库(durable admit),再 `promoted_seq` 被 agent 循环取走 —— 输入也是持久化的。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
|
||||||
|
- "事件日志为真相 + 投影表 + 视图 API"的 CQRS/事件溯源结构在 Python 侧可用 `sqlite3`/`SQLAlchemy` + 事件表(aggregate_id, seq, type, data)实现,成本可控。
|
||||||
|
- 文件级 Session 与项目隔离(`project_id`/`directory` 列)对 Astrion 多 agent、多工作区是现成参考。
|
||||||
|
- 输入 admit/promote 两段式(先持久化再消费)能天然解决"请求丢失/重复"问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 权限 / 审批流:`POST /session/:id/permissions/:permissionID`
|
||||||
|
|
||||||
|
### 4.1 路由在哪
|
||||||
|
|
||||||
|
当前主服务的定义在 `packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
SessionPaths = {
|
||||||
|
...
|
||||||
|
permissions: `${root}/:sessionID/permissions/:permissionID`, // root = "/session"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
handler 在 `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx) {
|
||||||
|
yield* requireSession(ctx.params.sessionID)
|
||||||
|
yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response })
|
||||||
|
.pipe(Effect.catchTag("Permission.NotFoundError", ...))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
(repo 中也存在 v2 等价路径 `POST /api/session/:sessionID/permission/:requestID/reply`,见 `packages/protocol/src/groups/permission.ts`。)
|
||||||
|
|
||||||
|
### 4.2 审批请求如何产生
|
||||||
|
|
||||||
|
审批服务于两代实现,机制相同(pending map + Deferred 阻塞 + 事件广播):
|
||||||
|
|
||||||
|
- **老 v1**:`packages/opencode/src/permission/index.ts` —— `ask()` 先对 `ruleset + approved` 求值(`evaluate(permission, pattern, ...rulesets)`,`Wildcard.match` 匹配 rule:allow/deny/ask 默认 ask);需要问人时创建 `PendingEntry{info, deferred}` 放入内存 `pending: Map<ID, PendingEntry>`,然后 `events.publish(Event.Asked, info)` 广播,最后 `Deferred.await(deferred)` **阻塞住 agent 的工具调用**直到有人回复。
|
||||||
|
- **新 v2**:`packages/core/src/permission.ts` —— `PermissionV2.ask()`(返回 `{id, effect}`)与 `assert()`(阻塞式,供 agent 内部用):问人时 `create(request, agent)` 把 `{request, agent, deferred}` 放进 `pending: Map<ID, Pending>` 并 `events.publish(Event.Asked, request)`。
|
||||||
|
|
||||||
|
### 4.3 如何推给客户端
|
||||||
|
|
||||||
|
**通过事件总线广播,不是 HTTP 推送**:
|
||||||
|
|
||||||
|
- `events.publish(Event.Asked, info)` → `EventV2Bridge`(`packages/opencode/src/event-v2-bridge.ts`)→ `GlobalBus.emit("event", ...)` → `/global/event` SSE 推给所有已连接客户端。
|
||||||
|
- TUI 侧:`packages/tui/src/context/permission.tsx` / `feature-plugins/system/notifications.ts` 监听 `permission.asked` 事件弹审批 UI;同时 `context/sync.tsx` 的 case `permission.asked`/`permission.replied` 更新本地状态。
|
||||||
|
|
||||||
|
### 4.4 多个客户端同时连接时审批路由给谁
|
||||||
|
|
||||||
|
**不做"路由",做"共享待办 + 先到先得"**:
|
||||||
|
|
||||||
|
- pending 表在 **server 进程内存**(`Map<ID, Pending/PendingEntry>`),所有客户端共享;
|
||||||
|
- `permission.asked` 广播给**所有** SS E 连接(TUI、web、其他 SDK 客户端都能看到并都能回复);
|
||||||
|
- 回复接口按 `sessionID + requestID` 定位 pending 项(`reply()` 里 `pending.get(input.requestID)`),任何客户端 POST 都算;`Deferred` 只能被 resolve/fail 一次,后到的回复找不到请求(404 PermissionNotFoundError);
|
||||||
|
- 不存在"审批钉死给某个 client"的机制 —— **谁先回复谁生效**。当多个 TUI 同时开着,`requestID` 是共享主键,service 层不区分连接。
|
||||||
|
- 附加:回复 `"always"` 时保存规则到 `permission_saved` 表(`packages/core/src/permission/saved.ts` + `sql.ts`),并把其他 pending 的同类问题自动放行;`"reject"` 会级联拒绝同 session 所有 pending。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
|
||||||
|
- 审批 = "异步请求对象(内存/DB)+ 阻塞等待(Deferred/Promise)+ 事件广播(总线)+ 客户端主动回复(HTTP POST)",这是 HTTP 世界做 human-in-the-loop 的最小可靠模型。
|
||||||
|
- **多客户端审批共享同一请求 ID、先到先得** 意味着 Astrion 不需要做"连接路由",只需保证请求对象全局唯一、回复幂等。
|
||||||
|
- 用**事件(permission.asked)驱动 UI、用 REST 回复**的做法,比 RPC 回调更解耦,值得在 gateway 中复用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. TUI 与 server 的关系:连接方式与 /tui/* 反向控制
|
||||||
|
|
||||||
|
### 5.1 TUI 进程如何连接 server
|
||||||
|
|
||||||
|
**两种模式**(`packages/opencode/src/cli/cmd/tui.ts`):
|
||||||
|
|
||||||
|
1. **内嵌 worker 模式(默认)**:CLI handler 用 `new Worker(file, {...})` 拉起 `packages/opencode/src/cli/tui/worker.ts` 作为 **Bun worker 子进程**;worker 内 `Server.Default().app.fetch` 直接处理请求(不监听端口,URL 伪装成 `http://opencode.internal`):
|
||||||
|
- HTTP 请求走 RPC:`createWorkerFetch(client)` → `client.call("fetch", {...})` → worker `rpc.fetch` 调 `Server.Default().app.fetch(request)` 返回 Response;
|
||||||
|
- 事件走 RPC 事件:`createEventSource(client)` → `client.on("global.event")` 把 worker 里 `GlobalBus.on("event")` 转发出来的事件喂给 TUI 的 EventSource 接口。
|
||||||
|
2. **外部模式(`--port`/`--hostname`/`--mdns`)**:先 `client.call("server", network)` 让 worker 真正 `Server.listen()` 起 HTTP 端口,TUI 用真实 URL + `Authorization` 头(`ServerAuth.headers()`,`packages/opencode/src/server/auth.ts`,`OPENCODE_SERVER_PASSWORD`)直连。
|
||||||
|
|
||||||
|
TUI 内全部通过**生成的 SDK**(`@opencode-ai/sdk/v2` 的 `createOpencodeClient`)访问 API,`packages/tui/src/context/sdk.tsx` 里 `startSSE()` 调 `sdk.global.event()` 开 SSE(见 §2.4 的重连逻辑)。
|
||||||
|
|
||||||
|
### 5.2 /tui/* 反向控制接口的设计意图
|
||||||
|
|
||||||
|
定义:`packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts`(`TuiPaths`:`/tui/append-prompt`、`open-help`、`open-sessions`、`open-themes`、`open-models`、`submit-prompt`、`clear-prompt`、`execute-command`、`show-toast`、`publish`、`select-session`、`control/next`、`control/response`)。
|
||||||
|
|
||||||
|
**意图:把 TUI 当作一个可被 server 及任何客户端(web、插件、MCP、Agent)控制的"界面设备"**,实现方式不是私有的进程内回调,而是**两条标准通道**:
|
||||||
|
|
||||||
|
1. **事件通道(多数 /tui/* 端点)**:handler(`handlers/tui.ts`)并不直接调 TUI 内部函数,而是 `events.publish(TuiEvent.PromptAppend / CommandExecute / ToastShow / SessionSelect, ...)` **把"UI 指令"作为普通事件发布到事件总线**;TUI 作为 SSE 消费者收到 `tui.toast.show`、`tui.command.execute`(command 恒为 `session.list`、`help.show`、`model.list` 等字符串命令)后自己执行弹窗/切换。例如:
|
||||||
|
- `openHelp` → `publishCommand("help.show")`
|
||||||
|
- `openSessions` → `publishCommand("session.list")`
|
||||||
|
- MCP 认证失败时 server 自己 `events.publish(TuiEvent.ToastShow, {title:"MCP Authentication Required", ...})`(`packages/opencode/src/mcp/index.ts`)—— 同一通道、任意调用方。
|
||||||
|
2. **请求/响应队列通道(`/tui/control/next` + `/tui/control/response`)**:`packages/opencode/src/server/shared/tui-control.ts` 用两个模块级 `AsyncQueue`(`packages/opencode/src/util/queue.ts`,阻塞式队列)实现 `submitTuiRequest({path, body})` / `nextTuiRequest()` / `submitTuiResponse(body)` / `nextTuiResponse()`;TUI 拉取 `control/next`(long-poll)执行、POST `control/response` 交回结果。适合"必须拿到返回值"的 UI 操作(如 TUI 弹一个选择框,server 等它的结果)。
|
||||||
|
|
||||||
|
**结论**:`/tui/*` = "通过标准 HTTP 接口把事件塞进总线、由 TUI 自主消费",使 TUI 与 server 彻底解耦 —— 同一台 server 可以同时被 TUI、桌面端、web 端、Agent 进程控制,且 UI 指令事件天然对所有客户端可见。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
|
||||||
|
- "UI 即客户端设备、指令走事件总线、回执走请求队列"是 gateway 化后"远程控制本地交互界面"的标准答案:Astrion 的"弹确认框/提示"可以由任意调用方发布指令事件,前端订阅执行。
|
||||||
|
- 内嵌 worker + RPC 屏蔽 HTTP 与进程内调用的差异(`rpc.fetch` 模式),方便单元测试与本机零端口运行;对外则暴露真实端口 + 密码认证。Astrion 可在"进程内 gateway"与"独立 gateway 服务"之间无缝切换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 实例模型:单实例单项目 vs 多项目多会话
|
||||||
|
|
||||||
|
**是"单 server 进程 + 多 project 实例 + 每目录懒加载"**。
|
||||||
|
|
||||||
|
- **instance 概念**:`packages/opencode/src/project/instance-store.ts` 里 `InstanceStore.load({directory, ...})` —— 每个**目录(directory)**是一个 `InstanceContext {directory, worktree, project}`,server 进程用 `cache: Map<string, Entry>` 按目录缓存懒加载的实例;实例内包含一套完整 core 服务(Session/Permission/EventV2 等 Effect 层,`AppNodeBuilder` 组装)。
|
||||||
|
- **路由如何选实例**:`WorkspaceRoutingMiddleware`(`middleware/workspace-routing.ts`)读 query 的 `directory`/`workspace` 或 header `x-opencode-directory` → `InstanceContextMiddleware`(`middleware/instance-context.ts`)`store.load({directory})` 把请求路由到该目录的实例上下文(`InstanceRef`)。SDK 客户端可在创建时传 `directory`(`packages/sdk/js/src/v2/client.ts` 自动加 `x-opencode-directory` 头)。
|
||||||
|
- `opencode serve` 的注释直接说明:"Server loads instances per-request via x-opencode-directory header — no need for an ambient project InstanceContext at startup."(`packages/opencode/src/cli/cmd/serve.ts`)
|
||||||
|
- **workspace(实验性)**:`WorkspaceV2`(control-plane 的 `workspace.ts`)在 directory 之上再加一层,`WorkspaceRouteContext {directory, workspaceID}`;事件按 `location.directory + workspaceID` 过滤。
|
||||||
|
- 会话(session)挂在 project(`SessionTable.project_id`)下,同一 server 可同时服务多个 project/session;每个项目有自己的 SQLite 数据(同库分目录),隔离靠 directory/project 列与实例上下文。
|
||||||
|
- 多实例并发时,单写者原则由 `EventSequenceTable.owner_id` + `claim`/`strictOwner` 保证(§3.2)。
|
||||||
|
|
||||||
|
### 对 Astrion 的借鉴意义
|
||||||
|
|
||||||
|
- Astrion gateway 可以是"单进程多 workspace 懒加载实例"而非"一项目一进程":进程常驻、按请求头路由实例上下文,大幅简化部署;实例级状态(session、事件流)天然隔离。
|
||||||
|
- 用请求头/query 选实例的方案(`x-opencode-directory`)可作为 Astrion 多租户路由的样板。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## opencode 设计要点速查表
|
||||||
|
|
||||||
|
| # | 要点 | 一句话 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 框架 | Effect `HttpApi/HttpRouter`(非 Hono),Node http 底层,`OpenApi.fromApi` 代码生成 OpenAPI 3.1 |
|
||||||
|
| 2 | SDK | `bun dev generate` → openapi.json → `@hey-api/openapi-ts` → TS SDK(fetch client + SSE 类型) |
|
||||||
|
| 3 | Server 分层 | `@opencode-ai/protocol`(协议/group) + `@opencode-ai/server`(middleware/handler 注入) + `@opencode-ai/core`(领域服务) |
|
||||||
|
| 4 | 事件总线 | `GlobalBus`(EventEmitter)→ `/global/event` SSE(全量广播、live-only、10s 心跳);`/event` 按 directory 过滤 |
|
||||||
|
| 5 | 可靠事件 | EventV2:SQLite `event/event_sequence` 表 + per-aggregate seq + version;`durable(after)` 先重放后续传 |
|
||||||
|
| 6 | session 真状态 | Server 进程 + SQLite(WAL);消息/part 是 JSON 投影(v1 message/part 表、v2 session_message 表),事件日志即真相 |
|
||||||
|
| 7 | 消息模型 | Message/Part 都是 taggged union(type 判别),v2 session_message 带 per-session seq |
|
||||||
|
| 8 | 审批流 | pending Map + Deferred 阻塞等待 + `permission.asked` 事件广播;按 requestID 先到先得回复,无连接路由 |
|
||||||
|
| 9 | TUI 关系 | TUI=瘦客户端:内嵌 Bun worker + RPC fetch/事件,或 `--port` 真 HTTP + 密码认证;`/tui/*` 把 UI 指令发布为事件、TUI 自主消费 |
|
||||||
|
| 10 | 实例模型 | 单进程多项目:按 directory/`x-opencode-directory` 头懒加载 InstanceContext,实例内一套 core 服务;单写者由事件 ownership 保证 |
|
||||||
Loading…
Reference in New Issue
Block a user