新增: 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/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -0,0 +1,140 @@
# 经验记录
> 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 微调 / 已被取代