- 后端 module_dependencies 表(V35 迁移)+ ModuleDependencyRepo CRUD - IPC:add/remove/list_module_dependencies - 前端 API 封装 + DependencyGraph 接入真实边数据 - 依赖类型颜色区分(library/api/mq/shared/custom) - 小地图插件(MiniMap)大图概览导航 - 点击节点跳转项目详情 - 任务列表后端真分页 count_by_query 方法
1719 lines
78 KiB
Rust
1719 lines
78 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 = F-260619-01(任务关联灵感 idea_id);V21 = 消息拆分存储 + audit message_id;
|
|
// V22 = 灵感评估历史持久化(idea_evaluations 追加型审计表);
|
|
// V23 = knowledges.embedding_status 列(嵌入失败可补偿重试);
|
|
// V24 = ideas.related_ids 列(灵感间关联关系持久化打底);
|
|
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底);
|
|
// V26 = F-260621-02 任务索引缺口补全(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<()>); 35] = [
|
|
(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),
|
|
];
|
|
|
|
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 表
|
|
fn migrate_v2(conn: &Connection) -> Result<()> {
|
|
conn.execute_batch(V2_SQL)?;
|
|
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 退回累计轮数,F-260616-04)
|
|
///
|
|
/// 任务推进链状态机退回时累加: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 列(模型能力配置,F-01 阶段1)
|
|
///
|
|
/// 模型 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 列(F-260614-04 多 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 负载均衡池,F-260614-04)");
|
|
}
|
|
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 负载均衡池,F-260614-04)");
|
|
}
|
|
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [19])?;
|
|
tracing::info!("迁移 v19 完成");
|
|
Ok(())
|
|
}
|
|
|
|
/// V20:幂等补 tasks.idea_id 列(F-260619-01 任务关联灵感,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 列(任务关联灵感,F-260619-01)");
|
|
}
|
|
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,F-260619-04)
|
|
// 表存在性兜底: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 保守拒绝
|
|
/// (FR-S6),与追加型审计语义一致(历史不改),不开放通用写路径。
|
|
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 索引(F-260621-02 索引缺口)
|
|
///
|
|
/// 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<String>)管理,原先仅在内存态存在,
|
|
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
|
fn migrate_v32(conn: &Connection) -> Result<()> {
|
|
conn.execute_batch(
|
|
"ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';"
|
|
)?;
|
|
tracing::info!("v32: ai_conversations 加 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 命令),不存表。
|
|
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(())
|
|
}
|
|
|
|
/// 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,
|
|
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,
|
|
-- F-260619-01 任务关联灵感(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 补建(F-260621-02 索引缺口):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 — 补齐数据层断裂字段
|
|
///
|
|
/// 注意: SQLite 的 ALTER TABLE ADD COLUMN 一条语句只能加一列。
|
|
const V2_SQL: &str = "
|
|
-- 想法表: 晋升关联 + AI 分析 + 多维评分
|
|
ALTER TABLE ideas ADD COLUMN promoted_to TEXT;
|
|
ALTER TABLE ideas ADD COLUMN ai_analysis TEXT;
|
|
ALTER TABLE ideas ADD COLUMN scores TEXT;
|
|
|
|
-- 任务表: 工作流定义关联 + 基础分支
|
|
ALTER TABLE tasks ADD COLUMN workflow_def_id TEXT;
|
|
ALTER TABLE tasks ADD COLUMN base_branch TEXT;
|
|
|
|
-- 工作流执行表: 项目 / 任务关联
|
|
ALTER TABLE workflow_executions ADD COLUMN project_id TEXT;
|
|
ALTER TABLE workflow_executions ADD COLUMN task_id TEXT;
|
|
|
|
-- 分支表 — 任务与 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
|
|
);
|
|
|
|
-- F-260619-03 消息拆分存储:每条 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,
|
|
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 迁移幂等安全(新库/老库/坏数据三态,F-260619-03)
|
|
// ============================================================
|
|
|
|
#[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 迁移幂等安全(F-260619-01 任务关联灵感)
|
|
// ============================================================
|
|
|
|
/// 构造最小老库 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);
|
|
}
|
|
}
|