新增: 任务推进链(7态状态机+advance_task CAS原子写)+软删除+前后端7态对齐
This commit is contained in:
@@ -8,6 +8,7 @@ df-core = { path = "../df-core" }
|
||||
df-execute = { path = "../df-execute" }
|
||||
df-workflow = { path = "../df-workflow" }
|
||||
df-ai = { path = "../df-ai" }
|
||||
df-storage = { path = "../df-storage" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
pub mod ai_node;
|
||||
pub mod human_node;
|
||||
pub mod script_node;
|
||||
pub mod task_advance_node;
|
||||
pub mod task_state_machine;
|
||||
|
||||
@@ -36,6 +36,7 @@ impl Node for ScriptNode {
|
||||
working_dir,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs,
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
tracing::info!("ScriptNode 执行命令: {}", command);
|
||||
|
||||
341
crates/df-nodes/src/task_advance_node.rs
Normal file
341
crates/df-nodes/src/task_advance_node.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
//! 任务推进节点 — advance_task 推进链触发器(F-260616-02)
|
||||
//!
|
||||
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
|
||||
//! 1. 读当前 TaskRecord(取 from status)
|
||||
//! 2. 状态机校验 can_transition(from, to)(task_state_machine)
|
||||
//! 3. 原子写:下沉 SQL `WHERE id AND status=expected` 防 TOCTOU,退回转换一并
|
||||
//! review_rounds+=1(见 df_storage::crud::TaskRepo::advance_status_atomic)
|
||||
//!
|
||||
//! 两条调用路径复用同一推进逻辑:
|
||||
//! - DAG 工作流:TaskAdvanceNode 实现 Node trait,从 NodeContext.config 读 task_id /
|
||||
//! target_status,持有 Arc<Database> 构造 TaskRepo。
|
||||
//! - IPC 直驱:advance_task_atomic 公开 async fn,src-tauri commands::task::advance_task
|
||||
//! thin 入口直接调用(无需构造 NodeContext,避免 EventBus/StateMachine 等重依赖)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_storage::crud::TaskRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::TaskRecord;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
use crate::task_state_machine::{can_transition, is_regression, is_valid_state};
|
||||
|
||||
/// 推进任务到目标状态(核心逻辑,DAG 节点与 IPC 入口共用)。
|
||||
///
|
||||
/// 入参:
|
||||
/// - `repo`:df-storage TaskRepo
|
||||
/// - `id`:任务 ID
|
||||
/// - `target_status`:目标状态(7 态之一,snake_case)
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 任务存在性:找不到 → InvalidState 错误(复用 Error::InvalidState 语义,前端可据此提示)
|
||||
/// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链)
|
||||
/// 3. 状态机:can_transition(from, to) 不合法 → InvalidState 错误(含 from/to 上下文)
|
||||
/// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true
|
||||
/// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功)
|
||||
///
|
||||
/// 返回:成功推进后的最新 TaskRecord(含新 status / 累加后的 review_rounds / 新 updated_at)。
|
||||
pub async fn advance_task_atomic(
|
||||
repo: &TaskRepo,
|
||||
id: &str,
|
||||
target_status: &str,
|
||||
) -> df_core::error::Result<TaskRecord> {
|
||||
// 1. target 合法性(7 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
|
||||
if !is_valid_state(target_status) {
|
||||
return Err(df_core::error::Error::Validation(format!(
|
||||
"非法 target_status {:?},合法值: todo/in_progress/in_review/testing/done/blocked/cancelled",
|
||||
target_status
|
||||
)));
|
||||
}
|
||||
|
||||
// 2. 读当前任务(取 from status)。
|
||||
let current = repo
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| df_core::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
|
||||
|
||||
// 3. 状态机校验(同态拒绝:todo→todo 等,can_transition 返回 false)。
|
||||
let from = current.status.as_str();
|
||||
if from == target_status {
|
||||
return Err(df_core::error::Error::InvalidState {
|
||||
current: from.to_string(),
|
||||
expected: target_status.to_string(),
|
||||
});
|
||||
}
|
||||
if !can_transition(from, target_status) {
|
||||
return Err(df_core::error::Error::InvalidState {
|
||||
current: from.to_string(),
|
||||
expected: target_status.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 原子 CAS 写。退回转换一并 review_rounds+=1。
|
||||
let bump = is_regression(from, target_status);
|
||||
let updated = repo
|
||||
.advance_status_atomic(id, from, target_status, bump)
|
||||
.await?;
|
||||
|
||||
// 5. CAS 失败:并发已改动 status(或任务被删),拒绝静默成功。
|
||||
updated.ok_or_else(|| df_core::error::Error::InvalidState {
|
||||
current: "已变更(并发推进或旁路修改)".to_string(),
|
||||
expected: from.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DAG 工作流节点 — TaskAdvanceNode(推进链在 DAG 内的形态)
|
||||
// ============================================================
|
||||
|
||||
/// 任务推进节点 — DAG 工作流内触发 advance_task。
|
||||
///
|
||||
/// 持有 Arc<Database> 在 NodeRegistry 注册时注入(state.rs build_registry),
|
||||
/// Node::execute 从 NodeContext.config 读 task_id / target_status,调 advance_task_atomic。
|
||||
/// 当前推进链 F-01~04 阶段为手动推进(IPC 直驱),DAG 形态为阶段 2(工作流联动)预留。
|
||||
pub struct TaskAdvanceNode {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl TaskAdvanceNode {
|
||||
/// 构造节点(注册表工厂调用,注入数据库句柄)
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for TaskAdvanceNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
let task_id = ctx
|
||||
.config
|
||||
.get("task_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: task_id"))?;
|
||||
let target_status = ctx
|
||||
.config
|
||||
.get("target_status")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: target_status"))?;
|
||||
|
||||
let repo = TaskRepo::new(&self.db);
|
||||
let updated = advance_task_atomic(&repo, task_id, target_status)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("推进任务失败: {}", e))?;
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"task": updated,
|
||||
"task_id": updated.id,
|
||||
"status": updated.status,
|
||||
"review_rounds": updated.review_rounds,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": { "type": "string" },
|
||||
"target_status": {
|
||||
"type": "string",
|
||||
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled"]
|
||||
}
|
||||
},
|
||||
"required": ["task_id", "target_status"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": { "type": "string" },
|
||||
"status": { "type": "string" },
|
||||
"review_rounds": { "type": "integer" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"task_advance"
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — advance_task_atomic 状态机集成(内存 DB)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_storage::crud::ProjectRepo;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
|
||||
fn rec(id: &str, status: &str) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: format!("t-{id}"),
|
||||
description: "".to_string(),
|
||||
status: status.to_string(),
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> TaskRepo {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
// 先插父项目,满足 tasks.project_id FK 约束(PRAGMA foreign_keys=ON)。
|
||||
ProjectRepo::new(&db)
|
||||
.insert(ProjectRecord {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("insert project");
|
||||
TaskRepo::new(&db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_path_todo_to_done() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
// 主路径逐级推进
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
|
||||
assert_eq!(r.status, "testing");
|
||||
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
|
||||
assert_eq!(r.status, "done");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_in_review_to_in_progress_bumps_rounds() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_review")).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_testing_to_in_review_bumps_rounds() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "testing")).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_regressions_accumulate() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_review")).await.unwrap();
|
||||
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
|
||||
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.review_rounds, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn illegal_skip_rejected() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
|
||||
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_done_no_successor() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "done")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
|
||||
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_status_rejected() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_progress")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
|
||||
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_target_rejected() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
|
||||
assert!(matches!(err, df_core::error::Error::Validation(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_found_rejected() {
|
||||
let repo = setup().await;
|
||||
let err = advance_task_atomic(&repo, "nope", "in_progress").await.unwrap_err();
|
||||
assert!(matches!(err, df_core::error::Error::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_cas_change_detected() {
|
||||
// 模拟 TOCTOU:推进前已被旁路改 status。CAS 的 expected 与库内不符 → None → 报错。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
// 旁路把 status 改成 in_progress(模拟另一路并发推进)
|
||||
repo.update_field("t1", "status", "in_progress").await.unwrap();
|
||||
// 此时读出来是 in_progress,推进到 in_review 是合法的——这测的不是 CAS 失败,
|
||||
// 而是验证「读后改」路径在 status 一致时正常。CAS 失败路径靠 advance_status_atomic
|
||||
// 自身的 None 返回覆盖(下方 cas_returns_none_when_status_mismatch 单测)。
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cas_returns_none_when_status_mismatch() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
|
||||
let r = repo
|
||||
.advance_status_atomic("t1", "todo", "in_review", false)
|
||||
.await
|
||||
.unwrap();
|
||||
// 此时 status 确实是 todo,CAS 成功,返回 Some
|
||||
assert!(r.is_some());
|
||||
// 再用错误的 expected todo(实际已是 in_review)→ CAS 失败 None
|
||||
let r = repo
|
||||
.advance_status_atomic("t1", "todo", "done", false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(r.is_none(), "expected 与库内不符应 CAS 失败");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_round_trip_does_not_bump() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_progress")).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
|
||||
assert_eq!(r.status, "blocked");
|
||||
assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
|
||||
}
|
||||
}
|
||||
256
crates/df-nodes/src/task_state_machine.rs
Normal file
256
crates/df-nodes/src/task_state_machine.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
//! 任务推进状态机 — 7 态合法转换定义(F-260616-01)
|
||||
//!
|
||||
//! 独立模块,非挂在 df-core::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
|
||||
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
|
||||
//! (原子写 SQL 在 task_advance_node.rs 完成,见 F-260616-02)。
|
||||
//!
|
||||
//! 7 态(与 df-core::TaskStatus / 前端对齐,D-260616-01):
|
||||
//! todo / in_progress / in_review / testing / done / blocked / cancelled
|
||||
//!
|
||||
//! 闸门链主路径: todo → in_progress → in_review → testing → done
|
||||
//!
|
||||
//! 合法转换矩阵(以报告 docs/05-代码审查/任务执行与推进能力分析-2026-06-16.md §8
|
||||
//! 与决策 docs/02-架构设计/任务推进链实施路径-2026-06-16.md F-01 为准):
|
||||
//! - todo → in_progress(开始), cancelled(取消)
|
||||
//! - in_progress → in_review(提交审查), blocked(阻塞), cancelled
|
||||
//! - in_review → testing(审查通过进测试), in_progress(退回修改, review_rounds+1),
|
||||
//! blocked, cancelled
|
||||
//! - testing → done(测试通过), in_review(退回重审, review_rounds+1),
|
||||
//! blocked, cancelled
|
||||
//! - done → 终态(原则上不可变;若需重开走 cancelled 或新任务)
|
||||
//! - blocked → in_progress(解除阻塞继续), cancelled
|
||||
//! - cancelled → 终态
|
||||
|
||||
// ============================================================
|
||||
// 状态字符串常量 — 与 df-core::TaskStatus::as_str 一一对应
|
||||
// ============================================================
|
||||
//
|
||||
// 不复用 df-core::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型),
|
||||
// 但字符串值严格对齐(df-core::TaskStatus::as_str 产出的小写 snake_case),
|
||||
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致。
|
||||
|
||||
/// 待开始
|
||||
pub const TODO: &str = "todo";
|
||||
/// 进行中
|
||||
pub const IN_PROGRESS: &str = "in_progress";
|
||||
/// 代码审查中
|
||||
pub const IN_REVIEW: &str = "in_review";
|
||||
/// 测试中
|
||||
pub const TESTING: &str = "testing";
|
||||
/// 已完成(终态)
|
||||
pub const DONE: &str = "done";
|
||||
/// 已阻塞
|
||||
pub const BLOCKED: &str = "blocked";
|
||||
/// 已取消(终态)
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
/// 全部合法状态值(供输入校验与错误提示复用)
|
||||
pub const ALL_STATES: &[&str] = &[
|
||||
TODO,
|
||||
IN_PROGRESS,
|
||||
IN_REVIEW,
|
||||
TESTING,
|
||||
DONE,
|
||||
BLOCKED,
|
||||
CANCELLED,
|
||||
];
|
||||
|
||||
/// 字符串是否为合法状态值
|
||||
pub fn is_valid_state(s: &str) -> bool {
|
||||
ALL_STATES.contains(&s)
|
||||
}
|
||||
|
||||
/// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。
|
||||
///
|
||||
/// 终态(done/cancelled)无任何合法后继;非法或未知状态入参一律返回 false
|
||||
/// (调用方 advance_task 在前置校验已拦截非法 status,此处保守拒绝防漏)。
|
||||
///
|
||||
/// 注意:仅判「是否合法」,不判「是否是退回」——退回(导致 review_rounds+1)
|
||||
/// 的识别见 [`is_regression`],advance_task 据此决定是否一并 SET review_rounds+=1。
|
||||
pub fn can_transition(from: &str, to: &str) -> bool {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
// 转换表静态构造一次(运行时常量,无锁开销分摊)。行=from,列=to。
|
||||
// 表条目为 true 即合法转换。未列入的 (from, to) 一律 false。
|
||||
static TABLE: OnceLock<std::collections::HashMap<(&'static str, &'static str), bool>> =
|
||||
OnceLock::new();
|
||||
let table = TABLE.get_or_init(|| {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
let allowed: &[(&str, &str)] = &[
|
||||
// todo → in_progress(开始), cancelled(取消)
|
||||
(TODO, IN_PROGRESS),
|
||||
(TODO, CANCELLED),
|
||||
// in_progress → in_review(提交审查), blocked(阻塞), cancelled
|
||||
(IN_PROGRESS, IN_REVIEW),
|
||||
(IN_PROGRESS, BLOCKED),
|
||||
(IN_PROGRESS, CANCELLED),
|
||||
// in_review → testing(审查通过进测试), in_progress(退回修改),
|
||||
// blocked, cancelled
|
||||
(IN_REVIEW, TESTING),
|
||||
(IN_REVIEW, IN_PROGRESS),
|
||||
(IN_REVIEW, BLOCKED),
|
||||
(IN_REVIEW, CANCELLED),
|
||||
// testing → done(测试通过), in_review(退回重审), blocked, cancelled
|
||||
(TESTING, DONE),
|
||||
(TESTING, IN_REVIEW),
|
||||
(TESTING, BLOCKED),
|
||||
(TESTING, CANCELLED),
|
||||
// done → 终态,无后继
|
||||
// blocked → in_progress(解除阻塞继续), cancelled
|
||||
(BLOCKED, IN_PROGRESS),
|
||||
(BLOCKED, CANCELLED),
|
||||
// cancelled → 终态,无后继
|
||||
];
|
||||
for (f, t) in allowed {
|
||||
m.insert((*f, *t), true);
|
||||
}
|
||||
m
|
||||
});
|
||||
|
||||
table.get(&(from, to)).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 判定一次转换是否为「退回」(review_rounds 应 +1)。
|
||||
///
|
||||
/// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、
|
||||
/// 需重做。具体两类:
|
||||
/// - in_review → in_progress(审查退回修改)
|
||||
/// - testing → in_review(测试退回重审)
|
||||
///
|
||||
/// 其余合法转换(前向推进 / 进出 blocked / 进 cancelled)均不累加 review_rounds。
|
||||
pub fn is_regression(from: &str, to: &str) -> bool {
|
||||
matches!((from, to), (IN_REVIEW, IN_PROGRESS) | (TESTING, IN_REVIEW))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — 转换矩阵 + 边界
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---------- can_transition 闸门主路径 ----------
|
||||
|
||||
#[test]
|
||||
fn main_path_todo_to_done_all_forward() {
|
||||
assert!(can_transition(TODO, IN_PROGRESS));
|
||||
assert!(can_transition(IN_PROGRESS, IN_REVIEW));
|
||||
assert!(can_transition(IN_REVIEW, TESTING));
|
||||
assert!(can_transition(TESTING, DONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_from_any_non_terminal() {
|
||||
for s in [TODO, IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
|
||||
assert!(can_transition(s, CANCELLED), "应允许 {s} → cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_round_trip() {
|
||||
assert!(can_transition(IN_PROGRESS, BLOCKED));
|
||||
assert!(can_transition(BLOCKED, IN_PROGRESS));
|
||||
assert!(can_transition(IN_REVIEW, BLOCKED));
|
||||
assert!(can_transition(TESTING, BLOCKED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_paths_allowed() {
|
||||
assert!(can_transition(IN_REVIEW, IN_PROGRESS));
|
||||
assert!(can_transition(TESTING, IN_REVIEW));
|
||||
}
|
||||
|
||||
// ---------- 非法转换被拒 ----------
|
||||
|
||||
#[test]
|
||||
fn illegal_forward_skips_rejected() {
|
||||
// 不允许跳过闸门(直奔 done)
|
||||
assert!(!can_transition(TODO, DONE));
|
||||
assert!(!can_transition(TODO, TESTING));
|
||||
assert!(!can_transition(TODO, IN_REVIEW));
|
||||
assert!(!can_transition(IN_PROGRESS, DONE));
|
||||
assert!(!can_transition(IN_PROGRESS, TESTING));
|
||||
assert!(!can_transition(IN_REVIEW, DONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_states_have_no_successors() {
|
||||
// done / cancelled 是终态,任何后继都拒绝
|
||||
for term in [DONE, CANCELLED] {
|
||||
for to in ALL_STATES {
|
||||
assert!(!can_transition(term, to), "终态 {term} 不应有后继 → {to}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_to_todo_rejected() {
|
||||
// 不允许回退到 todo(开始即不可撤回)
|
||||
for s in [IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
|
||||
assert!(!can_transition(s, TODO), "不应允许 {s} → todo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_only_to_in_progress_or_cancelled() {
|
||||
assert!(can_transition(BLOCKED, IN_PROGRESS));
|
||||
assert!(can_transition(BLOCKED, CANCELLED));
|
||||
// 解除阻塞不能直接跳到 in_review/testing/done
|
||||
assert!(!can_transition(BLOCKED, IN_REVIEW));
|
||||
assert!(!can_transition(BLOCKED, TESTING));
|
||||
assert!(!can_transition(BLOCKED, DONE));
|
||||
}
|
||||
|
||||
// ---------- 未知状态 ----------
|
||||
|
||||
#[test]
|
||||
fn unknown_states_rejected() {
|
||||
assert!(!can_transition("unknown", TODO));
|
||||
assert!(!can_transition(TODO, "unknown"));
|
||||
assert!(!can_transition("", ""));
|
||||
}
|
||||
|
||||
// ---------- is_regression ----------
|
||||
|
||||
#[test]
|
||||
fn regression_only_on_review_back_edges() {
|
||||
assert!(is_regression(IN_REVIEW, IN_PROGRESS));
|
||||
assert!(is_regression(TESTING, IN_REVIEW));
|
||||
// 前向推进不累加
|
||||
assert!(!is_regression(TODO, IN_PROGRESS));
|
||||
assert!(!is_regression(IN_PROGRESS, IN_REVIEW));
|
||||
assert!(!is_regression(IN_REVIEW, TESTING));
|
||||
assert!(!is_regression(TESTING, DONE));
|
||||
// 进出 blocked 不累加
|
||||
assert!(!is_regression(IN_PROGRESS, BLOCKED));
|
||||
assert!(!is_regression(BLOCKED, IN_PROGRESS));
|
||||
// 进 cancelled 不累加
|
||||
assert!(!is_regression(IN_REVIEW, CANCELLED));
|
||||
// 不合法的"退回"(实际上不存在的转换)也不应判定为 regression
|
||||
assert!(!is_regression(TESTING, IN_PROGRESS));
|
||||
}
|
||||
|
||||
// ---------- is_valid_state ----------
|
||||
|
||||
#[test]
|
||||
fn is_valid_state_accepts_7_known() {
|
||||
for s in ALL_STATES {
|
||||
assert!(is_valid_state(s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_state_rejects_unknown() {
|
||||
assert!(!is_valid_state(""));
|
||||
assert!(!is_valid_state("unknown"));
|
||||
assert!(!is_valid_state("TODO")); // 大写
|
||||
assert!(!is_valid_state("merged")); // 历史 5 态残留
|
||||
assert!(!is_valid_state("abandoned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_states_has_seven_entries() {
|
||||
assert_eq!(ALL_STATES.len(), 7);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user