Files
DevFlow/docs/02-架构设计/经验记录.md
绝尘 cf017f81e2 新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录
功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore
修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸
文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
2026-06-14 14:08:20 +08:00

141 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 经验记录
> DevFlow 开发中沉淀的**经验性内容**——踩坑、约定、技巧、bug 排查教训。聚焦「这个坑怎么踩的 / 这个约定为什么这么定 / 这个 bug 怎么定位的」,区别于 [功能决策记录](./功能决策记录.md)(记需求规格 + 设计决策规格)。
>
> 创建2026-06-14从功能决策记录.md 分流出经验性条目) | 维护:随开发追加
## 约定
- 按类型分组:**踩坑**(隐性坑/反直觉)/ **约定**(代码实现约定 / 命名约定)/ **技巧**(具体技巧/配置)/ **bug 排查**bug 定位过程与教训)。
- 每条标题标 `[来源日期]` + `[Sprint]`(如有),便于回溯原上下文。
- 三要素:**现象/决策** → **原因/根因****状态/教训**
- 与功能决策记录区分:这里记「怎么实现的细节坑」,不记「为什么这么设计」。
---
## 一、踩坑
### i18n 模块必须命名空间化导出(扁平导出会断 $t + 键覆盖)[2026-06-14]
- **现象**:左侧菜单显示 `'nav.tasks'`(原样键名);`dashboard` 整页显示 key 名;`AiChat``$t('ai.assistant')` 失效。
- **决策**:每个 i18n 模块文件 `export default { 命名空间: {...} }`(如 `nav.ts``{ nav: {...} }`**禁止扁平导出顶层词条**。模板查询走 `$t('命名空间.key')`
- **根因**`index.ts` 聚合是 `Object.assign` 扁平合并各模块顶层 key见「locale 拆分 + glob 聚合」决策)。扁平导出导致两个 bug`$t('nav.tasks')``messages.nav` 不存在,原样显示键名;② 扁平键(如 nav 的 `ideas`/`projects`/`tasks`/`knowledge`与同名命名空间模块ideas.ts/projects.ts/...)按文件名字母序互相覆盖。本次 nav.ts 扁平导出导致 4 个键被覆盖。
- **状态/教训**:✅ 系统性修复,共 4 模块扁平已全部改嵌套zh/en 8 文件nav / common8 文件 25 处 $t 引用)/ dashboard / ai。原本正确嵌套ideas/projects/tasks/settings/knowledge/projectDetail/aiChat。**教训**:扁平导出是体系性 bug 非单点。排查「$t 显示原样键名」时应**优先怀疑模块导出结构(扁平 vs 嵌套)**,而非 SSR / locale 初始化。
- **绕路纠错**:曾误判根因为 SSR实际 Tauri 纯客户端无 SSR→ nav 走 `getNavTranslations` 硬编码 map + displayText 绕路 → 清除绕路恢复标准 `$t`
---
## 二、约定
### Anthropic 流式 output_tokens 当累计值直接覆盖 / 流式 token 落库走累加模式 [2026-06-13]
- **决策**:① `message_delta` 事件的 output_tokens 直接覆盖 completion_tokens**不像 prompt 那样累加**;② `save_conversation` upsert 路径 token 读旧值叠加(非覆盖);`run_agentic_loop` 局部累加器每轮叠加、退出时一次性传 save。
- **原因**:① Anthropic 协议在 `message_delta` 返回的是**累计** output_tokens截至当前总量非增量当增量处理会重复计算。OpenAI 则是末 chunk 一次性给全量——两协议语义不同,各自处理。② 审批暂停→恢复 spawn 全新 `run_agentic_loop` 实例,新 loop 局部累加器从 0 起;若覆盖写会丢旧 loop 已落库的 token。累加保证跨 loop 实例的对话总用量正确。
- **状态**:✅ 2026-06-13两协议各自语义处理 + 跨 loop 累加保对话总量)
### db 字段加列须同步四处migration + crud 白名单 + AI 工具层白名单 + 工具描述 [2026-06-14]
- **现象**AI 对话让 AI 绑定目录update_project(path) 报「不允许更新字段 'path'」,但 db schema 和 crud 白名单都已有 path。
- **根因**可更新字段有两套独立白名单——crud.rs::allowed_columns_forDB 层)+ ai.rs 工具闭包硬编码 matchAI 工具层)。加 path/stack 时只同步 DB 层漏 AI 工具层,两层不一致。
- **教训**:加 Record 可变字段同步四处migration + crud 白名单 + ai.rs 工具白名单 + 工具描述。排查「DB 有字段但工具报不允许」直查 ai.rs 硬编码。架构债:白名单双份去重。
### Tauri 命令文件拆子 module命令函数必须 glob `pub use *`,不能逐个显式 [2026-06-14]
- **现象**:把含 `#[tauri::command]` 的单文件(如 ai.rs拆成 `ai/` 子 module 时mod.rs 用 `pub use self::commands::{ai_chat_send, ...}` 逐个显式重导出 17 个命令,`cargo check` 报 40 个 E0433`cannot find __cmd__ai_chat_send in ai` / `cannot find __tauri_command_name_ai_chat_send in ai`
- **根因**`#[tauri::command]` 宏不只生成命令函数本身,还用 `paste!` 宏拼接生成一组同模块定义的内部符号(`__cmd__xxx``__tauri_command_name_xxx`)。`generate_handler!` 解析 `commands::ai::ai_chat_send` 时会查找 `commands::ai::__cmd__ai_chat_send`。逐个 `pub use self::commands::{ai_chat_send}` 只拉函数本身,**拉不到这些 `__cmd__` 内部符号**(即使它们在原模块是 pub 的)。
- **教训**拆命令文件时mod.rs 重导出命令必须用 `pub use self::commands::*;`glob 把宏生成的全部符号一起拉到上层路径),不能用逐个显式。非命令 pub 项(如 `build_ai_tool_registry`/`restore_pending_approvals`)可逐个显式。后续若拆 idea.rs/project.rs/task.rs 等其他含命令的大文件,同此模式。
- **状态**:✅ 2026-06-14 验证ai.rs 拆 11 子 moduleglob 重导出后 cargo check 0 error
### 跨层模块拆分:`super::xxx` 路径失效需改全限定 [2026-06-14]
- **现象**ai.rscommands 直接子模块)拆到 `ai/xxx.rs`commands 孙模块6 个子文件 `use super::now_millis` 全报 E0425 unresolved import。
- **根因**`super` 指向当前模块的父——ai.rs 时 `super` = `commands``now_millis` 定义处);拆到 `ai/xxx.rs``super` = `commands::ai``now_millis` 在祖父模块 `commands`
- **教训**:拆层后所有 `super::xxx` 引用需重审。父模块的 helper`now_millis`)改全限定 `crate::commands::now_millis` 最稳(不依赖层级)。或拆层前把 helper 下沉到子 mod.rs 内 `use` 一次,子文件用 `super::xxx`
- **状态**:✅ 2026-06-14 验证(批量改 `crate::commands::now_millis`6 文件 20+ 处)
### 删文件后被 linter/工具重建为 0 字节触发 E0761 [2026-06-14]
- **现象**`rm commands/ai.rs` 后某 linter/hook 又建了 0 字节的 ai.rs触发 `E0761: file for module ai found at both ai.rs and ai/mod.rs`,且 Rust 优先选空文件导致后续 40 个 `cannot find __cmd__xxx`(与 glob 重导出坑叠加,表象一致根因不同)。
- **教训**:拆分时删原文件后**立即 ls 验证不存在**再跑 cargo check避免空文件 + 目录并存的 E0761 与命令宏符号坑混淆。E0761 出现先查是否有 0 字节残留文件。
- **状态**:✅ 2026-06-14 验证(删空 ai.rs 后通过)
### ALLOWED_COLUMNS 从全局共享演进为按表隔离 [2026-06-13]
- **决策**`crud.rs` 列名白名单从单一全局 `ALLOWED_COLUMNS` 改为 `allowed_columns_for(table)` 按表 match`validate_column_name(field, table)` 接收表名;宏 `query`/`update_field``$table`。专用更新路径列knowledges.embedding 走 set_embedding、projects.deleted_at 走 soft_delete/restore排除出白名单。
- **演进原因**:原原则(见功能决策记录需求澄清「代码审查甄别原则」)基于「全局白名单够防注入」。本轮多代理代码审查发现**真实 bug**:全局白名单**误含 ideas 表没有的 `reasoning` 列**reasoning 属 knowledges/V10`update_idea("reasoning")` 会 validate 通过但 SQLite 报 `no such column`——错误从「白名单拒绝」退化成「底层 SQL 错」且语义错。按表隔离既修此 bugideas 白名单不含 reasoning又防未来跨表字段update_task 误传 projects 的 `name` 在校验阶段拒绝,非靠 SQL 兜底)。
- **代价/取舍**12 表 × N 列的 match 冗长,但数据驱动、可读、一次写对。**规模判断不变**(仍不加分页/不拆 LIMIT仅白名单从「全局防注入」升级为「按表防注入 + 防跨表字段」。
- **状态**:✅ 2026-06-13 落地cargo check + df-storage 32 test 全绿,含 `update_field_rejects_cross_table_column_tasks_name` 用例验证跨表字段被拒)
### 配置存储SQLite/AppState Arc<Mutex>,非 Tauri app config [2026-06-13]
- **决策**KnowledgeConfig提取+注入共 5 项)**存 AppState 内存**`knowledge_config: Arc<Mutex<KnowledgeConfig>>`),前后端通过 `knowledge_get_config`/`knowledge_save_config` IPC 读写;**不引入 tauri-plugin-store**。
- **演进**[2026-06-13 初版设计] 写「存 Tauri app config」 → [2026-06-13 审查修正] 代码实证项目 Cargo.toml 仅 opener+window_state 两插件,**从未用过 config/store 机制**现有设置走两条路SQLite 存 provider / localStorage 存 UI 偏好) → [2026-06-14] **`SettingsRepo` 兑现本条预言**`app_settings` KV 表V13 迁移)+ 手写 `SettingsRepo`(get/set/get_all/delete不走 `impl_repo!` 宏因 KV 无固定 schema)。localStorage 11 key 迁移启动:敏感 `df-connections` + UI 偏好(theme/language/ai-width/ai-ui/token/concurrency) + `df-ai-active-conv`;例外 `df-ai-gen`/`df-ai-text`(流式临时快照,每个 delta 写一次SQLite 高频写拖慢流式,留 localStorage
- **原因**AI Provider 配置已是 SQLite+Repo+IPC 模式,知识库行为配置(后端行为,非 UI 偏好)对齐同模式最一致。引入 tauri-plugin-store 是全新基础设施依赖,与既有 DB 路线割裂。AppState Arc<Mutex> 内存持有 + IPC 读写,启动时 `default()` 初始化Tier 1 未持久化到 DB进程重启回默认——够用因这是行为偏好非数据。未来要持久化时复用同一套 SettingsRepo 即可。
- **状态**:✅ 已实施Tier 1
### 知识删除语义knowledge_archive 软删除(命名统一)[2026-06-13]
- **决策**:知识删除 command 命名 `knowledge_archive`(执行 `UPDATE status='archived'`**不叫 knowledge_delete**。匹配 `ai_conversation_archive` 先例;主列表 `knowledge_list(status=None)` 默认 `AND status!='archived'` 过滤。
- **原因/取舍**idea/task/project 的 `delete_xxx` 都是硬删DELETE FROM若 knowledge 也叫 delete 却做归档API 语义混淆(调用方期望数据消失,实际还在 DB。conversation 模块已有正确先例archive 命名表示软删除)。软删除复用 archived 状态,数据保留可追溯,列表默认过滤保证用户感知「已删除」。状态机 published→archived 也走同一路径。
- **状态**:✅ 已实施Tier 1
### Store 状态字段用 getter 替代引用快照 [Sprint 10]
- **决策**`useProjectStore()` 返回对象的状态字段projects/tasks/ideas/workflowExecutions/liveEvents/loading/error改 getter 实时读 state而非 `ideas: state.ideas` 引用快照。
- **原因**:引用快照在 `loadIdeas()` 等重新赋值 state.ideas 后,返回对象的 ideas 属性不更新刷新后视图空需切菜单再切回才显示getter 每次读 state响应链成立。computedstats/pendingApproval在 reactive 内仍自动解包,各视图用法零改动。
- **状态**:🚧 Sprint 10编译/构建通过,未 tauri dev 实测,根因通杀 Projects/Tasks/Dashboard
---
## 三、技巧
### migrate_v4PRAGMA table_info 探测列存在性 [Sprint 10]
- **决策**v4 加 `archived` 列时,用 `PRAGMA table_info` 幂等探测列是否已存在,而非仅依赖 `schema_version` 版本号 gate。
- **原因**:历史坏库 `schema_version` 值混乱(早期迁移异常致版本号与实际 schema 不符),版本号不可靠;直接探列存在性最稳——已存在则跳过,不存在则补建,幂等可重入。
- **状态**:✅ Sprint 10
### Vite 端口 `strictPort: true` 不自动迁移 [Sprint 1]
- **决策**`vite.config.ts``port: 1420` + `strictPort: true`,端口被占时**直接报错退出**而非自动 +1 迁移;`tauri.conf.json``devUrl` 写死 `http://localhost:1420`
- **原因**Tauri webview 启动时按 `devUrl` 加载前端,若 Vite 因冲突静默迁移到 1421 而 devUrl 仍是 1420 → 白屏/连不上,错误难定位(易误判为前端代码 bug`strictPort` 让端口冲突当场炸出定位明确。代价1420 被占需手动杀进程但换取「devUrl 与实际端口必一致」的不变量。
- **状态**:✅ Sprint 1本次会话核对1420 vs 2661 反复折腾后回退到 1420即此耦合的直接体现
---
## 四、bug 排查
### ai_tool_executions 审计回写失效Med/High 审批后卡 pending[#54 实测]
- **现象**:用户审批 Med/High 工具后执行成功(副作用落库,如 create_project→projects 有记录),但 `ai_tool_executions``status=pending / decided_by=None / executed_at=None / result=None`审计未闭环。Low 工具正常(`decided_by=auto` 完整)。
- **根因(代码层定位)**`crud.rs:103``impl_repo!` 生成的通用 `query` 硬编码 `ORDER BY created_at DESC`,但 `ai_tool_executions` 表**无 `created_at` 列** → `audit_finalize``query("tool_call_id", x)` SQL 报 `no such column: created_at``.unwrap_or_default()` 吞错返回空 → `if let Some(rec)` 为 None → **永不回写**。Low 工具不走 query`process_tool_calls` Low 分支直接 `audit_tool_call` insert 完整记录)故不受影响。
- **架构隐患**:通用 `query``ORDER BY created_at` 假设所有表都有该列——`ai_tool_executions`(及潜在其他无 `created_at` 的表)任何 `query()` 调用都静默失败;`unwrap_or_default` 吞 SQL 错误放大隐患。
- **修复**:✅ 已落地2026-06-13。采用方向①`crud.rs``AiToolExecutionRepo` 加专用 `find_by_tool_call_id`(裸 SQL `ORDER BY requested_at DESC LIMIT 1`,绕过宏的 `created_at` 假设);`ai.rs audit_finalize` 改用之,查不到记录改 `tracing::warn`(不再 `unwrap_or_default` 静默吞错)。
- → 未改宏(方向②影响 7+ 表)/ 未加列(方向③需迁移):隐患仅 `ai_tool_executions` 一处暴露,局部修最小影响。
-**架构隐患仍存(未根治)**:通用 `query`/`list_all` 宏对无 `created_at` 的表(`ai_tool_executions`/`node_executions`/`workflow_executions`)调用仍静默失败。当前仅 `ai_tool_executions``query` 调用且已绕开,余者暂无 `query` 调用点。未来新增调用时,要么该表登记 `created_at`,要么宏做容错。
- **教训**:宏生成的通用方法对表 schema 的隐式假设(这里「所有表都有 created_at」是隐蔽的系统性风险`unwrap_or_default()` 吞错误让 bug 隐形——关键路径慎用。
### reasoning 字段回填(修 bugprompt 要求但写库丢弃)[2026-06-13]
- **现象/决策**`KnowledgeRecord``reasoning: Option<String>`V10 ALTER`extract_knowledge_from_conversation` 解析 LLM JSON 的 `reasoning` 字段写入主表;前端详情溯源区展示「🤖 AI 判断依据」。
- **根因**`EXTRACTION_SYSTEM_PROMPT` 早已要求 LLM 输出 `reasoning: "为何值得沉淀"`但提炼循环ai.rs 旧版)只取 kind/title/content/tags/confidence**reasoning 被 LLM 产出却遭代码丢弃**——是信息链断裂的 bug非缺功能。审核员光看 content 结论,缺 AI 判断依据(尤其 confidence=low 的弱信号更靠 reasoning 解释为何还提炼)。回填后溯源完整。
- **状态**:✅ 已实施reasoning 存主表 + extracted 事件 context.reasoning 双写,前端优先取主表降级取事件)
- **教训**LLM 输出字段与代码消费字段须对账——prompt 要求 LLM 产出的字段,代码侧漏消费是常见隐性 bug。
### prompt_tokens=0深挖证伪非代码 bug疑 GLM 订阅端点 message_start 缺 input_tokens[#54 实测发现]
- **现象**`ai_conversations.prompt_tokens=0`completion=1496 正常。GLM-订阅anthropic 协议1 对话 24 消息,所有 assistant 消息 `usage=None`
- **深挖结论(→ 修正初判)**初判「anthropic_compat usage 解析漏 input_tokens待修」**证伪**。逐段验证:
1. `anthropic_compat` message_start 取 `input_tokens→prompt_tokens` **有单测**input=42 过);
2. `stream_llm`857`final_usage=chunk.usage.clone()` 累积对;
3. `ai.rs:699` `tokens.add` 链路对。
代码按标准 Anthropic 协议解析正确。`inp as u32`Some→值None→0completion 有值说明 message_delta 的 output GLM 返回了,**prompt=0 = GLM 订阅端点 message_start 疑未返回 `usage.input_tokens`**(协议非标)。**勿改 anthropic_compat**(改了 = 误改正确实现)。
- **状态**:📐 待修(误判)→ 🚫 非代码 bug。待抓 GLM 订阅 SSE 原文确认 input_tokens 在哪个事件/字段(临时打 message_start/message_delta 的 usage JSON 日志,测完删);若确认端点缺则属 provider 兼容性待办,非解析 bug。
- **教训**bug 定位优先用单测/逐段验证证伪代码层假设,不要急着改「看似正确」的实现。深挖证伪避免了一次误改。
---
**相关文档**
- [功能决策记录](./功能决策记录.md) — 需求规格 + 设计决策规格
- [功能决策记录-归档](./功能决策记录-归档.md) — 纯流水 / 老 Sprint / UX 微调 / 已被取代