Files
DevFlow/src-tauri/src/commands/task.rs
T
2026-10-05 00:35:01 +08:00

901 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 任务相关命令
use serde::{Deserialize, Serialize};
use tauri::State;
use df_types::types::{new_id, TaskStatus};
use df_storage::crud::TaskQuery;
use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord};
// LW-9父任务聚合重算(df-nodes 共享层,与 advance_task 同源,消除双轨)。
use df_nodes::task_advance_node::recompute_parent_status;
use crate::state::AppState;
use super::{err_str, now_millis};
// ============================================================
// 事件流埋点辅助(best-effort)
// ============================================================
/// 追加一条项目事件到 project_events(best-effort)。
///
/// 埋点策略(设计 §2.4):事件写入失败**不阻断主操作**,仅 `tracing::warn` 记录。
/// 设计 §10.1「事件流写入失败 — 影响事件完整性 — 事件写入失败不阻断主操作(best-effort)」。
///
/// `source` 语义:本辅助仅由 IPC 层调用,标 `"human"`(AI 工具路径在 tool_registry 内自行标
/// `"ai"`,不经本 IPC)。`project_id` / `entity_type` / `entity_id` / `event_type` 由调用方
/// 提供。`from_state` / `to_state` / `context_json` 可选。
async fn emit_event(
state: &State<'_, AppState>,
project_id: &str,
event_type: &str,
entity_type: Option<&str>,
entity_id: Option<&str>,
from_state: Option<&str>,
to_state: Option<&str>,
) {
let record = ProjectEventRecord {
id: new_id(),
project_id: project_id.to_string(),
event_type: event_type.to_string(),
entity_type: entity_type.map(|s| s.to_string()),
entity_id: entity_id.map(|s| s.to_string()),
from_state: from_state.map(|s| s.to_string()),
to_state: to_state.map(|s| s.to_string()),
context_json: None,
source: Some("human".to_string()),
conversation_id: None,
created_at: now_millis(),
};
if let Err(e) = state.project_events.insert(record).await {
tracing::warn!(
event_type = event_type,
project_id = project_id,
error = %e,
"[事件流] 埋点写入失败(不阻断主操作)"
);
}
}
/// 创建任务入参
#[derive(Debug, Deserialize)]
pub struct CreateTaskInput {
pub project_id: String,
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_priority")]
pub priority: i32,
pub branch_name: Option<String>,
pub assignee: Option<String>,
/// 关联灵感 ID(F-260619-01,1对1 单向,可空=不关联)。
/// 空字符串视为不关联(与 AI 工具层一致)。
pub idea_id: Option<String>,
/// 关联工程 ID(多工程:任务属于某个 module,可空=不关联)。
/// 空字符串视为 None(向后兼容)。
#[serde(default)]
pub module_id: Option<String>,
/// 管理维度池。默认 "todo"(待办池)。
/// 合法值:backlog / todo / decision / active / done。非法值在 IPC 层兜底校验。
/// 空字符串视为默认 todo(向后兼容,与 idea_id 一致处理)。
#[serde(default = "default_queue")]
pub queue: String,
/// 父任务 ID。默认 None = 叶子任务。
/// 非空 = 子任务(限制 1 级嵌套,无孙任务:父任务自身不能有 parent_id,由 IPC 层校验)。
/// 空字符串视为 None(向后兼容)。
#[serde(default)]
pub parent_id: Option<String>,
/// 结构化需求规格 JSON 字符串。
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
/// None = 无结构化规格(纯文本 description)。
#[serde(default)]
pub content_json: Option<String>,
}
fn default_priority() -> i32 {
2 // medium — 新任务默认中优先级(非 high),符合常识
}
/// queue 默认值(serde default,对标 DB DEFAULT 'todo')
fn default_queue() -> String {
"todo".to_string()
}
// ============================================================
// queue 白名单 + queue/status 一致性约束(IPC 层校验)
// ============================================================
/// queue 合法值白名单(对标设计 §2.1 queue 字段语义)。
/// 不走 TaskStatus 枚举(queue 是独立的管理维度,与 status 执行维度正交)。
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
/// queue 执行中池(active 时 status 必须属于执行中三态之一,对标设计 §2.1 一致性约束)
const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"];
/// 校验 queue 值在白名单内,否则返回 Err(防拼写漂移/非法值进库)。
fn validate_queue(queue: &str) -> Result<(), String> {
if TASK_QUEUE_VALUES.contains(&queue) {
Ok(())
} else {
Err(format!(
"非法 queue 值 {:?},合法值: {:?}",
queue, TASK_QUEUE_VALUES
))
}
}
/// queue/status 一致性约束校验(对标设计 §2.1,IPC 层校验不进状态机)。
///
/// 规则(设计 §2.1「queue 与 status 的关系」一致性约束):
/// - queue=done 时 status 必须=done
/// - queue=backlog 时 status 必须=todo
/// - queue=active 时 status ∈ {in_progress, in_review, testing}
/// - status=blocked 时 queue 可为 decision 或 active(本规则约束 queue 赋值场景,不在此单独拦)
/// - queue=todo 时 status=todo(默认);queue=decision 时无 status 强约束(待决策池可任意 status)
///
/// 注:create_task 仅校验 queue(新建任务 status 恒 todo),完整约束在 move_task_queue 落实。
fn assert_queue_status_consistent(queue: &str, status: &str) -> Result<(), String> {
match queue {
"done" => {
if status != "done" {
return Err(format!(
"一致性约束违反:queue=done 要求 status=done,当前 status={status:?}"
));
}
}
"backlog" => {
if status != "todo" {
return Err(format!(
"一致性约束违反:queue=backlog 要求 status=todo,当前 status={status:?}"
));
}
}
"active" => {
if !ACTIVE_OK_STATUSES.contains(&status) {
return Err(format!(
"一致性约束违反:queue=active 要求 status ∈ {:?},当前 status={status:?}",
ACTIVE_OK_STATUSES
));
}
}
_ => {} // todo / decision 无 status 强约束
}
Ok(())
}
/// 列出未删除任务(deleted_at IS NULL)。
///
/// **向后兼容铁律**:`project_id` 与 `query` 两参都可选,旧调用方不传(或只传 project_id)
/// 必须等价改造前的全量行为,零破坏。
///
/// F-260621-02 查询维度补全(机制优先 prompt 说教):
/// - 优先走 `query`(`TaskQuery` 多维动态 WHERE):status(P1 下沉)/ keyword(P2 LIKE)
/// /project_id/priority/assignee/order_by/limit/offset 任意组合。
/// - 旧调用方仍可直接传 `project_id`(单维度),此时走 list_active_by_project
/// (SQL 下推,命中 idx_tasks_project_id),保持等价行为。
/// - query 与 project_id 同时传时:query 优先(其内含 project_id 维度,更全),project_id 忽略。
/// - 均不传时:全量未删任务(等价改造前 list_active 行为)。
///
/// status 值合法性兜底:TaskStatus::is_valid 拦截非法值(拼写错/越界),非法值返回 Err
/// (与 update_task 的 status 校验一致),不静默返回空结果误导调用方。
#[tauri::command]
pub async fn list_tasks(
state: State<'_, AppState>,
project_id: Option<String>,
query: Option<TaskQuery>,
) -> Result<Vec<TaskRecord>, String> {
if let Some(q) = &query {
// status 值兜底校验(非法值早 Err,不进 DB 层)
if let Some(status) = &q.status {
if !TaskStatus::is_valid(status) {
return Err(format!(
"非法 status 值 {:?},合法值: {:?}",
status,
TaskStatus::valid_values()
));
}
}
return state.tasks.list_by_query(q).await.map_err(err_str);
}
// 旧调用方:单维度 project_id(SQL 下推,fallback 等价改造前行为)
let tasks = match project_id {
Some(pid) => state
.tasks
.list_active_by_project(&pid)
.await
.map_err(err_str)?,
None => state.tasks.list_active().await.map_err(err_str)?,
};
Ok(tasks)
}
/// 按条件计数任务(分页 total 用)。
#[tauri::command]
pub async fn count_tasks(
state: State<'_, AppState>,
query: Option<TaskQuery>,
) -> Result<i64, String> {
let q = query.unwrap_or_default();
state.tasks.count_by_query(&q).await.map_err(err_str)
}
/// 按 id 查任务,找不到返回 Err(供前端详情页)
#[tauri::command]
pub async fn get_task_by_id(
state: State<'_, AppState>,
id: String,
) -> Result<TaskRecord, String> {
state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 {} 不存在", id))
}
/// 创建任务,返回完整记录
///
/// 扩展 queue/parent_id/content_json 可选参数(向后兼容,
/// 旧调用方不传等价改造前行为)。三个新参数的 IPC 层校验:
/// - `queue`:白名单校验(validate_queue)+ queue/status 一致性(create_task 时 status 恒 todo,
/// 仅 backlog/todo/decision 合法;active/done 需经 move_task_queue 或 advance_task 流转)。
/// - `parent_id`:1 级嵌套铁律(对标设计 §2.1 D2)。父任务自身不能有 parent_id(拒绝创建孙任务);
/// 父任务不存在则拒(防悬空 parent_id)。空字符串视为 None(向后兼容)。
/// - `content_json`:仅做轻量 JSON 合法性校验(非空时必须是合法 JSON),结构细节由 AI 消费层负责。
#[tauri::command]
pub async fn create_task(
state: State<'_, AppState>,
input: CreateTaskInput,
) -> Result<TaskRecord, String> {
// ── 空 title trim 拒空 + priority 值域校验(对齐前端 PRIORITY_LABELS 与
// update_task 校验,拦截空白标题 / 99 等脏数据静默落库)。 ──
let title = input.title.trim().to_string();
if title.is_empty() {
return Err("任务标题不能为空".to_string());
}
if !(0..=3).contains(&input.priority) {
return Err(format!(
"priority 必须在 0..=3 (0=critical/1=high/2=medium/3=low),收到 {}",
input.priority
));
}
// ── queue 校验(白名单 + 空串默认 todo)──
// 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致)
let queue = if input.queue.trim().is_empty() {
"todo".to_string()
} else {
let q = input.queue.trim();
validate_queue(q)?;
q.to_string()
};
// queue/status 一致性:create_task 时 status 恒 todo,仅 backlog/todo/decision 合法。
// active/done 必须经 move_task_queue 流转,新建直接落 active/done 违反一致性约束。
assert_queue_status_consistent(&queue, "todo")?;
// ── parent_id 校验(1 级嵌套铁律,对标设计 §2.1 D2)──
// 空字符串/纯空白视为 None(向后兼容)
let parent_id = input
.parent_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(pid) = &parent_id {
let parent = state
.tasks
.get_by_id(pid)
.await
.map_err(err_str)?
.ok_or_else(|| format!("父任务 {pid} 不存在,无法创建子任务"))?;
// 1 级嵌套铁律:父任务自身有 parent_id → 它是子任务 → 拒绝在其下创建孙任务。
if parent.parent_id.is_some() {
return Err(format!(
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
parent.parent_id
));
}
}
// ── content_json 轻量校验(非空时须是合法 JSON)──
let content_json = match &input.content_json {
Some(c) if !c.trim().is_empty() => {
// 校验合法 JSON(防脏数据/截断串进库);结构细节由 AI 消费层负责
serde_json::from_str::<serde_json::Value>(c)
.map_err(|e| format!("content_json 不是合法 JSON: {e}"))?;
Some(c.clone())
}
_ => None, // 空串/None 视为无结构化规格
};
let now = now_millis();
let record = TaskRecord {
id: new_id(),
project_id: input.project_id,
title,
description: input.description,
status: TaskStatus::Todo,
priority: input.priority,
branch_name: input.branch_name,
assignee: input.assignee,
workflow_def_id: None,
base_branch: None,
review_rounds: 0,
output_json: None,
// 空字符串视为不关联(与 AI 工具层一致)
idea_id: input.idea_id.filter(|s| !s.is_empty()),
// 多工程:可选关联到具体工程(module)
module_id: input.module_id.filter(|s| !s.is_empty()),
// 经上方校验的 queue / parent_id / content_json
queue,
parent_id,
content_json,
archived: false,
created_at: now.clone(),
updated_at: now,
};
state
.tasks
.insert(record.clone())
.await
.map_err(err_str)?;
// task_created 事件。best-effort 不阻断。
emit_event(
&state,
&record.project_id,
"task_created",
Some("task"),
Some(&record.id),
None,
Some(record.status.as_str()),
)
.await;
// LW-9建子任务后触发父聚合重算(容器模型,父 status 由子任务聚合)。
// 聚合失败仅 warn 不阻断(对齐 advance_task 宽容语义)。
if let Some(pid) = &record.parent_id {
if let Err(e) = recompute_parent_status(&state.tasks, pid).await {
tracing::warn!(
task_id = %record.id,
parent_id = %pid,
error = %e,
"[父聚合] create_task 后重算父 status 失败(不阻断)"
);
}
}
Ok(record)
}
/// 更新任务单个字段(字段名走 df-storage 白名单校验;status 值走枚举校验)。
///
/// 行(id 不存在 / 已软删)→ 返 Err 而非 false,杜绝「假成功」
/// (前端 updateTask 仅 await 不读 bool,但 store.runWithCatch 把 Err 转 toast,用户能感知;
/// 若返 false 则 store 静默忽略,本地状态前端手动改但库无变更,造成 UI 与 DB 不一致)。
///
/// 返回值保持 bool(成功恒 true):前端 api 包装 `Promise<boolean>` 契约不变零破坏,
/// title 不在 IPC 返回值——IPC 唯一调用点(Tasks.vue 优先级改)不展示带返回值的 toast,
/// title 仅供 AI 工具卡片路径(已修见 ai/tools/task.rs),IPC 路径返 title 为冗余。
#[tauri::command]
pub async fn update_task(
state: State<'_, AppState>,
id: String,
field: String,
value: String,
) -> Result<bool, String> {
// status 值校验保留:仅对「非法值」(拼写错 in-progess / "in progress" / 大小写错 / 越界)
// 给出友好早错误(先于 crud.rs 白名单那串冷冰冰的「字段不在白名单」拒)。合法 status 值
// 不可经本 IPC 写入——crud.rs tasks 白名单已收口移除 status(F-03 batch64 b94e74a /
// D-260616-04),所有 status 改动须走 advance_task_atomic 状态机(CAS + can_transition +
// review_rounds 累加,唯一 status 写入路径)。即此 is_valid 校验的「通过」分支永不会触达
// update_field 的 status 写入(白名单会先拒);它只为非法值兜底 UX,不承担合法值写入职责。
// priority 值校验同理补在下方:拦截 "abc" / 999 等脏数据静默落库。
if field == "status" && !TaskStatus::is_valid(&value) {
return Err(format!(
"非法 status 值 {:?},合法值: {:?}",
value,
TaskStatus::valid_values()
));
}
// priority 值域 0..=3(0=critical, 1=high, 2=medium, 3=low),与前端 <select> 及
// constants/project.ts 的 PRIORITY_LABELS 一致。拦截 "abc" / 999 等脏数据。
if field == "priority" {
match value.parse::<i32>() {
Ok(p) if (0..=3).contains(&p) => {}
_ => {
return Err(format!(
"非法 priority 值 {:?},合法值: 整数 0..=3(0=critical, 1=high, 2=medium, 3=low)",
value
));
}
}
}
// project_id 跨表存在性校验(收尾)。
// crud.rs tasks 白名单保留 project_id(支持跨项目移动),跨表约束由本命令层兜底。
// 拦截移动到不存在的项目(手输脏 id / 已物理删除的项目),避免 tasks.project_id 悬空。
if field == "project_id" {
let exists = state.projects.get_by_id(&value).await.map_err(err_str)?;
if exists.is_none() {
return Err(format!("非法 project_id 值 {:?},目标项目不存在", value));
}
}
// parent_id 1 级嵌套铁律(对标 create_task L203-221,verify agent 发现的 P1:update_task 绕过)。
// parent_id 在 tasks 白名单(设计 §五 move/update 路径),但 1 级铁律须 IPC 层校验,否则
// update_task(id,"parent_id",X) 可创建孙任务(X 自身有 parent_id)或悬空(X 不存在)或自环。
if field == "parent_id" {
let new_pid = value.trim();
if !new_pid.is_empty() {
if new_pid == id {
return Err("非法 parent_id:不能将任务设为自身的父任务(自环)".to_string());
}
let parent = state
.tasks
.get_by_id(new_pid)
.await
.map_err(err_str)?
.ok_or_else(|| format!("非法 parent_id 值 {:?},父任务不存在", value))?;
if parent.parent_id.is_some() {
return Err(format!(
"违反 1 级嵌套铁律:父任务 {new_pid} 自身是子任务(parent_id={:?}),不允许挂孙任务",
parent.parent_id
));
}
}
// 空字符串 = None(解除父),合法放行
}
// queue 字段校验(白名单 + queue/status 一致性,读当前 status)。
// update_task 只改单字段不改 status,故用严格一致性校验(与 move_task_queue 自动联动不同):
// 设置不兼容的 queue 直接拒绝,调用方需走 move_task_queue 自动联动调整 status。
if field == "queue" {
let q = value.trim();
validate_queue(q)?;
let current = state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 ID {id} 不存在或已删除"))?;
assert_queue_status_consistent(q, current.status.as_str())?;
}
// update_field 返 affected>0;false = id 不存在或已软删(0 行)。
// 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。
//
// 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated
// 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。
// 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。
// update_field_active 过滤软删(deleted_at IS NULL),回收站任务不可改字段。
let current = state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?;
let updated = state
.tasks
.update_field_active(&id, &field, &value)
.await
.map_err(err_str)?;
if !updated {
// update_field_active 恒带 archived = 0:0 行时区分「已归档」与「不存在/已软删」,
// 避免归档任务被误报为已删除(用户无从知道要先取消归档)。
if current.as_ref().is_some_and(|c| c.archived) {
return Err(format!("任务 ID {id} 已归档,请先取消归档再修改"));
}
return Err(format!("任务 ID {id} 不存在或已删除"));
}
// LW-9改 parent_id 后重算父聚合——旧父(任务已离开,计数减少)与新父(任务挂上)。
// 从 current 读旧 parent_id(pre-update),value 是新父值。聚合失败仅 warn 不阻断。
if field == "parent_id" {
if let Some(old_pid) = current.as_ref().and_then(|c| c.parent_id.clone()) {
if let Err(e) = recompute_parent_status(&state.tasks, &old_pid).await {
tracing::warn!(
task_id = %id,
parent_id = %old_pid,
error = %e,
"[父聚合] 解除父后重算旧父 status 失败(不阻断)"
);
}
}
let new_pid = value.trim();
if !new_pid.is_empty() {
if let Err(e) = recompute_parent_status(&state.tasks, new_pid).await {
tracing::warn!(
task_id = %id,
parent_id = %new_pid,
error = %e,
"[父聚合] 挂载父后重算新父 status 失败(不阻断)"
);
}
}
}
if let Some(rec) = current {
emit_event(
&state,
&rec.project_id,
"task_updated",
Some("task"),
Some(&rec.id),
None,
None,
)
.await;
}
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<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 {
emit_event(
&state,
&r.project_id,
"task_deleted",
Some("task"),
Some(&r.id),
None,
None,
)
.await;
}
}
Ok(TaskDeleteResult { ok, cascaded })
}
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
///
/// 埋点 task_restored(问题3 项目最近活跃排序):恢复是业务事件。best-effort 不阻断。
#[tauri::command]
pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?;
let ok = state.tasks.restore(&id).await.map_err(err_str)?;
if ok {
if let Some(r) = rec {
emit_event(
&state,
&r.project_id,
"task_restored",
Some("task"),
Some(&r.id),
None,
None,
)
.await;
}
}
Ok(ok)
}
/// 归档/取消归档任务(归档功能 P1,V46)。对标 ai_conversation_archive。
///
/// 纯元数据标记,与 status/queue 正交:归档后任务退出主视图与状态机(冻结:
/// 不可推进/跨池/字段更新,repo 层活跃查询与写入路径恒带 archived = 0 收口),
/// 仅在归档视图(list_archived_tasks)可见。软删优先:回收站任务不可直接归档。
///
/// 埋点 task_archived/task_unarchived(项目事件流,best-effort 不阻断)。
#[tauri::command]
pub async fn archive_task(
state: State<'_, AppState>,
id: String,
archived: bool,
) -> Result<bool, String> {
let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?;
let ok = state.tasks.set_archived(&id, archived).await.map_err(err_str)?;
if ok {
if let Some(r) = rec {
emit_event(
&state,
&r.project_id,
if archived { "task_archived" } else { "task_unarchived" },
Some("task"),
Some(&r.id),
None,
Some(if archived { "archived" } else { "active" }),
)
.await;
}
}
Ok(ok)
}
/// 列出归档任务(archived = 1 且未删),按更新时间降序。归档视图数据源。
#[tauri::command]
pub async fn list_archived_tasks(
state: State<'_, AppState>,
project_id: Option<String>,
) -> Result<Vec<TaskRecord>, String> {
let tasks = state.tasks.list_archived().await.map_err(err_str)?;
// 归档量级小(低频操作),内存过滤 project_id 足够;SQL 下推待归档量增长再说
Ok(match project_id {
Some(pid) => tasks.into_iter().filter(|t| t.project_id == pid).collect(),
None => tasks,
})
}
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
///
/// thin 入口业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合)
/// 落 df-nodes::task_advance_node,本命令只做参数转发与结果包装。
///
/// 返回: `Result<TaskRecord, String>` — 成功返更新后的任务;失败 Err(状态机拒绝/CAS 冲突/
/// 库错误)走 Tauri reject,前端 `.catch` 捕获(与 list/update/create 单轨范式一致)。
#[tauri::command]
pub async fn advance_task(
state: State<'_, AppState>,
id: String,
target_status: String,
) -> Result<TaskRecord, String> {
let from_state = match state.tasks.get_by_id(&id).await {
Ok(Some(t)) => Some(t.status),
_ => None,
};
let updated = df_nodes::task_advance_node::advance_task_with_parent(
&state.tasks,
&id,
&target_status,
)
.await
.map_err(err_str)?;
emit_event(
&state,
&updated.project_id,
"task_advanced",
Some("task"),
Some(&updated.id),
from_state.as_ref().map(TaskStatus::as_str),
Some(updated.status.as_str()),
)
.await;
Ok(updated)
}
// ============================================================
// task_link CRUD IPC
// ============================================================
/// 创建任务横向关联(对标设计 §2.2,AI 拓扑排序编排调度的基础)。
///
/// 调 TaskLinkRepo::create_link(应用层校验 link_type 白名单 + depends_on 链 BFS 循环依赖检测)。
/// 循环依赖 / 自环 / 非法 link_type 在 repo 层拒绝(对标 D8)。
///
/// 参数:
/// - `task_id`:source 任务 ID(关联发起方)
/// - `target_id`:target 任务 ID(关联指向方)
/// - `link_type`:depends_on / blocks / relates_to(白名单校验)
/// - `remark`:可选备注
///
/// 返回:插入的 link id。
#[tauri::command]
pub async fn create_task_link(
state: State<'_, AppState>,
task_id: String,
target_id: String,
link_type: String,
remark: Option<String>,
) -> Result<String, String> {
let id = new_id();
state
.task_links
.create_link(&id, &task_id, &target_id, &link_type, remark.as_deref())
.await
.map_err(err_str)
}
/// 删除任务关联(按 link id,对标设计 §2.2)。返回是否命中。
#[tauri::command]
pub async fn remove_task_link(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.task_links.delete(&id).await.map_err(err_str)
}
/// 任务关联查询入参方向(对标设计 §2.2 + §五 list_task_links)。
///
/// - `Outgoing`:task_id 作为 source 查其声明的全部关联(我依赖谁/我阻塞谁)
/// - `Incoming`:task_id 作为 target 查谁指向它(谁依赖我/谁被我阻塞,反向查询)
/// - `Both`:双向合并(去重),全量关联视图
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LinkDirection {
Outgoing,
Incoming,
Both,
}
impl Default for LinkDirection {
fn default() -> Self {
LinkDirection::Both
}
}
/// 查询任务关联(AI 编排调度用,对标设计 §2.2 + §五 list_task_links)。
///
/// 参数:
/// - `task_id`:目标任务 ID
/// - `direction`:查询方向(Outgoing=作为 source / Incoming=作为 target / Both=双向合并),
/// 默认 Both(全量关联视图)。空字符串/未传走默认 Both(向后兼容)。
///
/// 返回:关联记录列表(TaskLinkRecord,按 created_at 升序)。
#[tauri::command]
pub async fn list_task_links(
state: State<'_, AppState>,
task_id: String,
direction: Option<LinkDirection>,
) -> Result<Vec<TaskLinkRecord>, String> {
let dir = direction.unwrap_or_default();
match dir {
LinkDirection::Outgoing => state.task_links.get_by_source(&task_id).await.map_err(err_str),
LinkDirection::Incoming => state.task_links.get_by_target(&task_id).await.map_err(err_str),
// 双向合并:取 Outgoing + Incoming 后按 id 去重(同一 link 在双向查询中可能出现两次)
LinkDirection::Both => {
let mut outgoing = state.task_links.get_by_source(&task_id).await.map_err(err_str)?;
let incoming = state.task_links.get_by_target(&task_id).await.map_err(err_str)?;
// 按 id 去重(Outgoing 优先保留,incoming 中 id 未出现的才追加)
let mut seen: std::collections::HashSet<String> =
outgoing.iter().map(|l| l.id.clone()).collect();
for l in incoming {
if seen.insert(l.id.clone()) {
outgoing.push(l);
}
}
// 按 created_at 升序(合并后重排,与单向查询排序一致)
outgoing.sort_by(|a, b| a.created_at.cmp(&b.created_at));
Ok(outgoing)
}
}
}
// ============================================================
// move_task_queue(跨池移动 + 一致性约束)
// ============================================================
/// 跨池移动任务(对标设计 §2.1 queue 字段语义 + §五 move_task_queue)。
///
/// queue 是管理维度池(backlog/todo/decision/active/done),与 status(执行维度)正交。
/// move_task_queue 改 queue,同时按一致性约束联动调整 status(对标设计 §2.1):
/// - queue=done → status 强制=done(池完成即任务完成)
/// - queue=backlog → status 强制=todo(需求池任务尚未开始)
/// - queue=active → status 若不在 {in_progress,in_review,testing} 则强制=in_progress
/// - queue=todo → status 若非 todo 则强制=todo(待办池任务尚未开始)
/// - queue=decision → status 不变(待决策池可保留任意执行态,暂停推进但执行态保留)
///
/// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则管,本命令仅联动改 status
/// 以满足一致性约束,聚合规则在子任务推进时仍会重算)。
///
/// 一致性联动 + 写 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>,
id: String,
new_queue: String,
) -> Result<TaskRecord, String> {
let new_queue = new_queue.trim().to_string();
validate_queue(&new_queue)?;
// 读当前(取旧 queue 做事件埋点决策;一致性联动 + 原子写全在 repo 单事务内完成)。
let current = state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 {id} 不存在"))?;
// 单事务原子移动(读当前 → 联动 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} 不存在"))?;
// queue 变化事件。best-effort 不阻断。
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。
//
// M17:用独立 event_type "task_queue_moved"(非复用 "task_advanced"),与 status 推进事件
// 语义解耦——advance_task 触发的 task_advanced 用 from_state/to_state 装**执行态**值
// (todo→in_progress 等),而 move_task_queue 改的是**管理维度池**(backlog/todo/decision/
// active/done),两者正交。混用同一 event_type + 同样的 from/to 字段会让消费者无法区分
// 「任务执行推进」与「跨池移动」,过滤 task_advanced 的事件流会混入 queue 漂移噪音。
// 独立 type 后:status 推进走 task_advanced,queue 移动走 task_queue_moved,语义自洽,
// from/to 字段各自装对应维度的值(queue_moved 装 queue 值),不冲突。
if current.queue != new_queue {
emit_event(
&state,
&current.project_id,
"task_queue_moved",
Some("task"),
Some(&current.id),
Some(&current.queue),
Some(&new_queue),
)
.await;
}
// LW-9跨池移动后子任务 status 可能联动变化(一致性联动)→ 触发其父聚合重算。
// 仅当被移动任务是子任务;父任务自身 move 不重算自身聚合(容器 status 由子任务推进时重算)。
// 聚合失败仅 warn 不阻断。
if let Some(pid) = &updated.parent_id {
if let Err(e) = recompute_parent_status(&state.tasks, pid).await {
tracing::warn!(
task_id = %updated.id,
parent_id = %pid,
error = %e,
"[父聚合] move_task_queue 后重算父 status 失败(不阻断)"
);
}
}
Ok(updated)
}
// ============================================================
// get_task_tree(父子任务树)
// ============================================================
/// 任务树节点(父 + 子任务列表,对标设计 §2.1 限 1 级嵌套)。
///
/// 设计限 1 级嵌套(无孙任务,D2),故树结构是扁平的「父 + 直接子任务列表」,无需递归。
#[derive(Debug, Serialize)]
pub struct TaskTreeNode {
/// 父任务(根节点,可能是叶子任务无子)
pub parent: TaskRecord,
/// 直接子任务列表(parent_id 指向 parent 的任务,按 created_at 升序)。叶子任务时为空。
pub children: Vec<TaskRecord>,
}
/// 获取任务父子树(对标设计 §2.1 + §五 get_task_tree)。
///
/// 限 1 级嵌套(设计 D2:无孙任务),故本方法取「指定任务 + 其直接子任务」,不递归。
///
/// 参数 `parent_id`:任务 ID(无论它是叶子还是父任务,都返回其自身 + 子任务列表)。
/// AI 用途:查看需求分解结构、子任务进度聚合(设计 §六 ⑥⑦ AI 自检 + 父聚合)。
#[tauri::command]
pub async fn get_task_tree(
state: State<'_, AppState>,
parent_id: String,
) -> Result<TaskTreeNode, String> {
let parent = state
.tasks
.get_by_id(&parent_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 {parent_id} 不存在"))?;
let children = state.tasks.get_children(&parent_id).await.map_err(err_str)?;
Ok(TaskTreeNode { parent, children })
}