//! 任务相关命令 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, pub assignee: Option, /// 关联灵感 ID(F-260619-01,1对1 单向,可空=不关联)。 /// 空字符串视为不关联(与 AI 工具层一致)。 pub idea_id: Option, /// 管理维度池(知识图谱 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, /// 结构化需求规格 JSON 字符串(知识图谱 Phase 1 V29)。 /// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。 /// None = 无结构化规格(纯文本 description)。 #[serde(default)] pub content_json: Option, } 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, query: Option, ) -> Result, 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, ) -> Result { 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 { 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 { // ── 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::(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 { // 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),与前端