849 lines
34 KiB
Rust
849 lines
34 KiB
Rust
//! 任务相关命令
|
||
|
||
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,
|
||
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 值走枚举校验)。
|
||
///
|
||
/// B-260801-010 行(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 跨表存在性校验(B-260616-16 收尾)。
|
||
// 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 {
|
||
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)
|
||
}
|
||
|
||
/// 推进任务状态(任务推进链 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,
|
||
¤t.project_id,
|
||
"task_queue_moved",
|
||
Some("task"),
|
||
Some(¤t.id),
|
||
Some(¤t.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 })
|
||
}
|