新增: 任务关联工程(module_id 数据层V41 + AI module 写工具6个 + 任务构造补字段)

This commit is contained in:
lxy
2026-08-08 20:01:34 +08:00
parent fe780c0084
commit 97525a3143
12 changed files with 567 additions and 43 deletions
+3
View File
@@ -567,6 +567,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
module_id: None,
queue: "todo".to_string(), queue: "todo".to_string(),
parent_id, parent_id,
content_json: None, content_json: None,
@@ -630,6 +631,7 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
idea_id: existing.idea_id, idea_id: existing.idea_id,
queue: existing.queue, queue: existing.queue,
parent_id: existing.parent_id, parent_id: existing.parent_id,
module_id: existing.module_id,
content_json: existing.content_json, content_json: existing.content_json,
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
@@ -1071,6 +1073,7 @@ mod tests {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
module_id: None,
queue: "todo".to_string(), queue: "todo".to_string(),
parent_id: None, parent_id: None,
content_json: None, content_json: None,
@@ -336,6 +336,7 @@ mod tests {
review_rounds: 0, review_rounds: 0,
output_json: task_output_json.map(String::from), output_json: task_output_json.map(String::from),
idea_id: None, idea_id: None,
module_id: None,
queue: "todo".to_string(), queue: "todo".to_string(),
parent_id: None, parent_id: None,
content_json: None, content_json: None,
+1
View File
@@ -303,6 +303,7 @@ mod tests {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
module_id: None,
queue: "todo".to_string(), queue: "todo".to_string(),
parent_id: None, parent_id: None,
content_json: None, content_json: None,
+2 -1
View File
@@ -330,7 +330,7 @@ mod tests {
use crate::models::TaskRecord; use crate::models::TaskRecord;
use df_types::types::{ProjectStatus, TaskStatus}; use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。 /// 构造一条 TaskRecord fixture(19 字段全填,queue 默认 todo)。
fn trec(id: &str, project_id: &str) -> TaskRecord { fn trec(id: &str, project_id: &str) -> TaskRecord {
TaskRecord { TaskRecord {
id: id.to_string(), id: id.to_string(),
@@ -349,6 +349,7 @@ mod tests {
queue: "todo".to_string(), queue: "todo".to_string(),
parent_id: None, parent_id: None,
content_json: None, content_json: None,
module_id: None,
created_at: "1700000000000".to_string(), created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(),
} }
+97 -22
View File
@@ -41,6 +41,8 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
queue: row.get("queue")?, queue: row.get("queue")?,
parent_id: row.get("parent_id")?, parent_id: row.get("parent_id")?,
content_json: row.get("content_json")?, content_json: row.get("content_json")?,
// 工程系统 V41:任务关联具体工程(module_id),18→19 列同步之一。
module_id: row.get("module_id")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
}) })
@@ -86,6 +88,10 @@ pub struct TaskQuery {
/// 查叶子任务(parent_id IS NULL)由专用方法 get_children 之外的语义决定,本字段只做等值匹配。 /// 查叶子任务(parent_id IS NULL)由专用方法 get_children 之外的语义决定,本字段只做等值匹配。
#[serde(default)] #[serde(default)]
pub parent_id: Option<String>, pub parent_id: Option<String>,
/// 所属工程 ID 过滤(工程系统 V41):Some(id) = 查关联到某 module 的任务。
/// 工程维度筛选数据源(任务按工程分列/过滤)。
#[serde(default)]
pub module_id: Option<String>,
/// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。 /// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。
pub order_by: Option<String>, pub order_by: Option<String>,
/// 分页上限(钳制 ≤500)。P3 基建就绪。 /// 分页上限(钳制 ≤500)。P3 基建就绪。
@@ -123,25 +129,25 @@ impl_repo!(
from_row => |row| task_from_row(row), from_row => |row| task_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at) "INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![ params![
rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority, rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.queue, rec.parent_id, rec.content_json, rec.module_id,
rec.created_at, rec.updated_at rec.created_at, rec.updated_at
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( 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, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17", "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, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18",
params![ params![
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.queue, rec.parent_id, rec.content_json, rec.module_id,
rec.updated_at, rec.id rec.updated_at, rec.id
], ],
) )
@@ -151,14 +157,14 @@ impl_repo!(
impl TaskRepo { impl TaskRepo {
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active /// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
/// ///
/// 显式列出全部 18 个 TaskRecord 列名(同 ProjectRepo::list_active 写法), /// 显式列出全部 19 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。 /// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> { pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone(); let conn = self.conn.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard 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, idea_id, queue, parent_id, content_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, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map([], |row| task_from_row(row)) .query_map([], |row| task_from_row(row))
@@ -259,7 +265,7 @@ impl TaskRepo {
} }
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。 // 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
let mut stmt = guard 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, idea_id, queue, parent_id, content_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, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE id = ?1")
.map_err(storage_err)?; .map_err(storage_err)?;
let row = stmt let row = stmt
.query_row(params![id], |row| task_from_row(row)) .query_row(params![id], |row| task_from_row(row))
@@ -281,7 +287,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard 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, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 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, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pid], |row| task_from_row(row)) .query_map(params![pid], |row| task_from_row(row))
@@ -300,15 +306,17 @@ impl TaskRepo {
/// ///
/// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集 /// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集
/// (Vec<String> WHERE 子句 + Vec<rusqlite::Value> 参数)替代 if-let 二分支—— /// (Vec<String> WHERE 子句 + Vec<rusqlite::Value> 参数)替代 if-let 二分支——
/// TaskQuery 有 4 个过滤维度(project_id/status/priority/assignee/keyword),2^n 分支不可行, /// TaskQuery 有个过滤维度(project_id/status/priority/assignee/keyword/queue/parent_id/
/// 累积式天然支持任意维度组合,且每个 if-let 分支只 push 子句+参数,新增维度零样板。 /// module_id),2^n 分支不可行,累积式天然支持任意维度组合,且每个 if-let 分支只 push
/// 子句+参数,新增维度零样板。
/// ///
/// - 过滤维度:project_id / status / priority / assignee(精确等值)+ keyword(title/description LIKE) /// - 过滤维度:project_id / status / priority / assignee / queue / parent_id / module_id
/// (精确等值)+ keyword(title/description LIKE)
/// - keyword 拼成 `(title LIKE ?N OR description LIKE ?M)`,pattern = `%kw%`(对齐知识库 search) /// - keyword 拼成 `(title LIKE ?N OR description LIKE ?M)`,pattern = `%kw%`(对齐知识库 search)
/// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致) /// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致)
/// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路) /// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路)
/// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭) /// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭)
/// - 显式列出全部 18 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列) /// - 显式列出全部 19 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
/// ///
/// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。 /// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。
/// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集 /// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集
@@ -337,6 +345,7 @@ impl TaskRepo {
let keyword = query.keyword.clone(); let keyword = query.keyword.clone();
let queue = query.queue.clone(); let queue = query.queue.clone();
let parent_id = query.parent_id.clone(); let parent_id = query.parent_id.clone();
let module_id = query.module_id.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
@@ -376,6 +385,11 @@ impl TaskRepo {
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1)); where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone())); params_vec.push(Box::new(pid.clone()));
} }
// module_id(工程系统 V41):所属工程等值过滤(查关联到某 module 的任务)
if let Some(mid) = &module_id {
where_clauses.push(format!("module_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(mid.clone()));
}
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式) // keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
if let Some(kw) = &keyword { if let Some(kw) = &keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_"); let escaped = kw.replace('%', "\\%").replace('_', "\\_");
@@ -399,12 +413,12 @@ impl TaskRepo {
}; };
// 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。 // 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。
// 显式列出全部 18 列(含 V29 queue/parent_id/content_json,不 SELECT deleted_at: // 显式列出全部 19 列(含 V29 queue/parent_id/content_json 与 V41 module_id,
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。 // 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
let sql = format!( let sql = format!(
"SELECT id, project_id, title, description, status, priority, branch_name, \ "SELECT id, project_id, title, description, status, priority, branch_name, \
assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \ assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \
queue, parent_id, content_json, created_at, updated_at \ queue, parent_id, content_json, module_id, created_at, updated_at \
FROM tasks WHERE {} ORDER BY {} DESC{}", FROM tasks WHERE {} ORDER BY {} DESC{}",
where_clauses.join(" AND "), where_clauses.join(" AND "),
order_col, order_col,
@@ -503,7 +517,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard 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, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND parent_id = ?1 ORDER BY created_at ASC") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND parent_id = ?1 ORDER BY created_at ASC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pid], |row| task_from_row(row)) .query_map(params![pid], |row| task_from_row(row))
@@ -638,11 +652,11 @@ impl TaskRepo {
let id = id.to_owned(); let id = id.to_owned();
let new_queue = new_queue.to_owned(); let new_queue = new_queue.to_owned();
let now = now_millis_str(); let now = now_millis_str();
// 显式列出全部 18 列(同 from_row 消费列,不 SELECT deleted_at: // 显式列出全部 19 列(同 from_row 消费列,不 SELECT deleted_at:
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。 // TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
const TASK_COLS: &str = "id, project_id, title, description, status, priority, branch_name, \ const TASK_COLS: &str = "id, project_id, title, description, status, priority, branch_name, \
assignee, workflow_def_id, base_branch, review_rounds, output_json, \ assignee, workflow_def_id, base_branch, review_rounds, output_json, \
idea_id, queue, parent_id, content_json, created_at, updated_at"; idea_id, queue, parent_id, content_json, module_id, created_at, updated_at";
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut guard = conn.blocking_lock(); let mut guard = conn.blocking_lock();
let tx = guard.transaction().map_err(storage_err)?; let tx = guard.transaction().map_err(storage_err)?;
@@ -715,7 +729,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard 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, idea_id, queue, parent_id, content_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, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map([], |row| task_from_row(row)) .query_map([], |row| task_from_row(row))
@@ -738,8 +752,8 @@ impl TaskRepo {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::{ProjectModuleRepo, ProjectRepo};
use crate::models::ProjectRecord; use crate::models::{ProjectModuleRecord, ProjectRecord};
use df_types::types::{ProjectStatus, TaskStatus}; use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。 /// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
@@ -766,6 +780,7 @@ mod tests {
queue: queue.to_string(), queue: queue.to_string(),
parent_id: parent_id.map(|s| s.to_string()), parent_id: parent_id.map(|s| s.to_string()),
content_json: None, content_json: None,
module_id: None,
created_at: "1700000000000".to_string(), created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(),
} }
@@ -871,6 +886,66 @@ mod tests {
assert_eq!(res.len(), 2); assert_eq!(res.len(), 2);
} }
#[tokio::test]
async fn list_by_query_module_id_filter() {
// 工程系统 V41:按 module_id 等值过滤任务。
// 需先建 project_modules 行满足 tasks.module_id FK(PRAGMA foreign_keys=ON)。
let db = Database::open_in_memory().await.expect("open_in_memory");
let project_repo = ProjectRepo::new(&db);
project_repo
.insert(ProjectRecord {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
})
.await
.unwrap();
let module_repo = ProjectModuleRepo::new(&db);
module_repo
.insert(ProjectModuleRecord {
id: "mod-1".to_string(),
project_id: "proj-1".to_string(),
name: "backend".to_string(),
path: "/repo/backend".to_string(),
git_url: None,
stack: None,
auto_detected: false,
sort_order: 0,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
description: None,
status: None,
})
.await
.unwrap();
let repo = TaskRepo::new(&db);
// 2 个任务关联 mod-1,1 个无关联工程
let mut t1 = trec("t1", "todo", None);
t1.module_id = Some("mod-1".to_string());
let mut t2 = trec("t2", "todo", None);
t2.module_id = Some("mod-1".to_string());
repo.insert(t1).await.unwrap();
repo.insert(t2).await.unwrap();
repo.insert(trec("t3", "todo", None)).await.unwrap();
let q = TaskQuery {
module_id: Some("mod-1".to_string()),
..Default::default()
};
let res = repo.list_by_query(&q).await.unwrap();
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids.len(), 2, "module_id 过滤应只返回关联 mod-1 的任务");
assert!(ids.contains(&"t1"));
assert!(ids.contains(&"t2"));
}
#[tokio::test] #[tokio::test]
async fn get_children_returns_only_direct_children() { async fn get_children_returns_only_direct_children() {
let repo = setup().await; let repo = setup().await;
+115 -11
View File
@@ -45,7 +45,8 @@ pub fn run(conn: &Connection) -> Result<()> {
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 40] = [ // V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。
let steps: [(i32, fn(&Connection) -> Result<()>); 41] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -86,6 +87,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(38, migrate_v38), (38, migrate_v38),
(39, migrate_v39), (39, migrate_v39),
(40, migrate_v40), (40, migrate_v40),
(41, migrate_v41),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1230,6 +1232,31 @@ fn migrate_v40(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V41: 幂等补 tasks.module_id 列(任务关联到具体工程 module)
///
/// 工程系统打底(V34 project_modules 表):一个项目可含多个工程(Monorepo 多仓库 /
/// 微服务 / 前后端分离),任务此前只能关联到项目(project_id),本列让任务落到具体
/// 工程,是后续 IPC / 前端 / AI 按工程筛选任务的数据基础。TEXT NULL 向后兼容:
/// 老任务无关联 → None(TaskRecord 字段为 Option<String>)。
///
/// 外键 REFERENCES project_modules(id) ON DELETE SET NULL:module 被删除时该任务
/// module_id 自动置 NULL 解关联(不阻塞删除、不留悬挂引用)。新列默认值 NULL,
/// 存量行全为 NULL,foreign_keys=ON 下 ALTER ADD COLUMN 不报错(SQLite 要求
/// 带 REFERENCES 的新列默认值为 NULL)。用 PRAGMA 探测列存在性,缺失才 ALTER
/// (同 v20/v29 模式),对新库/老库/坏库均安全。
fn migrate_v41(conn: &Connection) -> Result<()> {
if !column_exists(conn, "tasks", "module_id") {
conn.execute(
"ALTER TABLE tasks ADD COLUMN module_id TEXT REFERENCES project_modules(id) ON DELETE SET NULL",
[],
)?;
tracing::info!("v41: 补建 tasks.module_id 列(任务关联工程)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [41])?;
tracing::info!("迁移 v41 完成");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -1915,7 +1942,7 @@ mod tests {
} }
// ============================================================ // ============================================================
// 全量迁移测试 — 新库从零跑完整 V1-V40 路径 // 全量迁移测试 — 新库从零跑完整 V1-V41 路径
// ------------------------------------------------------------ // ------------------------------------------------------------
// 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露, // 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露,
// 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表 // 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表
@@ -1939,12 +1966,13 @@ mod tests {
); );
} }
/// 全量迁移:新库从零跑完 V1-V40,验证关键表齐全 + 列数 > 0 + 关键列存在。 /// 全量迁移:新库从零跑完 V1-V41,验证关键表齐全 + 列数 > 0 + 关键列存在。
/// ///
/// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions / /// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions /
/// conversation_checkpoints / ai_providers / projects / ideas。 /// conversation_checkpoints / ai_providers / projects / ideas。
/// 抽查关键列:ai_providers.enabled/weight、conversation_checkpoints.snapshot、 /// 抽查关键列:ai_providers.enabled/weight、conversation_checkpoints.snapshot、
/// tasks.idea_id、project_modules.description/status(这些列由不同 vN 加,任一漏加此处失败)。 /// tasks.idea_id、tasks.module_id、project_modules.description/status(这些列由
/// 不同 vN 加,任一漏加此处失败)。
#[tokio::test] #[tokio::test]
async fn test_full_migration_on_fresh_db() { async fn test_full_migration_on_fresh_db() {
// 用 Database::open_in_memory 打开新库,内部自动跑 migrations::run() 全量迁移 // 用 Database::open_in_memory 打开新库,内部自动跑 migrations::run() 全量迁移
@@ -1989,8 +2017,13 @@ mod tests {
column_exists(&conn, "tasks", "idea_id"), column_exists(&conn, "tasks", "idea_id"),
"tasks.idea_id 列缺失(V1 建表已带)" "tasks.idea_id 列缺失(V1 建表已带)"
); );
// tasks.module_id(V41 老库 ALTER 补,新库也走 V41——新库应有)
assert!(
column_exists(&conn, "tasks", "module_id"),
"tasks.module_id 列缺失(V41 加)"
);
// 3. schema_version 应推进到 40(全量迁移成功落版本号) // 3. schema_version 应推进到 41(全量迁移成功落版本号)
let max_version: i64 = conn let max_version: i64 = conn
.query_row( .query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version", "SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -1999,8 +2032,8 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 40, max_version, 41,
"全量迁移后 schema_version 应为 40(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为 41(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version max_version
); );
@@ -2056,13 +2089,13 @@ mod tests {
assert_eq!(v_count, 1, "版本号 32 应只写一次"); assert_eq!(v_count, 1, "版本号 32 应只写一次");
} }
/// G5.5: 全链幂等不变量——V1-V40 每步执行两遍不抛错。 /// G5.5: 全链幂等不变量——V1-V41 每步执行两遍不抛错。
/// ///
/// 首轮 run() 建全 schema;清空 schema_version 强制下一轮从 V1 重跑每步 /// 首轮 run() 建全 schema;清空 schema_version 强制下一轮从 V1 重跑每步
/// (模拟存量库 + 崩溃重跑/版本号回退)。任何 migrate_vN 的裸 ALTER(无 column_exists /// (模拟存量库 + 崩溃重跑/版本号回退)。任何 migrate_vN 的裸 ALTER(无 column_exists
/// 守卫,如 v32 修前形态)都会在第二遍报 duplicate column 被此测试捕获。 /// 守卫,如 v32 修前形态)都会在第二遍报 duplicate column 被此测试捕获。
#[test] #[test]
fn v1_to_v40_full_chain_rerun_idempotent() { fn v1_to_v41_full_chain_rerun_idempotent() {
let conn = Connection::open_in_memory().expect("open in-memory db"); let conn = Connection::open_in_memory().expect("open in-memory db");
run(&conn).expect("首轮全量迁移应成功"); run(&conn).expect("首轮全量迁移应成功");
let max_v: i64 = conn let max_v: i64 = conn
@@ -2072,7 +2105,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 40, "首轮应推进到 40"); assert_eq!(max_v, 41, "首轮应推进到 41");
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2084,6 +2117,77 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v2, 40, "重跑后应重新推进到 40"); assert_eq!(max_v2, 41, "重跑后应重新推进到 41");
}
// ============================================================
// V41 迁移幂等安全(任务关联工程 module)
// ============================================================
/// 构造最小老库 schema:tasks 表(无 module_id 列,模拟 V40 前老形态)+ project_modules
/// 表(V41 外键引用目标,工程系统 V34)+ schema_version。
fn setup_legacy_tasks_no_module_db() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL
);
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo',
priority INTEGER NOT NULL DEFAULT 2,
branch_name TEXT,
assignee TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);",
)
.expect("create legacy tables");
conn
}
/// 老库无 module_id 列:迁移应补建 + 写版本号 41
#[test]
fn v41_legacy_db_adds_module_id_column() {
let conn = setup_legacy_tasks_no_module_db();
assert!(
!column_exists(&conn, "tasks", "module_id"),
"迁移前应无 module_id 列"
);
migrate_v41(&conn).expect("v41 应在老库补建 module_id 列");
assert!(
column_exists(&conn, "tasks", "module_id"),
"迁移后应有 module_id 列"
);
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 41, "应写入版本号 41");
}
/// 幂等重跑:列已存在时跳过 ALTER,不报 duplicate column(对齐 v20/v32 守卫模式)
#[test]
fn v41_column_exists_skips_alter() {
let conn = setup_legacy_tasks_no_module_db();
migrate_v41(&conn).expect("首次迁移");
assert!(column_exists(&conn, "tasks", "module_id"));
// 手动回退版本号模拟「列已存在但版本号未写」场景,验证 ALTER 被短路不报 duplicate column
conn.execute("DELETE FROM schema_version WHERE version = 41", [])
.unwrap();
migrate_v41(&conn).expect("列存在时应跳过 ALTER 不报错");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 41);
} }
} }
+7
View File
@@ -119,6 +119,13 @@ pub struct TaskRecord {
/// #[serde(default, skip_serializing_if = "Option::is_none")] 兼容旧 JSON + 无值不序列化。 /// #[serde(default, skip_serializing_if = "Option::is_none")] 兼容旧 JSON + 无值不序列化。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub content_json: Option<String>, pub content_json: Option<String>,
/// 所属工程 ID(任务关联到具体 module,工程系统 V41 列)。
/// 一个项目可含多个工程(Monorepo/微服务),本字段让任务落到具体工程,是后续
/// IPC / 前端 / AI 按工程筛选任务的数据基础。外键 REFERENCES project_modules(id)
/// ON DELETE SET NULL(module 删除时任务自动解关联)。可空(老任务无关联 → None)。
/// #[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)]
pub module_id: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -55,6 +55,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
queue: "todo".to_string(), queue: "todo".to_string(),
parent_id: None, parent_id: None,
content_json: None, content_json: None,
module_id: None,
created_at: now_ts(), created_at: now_ts(),
updated_at: now_ts(), updated_at: now_ts(),
} }
+11 -4
View File
@@ -1361,18 +1361,22 @@ mod tests {
// mongo_update/mongo_delete/redis_get/redis_set/redis_del,声明式注册在 tools/local_proxy.rs, // mongo_update/mongo_delete/redis_get/redis_set/redis_del,声明式注册在 tools/local_proxy.rs,
// build_ai_tool_registry 加第 5 参 LocalProxyCtx)。 // build_ai_tool_registry 加第 5 参 LocalProxyCtx)。
// 66 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。 // 66 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
// 工程模块写工具(2026-08-08): data 层 36→42(新增 create_module/update_module/delete_module +
// list_module_dependencies/add_module_dependency/remove_module_dependency,扩展 tools/task_graph.rs,
// 复用 module.rs CRUD 逻辑;delete_module 级联清理依赖边防外键约束)。
// 72 = 42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
assert_eq!( assert_eq!(
registry.len(), registry.len(),
66, 72,
"工具总数应为 66(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len() "工具总数应为 72(42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len()
); );
// 工具名集合基线:防 rename / 漏注册 / 误删除。 // 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 36 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 // data 层 42 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 + 工程模块写工具
// file 层 14 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载 // file 层 14 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载
// http 层 1 个(不持 db):http_request // http 层 1 个(不持 db):http_request
let mut expected: Vec<&str> = vec![ let mut expected: Vec<&str> = vec![
// ── data 层 (36) ── // ── data 层 (42) ──
"list_projects", "list_tasks", "list_ideas", "list_projects", "list_tasks", "list_ideas",
"update_project", "create_project", "bind_directory", "update_project", "create_project", "bind_directory",
"create_task", "update_task", "advance_task", "create_task", "update_task", "advance_task",
@@ -1389,6 +1393,9 @@ mod tests {
"add_project_service", "list_project_services", "add_project_service", "list_project_services",
// 工程系统:项目工程列表查询 // 工程系统:项目工程列表查询
"list_project_modules", "list_project_modules",
// 工程模块写工具(2026-08-08):create/update/delete 工程 + 依赖关系管理
"create_module", "update_module", "delete_module",
"list_module_dependencies", "add_module_dependency", "remove_module_dependency",
// Git 只读工具 // Git 只读工具
"git_status", "git_diff", "git_log", "git_status", "git_diff", "git_log",
// Git 写工具 // Git 写工具
+1
View File
@@ -141,6 +141,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
output_json: None, output_json: None,
// F-260619-01 可选关联灵感(空字符串/缺省视为不关联) // F-260619-01 可选关联灵感(空字符串/缺省视为不关联)
idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
module_id: args.get("module_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
// 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json // 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json
queue, queue,
parent_id, parent_id,
+322 -5
View File
@@ -1,24 +1,27 @@
//! 知识图谱 / 工程系统类 AI 工具声明式注册(register_task_graph_tools 10 个迁入)。 //! 知识图谱 / 工程系统类 AI 工具声明式注册(register_task_graph_tools 16 个迁入)。
//! //!
//! 迁自 `tool_registry.rs::register_task_graph_tools`(原 10 个:task_link CRUD + 父子树 //! 迁自 `tool_registry.rs::register_task_graph_tools`(原 10 个:task_link CRUD + 父子树
//! + 跨池移动 + content 更新 + 项目事件流 + 项目基础设施配置 + 工程列表),改用 `declare_tool!` 宏。 //! + 跨池移动 + content 更新 + 项目事件流 + 项目基础设施配置 + 工程列表),改用 `declare_tool!` 宏。
//! 后续在声明式注册中新增 6 个工程模块写工具(create_module/update_module/delete_module +
//! list/add/remove_module_dependency),共 16 个。
//! //!
//! 迁移策略(handler 逻辑零变更): //! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_task_graph_tools` 内 async move 块(逻辑等价), //! - handler body 逐字照搬原 `register_task_graph_tools` 内 async move 块(逻辑等价),
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); //! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。 //! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致。 //! - name/desc/schema/risk 与原手写定义逐字一致。
//! - 复用类型:new_id/ProjectServiceRecord/now_millis(各 pub(crate) 来源,与原同源)。 //! - 复用类型:new_id/ProjectServiceRecord/ProjectModuleRecord/ModuleDependencyRecord/now_millis
//! (各 pub(crate) 来源,与原同源)。
//! //!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 断言 48 总量 + //! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 断言工具名集合稳定
//! 工具名集合稳定(防 rename / 漏注册)。 //! (防 rename / 漏注册)。
use std::sync::Arc; use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool; use df_ai::declare_tool;
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::ProjectServiceRecord; use df_storage::models::{ModuleDependencyRecord, ProjectModuleRecord, ProjectServiceRecord};
use df_types::types::new_id; use df_types::types::new_id;
use crate::commands::now_millis; use crate::commands::now_millis;
@@ -410,4 +413,318 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
})) }))
} }
); );
// ── create_module (Medium 写库,工程系统) ──
// 创建工程(module)。复用 module.rs::add_project_module 的创建逻辑:
// trim + 非空校验 + sort_order 取当前项目工程数末尾追加,stack/description 空串归一 None。
// AI 用途:多工程场景 AI 主动登记新工程(发现新代码仓库 / 初始化子项目)。
declare_tool!(
registry,
db: Arc<Database>,
"create_module",
"创建工程(module,独立代码仓库)。参数:project_id(项目 ID)、name(工程名)、path(工程目录绝对路径)、stack(可选,技术栈,如 rust/vue)、description(可选,工程职责描述)。返回新建的完整工程记录(含 id/sort_order/status)。中等风险,写库需审批",
RiskLevel::Medium,
schema: object_schema(vec![
("project_id", "string", true),
("name", "string", true),
("path", "string", true),
("stack", "string", false),
("description", "string", false),
]),
args => {
let project_id = args["project_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
.trim()
.to_string();
let name = args["name"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 name"))?
.trim()
.to_string();
let path = args["path"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 path"))?
.trim()
.to_string();
if project_id.is_empty() {
anyhow::bail!("project_id 不能为空");
}
if name.is_empty() {
anyhow::bail!("name 不能为空");
}
if path.is_empty() {
anyhow::bail!("path 不能为空");
}
// 可选字段 trim + 空串归一 None(对齐 module.rs trim_opt 语义)。
let stack = args.get("stack")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let description = args.get("description")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
// sort_order 取当前项目下工程数(末尾追加,新工程排末位,对齐 add_project_module)。
let existing = repo.list_by_project(&project_id).await?;
let sort_order = existing.len() as i32;
let record = ProjectModuleRecord {
id: new_id(),
project_id,
name,
path,
git_url: None,
stack,
auto_detected: false,
sort_order,
created_at: now_millis(),
updated_at: now_millis(),
description,
// 未传 status → None(应用层视为 active,对齐 GUI add_project_module 语义)。
status: None,
};
let record_id = record.id.clone();
let ok = repo.insert(record).await?;
if !ok {
anyhow::bail!("插入工程记录失败");
}
let inserted = repo.get_by_id(&record_id).await?
.ok_or_else(|| anyhow::anyhow!("插入后回读失败"))?;
Ok(serde_json::to_value(&inserted)?)
}
);
// ── update_module (Medium 写库,工程系统) ──
// 更新工程(部分更新:仅传字段被覆盖,其余保留)。复用 module.rs::update_project_module
// 的部分更新语义:name/path 传空串拒绝;stack/description 传空串清空。
declare_tool!(
registry,
db: Arc<Database>,
"update_module",
"更新工程(module,部分更新:仅传入字段被覆盖,其余保留)。参数:id(工程 ID)、name/path/stack/description(可选,至少提供一个)。约束:name/path 不能为空串;stack/description 传空串清空。返回更新后的完整工程记录",
RiskLevel::Medium,
schema: object_schema(vec![
("id", "string", true),
("name", "string", false),
("path", "string", false),
("stack", "string", false),
("description", "string", false),
]),
args => {
let id = args["id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 id"))?
.trim()
.to_string();
if id.is_empty() {
anyhow::bail!("id 不能为空");
}
// 解析待更新字段(args.get 区分「未提供」vs「提供空串」:未提供保留原值)。
let name = args.get("name").and_then(|v| v.as_str());
let path = args.get("path").and_then(|v| v.as_str());
let stack = args.get("stack").and_then(|v| v.as_str());
let description = args.get("description").and_then(|v| v.as_str());
if name.is_none() && path.is_none() && stack.is_none() && description.is_none() {
anyhow::bail!("至少提供一个待更新字段 (name/path/stack/description)");
}
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
// 先校验存在(404 友好错误),再整体更新(保留不可变字段,对齐 update_project_module)。
let mut existing = repo.get_by_id(&id).await?
.ok_or_else(|| anyhow::anyhow!("工程 {id} 不存在"))?;
if let Some(n) = name {
let n = n.trim().to_string();
if n.is_empty() {
anyhow::bail!("name 不能为空");
}
existing.name = n;
}
if let Some(p) = path {
let p = p.trim().to_string();
if p.is_empty() {
anyhow::bail!("path 不能为空");
}
existing.path = p;
}
// stack/description 仅在传入时覆盖;空串归一 None(清空,对齐 trim_opt 语义)。
if let Some(s) = stack {
existing.stack = Some(s.trim().to_string())
.filter(|v| !v.is_empty());
}
if let Some(d) = description {
existing.description = Some(d.trim().to_string())
.filter(|v| !v.is_empty());
}
let hit = repo.update_full(&existing).await?;
if !hit {
anyhow::bail!("工程 {id} 不存在(更新未命中)");
}
let updated = repo.get_by_id(&id).await?
.ok_or_else(|| anyhow::anyhow!("更新后回读失败"))?;
Ok(serde_json::to_value(&updated)?)
}
);
// ── delete_module (High 破坏性,工程系统) ──
// 删除工程(硬删,对齐 GUI remove_project_module 语义)。
// 谨慎处理依赖关系:module_dependencies 表 from_module_id/to_module_id 均有外键
// REFERENCES project_modules(id),且 SQLite PRAGMA foreign_keys=ON——直接删被依赖
// 的工程会撞外键约束。故先级联删除引用该工程的依赖边,再删工程本体。
declare_tool!(
registry,
db: Arc<Database>,
"delete_module",
"删除工程(module,硬删,不可恢复)。参数:id(工程 ID)。高风险,需审批。若该工程被依赖关系引用(from 或 to),先级联删除相关依赖边再删除工程本体(防外键约束阻塞)。返回 { deleted, removed_dependencies, id }",
RiskLevel::High,
schema: object_schema(vec![
("id", "string", true),
]),
args => {
let id = args["id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 id"))?
.trim()
.to_string();
if id.is_empty() {
anyhow::bail!("id 不能为空");
}
let module_repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = module_repo.get_by_id(&id).await?
.ok_or_else(|| anyhow::anyhow!("工程 {id} 不存在"))?;
// 级联清理依赖边:先列项目全部依赖,过滤引用本工程的边逐条删除(外键约束先清引用)。
let dep_repo = df_storage::crud::ModuleDependencyRepo::new(&db);
let deps = dep_repo.list_by_field("project_id", &module.project_id).await?;
let mut removed_dependencies = 0usize;
for d in deps {
if d.from_module_id == id || d.to_module_id == id {
if dep_repo.delete(&d.id).await? {
removed_dependencies += 1;
}
}
}
let deleted = module_repo.delete(&id).await?;
Ok(serde_json::json!({
"deleted": deleted,
"removed_dependencies": removed_dependencies,
"id": id,
}))
}
);
// ── list_module_dependencies (Low 只读,工程系统) ──
// 列出项目工程依赖关系(AI 了解工程间调用/依赖拓扑,决定构建/部署顺序)。
declare_tool!(
registry,
db: Arc<Database>,
"list_module_dependencies",
"列出项目全部工程依赖关系(只读)。参数:project_id(项目 ID)。返回 { items: 依赖边列表(每条含 id/from_module_id/to_module_id/dep_type/label), total, project_id }。AI 据此了解工程间依赖拓扑,决定构建/部署顺序",
RiskLevel::Low,
schema: object_schema(vec![
("project_id", "string", true),
]),
args => {
let project_id = args["project_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
.trim()
.to_string();
if project_id.is_empty() {
anyhow::bail!("project_id 不能为空");
}
let repo = df_storage::crud::ModuleDependencyRepo::new(&db);
let items = repo.list_by_field("project_id", &project_id).await?;
let total = items.len();
Ok(serde_json::json!({
"items": items,
"total": total,
"project_id": project_id,
}))
}
);
// ── add_module_dependency (Medium 写库,工程系统) ──
// 添加工程依赖边(对齐 GUI add_module_dependency:dep_type 默认 library,label 可选)。
// AI 用途:登记工程间依赖(如 API 调用 / 共享库),供依赖图 / 拓扑排序使用。
declare_tool!(
registry,
db: Arc<Database>,
"add_module_dependency",
"添加工程依赖关系(module_dependencies 边,登记 from 依赖 to)。参数:project_id(项目 ID)、from_module_id(依赖方工程 ID)、to_module_id(被依赖方工程 ID)、dep_type(可选,依赖类型 library/api/mq/shared/custom,默认 library)、label(可选,备注)。返回新增完整记录。中等风险,写库需审批",
RiskLevel::Medium,
schema: object_schema(vec![
("project_id", "string", true),
("from_module_id", "string", true),
("to_module_id", "string", true),
("dep_type", "string", false),
("label", "string", false),
]),
args => {
let project_id = args["project_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
.trim()
.to_string();
let from_module_id = args["from_module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 from_module_id"))?
.trim()
.to_string();
let to_module_id = args["to_module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 to_module_id"))?
.trim()
.to_string();
if project_id.is_empty() || from_module_id.is_empty() || to_module_id.is_empty() {
anyhow::bail!("project_id / from_module_id / to_module_id 不能为空");
}
let dep_type = args.get("dep_type")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("library")
.to_string();
let label = args.get("label")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let record = ModuleDependencyRecord {
id: new_id(),
project_id,
from_module_id,
to_module_id,
dep_type,
label,
created_at: now_millis(),
};
let repo = df_storage::crud::ModuleDependencyRepo::new(&db);
let ok = repo.insert(record.clone()).await?;
if !ok {
anyhow::bail!("插入依赖记录失败");
}
Ok(serde_json::to_value(&record)?)
}
);
// ── remove_module_dependency (Medium 写库,工程系统) ──
// 删除工程依赖边(按依赖记录 id)。对齐 GUI remove_module_dependency 语义。
declare_tool!(
registry,
db: Arc<Database>,
"remove_module_dependency",
"删除工程依赖关系(按依赖记录 id)。参数:id(依赖记录 ID)。返回 deleted(布尔,是否命中,不存在则 false)。中等风险,写库需审批",
RiskLevel::Medium,
schema: object_schema(vec![
("id", "string", true),
]),
args => {
let id = args["id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 id"))?
.trim()
.to_string();
if id.is_empty() {
anyhow::bail!("id 不能为空");
}
let repo = df_storage::crud::ModuleDependencyRepo::new(&db);
let deleted = repo.delete(&id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
}
);
} }
+6
View File
@@ -69,6 +69,10 @@ pub struct CreateTaskInput {
/// 关联灵感 ID(F-260619-01,1对1 单向,可空=不关联)。 /// 关联灵感 ID(F-260619-01,1对1 单向,可空=不关联)。
/// 空字符串视为不关联(与 AI 工具层一致)。 /// 空字符串视为不关联(与 AI 工具层一致)。
pub idea_id: Option<String>, pub idea_id: Option<String>,
/// 关联工程 ID(多工程:任务属于某个 module,可空=不关联)。
/// 空字符串视为 None(向后兼容)。
#[serde(default)]
pub module_id: Option<String>,
/// 管理维度池(知识图谱 Phase 1 V29,对标设计 §2.1)。默认 "todo"(待办池)。 /// 管理维度池(知识图谱 Phase 1 V29,对标设计 §2.1)。默认 "todo"(待办池)。
/// 合法值:backlog / todo / decision / active / done。非法值在 IPC 层兜底校验。 /// 合法值:backlog / todo / decision / active / done。非法值在 IPC 层兜底校验。
/// 空字符串视为默认 todo(向后兼容,与 idea_id 一致处理)。 /// 空字符串视为默认 todo(向后兼容,与 idea_id 一致处理)。
@@ -304,6 +308,8 @@ pub async fn create_task(
output_json: None, output_json: None,
// F-260619-01:空字符串视为不关联(与 AI 工具层一致) // F-260619-01:空字符串视为不关联(与 AI 工具层一致)
idea_id: input.idea_id.filter(|s| !s.is_empty()), idea_id: input.idea_id.filter(|s| !s.is_empty()),
// 多工程:可选关联到具体工程(module)
module_id: input.module_id.filter(|s| !s.is_empty()),
// 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json // 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json
queue, queue,
parent_id, parent_id,