新增: 任务灵感归档后端

This commit is contained in:
lxy
2026-10-05 00:35:01 +08:00
parent c8c13d302a
commit f4d31e884a
17 changed files with 354 additions and 48 deletions
+88 -28
View File
@@ -15,6 +15,20 @@ use crate::models::TaskRecord;
use super::impl_repo;
use super::{now_millis_str, storage_err, validate_column_name};
// ============================================================
// 列清单(防 COLS 漂移,对标 idea_repo.rs IDEA_COLS 先例)
// ============================================================
/// `tasks` 表 20 列(顺序与 TaskRecord 结构体一致,不含 deleted_at)。
///
/// 19→20 列(归档功能 P1 V46 加 archived)。此前散落 8 处内联列名串
/// (list_active/get_by_id_active/advance_status_atomic 回读/list_active_by_project/
/// list_by_query/get_children/move_task_queue/list_deleted),加列须处处同步,
/// 漏一处只在运行时 rusqlite 报错。集中一处 + from_row 按名取列,加列漏改测试期捕获。
pub(crate) const TASK_COLS: &str = "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, archived, created_at, updated_at";
// ============================================================
// from_row 辅助函数
// ============================================================
@@ -52,6 +66,8 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
content_json: row.get("content_json")?,
// 工程系统 V41:任务关联具体工程(module_id),18→19 列同步之一。
module_id: row.get("module_id")?,
// 归档功能 P1 V46:20 列同步之一(SQLite 无真 BOOLEAN,0→false/非0→true)。
archived: row.get::<_, i32>("archived")? != 0,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
@@ -138,26 +154,26 @@ 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, 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, ?19)",
"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, archived, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
params![
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.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
rec.created_at, rec.updated_at
rec.archived as i32, 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, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18",
"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, archived = ?17, updated_at = ?18 WHERE id = ?19",
params![
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.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
rec.updated_at, rec.id
rec.archived as i32, rec.updated_at, rec.id
],
)
}
@@ -173,7 +189,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, 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")
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE deleted_at IS NULL AND archived = 0 ORDER BY created_at DESC"))
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| task_from_row(row))
@@ -240,7 +256,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, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE id = ?1 AND deleted_at IS NULL")
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?1 AND deleted_at IS NULL AND archived = 0"))
.map_err(storage_err)?;
let row = stmt
.query_row(params![id], |row| task_from_row(row))
@@ -285,10 +301,10 @@ impl TaskRepo {
// 返回 None,杜绝回收站任务被推进(D-02 软删语义收口,一处关闭)。
let sql = if bump_rounds {
"UPDATE tasks SET status = ?1, review_rounds = review_rounds + 1, updated_at = ?2 \
WHERE id = ?3 AND status = ?4 AND deleted_at IS NULL"
WHERE id = ?3 AND status = ?4 AND deleted_at IS NULL AND archived = 0"
} else {
"UPDATE tasks SET status = ?1, updated_at = ?2 \
WHERE id = ?3 AND status = ?4 AND deleted_at IS NULL"
WHERE id = ?3 AND status = ?4 AND deleted_at IS NULL AND archived = 0"
};
let affected = guard
.execute(sql, params![new_status, now, id, expected])
@@ -298,7 +314,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, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE id = ?1")
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?1"))
.map_err(storage_err)?;
let row = stmt
.query_row(params![id], |row| task_from_row(row))
@@ -320,7 +336,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, 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")
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE deleted_at IS NULL AND archived = 0 AND project_id = ?1 ORDER BY created_at DESC"))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pid], |row| task_from_row(row))
@@ -389,7 +405,7 @@ impl TaskRepo {
// 真参数拿到 ?2 而 params_vec 只有 1 个元素 → rusqlite "needed 2, got 1"。
// 用 params_vec.len()+1 保证 ?N 与参数位置严格对齐(N = 参数序号)。
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string(), "archived = 0".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(pid) = &project_id {
@@ -454,9 +470,7 @@ impl TaskRepo {
// 显式列出全部 19 列(含 V29 queue/parent_id/content_json 与 V41 module_id,
// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
let sql = format!(
"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 \
"SELECT {TASK_COLS} \
FROM tasks WHERE {} ORDER BY {} DESC{}",
where_clauses.join(" AND "),
order_col,
@@ -511,7 +525,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string(), "archived = 0".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ref pid) = project_id {
@@ -581,7 +595,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, 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")
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE deleted_at IS NULL AND archived = 0 AND parent_id = ?1 ORDER BY created_at ASC"))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pid], |row| task_from_row(row))
@@ -618,7 +632,7 @@ impl TaskRepo {
let mut stmt = guard
.prepare(
"SELECT status, COUNT(*) AS cnt FROM tasks \
WHERE deleted_at IS NULL AND parent_id = ?1 \
WHERE deleted_at IS NULL AND archived = 0 AND parent_id = ?1 \
GROUP BY status",
)
.map_err(storage_err)?;
@@ -660,7 +674,7 @@ impl TaskRepo {
let affected = guard
.execute(
"UPDATE tasks SET status = ?1, updated_at = ?2 \
WHERE id = ?3 AND deleted_at IS NULL",
WHERE id = ?3 AND deleted_at IS NULL AND archived = 0",
params![new_status, now, id],
)
.map_err(storage_err)?;
@@ -716,11 +730,6 @@ impl TaskRepo {
let id = id.to_owned();
let new_queue = new_queue.to_owned();
let now = now_millis_str();
// 显式列出全部 19 列(同 from_row 消费列,不 SELECT deleted_at:
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
const TASK_COLS: &str = "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";
tokio::task::spawn_blocking(move || {
let mut guard = conn.blocking_lock();
let tx = guard.transaction().map_err(storage_err)?;
@@ -729,7 +738,7 @@ impl TaskRepo {
let current: Option<TaskRecord> = {
let mut stmt = tx
.prepare(&format!(
"SELECT {TASK_COLS} FROM tasks WHERE id = ?1 AND deleted_at IS NULL"
"SELECT {TASK_COLS} FROM tasks WHERE id = ?1 AND deleted_at IS NULL AND archived = 0"
))
.map_err(storage_err)?;
stmt.query_row(params![id], |row| task_from_row(row))
@@ -793,7 +802,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, 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")
.prepare(&format!("SELECT {TASK_COLS} 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))
@@ -833,7 +842,7 @@ impl TaskRepo {
.execute(
// deleted_at IS NULL:回收站任务不可被 CAS 覆盖——MCP 拿旧 expected 也能
// 命中已软删行,造成回收站内容被改且 updated_at 抖动,调用方无从感知。
"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 AND updated_at = ?19 AND deleted_at IS NULL",
"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 AND updated_at = ?19 AND deleted_at IS NULL AND archived = 0",
params![
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,
@@ -859,7 +868,7 @@ impl TaskRepo {
validate_column_name(field, "tasks")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE tasks SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
"UPDATE tasks SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL AND archived = 0",
field
);
let id = id.to_owned();
@@ -875,6 +884,56 @@ impl TaskRepo {
.await
.map_err(storage_err)?
}
/// 归档/取消归档(归档功能 P1,V46)。
///
/// 纯元数据标记(对标 ConversationRepo::set_archived),仅作用于未软删任务:
/// 软删(回收站)优先级高于归档——回收站任务不可直接归档/取消归档,先恢复再操作。
/// archived=true 后任务冻结(活跃查询恒过滤,不可推进/跨池/字段更新),仅在归档视图可见。
/// 返回是否命中。
pub async fn set_archived(&self, id: &str, archived: bool) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE tasks SET archived = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
params![if archived { 1 } else { 0 }, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 列出归档任务(archived = 1 且未软删),按更新时间降序(最近归档在前)。
///
/// 归档视图数据源。与 list_active 对偶:活跃查询恒带 archived = 0,
/// 本方法恒带 archived = 1,两集合互斥且并集 = 全部未删任务。
pub async fn list_archived(&self) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&format!(
"SELECT {TASK_COLS} FROM tasks WHERE deleted_at IS NULL AND archived = 1 ORDER BY updated_at DESC"
))
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| task_from_row(row))
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}
#[cfg(test)]
@@ -909,6 +968,7 @@ mod tests {
parent_id: parent_id.map(|s| s.to_string()),
content_json: None,
module_id: None,
archived: false,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}