AI loop 竞态(P0):per-conv epoch/owner token + 存活心跳治 force_send 双loop + stop 3s兜底误判;旧loop stale 全跳过(guard/emit/save)
agentic 收尾(A2-B8):Fatal 退出落库user消息(镜像Exhausted)+ 入口早退补save + usage is_estimated 打标 + emit_ai_completed_once 单点收敛清审批残留
聊天清理(A2-B9):clearChat 先停loop→DB单事务→内存清(clear_conversation_atomic)+ 前端错误气泡
循环并发(A2-B11):三态 ProviderAcquire(NotConfigured/Acquired/Exhausted)+ 候选循环非阻塞+防抖3次饱和降级+单测
错误分类(A2-B12):stream error帧接入 classify_status_or_class + 关键词保守降级 + 7单测
数据(G1.2/G1.4):purge_with_descendants 级联补全(11表单事务+存在性守卫)+ move_task_queue 单事务收口(两调用方共用)
git只读(G3.1):run_git_status/diff/log success判定(exit_code差异语义,失败结构化{success:false,error})
安全(G5.2/G5.6):create_project 目录Err+name校验 + module.rs 路径遍历DRY(分段匹配修a..b.rs误伤)
幂等(V2/V32):裸ALTER全守卫化 + v1..v40全链重跑幂等测试(16过)
附:remote_bridge await 临时引用修(E0716)+ agentic emit 收敛 E0716 app_state 绑定修
2090 lines
95 KiB
Rust
2090 lines
95 KiB
Rust
//! 数据库迁移 — 建表 SQL 与版本管理
|
|
|
|
use crate::migrations_helpers::column_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 内存态,使待审批不丢。
|
|
let steps: [(i32, fn(&Connection) -> Result<()>); 40] = [
|
|
(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),
|
|
];
|
|
|
|
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<f32> 的小端字节序列化 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<String>),
|
|
/// 经通用 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<String>(未关联灵感的任务为 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. COUNT 探测 ai_messages 已有数据 → 跳过数据迁移(仅写版本号,防重复迁移)
|
|
/// 4. 遍历 ai_conversations.messages JSON → 逐条提取到 ai_messages(分批 commit)
|
|
///
|
|
/// 设计要点(详见消息拆分存储设计 §4.2):
|
|
/// - **幂等安全**:COUNT 探测 + INSERT OR IGNORE,中途崩溃重跑跳过已迁移数据
|
|
/// - **分批 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. COUNT 探测:ai_messages 已有数据 → 跳过迁移只写版本号(幂等)
|
|
// INSERT OR IGNORE 防崩溃重跑(schema_version PK 冲突):run() 正常流程只调
|
|
// 一次 migrate_v21(current_version<21),但崩溃重跑/手动重调时 version=21
|
|
// 可能已存在,IGNORE 保证幂等不报错。
|
|
let existing: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM ai_messages", [], |row| row.get(0),
|
|
)?;
|
|
if existing > 0 {
|
|
tracing::info!("v21: ai_messages 已有 {} 条,跳过数据迁移", existing);
|
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [21])?;
|
|
return Ok(());
|
|
}
|
|
|
|
// 4. 遍历 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::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
// 5. 分批 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 {
|
|
// 6. 逐对话反序列化 messages JSON → Vec<serde_json::Value>
|
|
// (用裸 JSON 而非 ChatMessage,因 df-storage 不依赖 df-ai-core)
|
|
let messages: Vec<serde_json::Value> = match serde_json::from_str(messages_json) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!("v21: 对话 {} messages JSON 解析失败,跳过: {}", conv_id, e);
|
|
continue; // 坏数据跳过,不中断迁移
|
|
}
|
|
};
|
|
|
|
for (seq, msg) in messages.iter().enumerate() {
|
|
// 7. 逐条消息提取字段 → 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<String>(未设关联的灵感为 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(TD-260621-05)
|
|
///
|
|
/// 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<GoalEntry>)管理,原先仅在内存态存在,
|
|
/// 此迁移为其提供持久化列,默认空 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(())
|
|
}
|
|
|
|
/// 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,
|
|
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,
|
|
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<String>, Option<String>, 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 不重复迁移(COUNT 探测跳过)
|
|
#[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);
|
|
|
|
// 第二次跑:COUNT 探测 > 0 → 跳过数据迁移,不重复
|
|
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_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(TD-260621-05)
|
|
/// 存量 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);
|
|
}
|
|
|
|
// ============================================================
|
|
// 全量迁移测试 — 新库从零跑完整 V1-V40 路径
|
|
// ------------------------------------------------------------
|
|
// 目的:某 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-V40,验证关键表齐全 + 列数 > 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、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 建表已带)"
|
|
);
|
|
|
|
// 3. schema_version 应推进到 40(全量迁移成功落版本号)
|
|
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, 40,
|
|
"全量迁移后 schema_version 应为 40(实际 {}),说明某条 migrate_vN 链路断在中间",
|
|
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 加)"
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 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-V40 每步执行两遍不抛错。
|
|
///
|
|
/// 首轮 run() 建全 schema;清空 schema_version 强制下一轮从 V1 重跑每步
|
|
/// (模拟存量库 + 崩溃重跑/版本号回退)。任何 migrate_vN 的裸 ALTER(无 column_exists
|
|
/// 守卫,如 v32 修前形态)都会在第二遍报 duplicate column 被此测试捕获。
|
|
#[test]
|
|
fn v1_to_v40_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, 40, "首轮应推进到 40");
|
|
|
|
// 清空版本表强制全链第二遍(每步 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, 40, "重跑后应重新推进到 40");
|
|
}
|
|
}
|