新增: 任务推进链(7态状态机+advance_task CAS原子写)+软删除+前后端7态对齐

This commit is contained in:
2026-06-16 02:33:15 +08:00
parent f30df333b3
commit d2cb38cdac
21 changed files with 1628 additions and 167 deletions

View File

@@ -310,7 +310,8 @@ impl SettingsRepo {
/// 白名单两重作用:① 防注入(列名参数化前校验);② 按表隔离(update_task 误传
/// projects 的 "name" 会在校验阶段拒绝,而非靠 SQLite "no such column" 兜底报错)。
/// 专用更新路径的列不列入:knowledges.embedding(set_embedding)、projects.deleted_at
/// (soft_delete/restore)。未登记的表返回 None → 放行(仅靠参数化防注入,向后兼容)。
/// (soft_delete/restore)、tasks.deleted_at(soft_delete/restore)。未登记的表返回 None
/// → 放行(仅靠参数化防注入,向后兼容)。
pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
Some(match table {
"ideas" => &[
@@ -322,8 +323,13 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"updated_at",
],
"tasks" => &[
"id", "project_id", "title", "description", "status", "priority", "branch_name",
"assignee", "workflow_def_id", "base_branch", "created_at", "updated_at",
// id/created_at 不列入:主键与创建时间不可通过通用 update_field 改写
// (防篡改主键/伪造创建时间/跨项目移动)。
"project_id", "title", "description", "status", "priority", "branch_name",
"assignee", "workflow_def_id", "base_branch", "updated_at",
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。
// project_id 当前可在白名单内改写,合法目标存在性由上层命令层校验。
],
"releases" => &[
"id", "project_id", "version", "status", "task_ids", "changelog", "created_at",
@@ -464,6 +470,7 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
assignee: row.get("assignee")?,
workflow_def_id: row.get("workflow_def_id")?,
base_branch: row.get("base_branch")?,
review_rounds: row.get("review_rounds")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
@@ -724,27 +731,173 @@ 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, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
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.created_at, rec.updated_at
rec.review_rounds, 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, updated_at = ?10 WHERE id = ?11",
"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, updated_at = ?11 WHERE id = ?12",
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.updated_at, rec.id
rec.review_rounds, rec.updated_at, rec.id
],
)
}
);
impl TaskRepo {
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
///
/// 显式列出 13 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone();
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, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
.query_map([], |row| task_from_row(row))
.map_err(|e| Error::Storage(e.to_string()))?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(|e| Error::Storage(e.to_string()))?);
}
Ok(results)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删任务,返回是否命中。
/// 对标 ProjectRepo::soft_delete。
pub async fn soft_delete(&self, id: &str) -> 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 deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
params![now, id],
)
.map_err(|e| Error::Storage(e.to_string()))?;
Ok(affected > 0)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
/// 恢复:清 deleted_at(从回收站还原)。仅作用于已删任务,返回是否命中。
/// 对标 ProjectRepo::restore。
pub async fn restore(&self, id: &str) -> 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 deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL",
params![now, id],
)
.map_err(|e| Error::Storage(e.to_string()))?;
Ok(affected > 0)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
/// 原子推进任务状态(任务推进链 F-260616-02 唯一 status 写入路径)
///
/// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU:
/// 并发推进/旁路修改若已改 status,affected_rows==0,本方法返回 None,调用方
/// (task_advance_node)据此报「状态已变,推进中止」。`review_rounds` 不进通用
/// update_field 白名单(收口:仅本方法可改 status 与 review_rounds)。
///
/// - `expected`:调用方读取的当前 status(状态机校验时的 from),CAS 前置。
/// - `new_status`:目标 status(状态机 can_transition 已校验合法)。
/// - `bump_rounds`:退回转换(in_review→in_progress / testing→in_review)传 true,
/// 一并 `review_rounds = review_rounds + 1`(同 UPDATE 原子,避免读改写竞争)。
/// 前向推进 / 进出 blocked / 进 cancelled 传 false,不动 review_rounds。
///
/// 返回:成功推进返回更新后的 TaskRecord;affected==0(状态已变/任务不存在)返回 None。
pub async fn advance_status_atomic(
&self,
id: &str,
expected: &str,
new_status: &str,
bump_rounds: bool,
) -> Result<Option<TaskRecord>> {
let conn = self.conn.clone();
let id = id.to_owned();
let expected = expected.to_owned();
let new_status = new_status.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// CAS:WHERE id AND status=expected 锁定当前态;affected==0 即并发已改动。
let sql = if bump_rounds {
"UPDATE tasks SET status = ?1, review_rounds = review_rounds + 1, updated_at = ?2 \
WHERE id = ?3 AND status = ?4"
} else {
"UPDATE tasks SET status = ?1, updated_at = ?2 \
WHERE id = ?3 AND status = ?4"
};
let affected = guard
.execute(sql, params![new_status, now, id, expected])
.map_err(|e| Error::Storage(e.to_string()))?;
if affected == 0 {
return Ok(None);
}
// 回读更新后的记录(含新 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, created_at, updated_at FROM tasks WHERE id = ?1")
.map_err(|e| Error::Storage(e.to_string()))?;
let row = stmt
.query_row(params![id], |row| task_from_row(row))
.optional()
.map_err(|e| Error::Storage(e.to_string()))?;
Ok(row)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
///
/// 注:任务表无专用 list_active_by_project 方法,按项目列活跃任务由 commands/task.rs
/// 的 list_tasks 用 list_active 后内存过滤 project_id 实现(任务量小,无需 SQL 下推)。
pub async fn list_deleted(&self) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone();
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, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
.query_map([], |row| task_from_row(row))
.map_err(|e| Error::Storage(e.to_string()))?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(|e| Error::Storage(e.to_string()))?);
}
Ok(results)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
}
impl_repo!(
/// 分支表 CRUD
BranchRepo,

View File

@@ -84,6 +84,14 @@ pub fn run(conn: &Connection) -> Result<()> {
migrate_v13(conn)?;
}
if current_version < 14 {
migrate_v14(conn)?;
}
if current_version < 15 {
migrate_v15(conn)?;
}
Ok(())
}
@@ -267,6 +275,41 @@ fn migrate_v13(conn: &Connection) -> Result<()> {
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(())
}
/// V1 建表 SQL
const V1_SQL: &str = "
-- 想法表
@@ -301,7 +344,8 @@ CREATE TABLE IF NOT EXISTS tasks (
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo',
priority INTEGER NOT NULL DEFAULT 1,
-- priority 默认 2 对齐 task.rs default_priority()=2(medium)
priority INTEGER NOT NULL DEFAULT 2,
branch_name TEXT,
assignee TEXT,
created_at TEXT NOT NULL,

View File

@@ -61,6 +61,11 @@ pub struct TaskRecord {
pub assignee: Option<String>,
pub workflow_def_id: Option<String>, // 关联的工作流定义 ID
pub base_branch: Option<String>, // 基础分支
/// review 退回累计轮数(in_review→in_progress / testing→in_review 时 +1,
/// 由 advance_task 原子写入,见 df-nodes::task_advance_node)。
/// #[serde(default)] 兼容旧前端无该字段的 JSON(老任务记录默认 0)。
#[serde(default)]
pub review_rounds: i32,
pub created_at: String,
pub updated_at: String,
}

View File

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