feat(runtime): Gateway 收尾——session 传输暴露、host Bearer 通道、flask 依赖拆解、审批链路收口
- session.* 传输暴露:新增 gateway_api 蓝图(list/create/history),RuntimeService 补 create_session - host Bearer 通道:gateway_auth 双通道认证(host 模式+回环限定,token 存 DATA_DIR/host_api_token),tasks/approval 路由接入 - api_v1 会话路由转调公共入口消双轨,会话索引/列表补齐 run_mode/model_key/custom_prompt_name/personalization_name - flask 包依赖拆解:_flask_bridge 延迟桥接 + context 子包 PEP 562 懒加载,import server.tasks/runtime 不再拉起 flask - 审批链路:mark_expired 终态回写(超时/软停止/取消三路径)+ 终态 TTL 惰性清理(3600s) - 测试 patch 点随迁;全量 75 测试失败恰为 4 项存量,独立启动验收 4/4 全绿 Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
This commit is contained in:
parent
0381557fe9
commit
aa9bf377a3
@ -0,0 +1,129 @@
|
|||||||
|
# Client ↔ Gateway 链路审核报告(前端/CLI 消费面)
|
||||||
|
|
||||||
|
**审核日期**:2026-09-08
|
||||||
|
**审核对象**:`<repo>`(Flask 后端 + Vue3 Web + React/Ink CLI)
|
||||||
|
**审核方式**:只读。grep/符号定位 + 精确行段阅读,禁止整文件通读。行号以本次审核时代码为准。
|
||||||
|
**背景**:验证设计文档「①②③ 链路(Client ↔ Gateway ↔ Runtime)已贯通」四个实例声明是否属实。
|
||||||
|
**结论前缀**:✅属实 / ⚠️部分属实 / ❌不属实 / 「未验证」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 每项声明的验证结论 + 代码证据
|
||||||
|
|
||||||
|
### 声明 A:全部任务创建入口已收敛到 runtime_service.create_task(ctx)
|
||||||
|
|
||||||
|
**结论:✅ 属实(主 Run 受理层面)。**
|
||||||
|
|
||||||
|
**证据:**
|
||||||
|
- 唯一的 `task_manager.create_chat_task` 生产调用点收敛在网关内:
|
||||||
|
- `server/runtime/service.py:44` — `return task_manager.create_chat_task(ctx, conversation_id=conversation_id)`
|
||||||
|
- 全仓 `grep '\.create_chat_task('` 除以下之外无生产调用:`test/test_runtime_service.py:164`(测试用,验证 None ctx 抛错)、`.claude/worktrees/stoic-matsumoto-ac8ca5/`(旧 worktree,不在当前主工作树)、`_experiments/verify_conversation_level_terminal.py:83,93`(实验脚本,非生产入口)。
|
||||||
|
- `runtime_service.create_task(ctx)` 调用点共 6 处,均经公开入口:
|
||||||
|
- `server/tasks/api.py:187`(Web 创建任务路由)
|
||||||
|
- `server/api_v1.py:335`(API v1 消息路由,`@api_token_required`)
|
||||||
|
- `server/workflow_runtime_api.py:192`(workflow 激活)、`:269`(workflow notice 派发)
|
||||||
|
- `server/chat_flow_task_main.py:618`(多智能体完成通知派发 `_dispatch_completion_user_notice`)、`:1424`(多智能体闲时派发 `dispatch_ma_idle`)
|
||||||
|
- `RuntimeContext` 三层构造符合契约:适配层直接构造两个点(`server/tasks/api.py:170`、`server/api_v1.py:323`,principal 经 `principal_from_session_snapshot` 由认证后的 session 构造);内部通知链/工作流/多智能体用 `RuntimeContext.from_terminal`(`server/runtime/context.py` 定义,身份取自 terminal/workspace),不带客户端可提交的门闸 token 等内部指令。
|
||||||
|
- 执行链 `create_chat_task → 线程 → _run_chat_task → run_chat_task_sync → process_message_task → handle_task_with_sender` 的调用均在运行线程内部(`server/tasks/models.py:991` 调用 `run_chat_task_sync`;`chat_flow_task_main.py:664/1899/2500` 在运行线程内 spawn 子分支),不存在独立于网关的直拉过程。
|
||||||
|
|
||||||
|
**说明/例外:**
|
||||||
|
- 子智能体任务、传统后台命令是**独立的派生工作者实体**,不走 `runtime_service.create_task`(被 `server/chat_flow_task_main.py:2642` 注释明确说明「与传统后台任务完全分离,避免两者竞争 create_chat_task 的单工作区互斥」)。这与 `docs/runtime_protocol.md` §2「子智能体/后台命令是主 Run 派生的后台工作者」一致,不算漏网。
|
||||||
|
|
||||||
|
### 声明 B:server/chat/approval.py 六个路由全部转调 runtime_service
|
||||||
|
|
||||||
|
**结论:✅ 属实。**
|
||||||
|
|
||||||
|
**证据(`server/chat/approval.py`,六路由全部经公共入口):**
|
||||||
|
- `list_pending_user_questions` → `runtime_service.list_pending_approvals(..., kind="question")`(`:56`)
|
||||||
|
- `answer_user_question` → `runtime_service.resolve_approval("question", ...)`(`:71`)
|
||||||
|
- `list_pending_plan_approvals` → `runtime_service.list_pending_approvals(..., kind="plan")`(`:101`)
|
||||||
|
- `answer_plan_approval` → `runtime_service.resolve_approval("plan", ...)`(`:116`)
|
||||||
|
- `list_pending_tool_approvals` → `runtime_service.list_pending_approvals(..., kind="tool")`(`:145`)
|
||||||
|
- `decide_tool_approval` → `runtime_service.resolve_approval("tool", ...)`(`:161`)
|
||||||
|
|
||||||
|
**仪表验证:** `server/runtime/service.py` 中 `resolve_approval`/`list_pending_approvals` 均作了 kind 路由 + 权限/参数校验,再委托给 `server/state` 下的三个 manager。
|
||||||
|
|
||||||
|
**例外观察(非 Web 路由,不算入口违规):**
|
||||||
|
- `modules/auto_approval_service.py:89` 内部 `tool_approval_manager.decide(...)` 直调,是**自动审批 agent**(执行侧自动裁决),仅被 `server/chat_flow_tool_loop.py:894,1166` 调用(运行循环内)。属于 Execution Plane/Agent Runtime 侧机制,不经客户端入口,但若后续要统一裁决语义可考虑让其复用 `resolve_approval`。
|
||||||
|
|
||||||
|
### 声明 C:公共查询入口 list_runs / get_task / get_task_events / list_pending_approvals / list_sessions / get_session_history
|
||||||
|
|
||||||
|
**结论:⚠️ 部分属实(run.* 与 approval.* 已供 HTTP,session.list/history 无传输暴露)。**
|
||||||
|
|
||||||
|
**证据:**
|
||||||
|
- `run.list`:HTTP 暴露 `/api/tasks` GET(`server/tasks/api.py:33-44` → `runtime_service.list_runs`)。
|
||||||
|
- `run.get`:HTTP 暴露 `/api/tasks/<task_id>` GET(`server/tasks/api.py:205` → `runtime_service.get_task`)。
|
||||||
|
- `run.events`:HTTP 暴露 `/api/tasks/<task_id>?from=`(`server/tasks/api.py:220` → `runtime_service.get_task_events`,响应透传 `window_start`)。
|
||||||
|
- `approval.list`:HTTP 暴露 approval.py 三个 pending 路由。
|
||||||
|
- **`session.list` / `session.history`:❌ 仅进程内服务方法,无任何 HTTP 路由调用。**
|
||||||
|
- `grep` `list_sessions|get_session_history` 仅命中 `server/runtime/service.py` 定义 + `test/runtime_standalone_checks.py:273/282/290/300`(测试)。
|
||||||
|
- Web/API v1 的会话查询走的是**旧链路**,未复用公共入口:
|
||||||
|
- `server/conversation.py:571` `GET /api/conversations` 直接 `terminal.get_conversations_list(...)`
|
||||||
|
- `server/api_v1.py:351` `GET /workspaces/<id>/conversations` 直接 `conv_dir.glob("conv_*.json")`;`:385` `GET .../<conv_id>` 直接 `json.loads(_conversation_path...)`
|
||||||
|
- 即:`list_sessions`/`get_session_history` 是「为 CLI/定时任务预留的进程内直调入口」,但当前 Web/CLI 实际消费会话列表/历史仍在走旧 Web 路由。
|
||||||
|
|
||||||
|
### 声明 D:事件协议 task 级 idx/offset + meta.window_start 缺口;Web 与 CLI 客户端缺口处理
|
||||||
|
|
||||||
|
**结论:✅ 属实的服务端/Web 实现;⚠️ CLI 缺口处理不完整(见差距清单)。**
|
||||||
|
|
||||||
|
**证据:**
|
||||||
|
- 服务端:`server/runtime/service.py:get_task_events` 返回 `meta.window_start`(缺口水位);`server/tasks/api.py:220-225` 从 `ev_meta` 取 `window_start` 透传 HTTP。
|
||||||
|
- Web:`static/src/stores/task.ts`
|
||||||
|
- 轮询 `/api/tasks/<id>?from=<offset>`(`:191`)
|
||||||
|
- 读 `data.window_start`(`:264`)
|
||||||
|
- 缺口检测 `if (fromOffset > 0 && fromOffset < windowStart)` → 发合成事件 `event_window_gap`(`:263-285`)
|
||||||
|
- `static/src/app/methods/taskPolling/lifecycle.ts:395-408` `case 'event_window_gap'` → 跳过重建期后 `this.fetchAndDisplayHistory({ force: true })` + toast(i18n `stores.eventWindowGap*` 在 `locales/zh-CN/stores.ts:63-64`/`en-US/stores.ts:63-64`)。**缺口 → 快照对账链路真实存在。**
|
||||||
|
- CLI:`cli/src/App.tsx:491-503` `pollTask` 读 `result.window_start`,`if (offset > 0 && offset < windowStart)` → 提示「部分实时输出已被裁剪…以最终结果为准」并 `offset = windowStart` 对齐续读。**检测+对齐窗口存在,但仅提示不对账(见 §3 差距 2)。**
|
||||||
|
|
||||||
|
### 声明 E:CLI 当前是「Web API 消费者」(fetch 127.0.0.1:8091 + cookie/CSRF)
|
||||||
|
|
||||||
|
**结论:✅ 属实(且依赖度比声明更重)。**
|
||||||
|
|
||||||
|
**证据(`cli/src/api.ts`):**
|
||||||
|
- 认证:`ensureConnected` 调 `/api/csrf-token`(`fetchCsrf`)、`/host-login`(`hostLogin`);请求头携带 `Cookie` + `X-CSRF-Token`(`request`/`captureCookies`,`:160-215`);401 时自动 `fetchCsrf`+`hostLogin` 重试。
|
||||||
|
- 直连目标:`createDefaultApiClient` 默认 `http://127.0.0.1:${port}`(`AGENTS_API_PORT || WEB_SERVER_PORT || 8091`)。
|
||||||
|
- 若连不通,`startServer` 直接 `spawn('python3', ['-m', 'server.app', ...])`(`:52-75`)拉起完整 Flask/SocketIO Web 服务;`server/app.py` → `app_legacy.run_server`(含 socketio)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 发现的漏网路径 / 不一致清单
|
||||||
|
|
||||||
|
| # | 类型 | 描述 | 影响 | 代码证据 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | **双轨会话查询** | `session.list/history` 公共入口 `list_sessions`/`get_session_history` **无任何 HTTP 路由调用**;Web(`server/conversation.py:571`)与 API v1(`server/api_v1.py:351/385`)会话查询仍在直读磁盘/terminal,绕过公共入口 | 会话能力未统一到协议面;「新客户端可经公共协议查询会话」目前仅能靠进程内直调 | protocol §4 表 `session.list/history`(`runtime_service.list_sessions/get_session_history`)+ `grep` 无 HTTP 调用方 |
|
||||||
|
| 2 | **API v1 会话路由旧实施** | `GET /workspaces/<id>/conversations`、`GET .../<conv_id>` 用 `Path.glob("conv_*.json")` / `json.loads` 直读,独立于 `runtime_service` 且未做属主/工作区一致性校验的同构去重 | 与公共 `list_sessions` 载荷/校验不一致;多端字段未统一 | `server/api_v1.py:351-404` |
|
||||||
|
| 3 | **自动审批直调 manager** | `modules/auto_approval_service.py:89` `tool_approval_manager.decide(...)` 绕过 `resolve_approval` | 统一裁决语义分叉(审批条目来源/回调/审计若日后收敛到公共入口会遗漏该路径) | `modules/auto_approval_service.py:72-96`,仅被 `chat_flow_tool_loop.py:894/1166` 调用 |
|
||||||
|
| 4 | **旧 worktree/实验脚本直调** | `.claude/worktrees/stoic-matsumoto-ac8ca5/server/…` 多处 `task_manager.create_chat_task`;`_experiments/verify_conversation_level_terminal.py:83,93` | 不在当前主工作树/非生产;仅提示清理,不构成本机违规 | grep `.create_chat_task(` 结果 |
|
||||||
|
| 5 | **run.guide / queue 端点存在但 CLI 未消费** | `/api/tasks/<id>/runtime_guidance`(tasks/api.py:282)、`runtime_queue`(:310/338/360)已有 HTTP 路由,但 CLI `api.ts` 无对应方法 | CLI 作为待实现「Runtime Client」缺失 run.guide/追问队列能力 | `server/tasks/api.py:282-364` vs `cli/src/api.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CLI 成为「独立 Runtime Client」的差距清单
|
||||||
|
|
||||||
|
| # | 差距 | 证据 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **依赖 Web 专属认证流程** | `cli/src/api.ts:48-90` 用 `/api/csrf-token` + `/host-login` + Cookie/CSRF 头 | 协议 §6 明确的 Web 适配层认证;非通道无关 Token。独立客户端不应走 CSRF。 |
|
||||||
|
| 2 | **缺口只对齐不重同步** | `cli/src/App.tsx:497-502` 检测 `offset < windowStart` 仅提示并 `offset=windowStart`;**未触发 §5.4 快照对账**(Web 走 `fetchAndDisplayHistory({force:true})`) | 协议 §5.2 要求缺口必须「走重新同步(§5.4)」;CLI 会丢被裁剪的中间内容,仅以「最终结果为准」凑合。 |
|
||||||
|
| 3 | **仅支持 tool 审批,缺 plan/question** | `cli/src/App.tsx:505-511` 只处理 `tool_approval_required`;`api.ts:244` 只打 `/api/tool-approvals/<id>/decision` | approval.resolve 的 plan(submit_plan)/question(ask_user)能力 CLI 不可用。 |
|
||||||
|
| 4 | **自拉起完整 Web 服务** | `api.ts:startServer` spawn `python3 -m server.app`(SocketIO Web 全量) | 协议 §6「禁止每个客户端各起一份独立状态 Runtime 却宣称共享 Gateway」的边界上;本地单机场景可用,但非「只连公共 Gateway」。 |
|
||||||
|
| 5 | **依赖大量 Web-only 端点完成配置/调研** | `api.ts` 中 `/api/status`、`/api/conversations*`、`/api/model`、`/api/thinking-mode`、`/api/permission-mode`、`/api/execution-mode`、`/api/tool-settings`、`/api/path-authorization`、`/api/host/workspaces` 等 | 这些不受 protocol Commands/Queries 覆盖;独立客户端需等价 run.*/session.* 能力或明确这些为 Web adapter 私有。 |
|
||||||
|
| 6 | **无 run.list(Run 发现)消费** | `cli/src/api.ts` 未调用 `/api/tasks` GET 或 `list_runs` | 主 Run 发现能力(审核 F1 已落在服务层)CLI 未使用;不利于 A/B 观察场景。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 总体判断
|
||||||
|
|
||||||
|
**「①②③ 链路(Client ↔ Gateway ↔ Runtime)已贯通」:⚠️ 部分成立,未达到「新客户端可不复制 Web 专属流程直接接入」的程度。**
|
||||||
|
|
||||||
|
- **服务端收敛度**:高。主 Run 创建的 6 个入口全部经 `runtime_service.create_task`;approval.py 六路由全部经 `resolve_approval`/`list_pending_approvals`;run.list/get/events/cancel/guide/queue 均有 HTTP 公开路由并透传 `window_start`。声明 A、B、D(服务端/Web)、E 属实。
|
||||||
|
- **Web 消费面**:已消费公共协议面的 HTTP 端点(task 轮询 + approval + 缺口快照对账),基本属「公共协议能力消费方」。
|
||||||
|
- **主要缺口集中在「会话查询未传输暴露」与「CLI 独立性」两点**:
|
||||||
|
1. `session.list/history` 与 `session.create/load` 显式命令**没有 HTTP 传输暴露**,Web/API v1 仍在走旧会话路由——协议表虽标「✅/⚠️」但事实是**只有进程内直调入口**。
|
||||||
|
2. CLI 仍是「Web API 消费者」:cookie/CSRF/hostLogin + 自拉起完整 Web 服务,仅 tool 审批、缺口不重同步、依赖大量 Web-only 端点。
|
||||||
|
- **结论**:Gateway(服务层公共入口)本身已基本贯通且 Web 消费面已对齐;但「新客户端不复制 Web 专属流程即可接入」的**完整性目标未达成**,卡在 (a) 会话查询/创建的传输暴露、(b) CLI 认证与启动方式的 Web 耦合、以及 (c) 审批三类型覆盖与缺口快照对账。严格意义下应表述为「服务端公共入口贯通完成,客户端侧仍处『Web adapter 消费者』过渡态」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 附:未验证项
|
||||||
|
- 未在本次审核中执行真实运行验收(如 ApprovalWaitChainTest / ProtocolSmokeChainTest 的运行通过性),仅静态符号与行段核对;测试伪实现(`test_runtime_service.py` 曾把 `_run_chat_task` 替换为 lambda)的历史问题本报告未复验。
|
||||||
|
- HTTP 端点归属(哪些路由挂载到哪个 blueprint / app_legacy)的注册细节未逐一核对,仅核对路由定义文件内部映射。
|
||||||
@ -0,0 +1,335 @@
|
|||||||
|
# Runtime ↔ Execution Plane 链路审核报告
|
||||||
|
|
||||||
|
- 审核日期:2026-09-08
|
||||||
|
- 审核方式:只读(grep/符号定位 + 精确行段阅读 + 隔离 import 实验 + 子进程验收测试运行观察)
|
||||||
|
- 审核对象:Gateway(server/runtime/)→ Agent Runtime(server/chat_flow*.py、core/main_terminal.py)→ Execution Plane(core/main_terminal_parts/tools_execution.py、modules/execution_plane/)
|
||||||
|
- 结论分级:✅属实 / ⚠️部分属实 / ❌不属实 / 未验证
|
||||||
|
- 验收测试运行结果(本机 venv 实测):
|
||||||
|
- `check_lifecycle` → **PASSED (1.6s)**(无 Flask app 真实生命周期)
|
||||||
|
- `check_fake_exec` → **PASSED (0.7s)**(替身执行 E1-E4,零真实副作用)
|
||||||
|
- `check_chain` → **PASSED (2.7s)**(双客户端发现/观察/取消 + 历史 + 审批)
|
||||||
|
- `check_approval_wait` → **PASSED (3.5s)**(执行中审批等待 → 公共入口批准 → 继续)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、逐项声明验证结论
|
||||||
|
|
||||||
|
### 1. Runtime 层本体(server/runtime/context.py + service.py)
|
||||||
|
|
||||||
|
#### (a) 是否真的不 import flask —— ✅ 属实
|
||||||
|
|
||||||
|
证据:
|
||||||
|
- `server/runtime/context.py` 顶层 import 仅:`__future__` / `dataclasses` / `typing`(L14-17)。
|
||||||
|
- `server/runtime/service.py` 顶层 import 仅:`__future__` / `typing` / `modules.i18n.tr` / `server.runtime.context`(L13-18)。
|
||||||
|
- `server/runtime/` 全文 grep `flask|socketio`:唯一命中的是 `context.py:141` 的**注释文本**「本模块不 import flask」(说明性文档,非导入)。
|
||||||
|
- `grep -rniE "import flask|from flask|import socketio|from socketio" server/runtime/` → **零实际导入命中**。
|
||||||
|
|
||||||
|
import 实验佐证(本机 `.venv/bin/python3`):
|
||||||
|
```
|
||||||
|
>>> import server.runtime.service
|
||||||
|
OK
|
||||||
|
>>> 'flask' in sys.modules # False(未拉起)
|
||||||
|
>>> 'flask_socketio' in sys.modules # False
|
||||||
|
>>> 'server.tasks' in sys.modules # False(延迟导入未触发)
|
||||||
|
```
|
||||||
|
结论:Runtime 层本体零 Flask 依赖 ✅。注意:**模块本体不依赖 flask ≠ 调用链不依赖 flask**(见第 3 节 import 链实测)。
|
||||||
|
|
||||||
|
#### (b) TrustedPrincipal 只能由适配层构造?防自报 role 机制 —— ⚠️ 部分属实
|
||||||
|
|
||||||
|
证据:
|
||||||
|
- `context.py:20-41`:`TrustedPrincipal` 是普通 `@dataclass(frozen=True)`,**没有私有构造/工厂限制**——任何代码均可直接 `TrustedPrincipal(username=..., workspace_id=..., role="admin")` 自报角色。
|
||||||
|
- `validate()`(L30-34)只校验 `username`、`workspace_id` 非空,**不校验 role 白名单、不校验 is_api_user/host_mode 组合**。
|
||||||
|
- `RuntimeContext.from_terminal`(L79-105)与 `principal_from_session_snapshot`(L139-168)是仅有的两个"可信构造路径":
|
||||||
|
- `from_terminal`:host_mode 取 `workspace.username == "host"`、role 取 `terminal.user_role`(对话级 terminal 既有属性,非客户端自报);
|
||||||
|
- `principal_from_session_snapshot`:显式声明「调用方必须在完成认证后调用」,从适配层传入的 session dict 快照构造。
|
||||||
|
- 防御纵深确实存在:`service._resources_for_query`(service.py:269-283)校验 `principal.username == username` 与 `principal.workspace_id == workspace_id`,**跨身份/跨工作区查询抛 PermissionError**(测试 check_chain 第 5.5 节已验证此语义)。
|
||||||
|
|
||||||
|
结论:**「只能由适配层构造」是模块 docstring 与调用约定的强语义 + 查询侧纵深防御,不是 dataclass 层面的强制**。同一进程内任何持有代码写入权的调用方(或未来 Schedule payload)仍可直接构造高权限 principal。当前所有生产调用点均经由适配层(HTTP session 或 from_terminal),实战安全 ✅;但"防自报 role 的机制"属于**约定约束而非结构约束**,标注 ⚠️。
|
||||||
|
|
||||||
|
#### (c) service 方法集与文档声称一致性 —— ✅ 属实
|
||||||
|
|
||||||
|
文档声称:create_task / cancel_task / guidance / pending 队列 / get_task / get_task_events / list_runs / list_sessions / get_session_history / resolve_approval。
|
||||||
|
|
||||||
|
实测方法集(service.py):
|
||||||
|
| 文档声称 | 实际方法 | 证据行 |
|
||||||
|
|---|---|---|
|
||||||
|
| create_task | `create_task(ctx)` + `_ensure_conversation_for_chat` | L47-57, L60-96 |
|
||||||
|
| cancel_task | `cancel_task(username, task_id)` | L100-103 |
|
||||||
|
| guidance | `enqueue_runtime_guidance` | L106-109 |
|
||||||
|
| pending 队列 | `enqueue_runtime_pending_message` / `remove_runtime_pending_message` / `promote_runtime_pending_to_guidance` / `get_runtime_pending_messages` | L112-124, L312-316 |
|
||||||
|
| get_task | `get_task(username, task_id)` | L318-320 |
|
||||||
|
| get_task_events | `get_task_events(username, task_id, offset)`(返回 events/next_offset/error/meta,含 window_start 缺口水位) | L323-342 |
|
||||||
|
| list_runs | `list_runs(username, workspace_id, conversation_id/status 筛选)`(复用 `task_public_payload` 单序列化实现) | L294-310 |
|
||||||
|
| list_sessions | `list_sessions(username, workspace_id, principal, ...)` | L158-174 |
|
||||||
|
| get_session_history | `get_session_history(username, workspace_id, conversation_id, principal)` | L177-192 |
|
||||||
|
| resolve_approval | `resolve_approval(kind, ...)`(tool/plan/question 三路由)+ `list_pending_approvals` | L127-156, L117-125 |
|
||||||
|
|
||||||
|
方法集与文档完全对齐 ✅(文档未列的 `_resources_for_query`/`_ensure_conversation_for_chat` 是私有实现细节)。
|
||||||
|
|
||||||
|
#### (d) service 是否确实不持有任务状态 —— ✅ 属实
|
||||||
|
|
||||||
|
证据:
|
||||||
|
- `service.py` docstring:L8-9「本服务只做受理裁决 + 显式上下文转发 + 控制委托,不持有任务状态」;L24「无状态:全部状态委托给 task_manager 单例」。
|
||||||
|
- 全部状态操作均**延迟导入**并委托:`from server.tasks import task_manager`(L52、L101、L107 等 8 处)、任务记录/事件流/门闸/保存保护由 TaskManager / main_task_gate / conversation_manager 承载。
|
||||||
|
- 方法体内无 `self._xxx` 状态字段;`RuntimeService` 类没有 `__init__` 之外的实例属性。
|
||||||
|
- 进程级单例 `runtime_service = RuntimeService()`(L353)可安全共享。
|
||||||
|
|
||||||
|
结论 ✅。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 执行链贯通验证
|
||||||
|
|
||||||
|
链:`server/tasks/models.py::create_chat_task`(L136)→ 线程 `_run_chat_task`(L771)→ 延迟 `from server.chat_flow import run_chat_task_sync`(L958-959)→ `run_chat_task_sync`(chat_flow.py:280-282)→ `process_message_task`(chat_flow.py:133)→ 门闸获取(L141)→ `handle_task_with_sender`(由 chat_flow_runner → chat_flow_task_runner re-export,定义于 chat_flow_task_main.py:1475)。
|
||||||
|
|
||||||
|
#### (a) session_data dict 零残留 —— ✅ 属实
|
||||||
|
|
||||||
|
grep `session_data`(server/ core/ modules/ utils/ config/ 全部生产代码,排除 __pycache__)命中仅 3 处,全部为**注释或 i18n key 文案,无 dict 状态**:
|
||||||
|
- `server/tasks/models.py:54` 注释「不再有 session_data 兼容快照 dict」;
|
||||||
|
- `server/tasks/models.py:148` `raise ValueError(tr("tasks.missing_session_data"))`(错误消息 key,语义已改为「缺少运行上下文,请通过公共任务入口提交」——见 modules/i18n_messages/api_tasks.py:105);
|
||||||
|
- `server/runtime/service.py:11` 注释。
|
||||||
|
- `TaskRecord.__slots__`(models.py:35-66)为 `principal / task_params / directives / goal_progress` 四层结构化字段,`to_session_data()` 已不存在(grep 零命中)。
|
||||||
|
|
||||||
|
结论:生产代码 dict 零残留 ✅(i18n key 名称未改属命名瑕疵,不影响语义)。
|
||||||
|
|
||||||
|
#### (b) test_request_context 零残留 —— ✅ 属实
|
||||||
|
|
||||||
|
grep `test_request_context`(server/ core/ modules/)→ **零命中**(返回码 1 表示无匹配)。
|
||||||
|
`models.py:771-780` 任务线程装配:从 `rec.principal` 映射 `RuntimeIdentity(...)` 后直接 `get_user_resources(..., update_session=False, identity=identity)`——全程无 Flask 请求上下文。`get_user_resources` 内部(resources.py:140-143):`explicit = identity is not None`、`can_write_session = (not explicit) and update_session and has_request_context()`——显式身份模式下与 session 完全解耦。
|
||||||
|
|
||||||
|
#### (c) RuntimeIdentity 驱动的资源解析链路 —— ✅ 属实
|
||||||
|
|
||||||
|
- `server/context/identity.py:19-31`:`RuntimeIdentity` dataclass(host_mode / host_workspace_id / is_api_user / role / preferred_* 偏好快照),docstring 明确「传入 get_user_resources 后,资源装配完全不读写 Flask session;为 None 时保持既有行为(HTTP 适配层在请求上下文内读取 session)」。
|
||||||
|
- `identity.py:38-44` `_resolve_user_role`:显式身份模式不触碰 session。
|
||||||
|
- `resources.py:118-123` `get_user_resources(username, workspace_id=..., update_session=True, conversation_id=None, identity=None)`——identity 参数化真实存在。
|
||||||
|
- 调用点:
|
||||||
|
- 任务线程 `_run_chat_task`(models.py:782-795)传 identity;
|
||||||
|
- service 查询面 `_resources_for_query`(service.py:276-288)传 identity;
|
||||||
|
- service 补建对话 `_ensure_conversation_for_chat`(service.py:71-76)传 identity。
|
||||||
|
|
||||||
|
结论 ✅。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 独立启动验证
|
||||||
|
|
||||||
|
#### (a) 验收真实性 —— ✅ 属实(真生命周期,非假验收)
|
||||||
|
|
||||||
|
读取 `test/runtime_standalone_checks.py` 全文 + `test/test_runtime_standalone_lifecycle.py`:
|
||||||
|
|
||||||
|
- **没有将 `_run_chat_task` 替换为空 lambda**。薄壳 `test_runtime_standalone_lifecycle.py` 仅用 `subprocess.run([sys.executable, checks_script, mode])` 隔离执行(L26-36),验收逻辑全在 checks 脚本:
|
||||||
|
- `check_lifecycle`:`from flask import has_app_context` 断言无 app 上下文 → 真实 `runtime_service.create_task(_make_ctx(...))` → 轮询 `get_task_events` 等 **`api_request_start` 出现(装配证据,历史/请求构造完成、模型调用前)** → 装配后主动取消 → 断言终态 → 断言对话级 terminal 存在(`resources.state.user_terminals.get(term_key) is not None`)→ 断言 `is_main_task_gate_busy(terminal)` 为 False(门闸释放)→ 结尾再断言无 app 上下文且 `ext.socketio.server is None`。
|
||||||
|
- 审核 F4 的"假通过"修复点明确存在:`api_request_start 之前出现 error = 装配失败,验收必须明确失败`(L108-113)——杜绝"模型调用失败被误判为通过"。
|
||||||
|
- `check_chain`:B 客户端不持 task_id,经 `runtime_service.list_runs` 发现 A 的活动 Run → 观察事件流等 user_message 落盘 → 跨端取消 → 会话 JSON 持久化断言 → 事件流 idx 单调/无重复/offset 续读 → 第二 Run 复用会话 → list_sessions/get_session_history + principal 不一致抛 PermissionError(跨用户 & 跨工作区两条)→ list_runs 归属/会话/状态筛选 → 审批公共入口(重复裁决返回现状、越权 PermissionError、未知类型 ValueError)。
|
||||||
|
- `check_fake_exec`:真实 `terminal.handle_tool_call` 驱动 FakeExecutionBackend,断言替身收到 E1-E4 全部调用、输出结构被编排层消费、**零真实磁盘写入**(`assert not (_SMOKE_WORKSPACE / "fake_probe.txt").exists()`)、默认路径新建 terminal 的 `execution_backend is None`。
|
||||||
|
- `check_approval_wait`:真实工具编排层 `_execute_tool_calls_impl` + approval 权限模式,线程执行中产生 `tool_approval_required` 事件 → 主线程经 `resolve_approval` 批准 → 替身收到命令、工具循环退出。
|
||||||
|
|
||||||
|
**本机实测(venv)**:四次检查全部 PASSED(lifecycle 1.6s / fake_exec 0.7s / chain 2.7s / approval_wait 3.5s)——独立 Gateway 在**无 Flask app 上下文**环境下完成了 受理→装配(api_request_start 证据)→事件→取消→终态→门闸释放 的全流程。模型调用因指向 127.0.0.1:9 连接拒绝失败属预期(外部依赖非验收对象),装配与生命周期真实发生。
|
||||||
|
|
||||||
|
#### (b) import 依赖链现状 —— ⚠️ 部分属实(存在实质缺陷)
|
||||||
|
|
||||||
|
实测追踪(import 钩子打印调用栈 + sys.modules 观察):
|
||||||
|
|
||||||
|
**链路 A:`import server.runtime.service`** → 不拉起 flask / flask_socketio / server.tasks ✓
|
||||||
|
|
||||||
|
**链路 B:`from server.tasks import task_manager`(service 方法体内延迟触发)**:
|
||||||
|
```
|
||||||
|
>>> flask imported BY server.auth_helpers
|
||||||
|
>>> flask imported BY server.context.personalization
|
||||||
|
>>> flask imported BY server.context.upload
|
||||||
|
>>> flask imported BY server.context.conversation
|
||||||
|
>>> flask imported BY server.context.resources
|
||||||
|
>>> flask imported BY server.context.decorators
|
||||||
|
flask: True | flask_socketio: False | server.extensions: False | server.chat_flow: False
|
||||||
|
```
|
||||||
|
即:**server.tasks 的导入链仍会级联拉起 Flask 包本体**。具体断点:`server/tasks/models.py:16` 顶层 `from server.context import RuntimeIdentity, get_user_resources, ...` → `server/context/__init__.py` 顶层 re-export 会加载 identity/resources/personalization 等 → `server/context/identity.py:7` 顶层 `from server.auth_helpers import get_current_user_role` → `server/auth_helpers.py:5` 顶层 `from flask import session, redirect, jsonify`。`resources.py:15` 也直接 `from flask import session, has_request_context`。
|
||||||
|
|
||||||
|
且**在未安装 flask 的裸环境(系统 python3)中,`from server.tasks import task_manager` 直接 ModuleNotFoundError**——"server/tasks/__init__.py 注释声称『无 Web 依赖,可独立加载』"(__init__.py L5-8)与事实不符:不依赖 **Web app 初始化/Blueprint/SocketIO** ✅,但依赖 **flask 包本体** ❌。
|
||||||
|
|
||||||
|
**链路 C:`from server.chat_flow import run_chat_task_sync`(_run_chat_task 线程内延迟导入)**:
|
||||||
|
```
|
||||||
|
flask: True | flask_socketio: True | server.extensions: True | server.app: False | server.app_legacy: False
|
||||||
|
```
|
||||||
|
chat_flow.py:19 顶层 `from flask import ...` + L73 `from .extensions import socketio, run_background` → extensions.py:4 `from flask_socketio import SocketIO` + L7 实例化 `socketio = SocketIO(...)`。注意:**实例化 SocketIO 对象但不 init_app**(`socketio.server is None`),且不触发 `server.app` / `server.app_legacy`(无 Flask app 创建、无蓝图注册)——与"无 Flask app 上下文"验收一致。
|
||||||
|
|
||||||
|
**综合结论 ⚠️**:任务线程拆除 test_request_context、RuntimeIdentity 驱动、验收测试无 Flask app 全部属实;但**「Gateway 可脱离 Flask 初始化」的声明过头了**——真正的表述应为:
|
||||||
|
- ✅ Runtime 层本体(server.runtime)零 Flask 依赖,可 import;
|
||||||
|
- ⚠️ 一旦调用 create_task(延迟导入 server.tasks),**flask 包必然被拉起**(经 server.context.identity → auth_helpers),无 flask 环境直接 import 失败;
|
||||||
|
- ⚠️ 任务实际执行(_run_chat_task → 延迟导入 chat_flow)会拉起 **flask + flask_socketio + SocketIO 实例**(但不 init_app、不创建 app、不注册蓝图);
|
||||||
|
- 即:**进程可无 Flask app 上下文运行,但无法在未安装 flask/flask_socketio 的纯环境运行**——装修层(Web 适配模块)侵入核心 import 链的实质未完全消除。
|
||||||
|
|
||||||
|
残留耦合点(建议未来拆解):
|
||||||
|
1. `server/context/identity.py:7` 顶层 `from server.auth_helpers import get_current_user_role`——identity 是核心身份模型,却依赖 Web 认证适配层(即使显式身份路径不用它,_resolve_user_role 的 None 分支使用);
|
||||||
|
2. `server/context/resources.py:15` 顶层 `from flask import session, has_request_context`——资源装配核心模块顶层依赖 flask;
|
||||||
|
3. `server/context/__init__.py` 一次 re-export 全量子模块,导致 identity/resources/personalization/upload/conversation/decorators 连带加载。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Runtime ↔ 执行层边界(modules/execution_plane/ + docs/execution_contract.md)
|
||||||
|
|
||||||
|
#### (a) ExecutionBackend 协议 E1-E4 方法签名 —— ✅ 属实
|
||||||
|
|
||||||
|
`modules/execution_plane/base.py`:
|
||||||
|
```
|
||||||
|
@runtime_checkable
|
||||||
|
class ExecutionBackend(Protocol):
|
||||||
|
async def run_command(command, *, timeout, sandbox_write_access, network_permission) -> Dict # E1
|
||||||
|
def run_command_background(command, *, timeout, conversation_id, wait_seconds, network_permission, sandbox_write_access) -> Dict # E2
|
||||||
|
def write_file(path, content, *, mode="w") -> Dict # E3
|
||||||
|
def edit_file(path, replacements: List[Dict]) -> Dict # E4
|
||||||
|
```
|
||||||
|
结果结构约定(docstring):
|
||||||
|
- run_command: `{success, status, output, return_code, truncated, elapsed_ms}`,status ∈ completed/timeout/error/cancelled;
|
||||||
|
- run_command_background: `{success, command_id, status}`;
|
||||||
|
- write/edit: `{success, path, original_file, new_file}`。
|
||||||
|
`FakeExecutionBackend`(fake.py)实现全部 4 方法(内存文件系统 files dict + calls 记录 + background_commands dict),签名与协议逐一对应。
|
||||||
|
|
||||||
|
#### (b) MainTerminal.execution_backend 四注入分支真实存在且默认 None 走原路径 —— ✅ 属实
|
||||||
|
|
||||||
|
- 初始化:`core/main_terminal.py:149` `self.execution_backend = None`(注释:None = 现有 Host/Docker 真实链路;注入 ExecutionBackend 实现后 E1-E4 分支改走该后端)。
|
||||||
|
- 四分支(tools_execution.py `handle_tool_call`,L1057):
|
||||||
|
- **write_file**(L1635-1648):`backend is not None` → `backend.write_file(path, content, mode)`(跳过浅备份);else → `_track_shallow_versioning` + `self.file_manager.write_file`。
|
||||||
|
- **edit_file**(L1669-1683):backend → `backend.edit_file(path, replacements)`;else → `_track_shallow_versioning` + `self.file_manager.replace_many_in_file`。
|
||||||
|
- **run_command 后台**(L1923-1932):backend → `backend.run_command_background(command, timeout=..., conversation_id=..., wait_seconds=5.0, network_permission=..., sandbox_write_access=...)`;else → `bg_manager.create_background_command(...)`。
|
||||||
|
- **run_command 前台**(L1958-1966):backend → `await backend.run_command(command, timeout=..., sandbox_write_access=..., network_permission=...)`;else → `await self.terminal_ops.run_command(...)`。
|
||||||
|
- 四分支共用前置:权限裁决在进入分支前完成(L1891-1906:`permission_mode = self.get_permission_mode()` → `sandbox_write_access = not (readonly or (approval/auto_approval and not write_granted_once))` → `network_permission` 解析);后端分支**不重复裁决**(协议"只承诺执行语义,不构成安全边界")。
|
||||||
|
- 默认路径回归验证:check_fake_exec 断言新建对话级 terminal 的 `execution_backend is None` → 真实链路。
|
||||||
|
|
||||||
|
#### (c) 权限裁决/沙箱计划在哪一层 —— ✅ 属实(Runtime 编排层裁决,执行层不重复)
|
||||||
|
|
||||||
|
- `tools_execution.py:391` `evaluate_tool_permission(tool_name, arguments)`:permission mode 语义(readonly/approval/auto_approval/unrestricted)完整在此层(L394-470 多档分支)。
|
||||||
|
- 注入分支处显式传递**裁决结论**(`sandbox_write_access` / `network_permission` 已解析),ExecutionBackend 收到的即结论,不再二次裁决(base.py docstring L8-10、execution_contract.md §4)。
|
||||||
|
- 先读后写拦截(`_check_read_before_edit_prerequisite`)与浅版本备份也在编排层(write/edit 分支前置)。
|
||||||
|
- **契约注记(执行层安全边界分工)**:命令校验(_validate_command/FORBIDDEN_COMMANDS)与路径授权(_validate_path/禁读清单)仍由旧 terminal_ops/file_manager 链路承担(execution_contract.md §4 + base.py docstring——审核 §4 修正后的表述),OS 层强制力(Seatbelt/bwrap/DAC+Landlock)是最终边界。
|
||||||
|
|
||||||
|
#### (d) Host/Docker 迁入还差什么 —— ✅ 属实(E5-E10 后置为独立工作)
|
||||||
|
|
||||||
|
依据 `docs/execution_contract.md` §3 表:
|
||||||
|
- 已入协议(✅):E1 run_command(terminal_ops/run.py)、E2 run_command_background(background_command_manager.py)、E3 write_file(file_manager/crud_mixin.py)、E4 edit_file(file_manager/replace_mixin.py)。
|
||||||
|
- 后续纳入(⬜):E5 read_file 三模式、E6 create/delete/rename/mkdir 族、E7 持久终端会话、E8 path_validate、E9 执行环境快照、E10 命令校验/超时钳制。
|
||||||
|
- 剩余工作(文档 §6 + 记忆 gateway_runtime_work_plan 中用户拍板):
|
||||||
|
1. HostDockerBackend 适配器(真实后端实现 E1-E10 + 保留现有校验分配);
|
||||||
|
2. 编排层全面切换(现仅 E1-E4 四分支注入,其余工具仍直挂 terminal_ops/file_manager);
|
||||||
|
3. 结构债:执行命令后端选择逻辑在 terminal_ops/run.py、background_command_manager.py、persistent_terminal/start.py、container_file_proxy.py **4 处各有一份复制**(契约 §5),E5-E10 接口面完整化后是天然收敛点。
|
||||||
|
- 生产路径不变:默认 `execution_backend=None`,无替换行为。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 智能体循环层独立性(core/ 对 Flask 依赖残留)
|
||||||
|
|
||||||
|
#### —— ✅ 属实(Agent Runtime 层零 Flask 反向依赖)
|
||||||
|
|
||||||
|
- `grep -rnE "^from flask|^import flask|^from flask_socketio|socketio" core/` → **零命中**(两次独立 grep 均为空,返回码 1)。
|
||||||
|
- `core/main_terminal.py` 顶层 import(L3-72):全为 modules/*、utils/*、config/model_profiles、core.main_terminal_parts、modules.i18n——**无 flask/socketio**。
|
||||||
|
- `core/main_terminal_parts/tools_execution.py`:顶层无 flask/socketio(grep 零命中);`core/main_terminal_parts/` 其余文件同样零命中。
|
||||||
|
- 唯一注意点:`tools_execution.py:2195/2498/2514` 有 `_is_web = '/web/users/' in _data_dir` 的**路径字符串判定**(Web/CLI 数据目录布局差异),属身份/路径耦合(记忆里已归入遗留),非 Flask API 依赖。
|
||||||
|
|
||||||
|
结论:Agent Runtime(③ 层)不反向依赖 Web 层(②)✅。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、Gateway → Runtime → Execution 实际分层图(按代码事实)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ① Client(Web 前端 / CLI / 未来定时触发器) │
|
||||||
|
└───────────────┬──────────────────────────────────────────────────────┘
|
||||||
|
│ HTTP / 轮询事件流(idx/offset 协议)
|
||||||
|
┌───────────────▼──────────────────────────────────────────────────────┐
|
||||||
|
│ ② Gateway(server/runtime/) │
|
||||||
|
│ context.py: RuntimeContext = TrustedPrincipal + TaskParams │
|
||||||
|
│ + InternalDirectives(门闸 token 走 internal,不接受 │
|
||||||
|
│ 客户端提交;principal 由适配层构造【约定级约束】) │
|
||||||
|
│ service.py: RuntimeService 无状态薄层(受理/控制/查询/审批/会话, │
|
||||||
|
│ 全部延迟委托 task_manager) │
|
||||||
|
│ ─── 边界清晰 ⚠️:本体零 Flask;但延迟导入 server.tasks 链 │
|
||||||
|
│ 仍级联拉起 flask 包(context.identity → auth_helpers) │
|
||||||
|
└───────────────┬──────────────────────────────────────────────────────┘
|
||||||
|
│ create_task → create_chat_task → 线程 _run_chat_task
|
||||||
|
│ RuntimeIdentity 驱动,全程无隐式上下文 / 无 │
|
||||||
|
│ test_request_context / 无 session_data │
|
||||||
|
┌───────────────▼──────────────────────────────────────────────────────┐
|
||||||
|
│ ③ Agent Runtime(server/chat_flow*.py + core/main_terminal.py) │
|
||||||
|
│ 执行链:run_chat_task_sync → process_message_task(门闸获取/ │
|
||||||
|
│ finally 释放)→ handle_task_with_sender(模型循环) │
|
||||||
|
│ → core/main_terminal_parts/tools_execution.py::handle_tool_call │
|
||||||
|
│ ─── core/ 零 Flask 依赖 ✅ 边界清晰 │
|
||||||
|
│ ⚠️ 残留:chat_flow.py/chat_flow_task_main.py 顶层仍在 │
|
||||||
|
│ (延迟导入 chat_flow 时拉起 flask+extensions) │
|
||||||
|
└───────────────┬──────────────────────────────────────────────────────┘
|
||||||
|
│ E1-E4 注入分支(execution_backend:None → 原路径;
|
||||||
|
│ 注入 → 替身/未来远端后端);权限裁决【编排层】完成,
|
||||||
|
│ 传裁决结论(sandbox_write_access/network_permission)
|
||||||
|
┌───────────────▼──────────────────────────────────────────────────────┐
|
||||||
|
│ ④ Execution Plane(modules/execution_plane/) │
|
||||||
|
│ ExecutionBackend 协议 E1-E4(Protocol, runtime_checkable) │
|
||||||
|
│ FakeExecutionBackend 替身(内存,验收通过) │
|
||||||
|
│ Host/Docker 真实后端(terminal_ops/file_manager 直挂) │
|
||||||
|
│ ─── 边界清晰但不完整:仅 E1-E4 协议化;E5-E10 + 4 处后端选择逻辑 │
|
||||||
|
│ 复制收敛 = 后置独立工作 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
边界评级:
|
||||||
|
[①→②] 清晰(公共协议 + 事件流 + 三层上下文;但 principal 自报 role 为约定级防线)
|
||||||
|
[②→③] ⚠️ 半清晰(任务线程纯 RuntimeIdentity;但 flask 包经 server.context
|
||||||
|
identity/resources 顶层 import 侵入核心链,无 flask 环境无法运行)
|
||||||
|
[③→④] 半清晰(core/ 零 Flask ✅;E1-E4 注入 ✅;但权限校验分两处——
|
||||||
|
编排层裁决 + 旧链路命令校验/路径授权,Host/Docker 未迁入协议)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、与文档声称不符的发现清单
|
||||||
|
|
||||||
|
| # | 严重度 | 文档声称 | 代码事实 | 证据 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| F1 | 中 | tasks/__init__.py 注释「本包只导出任务核心层…保证 server.runtime 在无 Web 应用初始化的进程中可独立加载使用」 | 可脱离 **Web app 初始化** 加载 ✅,但不可脱离 **flask 包** 加载:`from server.tasks import task_manager` 级联拉起 flask(经 context.identity→auth_helpers),裸环境直接 ModuleNotFoundError | identity.py:7 `from server.auth_helpers import get_current_user_role`;resources.py:15 `from flask import session, has_request_context`;auth_helpers.py:5 `from flask import session, redirect, jsonify`;裸 python3 import 实验 FAIL |
|
||||||
|
| F2 | 中 | 任务线程「全程 RuntimeIdentity 驱动」隐含"模块级已解耦 Web" | 线程内确实 RuntimeIdentity 驱动(无 test_request_context)✅;但**执行阶段** `_run_chat_task` 延迟导入 `server.chat_flow` → 拉起 flask_socketio + `SocketIO()` 实例(即时不 init_app、`has_app_context()` 仍 False,socketio.server=None 静默跳过) | models.py:958-959;chat_flow.py:19,73;extensions.py:4-7;venv import 实验3 |
|
||||||
|
| F3 | 低 | TrustedPrincipal「只能由适配层构造」 | **约定 + 校验侧纵深防御,非结构强制**:dataclass 无构造限制、validate() 不校验 role 白名单;任何代码可直接构造 admin principal | context.py:20-41 validate L30-34;service.py `_resources_for_query` L269-283 仅约束查询侧 |
|
||||||
|
| F4 | 低 | 「拆除了 session_data 兼容桥」的表述 | 属实 ✅;仅 i18n key `tasks.missing_session_data` 命名保留旧词(错误文案已更新为「缺少任务运行上下文,请通过公共任务入口提交」),语义无残留 | models.py:148;i18n api_tasks.py:105 |
|
||||||
|
| F5 | 低 | execution_contract.md §4「权限裁决在 Runtime 编排层」 | 属实但要精确:**编排层做①permission mode 裁决②审批一次性授权③网络权限解析**,但**命令校验(FORBIDDEN_COMMANDS)与路径授权(禁读清单)仍在旧 terminal_ops/file_manager 链路**,经本接口注入的后端分支不会自动获得这些校验(契约已加注记,与代码一致) | base.py docstring L9-11;execution_contract.md §4 注记;tools_execution.py:1891-1906 |
|
||||||
|
|
||||||
|
未标注项:1(a)(c)(d)、2(a)(b)(c)、3(a)、4(a)(b)(c)(d)、5 全部与文档一致 ✅。
|
||||||
|
|
||||||
|
**未验证项**:无(全部声明均已通过代码定位 + import 实验 + 验收测试运行验证;唯一无法在本环境复现的是「Web 生产环境真实运行中 socketio 推送」——需要用户重启服务后人工验证,属记忆 gateway_runtime_work_plan 中的遗留待办 N3)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、总体判断
|
||||||
|
|
||||||
|
**目标「③④ 层达到 Runtime 与执行环境可通过替身独立测试、换执行后端不碰智能体循环」——达到了 70% 达成度(③↔④ 边界达标;②→③ 尚有一处实质耦合)。**
|
||||||
|
|
||||||
|
逐项对照:
|
||||||
|
|
||||||
|
1. **「Runtime 与执行环境可通过替身独立测试」✅ 完全达成**:`check_fake_exec`(0.7s PASSED)与 `check_approval_wait`(3.5s PASSED)证明真实工具编排层 handle_tool_call + FakeExecutionBackend 可以零真实副作用跑通 E1-E4,且默认路径(backend=None)行为回归断言存在。
|
||||||
|
|
||||||
|
2. **「换执行后端不碰智能体循环」✅ 达成(限 E1-E4)**:注入点是 `MainTerminal.execution_backend` 属性 + handle_tool_call 四分支,智能体循环(模型循环/工具分派/事件)不感知后端具体实现;替换 Host/Docker 后端只需实现协议并注入。❌ **但完整达成需 E5-E10 全部协议化**——当前 read_file、mkdir、终端会话、路径校验等仍直挂 terminal_ops/file_manager,Host/Docker 真实后端「换后端不碰循环」仅对 E1-E4 成立。
|
||||||
|
|
||||||
|
3. **③④ 层的方向独立性已建立,但存在一个实质耦合点**:核心链 `server.tasks` → `server.context.identity` → `server.auth_helpers` → `flask` 使 **Gateway 进程仍必须安装 flask 包**(虽然不需要 Flask app / SocketIO 服务 / 蓝图)。独立启动验收之所以能过,是因为验收环境装了 flask+flask_socketio —— 若在未安装 Flask 的精简部署环境运行,`create_task` 将直接 ModuleNotFoundError。
|
||||||
|
|
||||||
|
4. **对「Gateway 独立启动(无 Web 初始化)」声明**:验收测试已证明无 app 上下文、无 SocketIO 服务、无蓝图注册、门闸/终态/事件流/审批全生命周期真实通过 ✅;但「零 Flask 依赖」不成立 ⚠️——准确表述应为「无 Flask **应用初始化** 依赖,仍有 Flask **包** 依赖」。
|
||||||
|
|
||||||
|
**建议(按优先级)**:
|
||||||
|
1. 拆 `server/context/identity.py:7` 顶层 `from server.auth_helpers import get_current_user_role`(改为延迟导入或在 context 包内实现角色默认逻辑),`resources.py:15` 的 flask session 依赖同理下沉到适配层——可一次性切断 ②→③ 链的 flask 包依赖;
|
||||||
|
2. server/context/__init__.py 目前一次 re-export 全量子模块(连带加载),可考虑按需分包或延迟绑定;
|
||||||
|
3. Host/Docker 迁入(E5-E10 + HostDockerBackend + 编排层切换 + 4 处后端逻辑收敛)按既定后置计划执行,与本轮结论无冲突。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 附录:import 实验记录(本机 venv=python3.9.6 / 裸 python3=3.9.6)
|
||||||
|
|
||||||
|
| 实验 | 命令 | 结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `import server.runtime.service`(裸 python3) | OK;flask=False、flask_socketio=False、server.tasks=False |
|
||||||
|
| 2 | `from server.tasks import task_manager`(裸 python3) | **FAIL: ModuleNotFoundError 'flask'**(追踪:server.tasks→models→server.context→identity→auth_helpers→flask) |
|
||||||
|
| 3 | `from server.tasks import task_manager`(venv) | OK;**flask=True**、flask_socketio=False、extensions=False、chat_flow=False |
|
||||||
|
| 4 | `from server.chat_flow import run_chat_task_sync`(venv) | OK;**flask=True、flask_socketio=True、server.extensions=True**、server.app=False、app_legacy=False |
|
||||||
|
|
||||||
|
### 附录:验收测试实测记录
|
||||||
|
|
||||||
|
| 检查 | 用时 | 结果 | 关键证据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| check_lifecycle | 1.6s | PASSED | 无 app 上下文 → create_task → api_request_start 装配证据 → 取消 → 终态 → terminal 存在 → 门闸释放 → socketio.server is None |
|
||||||
|
| check_fake_exec | 0.7s | PASSED | E1-E4 四分支替身全部收到;零真实磁盘写入;backend=None 回归 |
|
||||||
|
| check_chain | 2.7s | PASSED | list_runs 发现 → 事件观察 → 跨端取消 → 历史落盘 → offset 续读 → 会话查询 → 审批语义 |
|
||||||
|
| check_approval_wait | 3.5s | PASSED | 执行中审批等待 → 公共入口批准 → 替身收到命令 → 循环退出 |
|
||||||
@ -250,16 +250,23 @@ v1 原文把「内存态」直接判为「状态唯一 Owner 的障碍」,混
|
|||||||
> | 能力 | 接口存在 | 适配完成(既有入口转调) | 行为验收 |
|
> | 能力 | 接口存在 | 适配完成(既有入口转调) | 行为验收 |
|
||||||
> |---|---|---|---|
|
> |---|---|---|---|
|
||||||
> | run.start / cancel / guide / queue | ✅ | ✅(tasks/api.py、api_v1.py 全部转调) | ✅ chain 双客户端 + lifecycle |
|
> | run.start / cancel / guide / queue | ✅ | ✅(tasks/api.py、api_v1.py 全部转调) | ✅ chain 双客户端 + lifecycle |
|
||||||
> | run.get / run.events(含 window_start) | ✅ | ✅(HTTP 轮询透传水位) | ✅ offset/水位断言 |
|
> | run.get / run.events(含 window_start) | ✅ | ✅(HTTP 轮询透传水位) | ✅ offset/水位断言 + 客户端缺口处理(2026-09-08:Web 检测后发 `event_window_gap` → 会话快照对账;CLI 提示并对齐窗口续读) |
|
||||||
> | run.list(发现,审核 F1) | ✅ | ✅(/api/tasks 列表、running-status、api_v1 删除保护转调) | ✅ B 发现 A 的活动 Run 并取消 |
|
> | run.list(发现,审核 F1) | ✅ | ✅(/api/tasks 列表、running-status、api_v1 删除保护转调) | ✅ B 发现 A 的活动 Run 并取消 |
|
||||||
> | approval.list / resolve | ✅ | ✅(chat/approval.py 三类六个路由转调) | ✅ 等待→批准→继续(approval_wait) |
|
> | approval.list / resolve | ✅ | ✅(chat/approval.py 三类六个路由转调) | ✅ 等待→批准→继续(approval_wait) |
|
||||||
> | session.list / history(principal 用户+工作区双校验,F3) | ✅ | ⬜(Web 会话路由未动——属 conversation 域存量链路) | ✅ 含越权/跨工作区拒绝断言 |
|
> | session.list / history(principal 用户+工作区双校验,F3) | ✅ | ✅(2026-09-08:新增 server/gateway_api.py `GET/POST /api/runtime/sessions` + `GET .../history` 传输暴露;api_v1 两会话路由转调公共入口消双轨,载荷字段经索引补字段保持兼容) | ✅ 含越权/跨工作区拒绝断言 |
|
||||||
|
> | 通道认证(host Bearer token) | ✅(2026-09-08:server/gateway_auth.py;host 模式+回环限定,token 存 `<DATA_DIR>/host_api_token` 0600) | ✅(gateway_api 3 路由 + tasks/api.py 9 路由 + chat/approval.py 6 路由均支持双通道) | ⬜ 待真实环境验证 |
|
||||||
> | Gateway 独立初始化 | — | — | ✅ 子进程隔离(ASTRION_IGNORE_DOTENV 逃生门 + 假模型自包含 + 装配证据断言,F4) |
|
> | Gateway 独立初始化 | — | — | ✅ 子进程隔离(ASTRION_IGNORE_DOTENV 逃生门 + 假模型自包含 + 装配证据断言,F4) |
|
||||||
> | ExecutionBackend 替身(E1-E4) | ✅ | —(默认 None,生产路径不变) | ✅ fake_exec |
|
> | ExecutionBackend 替身(E1-E4) | ✅ | —(默认 None,生产路径不变) | ✅ fake_exec |
|
||||||
> | ③↔④ 全量贯通(Host/Docker 迁入、E5-E10) | ⬜ | ⬜ | ⬜ 后置(§7.5-补) |
|
> | ③↔④ 全量贯通(Host/Docker 迁入、E5-E10) | ⬜ | ⬜ | ⬜ 后置(§7.5-补) |
|
||||||
>
|
>
|
||||||
> **审核收口记录(gateway_implementation_review_2026-09-07.md)**:F1 已补 run.list 公共发现入口;F2 已完成 15+ 处路由转调(tasks/api.py 7、api_v1.py 3、chat/approval.py 6 + 载荷序列化收敛至 models.py 单一实现,死导入清零);F3 已补 principal 工作区一致性校验;F4 已修(config .env 逃生门 + 测试自包含 + 假通过修复 + 审批等待链)。审核 §4 契约注释误导已修正(execution_plane/base.py:命令校验/路径授权仍在旧链路,真实后端接入时必须保留)。
|
> **审核收口记录(gateway_implementation_review_2026-09-07.md)**:F1 已补 run.list 公共发现入口;F2 已完成 15+ 处路由转调(tasks/api.py 7、api_v1.py 3、chat/approval.py 6 + 载荷序列化收敛至 models.py 单一实现,死导入清零);F3 已补 principal 工作区一致性校验;F4 已修(config .env 逃生门 + 测试自包含 + 假通过修复 + 审批等待链)。审核 §4 契约注释误导已修正(execution_plane/base.py:命令校验/路径授权仍在旧链路,真实后端接入时必须保留)。
|
||||||
>
|
>
|
||||||
|
> **第一档+第二档收尾(2026-09-08 实施)**:
|
||||||
|
> - **flask 包依赖拆解(G4 收口)**:新增 `server/context/_flask_bridge.py`(延迟桥接 has_request_context/session_get/session_set,flask 缺失按无上下文处理);identity/resources/conversation/personalization 四子模块顶层 flask+auth_helpers 依赖全拆;`server/context/__init__.py` 改 PEP 562 懒加载;chat_flow.py 清理死导入。实测:`import server.tasks` / `import server.runtime` 不再拉起 flask/flask_socketio/auth_helpers/chat_flow(venv 验证)。**剩余形态**:任务执行阶段(_run_chat_task 线程内延迟导入 chat_flow → security/extensions)仍拉起 flask+flask_socketio(SocketIO 空壳实例不 init_app),执行层彻底脱 flask 属更大工程,本轮未动。
|
||||||
|
> - **审批链路三缺口收口**:三个 manager 新增 `mark_expired`(幂等)+ 终态 TTL 惰性清理(RESOLVED_TTL_SECONDS=3600,pending 永不自动清理);三个 _wait_* 等待函数超时/软停止(stop_check 注入)/协程取消(CancelledError)三路径均回写 expired 终态;软停止打断对齐 REST 硬取消。i18n 新增 approval_stopped/approval_expired/question_stopped/question_expired 四键。
|
||||||
|
> - **测试 patch 点随迁**:test_conversation_model_persistence(session→session_get/session_set)、test_runtime_identity_resources(get_current_user_*→_get_current_user_* wrapper)。全量 75 测试失败恰为 4 项存量(与改造无关)。
|
||||||
|
> - **存量索引兼容注记**:会话列表 items 新增 run_mode/model_key/custom_prompt_name/personalization_name 字段(索引两个写入点同步补齐);存量索引条目在对话下次更新前这两个新字段为 None。
|
||||||
|
>
|
||||||
> **本轮收口决策(2026-09-07 用户拍板)**:①②③ 链路(Client ↔ Gateway ↔ Runtime)贯通即为本轮终点;③↔④ 全量贯通(Host/Docker 迁入 ExecutionBackend 契约、E5-E10 纳入)后置为独立工作,期间默认路径 `execution_backend=None`(现有真实链路不变)。
|
> **本轮收口决策(2026-09-07 用户拍板)**:①②③ 链路(Client ↔ Gateway ↔ Runtime)贯通即为本轮终点;③↔④ 全量贯通(Host/Docker 迁入 ExecutionBackend 契约、E5-E10 纳入)后置为独立工作,期间默认路径 `execution_backend=None`(现有真实链路不变)。
|
||||||
>
|
>
|
||||||
> **②↔③ 内部形态结构化(2026-09-08)**:`to_session_data()` 兼容桥已拆除——`create_chat_task` 签名改为收 `RuntimeContext` 必填,`TaskRecord` 三层结构化直存(`principal`/`task_params`/`directives` + 可变 `goal_progress` 字段);`_run_chat_task` 身份还原/门闸认领/事件注入/terminal 属性设置全部改为按层属性访问;`to_session_data()` 方法删除,生产代码 session_data dict 零残留。验证:75 测试全量回归失败恰为 4 项存量。
|
> **②↔③ 内部形态结构化(2026-09-08)**:`to_session_data()` 兼容桥已拆除——`create_chat_task` 签名改为收 `RuntimeContext` 必填,`TaskRecord` 三层结构化直存(`principal`/`task_params`/`directives` + 可变 `goal_progress` 字段);`_run_chat_task` 身份还原/门闸认领/事件注入/terminal 属性设置全部改为按层属性访问;`to_session_data()` 方法删除,生产代码 session_data dict 零残留。验证:75 测试全量回归失败恰为 4 项存量。
|
||||||
|
|||||||
@ -80,6 +80,22 @@ MESSAGES = {
|
|||||||
"zh-CN": "审批超时",
|
"zh-CN": "审批超时",
|
||||||
"en-US": "Approval timed out",
|
"en-US": "Approval timed out",
|
||||||
},
|
},
|
||||||
|
"tool_loop.approval_stopped": {
|
||||||
|
"zh-CN": "任务已停止,审批不再等待",
|
||||||
|
"en-US": "Task stopped; approval no longer awaited",
|
||||||
|
},
|
||||||
|
"tool_loop.approval_expired": {
|
||||||
|
"zh-CN": "审批请求已过期",
|
||||||
|
"en-US": "Approval request expired",
|
||||||
|
},
|
||||||
|
"tool_loop.question_stopped": {
|
||||||
|
"zh-CN": "任务已停止,问题不再等待回答。",
|
||||||
|
"en-US": "Task stopped; the question is no longer awaiting an answer.",
|
||||||
|
},
|
||||||
|
"tool_loop.question_expired": {
|
||||||
|
"zh-CN": "用户问题已过期。",
|
||||||
|
"en-US": "The user question has expired.",
|
||||||
|
},
|
||||||
"tool_loop.awaiting_approval": {
|
"tool_loop.awaiting_approval": {
|
||||||
"zh-CN": "等待用户审批",
|
"zh-CN": "等待用户审批",
|
||||||
"en-US": "Waiting for user approval",
|
"en-US": "Waiting for user approval",
|
||||||
|
|||||||
@ -10,6 +10,10 @@ from modules.i18n import tr
|
|||||||
# 计划文档内容送进弹窗/记录的最大字符数(超出截断,完整内容始终在计划文件里)
|
# 计划文档内容送进弹窗/记录的最大字符数(超出截断,完整内容始终在计划文件里)
|
||||||
PLAN_CONTENT_MAX_CHARS = 20000
|
PLAN_CONTENT_MAX_CHARS = 20000
|
||||||
|
|
||||||
|
# 终态条目保留时长(秒):与任务记录终态清理(3600s)对齐。
|
||||||
|
# pending 条目永不自动清理;等待方退出时经 mark_expired 转为终态后惰性回收。
|
||||||
|
RESOLVED_TTL_SECONDS = 3600.0
|
||||||
|
|
||||||
|
|
||||||
class PlanApprovalManager:
|
class PlanApprovalManager:
|
||||||
"""In-memory manager for plan-mode plan approval requests (submit_plan 工具).
|
"""In-memory manager for plan-mode plan approval requests (submit_plan 工具).
|
||||||
@ -22,6 +26,29 @@ class PlanApprovalManager:
|
|||||||
self._items: Dict[str, Dict[str, Any]] = {}
|
self._items: Dict[str, Dict[str, Any]] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _prune_resolved(self, now: Optional[float] = None) -> None:
|
||||||
|
"""惰性清理过期终态条目(锁内调用)。"""
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
expired_keys = [
|
||||||
|
key
|
||||||
|
for key, item in self._items.items()
|
||||||
|
if item.get("status") != "pending"
|
||||||
|
and float(item.get("resolved_at") or item.get("created_at") or 0.0) + RESOLVED_TTL_SECONDS <= now
|
||||||
|
]
|
||||||
|
for key in expired_keys:
|
||||||
|
self._items.pop(key, None)
|
||||||
|
|
||||||
|
def mark_expired(self, approval_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""将 pending 条目标记为 expired 终态(等待方超时/停止/取消时调用,幂等)。"""
|
||||||
|
with self._lock:
|
||||||
|
self._prune_resolved()
|
||||||
|
item = self._items.get(approval_id)
|
||||||
|
if not item or item.get("status") != "pending":
|
||||||
|
return dict(item) if item else None
|
||||||
|
item["status"] = "expired"
|
||||||
|
item["resolved_at"] = time.time()
|
||||||
|
return dict(item)
|
||||||
|
|
||||||
def create_request(
|
def create_request(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@ -60,11 +87,13 @@ class PlanApprovalManager:
|
|||||||
|
|
||||||
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
|
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._prune_resolved()
|
||||||
item = self._items.get(approval_id)
|
item = self._items.get(approval_id)
|
||||||
return dict(item) if item else None
|
return dict(item) if item else None
|
||||||
|
|
||||||
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._prune_resolved()
|
||||||
rows = []
|
rows = []
|
||||||
for item in self._items.values():
|
for item in self._items.values():
|
||||||
if item.get("username") != username:
|
if item.get("username") != username:
|
||||||
|
|||||||
@ -7,12 +7,29 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from modules.i18n import tr
|
from modules.i18n import tr
|
||||||
|
|
||||||
|
# 终态条目保留时长(秒):与任务记录终态清理(3600s)对齐。
|
||||||
|
# pending 条目永不自动清理——仍属合法等待;等待方退出(超时/停止/取消)
|
||||||
|
# 时会经 mark_expired 转为 expired 终态,再由本 TTL 惰性回收。
|
||||||
|
RESOLVED_TTL_SECONDS = 3600.0
|
||||||
|
|
||||||
|
|
||||||
class ToolApprovalManager:
|
class ToolApprovalManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._items: Dict[str, Dict[str, Any]] = {}
|
self._items: Dict[str, Dict[str, Any]] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _prune_resolved(self, now: Optional[float] = None) -> None:
|
||||||
|
"""惰性清理过期终态条目(锁内调用)。"""
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
expired_keys = [
|
||||||
|
key
|
||||||
|
for key, item in self._items.items()
|
||||||
|
if item.get("status") != "pending"
|
||||||
|
and float(item.get("decided_at") or item.get("created_at") or 0.0) + RESOLVED_TTL_SECONDS <= now
|
||||||
|
]
|
||||||
|
for key in expired_keys:
|
||||||
|
self._items.pop(key, None)
|
||||||
|
|
||||||
def create_request(
|
def create_request(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@ -45,11 +62,28 @@ class ToolApprovalManager:
|
|||||||
|
|
||||||
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
|
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._prune_resolved()
|
||||||
item = self._items.get(approval_id)
|
item = self._items.get(approval_id)
|
||||||
return dict(item) if item else None
|
return dict(item) if item else None
|
||||||
|
|
||||||
|
def mark_expired(self, approval_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""将 pending 条目标记为 expired 终态(等待方超时/停止/取消时调用)。
|
||||||
|
|
||||||
|
幂等:非 pending 返回现状;不存在返回 None。过期后迟到的 decide
|
||||||
|
因状态非 pending 返回现状(不再生效)。
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._prune_resolved()
|
||||||
|
item = self._items.get(approval_id)
|
||||||
|
if not item or item.get("status") != "pending":
|
||||||
|
return dict(item) if item else None
|
||||||
|
item["status"] = "expired"
|
||||||
|
item["decided_at"] = time.time()
|
||||||
|
return dict(item)
|
||||||
|
|
||||||
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._prune_resolved()
|
||||||
rows = []
|
rows = []
|
||||||
for item in self._items.values():
|
for item in self._items.values():
|
||||||
if item.get("username") != username:
|
if item.get("username") != username:
|
||||||
|
|||||||
@ -7,6 +7,10 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from modules.i18n import tr
|
from modules.i18n import tr
|
||||||
|
|
||||||
|
# 终态条目保留时长(秒):与任务记录终态清理(3600s)对齐。
|
||||||
|
# pending 条目永不自动清理;等待方退出时经 mark_expired 转为终态后惰性回收。
|
||||||
|
RESOLVED_TTL_SECONDS = 3600.0
|
||||||
|
|
||||||
|
|
||||||
class UserQuestionManager:
|
class UserQuestionManager:
|
||||||
"""In-memory manager for blocking model-to-user questions."""
|
"""In-memory manager for blocking model-to-user questions."""
|
||||||
@ -15,6 +19,29 @@ class UserQuestionManager:
|
|||||||
self._items: Dict[str, Dict[str, Any]] = {}
|
self._items: Dict[str, Dict[str, Any]] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _prune_resolved(self, now: Optional[float] = None) -> None:
|
||||||
|
"""惰性清理过期终态条目(锁内调用)。"""
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
expired_keys = [
|
||||||
|
key
|
||||||
|
for key, item in self._items.items()
|
||||||
|
if item.get("status") != "pending"
|
||||||
|
and float(item.get("answered_at") or item.get("created_at") or 0.0) + RESOLVED_TTL_SECONDS <= now
|
||||||
|
]
|
||||||
|
for key in expired_keys:
|
||||||
|
self._items.pop(key, None)
|
||||||
|
|
||||||
|
def mark_expired(self, question_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""将 pending 条目标记为 expired 终态(等待方超时/停止/取消时调用,幂等)。"""
|
||||||
|
with self._lock:
|
||||||
|
self._prune_resolved()
|
||||||
|
item = self._items.get(question_id)
|
||||||
|
if not item or item.get("status") != "pending":
|
||||||
|
return dict(item) if item else None
|
||||||
|
item["status"] = "expired"
|
||||||
|
item["answered_at"] = time.time()
|
||||||
|
return dict(item)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_options(options: Any) -> List[Dict[str, str]]:
|
def _normalize_options(options: Any) -> List[Dict[str, str]]:
|
||||||
if not isinstance(options, list):
|
if not isinstance(options, list):
|
||||||
@ -83,11 +110,13 @@ class UserQuestionManager:
|
|||||||
|
|
||||||
def get(self, question_id: str) -> Optional[Dict[str, Any]]:
|
def get(self, question_id: str) -> Optional[Dict[str, Any]]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._prune_resolved()
|
||||||
item = self._items.get(question_id)
|
item = self._items.get(question_id)
|
||||||
return dict(item) if item else None
|
return dict(item) if item else None
|
||||||
|
|
||||||
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._prune_resolved()
|
||||||
rows = []
|
rows = []
|
||||||
for item in self._items.values():
|
for item in self._items.values():
|
||||||
if item.get("username") != username:
|
if item.get("username") != username:
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from flask import Blueprint, request, jsonify, send_file, session
|
|||||||
from .api_auth import api_token_required
|
from .api_auth import api_token_required
|
||||||
from .security import rate_limited
|
from .security import rate_limited
|
||||||
from .tasks import task_manager
|
from .tasks import task_manager
|
||||||
from server.runtime import RuntimeContext, TaskParams, principal_from_session_snapshot, runtime_service
|
from server.runtime import RuntimeContext, TaskParams, TrustedPrincipal, principal_from_session_snapshot, runtime_service
|
||||||
from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides
|
from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides
|
||||||
from .utils_common import sanitize_filename_preserve_unicode
|
from .utils_common import sanitize_filename_preserve_unicode
|
||||||
from .utils_common import debug_log
|
from .utils_common import debug_log
|
||||||
@ -353,33 +353,34 @@ def send_message_api(workspace_id: str):
|
|||||||
def list_conversations_api(workspace_id: str):
|
def list_conversations_api(workspace_id: str):
|
||||||
username = session.get("username")
|
username = session.get("username")
|
||||||
ws = _resolve_workspace(username, workspace_id)
|
ws = _resolve_workspace(username, workspace_id)
|
||||||
terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
|
|
||||||
if not terminal or not workspace:
|
|
||||||
return jsonify({"success": False, "error": tr("api_v1.system_not_initialized")}), 503
|
|
||||||
limit = max(1, min(int(request.args.get("limit", 20)), 100))
|
limit = max(1, min(int(request.args.get("limit", 20)), 100))
|
||||||
offset = max(0, int(request.args.get("offset", 0)))
|
offset = max(0, int(request.args.get("offset", 0)))
|
||||||
conv_dir = Path(workspace.data_dir) / "conversations"
|
# 数据通道统一走公共入口(消除直读磁盘双轨);载荷字段保持 api_v1 既有形态
|
||||||
items = []
|
principal = TrustedPrincipal(
|
||||||
for p in sorted(conv_dir.glob("conv_*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
|
username=str(username or ""), workspace_id=ws.workspace_id, role="api", is_api_user=True
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
data = json.loads(p.read_text(encoding="utf-8"))
|
result = runtime_service.list_sessions(
|
||||||
meta = data.get("metadata") or {}
|
str(username or ""), ws.workspace_id, principal, limit=limit, offset=offset
|
||||||
items.append({
|
)
|
||||||
"id": data.get("id"),
|
except RuntimeError as exc:
|
||||||
"title": data.get("title"),
|
return jsonify({"success": False, "error": str(exc)}), 503
|
||||||
"created_at": data.get("created_at"),
|
items = [
|
||||||
"updated_at": data.get("updated_at"),
|
{
|
||||||
"run_mode": meta.get("run_mode"),
|
"id": it.get("id"),
|
||||||
"model_key": meta.get("model_key"),
|
"title": it.get("title"),
|
||||||
"custom_prompt_name": meta.get("custom_prompt_name"),
|
"created_at": it.get("created_at"),
|
||||||
"personalization_name": meta.get("personalization_name"),
|
"updated_at": it.get("updated_at"),
|
||||||
|
"run_mode": it.get("run_mode"),
|
||||||
|
"model_key": it.get("model_key"),
|
||||||
|
"custom_prompt_name": it.get("custom_prompt_name"),
|
||||||
|
"personalization_name": it.get("personalization_name"),
|
||||||
"workspace_id": ws.workspace_id,
|
"workspace_id": ws.workspace_id,
|
||||||
"messages_count": len(data.get("messages", [])),
|
"messages_count": it.get("total_messages", 0),
|
||||||
})
|
}
|
||||||
except Exception:
|
for it in (result.get("conversations") or [])
|
||||||
continue
|
]
|
||||||
sliced = items[offset:offset + limit]
|
return jsonify({"success": True, "data": items, "total": result.get("total", len(items))})
|
||||||
return jsonify({"success": True, "data": sliced, "total": len(items)})
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", methods=["GET"])
|
@api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", methods=["GET"])
|
||||||
@ -387,20 +388,25 @@ def list_conversations_api(workspace_id: str):
|
|||||||
def get_conversation_api(workspace_id: str, conv_id: str):
|
def get_conversation_api(workspace_id: str, conv_id: str):
|
||||||
username = session.get("username")
|
username = session.get("username")
|
||||||
ws = _resolve_workspace(username, workspace_id)
|
ws = _resolve_workspace(username, workspace_id)
|
||||||
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
|
# 数据通道统一走公共入口(磁盘权威快照);载荷形态保持 api_v1 既有语义
|
||||||
if not workspace:
|
normalized = conv_id if conv_id.startswith("conv_") else f"conv_{conv_id}"
|
||||||
return jsonify({"success": False, "error": tr("api_v1.system_not_initialized")}), 503
|
principal = TrustedPrincipal(
|
||||||
path = _conversation_path(workspace, conv_id)
|
username=str(username or ""), workspace_id=ws.workspace_id, role="api", is_api_user=True
|
||||||
if not path.exists():
|
)
|
||||||
return jsonify({"success": False, "error": tr("api_v1.conversation_not_found")}), 404
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = runtime_service.get_session_history(
|
||||||
|
str(username or ""), ws.workspace_id, normalized, principal
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 503
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 500
|
||||||
|
if data is None:
|
||||||
|
return jsonify({"success": False, "error": tr("api_v1.conversation_not_found")}), 404
|
||||||
include_messages = request.args.get("full", "0") == "1"
|
include_messages = request.args.get("full", "0") == "1"
|
||||||
if not include_messages:
|
if not include_messages:
|
||||||
data["messages"] = None
|
data["messages"] = None
|
||||||
return jsonify({"success": True, "data": data, "workspace_id": ws.workspace_id})
|
return jsonify({"success": True, "data": data, "workspace_id": ws.workspace_id})
|
||||||
except Exception as exc:
|
|
||||||
return jsonify({"success": False, "error": str(exc)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", methods=["DELETE"])
|
@api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", methods=["DELETE"])
|
||||||
|
|||||||
@ -40,6 +40,7 @@ from server.multi_agent import multi_agent_bp
|
|||||||
from server.workflow_page import workflow_page_bp
|
from server.workflow_page import workflow_page_bp
|
||||||
from server.workflow_runtime_api import workflow_runtime_bp
|
from server.workflow_runtime_api import workflow_runtime_bp
|
||||||
from server.conversation_bootstrap import conversation_bootstrap_bp
|
from server.conversation_bootstrap import conversation_bootstrap_bp
|
||||||
|
from server.gateway_api import gateway_bp
|
||||||
from server.socket_handlers import socketio
|
from server.socket_handlers import socketio
|
||||||
from server.security import attach_security_hooks
|
from server.security import attach_security_hooks
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
@ -308,6 +309,7 @@ app.register_blueprint(multi_agent_bp)
|
|||||||
app.register_blueprint(workflow_page_bp)
|
app.register_blueprint(workflow_page_bp)
|
||||||
app.register_blueprint(workflow_runtime_bp)
|
app.register_blueprint(workflow_runtime_bp)
|
||||||
app.register_blueprint(conversation_bootstrap_bp)
|
app.register_blueprint(conversation_bootstrap_bp)
|
||||||
|
app.register_blueprint(gateway_bp)
|
||||||
|
|
||||||
# 安全钩子(CSRF 校验 + 响应头)
|
# 安全钩子(CSRF 校验 + 响应头)
|
||||||
attach_security_hooks(app)
|
attach_security_hooks(app)
|
||||||
|
|||||||
@ -33,7 +33,8 @@ from modules.user_manager import UserWorkspace
|
|||||||
from core.web_terminal import WebTerminal
|
from core.web_terminal import WebTerminal
|
||||||
from config.model_profiles import get_model_context_window
|
from config.model_profiles import get_model_context_window
|
||||||
|
|
||||||
from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
|
from server.auth_helpers import resolve_admin_policy, get_current_user_record, get_current_username
|
||||||
|
from server.gateway_auth import api_login_or_host_token_required
|
||||||
from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker
|
from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker
|
||||||
from server.security import rate_limited, prune_socket_tokens
|
from server.security import rate_limited, prune_socket_tokens
|
||||||
from server.utils_common import debug_log
|
from server.utils_common import debug_log
|
||||||
@ -46,7 +47,7 @@ from modules.i18n import tr
|
|||||||
|
|
||||||
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
|
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
|
||||||
@chat_bp.route('/api/user-questions/pending', methods=['GET'])
|
@chat_bp.route('/api/user-questions/pending', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
"""获取当前用户待回答的问题列表。"""
|
"""获取当前用户待回答的问题列表。"""
|
||||||
@ -61,7 +62,7 @@ def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace,
|
|||||||
})
|
})
|
||||||
|
|
||||||
@chat_bp.route('/api/user-questions/<question_id>/answer', methods=['POST'])
|
@chat_bp.route('/api/user-questions/<question_id>/answer', methods=['POST'])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
@rate_limited("user_question_answer", 120, 60, scope="user")
|
@rate_limited("user_question_answer", 120, 60, scope="user")
|
||||||
def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, username: str, question_id: str):
|
def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, username: str, question_id: str):
|
||||||
@ -91,7 +92,7 @@ def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, userna
|
|||||||
})
|
})
|
||||||
|
|
||||||
@chat_bp.route('/api/plan-approvals/pending', methods=['GET'])
|
@chat_bp.route('/api/plan-approvals/pending', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
"""获取当前用户待批准的计划列表(work_mode=plan 的 submit_plan 工具)。"""
|
"""获取当前用户待批准的计划列表(work_mode=plan 的 submit_plan 工具)。"""
|
||||||
@ -106,7 +107,7 @@ def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace,
|
|||||||
})
|
})
|
||||||
|
|
||||||
@chat_bp.route('/api/plan-approvals/<approval_id>/answer', methods=['POST'])
|
@chat_bp.route('/api/plan-approvals/<approval_id>/answer', methods=['POST'])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
@rate_limited("plan_approval_answer", 120, 60, scope="user")
|
@rate_limited("plan_approval_answer", 120, 60, scope="user")
|
||||||
def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):
|
def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):
|
||||||
@ -135,7 +136,7 @@ def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, userna
|
|||||||
})
|
})
|
||||||
|
|
||||||
@chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
|
@chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
"""获取当前用户待审批工具列表。"""
|
"""获取当前用户待审批工具列表。"""
|
||||||
@ -150,7 +151,7 @@ def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace,
|
|||||||
})
|
})
|
||||||
|
|
||||||
@chat_bp.route('/api/tool-approvals/<approval_id>/decision', methods=['POST'])
|
@chat_bp.route('/api/tool-approvals/<approval_id>/decision', methods=['POST'])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
@rate_limited("tool_approval_decision", 60, 60, scope="user")
|
@rate_limited("tool_approval_decision", 60, 60, scope="user")
|
||||||
def decide_tool_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):
|
def decide_tool_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):
|
||||||
|
|||||||
@ -16,8 +16,7 @@ from datetime import datetime, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List, Tuple
|
from typing import Dict, Any, Optional, List, Tuple
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, session
|
from flask import Blueprint
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
OUTPUT_FORMATS,
|
OUTPUT_FORMATS,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from typing import Optional, Dict, Any, List
|
|||||||
from .utils_common import debug_log
|
from .utils_common import debug_log
|
||||||
from .state import MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, MONITOR_SNAPSHOT_CHAR_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT
|
from .state import MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, MONITOR_SNAPSHOT_CHAR_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT
|
||||||
from .state import tool_approval_manager, user_question_manager, plan_approval_manager
|
from .state import tool_approval_manager, user_question_manager, plan_approval_manager
|
||||||
|
from .state import get_stop_flag as _state_get_stop_flag
|
||||||
from .monitor import cache_monitor_snapshot
|
from .monitor import cache_monitor_snapshot
|
||||||
from .security import compact_web_search_result
|
from .security import compact_web_search_result
|
||||||
from .chat_flow_helpers import detect_tool_failure
|
from .chat_flow_helpers import detect_tool_failure
|
||||||
@ -245,8 +246,14 @@ def _approval_timeout_for(web_terminal) -> Optional[float]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]:
|
async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0, stop_check=None) -> Dict[str, Any]:
|
||||||
|
"""阻塞等待工具审批裁决。
|
||||||
|
|
||||||
|
等待方退出路径(超时/软停止/协程取消)一律回写 expired 终态,
|
||||||
|
保证 manager 条目不再孤悬 pending(迟到回答不再生效)。
|
||||||
|
"""
|
||||||
started = time.time()
|
started = time.time()
|
||||||
|
try:
|
||||||
while True:
|
while True:
|
||||||
row = tool_approval_manager.get(approval_id)
|
row = tool_approval_manager.get(approval_id)
|
||||||
if not row:
|
if not row:
|
||||||
@ -256,9 +263,18 @@ async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_se
|
|||||||
status = row.get("status")
|
status = row.get("status")
|
||||||
if status in {"approved", "rejected"}:
|
if status in {"approved", "rejected"}:
|
||||||
return {"decision": status, "item": row}
|
return {"decision": status, "item": row}
|
||||||
|
if status == "expired":
|
||||||
|
return {"decision": "rejected", "code": "approval_expired", "reason": tr("tool_loop.approval_expired")}
|
||||||
if (time.time() - started) >= timeout_seconds:
|
if (time.time() - started) >= timeout_seconds:
|
||||||
|
tool_approval_manager.mark_expired(approval_id)
|
||||||
return {"decision": "rejected", "code": "approval_timeout", "reason": tr("tool_loop.approval_timeout")}
|
return {"decision": "rejected", "code": "approval_timeout", "reason": tr("tool_loop.approval_timeout")}
|
||||||
|
if stop_check is not None and stop_check():
|
||||||
|
tool_approval_manager.mark_expired(approval_id)
|
||||||
|
return {"decision": "rejected", "code": "approval_stopped", "reason": tr("tool_loop.approval_stopped")}
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
tool_approval_manager.mark_expired(approval_id)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _safe_parse_tool_arguments_for_question(web_terminal, tool_call: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def _safe_parse_tool_arguments_for_question(web_terminal, tool_call: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
@ -278,10 +294,11 @@ def _safe_parse_tool_arguments_for_question(web_terminal, tool_call: Dict[str, A
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_user_questions(*, question_ids: List[str], username: str, timeout_seconds: float = 3600.0) -> Dict[str, Dict[str, Any]]:
|
async def _wait_for_user_questions(*, question_ids: List[str], username: str, timeout_seconds: float = 3600.0, stop_check=None) -> Dict[str, Dict[str, Any]]:
|
||||||
started = time.time()
|
started = time.time()
|
||||||
pending = {str(qid) for qid in question_ids if qid}
|
pending = {str(qid) for qid in question_ids if qid}
|
||||||
answered: Dict[str, Dict[str, Any]] = {}
|
answered: Dict[str, Dict[str, Any]] = {}
|
||||||
|
try:
|
||||||
while pending:
|
while pending:
|
||||||
for qid in list(pending):
|
for qid in list(pending):
|
||||||
row = user_question_manager.get(qid)
|
row = user_question_manager.get(qid)
|
||||||
@ -296,31 +313,53 @@ async def _wait_for_user_questions(*, question_ids: List[str], username: str, ti
|
|||||||
if row.get("status") == "answered":
|
if row.get("status") == "answered":
|
||||||
answered[qid] = {**row, "answer_text": format_user_question_answer(row)}
|
answered[qid] = {**row, "answer_text": format_user_question_answer(row)}
|
||||||
pending.remove(qid)
|
pending.remove(qid)
|
||||||
|
elif row.get("status") == "expired":
|
||||||
|
answered[qid] = {"status": "expired", "answer_text": tr("tool_loop.question_expired")}
|
||||||
|
pending.remove(qid)
|
||||||
if not pending:
|
if not pending:
|
||||||
break
|
break
|
||||||
if (time.time() - started) >= timeout_seconds:
|
if (time.time() - started) >= timeout_seconds:
|
||||||
for qid in list(pending):
|
for qid in list(pending):
|
||||||
|
user_question_manager.mark_expired(qid)
|
||||||
answered[qid] = {"status": "timeout", "answer_text": tr("tool_loop.question_timeout")}
|
answered[qid] = {"status": "timeout", "answer_text": tr("tool_loop.question_timeout")}
|
||||||
pending.remove(qid)
|
pending.remove(qid)
|
||||||
break
|
break
|
||||||
|
if stop_check is not None and stop_check():
|
||||||
|
for qid in list(pending):
|
||||||
|
user_question_manager.mark_expired(qid)
|
||||||
|
answered[qid] = {"status": "stopped", "answer_text": tr("tool_loop.question_stopped")}
|
||||||
|
pending.remove(qid)
|
||||||
|
break
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
for qid in list(pending):
|
||||||
|
user_question_manager.mark_expired(qid)
|
||||||
|
raise
|
||||||
return answered
|
return answered
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_plan_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]:
|
async def _wait_for_plan_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0, stop_check=None) -> Dict[str, Any]:
|
||||||
"""阻塞等待计划批准结果(轮询管理器,与 _wait_for_user_questions 同构)。"""
|
"""阻塞等待计划批准结果(轮询管理器,与 _wait_for_user_questions 同构)。"""
|
||||||
started = time.time()
|
started = time.time()
|
||||||
|
try:
|
||||||
while True:
|
while True:
|
||||||
row = plan_approval_manager.get(approval_id)
|
row = plan_approval_manager.get(approval_id)
|
||||||
if not row:
|
if not row:
|
||||||
return {"status": "missing"}
|
return {"status": "missing"}
|
||||||
if row.get("username") != username:
|
if row.get("username") != username:
|
||||||
return {"status": "forbidden"}
|
return {"status": "forbidden"}
|
||||||
if row.get("status") in {"approved", "rejected"}:
|
if row.get("status") in {"approved", "rejected", "expired"}:
|
||||||
return row
|
return row
|
||||||
if (time.time() - started) >= timeout_seconds:
|
if (time.time() - started) >= timeout_seconds:
|
||||||
|
plan_approval_manager.mark_expired(approval_id)
|
||||||
return {"status": "timeout"}
|
return {"status": "timeout"}
|
||||||
|
if stop_check is not None and stop_check():
|
||||||
|
plan_approval_manager.mark_expired(approval_id)
|
||||||
|
return {"status": "stopped"}
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
plan_approval_manager.mark_expired(approval_id)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments, sender, workspace, messages, conversation_id: Optional[str]) -> str:
|
async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments, sender, workspace, messages, conversation_id: Optional[str]) -> str:
|
||||||
@ -447,11 +486,12 @@ async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender
|
|||||||
'conversation_id': conversation_id,
|
'conversation_id': conversation_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 4. 阻塞等待用户决定
|
# 4. 阻塞等待用户决定(软停止/取消时经 stop_check 退出并回写终态)
|
||||||
resolved = await _wait_for_plan_approval(
|
resolved = await _wait_for_plan_approval(
|
||||||
approval_id=str(approval.get("approval_id") or ""),
|
approval_id=str(approval.get("approval_id") or ""),
|
||||||
username=username,
|
username=username,
|
||||||
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
|
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
|
||||||
|
stop_check=(lambda: _state_get_stop_flag(task_id, username, include_user=False)) if task_id else None,
|
||||||
)
|
)
|
||||||
status = str(resolved.get("status") or "")
|
status = str(resolved.get("status") or "")
|
||||||
comment = str(resolved.get("comment") or "").strip()
|
comment = str(resolved.get("comment") or "").strip()
|
||||||
@ -595,6 +635,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
|
|||||||
question_ids=[str(q.get("question_id") or "") for q in created_questions],
|
question_ids=[str(q.get("question_id") or "") for q in created_questions],
|
||||||
username=username,
|
username=username,
|
||||||
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
|
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
|
||||||
|
stop_check=lambda: get_stop_flag(client_sid, username, include_user=False),
|
||||||
)
|
)
|
||||||
for question in created_questions:
|
for question in created_questions:
|
||||||
qid = str(question.get("question_id") or "")
|
qid = str(question.get("question_id") or "")
|
||||||
@ -907,6 +948,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
|
|||||||
approval_id=approval_item.get("approval_id"),
|
approval_id=approval_item.get("approval_id"),
|
||||||
username=username,
|
username=username,
|
||||||
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
|
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
|
||||||
|
stop_check=lambda: get_stop_flag(client_sid, username, include_user=False),
|
||||||
)
|
)
|
||||||
sender('tool_approval_resolved', {
|
sender('tool_approval_resolved', {
|
||||||
'approval_id': approval_item.get("approval_id"),
|
'approval_id': approval_item.get("approval_id"),
|
||||||
@ -1179,6 +1221,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
|
|||||||
approval_id=approval_item.get("approval_id"),
|
approval_id=approval_item.get("approval_id"),
|
||||||
username=username,
|
username=username,
|
||||||
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
|
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
|
||||||
|
stop_check=lambda: get_stop_flag(client_sid, username, include_user=False),
|
||||||
)
|
)
|
||||||
sender('tool_approval_resolved', {
|
sender('tool_approval_resolved', {
|
||||||
'approval_id': approval_item.get("approval_id"),
|
'approval_id': approval_item.get("approval_id"),
|
||||||
|
|||||||
@ -1,28 +1,46 @@
|
|||||||
# server/context/__init__.py - 兼容入口(原 server/context.py 拆分为子包)
|
# server/context/__init__.py - 兼容入口(原 server/context.py 拆分为子包)
|
||||||
# 所有历史导入路径 `from server.context import X` 不变。
|
# 所有历史导入路径 `from server.context import X` 不变。
|
||||||
from server.context.identity import NoWorkspaceError, RuntimeIdentity, _resolve_user_role
|
#
|
||||||
from server.context.broadcast import (
|
# 2026-09-08(G4 拆解):改为 PEP 562 模块级 __getattr__ 懒加载——
|
||||||
make_terminal_callback,
|
# 符号首次被引用时才加载对应子模块,避免任务核心层(server.tasks)
|
||||||
attach_user_broadcast,
|
# import 单个符号就连带拉起全部子模块(含 Web 适配层的 flask 依赖链)。
|
||||||
_wrap_callback_with_conversation_id,
|
from importlib import import_module as _import_module
|
||||||
)
|
|
||||||
from server.context.personalization import _apply_workspace_personalization_preferences
|
_SYMBOL_MODULE = {
|
||||||
from server.context.usage import get_or_create_usage_tracker, emit_user_quota_update
|
# identity
|
||||||
from server.context.upload import get_gui_manager, get_upload_guard, build_upload_error_response
|
"NoWorkspaceError": "server.context.identity",
|
||||||
from server.context.conversation import ensure_conversation_loaded, apply_conversation_overrides
|
"RuntimeIdentity": "server.context.identity",
|
||||||
from server.context.resources import (
|
"_resolve_user_role": "server.context.identity",
|
||||||
get_user_resources,
|
# broadcast
|
||||||
_make_terminal_key,
|
"make_terminal_callback": "server.context.broadcast",
|
||||||
_touch_terminal_activity,
|
"attach_user_broadcast": "server.context.broadcast",
|
||||||
_set_terminal_workspace_label,
|
"_wrap_callback_with_conversation_id": "server.context.broadcast",
|
||||||
_ensure_workspace_skills_synced,
|
# personalization
|
||||||
)
|
"_apply_workspace_personalization_preferences": "server.context.personalization",
|
||||||
from server.context.decorators import with_terminal, get_terminal_for_sid
|
# usage
|
||||||
from server.context.reaper import (
|
"get_or_create_usage_tracker": "server.context.usage",
|
||||||
reset_system_state,
|
"emit_user_quota_update": "server.context.usage",
|
||||||
reap_idle_conversation_terminals,
|
# upload
|
||||||
start_conversation_terminal_reaper,
|
"get_gui_manager": "server.context.upload",
|
||||||
)
|
"get_upload_guard": "server.context.upload",
|
||||||
|
"build_upload_error_response": "server.context.upload",
|
||||||
|
# conversation
|
||||||
|
"ensure_conversation_loaded": "server.context.conversation",
|
||||||
|
"apply_conversation_overrides": "server.context.conversation",
|
||||||
|
# resources
|
||||||
|
"get_user_resources": "server.context.resources",
|
||||||
|
"_make_terminal_key": "server.context.resources",
|
||||||
|
"_touch_terminal_activity": "server.context.resources",
|
||||||
|
"_set_terminal_workspace_label": "server.context.resources",
|
||||||
|
"_ensure_workspace_skills_synced": "server.context.resources",
|
||||||
|
# decorators
|
||||||
|
"with_terminal": "server.context.decorators",
|
||||||
|
"get_terminal_for_sid": "server.context.decorators",
|
||||||
|
# reaper
|
||||||
|
"reset_system_state": "server.context.reaper",
|
||||||
|
"reap_idle_conversation_terminals": "server.context.reaper",
|
||||||
|
"start_conversation_terminal_reaper": "server.context.reaper",
|
||||||
|
}
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NoWorkspaceError",
|
"NoWorkspaceError",
|
||||||
@ -43,3 +61,16 @@ __all__ = [
|
|||||||
"reap_idle_conversation_terminals",
|
"reap_idle_conversation_terminals",
|
||||||
"start_conversation_terminal_reaper",
|
"start_conversation_terminal_reaper",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
module_name = _SYMBOL_MODULE.get(name)
|
||||||
|
if module_name is None:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
value = getattr(_import_module(module_name), name)
|
||||||
|
globals()[name] = value # 缓存,后续访问直接命中
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__():
|
||||||
|
return sorted(set(list(globals()) + list(_SYMBOL_MODULE)))
|
||||||
|
|||||||
40
server/context/_flask_bridge.py
Normal file
40
server/context/_flask_bridge.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""Flask 请求上下文的延迟桥接(任务核心层解耦用,2026-09-08)。
|
||||||
|
|
||||||
|
背景:server.context 子包中仅「兼容模式」(未传 RuntimeIdentity)的代码路径
|
||||||
|
需要读写 Flask session。本模块把 flask 依赖收敛为使用点延迟导入:
|
||||||
|
flask 包不可用时按「无请求上下文」处理(读返回默认值、写静默跳过),
|
||||||
|
使 server.tasks 等核心层在无 flask 环境中也能独立加载(G4 拆解)。
|
||||||
|
|
||||||
|
函数命名与 flask 原符号保持一致(has_request_context),调用点改动最小;
|
||||||
|
调用方通过 `from server.context._flask_bridge import ...` 引入。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def has_request_context() -> bool:
|
||||||
|
"""探测 Flask 请求上下文;flask 包不可用时返回 False。"""
|
||||||
|
try:
|
||||||
|
from flask import has_request_context as _flask_has_request_context
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
return _flask_has_request_context()
|
||||||
|
|
||||||
|
|
||||||
|
def session_get(key: str, default: Any = None) -> Any:
|
||||||
|
"""读取 Flask session;无 flask 包或无请求上下文时返回 default。"""
|
||||||
|
if not has_request_context():
|
||||||
|
return default
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
return session.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def session_set(key: str, value: Any) -> None:
|
||||||
|
"""回写 Flask session;无 flask 包或无请求上下文时静默跳过。"""
|
||||||
|
if not has_request_context():
|
||||||
|
return
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
session[key] = value
|
||||||
@ -5,7 +5,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from flask import session, has_request_context
|
from server.context._flask_bridge import has_request_context, session_set
|
||||||
|
|
||||||
from core.web_terminal import WebTerminal
|
from core.web_terminal import WebTerminal
|
||||||
from modules.i18n import tr
|
from modules.i18n import tr
|
||||||
@ -31,8 +31,8 @@ def ensure_conversation_loaded(
|
|||||||
raise RuntimeError(result.get("message", tr("context.create_conversation_failed")))
|
raise RuntimeError(result.get("message", tr("context.create_conversation_failed")))
|
||||||
conversation_id = result["conversation_id"]
|
conversation_id = result["conversation_id"]
|
||||||
if update_session and has_request_context():
|
if update_session and has_request_context():
|
||||||
session['run_mode'] = terminal.run_mode
|
session_set('run_mode', terminal.run_mode)
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session_set('thinking_mode', terminal.thinking_mode)
|
||||||
created_new = True
|
created_new = True
|
||||||
else:
|
else:
|
||||||
conversation_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
|
conversation_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
|
||||||
@ -66,9 +66,9 @@ def ensure_conversation_loaded(
|
|||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
pass
|
pass
|
||||||
if update_session and has_request_context():
|
if update_session and has_request_context():
|
||||||
session['run_mode'] = terminal.run_mode
|
session_set('run_mode', terminal.run_mode)
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session_set('thinking_mode', terminal.thinking_mode)
|
||||||
session['model_key'] = getattr(terminal, "model_key", None)
|
session_set('model_key', getattr(terminal, "model_key", None))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if workspace is not None:
|
if workspace is not None:
|
||||||
|
|||||||
@ -4,7 +4,9 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from server.auth_helpers import get_current_user_role
|
# 注意:本模块属于任务核心层依赖链(server.tasks → server.context),
|
||||||
|
# 禁止顶层 import Web 适配层(server.auth_helpers 依赖 flask)。
|
||||||
|
# 兼容模式下的角色解析在使用点函数内延迟导入(见 _resolve_user_role)。
|
||||||
|
|
||||||
|
|
||||||
class NoWorkspaceError(RuntimeError):
|
class NoWorkspaceError(RuntimeError):
|
||||||
@ -38,4 +40,7 @@ def _resolve_user_role(identity: Optional[RuntimeIdentity], record, default: str
|
|||||||
if identity.role:
|
if identity.role:
|
||||||
return identity.role
|
return identity.role
|
||||||
return (record.role if record and getattr(record, "role", None) else default)
|
return (record.role if record and getattr(record, "role", None) else default)
|
||||||
|
# 兼容模式(HTTP 请求上下文):延迟导入 Web 认证辅助
|
||||||
|
from server.auth_helpers import get_current_user_role
|
||||||
|
|
||||||
return get_current_user_role(record)
|
return get_current_user_role(record)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from flask import session, has_request_context
|
from server.context._flask_bridge import has_request_context, session_get, session_set
|
||||||
|
|
||||||
from core.web_terminal import WebTerminal
|
from core.web_terminal import WebTerminal
|
||||||
from modules.personalization_manager import load_personalization_config
|
from modules.personalization_manager import load_personalization_config
|
||||||
@ -30,7 +30,7 @@ def _apply_workspace_personalization_preferences(
|
|||||||
if isinstance(session_model, str) and session_model.strip():
|
if isinstance(session_model, str) and session_model.strip():
|
||||||
resolved_session_model = session_model.strip()
|
resolved_session_model = session_model.strip()
|
||||||
elif allow_session_io and has_request_context():
|
elif allow_session_io and has_request_context():
|
||||||
raw_session_model = session.get("model_key")
|
raw_session_model = session_get("model_key")
|
||||||
if isinstance(raw_session_model, str) and raw_session_model.strip():
|
if isinstance(raw_session_model, str) and raw_session_model.strip():
|
||||||
resolved_session_model = raw_session_model.strip()
|
resolved_session_model = raw_session_model.strip()
|
||||||
|
|
||||||
@ -84,8 +84,8 @@ def _apply_workspace_personalization_preferences(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if allow_session_io and has_request_context() and update_session:
|
if allow_session_io and has_request_context() and update_session:
|
||||||
session["run_mode"] = getattr(terminal, "run_mode", session.get("run_mode"))
|
session_set("run_mode", getattr(terminal, "run_mode", session_get("run_mode")))
|
||||||
session["thinking_mode"] = getattr(terminal, "thinking_mode", session.get("thinking_mode"))
|
session_set("thinking_mode", getattr(terminal, "thinking_mode", session_get("thinking_mode")))
|
||||||
session["model_key"] = getattr(terminal, "model_key", session.get("model_key"))
|
session_set("model_key", getattr(terminal, "model_key", session_get("model_key")))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
debug_log(f"[Personalization] 应用工作区偏好失败: {exc}")
|
debug_log(f"[Personalization] 应用工作区偏好失败: {exc}")
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from typing import Optional, Tuple, TYPE_CHECKING
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import modules.user_manager
|
import modules.user_manager
|
||||||
|
|
||||||
from flask import session, has_request_context
|
from server.context._flask_bridge import has_request_context, session_get, session_set
|
||||||
|
|
||||||
from core.web_terminal import WebTerminal
|
from core.web_terminal import WebTerminal
|
||||||
from modules.personalization_manager import load_personalization_config
|
from modules.personalization_manager import load_personalization_config
|
||||||
@ -28,9 +28,32 @@ from config.model_profiles import get_registered_model_keys
|
|||||||
from modules.i18n import tr
|
from modules.i18n import tr
|
||||||
from server import state
|
from server import state
|
||||||
from server.utils_common import debug_log
|
from server.utils_common import debug_log
|
||||||
from server.auth_helpers import get_current_username, get_current_user_record, get_current_user_role
|
|
||||||
from utils.host_workspace_debug import write_host_workspace_debug
|
from utils.host_workspace_debug import write_host_workspace_debug
|
||||||
|
|
||||||
|
# 兼容模式(未传 RuntimeIdentity)需要的 Web 认证辅助改为使用点延迟导入,
|
||||||
|
# 保持任务核心层依赖链(server.tasks → server.context.resources)无 flask 包依赖。
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_username() -> Optional[str]:
|
||||||
|
"""兼容模式专用:延迟导入 Web 认证辅助。"""
|
||||||
|
from server.auth_helpers import get_current_username
|
||||||
|
|
||||||
|
return get_current_username()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_user_record():
|
||||||
|
"""兼容模式专用:延迟导入 Web 认证辅助。"""
|
||||||
|
from server.auth_helpers import get_current_user_record
|
||||||
|
|
||||||
|
return get_current_user_record()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_user_role(record=None) -> str:
|
||||||
|
"""兼容模式专用:延迟导入 Web 认证辅助。"""
|
||||||
|
from server.auth_helpers import get_current_user_role
|
||||||
|
|
||||||
|
return get_current_user_role(record)
|
||||||
|
|
||||||
from server.context.identity import NoWorkspaceError, RuntimeIdentity, _resolve_user_role
|
from server.context.identity import NoWorkspaceError, RuntimeIdentity, _resolve_user_role
|
||||||
from server.context.broadcast import make_terminal_callback, attach_user_broadcast
|
from server.context.broadcast import make_terminal_callback, attach_user_broadcast
|
||||||
from server.context.personalization import _apply_workspace_personalization_preferences
|
from server.context.personalization import _apply_workspace_personalization_preferences
|
||||||
@ -133,7 +156,7 @@ def get_user_resources(
|
|||||||
读取 session 并回写)。
|
读取 session 并回写)。
|
||||||
"""
|
"""
|
||||||
from modules.user_manager import UserWorkspace
|
from modules.user_manager import UserWorkspace
|
||||||
username = (username or get_current_username())
|
username = (username or _get_current_username())
|
||||||
if not username:
|
if not username:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
@ -146,7 +169,7 @@ def get_user_resources(
|
|||||||
if explicit:
|
if explicit:
|
||||||
host_mode_session = identity.host_mode
|
host_mode_session = identity.host_mode
|
||||||
else:
|
else:
|
||||||
host_mode_session = bool(session.get("host_mode")) if has_request_context() else False
|
host_mode_session = bool(session_get("host_mode"))
|
||||||
sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host"
|
sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host"
|
||||||
if host_mode_session and sandbox_is_host:
|
if host_mode_session and sandbox_is_host:
|
||||||
# 宿主机多工作区并行:资源选择必须优先由显式 workspace_id / 当前请求 session 决定,
|
# 宿主机多工作区并行:资源选择必须优先由显式 workspace_id / 当前请求 session 决定,
|
||||||
@ -157,8 +180,8 @@ def get_user_resources(
|
|||||||
selected_workspace_id = identity.host_workspace_id
|
selected_workspace_id = identity.host_workspace_id
|
||||||
else:
|
else:
|
||||||
selected_workspace_id = (
|
selected_workspace_id = (
|
||||||
(session.get("host_workspace_id") if has_request_context() else None)
|
session_get("host_workspace_id")
|
||||||
or (session.get("workspace_id") if has_request_context() else None)
|
or session_get("workspace_id")
|
||||||
)
|
)
|
||||||
with state.HOST_ACTIVE_WORKSPACE_LOCK:
|
with state.HOST_ACTIVE_WORKSPACE_LOCK:
|
||||||
active_workspace_id = state.HOST_ACTIVE_WORKSPACE_ID
|
active_workspace_id = state.HOST_ACTIVE_WORKSPACE_ID
|
||||||
@ -283,8 +306,8 @@ def get_user_resources(
|
|||||||
run_mode = identity.preferred_run_mode
|
run_mode = identity.preferred_run_mode
|
||||||
thinking_mode_flag = identity.preferred_thinking_mode
|
thinking_mode_flag = identity.preferred_thinking_mode
|
||||||
else:
|
else:
|
||||||
run_mode = session.get('run_mode') if has_request_context() else None
|
run_mode = session_get('run_mode')
|
||||||
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None
|
thinking_mode_flag = session_get('thinking_mode')
|
||||||
if run_mode not in {"fast", "thinking", "deep"}:
|
if run_mode not in {"fast", "thinking", "deep"}:
|
||||||
run_mode = "fast"
|
run_mode = "fast"
|
||||||
thinking_mode_flag = False
|
thinking_mode_flag = False
|
||||||
@ -306,18 +329,18 @@ def get_user_resources(
|
|||||||
terminal.user_role = "admin"
|
terminal.user_role = "admin"
|
||||||
terminal.quota_update_callback = None
|
terminal.quota_update_callback = None
|
||||||
if can_write_session:
|
if can_write_session:
|
||||||
session['run_mode'] = terminal.run_mode
|
session_set('run_mode', terminal.run_mode)
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session_set('thinking_mode', terminal.thinking_mode)
|
||||||
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
session_set('workspace_id', getattr(workspace, "workspace_id", None))
|
||||||
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
|
session_set('host_workspace_id', getattr(workspace, "workspace_id", None))
|
||||||
else:
|
else:
|
||||||
terminal.update_container_session(container_handle)
|
terminal.update_container_session(container_handle)
|
||||||
attach_user_broadcast(terminal, "host")
|
attach_user_broadcast(terminal, "host")
|
||||||
terminal.username = "host"
|
terminal.username = "host"
|
||||||
terminal.user_role = "admin"
|
terminal.user_role = "admin"
|
||||||
if can_write_session:
|
if can_write_session:
|
||||||
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
session_set('workspace_id', getattr(workspace, "workspace_id", None))
|
||||||
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
|
session_set('host_workspace_id', getattr(workspace, "workspace_id", None))
|
||||||
_set_terminal_workspace_label(
|
_set_terminal_workspace_label(
|
||||||
terminal,
|
terminal,
|
||||||
host_workspace.get("label") or host_workspace.get("workspace_id") or getattr(workspace, "workspace_id", None),
|
host_workspace.get("label") or host_workspace.get("workspace_id") or getattr(workspace, "workspace_id", None),
|
||||||
@ -328,8 +351,8 @@ def get_user_resources(
|
|||||||
from core.tool_config import ToolCategory
|
from core.tool_config import ToolCategory
|
||||||
from modules import admin_policy_manager
|
from modules import admin_policy_manager
|
||||||
|
|
||||||
record = None if explicit else get_current_user_record()
|
record = None if explicit else _get_current_user_record()
|
||||||
role = _resolve_user_role(identity, record, default="admin") if explicit else (get_current_user_role(record) if record else "admin")
|
role = _resolve_user_role(identity, record, default="admin") if explicit else (_get_current_user_role(record) if record else "admin")
|
||||||
invite_code = getattr(record, "invite_code", None) if record else None
|
invite_code = getattr(record, "invite_code", None) if record else None
|
||||||
policy = admin_policy_manager.get_effective_policy(
|
policy = admin_policy_manager.get_effective_policy(
|
||||||
record.username if record else username,
|
record.username if record else username,
|
||||||
@ -356,7 +379,7 @@ def get_user_resources(
|
|||||||
try:
|
try:
|
||||||
terminal.set_model(candidate)
|
terminal.set_model(candidate)
|
||||||
if can_write_session:
|
if can_write_session:
|
||||||
session["model_key"] = terminal.model_key
|
session_set("model_key", terminal.model_key)
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@ -383,7 +406,7 @@ def get_user_resources(
|
|||||||
)
|
)
|
||||||
return terminal, workspace
|
return terminal, workspace
|
||||||
|
|
||||||
is_api_user = identity.is_api_user if explicit else (bool(session.get("is_api_user")) if has_request_context() else False)
|
is_api_user = identity.is_api_user if explicit else bool(session_get("is_api_user"))
|
||||||
# API 用户与网页用户使用不同的 manager
|
# API 用户与网页用户使用不同的 manager
|
||||||
if is_api_user:
|
if is_api_user:
|
||||||
record = None
|
record = None
|
||||||
@ -391,13 +414,13 @@ def get_user_resources(
|
|||||||
raise RuntimeError(tr("context.missing_workspace_id"))
|
raise RuntimeError(tr("context.missing_workspace_id"))
|
||||||
workspace = state.api_user_manager.ensure_workspace(username, workspace_id)
|
workspace = state.api_user_manager.ensure_workspace(username, workspace_id)
|
||||||
else:
|
else:
|
||||||
record = (state.user_manager.get_user(username) if explicit else get_current_user_record())
|
record = (state.user_manager.get_user(username) if explicit else _get_current_user_record())
|
||||||
if explicit:
|
if explicit:
|
||||||
selected_workspace_id = workspace_id or "default"
|
selected_workspace_id = workspace_id or "default"
|
||||||
else:
|
else:
|
||||||
selected_workspace_id = (
|
selected_workspace_id = (
|
||||||
workspace_id
|
workspace_id
|
||||||
or (session.get("workspace_id") if has_request_context() else None)
|
or session_get("workspace_id")
|
||||||
or "default"
|
or "default"
|
||||||
)
|
)
|
||||||
workspace = state.user_manager.ensure_user_workspace(username, selected_workspace_id)
|
workspace = state.user_manager.ensure_user_workspace(username, selected_workspace_id)
|
||||||
@ -423,8 +446,8 @@ def get_user_resources(
|
|||||||
run_mode = identity.preferred_run_mode
|
run_mode = identity.preferred_run_mode
|
||||||
thinking_mode_flag = identity.preferred_thinking_mode
|
thinking_mode_flag = identity.preferred_thinking_mode
|
||||||
else:
|
else:
|
||||||
run_mode = session.get('run_mode') if has_request_context() else None
|
run_mode = session_get('run_mode')
|
||||||
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None
|
thinking_mode_flag = session_get('thinking_mode')
|
||||||
if run_mode not in {"fast", "thinking", "deep"}:
|
if run_mode not in {"fast", "thinking", "deep"}:
|
||||||
preferred_run_mode = None
|
preferred_run_mode = None
|
||||||
try:
|
try:
|
||||||
@ -460,10 +483,10 @@ def get_user_resources(
|
|||||||
terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record)
|
terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record)
|
||||||
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
|
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
|
||||||
if can_write_session:
|
if can_write_session:
|
||||||
session['run_mode'] = terminal.run_mode
|
session_set('run_mode', terminal.run_mode)
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session_set('thinking_mode', terminal.thinking_mode)
|
||||||
session['model_key'] = getattr(terminal, "model_key", None)
|
session_set('model_key', getattr(terminal, "model_key", None))
|
||||||
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
session_set('workspace_id', getattr(workspace, "workspace_id", None))
|
||||||
else:
|
else:
|
||||||
terminal.update_container_session(container_handle)
|
terminal.update_container_session(container_handle)
|
||||||
attach_user_broadcast(terminal, username)
|
attach_user_broadcast(terminal, username)
|
||||||
@ -471,7 +494,7 @@ def get_user_resources(
|
|||||||
terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record)
|
terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record)
|
||||||
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
|
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
|
||||||
if can_write_session:
|
if can_write_session:
|
||||||
session['workspace_id'] = getattr(workspace, "workspace_id", None)
|
session_set('workspace_id', getattr(workspace, "workspace_id", None))
|
||||||
|
|
||||||
if is_api_user:
|
if is_api_user:
|
||||||
workspace_label = workspace_id_value
|
workspace_label = workspace_id_value
|
||||||
@ -516,7 +539,7 @@ def get_user_resources(
|
|||||||
try:
|
try:
|
||||||
terminal.set_model(candidate)
|
terminal.set_model(candidate)
|
||||||
if can_write_session:
|
if can_write_session:
|
||||||
session["model_key"] = terminal.model_key
|
session_set("model_key", terminal.model_key)
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|||||||
111
server/gateway_api.py
Normal file
111
server/gateway_api.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""Gateway 公共协议路由(2026-09-08 新增)。
|
||||||
|
|
||||||
|
定位:docs/runtime_protocol.md 的 session.* 能力传输暴露——
|
||||||
|
为 CLI/GUI 等非 Web 客户端提供不依赖 Web 专属会话路由的公共入口,
|
||||||
|
Web 端既有 /api/conversations 路由保持不动。
|
||||||
|
|
||||||
|
路由(全部支持双通道认证:Web session 或 host Bearer token):
|
||||||
|
- GET /api/runtime/sessions → session.list
|
||||||
|
- POST /api/runtime/sessions → session.create(显式)
|
||||||
|
- GET /api/runtime/sessions/<cid>/history → session.history
|
||||||
|
|
||||||
|
「加载会话用于展示」= session.history;「让 agent 在某会话中执行」=
|
||||||
|
run.start 携带 conversation_id。不单独提供 session.load 命令
|
||||||
|
(Web 的 load 语义是切换 terminal 内存态,属 Web 适配层视图概念)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, session
|
||||||
|
|
||||||
|
from modules.i18n import tr
|
||||||
|
from server.gateway_auth import api_login_or_host_token_required
|
||||||
|
from server.runtime.context import principal_from_session_snapshot
|
||||||
|
from server.runtime.service import runtime_service
|
||||||
|
|
||||||
|
gateway_bp = Blueprint("gateway", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_workspace_id(explicit: str = "") -> str:
|
||||||
|
"""查询/创建未显式指定工作区时,回落当前会话工作区。"""
|
||||||
|
return str(explicit or session.get("workspace_id") or "default")
|
||||||
|
|
||||||
|
|
||||||
|
@gateway_bp.route("/api/runtime/sessions", methods=["GET"])
|
||||||
|
@api_login_or_host_token_required
|
||||||
|
def list_runtime_sessions():
|
||||||
|
"""session.list:会话列表(工作区/数量/多智能体筛选)。"""
|
||||||
|
workspace_id = _resolve_workspace_id(request.args.get("workspace_id", ""))
|
||||||
|
limit = request.args.get("limit", 50, type=int)
|
||||||
|
offset = request.args.get("offset", 0, type=int)
|
||||||
|
ma_param = request.args.get("multi_agent_mode", None)
|
||||||
|
multi_agent_mode = None if ma_param is None else ma_param in ("1", "true", "True")
|
||||||
|
username = str(session.get("username") or "")
|
||||||
|
principal = principal_from_session_snapshot(dict(session), workspace_id, username)
|
||||||
|
try:
|
||||||
|
result = runtime_service.list_sessions(
|
||||||
|
username,
|
||||||
|
workspace_id,
|
||||||
|
principal,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
multi_agent_mode=multi_agent_mode,
|
||||||
|
)
|
||||||
|
return jsonify({"success": True, **(result or {})})
|
||||||
|
except PermissionError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 403
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@gateway_bp.route("/api/runtime/sessions", methods=["POST"])
|
||||||
|
@api_login_or_host_token_required
|
||||||
|
def create_runtime_session():
|
||||||
|
"""session.create:显式创建会话,返回 conversation_id。"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
workspace_id = _resolve_workspace_id(str(data.get("workspace_id") or ""))
|
||||||
|
username = str(session.get("username") or "")
|
||||||
|
principal = principal_from_session_snapshot(dict(session), workspace_id, username)
|
||||||
|
try:
|
||||||
|
result = runtime_service.create_session(
|
||||||
|
username,
|
||||||
|
workspace_id,
|
||||||
|
principal,
|
||||||
|
run_mode=data.get("run_mode"),
|
||||||
|
thinking_mode=data.get("thinking_mode"),
|
||||||
|
model_key=data.get("model_key"),
|
||||||
|
multi_agent_mode=bool(data.get("multi_agent_mode")),
|
||||||
|
)
|
||||||
|
return jsonify({"success": True, **result})
|
||||||
|
except PermissionError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 403
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@gateway_bp.route("/api/runtime/sessions/<conversation_id>/history", methods=["GET"])
|
||||||
|
@api_login_or_host_token_required
|
||||||
|
def get_runtime_session_history(conversation_id: str):
|
||||||
|
"""session.history:会话历史(磁盘权威快照)。"""
|
||||||
|
workspace_id = _resolve_workspace_id(request.args.get("workspace_id", ""))
|
||||||
|
username = str(session.get("username") or "")
|
||||||
|
principal = principal_from_session_snapshot(dict(session), workspace_id, username)
|
||||||
|
try:
|
||||||
|
result = runtime_service.get_session_history(
|
||||||
|
username, workspace_id, conversation_id, principal
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 403
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 503
|
||||||
|
if result is None:
|
||||||
|
return jsonify({"success": False, "error": tr("ctx_mgr.conversation_not_found", conversation_id=conversation_id)}), 404
|
||||||
|
return jsonify({"success": True, "conversation": result})
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["gateway_bp"]
|
||||||
142
server/gateway_auth.py
Normal file
142
server/gateway_auth.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""Gateway 本机客户端通道认证(host Bearer token,2026-09-08 新增)。
|
||||||
|
|
||||||
|
定位:协议 docs/runtime_protocol.md §6「认证与授权按通道裁剪」的 host 通道
|
||||||
|
实现——让 CLI/GUI 等本机客户端无需 Web 会话流程(Cookie/CSRF/host-login)
|
||||||
|
即可接入 Gateway 公共协议端点。
|
||||||
|
|
||||||
|
安全模型与 /host-login 完全对齐(host 模式定位为本机单人使用):
|
||||||
|
- 仅 TERMINAL_SANDBOX_MODE=host(且非 LINUX_SAFETY)生效;
|
||||||
|
- 仅允许回环地址(127.0.0.1/::1/localhost)直连调用;
|
||||||
|
- token 为本机生成的随机串,存于运行态数据目录
|
||||||
|
(<DATA_DIR>/host_api_token,权限 0600),本机客户端读取后经
|
||||||
|
`Authorization: Bearer <token>` 携带。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from flask import current_app, jsonify, request, session
|
||||||
|
|
||||||
|
from config import DATA_DIR, TERMINAL_SANDBOX_MODE
|
||||||
|
from config.terminal import LINUX_SAFETY
|
||||||
|
from modules.host_workspace_manager import resolve_host_workspace
|
||||||
|
from modules.i18n import tr
|
||||||
|
from server.auth_helpers import is_logged_in
|
||||||
|
|
||||||
|
_LOOPBACK_ADDRS = {"127.0.0.1", "::1", "localhost"}
|
||||||
|
_TOKEN_FILENAME = "host_api_token"
|
||||||
|
|
||||||
|
|
||||||
|
def _host_channel_enabled() -> bool:
|
||||||
|
return (TERMINAL_SANDBOX_MODE or "").lower() == "host" and not LINUX_SAFETY
|
||||||
|
|
||||||
|
|
||||||
|
def host_api_token_path() -> Path:
|
||||||
|
return Path(DATA_DIR).expanduser() / _TOKEN_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_host_api_token() -> Optional[str]:
|
||||||
|
"""读取(不存在则生成)host 通道 token;非 host 模式返回 None。
|
||||||
|
|
||||||
|
生成使用临时文件 + os.replace 原子替换 + 0600 权限。本地单进程
|
||||||
|
场景下并发首次生成概率极低;万一竞争产生两个 token,后写覆盖先写,
|
||||||
|
持旧 token 的一方 401 后重读文件即可自愈。
|
||||||
|
"""
|
||||||
|
if not _host_channel_enabled():
|
||||||
|
return None
|
||||||
|
path = host_api_token_path()
|
||||||
|
try:
|
||||||
|
existing = path.read_text(encoding="utf-8").strip()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_name(path.name + ".tmp")
|
||||||
|
tmp.write_text(token + "\n", encoding="utf-8")
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bearer_token() -> str:
|
||||||
|
auth_header = request.headers.get("Authorization") or ""
|
||||||
|
if not auth_header.lower().startswith("bearer "):
|
||||||
|
return ""
|
||||||
|
return auth_header.split(" ", 1)[1].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_host_bearer(token: str) -> bool:
|
||||||
|
"""校验 host Bearer token:host 模式 + 回环地址 + token 匹配。"""
|
||||||
|
if not token or not _host_channel_enabled():
|
||||||
|
return False
|
||||||
|
remote_addr = (request.remote_addr or "").strip()
|
||||||
|
if remote_addr not in _LOOPBACK_ADDRS:
|
||||||
|
return False
|
||||||
|
expected = get_or_create_host_api_token()
|
||||||
|
if not expected:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(token, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_host_identity() -> None:
|
||||||
|
"""注入与 /host-login 等价的 host 会话身份(复用现有上下文装配逻辑)。
|
||||||
|
|
||||||
|
session 字段与 server/auth.py 的 host_login 保持一致(含 login_nonce),
|
||||||
|
保证路由内及下游 is_logged_in()/principal 构造等既有逻辑无差异。
|
||||||
|
"""
|
||||||
|
from server.auth import _issue_login_nonce
|
||||||
|
|
||||||
|
_, host_workspace = resolve_host_workspace()
|
||||||
|
session["logged_in"] = True
|
||||||
|
session["username"] = "host"
|
||||||
|
session["role"] = "admin"
|
||||||
|
session["host_mode"] = True
|
||||||
|
if host_workspace:
|
||||||
|
workspace_id = host_workspace.get("workspace_id") or "default"
|
||||||
|
session["host_workspace_id"] = workspace_id
|
||||||
|
session["workspace_id"] = workspace_id
|
||||||
|
default_thinking = current_app.config.get("DEFAULT_THINKING_MODE", False)
|
||||||
|
session["thinking_mode"] = default_thinking
|
||||||
|
session["run_mode"] = current_app.config.get(
|
||||||
|
"DEFAULT_RUN_MODE", "deep" if default_thinking else "fast"
|
||||||
|
)
|
||||||
|
session.permanent = True
|
||||||
|
_issue_login_nonce("host")
|
||||||
|
|
||||||
|
|
||||||
|
def api_login_or_host_token_required(view_func):
|
||||||
|
"""双通道认证装饰器:host Bearer token 或 Web session 登录。
|
||||||
|
|
||||||
|
- 携带 Bearer token 且匹配 host 通道 → 注入 host 身份并放行
|
||||||
|
- 否则回退 Web session 检查(is_logged_in)
|
||||||
|
Bearer 请求天然跳过 CSRF(server/security.py 已放行 Authorization 头)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@functools.wraps(view_func)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
token = _extract_bearer_token()
|
||||||
|
if token and _verify_host_bearer(token):
|
||||||
|
_inject_host_identity()
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
if not is_logged_in():
|
||||||
|
return jsonify({"success": False, "error": tr("auth.session_expired")}), 401
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"api_login_or_host_token_required",
|
||||||
|
"get_or_create_host_api_token",
|
||||||
|
"host_api_token_path",
|
||||||
|
]
|
||||||
@ -229,6 +229,46 @@ class RuntimeService:
|
|||||||
manager = cm._get_conversation_manager_for_id(conversation_id)
|
manager = cm._get_conversation_manager_for_id(conversation_id)
|
||||||
return manager.load_conversation(conversation_id)
|
return manager.load_conversation(conversation_id)
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
workspace_id: str,
|
||||||
|
principal: Optional[TrustedPrincipal] = None,
|
||||||
|
*,
|
||||||
|
run_mode: Optional[str] = None,
|
||||||
|
thinking_mode: Optional[bool] = None,
|
||||||
|
model_key: Optional[str] = None,
|
||||||
|
multi_agent_mode: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""显式创建会话(session.create)。
|
||||||
|
|
||||||
|
纯创建对话文件(不切换任何 terminal 的当前对话);返回
|
||||||
|
{"conversation_id": ...}。客户端随后 run.start 携带该 id 即可在
|
||||||
|
新会话中执行——「先建会话再发任务」的装配职责收在服务层单点。
|
||||||
|
默认模式解析优先级:显式传参 > principal 偏好快照 > 系统默认。
|
||||||
|
"""
|
||||||
|
terminal, workspace = self._resources_for_query(username, workspace_id, principal)
|
||||||
|
cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None)
|
||||||
|
if cm is None or workspace is None:
|
||||||
|
raise RuntimeError(tr("tasks.system_not_initialized"))
|
||||||
|
resolved_run_mode = run_mode or (principal.preferred_run_mode if principal else None) or "fast"
|
||||||
|
if resolved_run_mode not in {"fast", "thinking", "deep"}:
|
||||||
|
resolved_run_mode = "fast"
|
||||||
|
resolved_thinking = thinking_mode
|
||||||
|
if resolved_thinking is None and principal is not None:
|
||||||
|
resolved_thinking = principal.preferred_thinking_mode
|
||||||
|
resolved_thinking = (
|
||||||
|
bool(resolved_thinking) if resolved_thinking is not None else (resolved_run_mode != "fast")
|
||||||
|
)
|
||||||
|
conversation_id = cm.create_conversation(
|
||||||
|
project_path=str(getattr(workspace, "project_path", "") or "."),
|
||||||
|
run_mode=resolved_run_mode,
|
||||||
|
thinking_mode=resolved_thinking,
|
||||||
|
model_key=model_key or (principal.preferred_model_key if principal else None),
|
||||||
|
metadata_overrides={"multi_agent_mode": True} if multi_agent_mode else None,
|
||||||
|
)
|
||||||
|
return {"conversation_id": conversation_id}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resources_for_query(username: str, workspace_id: str, principal: Optional[TrustedPrincipal]):
|
def _resources_for_query(username: str, workspace_id: str, principal: Optional[TrustedPrincipal]):
|
||||||
"""查询类调用的资源装配(工作区级 terminal 即可,不加载会话到内存)。"""
|
"""查询类调用的资源装配(工作区级 terminal 即可,不加载会话到内存)。"""
|
||||||
|
|||||||
@ -14,7 +14,8 @@ from typing import Dict, Any, Optional, List
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from flask import current_app, session
|
from flask import current_app, session
|
||||||
|
|
||||||
from server.auth_helpers import api_login_required, get_current_username
|
from server.auth_helpers import get_current_username
|
||||||
|
from server.gateway_auth import api_login_or_host_token_required
|
||||||
from server.context import get_user_resources, ensure_conversation_loaded
|
from server.context import get_user_resources, ensure_conversation_loaded
|
||||||
from server.security import rate_limited
|
from server.security import rate_limited
|
||||||
from server.state import stop_flags
|
from server.state import stop_flags
|
||||||
@ -31,7 +32,7 @@ from modules.i18n import tr
|
|||||||
|
|
||||||
|
|
||||||
@tasks_bp.route("/api/tasks", methods=["GET"])
|
@tasks_bp.route("/api/tasks", methods=["GET"])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
def list_tasks_api():
|
def list_tasks_api():
|
||||||
username = get_current_username()
|
username = get_current_username()
|
||||||
workspace_id = (request.args.get("workspace_id") or "").strip() or None
|
workspace_id = (request.args.get("workspace_id") or "").strip() or None
|
||||||
@ -43,7 +44,7 @@ def list_tasks_api():
|
|||||||
})
|
})
|
||||||
|
|
||||||
@tasks_bp.route("/api/conversations/<conversation_id>/running-status", methods=["GET"])
|
@tasks_bp.route("/api/conversations/<conversation_id>/running-status", methods=["GET"])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
def get_conversation_running_status_api(conversation_id: str):
|
def get_conversation_running_status_api(conversation_id: str):
|
||||||
"""REST 对账接口:聚合某对话的完整运行状态。
|
"""REST 对账接口:聚合某对话的完整运行状态。
|
||||||
|
|
||||||
@ -93,7 +94,7 @@ def get_conversation_running_status_api(conversation_id: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
@tasks_bp.route("/api/tasks", methods=["POST"])
|
@tasks_bp.route("/api/tasks", methods=["POST"])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
@rate_limited("chat_task_create", 30, 60, scope="user")
|
@rate_limited("chat_task_create", 30, 60, scope="user")
|
||||||
def create_task_api():
|
def create_task_api():
|
||||||
username = get_current_username()
|
username = get_current_username()
|
||||||
@ -201,7 +202,7 @@ def create_task_api():
|
|||||||
}), 202
|
}), 202
|
||||||
|
|
||||||
@tasks_bp.route("/api/tasks/<task_id>", methods=["GET"])
|
@tasks_bp.route("/api/tasks/<task_id>", methods=["GET"])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
def get_task_api(task_id: str):
|
def get_task_api(task_id: str):
|
||||||
started_at = time.time()
|
started_at = time.time()
|
||||||
username = get_current_username()
|
username = get_current_username()
|
||||||
@ -259,7 +260,7 @@ def get_task_api(task_id: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
@tasks_bp.route("/api/tasks/<task_id>/cancel", methods=["POST"])
|
@tasks_bp.route("/api/tasks/<task_id>/cancel", methods=["POST"])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
def cancel_task_api(task_id: str):
|
def cancel_task_api(task_id: str):
|
||||||
username = get_current_username()
|
username = get_current_username()
|
||||||
rec = runtime_service.get_task(username, task_id)
|
rec = runtime_service.get_task(username, task_id)
|
||||||
@ -280,7 +281,7 @@ def cancel_task_api(task_id: str):
|
|||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
|
|
||||||
@tasks_bp.route("/api/tasks/<task_id>/runtime_guidance", methods=["POST"])
|
@tasks_bp.route("/api/tasks/<task_id>/runtime_guidance", methods=["POST"])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
def enqueue_runtime_guidance_api(task_id: str):
|
def enqueue_runtime_guidance_api(task_id: str):
|
||||||
username = get_current_username()
|
username = get_current_username()
|
||||||
payload = request.get_json() or {}
|
payload = request.get_json() or {}
|
||||||
@ -308,7 +309,7 @@ def enqueue_runtime_guidance_api(task_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue", methods=["POST"])
|
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue", methods=["POST"])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
def enqueue_runtime_queue_message_api(task_id: str):
|
def enqueue_runtime_queue_message_api(task_id: str):
|
||||||
username = get_current_username()
|
username = get_current_username()
|
||||||
payload = request.get_json() or {}
|
payload = request.get_json() or {}
|
||||||
@ -336,7 +337,7 @@ def enqueue_runtime_queue_message_api(task_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue/<message_id>", methods=["DELETE"])
|
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue/<message_id>", methods=["DELETE"])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
def delete_runtime_queue_message_api(task_id: str, message_id: str):
|
def delete_runtime_queue_message_api(task_id: str, message_id: str):
|
||||||
username = get_current_username()
|
username = get_current_username()
|
||||||
result = runtime_service.remove_runtime_pending_message(username, task_id, message_id)
|
result = runtime_service.remove_runtime_pending_message(username, task_id, message_id)
|
||||||
@ -358,7 +359,7 @@ def delete_runtime_queue_message_api(task_id: str, message_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue/<message_id>/guide", methods=["POST"])
|
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue/<message_id>/guide", methods=["POST"])
|
||||||
@api_login_required
|
@api_login_or_host_token_required
|
||||||
def guide_runtime_queue_message_api(task_id: str, message_id: str):
|
def guide_runtime_queue_message_api(task_id: str, message_id: str):
|
||||||
username = get_current_username()
|
username = get_current_username()
|
||||||
result = runtime_service.promote_runtime_pending_to_guidance(username, task_id, message_id)
|
result = runtime_service.promote_runtime_pending_to_guidance(username, task_id, message_id)
|
||||||
|
|||||||
@ -59,7 +59,10 @@ class TestApplyWorkspacePersonalizationPreferences(unittest.TestCase):
|
|||||||
session = FakeSession()
|
session = FakeSession()
|
||||||
session["model_key"] = "session-model"
|
session["model_key"] = "session-model"
|
||||||
|
|
||||||
with patch(f"{_PERSONALIZATION_NS}.session", session):
|
# flask bridge 化后(2026-09-08):session 读写经 session_get/session_set,
|
||||||
|
# patch 点随之指向 bridge 函数而非模块级 session 符号。
|
||||||
|
with patch(f"{_PERSONALIZATION_NS}.session_get", side_effect=lambda key, default=None: session.get(key, default)), \
|
||||||
|
patch(f"{_PERSONALIZATION_NS}.session_set", side_effect=lambda key, value: session.__setitem__(key, value)):
|
||||||
_apply_workspace_personalization_preferences(terminal, workspace)
|
_apply_workspace_personalization_preferences(terminal, workspace)
|
||||||
|
|
||||||
terminal.set_model.assert_called_once_with("session-model")
|
terminal.set_model.assert_called_once_with("session-model")
|
||||||
@ -74,7 +77,8 @@ class TestApplyWorkspacePersonalizationPreferences(unittest.TestCase):
|
|||||||
workspace = self._make_workspace()
|
workspace = self._make_workspace()
|
||||||
session = FakeSession()
|
session = FakeSession()
|
||||||
|
|
||||||
with patch(f"{_PERSONALIZATION_NS}.session", session):
|
with patch(f"{_PERSONALIZATION_NS}.session_get", side_effect=lambda key, default=None: session.get(key, default)), \
|
||||||
|
patch(f"{_PERSONALIZATION_NS}.session_set", side_effect=lambda key, value: session.__setitem__(key, value)):
|
||||||
_apply_workspace_personalization_preferences(terminal, workspace)
|
_apply_workspace_personalization_preferences(terminal, workspace)
|
||||||
|
|
||||||
terminal.set_model.assert_not_called()
|
terminal.set_model.assert_not_called()
|
||||||
|
|||||||
@ -153,8 +153,8 @@ class GetUserResourcesHostPolicyTest(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
with patch("server.context.resources.TERMINAL_SANDBOX_MODE", "host"), \
|
with patch("server.context.resources.TERMINAL_SANDBOX_MODE", "host"), \
|
||||||
patch("server.context.resources.resolve_host_workspace", return_value=(None, host_ws)), \
|
patch("server.context.resources.resolve_host_workspace", return_value=(None, host_ws)), \
|
||||||
patch("server.context.resources.get_current_user_record", return_value=record), \
|
patch("server.context.resources._get_current_user_record", return_value=record), \
|
||||||
patch("server.context.resources.get_current_user_role", return_value="admin") as get_role, \
|
patch("server.context.resources._get_current_user_role", return_value="admin") as get_role, \
|
||||||
patch("modules.admin_policy_manager.get_effective_policy", return_value=policy) as get_policy:
|
patch("modules.admin_policy_manager.get_effective_policy", return_value=policy) as get_policy:
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
app = Flask("host_policy_test")
|
app = Flask("host_policy_test")
|
||||||
|
|||||||
@ -241,7 +241,9 @@ class CrudMixin:
|
|||||||
"total_messages": metadata.total_messages,
|
"total_messages": metadata.total_messages,
|
||||||
"total_tools": metadata.total_tools,
|
"total_tools": metadata.total_tools,
|
||||||
"status": metadata.status,
|
"status": metadata.status,
|
||||||
"multi_agent_mode": bool(conversation_data["metadata"].get("multi_agent_mode", False))
|
"multi_agent_mode": bool(conversation_data["metadata"].get("multi_agent_mode", False)),
|
||||||
|
"custom_prompt_name": conversation_data["metadata"].get("custom_prompt_name"),
|
||||||
|
"personalization_name": conversation_data["metadata"].get("personalization_name"),
|
||||||
}
|
}
|
||||||
|
|
||||||
self._save_index(index)
|
self._save_index(index)
|
||||||
|
|||||||
@ -182,6 +182,8 @@ class IndexMixin:
|
|||||||
"total_tools": metadata.get("total_tools", 0),
|
"total_tools": metadata.get("total_tools", 0),
|
||||||
"status": metadata.get("status", "active"),
|
"status": metadata.get("status", "active"),
|
||||||
"multi_agent_mode": bool(metadata.get("multi_agent_mode", False)),
|
"multi_agent_mode": bool(metadata.get("multi_agent_mode", False)),
|
||||||
|
"custom_prompt_name": metadata.get("custom_prompt_name"),
|
||||||
|
"personalization_name": metadata.get("personalization_name"),
|
||||||
}
|
}
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
if perf_log:
|
if perf_log:
|
||||||
|
|||||||
@ -124,6 +124,10 @@ class ListSearchMixin:
|
|||||||
"project_path": metadata.get("project_path"),
|
"project_path": metadata.get("project_path"),
|
||||||
"project_relative_path": metadata.get("project_relative_path"),
|
"project_relative_path": metadata.get("project_relative_path"),
|
||||||
"thinking_mode": metadata.get("thinking_mode", False),
|
"thinking_mode": metadata.get("thinking_mode", False),
|
||||||
|
"run_mode": metadata.get("run_mode"),
|
||||||
|
"model_key": metadata.get("model_key"),
|
||||||
|
"custom_prompt_name": metadata.get("custom_prompt_name"),
|
||||||
|
"personalization_name": metadata.get("personalization_name"),
|
||||||
"total_messages": metadata.get("total_messages", 0),
|
"total_messages": metadata.get("total_messages", 0),
|
||||||
"total_tools": metadata.get("total_tools", 0),
|
"total_tools": metadata.get("total_tools", 0),
|
||||||
"status": metadata.get("status", "active"),
|
"status": metadata.get("status", "active"),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user