Files
DevFlow/src-tauri/src/commands/task.rs
T
lxy 36ea090d9b 新增: 依赖图环形检测 + PNG 导出 + 任务列表真实总数
- 后端 detect_module_cycles IPC(DFS 三色标记法检测环形依赖)
- 前端环检测按钮:高亮参与环的节点(红色边框)
- 图导出 PNG(X6 toPNG 回调模式)
- 后端 count_tasks IPC + 前端 taskApi.count()
- 任务列表所有筛选/搜索/排序/翻页均拉真实 total
2026-07-01 11:30:19 +08:00

788 lines
32 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};
use crate::state::AppState;
use super::{err_str, now_millis};
// ============================================================
// 知识图谱 Phase 2:事件流埋点辅助(best-effort,对标设计 §2.4 hook/after + §10.1)
// ============================================================
/// 追加一条项目事件到 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>,
/// 管理维度池(知识图谱 Phase 1 V29,对标设计 §2.1)。默认 "todo"(待办池)。
/// 合法值:backlog / todo / decision / active / done。非法值在 IPC 层兜底校验。
/// 空字符串视为默认 todo(向后兼容,与 idea_id 一致处理)。
#[serde(default = "default_queue")]
pub queue: String,
/// 父任务 ID(知识图谱 Phase 1 V29)。默认 None = 叶子任务。
/// 非空 = 子任务(限制 1 级嵌套,无孙任务:父任务自身不能有 parent_id,由 IPC 层校验)。
/// 空字符串视为 None(向后兼容)。
#[serde(default)]
pub parent_id: Option<String>,
/// 结构化需求规格 JSON 字符串(知识图谱 Phase 1 V29)。
/// 结构 { 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()
}
// ============================================================
// 知识图谱 Phase 1:queue 白名单 + queue/status 一致性约束(IPC 层校验,对标设计 §2.1)
// ============================================================
/// 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))
}
/// 创建任务,返回完整记录
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1):扩展 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> {
// ── 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: input.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,
// F-260619-01:空字符串视为不关联(与 AI 工具层一致)
idea_id: input.idea_id.filter(|s| !s.is_empty()),
// 知识图谱 Phase 1 V29 三列:经上方校验的 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)?;
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_created 事件。best-effort 不阻断。
emit_event(
&state,
&record.project_id,
"task_created",
Some("task"),
Some(&record.id),
None,
Some(record.status.as_str()),
)
.await;
Ok(record)
}
/// 更新任务单个字段(字段名走 df-storage 白名单校验;status 值走枚举校验)
#[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..=30=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..=30=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(解除父),合法放行
}
state
.tasks
.update_field(&id, &field, &value)
.await
.map_err(err_str)
}
/// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
#[tauri::command]
pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.tasks.soft_delete(&id).await.map_err(err_str)
}
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
#[tauri::command]
pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.tasks.restore(&id).await.map_err(err_str)
}
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
///
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)
/// 落 df-nodes::task_advance_node::advance_task_atomic,本命令只做参数转发与错误串化。
///
/// 流程:读当前态 → 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,
/// 对标设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
///
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
#[tauri::command]
pub async fn advance_task(
state: State<'_, AppState>,
id: String,
target_status: String,
) -> Result<TaskRecord, String> {
// 知识图谱 Phase 2:推进前读当前态,作 task_advanced 事件 from_state(仅一次轻量读,
// advance 低频无压力)。失败(任务不存在)不阻断——后续 atomic 会用 NotFound 拒绝,from 留空。
let from_state = state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.map(|t| t.status);
let updated = df_nodes::task_advance_node::advance_task_atomic(
&state.tasks,
&id,
&target_status,
)
.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,
&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)
}
// ============================================================
// 知识图谱 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 工具表)
// ============================================================
/// 创建任务横向关联(对标设计 §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)
}
}
}
// ============================================================
// 知识图谱 Phase 1:move_task_queue(跨池移动 + 一致性约束,对标设计 §2.1 + §五)
// ============================================================
/// 跨池移动任务(对标设计 §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 均走白名单校验(status 写入用专用 set_status_for_aggregation 绕过 status 收口,
/// 因 move_task_queue 是合法的非状态机 status 联动路径,非 advance_task 状态机路径)。
#[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)?;
// 读当前任务(取当前 status 做一致性联动决策)
let current = state
.tasks
.get_by_id(&id)
.await
.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(&current.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)?;
}
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。from/to 用 queue 值。
if current.queue != new_queue {
emit_event(
&state,
&current.project_id,
"task_advanced",
Some("task"),
Some(&current.id),
Some(&current.queue),
Some(&new_queue),
)
.await;
}
// 回读最新记录返回
state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 {id} 不存在(移动后回读失败)"))
}
// ============================================================
// 知识图谱 Phase 1:get_task_tree(父子任务树,对标设计 §2.1 + §五)
// ============================================================
/// 任务树节点(父 + 子任务列表,对标设计 §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 })
}