新增: 父子任务支持(数据→后端→前端全链路,会话前基线收尾)
- df-nodes task_advance_node(父聚合推进)+ task.rs 命令(create parent_id 支持/delete 级联软删子任务)+ task_graph 工具 - 前端 Tasks 树形列表(折叠箭头/子进度徽章/缩进)+ 新建弹窗父任务下拉 + TaskDetail 父面包屑/子任务面板 - 设计文档: 父子任务支持设计-2026-08-04
This commit is contained in:
@@ -203,10 +203,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let target_status = args["target_status"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
|
||||
// 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds),
|
||||
// 与 commands::task::advance_task IPC 同源,避免双轨。
|
||||
// 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds + 父聚合),
|
||||
// 与 commands::task::advance_task IPC 同源,避免双轨。设计 D3:子任务推进后
|
||||
// 自动触发父 status 聚合(advance_task_with_parent,父聚合失败仅 warn 不阻断)。
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let updated = df_nodes::task_advance_node::advance_task_atomic(
|
||||
let updated = df_nodes::task_advance_node::advance_task_with_parent(
|
||||
&repo, id, target_status,
|
||||
).await?;
|
||||
// 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
|
||||
|
||||
@@ -138,11 +138,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
|
||||
// ── move_task_queue (Medium,设计 §2.1 + §五) ──
|
||||
// 跨池移动任务(backlog/todo/decision/active/done),按一致性约束联动 status。
|
||||
// 对齐 commands::task::move_task_queue 语义:
|
||||
// 全部逻辑收口到 TaskRepo::move_task_queue 单事务方法(读当前 → 联动 status →
|
||||
// 写 queue+status 于同一 transaction),与 commands::task::move_task_queue 共用,防漂移。
|
||||
// - done → status 强制 done / backlog → status 强制 todo
|
||||
// - active → status 若不在执行中三态则强制 in_progress / todo → status 强制 todo
|
||||
// - decision → status 不变(待决策池保留执行态)
|
||||
// status 写入走专用 set_status_for_aggregation(绕过 status 收口,move_task_queue 是合法非状态机路径)。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
@@ -156,50 +156,14 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 new_queue"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
// queue 白名单校验(对标 commands::task::validate_queue)
|
||||
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
|
||||
if !TASK_QUEUE_VALUES.contains(&new_queue.as_str()) {
|
||||
anyhow::bail!("非法 new_queue 值 {:?},合法值: {:?}", new_queue, TASK_QUEUE_VALUES);
|
||||
}
|
||||
|
||||
// 单事务原子移动:读当前 → 一致性联动 status → 写 queue+status 全在
|
||||
// TaskRepo::move_task_queue 同一 transaction 内完成(queue 白名单/联动规则单点)。
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let current = repo
|
||||
.get_by_id(id)
|
||||
let updated = repo
|
||||
.move_task_queue(id, &new_queue)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在"))?;
|
||||
|
||||
// 一致性约束联动:根据 new_queue 决定 status 是否需调整
|
||||
// (对标 commands::task::move_task_queue 同源逻辑,单一真相源)
|
||||
const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"];
|
||||
let new_status = match new_queue.as_str() {
|
||||
"done" => "done".to_string(),
|
||||
"backlog" => "todo".to_string(),
|
||||
"active" => {
|
||||
if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) {
|
||||
current.status.as_str().to_string()
|
||||
} else {
|
||||
"in_progress".to_string()
|
||||
}
|
||||
}
|
||||
"todo" => "todo".to_string(),
|
||||
"decision" => current.status.as_str().to_string(),
|
||||
_ => unreachable!("queue 白名单已收口"),
|
||||
};
|
||||
|
||||
// 写 queue(queue 已在 tasks 白名单);与 current 不同才写(避免无谓 updated_at 抖动)
|
||||
if current.queue != new_queue {
|
||||
repo.update_field(id, "queue", &new_queue).await?;
|
||||
}
|
||||
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径)
|
||||
if current.status.as_str() != new_status {
|
||||
repo.set_status_for_aggregation(id, &new_status).await?;
|
||||
}
|
||||
|
||||
// 回读最新记录返回
|
||||
let updated = repo
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在(移动后回读失败)"))?;
|
||||
Ok(serde_json::to_value(&updated)?)
|
||||
}
|
||||
);
|
||||
|
||||
+53
-145
@@ -440,13 +440,39 @@ pub async fn update_task(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 删除任务结果(级联删子任务后返回)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TaskDeleteResult {
|
||||
/// 是否删除成功
|
||||
pub ok: bool,
|
||||
/// 级联软删的子任务数
|
||||
pub cascaded: i32,
|
||||
}
|
||||
|
||||
/// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
|
||||
///
|
||||
/// **级联软删子任务(设计 D4,容器语义)**:删除父任务即删整个工作单元。先 `get_children(id)`
|
||||
/// 取未删子任务列表,逐个 `soft_delete` 计数(cascaded),再 `soft_delete(id)` 父任务。
|
||||
/// 1 级嵌套(设计 D2),仅一层子任务,无需递归。
|
||||
///
|
||||
/// 埋点 task_deleted(问题3 项目最近活跃排序):删除是业务事件,推动项目活跃时间。
|
||||
/// best-effort 不阻断。读 project_id 一次轻量读(soft_delete 返 bool 不带 project_id)。
|
||||
#[tauri::command]
|
||||
pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
pub async fn delete_task(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<TaskDeleteResult, String> {
|
||||
let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?;
|
||||
// 级联软删子任务(容器语义 D4):逐个 soft_delete 并计数
|
||||
let children = state.tasks.get_children(&id).await.map_err(err_str)?;
|
||||
let mut cascaded = 0;
|
||||
for child in &children {
|
||||
let child_ok = state.tasks.soft_delete(&child.id).await.map_err(err_str)?;
|
||||
if child_ok {
|
||||
cascaded += 1;
|
||||
}
|
||||
}
|
||||
// 软删父任务本身
|
||||
let ok = state.tasks.soft_delete(&id).await.map_err(err_str)?;
|
||||
if ok {
|
||||
if let Some(r) = rec {
|
||||
@@ -462,7 +488,7 @@ pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool,
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(ok)
|
||||
Ok(TaskDeleteResult { ok, cascaded })
|
||||
}
|
||||
|
||||
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
|
||||
@@ -491,16 +517,16 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool
|
||||
|
||||
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
|
||||
///
|
||||
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)
|
||||
/// 落 df-nodes::task_advance_node::advance_task_atomic,本命令只做参数转发与错误串化。
|
||||
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合)
|
||||
/// 落 df-nodes::task_advance_node,本命令只做参数转发与错误串化。
|
||||
///
|
||||
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
|
||||
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
|
||||
///
|
||||
/// **知识图谱 Phase 1 V29 父聚合(对标设计 §2.1)**:推进完成后,若推进的任务有 parent_id,
|
||||
/// 触发父任务 status 重算(recompute_parent_status)。父任务=容器模型,status 不走状态机,
|
||||
/// 由子任务聚合计算(聚合规则见 recompute_parent_status)。聚合失败不阻断推进(best-effort,
|
||||
/// 对标设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
|
||||
/// **知识图谱 Phase 1 V29 父聚合(设计 §2.1)**:推进走 `advance_task_with_parent`,推进完成后
|
||||
/// 若任务有 parent_id,自动触发父任务 status 重算(recompute_parent_status,df-nodes 共享层
|
||||
/// D3)。父任务=容器模型,status 不走状态机,由子任务聚合计算。聚合失败不阻断推进(best-effort,
|
||||
/// 设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
|
||||
///
|
||||
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
|
||||
#[tauri::command]
|
||||
@@ -518,7 +544,9 @@ pub async fn advance_task(
|
||||
.map_err(err_str)?
|
||||
.map(|t| t.status);
|
||||
|
||||
let updated = df_nodes::task_advance_node::advance_task_atomic(
|
||||
// 推进 + 父聚合(df-nodes 共享层 D3,与 AI 工具/MCP 同源,消除双轨):
|
||||
// 子任务推进成功后自动触发父 status 聚合(容器模型,聚合失败仅 warn 不阻断)。
|
||||
let updated = df_nodes::task_advance_node::advance_task_with_parent(
|
||||
&state.tasks,
|
||||
&id,
|
||||
&target_status,
|
||||
@@ -526,21 +554,6 @@ pub async fn advance_task(
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
|
||||
// 父聚合(知识图谱 Phase 1 V29):推进的子任务有 parent_id → 重算父 status。
|
||||
// best-effort:聚合失败不阻断推进(子任务已成功推进是主结果,父 status 漂移可后续修正),
|
||||
// 仅 warn 日志记录。父任务 status 不走状态机,经专用方法 set_status_for_aggregation
|
||||
// 直接写(绕过 D-260616-04 status 收口:父任务=容器模型,聚合规则是唯一非状态机写入路径)。
|
||||
if let Some(pid) = &updated.parent_id {
|
||||
if let Err(e) = recompute_parent_status(&state, pid).await {
|
||||
tracing::warn!(
|
||||
task_id = %id,
|
||||
parent_id = %pid,
|
||||
error = %e,
|
||||
"[父聚合] 重算父任务 status 失败(不阻断子任务推进)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_advanced 事件。best-effort 不阻断。
|
||||
emit_event(
|
||||
&state,
|
||||
@@ -556,84 +569,6 @@ pub async fn advance_task(
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识图谱 Phase 1:父任务聚合(对标设计 §2.1 父聚合规则)
|
||||
// ============================================================
|
||||
|
||||
/// 父任务 status 重算(对标设计 §2.1 聚合规则,D3 父任务=容器模型)。
|
||||
///
|
||||
/// 聚合规则(设计 §2.1「父任务推导 status」表,优先级从高到低):
|
||||
/// 1. 任一子 blocked → 父 blocked(阻塞优先,避免掩盖卡点)
|
||||
/// 2. 任一子 in_progress → 父 in_progress(执行中)
|
||||
/// 3. 全子 done/cancelled → 父 done(全部完成/取消)
|
||||
/// 4. 全子 todo → 父 todo(尚未开始)
|
||||
/// 5. 其他混合态(如 todo+done) → 父 in_progress(进行中,有进展未全完)
|
||||
///
|
||||
/// 触发时机:advance_task 子任务推进成功后,若子任务有 parent_id 则调本函数。
|
||||
/// 数据源:count_children_by_status(一次 GROUP BY 查询,数据量小无压力)。
|
||||
/// 写入:set_status_for_aggregation(父任务 status 唯一非状态机写入路径)。
|
||||
///
|
||||
/// 返回:重算后的父任务最新 status(若与当前相同则不写,返当前值)。
|
||||
async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -> Result<String, String> {
|
||||
let counts = state
|
||||
.tasks
|
||||
.count_children_by_status(parent_id)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
// 无子任务(parent_id 悬空,理论上不该发生):不重算,返当前 status
|
||||
if counts.is_empty() {
|
||||
return state
|
||||
.tasks
|
||||
.get_by_id(parent_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.map(|t| t.status.as_str().to_string())
|
||||
.ok_or_else(|| format!("父任务 {parent_id} 不存在"));
|
||||
}
|
||||
|
||||
// 转 HashMap<status, count> 便于按规则判定
|
||||
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
|
||||
let total: i64 = map.values().sum();
|
||||
let blocked = map.get("blocked").copied().unwrap_or(0);
|
||||
let in_progress = map.get("in_progress").copied().unwrap_or(0);
|
||||
let todo = map.get("todo").copied().unwrap_or(0);
|
||||
let done = map.get("done").copied().unwrap_or(0);
|
||||
let cancelled = map.get("cancelled").copied().unwrap_or(0);
|
||||
|
||||
// 聚合规则判定(优先级从高到低,首个命中即定)
|
||||
let new_status = if blocked > 0 {
|
||||
"blocked".to_string()
|
||||
} else if in_progress > 0 {
|
||||
"in_progress".to_string()
|
||||
} else if (done + cancelled) == total {
|
||||
// 全 done/cancelled → done(终端态聚合为 done)
|
||||
"done".to_string()
|
||||
} else if todo == total {
|
||||
// 全 todo → todo(尚未开始)
|
||||
"todo".to_string()
|
||||
} else {
|
||||
// 其他混合态(如 todo+done, in_review+done 等)→ in_progress(进行中)
|
||||
"in_progress".to_string()
|
||||
};
|
||||
|
||||
// 读当前父 status,相同则不写(避免无谓 updated_at 抖动)
|
||||
let current = state
|
||||
.tasks
|
||||
.get_by_id(parent_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("父任务 {parent_id} 不存在"))?;
|
||||
if current.status.as_str() == new_status {
|
||||
return Ok(new_status);
|
||||
}
|
||||
state
|
||||
.tasks
|
||||
.set_status_for_aggregation(parent_id, &new_status)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
Ok(new_status)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识图谱 Phase 1:task_link CRUD IPC(对标设计 §2.2 + §五 AI 工具表)
|
||||
// ============================================================
|
||||
@@ -745,8 +680,11 @@ pub async fn list_task_links(
|
||||
/// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则管,本命令仅联动改 status
|
||||
/// 以满足一致性约束,聚合规则在子任务推进时仍会重算)。
|
||||
///
|
||||
/// queue/status 均走白名单校验(status 写入用专用 set_status_for_aggregation 绕过 status 收口,
|
||||
/// 因 move_task_queue 是合法的非状态机 status 联动路径,非 advance_task 状态机路径)。
|
||||
/// 一致性联动 + 写 queue/status 收口到 `TaskRepo::move_task_queue` **单事务原子方法**
|
||||
/// (G1.4:读当前 → 联动 status → 写 queue+status 于同一 transaction,防两段式独立写的
|
||||
/// 非原子中间态),与 AI 工具共用防漂移。status 联动绕过状态机收口(move_task_queue 是
|
||||
/// 合法的非状态机 status 联动路径,非 advance_task 状态机路径),soft 删回收站任务不可
|
||||
/// move(repo 方法返回 None)。
|
||||
#[tauri::command]
|
||||
pub async fn move_task_queue(
|
||||
state: State<'_, AppState>,
|
||||
@@ -756,7 +694,7 @@ pub async fn move_task_queue(
|
||||
let new_queue = new_queue.trim().to_string();
|
||||
validate_queue(&new_queue)?;
|
||||
|
||||
// 读当前任务(取当前 status 做一致性联动决策)
|
||||
// 读当前(取旧 queue 做事件埋点决策;一致性联动 + 原子写全在 repo 单事务内完成)。
|
||||
let current = state
|
||||
.tasks
|
||||
.get_by_id(&id)
|
||||
@@ -764,39 +702,15 @@ pub async fn move_task_queue(
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("任务 {id} 不存在"))?;
|
||||
|
||||
// 一致性约束联动:根据 new_queue 决定 status 是否需调整
|
||||
let new_status = match new_queue.as_str() {
|
||||
"done" => "done".to_string(),
|
||||
"backlog" => "todo".to_string(),
|
||||
"active" => {
|
||||
if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) {
|
||||
current.status.as_str().to_string() // 已在执行中三态,保留
|
||||
} else {
|
||||
"in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态)
|
||||
}
|
||||
}
|
||||
"todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态)
|
||||
"decision" => current.status.as_str().to_string(), // 待决策池保留当前 status(暂停推进不重置执行态)
|
||||
_ => unreachable!("validate_queue 已收口"),
|
||||
};
|
||||
|
||||
// 写 queue:走通用 update_field(queue 已在 tasks 白名单登记,知识图谱 Phase 1 V29 新增)。
|
||||
// 与 current.queue 不同才写(避免无谓 updated_at 抖动)。
|
||||
if current.queue != new_queue {
|
||||
state
|
||||
.tasks
|
||||
.update_field(&id, "queue", &new_queue)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
}
|
||||
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径)
|
||||
if current.status.as_str() != new_status {
|
||||
state
|
||||
.tasks
|
||||
.set_status_for_aggregation(&id, &new_status)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
}
|
||||
// 单事务原子移动(读当前 → 联动 status → 写 queue+status,见 TaskRepo::move_task_queue)。
|
||||
// G1.4:此前两段式(update_field queue + set_status_for_aggregation)两次独立写非原子,
|
||||
// 可被并发读/写破坏;现收口为单一 repo 方法,commands 与 AI 工具共用防漂移。
|
||||
let updated = state
|
||||
.tasks
|
||||
.move_task_queue(&id, &new_queue)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("任务 {id} 不存在"))?;
|
||||
|
||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
|
||||
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。
|
||||
@@ -821,13 +735,7 @@ pub async fn move_task_queue(
|
||||
.await;
|
||||
}
|
||||
|
||||
// 回读最新记录返回
|
||||
state
|
||||
.tasks
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("任务 {id} 不存在(移动后回读失败)"))
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user