新增: F-260619-01 任务可关联灵感(tasks.idea_id 1对1 单向)

- df-storage V20 迁移:tasks 加 idea_id TEXT REFERENCES ideas(id)(填预留空位,
  幂等 column_exists 探测)+ V9_SQL 同步 + TaskRecord.idea_id + task_repo SQL/SELECT + 白名单
- AI 工具 create_task 加 idea_id 参数;update_task 经白名单自动支持
- IPC commands/task.rs CreateTaskInput 加 idea_id
- 前端 types TaskRecord/CreateTaskInput idea_id + TaskDetail.vue 关联灵感展示
  (router-link 友好 title 非裸 id)+ i18n
- 补 idea_id 字段:df-mcp tools / df-nodes 测试 helper
- 单向 1对1,Option 可空,复用 projects.idea_id 模式,老任务 None 兼容

自验: df-storage 47 passed(含 V20 2)+ workspace EXIT 0 + vue-tsc EXIT 0
This commit is contained in:
2026-06-19 21:03:18 +08:00
parent e5a8fec1fe
commit 4a87c552cc
15 changed files with 158 additions and 17 deletions

View File

@@ -142,6 +142,9 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
// output_json:ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象
// (决策 a:task 中心,产出跟 task 走)。非状态机收口字段,合法可写。
"output_json",
// idea_id(F-260619-01 任务关联灵感,1对1 单向):任务可关联/解关联一条灵感,
// 非状态机收口字段,合法可写(idea_id 存在性由外键约束 + 上层校验兜底)。
"idea_id",
"updated_at",
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。

View File

@@ -31,6 +31,7 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
base_branch: row.get("base_branch")?,
review_rounds: row.get("review_rounds")?,
output_json: row.get("output_json")?,
idea_id: row.get("idea_id")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
@@ -48,22 +49,22 @@ impl_repo!(
from_row => |row| task_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.created_at, rec.updated_at
rec.review_rounds, rec.output_json, rec.idea_id, rec.created_at, rec.updated_at
],
)
},
update => |conn, rec| {
conn.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, updated_at = ?12 WHERE id = ?13",
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, updated_at = ?13 WHERE id = ?14",
params![
rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.updated_at, rec.id
rec.review_rounds, rec.output_json, rec.idea_id, rec.updated_at, rec.id
],
)
}
@@ -79,7 +80,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| task_from_row(row))
@@ -180,7 +181,7 @@ impl TaskRepo {
}
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at FROM tasks WHERE id = ?1")
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE id = ?1")
.map_err(storage_err)?;
let row = stmt
.query_row(params![id], |row| task_from_row(row))
@@ -201,7 +202,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| task_from_row(row))

View File

@@ -23,8 +23,8 @@ pub fn run(conn: &Connection) -> Result<()> {
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
// V20 预留给 F-260619-01(任务关联灵感),跳过;V21 = 消息拆分存储 + audit message_id。
let steps: [(i32, fn(&Connection) -> Result<()>); 20] = [
// V20 = F-260619-01(任务关联灵感 idea_id);V21 = 消息拆分存储 + audit message_id。
let steps: [(i32, fn(&Connection) -> Result<()>); 21] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -44,6 +44,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(17, migrate_v17),
(18, migrate_v18),
(19, migrate_v19),
(20, migrate_v20),
(21, migrate_v21),
];
@@ -350,6 +351,26 @@ fn migrate_v19(conn: &Connection) -> Result<()> {
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):
@@ -554,6 +575,8 @@ CREATE TABLE IF NOT EXISTS tasks (
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
);
@@ -997,4 +1020,72 @@ mod tests {
"迁移后应有 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);
}
}

View File

@@ -75,6 +75,11 @@ pub struct TaskRecord {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub output_json: Option<String>,
/// 关联灵感 ID(F-260619-01,1对1 单向,任务→灵感)。
/// 复用 projects.idea_id 模式(REFERENCES ideas(id) 外键),可空(任务可不关联灵感)。
/// 老任务无 idea_id → None。#[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)]
pub idea_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}

View File

@@ -43,6 +43,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
base_branch: None,
review_rounds: 0,
output_json: None,
idea_id: None,
created_at: now_ts(),
updated_at: now_ts(),
}