//! 数据库迁移 — 建表 SQL 与版本管理 //! 数据库迁移 — 建表 SQL 与版本管理 use crate::migrations_helpers::{column_exists, table_exists}; use anyhow::Result; use rusqlite::Connection; /// 执行所有迁移 pub fn run(conn: &Connection) -> Result<()> { // 创建迁移版本表 conn.execute_batch( "CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY );" )?; let current_version: i32 = conn .query_row( "SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |row| row.get(0), ) .unwrap_or(0); // 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。 // 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。 // V20 = 任务关联灵感 idea_id;V21 = 消息拆分存储 + audit message_id; // V22 = 灵感评估历史持久化(idea_evaluations 追加型审计表); // V23 = knowledges.embedding_status 列(嵌入失败可补偿重试); // V24 = ideas.related_ids 列(灵感间关联关系持久化打底); // V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底); // V26 = 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。 // V27 = TD-260621-05 审批状态统一(ai_tool_executions.status executed→completed, // 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 + // 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。 // V28 = 灵感软删回收站(ideas.deleted_at,对标 tasks.deleted_at V14/projects.deleted_at V11)。 // V29 = 知识图谱 Phase 1 任务网络基础(对标 docs/02-架构设计/专项设计/ // 项目知识图谱与任务队列系统-2026-06-26.md §2.1/§2.2):tasks 加 queue(管理池)/ // parent_id(父任务纵向关联)/content_json(结构化需求规格)三列 + task_links 表 // (横向关联 depends_on/blocks/relates_to),为 AI 编排地基打底。 // V30 = 知识图谱 Phase 2 统一事件流(对标设计 §2.4):project_events 追加型审计表, // 跨实体(idea/task/workflow/knowledge/module/service/project)事件流,AI 精准检索 // 的基础(回答"上周做了什么/这个任务为何 blocked/决策何时做出")。 // V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表, // 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了 // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 // V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。 // V42 = ai_messages 加 is_estimated 列(消息级估算标记,reload 逐条回显「估算」标注)。 // V43 = 存量知识一次性晋升(决策单 B4①:candidate + confidence='high' → published, // 纯数据 UPDATE 不经过 promote 函数,不触发嵌入生成)。 // V44 = 子代理轨迹来源标记(ai_conversations.origin TEXT,spawn_agent 出口整轨迹落库, // 桌面侧栏/miniapp 列表按 origin 过滤子会话)。 // V45 = 无人值守任务边界(ai_conversations.boundary_json TEXT,TaskBoundary JSON 快照, // 发起时一次声明存续期生效,随会话同亡)。 // V46/V47 = 任务/灵感归档(archived INTEGER NOT NULL DEFAULT 0,归档功能 P1, // 对标 ai_conversations.archived V4;归档与软删正交:回收站=删除缓冲,归档=低频可见保检索)。 // V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。 // V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座: // running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。 // V50 = 使用数据分析 schema 债补全(三项,详见 migrate_v50 文档): // ai_conversations.status 会话终局态 / ai_tool_executions.duration_ms 纯执行耗时 / // conversation_checkpoints.token_total 语义回填。 // V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体, // 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。 // V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。 // V53 = WorkingContext 持久化(ai_conversations.working_context_json TEXT,跨会话召回 L4 // 重启不失忆:save 随行写,会话载入与 parked 恢复读还原,NULL=无上下文行为同旧)。 let steps: [(i32, fn(&Connection) -> Result<()>); 53] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), (4, migrate_v4), (5, migrate_v5), (6, migrate_v6), (7, migrate_v7), (8, migrate_v8), (9, migrate_v9), (10, migrate_v10), (11, migrate_v11), (12, migrate_v12), (13, migrate_v13), (14, migrate_v14), (15, migrate_v15), (16, migrate_v16), (17, migrate_v17), (18, migrate_v18), (19, migrate_v19), (20, migrate_v20), (21, migrate_v21), (22, migrate_v22), (23, migrate_v23), (24, migrate_v24), (25, migrate_v25), (26, migrate_v26), (27, migrate_v27), (28, migrate_v28), (29, migrate_v29), (30, migrate_v30), (31, migrate_v31), (32, migrate_v32), (33, migrate_v33), (34, migrate_v34), (35, migrate_v35), (36, migrate_v36), (37, migrate_v37), (38, migrate_v38), (39, migrate_v39), (40, migrate_v40), (41, migrate_v41), (42, migrate_v42), (43, migrate_v43), (44, migrate_v44), (45, migrate_v45), // V46 = 任务归档(tasks.archived 列,归档功能 P1,对标 ai_conversations.archived V4); // V47 = 灵感归档(ideas.archived 列,同款)。归档与软删正交:回收站=删除缓冲,归档=低频可见保检索。 (46, migrate_v46), (47, migrate_v47), // V48 = 项目归档(projects.archived 列,归档功能 P2,对标 V46/V47 同款)。 (48, migrate_v48), // V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。 (49, migrate_v49), // V50 = 使用数据分析 schema 债补全(status/duration_ms/token_total 三项)。 (50, migrate_v50), // V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。 (51, migrate_v51), // V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。 (52, migrate_v52), // V53 = WorkingContext 持久化(ai_conversations.working_context_json,跨会话召回 L4)。 (53, migrate_v53), ]; for (version, migrate_fn) in steps { if current_version < version { migrate_fn(conn)?; } } Ok(()) } /// V1: 初始表结构 fn migrate_v1(conn: &Connection) -> Result<()> { conn.execute_batch(V1_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [1])?; tracing::info!("迁移 v1 完成"); Ok(()) } /// V2: 补齐关联字段 + branches 表 /// /// 幂等化(2026-08-05):V2_SQL 原含 7 个裸 ALTER,全链重跑 duplicate column。 /// 拆分:CREATE TABLE branches(IF NOT EXISTS 幂等,execute_batch 跑); /// 7 个 ALTER 改逐列 column_exists 守卫(对齐 v4+ 幂等模式)。 fn migrate_v2(conn: &Connection) -> Result<()> { // branches 等 CREATE TABLE IF NOT EXISTS 幂等,execute_batch 跑 conn.execute_batch(V2_SQL)?; // ideas: 晋升关联 + AI 分析 + 多维评分 for (table, col) in [("ideas", "promoted_to"), ("ideas", "ai_analysis"), ("ideas", "scores"), ("tasks", "workflow_def_id"), ("tasks", "base_branch"), ("workflow_executions", "project_id"), ("workflow_executions", "task_id")] { if !column_exists(conn, table, col) { conn.execute(&format!("ALTER TABLE {table} ADD COLUMN {col} TEXT"), [])?; } } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [2])?; tracing::info!("迁移 v2 完成"); Ok(()) } /// V3: AI 对话表补建(新库) + 归档标记列(新老库统一) fn migrate_v3(conn: &Connection) -> Result<()> { conn.execute_batch(V3_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [3])?; tracing::info!("迁移 v3 完成"); Ok(()) } /// V4: 幂等补 ai_conversations.archived 列 /// /// 修复历史缺陷:早期 v3 迁移仅写入版本号 3,ALTER ADD COLUMN archived 未实际生效, /// 导致 schema_version=3 但 ai_conversations 缺列,from_row 读 archived 报错, /// list_all 失败 → 前端历史会话不显示 + 新对话 insert 失败。 /// 因 run() 按 `current_version < 3` 跳过 v3,该列无法靠 v3 自补。 /// 此处用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全。 fn migrate_v4(conn: &Connection) -> Result<()> { let has_archived = column_exists(conn, "ai_conversations", "archived"); if !has_archived { conn.execute( "ALTER TABLE ai_conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0", [], )?; tracing::info!("v4: 补建 ai_conversations.archived 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [4])?; tracing::info!("迁移 v4 完成"); Ok(()) } /// V5: 幂等补 ai_conversations.prompt_tokens / completion_tokens 列 /// /// 流式 token 用量记录:对话级累计 input/output token(由 save_conversation 写入)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式)。 fn migrate_v5(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_conversations", "prompt_tokens") { conn.execute("ALTER TABLE ai_conversations ADD COLUMN prompt_tokens INTEGER", [])?; tracing::info!("v5: 补建 ai_conversations.prompt_tokens 列"); } if !column_exists(conn, "ai_conversations", "completion_tokens") { conn.execute("ALTER TABLE ai_conversations ADD COLUMN completion_tokens INTEGER", [])?; tracing::info!("v5: 补建 ai_conversations.completion_tokens 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [5])?; tracing::info!("迁移 v5 完成"); Ok(()) } /// V6: 幂等补 ai_conversations.models 列 /// /// 对话级多 model 记录:JSON 数组字符串(去重存对话用过的所有 model)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5 模式)。 fn migrate_v6(conn: &Connection) -> Result<()> { let has_models = column_exists(conn, "ai_conversations", "models"); if !has_models { conn.execute("ALTER TABLE ai_conversations ADD COLUMN models TEXT", [])?; tracing::info!("v6: 补建 ai_conversations.models 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [6])?; tracing::info!("迁移 v6 完成"); Ok(()) } /// V7: 知识库表 — 经验沉淀的基本单元(共享记忆层) /// /// 状态机: candidate → pending_review → published → archived /// AI 只产 candidate,人工门控发布;reuse_count 是唯一客观排序信号。 /// effectiveness 列不建(决策撤销人工评分)。时间字段用毫秒字符串(同既有 model 约定)。 fn migrate_v7(conn: &Connection) -> Result<()> { conn.execute_batch(V7_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [7])?; tracing::info!("迁移 v7 完成"); Ok(()) } /// V8: 幂等补 knowledges.embedding 列(向量检索) /// /// 存 Vec 的小端字节序列化 BLOB。NULL = 未嵌入(走 LIKE 降级)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6 模式)。 fn migrate_v8(conn: &Connection) -> Result<()> { let has_embedding = column_exists(conn, "knowledges", "embedding"); if !has_embedding { conn.execute("ALTER TABLE knowledges ADD COLUMN embedding BLOB", [])?; tracing::info!("v8: 补建 knowledges.embedding 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [8])?; tracing::info!("迁移 v8 完成"); Ok(()) } /// V9: 幂等补建 ai_providers + ai_tool_executions 表 /// /// 历史遗漏:这两张表从未写入迁移文件(V1-V8 均未包含), /// 旧库可能通过其他方式已建,新库缺失导致 save_provider 等操作报 SQL 错误。 /// 用 CREATE TABLE IF NOT EXISTS 幂等,已有表不受影响。 fn migrate_v9(conn: &Connection) -> Result<()> { conn.execute_batch(V9_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [9])?; tracing::info!("迁移 v9 完成"); Ok(()) } /// V10: 知识生命线 — 补 knowledges.reasoning 列 + 新建 knowledge_events 事件表 /// /// - reasoning: AI 提炼时给出的"为何值得沉淀"判断依据(此前 prompt 要求但写库丢弃, /// 此处补列修复;老库行默认 NULL,前端降级展示"手动录入/无依据")。幂等(PRAGMA 探测)。 /// - knowledge_events: 追加型审计表,记录产生/审核/引用/归档四类事件,支撑生命线视图。 /// 独立表(非 JSON 嵌主表): 一条知识可被引用数百次,JSON 嵌入致行膨胀+更新竞争。 fn migrate_v10(conn: &Connection) -> Result<()> { if !column_exists(conn, "knowledges", "reasoning") { conn.execute("ALTER TABLE knowledges ADD COLUMN reasoning TEXT", [])?; tracing::info!("v10: 补建 knowledges.reasoning 列"); } conn.execute_batch(V10_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [10])?; tracing::info!("迁移 v10 完成"); Ok(()) } /// V11: 幂等补 projects.deleted_at 列(软删回收站) /// /// 删除项目改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。 /// ProjectRecord 不带该字段,纯靠 SQL WHERE deleted_at IS NULL 过滤; /// 子表(tasks/releases/branches)不动,FK 仍满足,项目数据完整保留待恢复。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10 模式)。 fn migrate_v11(conn: &Connection) -> Result<()> { if !column_exists(conn, "projects", "deleted_at") { conn.execute("ALTER TABLE projects ADD COLUMN deleted_at TEXT", [])?; tracing::info!("v11: 补建 projects.deleted_at 列(软删回收站)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [11])?; tracing::info!("迁移 v11 完成"); Ok(()) } /// V12: 幂等补 projects.path / projects.stack 列(项目绑定真实代码目录) /// /// 项目与磁盘代码库脱钩是项目管理核心缺失:此版补 path(绑定目录绝对路径) + /// stack(技术栈 JSON 数组字符串),为「绑定目录 + 探测技术栈」打地基, /// 第二步「导入历史项目」直接复用。两列均 nullable,老项目 path/stack=NULL 天然兼容。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11 模式)。 fn migrate_v12(conn: &Connection) -> Result<()> { if !column_exists(conn, "projects", "path") { conn.execute("ALTER TABLE projects ADD COLUMN path TEXT", [])?; tracing::info!("v12: 补建 projects.path 列(绑定代码目录)"); } if !column_exists(conn, "projects", "stack") { conn.execute("ALTER TABLE projects ADD COLUMN stack TEXT", [])?; tracing::info!("v12: 补建 projects.stack 列(技术栈)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [12])?; tracing::info!("迁移 v12 完成"); Ok(()) } /// V13: 通用应用设置 KV 表(前端 localStorage 迁移目标) /// /// 存主题/语言/AI 偏好/连接配置等,`value` 为 JSON 字符串。CREATE TABLE IF NOT EXISTS 幂等。 fn migrate_v13(conn: &Connection) -> Result<()> { conn.execute_batch(V13_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [13])?; tracing::info!("迁移 v13 完成"); Ok(()) } /// V14: 幂等补 tasks.deleted_at 列(软删回收站,对标 projects.deleted_at V11) /// /// 删除任务改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。 /// 与 projects.soft_delete 同模板:TaskRecord 不带该字段,纯靠 SQL WHERE deleted_at IS NULL /// 过滤;子表(branches)不动,FK 仍满足,任务数据完整保留待恢复。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11 模式)。 fn migrate_v14(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "deleted_at") { conn.execute("ALTER TABLE tasks ADD COLUMN deleted_at TEXT", [])?; tracing::info!("v14: 补建 tasks.deleted_at 列(软删回收站)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [14])?; tracing::info!("迁移 v14 完成"); Ok(()) } /// V15: 幂等补 tasks.review_rounds 列(review 退回累计轮数) /// /// 任务推进链状态机退回时累加:in_review→in_progress / testing→in_review 各 +1, /// 由 advance_task(df-nodes::task_advance_node)原子写入。默认 0(从未退回过的任务)。 /// NOT NULL DEFAULT 0 保证老库行迁移后取值确定(非 NULL),TaskRecord 字段为 i32(非 Option)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14 模式)。 fn migrate_v15(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "review_rounds") { conn.execute( "ALTER TABLE tasks ADD COLUMN review_rounds INTEGER NOT NULL DEFAULT 0", [], )?; tracing::info!("v15: 补建 tasks.review_rounds 列(review 退回累计轮数)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [15])?; tracing::info!("迁移 v15 完成"); Ok(()) } /// V16: 幂等补 ai_conversations.pinned 列(对话置顶,UX-17) /// /// 侧栏置顶分组排序信号:前端按 pinned DESC, updated_at DESC 排,置顶在前。 /// 纯元数据标记(同 archived),NOT NULL DEFAULT 0 保证老库行非 NULL,AiConversationRecord.pinned 为 bool。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15 模式),对新库/老库均安全。 fn migrate_v16(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_conversations", "pinned") { conn.execute( "ALTER TABLE ai_conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0", [], )?; tracing::info!("v16: 补建 ai_conversations.pinned 列(对话置顶)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [16])?; tracing::info!("迁移 v16 完成"); Ok(()) } /// V17: 幂等补 tasks.output_json 列(AiNode 自审闭环产出,决策 a:task 中心) /// /// 任务产出 JSON 字符串:ai_execute 写产出 / ai_self_review 读产出做自审 / human_review 展示对象。 /// TEXT NULL 向后兼容(老库行默认 NULL,TaskRecord 字段为 Option), /// 经通用 update_field 白名单写入(非 status 状态机收口字段,status 收口 F-03 不变)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15/v16 模式),对新库/老库均安全。 fn migrate_v17(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "output_json") { conn.execute("ALTER TABLE tasks ADD COLUMN output_json TEXT", [])?; tracing::info!("v17: 补建 tasks.output_json 列(AiNode 自审闭环产出)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [17])?; tracing::info!("迁移 v17 完成"); Ok(()) } /// V18: 幂等补 ai_providers.model_configs 列(模型能力配置) /// /// 模型 4 维度(模态/能力/价格/智力)+ 路由控制配置 JSON 字符串。TEXT NULL 向后兼容: /// 老库行默认 NULL,from_row 经 deserialize_model_configs 解析为空 Vec(配合 default_model 过渡)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17 模式),对新库/老库均安全。 fn migrate_v18(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_providers", "model_configs") { conn.execute("ALTER TABLE ai_providers ADD COLUMN model_configs TEXT", [])?; tracing::info!("v18: 补建 ai_providers.model_configs 列(模型能力配置)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [18])?; tracing::info!("迁移 v18 完成"); Ok(()) } /// V19: 幂等补 ai_providers.enabled + ai_providers.weight 列(多 Provider 负载均衡池) /// /// - `enabled INTEGER NOT NULL DEFAULT 1`:provider 是否进入负载均衡池。 /// 老库行迁移后默认 1(所有现存 provider 默认启用,单 provider 路径零变化)。 /// is_default 仍保留作启动兜底(get_active_provider 无 active_provider_id 时取 is_default)。 /// - `weight INTEGER NOT NULL DEFAULT 50`:provider 在池中的选择权重(0-100)。 /// 高权重 provider 优先被选为主;同权重时退化近似轮询。 /// /// 向后兼容:老库行 ALTER 后取 DEFAULT,from_row 经 i32→bool / i32→u32 解析。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v17/v18 模式),对新库/老库均安全。 fn migrate_v19(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_providers", "enabled") { conn.execute( "ALTER TABLE ai_providers ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1", [], )?; tracing::info!("v19: 补建 ai_providers.enabled 列(多 Provider 负载均衡池)"); } if !column_exists(conn, "ai_providers", "weight") { conn.execute( "ALTER TABLE ai_providers ADD COLUMN weight INTEGER NOT NULL DEFAULT 50", [], )?; tracing::info!("v19: 补建 ai_providers.weight 列(多 Provider 负载均衡池)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [19])?; tracing::info!("迁移 v19 完成"); Ok(()) } /// V20:幂等补 tasks.idea_id 列(任务关联灵感,1对1 单向) /// /// 任务可关联到一条灵感(任务→灵感单向),复用 projects.idea_id 模式 /// (REFERENCES ideas(id) 外键)。TEXT NULL 向后兼容:老库行默认 NULL,TaskRecord /// 字段为 Option(未关联灵感的任务为 None)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v11/v14/v17 模式),对新库/老库均安全。 /// 新库已在 V9_SQL(tasks 建表)直接带 idea_id 列,此处只补老库。 fn migrate_v20(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "idea_id") { conn.execute( "ALTER TABLE tasks ADD COLUMN idea_id TEXT REFERENCES ideas(id)", [], )?; tracing::info!("v20: 补建 tasks.idea_id 列(任务关联灵感)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [20])?; tracing::info!("迁移 v20 完成"); Ok(()) } /// V21:消息拆分存储(ai_messages 表 + 全量迁移)+ ai_tool_executions.message_id 列 /// /// **一次原子迁移**(决策 V21 合并,不拆 V21a/V21b): /// 1. 建表 ai_messages(IF NOT EXISTS 幂等,新库空表/老库均安全) /// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit) /// 3. 遍历 ai_conversations.messages JSON → 逐条提取到 ai_messages(分批 commit) /// 4. 逐对话进度判定:已落库数 == JSON 消息数 → 跳过该对话(防重复迁移) /// /// 设计要点(详见消息拆分存储设计 §4.2): /// - **幂等安全**:逐对话进度判定 + INSERT OR IGNORE,中途崩溃重跑只补未迁对话。 /// 不用全局 COUNT 判完成——批量迁移每批一事务,批次中途崩溃后 ai_messages 已有 /// 数据,COUNT>0 会整段跳过,剩余对话永不再迁而版本号照样补齐,残缺被永久掩盖 /// - **分批 commit**:每 50 对话一批,避免长事务持有 SQLite 写锁 /// - **迁移期 ID**:`msg_migrated_{conv_id}_{seq}` —— 天然唯一(UNIQUE 是 conv_id+seq)、零依赖 /// - **裸 JSON 提取**:用 `serde_json::Value` 而非 ChatMessage(df-storage 不依赖 df-ai-core) /// - **坏数据跳过**:JSON 解析失败 → warn + continue,不中断迁移 /// - **status 归一化**:None/空 → "active",列语义清晰永不 NULL /// - **created_at 语义**:有 timestamp 用消息自己的;没有 fallback 到对话 created_at /// /// ⚠️ **迁移耦合点**:迁移函数硬编码 JSON 字段名(role/content/parts/tool_call_id/ /// tool_calls/model/status/reasoning_content/timestamp),与 ChatMessage serde 序列化字段 /// 一一对应。ChatMessage 改字段名必须同步更新此函数,否则老库迁移漏数据。 /// 同步标注已在 types.rs ChatMessage 定义处加注释。 fn migrate_v21(conn: &Connection) -> Result<()> { // 1. 建 ai_messages 表(IF NOT EXISTS 幂等) conn.execute_batch(V21_SQL)?; // 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit) // 表存在性兜底:run() 正常流程下 V9 已先建该表,但测试/手动调用可能跳过 V9。 // 表不存在时跳过 ALTER(新库会由 V9_SQL 建表带 message_id 列;此处只补老库已有表)。 let tool_exec_table_exists: bool = conn .query_row( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='ai_tool_executions'", [], |_| Ok(()), ) .is_ok(); if tool_exec_table_exists && !column_exists(conn, "ai_tool_executions", "message_id") { conn.execute( "ALTER TABLE ai_tool_executions ADD COLUMN message_id TEXT", [], )?; tracing::info!("v21: 补建 ai_tool_executions.message_id 列(消息级溯源 audit)"); } // 3. 遍历 ai_conversations,反序列化 messages JSON → 逐条写入 ai_messages let mut stmt = conn.prepare( "SELECT id, messages, created_at FROM ai_conversations", )?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, )) })?; let all_rows: Vec<(String, String, String)> = rows.collect::, _>>()?; // 4. 分批 commit(每 50 个对话一批,避免长事务持有写锁) const BATCH_SIZE: usize = 50; let mut migrated_count: usize = 0; for (batch_idx, batch) in all_rows.chunks(BATCH_SIZE).enumerate() { let tx = conn.unchecked_transaction()?; for (conv_id, messages_json, conv_created_at) in batch { // 5. 逐对话反序列化 messages JSON → Vec // (用裸 JSON 而非 ChatMessage,因 df-storage 不依赖 df-ai-core) let messages: Vec = match serde_json::from_str(messages_json) { Ok(v) => v, Err(e) => { tracing::warn!("v21: 对话 {} messages JSON 解析失败,跳过: {}", conv_id, e); continue; // 坏数据跳过,不中断迁移 } }; // 逐对话进度判定(幂等核心):批事务保证对话级原子——一个对话的全部消息 // 在同一事务内写入,要么全落库要么全无,不存在半迁持久态。已落库数等于 // JSON 消息数即该对话已完整迁移,跳过;否则(未迁/断点残缺)整对话重跑, // 行级 INSERT OR IGNORE 兜底去重。中途崩溃重跑只补未迁对话,不再被 // 全局 COUNT>0 整段短路。 let json_count = messages.len() as i64; let db_count: i64 = tx.query_row( "SELECT COUNT(*) FROM ai_messages WHERE conversation_id = ?1", rusqlite::params![conv_id], |row| row.get(0), )?; if json_count > 0 && db_count == json_count { continue; } for (seq, msg) in messages.iter().enumerate() { // 6. 逐条消息提取字段 → INSERT INTO ai_messages // 字段名硬编码("role"/"content" 等)——ChatMessage 改名会漏数据! // 迁移期 ID 天然唯一(UNIQUE 是 conv_id+seq),人类可读,零依赖 let id = format!("msg_migrated_{}_{}", conv_id, seq); let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user"); let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or(""); let parts = msg.get("parts") .filter(|v| !v.is_null()) .map(|v| v.to_string()); let tool_call_id = msg.get("tool_call_id") .and_then(|v| v.as_str()) .map(String::from); let tool_calls = msg.get("tool_calls") .filter(|v| !v.is_null()) .map(|v| v.to_string()); let model = msg.get("model") .and_then(|v| v.as_str()) .map(String::from); // status 归一化:None/空 → "active"(列语义清晰,永不 NULL) let status = msg.get("status") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .unwrap_or("active"); let reasoning_content = msg.get("reasoning_content") .and_then(|v| v.as_str()) .map(String::from); // created_at:有 timestamp 用消息自己的,没有 fallback 到对话创建时间 let timestamp = msg.get("timestamp").and_then(|v| v.as_i64()); let created_at = timestamp .map(|ts| ts.to_string()) .unwrap_or_else(|| conv_created_at.clone()); tx.execute( "INSERT OR IGNORE INTO ai_messages (id, conversation_id, seq, role, content, parts, tool_call_id, tool_calls, model, status, reasoning_content, timestamp, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", rusqlite::params![ id, conv_id, seq as i64, role, content, parts, tool_call_id, tool_calls, model, status, reasoning_content, timestamp, created_at ], )?; migrated_count += 1; } } tx.commit()?; tracing::info!("v21: 批次 {} 完成({} 对话)", batch_idx, batch.len()); } conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [21])?; tracing::info!("迁移 v21 完成,共迁移 {} 条消息", migrated_count); Ok(()) } /// V22:灵感评估历史持久化 — idea_evaluations 追加型审计表 /// /// 把灵感每次 AI 评估快照(ai_analysis / scores / score)按版本追加存表, /// 替代覆写 ideas.ai_analysis / ideas.scores 列。一条灵感多次评估产生多条记录, /// version 单调递增,前端按 (idea_id, version DESC) 取最新 + 翻历史。 /// /// 设计要点(对齐 knowledge_events 追加型审计表模式 V10): /// - **追加型**:只 INSERT 不 UPDATE,审计语义(评估快照不可篡改,历史可追溯) /// - **IF NOT EXISTS 幂等**:对新库建表 / 老库已有表跳过,均安全 /// - **索引**:`(idea_id, version DESC)` 覆盖「取某灵感最新评估」最高频查询 /// - **evaluated_by**:评估发起者(model 名 / human / system,可空) /// - **evaluated_at**:评估时间(毫秒字符串,同既有 model 约定) /// /// 不登记通用列白名单(allowed_columns_for):本表走专用 list_by_idea, /// 宏生成的 query/update_field 未登记表会被 validate_column_name 保守拒绝 /// 与追加型审计语义一致(历史不改),不开放通用写路径。 fn migrate_v22(conn: &Connection) -> Result<()> { conn.execute_batch(V22_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [22])?; tracing::info!("迁移 v22 完成"); Ok(()) } /// V23:幂等补 knowledges.embedding_status 列(嵌入生成失败可补偿重试) /// /// P1 修复(嵌入失败无标记):spawn_embedding_for_knowledge 此前 fire-and-forget, /// provider 临时不可用 → 仅 warn,该条永久无向量索引但无人感知(下次也不会重试)。 /// 新增 embedding_status 列跟踪嵌入生命周期: /// - NULL:未生成(老库行迁移后默认 NULL;代码从无显式写 NULL/pending 的路径, /// 仅此两种取值实际出现:done / failed) /// - done:成功(已有有效 embedding,由 KnowledgeRepo::set_embedding 写入) /// - failed:失败可重试(下次发布 / 手动 retry 时补偿,由 KnowledgeRepo::mark_embedding_failed 写入) /// /// 语义:embedding 列(BLOB)与 embedding_status 解耦 —— embedding 仅在 done 时有值; /// 失败时 status=failed + embedding 仍 NULL,检索侧 `embedding IS NOT NULL` 自然跳过。 /// 老库行(已成功嵌入的)embedding 有值但 status=NULL:这类条目检索正常(embedding IS NOT NULL), /// 不影响功能;若需精确状态,可在后台补偿脚本回填 done,但非必需(检索不依赖 status)。 /// /// TEXT NULL 向后兼容;不进通用 update_field 白名单(写入走 KnowledgeRepo::set_embedding / /// mark_embedding_failed 两个专用方法,而非独立的 set_embedding_status)。用 PRAGMA 探测列存在性, /// 缺失才 ALTER(同既有幂等模式),对新库/老库均安全。 fn migrate_v23(conn: &Connection) -> Result<()> { if !column_exists(conn, "knowledges", "embedding_status") { conn.execute("ALTER TABLE knowledges ADD COLUMN embedding_status TEXT", [])?; tracing::info!("v23: 补建 knowledges.embedding_status 列(嵌入失败可补偿重试)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [23])?; tracing::info!("迁移 v23 完成"); Ok(()) } /// V24:幂等补 ideas.related_ids 列(灵感间关联关系持久化打底) /// /// 为灵感关联关系 UI 打底:related_ids 存「关联灵感 id JSON 数组」字符串 /// (同 tags 的 JSON-in-TEXT 模式)。TEXT NULL 向后兼容:老库行默认 NULL, /// IdeaRecord 字段为 Option(未设关联的灵感为 None)。 /// /// 进通用 update_field 白名单(Ideas.vue 关联关系 UI 走 update_idea → /// update_field('related_ids', ...),同 tags 一样白名单登记该列;另有整行 /// update(update_full)路径,二者均可写入)。 /// /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17 /// /v18/v19/v20/v23 模式),对新库/老库均安全。 fn migrate_v24(conn: &Connection) -> Result<()> { if !column_exists(conn, "ideas", "related_ids") { conn.execute("ALTER TABLE ideas ADD COLUMN related_ids TEXT", [])?; tracing::info!("v24: 补建 ideas.related_ids 列(灵感关联关系持久化打底)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [24])?; tracing::info!("迁移 v24 完成"); Ok(()) } /// V25:幂等补 idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底) /// /// 评估历史 version 此前由 evaluate_idea 算 `list_by_idea().first().version + 1` /// 得到,读-改-写非原子。并发两次评估同一灵感可能读到相同最新 version,各自 +1 后 /// 写入相同 version(重复),破坏「version 单调递增 + 唯一」语义。单用户桌面应用 /// 概率低,但唯一约束是数据完整性兜底,值得加。 /// /// 实现选 CREATE UNIQUE INDEX IF NOT EXISTS 而非 ALTER TABLE ADD CONSTRAINT: /// SQLite 不支持 ALTER TABLE 加约束 / 也不支持 ALTER ... IF NOT EXISTS,而 /// `CREATE UNIQUE INDEX IF NOT EXISTS` 原生幂等(新库建 / 老库已有则跳过),满足 /// 迁移「对新库与老库均安全」要求。索引语义等价于表级 UNIQUE(idea_id, version), /// 同样在 INSERT 冲突时抛 SQLITE_CONSTRAINT_UNIQUE。 /// /// 注:既有重复数据(若老库已有重复 version 行)会导致建索引失败。单用户桌面应用 /// 几乎不会有重复,若真发生此处**降级跳过**(建索引失败 → warn + 继续迁移),而非 `?` 上抛 /// 致整个应用启动崩溃、用户无感。理由:唯一索引只是并发重复的兜底防御网,缺失它不影响历史 /// 数据读取(list_by_idea / list_recent_idea_evals 照常工作),应用仍可用,远胜启动失败黑屏。 /// 建索引失败时日志带原始错误,用户/开发者可据此清理重复后手动重跑迁移补索引。 fn migrate_v25(conn: &Connection) -> Result<()> { let build_result = conn.execute_batch( "CREATE UNIQUE INDEX IF NOT EXISTS uq_idea_evaluations_idea_version \ ON idea_evaluations(idea_id, version)", ); if let Err(e) = build_result { // 降级:建唯一索引失败(典型根因——老库已存在重复 (idea_id, version) 行)不阻断迁移。 // 索引缺失仅削弱并发重复防御,不破坏既有数据可读性;跳过继续记录 schema_version=25。 tracing::warn!( error = %e, "v25: 建 idea_evaluations(idea_id, version) 唯一索引失败(老库可能有重复 version 行),\ 降级跳过索引创建不阻断启动。清理重复后可手动重跑迁移补建索引" ); } else { tracing::info!("v25: 建 idea_evaluations(idea_id, version) 唯一索引完成"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [25])?; tracing::info!("迁移 v25 完成"); Ok(()) } /// V26:补建 tasks 表 priority/assignee 索引(索引缺口) /// /// list_by_query 已支持 priority/assignee 过滤下推(TaskQuery.priority/assignee), /// 但缺索引 → 数据量增长后全表扫描。补建索引对齐已有的 idx_tasks_status / /// idx_tasks_project_id 同类过滤维度。当前数据量 ~15(前瞻基建非性能驱动), /// 但索引零成本(SQLite 维护代价极小)且向后续 priority 排序/分配人筛选铺路。 /// /// 实现选 CREATE INDEX IF NOT EXISTS:原生幂等(新库 V1_SQL 已建则跳过, /// 老库无则补建),对新库与老库均安全,无需 PRAGMA 探测。索引定义须与 V1_SQL 中 /// 的同名索引一致(仅 priority/assignee 单列索引)。 /// /// 注:assignee 列允许 NULL(V1_SQL 未 NOT NULL),SQLite 索引正常包含 NULL 行, /// 不影响 assignee = ? 等值查询命中(过滤掉 NULL 行)。 fn migrate_v26(conn: &Connection) -> Result<()> { conn.execute_batch( "CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);\ CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)", )?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [26])?; tracing::info!("迁移 v26 完成"); Ok(()) } /// V27: 审批状态统一 executed→completed /// /// chat.rs ai_approve/ai_authorize_dir 审批通过后工具执行成功历史写 "executed",audit 内联执行 /// (低风险无审批)写 "completed",双轨并存致:① DTO 文档(audit/mod.rs:53)只列 completed 契约失配; /// ② 前端 AuditLog statusClass/i18n auditLog.status 无 executed 键 → executed 记录显示蓝pending /// 标签 + raw"executed"文案矛盾(用户见"待审批 executed");③ 未来 WHERE status='completed' /// 统计会漏 executed 路径。统一为 completed(find_cached SW-16 透传已兼容双值,无破坏)。 /// 数据迁移:存量 executed→completed,新库/老库均跑(UPDATE 0 行也安全)。 fn migrate_v27(conn: &Connection) -> Result<()> { conn.execute( "UPDATE ai_tool_executions SET status = 'completed' WHERE status = 'executed'", [], )?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [27])?; tracing::info!("迁移 v27 完成(审批状态 executed→completed 统一)"); Ok(()) } /// V28: 幂等补 ideas.deleted_at 列(灵感软删回收站,对标 tasks.deleted_at V14) /// /// 删除灵感改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。 /// 与 tasks/projects.soft_delete 同模板:IdeaRecord 不带该字段,纯靠 SQL WHERE /// deleted_at IS NULL 过滤;关联数据(idea_evaluations 评估历史)不动,FK 仍满足, /// 灵感数据完整保留待恢复。用 PRAGMA 探测列存在性,缺失才 ALTER /// (同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17/v18/v19/v20/v23/v24 模式),对新库/老库均安全。 fn migrate_v28(conn: &Connection) -> Result<()> { if !column_exists(conn, "ideas", "deleted_at") { conn.execute("ALTER TABLE ideas ADD COLUMN deleted_at TEXT", [])?; tracing::info!("v28: 补建 ideas.deleted_at 列(灵感软删回收站)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [28])?; tracing::info!("迁移 v28 完成"); Ok(()) } /// V29:知识图谱 Phase 1 任务网络基础 — 幂等补 tasks.queue / parent_id / content_json 三列 /// /// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.1, /// 为「AI 拥有完整项目知识图谱、自主分解任务编排依赖」打数据层地基。三列各司其职: /// /// - `queue TEXT NOT NULL DEFAULT 'todo'`:管理维度池标记,与 status(执行维度)正交。 /// 取值 backlog(需求池)/todo(待办池)/decision(待决策池)/active(执行中)/done(已完成)。 /// DEFAULT 'todo' 保证老任务迁移后取值确定(非 NULL),向后兼容:历史任务原 status=todo, /// 落 todo 池语义一致。queue 与 status 一致性约束由 IPC 层校验(不进状态机,不进 DB 约束)。 /// /// - `parent_id TEXT REFERENCES tasks(id)`:父任务纵向关联(AI 分解-执行编排结构)。 /// NULL = 叶子任务(走状态机 advance_task);非空 = 子任务。 /// 限制 1 级嵌套(无孙任务)由 IPC 层校验,不进 DB 约束。父任务=容器模型,status 由 /// 子任务聚合计算(不走状态机)。TEXT NULL 向后兼容(老任务无 parent → None)。 /// /// - `content_json TEXT`:结构化需求规格 JSON 字符串(AI 可读写的执行规格)。 /// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。 /// AI 从对话提取填充,执行中用 acceptance_criteria 自检。NULL = 无结构化规格(纯文本 description)。 /// TEXT NULL 向后兼容(老任务无 content_json → None)。 /// /// 对标 v14(v14 tasks.deleted_at)/ v24(v24 ideas.related_ids)幂等模式:每列用 PRAGMA /// 探测存在性,缺失才 ALTER,对新库/老库/坏库均安全(列已存在时跳过 ALTER 不报 duplicate column)。 fn migrate_v29(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "queue") { conn.execute( "ALTER TABLE tasks ADD COLUMN queue TEXT NOT NULL DEFAULT 'todo'", [], )?; tracing::info!("v29: 补建 tasks.queue 列(管理池,知识图谱 Phase 1)"); } if !column_exists(conn, "tasks", "parent_id") { conn.execute( "ALTER TABLE tasks ADD COLUMN parent_id TEXT REFERENCES tasks(id)", [], )?; tracing::info!("v29: 补建 tasks.parent_id 列(父任务纵向关联,知识图谱 Phase 1)"); } if !column_exists(conn, "tasks", "content_json") { conn.execute("ALTER TABLE tasks ADD COLUMN content_json TEXT", [])?; tracing::info!("v29: 补建 tasks.content_json 列(结构化需求规格,知识图谱 Phase 1)"); } // task_links 表(横向关联,对标设计 §2.2):depends_on/blocks/relates_to。 // CREATE TABLE IF NOT EXISTS 幂等:新库建、老库(V29 之前的库已跑过前半三列)已有则跳过。 // 循环依赖检测走应用层(TaskLinkRepo::create_link BFS),非 DB 约束(对标设计 D8)。 // 软删除语义:Task 软删不级联删 link(恢复后关系还在),故无 ON DELETE,FK 仅引用完整性。 conn.execute( "CREATE TABLE IF NOT EXISTS task_links ( id TEXT PRIMARY KEY, source_id TEXT NOT NULL REFERENCES tasks(id), target_id TEXT NOT NULL REFERENCES tasks(id), link_type TEXT NOT NULL, remark TEXT, created_at TEXT NOT NULL )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_task_links_source ON task_links(source_id)", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_task_links_target ON task_links(target_id)", [], )?; tracing::info!("v29: 建 task_links 表 + 索引(任务横向关联,知识图谱 Phase 1)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [29])?; tracing::info!("迁移 v29 完成"); Ok(()) } /// V30:知识图谱 Phase 2 统一事件流 — project_events 追加型审计表 /// /// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4, /// 为「AI 精准检索项目事件流」打数据层地基。一张表承载跨实体(idea/task/workflow/ /// knowledge/module/service/project)的全部事件,回答"上周做了什么 / 这个任务为何 blocked / /// 这个决策何时做出"——此前需跨 6 张表 JOIN。 /// /// **追加型审计表**(对标 knowledge_events V10 / idea_evaluations V22):只 INSERT 不 UPDATE/ /// DELETE,历史不改可追溯。故表无 updated_at,无通用 update/delete 路径,无列白名单登记 /// (ProjectEventRepo 走专用方法,与 TaskLinkRepo 同款全专用路径)。 /// /// **与 knowledge_events 的关系**(对标设计 D9 / §2.4):knowledge_events 保持不变(知识库专属, /// 向后兼容),project_events 是全局事件流;knowledge 相关事件**同时写入两者**——本表只管写入, /// 双写策略在埋点层(commands/IPC hook)实现,非本表职责。 /// /// 列语义(对标设计 §2.4): /// - `event_type`:事件类型白名单(idea_created/idea_promoted/...task_created/task_advanced/ /// .../decision_made 等,见设计 §2.4 注释枚举)。白名单**应用层校验**(类比 task_links /// link_type),不进 DB 约束(保留扩展性,新增事件类型无需迁移)。 /// - `entity_type` / `entity_id`:事件指向的实体(可空——部分事件无明确实体,如纯决策日志)。 /// entity_type 白名单同样应用层校验(idea/project/task/workflow/knowledge/module/service)。 /// - `from_state` / `to_state`:状态变化前后(仅状态变化类事件有值,如 task_advanced; /// created/referenced 类为 NULL)。 /// - `context_json`:事件附加上下文(JSON 字符串,因 event_type 而异)。 /// - `source`:ai / human / system(AI Working 溯源——区分是 AI 自主操作还是人操作)。 /// - `conversation_id`:触发事件的对应对话(AI Working 溯源链:事件→对话→决策,可空)。 /// /// **埋点策略**(设计 §2.4 hook/after):在现有 IPC 命令(create_task/advance_task/create_project /// /idea_promote 等)执行后追加事件写入,不侵入业务逻辑。事件写入失败 best-effort 不阻断主操作 /// (设计 §10.1 风险已识别),由埋点层实现,非本表职责。 /// /// **CREATE TABLE IF NOT EXISTS 幂等**:新库建表、老库(V30 之前的库)已有则跳过,均安全。 /// 索引覆盖最高频查询: /// - `idx_project_events_project(project_id, created_at)`:按项目查事件流(时间倒序,Dashboard 时间线) /// - `idx_project_events_entity(entity_type, entity_id)`:按实体反查(AI「这个任务发生过什么」) fn migrate_v30(conn: &Connection) -> Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS project_events ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), event_type TEXT NOT NULL, entity_type TEXT, entity_id TEXT, from_state TEXT, to_state TEXT, context_json TEXT, source TEXT, conversation_id TEXT, created_at TEXT NOT NULL )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_project_events_project \ ON project_events(project_id, created_at)", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_project_events_entity \ ON project_events(entity_type, entity_id)", [], )?; tracing::info!("v30: 建 project_events 表 + 索引(统一事件流,知识图谱 Phase 2)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [30])?; tracing::info!("迁移 v30 完成"); Ok(()) } /// V31:知识图谱 Phase 3 基础设施数据层 — project_services 表 /// /// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.3, /// 为「AI 执行任务时拥有项目基础设施上下文」打数据层地基。AI 知道"这项目用了什么数据库、 /// Redis 在哪、有没有 MQ、API 在哪个地址",从而在编码/部署/排障时不必反复问人。 /// /// **定位**:项目基础设施配置的元数据层(对标设计 §2.3)。 /// - `service_type`:基础设施类型(mysql/postgresql/sqlite/redis/mongodb/mq/api/other), /// **应用层校验**(类比 task_links.link_type / project_events.event_type,不进 DB 约束, /// 保留扩展性,新增类型无需迁移)。 /// - `endpoint`:连接地址(localhost:3306 / URL),纯连接信息。 /// - `config_json`:类型相关配置 JSON 字符串(如数据库连接池参数、MQ topic 列表)。 /// - `environment`:环境标识(development/staging/production),DEFAULT 'development'。 /// 同一服务可在不同环境各存一行(项目维度 + 环境维度组合定位)。 /// /// **边界(D10,对标设计 §2.3)**:⚠️ **不存敏感凭证**(密码/密钥/Token)。本表只存连接信息, /// 凭证走环境变量/外部密钥管理。config_json 不应含 password/secret/key 字段——此约束由 /// 应用层(ProjectServiceRepo)在 insert/update_full 时做内容审查(检测 password/secret/key/ /// token 子串拒绝),DB 层无 CHECK 约束(SQLite CHECK 对 JSON 内容无法表达)。 /// /// **元数据非运维工具**:不做连接池/健康检查/探活,仅记录"项目用了什么基础设施"这一事实, /// 供 AI 与 Dashboard 检索消费。运维能力是后续独立模块的职责。 /// /// **CREATE TABLE IF NOT EXISTS 幂等**:新库建表、老库(V31 之前的库)已有则跳过,均安全。 /// 索引 `idx_project_services_project(project_id)` 覆盖最高频查询:按项目列其全部基础设施 /// (Dashboard 项目视图 / AI 项目上下文注入)。 fn migrate_v31(conn: &Connection) -> Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS project_services ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), name TEXT NOT NULL, service_type TEXT NOT NULL, endpoint TEXT, config_json TEXT, environment TEXT NOT NULL DEFAULT 'development', remark TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_project_services_project \ ON project_services(project_id)", [], )?; tracing::info!("v31: 建 project_services 表 + 索引(基础设施数据层,知识图谱 Phase 3)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [31])?; tracing::info!("迁移 v31 完成"); Ok(()) } /// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化) /// /// 对话目标由 PerConvState.pinned_goals(Vec)管理,原先仅在内存态存在, /// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。 fn migrate_v32(conn: &Connection) -> Result<()> { // G5.5: 幂等守卫(column_exists 探测,同 v4/v20/v33 模式)——列已存在跳过 ALTER。 // 防存量库崩溃重跑/版本号回退后重跑 migrate_v32 报 duplicate column name。 if !column_exists(conn, "ai_conversations", "pinned_goals") { conn.execute_batch( "ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';" )?; tracing::info!("v32: ai_conversations 加 pinned_goals 列"); } else { tracing::info!("v32: pinned_goals 列已存在,跳过"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [32])?; tracing::info!("迁移 v32 完成"); Ok(()) } fn migrate_v33(conn: &Connection) -> Result<()> { // 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式) let has_col: bool = conn .query_row( "SELECT COUNT(*) > 0 FROM pragma_table_info('ai_conversations') WHERE name = 'pending_approvals'", [], |row| row.get(0), ) .unwrap_or(false); if !has_col { conn.execute_batch( "ALTER TABLE ai_conversations ADD COLUMN pending_approvals TEXT DEFAULT '{}';" )?; tracing::info!("v33: ai_conversations 加 pending_approvals 列(审批重启恢复)"); } else { tracing::info!("v33: pending_approvals 列已存在,跳过"); } // 任务4: workflow_executions.updated_at 列 —— 工作流执行记录更新时间戳(用于排序/增量同步/中文)。 // 同样用 PRAGMA 探测列存在性(同 v4 模式),缺失才 ALTER;若表本身不存在(极端坏库),跳过该列不阻断迁移。 let has_updated_at: bool = conn .query_row( "SELECT COUNT(*) > 0 FROM pragma_table_info('workflow_executions') WHERE name = 'updated_at'", [], |row| row.get(0), ) .unwrap_or(false); if !has_updated_at { // workflow_executions 表在 V1 建表,此处仅加列。若表不存在(理论上 V1 必建,但坏库防御) // pragma_table_info 返 0 行,has_updated_at 为 false,会尝试 ALTER → 报错被跳过(下面 match)。 match conn.execute_batch("ALTER TABLE workflow_executions ADD COLUMN updated_at TEXT;") { Ok(_) => tracing::info!("v33: workflow_executions 加 updated_at 列"), Err(e) => tracing::warn!("v33: workflow_executions.updated_at 加列失败(表不存在?)跳过: {}", e), } } else { tracing::info!("v33: workflow_executions.updated_at 列已存在,跳过"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?; tracing::info!("迁移 v33 完成"); Ok(()) } /// V34:工程系统—project_modules 表(项目多工程,每个工程独立代码仓库) /// /// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。 /// 每个工程有独立的目录(path)、Git 地址(git_url)、技术栈(stack)。 /// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。 /// /// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。 /// /// 注:description / status 列由 V40 追加(老库 V34 时无),新库由 V40 ALTER /// 补建(因 V34 首次建表已注册版本号,新库只跑 V34 一次)。两边列定义须一致。 fn migrate_v34(conn: &Connection) -> Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS project_modules ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), name TEXT NOT NULL, path TEXT NOT NULL, git_url TEXT, stack TEXT, auto_detected BOOLEAN NOT NULL DEFAULT FALSE, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_project_modules_project ON project_modules(project_id)", [], )?; tracing::info!("v34: 建 project_modules 表 + 索引(工程系统)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [34])?; tracing::info!("迁移 v34 完成"); Ok(()) } /// V35:工程依赖关系—module_dependencies 表(工程间依赖边,用于依赖图) /// /// dep_type 枚举值:library(类库) / api(API调用) / mq(消息队列) / shared(共享资源) / custom(自定义) fn migrate_v35(conn: &Connection) -> Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS module_dependencies ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), from_module_id TEXT NOT NULL REFERENCES project_modules(id), to_module_id TEXT NOT NULL REFERENCES project_modules(id), dep_type TEXT NOT NULL DEFAULT 'library', label TEXT, created_at TEXT NOT NULL )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_module_deps_project ON module_dependencies(project_id)", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_module_deps_from ON module_dependencies(from_module_id)", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_module_deps_to ON module_dependencies(to_module_id)", [], )?; tracing::info!("v35: 建 module_dependencies 表 + 索引(工程依赖图)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [35])?; tracing::info!("迁移 v35 完成"); Ok(()) } /// V36:多 Agent 并行执行数据层(ai_plans/ai_subtasks/ai_conflicts 3 新表 + /// ai_messages/ai_tool_executions 加 subtask_id 列)。 /// 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二 fn migrate_v36(conn: &Connection) -> Result<()> { // 1. ai_messages 加 subtask_id 列(消息归属子任务,NULL=单 Agent 时期) if !column_exists(conn, "ai_messages", "subtask_id") { conn.execute("ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT", [])?; tracing::info!("v36: ai_messages 加 subtask_id 列"); } // 2. ai_tool_executions 加 subtask_id 列(工具调用归属子任务) if !column_exists(conn, "ai_tool_executions", "subtask_id") { conn.execute("ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT", [])?; tracing::info!("v36: ai_tool_executions 加 subtask_id 列"); } // 3. ai_plans 表(Plan 生命周期:用户消息触发→拆解→执行→合并→完成) conn.execute( "CREATE TABLE IF NOT EXISTS ai_plans ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, user_message_id TEXT, status TEXT NOT NULL DEFAULT 'planning', subtask_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, completed_at TEXT )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_ai_plans_conv ON ai_plans(conversation_id, created_at)", [], )?; // 4. ai_subtasks 表(SubTask 状态 + DAG 层级 + Git worktree 分支) conn.execute( "CREATE TABLE IF NOT EXISTS ai_subtasks ( id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES ai_plans(id), persona_id TEXT, intent TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', layer INTEGER NOT NULL DEFAULT 0, deps TEXT, branch TEXT, created_at TEXT NOT NULL, completed_at TEXT )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_ai_subtasks_plan ON ai_subtasks(plan_id, layer)", [], )?; // 5. ai_conflicts 表(合并冲突:同文件路径 + 跨文件语义冲突) conn.execute( "CREATE TABLE IF NOT EXISTS ai_conflicts ( id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES ai_plans(id), file_path TEXT NOT NULL DEFAULT '', conflict_type TEXT NOT NULL DEFAULT 'file', subtask_a TEXT, subtask_b TEXT, diff_a TEXT, diff_b TEXT, resolution TEXT NOT NULL DEFAULT 'pending', resolved_by TEXT, created_at TEXT NOT NULL, resolved_at TEXT )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_ai_conflicts_plan ON ai_conflicts(plan_id, resolution)", [], )?; tracing::info!("v36: 建 ai_plans/ai_subtasks/ai_conflicts 表 + subtask_id 列(多 Agent 并行执行数据层)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [36])?; tracing::info!("迁移 v36 完成"); Ok(()) } /// V37: conversation_checkpoints 表(对话版本化快照) fn migrate_v37(conn: &Connection) -> Result<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS conversation_checkpoints (\ id TEXT PRIMARY KEY,\ conv_id TEXT NOT NULL,\ snapshot TEXT NOT NULL,\ token_total INTEGER NOT NULL,\ label TEXT,\ created_at TEXT NOT NULL\ );\ CREATE INDEX IF NOT EXISTS idx_ck_conv_id \ ON conversation_checkpoints(conv_id, created_at DESC);", )?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [37])?; tracing::info!("迁移 v37 完成: 建 conversation_checkpoints 表"); Ok(()) } /// V38: ai_messages 加 prompt_tokens / completion_tokens 列(消息级 token 持久化) /// /// 解「压缩/切会话后历史 assistant 消息 token 不显」:原 token 仅前端内存态 /// (useAiEvents AiCompleted 设 tokenUsage),DB 仅会话级累计 /// (ai_conversations.prompt_tokens/completion_tokens)。本迁移加消息级两列, /// 让 push_assistant_message 设的本轮 token 经 save_conversation → AiMessageRecord /// 落库,前端 reload 时映射回 tokenUsage。NULL(老消息)→ 前端 tokenUsage=undefined(向前兼容)。 fn migrate_v38(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_messages", "prompt_tokens") { conn.execute("ALTER TABLE ai_messages ADD COLUMN prompt_tokens INTEGER", [])?; tracing::info!("v38: ai_messages 加 prompt_tokens 列"); } if !column_exists(conn, "ai_messages", "completion_tokens") { conn.execute("ALTER TABLE ai_messages ADD COLUMN completion_tokens INTEGER", [])?; tracing::info!("v38: ai_messages 加 completion_tokens 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [38])?; tracing::info!("迁移 v38 完成: ai_messages 加消息级 token 列"); Ok(()) } /// V39: ai_messages 加 prompt_cache_hit_tokens / prompt_cache_miss_tokens / reasoning_tokens 列 /// /// token 分项显示(2026-08-02):各 provider 计费不同(deepseek cache 命中低价/未命中全价/ /// 输出价高/reasoning 隐藏输出),前端 in/cache/out/reason 分项展示 + 详情面板。 /// - prompt_cache_hit_tokens:缓存命中(deepseek prompt_cache_hit / anthropic cache_read) /// - prompt_cache_miss_tokens:未命中全价(deepseek prompt_cache_miss / anthropic cache_creation) /// - reasoning_tokens:思考(deepseek-reasoner/o1 reasoning_tokens) /// 三列均 nullable,老消息 NULL → None(向前兼容,非 cache provider 恒 0)。 fn migrate_v39(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_messages", "prompt_cache_hit_tokens") { conn.execute("ALTER TABLE ai_messages ADD COLUMN prompt_cache_hit_tokens INTEGER", [])?; tracing::info!("v39: ai_messages 加 prompt_cache_hit_tokens 列"); } if !column_exists(conn, "ai_messages", "prompt_cache_miss_tokens") { conn.execute("ALTER TABLE ai_messages ADD COLUMN prompt_cache_miss_tokens INTEGER", [])?; tracing::info!("v39: ai_messages 加 prompt_cache_miss_tokens 列"); } if !column_exists(conn, "ai_messages", "reasoning_tokens") { conn.execute("ALTER TABLE ai_messages ADD COLUMN reasoning_tokens INTEGER", [])?; tracing::info!("v39: ai_messages 加 reasoning_tokens 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [39])?; tracing::info!("迁移 v39 完成: ai_messages 加 cache/reasoning 分项 token 列"); Ok(()) } /// V40: project_modules 表加 description / status 列(工程实体化,补全描述身份) /// /// 解「工程仅有目录地址、无描述身份」:Monorepo 下前端工程/后端服务/基础设施 /// 各有职责与状态,原表只存 name/path/git_url/stack,失去真实场景表达。 /// - description:工程职责描述(如"前端 web 工程""后端 API 服务"),nullable 老工程兼容。 /// - status:工程状态(active/archived),nullable,默认 active(老工程回读时 None 由 /// 应用层归一为 active——见 ProjectModuleRecord 注释)。 /// /// 列可空,向前兼容:V34 前无此列的老库 ALTER 后旧行 NULL,前端/应用层视为未填写。 fn migrate_v40(conn: &Connection) -> Result<()> { if !column_exists(conn, "project_modules", "description") { conn.execute("ALTER TABLE project_modules ADD COLUMN description TEXT", [])?; tracing::info!("v40: project_modules 加 description 列"); } if !column_exists(conn, "project_modules", "status") { conn.execute("ALTER TABLE project_modules ADD COLUMN status TEXT", [])?; tracing::info!("v40: project_modules 加 status 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [40])?; tracing::info!("迁移 v40 完成: project_modules 加 description/status 列(工程实体化)"); Ok(()) } /// V41: 幂等补 tasks.module_id 列(任务关联到具体工程 module) /// /// 工程系统打底(V34 project_modules 表):一个项目可含多个工程(Monorepo 多仓库 / /// 微服务 / 前后端分离),任务此前只能关联到项目(project_id),本列让任务落到具体 /// 工程,是后续 IPC / 前端 / AI 按工程筛选任务的数据基础。TEXT NULL 向后兼容: /// 老任务无关联 → None(TaskRecord 字段为 Option)。 /// /// 外键 REFERENCES project_modules(id) ON DELETE SET NULL:module 被删除时该任务 /// module_id 自动置 NULL 解关联(不阻塞删除、不留悬挂引用)。新列默认值 NULL, /// 存量行全为 NULL,foreign_keys=ON 下 ALTER ADD COLUMN 不报错(SQLite 要求 /// 带 REFERENCES 的新列默认值为 NULL)。用 PRAGMA 探测列存在性,缺失才 ALTER /// (同 v20/v29 模式),对新库/老库/坏库均安全。 fn migrate_v41(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "module_id") { conn.execute( "ALTER TABLE tasks ADD COLUMN module_id TEXT REFERENCES project_modules(id) ON DELETE SET NULL", [], )?; tracing::info!("v41: 补建 tasks.module_id 列(任务关联工程)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [41])?; tracing::info!("迁移 v41 完成"); Ok(()) } /// V42: ai_messages 加 is_estimated 列(消息级估算标记) /// /// 消息级 token 持久化(V38/V39)已落 prompt/completion/cache/reasoning,但「该轮 prompt 是否 /// estimated 兜底」未存——reload 逐条回显时无法对齐 live 态 AiCompleted.is_estimated 标注。 /// 本迁移补 INTEGER 列(NULL=老消息未标记,向前兼容;0=false 真实,1=true 估算)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v38/v39 模式),对新库/老库均安全。 fn migrate_v42(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_messages", "is_estimated") { conn.execute("ALTER TABLE ai_messages ADD COLUMN is_estimated INTEGER", [])?; tracing::info!("v42: ai_messages 加 is_estimated 列(消息级估算标记)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [42])?; tracing::info!("迁移 v42 完成: ai_messages 加 is_estimated 列"); Ok(()) } /// V43: 存量知识一次性晋升 — candidate 且 confidence='high' 批量转 published /// /// 决策单 B4①(docs/02-架构设计/专项设计/aichat上下文管理-派工与决策单-2026-09-29.md ①, /// 2026-09-29)。DB 实测:knowledges 表 candidate 967 条(其中 confidence='high' 854 条) /// 长期滞留,而检索侧(search/vector/top_reused)只认 published(53 条)——AI 提炼的知识 /// 从未进入检索面,知识库沉淀近乎零生效。本迁移把存量高置信 candidate 一次性晋升, /// 与代码侧「复用即信任」(KnowledgeRepo::auto_publish_if_reused)互补:前者清历史存量, /// 后者管增量沉淀。 /// /// 设计要点: /// - **纯数据 UPDATE,不经过 promote 函数**:不触发嵌入生成(854 条不会在启动时批量调 /// 嵌入 API);embedding NULL 的条目检索走 LIKE 降级,向量面由后续复用/晋升路径逐步补 /// ——这是有意为之,非遗漏。也不写 updated_at(非内容变更,不伪造新近度)。 /// - **幂等双保险**:schema_version 版本号保证只跑一次(run() 按 current_version<43 跳过); /// 即便崩溃重跑/版本号回退重调本函数,WHERE 条件天然幂等(已晋升行不再命中,UPDATE /// 影响 0 行),版本号 INSERT OR IGNORE 防 PK 冲突(同 V21/V32 崩溃重跑防御模式)。 /// - **可逆方式**(备用,当前不执行):本迁移不落版本特征列,knowledges.updated_at 由业务 /// 路径维护、无法精确圈定本次影响集,可逆按晋升前后 confidence 不变这一事实圈定: /// `UPDATE knowledges SET status='candidate' WHERE status='published' AND confidence='high'` /// ——注意此语句会连带回退「非本迁移产生的 published+high」条目(人工放行/复用达标晋升的 /// 高置信条目),执行前需以 knowledge_events 审计表核对时间窗。 fn migrate_v43(conn: &Connection) -> Result<()> { let affected = conn.execute( "UPDATE knowledges SET status = 'published' \ WHERE status = 'candidate' AND confidence = 'high'", [], )?; conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [43])?; tracing::info!("迁移 v43 完成(存量知识 candidate+confidence=high 晋升 published,共 {} 条)", affected); Ok(()) } /// V44: 子代理轨迹来源标记 — ai_conversations 加 origin TEXT 列。 /// /// 子代理会话(spawn_agent)从「纯内存零落库」改为出口整轨迹落库(二期池①),落库时 /// origin='sub_agent' 随首插写入,桌面侧栏与 miniapp 会话列表按此过滤——对话本体可追溯 /// 而不污染主会话列表。主会话 origin 恒为 NULL(业务层 None),无其他写入点。 /// - **幂等**:column_exists 守卫(同 V42 模式),崩溃重跑/全链两遍不炸; /// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN origin`(SQLite 3.35+)。 fn migrate_v44(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_conversations", "origin") { conn.execute("ALTER TABLE ai_conversations ADD COLUMN origin TEXT", [])?; tracing::info!("v44: ai_conversations 加 origin 列(子代理轨迹来源标记)"); } conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [44])?; tracing::info!("迁移 v44 完成: ai_conversations 加 origin 列"); Ok(()) } /// V45: 无人值守任务边界 — ai_conversations 加 boundary_json TEXT 列。 /// /// TaskBoundary JSON 快照(风险上限/目录白名单/禁破坏性/预算),发起时一次声明、 /// 任务存续期生效、随会话同亡(无独立表,设计拍板点 2: 任务与会话 1:1,独立表过度设计)。 /// NULL=未声明(现状逐次审批,渐进增强零倒退)。 /// - **幂等**:column_exists 守卫(同 V42/V44 模式),崩溃重跑/全链两遍不炸; /// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN boundary_json`(SQLite 3.35+)。 fn migrate_v45(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_conversations", "boundary_json") { conn.execute("ALTER TABLE ai_conversations ADD COLUMN boundary_json TEXT", [])?; tracing::info!("v45: ai_conversations 加 boundary_json 列(无人值守任务边界)"); } conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [45])?; tracing::info!("迁移 v45 完成: ai_conversations 加 boundary_json 列"); Ok(()) } /// V46: 幂等补 tasks.archived 列(任务归档,归档功能 P1) /// /// 归档语义对标 ai_conversations.archived(V4):纯元数据标记,与 status/queue/deleted_at 正交。 /// 「作废」走 status=cancelled,「完结」走 done/cancelled——归档是另一种动作: /// 低频完结任务移出主视图但保留检索(回收站=删除缓冲不可见,归档=低频可见保检索)。 /// - `INTEGER NOT NULL DEFAULT 0`:老库行迁移后全为 0(未归档),行为零变化; /// - 不进 update_field 白名单(settings.rs):归档走专用 set_archived(带埋点 + 布尔语义), /// 对标 status 收口思路防旁路写; /// - 活跃查询(list_active/list_by_query/count_by_query/list_active_by_project/get_children/ /// advance_status_atomic/move_task_queue)恒带 `archived = 0` 过滤,归档任务退出主视图与 /// 状态机(冻结:不可推进/不可跨池/不可字段更新,需先取消归档)。 /// - **幂等**:column_exists 守卫(同 V45 模式);**可逆**:`ALTER TABLE tasks DROP COLUMN archived`。 fn migrate_v46(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "archived") { conn.execute( "ALTER TABLE tasks ADD COLUMN archived INTEGER NOT NULL DEFAULT 0", [], )?; tracing::info!("v46: 补建 tasks.archived 列(任务归档)"); } conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [46])?; tracing::info!("迁移 v46 完成: tasks 加 archived 列"); Ok(()) } /// V47: 幂等补 ideas.archived 列(灵感归档,归档功能 P1) /// /// 对标 V46 tasks.archived:rejected/deferred/已晋升灵感一键归档移出主视图,保留检索。 /// 灵感无状态机收口(status 走 idea_repo 通用路径),但归档同样不进 update_field 白名单, /// 走专用 set_archived(布尔语义 + 防旁路)。活跃查询恒带 `archived = 0`。 /// - **幂等**:column_exists 守卫;**可逆**:`ALTER TABLE ideas DROP COLUMN archived`。 fn migrate_v47(conn: &Connection) -> Result<()> { if !column_exists(conn, "ideas", "archived") { conn.execute( "ALTER TABLE ideas ADD COLUMN archived INTEGER NOT NULL DEFAULT 0", [], )?; tracing::info!("v47: 补建 ideas.archived 列(灵感归档)"); } conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [47])?; tracing::info!("迁移 v47 完成: ideas 加 archived 列"); Ok(()) } /// V48: 幂等补 projects.archived 列(项目归档,归档功能 P2) /// /// 对标 V46 tasks / V47 ideas 同款:归档与软删正交(回收站=删除缓冲,归档=低频可见保检索)。 /// archived 不进 update_field 通用白名单,走专用 set_archived(布尔语义 + 防旁路)。 /// 活跃查询(list_active/list_active_with_activity/list_by_query)恒带 `archived = 0`。 /// - **幂等**:column_exists 守卫;**可逆**:`ALTER TABLE projects DROP COLUMN archived`。 fn migrate_v48(conn: &Connection) -> Result<()> { if !column_exists(conn, "projects", "archived") { conn.execute( "ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0", [], )?; tracing::info!("v48: 补建 projects.archived 列(项目归档)"); } conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [48])?; tracing::info!("迁移 v48 完成: projects 加 archived 列"); Ok(()) } /// V49: 幂等补 ai_conversations.loop_state 列(断点续跑底座) /// /// loop_state TEXT NULL:None=非任务会话/已收敛;"running"=任务 loop 在跑(spawn 时置起); /// "parked"=重启扫描时 running 残留改写(侧栏标「任务中断可恢复」+ 一键恢复)。 /// 对齐无人值守长任务设计 §4.5 拍板 A(不自动续跑,标记+一键恢复)。 /// - **幂等**:column_exists 守卫;**可逆**:`ALTER TABLE ai_conversations DROP COLUMN loop_state`。 fn migrate_v49(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_conversations", "loop_state") { conn.execute( "ALTER TABLE ai_conversations ADD COLUMN loop_state TEXT", [], )?; tracing::info!("v49: 补建 ai_conversations.loop_state 列(断点续跑标记)"); } conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [49])?; tracing::info!("迁移 v49 完成: ai_conversations 加 loop_state 列"); Ok(()) } /// V50: 使用数据分析 schema 债补全(2026-10-04 实证三笔,归「V49 schema 迁移批」)。 /// /// 一次迁移承载三项相关改动,均为纯粹加列/回填,不改既有列语义: /// /// ① ai_conversations.status TEXT NOT NULL DEFAULT 'active' —— 会话终局态。 /// 原仅靠侧栏内消息尾部推断「是否收尾」(17/89 会话停在 tool 结果无 assistant 收尾), /// 无法直接统计未完成率。新增列建模终局: /// active=进行中/默认(含正常完成,完成态精确写入待收尾单点接驳),incomplete=停靠/ /// 断流等未完成终局,error=循环错误终局。与 loop_state(运行期 running/parked)正交: /// loop_state 管「能否续跑」,status 管「会话如何结束」。 /// ② ai_tool_executions.duration_ms INTEGER —— 纯执行耗时(毫秒)。 /// 原审计只有 requested_at→executed_at 区间,该区间在审批路径混入「等待人工批准」 /// 时长,耗时统计不可信。本列专记工具真正执行的墙钟毫秒(执行前→执行后),与审批 /// 等待解耦;NULL=老数据/未测路径(向后兼容)。 /// ③ conversation_checkpoints.token_total 回填 —— 原写入侧误用「消息条数」当 token 总量 /// (p50=120 vs 真实数万级,差 3 个量级)。写入侧已修为真实 token 累加(见 save 侧); /// 本迁移对历史行按 snapshot 内逐条 prompt_tokens+completion_tokens 重算回填,使存量 /// 可比。仅当重算结果 > 0 才写(N 老快照无消息级 token 时保留原值不误清零)。 /// /// - **幂等**:①②`column_exists` 守卫,重复执行不报 duplicate column;③重算确定(同输入同输出), /// 反复执行结果一致,且经 schema_version 版本号闸只跑一次。 /// - **可逆**:①`ALTER TABLE ai_conversations DROP COLUMN status`; /// ②`ALTER TABLE ai_tool_executions DROP COLUMN duration_ms`; /// ③无回退(DROP COLUMN 不会还原被覆盖的 token_total 旧值)——如需回退重建快照表 /// (老值本就失真无保留价值)。 fn migrate_v50(conn: &Connection) -> Result<()> { // ① 会话终局态列(NOT NULL 常量默认,SQLite ALTER 合法)。 if table_exists(conn, "ai_conversations") && !column_exists(conn, "ai_conversations", "status") { conn.execute( "ALTER TABLE ai_conversations ADD COLUMN status TEXT NOT NULL DEFAULT 'active'", [], )?; tracing::info!("v50: 补建 ai_conversations.status 列(会话终局态)"); } // ② 纯执行耗时列(NULL=老数据/未测路径)。 if table_exists(conn, "ai_tool_executions") && !column_exists(conn, "ai_tool_executions", "duration_ms") { conn.execute( "ALTER TABLE ai_tool_executions ADD COLUMN duration_ms INTEGER", [], )?; tracing::info!("v50: 补建 ai_tool_executions.duration_ms 列(纯执行耗时)"); } // ③ 存量 checkpoint token_total 按 snapshot 重算回填。 backfill_checkpoint_token_total(conn)?; conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [50])?; tracing::info!("迁移 v50 完成: status/duration_ms 加列 + checkpoint token_total 回填"); Ok(()) } /// V51: 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。 /// /// 对标 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。 /// 队列(931)与定时消息(935)本质是同一实体、不同触发条件,统一落本表: /// 队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。 /// /// 选独立表而非 app_settings KV:outbox 需按 status+due_at 索引扫描、按 id 逐条 CAS 更新, /// KV 整块 JSON 读改写有并发改期/取消互相覆盖的风险且不可索引。 /// /// - **幂等**:table_exists 守卫建表(同 V29/V30/V31 模式),索引 CREATE INDEX IF NOT EXISTS /// 原生幂等;版本号 INSERT OR IGNORE 防崩溃重跑 PK 冲突。全链重跑/断点续跑均安全。 /// - **可逆**(回退方式,当前不执行):本表纯新增、不触碰旧数据,回退即 /// `DROP TABLE ai_outbox`(同时关调度器开关 df-outbox-enabled=false),无需数据搬迁。 /// - **状态落点**:pending|sending|sent|failed|canceled 由调度器按项逐条 CAS 写,不依赖全局 /// 内存推算(V21 教训:全局 COUNT 幂等窗口中途崩溃致永久残缺)。崩溃残留的 sending 由 /// 调度器启动时 reset_stale_sending 复位为 pending。 /// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败 /// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。 pub const LATEST_SCHEMA_VERSION: i32 = 53; fn migrate_v51(conn: &Connection) -> Result<()> { if !table_exists(conn, "ai_outbox") { conn.execute( "CREATE TABLE ai_outbox ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'queue', text TEXT NOT NULL, skill TEXT, model_override TEXT, parts_json TEXT, spans_json TEXT, due_at INTEGER, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, sent_at INTEGER )", [], )?; tracing::info!("v51: 建 ai_outbox 表(待发箱)"); } // 索引覆盖两条最高频扫描:调度器 list_due(status+due_at)/ 前端按会话取视图(conversation_id+status)。 conn.execute( "CREATE INDEX IF NOT EXISTS idx_ai_outbox_due ON ai_outbox(status, due_at)", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_ai_outbox_conv ON ai_outbox(conversation_id, status)", [], )?; conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [51])?; tracing::info!("迁移 v51 完成: ai_outbox 表 + 两索引(定时消息与待发箱持久化)"); Ok(()) } /// V52: ai_messages 加 source 列(#958 消息溯源)。TEXT JSON(MessageSource 序列化), /// NULL=桌面本端。纯加列零回退风险(老数据恒 NULL=既有语义)。 fn migrate_v52(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_messages", "source") { conn.execute("ALTER TABLE ai_messages ADD COLUMN source TEXT", [])?; tracing::info!("v52: ai_messages 加 source 列(消息溯源)"); } conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [52])?; tracing::info!("迁移 v52 完成"); Ok(()) } /// V53: WorkingContext 持久化 — ai_conversations 加 working_context_json TEXT 列。 /// /// 跨会话召回 L4(重启不失忆):WorkingContext(自动目标/决策/未解项/当前步骤/脏标记版本) /// 此前纯内存,重启后压缩重锚定回填的决策与未解项全丢,恢复的会话半失忆续跑。本列随 save /// 每轮随行写、会话载入与 parked 恢复读还原;NULL=无上下文(老会话/全空),行为同旧。 /// 目标快照裁决语义另批,本列只做存储载体。 /// - **幂等**:column_exists 守卫(同 V44/V45/V49 模式),崩溃重跑/全链两遍不炸; /// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN working_context_json`(SQLite 3.35+)。 fn migrate_v53(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_conversations", "working_context_json") { conn.execute( "ALTER TABLE ai_conversations ADD COLUMN working_context_json TEXT", [], )?; tracing::info!("v53: 补建 ai_conversations.working_context_json 列(WorkingContext 持久化)"); } conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [53])?; tracing::info!("迁移 v53 完成: ai_conversations 加 working_context_json 列"); Ok(()) } /// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的 /// prompt_tokens+completion_tokens 求真实总量。 /// /// 幂等:重算确定,重复执行相同结果;仅当重算值 > 0 且与现值不同才 UPDATE /// (老快照无消息级 token → 重算 0 → 保留原值,不误清零)。表/列不存在时静默跳过 /// (极老库未跑到 V37,或手工删表)。 fn backfill_checkpoint_token_total(conn: &Connection) -> Result<()> { // 表存在性探测:未跑到 V37 的极老库无此表,PRAGMA 返回空即跳过。 if !column_exists(conn, "conversation_checkpoints", "token_total") { return Ok(()); } // 收集待回填行到内存再逐条 UPDATE:避免同一连接上「先 prepare 读游标、再同连接 execute 写」 // 造成的隐式锁/游标失效,分批小表可接受。 let rows: Vec<(String, String, i64)> = { let mut stmt = conn.prepare( "SELECT id, snapshot, token_total FROM conversation_checkpoints", )?; let mapped = stmt.query_map([], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?)) })?; mapped.filter_map(|r| r.ok()).collect() }; let mut updated = 0usize; for (id, snapshot, old_total) in rows { let recomputed = sum_snapshot_tokens(&snapshot); if recomputed > 0 && recomputed != old_total { conn.execute( "UPDATE conversation_checkpoints SET token_total = ?1 WHERE id = ?2", rusqlite::params![recomputed, id], )?; updated += 1; } } if updated > 0 { tracing::info!("v50: checkpoint token_total 回填 {updated} 行(消息条数→真实 token)"); } Ok(()) } /// 从消息 JSON 数组求 token 总量:逐条取 prompt_tokens + completion_tokens(UInt64) /// 求和。字段缺失/类型不符按 0 计(老消息无消息级 token → 贡献 0)。解析失败返回 0。 /// /// 用 `serde_json::Value` 裸取(df-storage 不依赖 df-ai-core 的 ChatMessage,同 V21 迁移模式)。 fn sum_snapshot_tokens(snapshot: &str) -> i64 { let Ok(msgs) = serde_json::from_str::>(snapshot) else { return 0; }; msgs.iter() .map(|m| { let p = m.get("prompt_tokens").and_then(|v| v.as_i64()).unwrap_or(0); let c = m.get("completion_tokens").and_then(|v| v.as_i64()).unwrap_or(0); p + c }) .sum() } /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 改动须两边同步。 const V21_SQL: &str = " CREATE TABLE IF NOT EXISTS ai_messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, seq INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', parts TEXT, tool_call_id TEXT, tool_calls TEXT, model TEXT, status TEXT NOT NULL DEFAULT 'active', reasoning_content TEXT, timestamp INTEGER, created_at TEXT NOT NULL, prompt_tokens INTEGER, completion_tokens INTEGER, prompt_cache_hit_tokens INTEGER, prompt_cache_miss_tokens INTEGER, reasoning_tokens INTEGER, is_estimated INTEGER, UNIQUE(conversation_id, seq) ); CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id, seq); "; /// V22 建表 SQL — 灵感评估历史(追加型审计表) /// /// 8 列:id(主键)/ idea_id(关联灵感)/ version(评估版本号,单调递增)/ /// ai_analysis(AI 分析结果 JSON,可空)/ scores(多维评分 JSON,可空)/ /// score(综合评分 REAL,可空)/ evaluated_by(评估者,可空)/ evaluated_at(毫秒字符串)。 /// 索引:(idea_id, version DESC) 覆盖「取某灵感最新评估」最高频查询。 const V22_SQL: &str = " CREATE TABLE IF NOT EXISTS idea_evaluations ( id TEXT PRIMARY KEY, idea_id TEXT NOT NULL, version INTEGER NOT NULL, ai_analysis TEXT, scores TEXT, score REAL, evaluated_by TEXT, evaluated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_idea_evaluations_idea ON idea_evaluations(idea_id, version DESC); "; /// V1 建表 SQL const V1_SQL: &str = " -- 想法表 CREATE TABLE IF NOT EXISTS ideas ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'draft', priority INTEGER NOT NULL DEFAULT 1, score REAL, tags TEXT, source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -- 项目表 CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'planning', idea_id TEXT REFERENCES ideas(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -- 任务表 CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'todo', -- priority 默认 2 对齐 task.rs default_priority()=2(medium) priority INTEGER NOT NULL DEFAULT 2, branch_name TEXT, assignee TEXT, -- 任务关联灵感(1对1 单向,复用 projects.idea_id 模式)。老库由 V20 迁移补列。 idea_id TEXT REFERENCES ideas(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -- 发布表 CREATE TABLE IF NOT EXISTS releases ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), version TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'planned', task_ids TEXT NOT NULL DEFAULT '[]', changelog TEXT, created_at TEXT NOT NULL, released_at TEXT ); -- 工作流执行表 CREATE TABLE IF NOT EXISTS workflow_executions ( id TEXT PRIMARY KEY, name TEXT NOT NULL, dag_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', triggered_by TEXT, created_at TEXT NOT NULL, completed_at TEXT ); -- 节点执行表 CREATE TABLE IF NOT EXISTS node_executions ( id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL REFERENCES workflow_executions(id), node_id TEXT NOT NULL, node_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', input_json TEXT, output_json TEXT, error_message TEXT, started_at TEXT, completed_at TEXT ); -- 索引 CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); -- V26 补建(索引缺口):priority/assignee 过滤下推索引,新库一次性建; -- 老库由 migrate_v26 CREATE INDEX IF NOT EXISTS 补建,两边索引定义须一致。 CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee); CREATE INDEX IF NOT EXISTS idx_releases_project_id ON releases(project_id); CREATE INDEX IF NOT EXISTS idx_node_executions_workflow_id ON node_executions(workflow_id); "; /// V2 迁移 SQL — 补齐数据层断裂字段 /// /// 幂等化(2026-08-05):原含 7 个裸 ALTER(ideas/tasks/workflow 加列),全链重跑 duplicate column。 /// ALTER 已移至 migrate_v2 逐列 column_exists 守卫,此处仅保留 branches CREATE + 索引(IF NOT EXISTS 幂等)。 const V2_SQL: &str = " -- 分支表 — 任务与 Git 分支绑定(核心功能) CREATE TABLE IF NOT EXISTS branches ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), task_id TEXT REFERENCES tasks(id), name TEXT NOT NULL, base TEXT NOT NULL DEFAULT 'main', status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_at TEXT ); -- 索引 CREATE INDEX IF NOT EXISTS idx_branches_project_id ON branches(project_id); CREATE INDEX IF NOT EXISTS idx_branches_task_id ON branches(task_id); "; /// V3 迁移 SQL — AI 对话表补建(新库首次创建;老库 IF NOT EXISTS 跳过) /// /// 注:archived 列不在此处 ALTER —— 由 v4 迁移幂等补建。 /// (历史 v3 曾写入版本号但 ALTER 未生效,统一交 v4 用 PRAGMA 探测修复) const V3_SQL: &str = " CREATE TABLE IF NOT EXISTS ai_conversations ( id TEXT PRIMARY KEY, title TEXT, messages TEXT NOT NULL DEFAULT '[]', provider_id TEXT, model TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); "; /// V7 建表 SQL — 知识库表 /// /// kind: 7 种 KnowledgeKind snake_case(review_rule/prompt_template/pitfall/ /// architecture_pattern/diagnosis/deployment_note/workflow_optimization) /// status: candidate|pending_review|published|archived /// confidence: high|medium|low(AI 提炼自评,可空) /// verified: 发布审核时一次性人工标(INTEGER 0/1) /// reuse_count: 检索命中自动 +1(唯一客观排序信号) /// source_project/source_ref: 来源溯源(不过滤,仅展示) const V7_SQL: &str = " CREATE TABLE IF NOT EXISTS knowledges ( id TEXT PRIMARY KEY, kind TEXT NOT NULL DEFAULT 'pitfall', title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', tags TEXT, status TEXT NOT NULL DEFAULT 'candidate', confidence TEXT, reuse_count INTEGER NOT NULL DEFAULT 0, verified INTEGER NOT NULL DEFAULT 0, source_project TEXT, source_ref TEXT, -- V23 补列(嵌入失败可补偿重试):新库直接带列,老库由 migrate_v23 ALTER 补; -- 两边列定义须一致(老库迁移注释 V23 已注明)。 embedding_status TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_knowledges_status ON knowledges(status); CREATE INDEX IF NOT EXISTS idx_knowledges_kind ON knowledges(kind); CREATE INDEX IF NOT EXISTS idx_knowledges_reuse_count ON knowledges(reuse_count DESC); "; /// V9 建表 SQL — AI Provider 配置 + 工具执行审计 /// /// 历史遗漏补建:ai_providers(AI 提供商配置) + ai_tool_executions(工具调用审计记录)。 /// CREATE TABLE IF NOT EXISTS 保证老库(已有表)和新库(缺表)均安全。 const V9_SQL: &str = " CREATE TABLE IF NOT EXISTS ai_providers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, provider_type TEXT NOT NULL DEFAULT 'openai_compat', api_key TEXT NOT NULL, base_url TEXT NOT NULL, default_model TEXT NOT NULL, models TEXT, is_default INTEGER NOT NULL DEFAULT 0, config TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, model_configs TEXT, enabled INTEGER NOT NULL DEFAULT 1, weight INTEGER NOT NULL DEFAULT 50 ); CREATE TABLE IF NOT EXISTS ai_tool_executions ( id TEXT PRIMARY KEY, conversation_id TEXT, message_id TEXT, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL, arguments TEXT NOT NULL, result TEXT, status TEXT NOT NULL DEFAULT 'pending', risk_level TEXT NOT NULL DEFAULT 'medium', requested_at TEXT NOT NULL, executed_at TEXT, decided_by TEXT ); -- 消息拆分存储:每条 ChatMessage 一行的独立表。 -- 与 V21 迁移建表 SQL 镜像(V21 用于老库 ALTER,此处给新库直接建最终态)。 -- 改动须两边同步(V21_SQL 见下方)。 CREATE TABLE IF NOT EXISTS ai_messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, seq INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', parts TEXT, tool_call_id TEXT, tool_calls TEXT, model TEXT, status TEXT NOT NULL DEFAULT 'active', reasoning_content TEXT, timestamp INTEGER, created_at TEXT NOT NULL, prompt_tokens INTEGER, completion_tokens INTEGER, prompt_cache_hit_tokens INTEGER, prompt_cache_miss_tokens INTEGER, reasoning_tokens INTEGER, is_estimated INTEGER, UNIQUE(conversation_id, seq) ); CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id, seq); "; /// V10 建表 SQL — 知识生命线事件表 /// /// 追加型审计表(只增不改),记录知识产生/审核/引用/归档四类事件,支撑生命线视图。 /// event_type: created | extracted | status_changed | referenced | archived /// context_json: 因 event_type 而异的上下文(如引用事件的 conv_id+query)。 const V10_SQL: &str = " CREATE TABLE IF NOT EXISTS knowledge_events ( id TEXT PRIMARY KEY, knowledge_id TEXT NOT NULL, event_type TEXT NOT NULL, source_ref TEXT, context_json TEXT, timestamp TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_knowledge_events_kid ON knowledge_events(knowledge_id); CREATE INDEX IF NOT EXISTS idx_knowledge_events_type ON knowledge_events(event_type); CREATE INDEX IF NOT EXISTS idx_knowledge_events_kid_type ON knowledge_events(knowledge_id, event_type); "; /// V13 建表 SQL — 通用应用设置 KV 表 /// /// 前端 localStorage 迁移目标:key/value(JSON 字符串)+ updated_at。 /// CREATE TABLE IF NOT EXISTS 幂等(新库建、老库已有则跳过)。 const V13_SQL: &str = " CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL ); "; // ============================================================ // 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态) // ============================================================ #[cfg(test)] mod tests { use super::*; use rusqlite::Connection; /// 构造最小老库 schema:ai_conversations 表(含 messages JSON 列)+ schema_version 表。 /// 不跑 V1-V19(测试聚焦 V21 单步行为),手动建最小依赖表。 fn setup_legacy_db() -> Connection { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE ai_conversations ( id TEXT PRIMARY KEY, title TEXT, messages TEXT NOT NULL DEFAULT '[]', provider_id TEXT, model TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL );", ) .expect("create legacy tables"); conn } /// 断言 ai_messages 表存在 + 列齐全 fn assert_ai_messages_schema(conn: &Connection) { assert!(column_exists(conn, "ai_messages", "id")); assert!(column_exists(conn, "ai_messages", "conversation_id")); assert!(column_exists(conn, "ai_messages", "seq")); assert!(column_exists(conn, "ai_messages", "role")); assert!(column_exists(conn, "ai_messages", "content")); assert!(column_exists(conn, "ai_messages", "status")); assert!(column_exists(conn, "ai_messages", "created_at")); } /// 新库空跑:无 ai_conversations 数据,迁移应建表 + 写版本号 + 不崩 + ai_messages 空 #[test] fn v21_new_db_empty_runs_clean() { let conn = setup_legacy_db(); migrate_v21(&conn).expect("v21 应在新库空跑成功"); assert_ai_messages_schema(&conn); // ai_tool_executions.message_id 列已补建 // 注:setup 未建 ai_tool_executions 表,column_exists 对不存在表返回 false。 // 此处验证迁移不因表不存在而崩(函数内 ALTER 被 column_exists 短路)。 let count: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 0, "新库空跑 ai_messages 应为空"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 21, "应写入版本号 21"); } /// 老库有数据:正确迁移 messages JSON → ai_messages,字段全提取 #[test] fn v21_legacy_db_migrates_messages() { let conn = setup_legacy_db(); // 插入一条对话,messages 含 3 条消息(覆盖 user/assistant/tool + 各字段) let messages_json = serde_json::json!([ {"role": "user", "content": "你好", "timestamp": 1718800000000i64}, {"role": "assistant", "content": "你好,有什么可以帮你?", "model": "glm-4", "reasoning_content": "思考中"}, {"role": "tool", "content": "工具结果", "tool_call_id": "call_abc", "tool_calls": [{"id": "call_abc"}]} ]).to_string(); conn.execute( "INSERT INTO ai_conversations (id, title, messages, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)", rusqlite::params!["conv_1", "测试", messages_json, "1718800000000", "1718800000000"], ) .unwrap(); migrate_v21(&conn).expect("v21 应成功迁移"); let count: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 3, "应迁移 3 条消息"); // 校验 seq 递增 + 字段提取 let mut stmt = conn .prepare("SELECT seq, role, content, model, tool_call_id, status, created_at FROM ai_messages WHERE conversation_id = 'conv_1' ORDER BY seq") .unwrap(); let rows: Vec<(i64, String, String, Option, Option, String, String)> = stmt .query_map([], |r| { Ok(( r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, )) }) .unwrap() .map(|r| r.unwrap()) .collect(); assert_eq!(rows.len(), 3); assert_eq!(rows[0].0, 0); // seq assert_eq!(rows[0].1, "user"); assert_eq!(rows[0].2, "你好"); assert_eq!(rows[0].5, "active", "无 status → 归一化为 active"); assert_eq!(rows[0].6, "1718800000000", "有 timestamp → created_at 用它"); assert_eq!(rows[1].0, 1); assert_eq!(rows[1].1, "assistant"); assert_eq!(rows[1].3.as_deref(), Some("glm-4")); assert_eq!(rows[1].6, "1718800000000", "assistant 无 timestamp → fallback conv created_at"); assert_eq!(rows[2].0, 2); assert_eq!(rows[2].1, "tool"); assert_eq!(rows[2].4.as_deref(), Some("call_abc")); } /// 坏数据:messages JSON 解析失败 → 该对话跳过,不中断整体迁移 #[test] fn v21_bad_json_skipped_not_crash() { let conn = setup_legacy_db(); // 坏数据对话 conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('bad', '{not valid json', '0', '0')", [], ) .unwrap(); // 正常对话 let good = serde_json::json!([{"role": "user", "content": "好"}]).to_string(); conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('good', ?1, '0', '0')", rusqlite::params![good], ) .unwrap(); migrate_v21(&conn).expect("坏数据不应中断迁移"); let count: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 1, "仅正常对话的 1 条被迁移"); // 坏数据对话在 ai_messages 无记录 let bad_count: i64 = conn .query_row( "SELECT COUNT(*) FROM ai_messages WHERE conversation_id = 'bad'", [], |r| r.get(0), ) .unwrap(); assert_eq!(bad_count, 0); } /// 幂等重跑:第二次 migrate_v21 按逐对话进度判定跳过已迁对话,不重复迁移 #[test] fn v21_idempotent_rerun() { let conn = setup_legacy_db(); let msgs = serde_json::json!([{"role": "user", "content": "hi"}]).to_string(); conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c', ?1, '0', '0')", rusqlite::params![msgs], ) .unwrap(); migrate_v21(&conn).expect("首次迁移"); let count_after_first: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!(count_after_first, 1); // 第二次跑:逐对话进度判定命中 → 跳过,不重复 migrate_v21(&conn).expect("二次迁移应幂等成功"); let count_after_second: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!(count_after_second, 1, "重跑不应重复插入"); // 版本号不重复写(schema_version version 是 PK,migrate_v21 用 INSERT OR IGNORE // 防崩溃重跑 PK 冲突) let v_count: i64 = conn .query_row( "SELECT COUNT(*) FROM schema_version WHERE version = 21", [], |r| r.get(0), ) .unwrap(); assert_eq!(v_count, 1, "版本号 21 应只写一次"); } /// 断点续迁:批次中途崩溃后 ai_messages 已有部分对话数据、版本号未写, /// 重跑必须补齐未迁对话,不得因「表已有数据」整段跳过。 /// 旧实现全局 COUNT>0 即跳过全部数据迁移,此场景下 conv_b 永不再迁而版本号照样补齐。 #[test] fn v21_crash_midway_resumes_missing_convs() { let conn = setup_legacy_db(); let msgs_a = serde_json::json!([ {"role": "user", "content": "a1"}, {"role": "assistant", "content": "a2"} ]) .to_string(); let msgs_b = serde_json::json!([{"role": "user", "content": "b1"}]).to_string(); conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('conv_a', ?1, '0', '0')", rusqlite::params![msgs_a], ) .unwrap(); conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('conv_b', ?1, '0', '0')", rusqlite::params![msgs_b], ) .unwrap(); // 模拟崩溃断点:表已建(V21_SQL,首次跑 migrate_v21 的产物)、conv_a 已按迁移格式 // 落库(其批次已 commit),conv_b 未迁,版本号未写 conn.execute_batch(V21_SQL).unwrap(); for seq in 0..2i64 { conn.execute( "INSERT INTO ai_messages (id, conversation_id, seq, role, content, status, created_at) VALUES (?1, 'conv_a', ?2, 'user', ?3, 'active', '0')", rusqlite::params![ format!("msg_migrated_conv_a_{}", seq), seq, format!("a{}", seq) ], ) .unwrap(); } let version_before: i64 = conn .query_row( "SELECT COUNT(*) FROM schema_version WHERE version = 21", [], |r| r.get(0), ) .unwrap(); assert_eq!(version_before, 0, "崩溃断点:版本号尚未写入"); migrate_v21(&conn).expect("重跑应补齐未迁对话"); // conv_b 被补迁(旧实现此处为 0 —— 被 COUNT>0 整段跳过) let b_count: i64 = conn .query_row( "SELECT COUNT(*) FROM ai_messages WHERE conversation_id = 'conv_b'", [], |r| r.get(0), ) .unwrap(); assert_eq!(b_count, 1, "未迁对话应被补齐"); // conv_a 不重复迁移 let a_count: i64 = conn .query_row( "SELECT COUNT(*) FROM ai_messages WHERE conversation_id = 'conv_a'", [], |r| r.get(0), ) .unwrap(); assert_eq!(a_count, 2, "已迁对话不应重复插入"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 21, "重跑后版本号应补齐"); } /// 批次边界断点续迁:55 个对话分两批(每批 50),首批已 commit 后崩溃, /// 重跑应只补剩余批次,全部对话迁移完整且无重复。 #[test] fn v21_batch_boundary_crash_resumes_remaining_batch() { let conn = setup_legacy_db(); let total = 55usize; let batch_done = 50usize; for i in 0..total { let msgs = serde_json::json!([ {"role": "user", "content": format!("q{}", i)}, {"role": "assistant", "content": format!("r{}", i)} ]) .to_string(); conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES (?1, ?2, '0', '0')", rusqlite::params![format!("conv_{:03}", i), msgs], ) .unwrap(); } // 模拟崩溃断点:表已建(V21_SQL)、首批 50 对话已按迁移格式落库(批次已 commit),其余未迁 conn.execute_batch(V21_SQL).unwrap(); for i in 0..batch_done { for seq in 0..2i64 { conn.execute( "INSERT INTO ai_messages (id, conversation_id, seq, role, content, status, created_at) VALUES (?1, ?2, ?3, 'user', '', 'active', '0')", rusqlite::params![ format!("msg_migrated_conv_{:03}_{}", i, seq), format!("conv_{:03}", i), seq ], ) .unwrap(); } } migrate_v21(&conn).expect("重跑应补齐剩余批次"); let total_count: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!( total_count as usize, total * 2, "55 对话 × 2 条,无重复无缺失" ); // 逐对话完整性:不存在条数不足的残缺对话 let incomplete: i64 = conn .query_row( "SELECT COUNT(*) FROM ( SELECT conversation_id FROM ai_messages GROUP BY conversation_id HAVING COUNT(*) < 2 )", [], |r| r.get(0), ) .unwrap(); assert_eq!(incomplete, 0, "不应存在残缺对话"); } /// ai_tool_executions.message_id 列补建(老库已有表无该列) #[test] fn v21_adds_message_id_column_to_tool_executions() { let conn = setup_legacy_db(); // 模拟老库已有 ai_tool_executions 表(V9 建的旧形态,无 message_id) conn.execute_batch( "CREATE TABLE ai_tool_executions ( id TEXT PRIMARY KEY, conversation_id TEXT, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL, arguments TEXT NOT NULL, result TEXT, status TEXT NOT NULL DEFAULT 'pending', risk_level TEXT NOT NULL DEFAULT 'medium', requested_at TEXT NOT NULL, executed_at TEXT, decided_by TEXT );", ) .unwrap(); assert!( !column_exists(&conn, "ai_tool_executions", "message_id"), "迁移前应无 message_id 列" ); migrate_v21(&conn).expect("v21 应补建 message_id 列"); assert!( column_exists(&conn, "ai_tool_executions", "message_id"), "迁移后应有 message_id 列" ); } // ============================================================ // V20 迁移幂等安全(任务关联灵感) // ============================================================ /// 构造最小老库 schema:tasks 表(无 idea_id 列,模拟 V1 建表老形态)+ schema_version。 fn setup_legacy_tasks_db() -> Connection { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE tasks ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'todo', priority INTEGER NOT NULL DEFAULT 2, branch_name TEXT, assignee TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL );", ) .expect("create legacy tasks table"); conn } /// 老库无 idea_id 列:迁移应补建 + 写版本号 20 #[test] fn v20_legacy_db_adds_idea_id_column() { let conn = setup_legacy_tasks_db(); assert!( !column_exists(&conn, "tasks", "idea_id"), "迁移前应无 idea_id 列" ); migrate_v20(&conn).expect("v20 应在老库补建 idea_id 列"); assert!( column_exists(&conn, "tasks", "idea_id"), "迁移后应有 idea_id 列" ); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 20, "应写入版本号 20"); } /// 幂等重跑:列已存在时跳过 ALTER,版本号不重复写(PK 冲突防) /// 注:migrate_v20 用普通 INSERT(非 IGNORE),重跑会因 PK 冲突报错——这是预期行为, /// run() 正常流程下 current_version<20 只调一次;此处验证列存在时 ALTER 被短路(不报 duplicate column)。 #[test] fn v20_column_exists_skips_alter() { let conn = setup_legacy_tasks_db(); // 先跑一次补列 migrate_v20(&conn).expect("首次迁移"); assert!(column_exists(&conn, "tasks", "idea_id")); // 手动回退版本号模拟「列已存在但版本号未写」场景,验证 ALTER 被短路不报 duplicate column conn.execute("DELETE FROM schema_version WHERE version = 20", []) .unwrap(); // 此时列存在但版本号 20 缺失 → migrate_v20 应跳过 ALTER 只补版本号 migrate_v20(&conn).expect("列存在时应跳过 ALTER 不报错"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 20); } /// V27:审批状态统一 executed→completed /// 存量 executed 记录转 completed,rejected/completed/failed 不变(只动 executed) #[test] fn v27_unifies_executed_to_completed() { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE ai_tool_executions ( id TEXT PRIMARY KEY, tool_call_id TEXT, tool_name TEXT, status TEXT NOT NULL, requested_at TEXT, executed_at TEXT );", ) .expect("create ai_tool_executions"); // 混合状态:2 executed(待转)+ completed/rejected/failed(应不变) conn.execute_batch( "INSERT INTO ai_tool_executions (id, tool_call_id, tool_name, status) VALUES ('e1', 'tc1', 'write_file', 'executed'), ('e2', 'tc2', 'read_file', 'executed'), ('c1', 'tc3', 'list_directory', 'completed'), ('r1', 'tc4', 'write_file', 'rejected'), ('f1', 'tc5', 'run_command', 'failed');", ) .unwrap(); migrate_v27(&conn).expect("v27 应成功统一状态"); // executed 全部转 completed let executed_left: i64 = conn .query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'executed'", [], |r| r.get(0)) .unwrap(); assert_eq!(executed_left, 0, "executed 应全部转为 completed"); let completed: i64 = conn .query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'completed'", [], |r| r.get(0)) .unwrap(); assert_eq!(completed, 3, "原 2 executed + 1 completed = 3 completed"); // 其他状态不受影响 let rejected: i64 = conn .query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'rejected'", [], |r| r.get(0)) .unwrap(); assert_eq!(rejected, 1, "rejected 不变"); let failed: i64 = conn .query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'failed'", [], |r| r.get(0)) .unwrap(); assert_eq!(failed, 1, "failed 不变"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 27, "应写入版本号 27"); } /// V27 幂等:无 executed 时再跑不崩(UPDATE 0 行),版本号照写 #[test] fn v27_idempotent_no_executed() { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE ai_tool_executions (id TEXT PRIMARY KEY, status TEXT NOT NULL);", ) .expect("create tables"); conn.execute( "INSERT INTO ai_tool_executions (id, status) VALUES ('c1', 'completed')", [], ) .unwrap(); migrate_v27(&conn).expect("v27 无 executed 时应幂等成功(UPDATE 0 行)"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 27); } // ============================================================ // V43 迁移(存量知识一次性晋升) // ============================================================ /// 构造最小 knowledges 表(schema_version 同建),只含 V43 触及的列。 fn setup_knowledges_db() -> Connection { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE knowledges ( id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'candidate', confidence TEXT );", ) .expect("create knowledges table"); conn } /// V43:仅 candidate + confidence='high' 晋升,其余状态/置信度不动 #[test] fn v43_promotes_only_candidate_high_confidence() { let conn = setup_knowledges_db(); conn.execute_batch( "INSERT INTO knowledges (id, status, confidence) VALUES ('k1', 'candidate', 'high'), ('k2', 'candidate', 'high'), ('k3', 'candidate', 'medium'), ('k4', 'candidate', 'low'), ('k5', 'candidate', NULL), ('k6', 'pending_review', 'high'), ('k7', 'published', 'high'), ('k8', 'archived', 'high');", ) .unwrap(); migrate_v43(&conn).expect("v43 应成功晋升"); // k1/k2 转 published let published_high: i64 = conn .query_row( "SELECT COUNT(*) FROM knowledges WHERE status = 'published' AND confidence = 'high'", [], |r| r.get(0), ) .unwrap(); assert_eq!(published_high, 3, "2 条存量晋升 + 原 k7 = 3 条 published+high"); // 非 candidate+high 一律不动 let untouched: i64 = conn .query_row( "SELECT COUNT(*) FROM knowledges WHERE id IN ('k3','k4','k5','k6','k8') \ AND status NOT IN ('candidate','pending_review','archived')", [], |r| r.get(0), ) .unwrap(); assert_eq!(untouched, 0, "medium/low/NULL 置信与 pending_review/archived 不应被晋升"); // candidate 剩余:k3/k4/k5(2 条 high 已走) let candidates_left: i64 = conn .query_row( "SELECT COUNT(*) FROM knowledges WHERE status = 'candidate'", [], |r| r.get(0), ) .unwrap(); assert_eq!(candidates_left, 3, "仅剩 medium/low/NULL 置信的 candidate"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 43, "应写入版本号 43"); } /// V43 幂等:重跑 UPDATE 影响 0 行(WHERE 天然幂等,版本号防重入) #[test] fn v43_idempotent_rerun_zero_affected() { let conn = setup_knowledges_db(); conn.execute( "INSERT INTO knowledges (id, status, confidence) VALUES ('k1', 'candidate', 'high')", [], ) .unwrap(); migrate_v43(&conn).expect("首次晋升"); migrate_v43(&conn).expect("重跑应幂等成功(UPDATE 0 行)"); let published: i64 = conn .query_row( "SELECT COUNT(*) FROM knowledges WHERE status = 'published'", [], |r| r.get(0), ) .unwrap(); assert_eq!(published, 1, "重跑不应产生重复变更"); let v_count: i64 = conn .query_row( "SELECT COUNT(*) FROM schema_version WHERE version = 43", [], |r| r.get(0), ) .unwrap(); assert_eq!(v_count, 1, "版本号 43 应只写一次"); } // ============================================================ // 全量迁移测试 — 新库从零跑完整 V1-V43 路径 // ------------------------------------------------------------ // 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露, // 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表 // 都会被这里捕获,不必等到应用启动真机才报错。 // ============================================================ /// 辅助:断言表存在且 PRAGMA table_info 返回的列数 > 0(即表非空、有列定义)。 fn assert_table_has_columns(conn: &Connection, table: &str) { let count: i64 = conn .query_row( &format!("SELECT COUNT(*) FROM pragma_table_info('{}')", table), [], |r| r.get(0), ) .unwrap_or_else(|e| panic!("查 {} 列信息失败: {}", table, e)); assert!( count > 0, "表 {} 应存在且至少 1 列(实际 {} 列)—— 可能 migrate_vN 建表 SQL 写错或被遗漏", table, count ); } /// 全量迁移:新库从零跑完 V1-V43,验证关键表齐全 + 列数 > 0 + 关键列存在。 /// /// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions / /// conversation_checkpoints / ai_providers / projects / ideas。 /// 抽查关键列:ai_providers.enabled/weight、conversation_checkpoints.snapshot、 /// tasks.idea_id、tasks.module_id、project_modules.description/status(这些列由 /// 不同 vN 加,任一漏加此处失败)。 #[tokio::test] async fn test_full_migration_on_fresh_db() { // 用 Database::open_in_memory 打开新库,内部自动跑 migrations::run() 全量迁移 let db = crate::db::Database::open_in_memory() .await .expect("新库应能跑完全量迁移"); let conn = db.conn(); let conn = conn.lock().await; // 1. 关键表存在且列数 > 0 for table in [ "tasks", "ai_conversations", "ai_messages", "ai_tool_executions", "conversation_checkpoints", "ai_providers", "projects", "ideas", ] { assert_table_has_columns(&conn, table); } // 2. 抽查关键列存在(跨多个 vN 加的列,任一漏加此处失败) // ai_providers.enabled / weight(V19 ALTER) assert!( column_exists(&conn, "ai_providers", "enabled"), "ai_providers.enabled 列缺失(V19 加)" ); assert!( column_exists(&conn, "ai_providers", "weight"), "ai_providers.weight 列缺失(V19 加)" ); // conversation_checkpoints.snapshot(V37 建表) assert!( column_exists(&conn, "conversation_checkpoints", "snapshot"), "conversation_checkpoints.snapshot 列缺失(V37 建表)" ); // tasks.idea_id(V1 建表已带,V20 老库兜底——新库应有) assert!( column_exists(&conn, "tasks", "idea_id"), "tasks.idea_id 列缺失(V1 建表已带)" ); // tasks.module_id(V41 老库 ALTER 补,新库也走 V41——新库应有) assert!( column_exists(&conn, "tasks", "module_id"), "tasks.module_id 列缺失(V41 加)" ); // tasks.archived(V46 归档功能 P1) assert!( column_exists(&conn, "tasks", "archived"), "tasks.archived 列缺失(V46 加)" ); // ideas.archived(V47 归档功能 P1) assert!( column_exists(&conn, "ideas", "archived"), "ideas.archived 列缺失(V47 加)" ); // projects.archived(V48 项目归档 P2) assert!( column_exists(&conn, "projects", "archived"), "projects.archived 列缺失(V48 加)" ); // 3. schema_version 应推进到 48(全量迁移成功落版本号) let max_version: i64 = conn .query_row( "SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |r| r.get(0), ) .expect("查 schema_version 应成功"); assert_eq!( max_version, LATEST_SCHEMA_VERSION as i64, "全量迁移后 schema_version 应为顶点 {}(实际 {}),说明某条 migrate_vN 链路断在中间", LATEST_SCHEMA_VERSION, max_version ); // 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑) assert!( column_exists(&conn, "project_modules", "description"), "project_modules.description 列缺失(V40 加)" ); assert!( column_exists(&conn, "project_modules", "status"), "project_modules.status 列缺失(V40 加)" ); // 5. V42 抽查:ai_messages.is_estimated 列存在(消息级估算标记) assert!( column_exists(&conn, "ai_messages", "is_estimated"), "ai_messages.is_estimated 列缺失(V42 加)" ); } // ============================================================ // G5.5: v32 幂等守卫 + 全链幂等不变量 // ------------------------------------------------------------ // 背景:migrate_v32 原为裸 ALTER(唯一漏网),崩溃重跑/版本号回退后重跑会报 // duplicate column name。修后应幂等;再加全链重跑不变量防止未来新 vN 引入裸 ALTER。 // ============================================================ /// G5.5: v32 幂等守卫——pinned_goals 列已存在时重跑不报 duplicate column。 /// 构造最小 ai_conversations 表(无 pinned_goals 列)模拟老库,验证补列 + 重跑短路。 #[test] fn v32_idempotent_column_guard() { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE ai_conversations (id TEXT PRIMARY KEY);", ) .expect("create tables"); assert!( !column_exists(&conn, "ai_conversations", "pinned_goals"), "迁移前应无 pinned_goals 列" ); migrate_v32(&conn).expect("首次迁移应补 pinned_goals 列"); assert!(column_exists(&conn, "ai_conversations", "pinned_goals")); // 回退版本号模拟「列已存在但版本号缺失」崩溃重跑场景 → ALTER 应被守卫短路, // 仅补版本号,不报 duplicate column name。 conn.execute("DELETE FROM schema_version WHERE version = 32", []) .unwrap(); migrate_v32(&conn).expect("列已存在时重跑应幂等不报错"); let v_count: i64 = conn .query_row( "SELECT COUNT(*) FROM schema_version WHERE version = 32", [], |r| r.get(0), ) .unwrap(); assert_eq!(v_count, 1, "版本号 32 应只写一次"); } /// G5.5: 全链幂等不变量——V1-V43 每步执行两遍不抛错。 /// /// 首轮 run() 建全 schema;清空 schema_version 强制下一轮从 V1 重跑每步 /// (模拟存量库 + 崩溃重跑/版本号回退)。任何 migrate_vN 的裸 ALTER(无 column_exists /// 守卫,如 v32 修前形态)都会在第二遍报 duplicate column 被此测试捕获。 #[test] fn v1_to_v41_full_chain_rerun_idempotent() { let conn = Connection::open_in_memory().expect("open in-memory db"); run(&conn).expect("首轮全量迁移应成功"); let max_v: i64 = conn .query_row( "SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |r| r.get(0), ) .unwrap(); assert_eq!(max_v, LATEST_SCHEMA_VERSION as i64, "首轮应推进到 {}", LATEST_SCHEMA_VERSION); // 清空版本表强制全链第二遍(每步 execute 第二次) conn.execute("DELETE FROM schema_version", []).unwrap(); run(&conn).expect("全链第二遍不抛错(幂等不变量)"); let max_v2: i64 = conn .query_row( "SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |r| r.get(0), ) .unwrap(); assert_eq!(max_v2, LATEST_SCHEMA_VERSION as i64, "重跑后应重新推进到 {}", LATEST_SCHEMA_VERSION); } // ============================================================ // V41 迁移幂等安全(任务关联工程 module) // ============================================================ /// 构造最小老库 schema:tasks 表(无 module_id 列,模拟 V40 前老形态)+ project_modules /// 表(V41 外键引用目标,工程系统 V34)+ schema_version。 fn setup_legacy_tasks_no_module_db() -> Connection { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE project_modules ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL ); CREATE TABLE tasks ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'todo', priority INTEGER NOT NULL DEFAULT 2, branch_name TEXT, assignee TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL );", ) .expect("create legacy tables"); conn } /// 老库无 module_id 列:迁移应补建 + 写版本号 41 #[test] fn v41_legacy_db_adds_module_id_column() { let conn = setup_legacy_tasks_no_module_db(); assert!( !column_exists(&conn, "tasks", "module_id"), "迁移前应无 module_id 列" ); migrate_v41(&conn).expect("v41 应在老库补建 module_id 列"); assert!( column_exists(&conn, "tasks", "module_id"), "迁移后应有 module_id 列" ); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 41, "应写入版本号 41"); } /// 幂等重跑:列已存在时跳过 ALTER,不报 duplicate column(对齐 v20/v32 守卫模式) #[test] fn v41_column_exists_skips_alter() { let conn = setup_legacy_tasks_no_module_db(); migrate_v41(&conn).expect("首次迁移"); assert!(column_exists(&conn, "tasks", "module_id")); // 手动回退版本号模拟「列已存在但版本号未写」场景,验证 ALTER 被短路不报 duplicate column conn.execute("DELETE FROM schema_version WHERE version = 41", []) .unwrap(); migrate_v41(&conn).expect("列存在时应跳过 ALTER 不报错"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 41); } /// V45 幂等:最小库补建 boundary_json 列;重跑(含手动回退版本号模拟崩溃断点)不炸、 /// 不报 duplicate column、版本号 INSERT OR IGNORE 防 PK 冲突。 #[test] fn v45_boundary_json_idempotent() { let conn = setup_legacy_db(); migrate_v45(&conn).expect("v45 首次迁移应成功"); assert!(column_exists(&conn, "ai_conversations", "boundary_json"), "应有 boundary_json 列"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 45, "应写入版本号 45"); // 模拟崩溃断点:版本号回退(45 未落)但列已建 → 重跑走 column_exists 短路 conn.execute("DELETE FROM schema_version WHERE version = 45", []).unwrap(); migrate_v45(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错"); let v2: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v2, 45, "重跑后版本号恢复 45"); // 列可写可读(NULL 语义=未声明边界) conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at, boundary_json) \ VALUES ('c1', '[]', '0', '0', '{\"risk_ceiling\":\"medium\"}')", [], ) .unwrap(); let got: Option = conn .query_row("SELECT boundary_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0)) .unwrap(); assert!(got.is_some(), "boundary_json 应可写可读"); } /// V50: 三列/回填一次承载,幂等重跑不炸。 /// ① ai_conversations.status 补建默认 'active'; /// ② ai_tool_executions.duration_ms 补建(可空); /// ③ conversation_checkpoints.token_total 按 snapshot 重算(消息条数→真实 token)。 #[test] fn v50_adds_columns_and_backfills_token_total() { let conn = setup_legacy_db(); // 前置:最小库无 status/duration_ms 列(V45 之后建,老库缺) assert!(!column_exists(&conn, "ai_conversations", "status")); // V50 需要 checkpoint / tool_executions 表(正常由 V1/V9 建;此处手工建以隔离本迁移被测量) conn.execute_batch( "CREATE TABLE IF NOT EXISTS conversation_checkpoints (\ id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\ token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL); INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\ VALUES ('ck1','c1',\ '[{\"role\":\"assistant\",\"prompt_tokens\":150000,\"completion_tokens\":20000}]',\ 1,'100'); CREATE TABLE IF NOT EXISTS ai_tool_executions (\ id TEXT PRIMARY KEY, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL,\ arguments TEXT NOT NULL, requested_at TEXT NOT NULL);", ) .expect("建 checkpoint/tool_executions 表 + 塞一条失真行"); migrate_v50(&conn).expect("v50 首次迁移应成功"); assert!(column_exists(&conn, "ai_conversations", "status"), "应有 status 列"); assert!(column_exists(&conn, "ai_tool_executions", "duration_ms"), "应有 duration_ms 列"); // ① 默认值 active 生效 conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c1','[]','0','0')", [], ) .unwrap(); let status: String = conn .query_row("SELECT status FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0)) .unwrap(); assert_eq!(status, "active", "新列默认值应为 active"); // ③ 失真 token_total(1)被回填为真实和(150000+20000) let tt: i64 = conn .query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0)) .unwrap(); assert_eq!(tt, 170_000, "应回填为 snapshot 内 token 真实和"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 50, "应写入版本号 50"); // 幂等:列已存在时重跑走 column_exists 短路;回填重算确定(值不变) migrate_v50(&conn).expect("列已存在时重跑应幂等不报错"); let tt2: i64 = conn .query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0)) .unwrap(); assert_eq!(tt2, 170_000, "重跑回填结果应一致"); } /// V50 回填不误清零:老快照无消息级 token(重算=0)时保留原值(不 UPDATE)。 #[test] fn v50_backfill_keeps_legacy_when_no_message_tokens() { let conn = setup_legacy_db(); conn.execute_batch( "CREATE TABLE IF NOT EXISTS conversation_checkpoints (\ id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\ token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL); INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\ VALUES ('ck2','c2','[{\"role\":\"user\",\"content\":\"hi\"}]', 42, '100');", ) .expect("建表塞老快照"); migrate_v50(&conn).expect("v50 应成功"); let tt: i64 = conn .query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck2'", [], |r| r.get(0)) .unwrap(); assert_eq!(tt, 42, "无消息级 token 的老快照应保留原值不误清零"); } /// sum_snapshot_tokens 纯函数:字段缺失/非法 JSON 按 0 计。 #[test] fn sum_snapshot_tokens_handles_missing_and_bad_json() { assert_eq!(sum_snapshot_tokens("not json"), 0, "非法 JSON 应返回 0"); assert_eq!(sum_snapshot_tokens("[]"), 0, "空数组应为 0"); assert_eq!( sum_snapshot_tokens(r#"[{"prompt_tokens":10},{"completion_tokens":5},{"foo":1}]"#), 15, "逐条相加,缺字段按 0" ); } /// V53 幂等:最小老库补建 working_context_json 列;重跑(含版本号回退模拟崩溃断点) /// 不炸不报 duplicate column;列可写可读(NULL 语义=无 WorkingContext)。 #[test] fn v53_working_context_json_idempotent() { let conn = setup_legacy_db(); assert!( !column_exists(&conn, "ai_conversations", "working_context_json"), "迁移前应无 working_context_json 列" ); migrate_v53(&conn).expect("v53 首次迁移应成功"); assert!(column_exists(&conn, "ai_conversations", "working_context_json"), "应有 working_context_json 列"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 53, "应写入版本号 53"); // 模拟崩溃断点:版本号回退但列已建 → 重跑走 column_exists 短路 conn.execute("DELETE FROM schema_version WHERE version = 53", []).unwrap(); migrate_v53(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错"); let v2: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v2, 53, "重跑后版本号恢复 53"); // 列可写可读;老会话行(不带该列插入)读回 NULL conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at, working_context_json) \ VALUES ('c1', '[]', '0', '0', '{\"goals\":[],\"decisions\":[]}')", [], ) .unwrap(); conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c2', '[]', '0', '0')", [], ) .unwrap(); let got: Option = conn .query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0)) .unwrap(); assert!(got.is_some(), "working_context_json 应可写可读"); let legacy: Option = conn .query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c2'", [], |r| r.get(0)) .unwrap(); assert!(legacy.is_none(), "老会话行应为 NULL(无 WorkingContext)"); } }