diff --git a/crates/df-mcp/src/tools.rs b/crates/df-mcp/src/tools.rs index 605f7b9..3c1c99e 100644 --- a/crates/df-mcp/src/tools.rs +++ b/crates/df-mcp/src/tools.rs @@ -17,7 +17,7 @@ use std::sync::Arc; use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo}; use df_storage::db::Database; use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; -use df_types::types::new_id; +use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id}; use futures::future::BoxFuture; use serde_json::{json, Value}; @@ -235,10 +235,11 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> Err(r) => return Box::pin(std::future::ready(r)), }; let description = arg_str_or(&args, "description", ""); - let status = arg_str_or(&args, "status", "active"); + let status = arg_str_or(&args, "status", "planning"); medium_audit("create_project", &name); Box::pin(async move { let now = now_millis(); + let status = ProjectStatus::from_db_str(&status).unwrap_or_default(); let rec = ProjectRecord { id: new_id(), name, @@ -269,7 +270,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> }; let name = arg_str_or(&args, "name", ""); let description = arg_str_or(&args, "description", ""); - let status = arg_str_or(&args, "status", "active"); + let status = arg_str_or(&args, "status", "planning"); medium_audit("update_project", &id); Box::pin(async move { let repo = ProjectRepo::new(&db); @@ -280,6 +281,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> Err(e) => return err_str(e), }; let now = now_millis(); + let status = ProjectStatus::from_db_str(&status).unwrap_or_default(); let rec = ProjectRecord { id: id.clone(), name, @@ -355,7 +357,7 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { list.retain(|t| t.project_id == *pid); } if let Some(st) = &status_filter { - list.retain(|t| t.status == *st); + list.retain(|t| t.status.as_str() == st.as_str()); } json_ok(json!({ "tasks": list, "count": list.len() })) } @@ -384,7 +386,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { project_id, title, description, - status: "todo".to_owned(), + status: TaskStatus::Todo, priority, branch_name: None, assignee: None, @@ -529,7 +531,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { id: new_id(), title, description, - status: "draft".to_owned(), + status: IdeaStatus::Draft, priority, score: None, tags: None, diff --git a/crates/df-nodes/src/ai_self_review_node.rs b/crates/df-nodes/src/ai_self_review_node.rs index 27ec792..2b8a7a1 100644 --- a/crates/df-nodes/src/ai_self_review_node.rs +++ b/crates/df-nodes/src/ai_self_review_node.rs @@ -273,6 +273,7 @@ mod tests { use df_storage::crud::{ProjectRepo, TaskRepo}; use df_storage::db::Database; use df_storage::models::{ProjectRecord, TaskRecord}; + use df_types::types::{ProjectStatus, TaskStatus}; use serde_json::json; // ============================================================ @@ -287,7 +288,7 @@ mod tests { id: "p1".to_string(), name: "proj".to_string(), description: "".to_string(), - status: "planning".to_string(), + status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, @@ -302,7 +303,7 @@ mod tests { project_id: "p1".to_string(), title: "t1".to_string(), description: "实现登录接口".to_string(), - status: "testing".to_string(), + status: TaskStatus::Testing, priority: 2, branch_name: None, assignee: None, diff --git a/crates/df-nodes/src/task_advance_node.rs b/crates/df-nodes/src/task_advance_node.rs index c2af821..4c2531b 100644 --- a/crates/df-nodes/src/task_advance_node.rs +++ b/crates/df-nodes/src/task_advance_node.rs @@ -174,14 +174,15 @@ mod tests { use super::*; use df_storage::crud::ProjectRepo; use df_storage::models::{ProjectRecord, TaskRecord}; + use df_types::types::{ProjectStatus, TaskStatus}; - fn rec(id: &str, status: &str) -> TaskRecord { + fn rec(id: &str, status: TaskStatus) -> TaskRecord { TaskRecord { id: id.to_string(), project_id: "p1".to_string(), title: format!("t-{id}"), description: "".to_string(), - status: status.to_string(), + status, priority: 2, branch_name: None, assignee: None, @@ -206,7 +207,7 @@ mod tests { id: "p1".to_string(), name: "proj".to_string(), description: "".to_string(), - status: "planning".to_string(), + status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, @@ -221,43 +222,43 @@ mod tests { #[tokio::test] async fn forward_path_todo_to_done() { let repo = setup().await; - repo.insert(rec("t1", "todo")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap(); // 主路径逐级推进 let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); - assert_eq!(r.status, "in_progress"); + assert_eq!(r.status.as_str(), "in_progress"); assert_eq!(r.review_rounds, 0); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); - assert_eq!(r.status, "in_review"); + assert_eq!(r.status.as_str(), "in_review"); assert_eq!(r.review_rounds, 0); let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap(); - assert_eq!(r.status, "testing"); + assert_eq!(r.status.as_str(), "testing"); let r = advance_task_atomic(&repo, "t1", "done").await.unwrap(); - assert_eq!(r.status, "done"); + assert_eq!(r.status.as_str(), "done"); assert_eq!(r.review_rounds, 0); } #[tokio::test] async fn regression_in_review_to_in_progress_bumps_rounds() { let repo = setup().await; - repo.insert(rec("t1", "in_review")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); - assert_eq!(r.status, "in_progress"); + assert_eq!(r.status.as_str(), "in_progress"); assert_eq!(r.review_rounds, 1, "退回应 +1"); } #[tokio::test] async fn regression_testing_to_in_review_bumps_rounds() { let repo = setup().await; - repo.insert(rec("t1", "testing")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::Testing)).await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); - assert_eq!(r.status, "in_review"); + assert_eq!(r.status.as_str(), "in_review"); assert_eq!(r.review_rounds, 1, "退回应 +1"); } #[tokio::test] async fn multiple_regressions_accumulate() { let repo = setup().await; - repo.insert(rec("t1", "in_review")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap(); // in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2) advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); @@ -269,7 +270,7 @@ mod tests { async fn illegal_skip_rejected() { // CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。 let repo = setup().await; - repo.insert(rec("t1", "todo")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap(); let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err(); match err { df_types::error::Error::InvalidState { current, expected } => { @@ -285,7 +286,7 @@ mod tests { async fn terminal_done_no_successor() { // CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。 let repo = setup().await; - repo.insert(rec("t1", "done")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::Done)).await.unwrap(); let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err(); match err { df_types::error::Error::InvalidState { current, .. } => { @@ -301,7 +302,7 @@ mod tests { // 同态属空操作,归 Validation「相同状态,无需推进」; // 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。 let repo = setup().await; - repo.insert(rec("t1", "in_progress")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap(); let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err(); match err { df_types::error::Error::Validation(msg) => { @@ -314,7 +315,7 @@ mod tests { #[tokio::test] async fn invalid_target_rejected() { let repo = setup().await; - repo.insert(rec("t1", "todo")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap(); let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err(); assert!(matches!(err, df_types::error::Error::Validation(_))); } @@ -332,7 +333,7 @@ mod tests { // 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖), // 而是验证读后改路径在 from=当前库内 status 时正常推进。 let repo = setup().await; - repo.insert(rec("t1", "todo")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap(); // 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径; // F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic) repo.advance_status_atomic("t1", "todo", "in_progress", false) @@ -340,13 +341,13 @@ mod tests { .unwrap(); // 读出来是 in_progress,推进到 in_review 合法 → 正常成功 let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); - assert_eq!(r.status, "in_review"); + assert_eq!(r.status.as_str(), "in_review"); } #[tokio::test] async fn cas_returns_none_when_status_mismatch() { let repo = setup().await; - repo.insert(rec("t1", "todo")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap(); // 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress) let r = repo .advance_status_atomic("t1", "todo", "in_review", false) @@ -365,12 +366,12 @@ mod tests { #[tokio::test] async fn blocked_round_trip_does_not_bump() { let repo = setup().await; - repo.insert(rec("t1", "in_progress")).await.unwrap(); + repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap(); let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap(); - assert_eq!(r.status, "blocked"); + assert_eq!(r.status.as_str(), "blocked"); assert_eq!(r.review_rounds, 0, "进 blocked 不累加"); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); - assert_eq!(r.status, "in_progress"); + assert_eq!(r.status.as_str(), "in_progress"); assert_eq!(r.review_rounds, 0, "解除 blocked 不累加"); } @@ -460,24 +461,24 @@ mod tests { async fn callback_completed_advance_lands_in_db() { // in_progress: todo → in_progress(②-3 in_progress 模板完成) let repo = setup().await; - repo.insert(rec("c1", "todo")).await.unwrap(); + repo.insert(rec("c1", TaskStatus::Todo)).await.unwrap(); let to = callback_advance_target("completed", "in_progress").unwrap(); let r = advance_task_atomic(&repo, "c1", &to).await.unwrap(); - assert_eq!(r.status, "in_progress"); + assert_eq!(r.status.as_str(), "in_progress"); assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds"); // testing: in_review → testing(②-3 testing 模板自审+核对通过) - repo.insert(rec("c2", "in_review")).await.unwrap(); + repo.insert(rec("c2", TaskStatus::InReview)).await.unwrap(); let to = callback_advance_target("completed", "testing").unwrap(); let r = advance_task_atomic(&repo, "c2", &to).await.unwrap(); - assert_eq!(r.status, "testing"); + assert_eq!(r.status.as_str(), "testing"); assert_eq!(r.review_rounds, 0); // done: testing → done(②-3 done 模板最终核对通过) - repo.insert(rec("c3", "testing")).await.unwrap(); + repo.insert(rec("c3", TaskStatus::Testing)).await.unwrap(); let to = callback_advance_target("completed", "done").unwrap(); let r = advance_task_atomic(&repo, "c3", &to).await.unwrap(); - assert_eq!(r.status, "done"); + assert_eq!(r.status.as_str(), "done"); assert_eq!(r.review_rounds, 0); } @@ -487,32 +488,32 @@ mod tests { async fn callback_failed_regression_lands_in_db_with_rounds_bump() { // testing 模板失败:任务当前 testing → 退回 in_review(rounds+1) let repo = setup().await; - repo.insert(rec("f1", "testing")).await.unwrap(); + repo.insert(rec("f1", TaskStatus::Testing)).await.unwrap(); let to = callback_advance_target("failed", "testing").unwrap(); assert_eq!(to, "in_review", "testing 失败应退回 in_review"); let r = advance_task_atomic(&repo, "f1", &to).await.unwrap(); - assert_eq!(r.status, "in_review"); + assert_eq!(r.status.as_str(), "in_review"); assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)"); // in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定): // in_review → in_progress。此处验证 regression_target 对 in_review 的映射, // 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。 - repo.insert(rec("f2", "in_review")).await.unwrap(); + repo.insert(rec("f2", TaskStatus::InReview)).await.unwrap(); let to = callback_advance_target("failed", "in_review").unwrap(); assert_eq!(to, "in_progress"); let r = advance_task_atomic(&repo, "f2", &to).await.unwrap(); - assert_eq!(r.status, "in_progress"); + assert_eq!(r.status.as_str(), "in_progress"); assert_eq!(r.review_rounds, 1); // in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b), // 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。 // 验证:callback 返回 None,不调 advance_task_atomic。 - repo.insert(rec("f3", "in_progress")).await.unwrap(); + repo.insert(rec("f3", TaskStatus::InProgress)).await.unwrap(); let to = callback_advance_target("failed", "in_progress"); assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}"); // 任务状态未被改动(仍是 in_progress) let still = repo.get_by_id("f3").await.unwrap().unwrap(); - assert_eq!(still.status, "in_progress", "None 退回不应改动 status"); + assert_eq!(still.status.as_str(), "in_progress", "None 退回不应改动 status"); } /// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。 diff --git a/crates/df-storage/src/crud/idea_repo.rs b/crates/df-storage/src/crud/idea_repo.rs index 9a68960..00af762 100644 --- a/crates/df-storage/src/crud/idea_repo.rs +++ b/crates/df-storage/src/crud/idea_repo.rs @@ -9,6 +9,7 @@ use df_types::error::Result; use crate::db::Database; use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord}; +use df_types::types::IdeaStatus; use super::impl_repo; use super::{now_millis_str, storage_err, validate_column_name}; @@ -93,7 +94,10 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result (crate::db::Database, ProjectEventRepo) { @@ -232,7 +233,7 @@ mod tests { id: "proj-1".to_string(), name: "proj-1".to_string(), description: String::new(), - status: "active".to_string(), + status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, diff --git a/crates/df-storage/src/crud/project_repo.rs b/crates/df-storage/src/crud/project_repo.rs index a53908b..daebfc8 100644 --- a/crates/df-storage/src/crud/project_repo.rs +++ b/crates/df-storage/src/crud/project_repo.rs @@ -6,6 +6,7 @@ use rusqlite::{params, Connection, OptionalExtension, Row}; use tokio::sync::Mutex; use df_types::error::Result; +use df_types::types::ProjectStatus; use crate::db::Database; use crate::models::{ @@ -80,7 +81,10 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result (crate::db::Database, ProjectServiceRepo) { @@ -308,7 +309,7 @@ mod tests { id: "proj-1".to_string(), name: "proj-1".to_string(), description: String::new(), - status: "active".to_string(), + status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, diff --git a/crates/df-storage/src/crud/task_link_repo.rs b/crates/df-storage/src/crud/task_link_repo.rs index 406d58b..244b58e 100644 --- a/crates/df-storage/src/crud/task_link_repo.rs +++ b/crates/df-storage/src/crud/task_link_repo.rs @@ -328,6 +328,7 @@ mod tests { use super::*; use crate::crud::TaskRepo; use crate::models::TaskRecord; + use df_types::types::{ProjectStatus, TaskStatus}; /// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。 fn trec(id: &str, project_id: &str) -> TaskRecord { @@ -336,7 +337,7 @@ mod tests { project_id: project_id.to_string(), title: format!("task-{id}"), description: String::new(), - status: "todo".to_string(), + status: TaskStatus::Todo, priority: 1, branch_name: None, assignee: None, @@ -369,7 +370,7 @@ mod tests { id: "proj-1".to_string(), name: "proj-1".to_string(), description: String::new(), - status: "active".to_string(), + status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, diff --git a/crates/df-storage/src/crud/task_repo.rs b/crates/df-storage/src/crud/task_repo.rs index 5963425..1b81f8b 100644 --- a/crates/df-storage/src/crud/task_repo.rs +++ b/crates/df-storage/src/crud/task_repo.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use df_types::error::Result; +use df_types::types::TaskStatus; use crate::db::Database; use crate::models::TaskRecord; @@ -24,7 +25,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result) -> TaskRecord { - trec_full(id, queue, parent_id, "todo") + trec_full(id, queue, parent_id, TaskStatus::Todo) } /// 全参 fixture(聚合测试需自定义 status 时用)。 - fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: &str) -> TaskRecord { + fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: TaskStatus) -> TaskRecord { TaskRecord { id: id.to_string(), project_id: "proj-1".to_string(), title: format!("task-{id}"), description: String::new(), - status: status.to_string(), + status, priority: 1, branch_name: None, assignee: None, @@ -605,7 +610,7 @@ mod tests { id: "proj-1".to_string(), name: "proj-1".to_string(), description: String::new(), - status: "active".to_string(), + status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, @@ -778,13 +783,13 @@ mod tests { async fn set_status_for_aggregation_writes_status() { let repo = setup().await; // 父任务初始 todo(queue=todo, parent_id=None 容器模型) - repo.insert(trec_full("parent", "todo", None, "todo")) + repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo)) .await .unwrap(); - let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap(); + let ok = repo.set_status_for_aggregation("parent", "in_progress"); assert!(ok, "应命中写入"); let after = repo.get_by_id("parent").await.unwrap().unwrap(); - assert_eq!(after.status, "in_progress", "status 应被聚合写入更新"); + assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新"); assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds"); } @@ -792,7 +797,7 @@ mod tests { async fn set_status_for_aggregation_skips_soft_deleted() { // 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false let repo = setup().await; - repo.insert(trec_full("parent", "todo", None, "todo")) + repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo)) .await .unwrap(); repo.soft_delete("parent").await.unwrap(); diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 30aaab9..f4c9eaa 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -1,6 +1,7 @@ //! 数据模型定义 — 与数据库表对应的 Rust 结构体 use df_ai_core::model::{deserialize_model_configs, ModelConfig}; +use df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus}; use serde::{Deserialize, Serialize}; // ============================================================ @@ -13,7 +14,7 @@ pub struct IdeaRecord { pub id: String, pub title: String, pub description: String, - pub status: String, + pub status: IdeaStatus, pub priority: i32, pub score: Option, pub tags: Option, // JSON 数组 @@ -53,7 +54,7 @@ pub struct ProjectRecord { pub id: String, pub name: String, pub description: String, - pub status: String, + pub status: ProjectStatus, pub idea_id: Option, /// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用) pub path: Option, @@ -74,7 +75,7 @@ pub struct TaskRecord { pub project_id: String, pub title: String, pub description: String, - pub status: String, + pub status: TaskStatus, pub priority: i32, pub branch_name: Option, pub assignee: Option, @@ -134,14 +135,14 @@ pub struct TaskRecord { /// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明 /// - `relates_to`:弱关联,无执行约束 → 上下文提示 /// - `remark`:可选备注。 -/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束, -/// 对标设计 D8:task_links 数据量小,检测成本低)。跨项目依赖允许。 +/// 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束, +/// 对标设计 D8:task_links 数据量小,检测成本高)。跨项目依赖允许。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskLinkRecord { pub id: String, pub source_id: String, pub target_id: String, - pub link_type: String, + pub link_type: LinkType, pub remark: Option, pub created_at: String, } @@ -265,7 +266,7 @@ pub struct NodeExecutionRecord { pub id: String, pub workflow_id: String, pub node_id: String, - pub node_type: String, + pub node_type: NodeType, pub status: String, pub input_json: Option, pub output_json: Option, diff --git a/crates/df-storage/tests/project_soft_delete.rs b/crates/df-storage/tests/project_soft_delete.rs index 55e8558..bab1a02 100644 --- a/crates/df-storage/tests/project_soft_delete.rs +++ b/crates/df-storage/tests/project_soft_delete.rs @@ -8,6 +8,7 @@ use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo}; use df_storage::db::Database; use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord}; +use df_types::types::{ProjectStatus, TaskStatus}; // ---------- fixtures ---------- @@ -20,7 +21,7 @@ fn project(id: &str) -> ProjectRecord { id: id.to_string(), name: format!("proj-{id}"), description: "desc".to_string(), - status: "active".to_string(), + status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, @@ -35,7 +36,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord { project_id: project_id.to_string(), title: format!("task-{id}"), description: "desc".to_string(), - status: "todo".to_string(), + status: TaskStatus::Todo, priority: 1, branch_name: None, assignee: None, @@ -268,7 +269,7 @@ async fn update_field_rejects_tasks_status() { // 对照:status 未被改写,仍为初始值(task fixture 的初始 status) let rec = tasks.get_by_id("t1").await.unwrap().unwrap(); - assert_ne!(rec.status, "done", "白名单拒绝后 status 不应被改写"); + assert_ne!(rec.status.as_str(), "done", "白名单拒绝后 status 不应被改写"); } #[tokio::test] diff --git a/crates/df-types/src/types.rs b/crates/df-types/src/types.rs index d827142..e8ec8fd 100644 --- a/crates/df-types/src/types.rs +++ b/crates/df-types/src/types.rs @@ -26,6 +26,12 @@ pub type PluginId = String; pub type ExecutionId = String; /// 决策 ID pub type DecisionId = String; +/// 节点类型(如 ai / human / ai_self_review) +pub type NodeType = String; +/// 任务链接类型(如 depends_on / blocks / relates_to) +pub type LinkType = String; +/// 工具调用类型(如 function) +pub type ToolCallType = String; // ============================================================ // 时间工具 diff --git a/docs/02-架构设计/已编号方案/消息级溯源P2-切读方案-2026-06-28.md b/docs/02-架构设计/已编号方案/消息级溯源P2-切读方案-2026-06-28.md new file mode 100644 index 0000000..f8d0288 --- /dev/null +++ b/docs/02-架构设计/已编号方案/消息级溯源P2-切读方案-2026-06-28.md @@ -0,0 +1,51 @@ +# 消息级溯源 P2 切读方案 + +> 决策:2026-06-28 — 一次性切读 + 脚本迁移历史数据 + +## 现状 + +- `ai_messages` 表 ✅ 已存在(V21 迁移) +- `AiMessageRepo` ✅ CRUD 已实现 +- V21 已有一次性数据迁移(从 `ai_conversations.messages` JSON 读 → 写入 `ai_messages`) +- **但当前读写路径仍走 `ai_conversations.messages` JSON 列**,`ai_messages` 表未启用 + +## 目标 + +读/写消息完全切换到 `ai_messages` 表,删除 `ai_conversations.messages` JSON 列。 + +## 涉及改动 + +### 1. 数据迁移(已有 V21,补充幂等脚本) + +V21 迁移已有从 `ai_conversations.messages` → `ai_messages` 的逻辑。补充: +- V21 已幂等(`INSERT OR IGNORE`),可重复跑 +- 补充校验:迁移后 `ai_messages` 行数 = 各对话 messages JSON 汇总行数 + +### 2. 切读 — 后端代码 + +| 模块 | 当前 | 改为 | +|------|------|------| +| `restore_from_messages` | 从 `rec.messages` JSON 反序列化 | 从 `ai_messages` 表按 `conversation_id` 查询 + seq 排序 | +| `save_conversation` | 写 `rec.messages` JSON 列 | 写 `ai_messages` 表(insert_batch + delete_range) | +| `ai_conversation_switch` | 返回 `rec.messages`(JSON 反序列化) | 返回 `AiMessageRepo.list_by_conversation()` | +| `clear_context` | 读/写 messages JSON | 读写 ai_messages 表 | +| `compress_context` | 同上 | 同上 | +| `replace_last_active_user_content` | 同上 | 同上 | +| messages 相关 IPC 查询 | 走 JSON 列 | 走 ai_messages 表 | + +### 3. 清理 + +- `ai_conversations.messages` 列标记废弃(暂不删列,避免大表 ALTER 风险) +- 后续 V22 可选删除该列 + +### 4. 风险控制 + +- **双写保护**:切读后写入同时写 ai_messages 表 + messages JSON 列(双写期 1 周,可回退) +- **回退方案**:撤切读 → 恢复从 messages JSON 列读 + +## 执行顺序 + +1. 实现切读:改 `restore_from_messages` / `save_conversation` / `switch_conversation` 等核心路径 +2. 运行 V21 迁移(幂等,补全遗留数据) +3. 双写期观察(ai_messages 数据一致) +4. 切读完成,标记 messages 列废弃 diff --git a/src-tauri/src/commands/ai/audit/idea_source.rs b/src-tauri/src/commands/ai/audit/idea_source.rs index a2b673b..4119be8 100644 --- a/src-tauri/src/commands/ai/audit/idea_source.rs +++ b/src-tauri/src/commands/ai/audit/idea_source.rs @@ -142,6 +142,7 @@ mod idea_source_test_helpers { use df_storage::crud::IdeaRepo; use df_storage::db::Database; use df_storage::models::IdeaRecord; + use df_types::types::IdeaStatus; /// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。 /// @@ -155,7 +156,7 @@ mod idea_source_test_helpers { id: id.to_string(), title: format!("fixture-{}", id), description: String::new(), - status: "draft".to_string(), + status: IdeaStatus::Draft, priority: 1, score: None, tags: None, diff --git a/src-tauri/src/commands/ai/augmentation/resolvers.rs b/src-tauri/src/commands/ai/augmentation/resolvers.rs index ea55924..0b30c61 100644 --- a/src-tauri/src/commands/ai/augmentation/resolvers.rs +++ b/src-tauri/src/commands/ai/augmentation/resolvers.rs @@ -88,7 +88,7 @@ impl MentionResolver for ProjectResolver { ref_id: id.clone(), })?; // status: String → ProjectStatus(未知字符串按 Planning 兜底,不阻断注入) - let status = ProjectStatus::from_db_str(&record.status).unwrap_or(ProjectStatus::Planning); + let status = ProjectStatus::from_db_str(record.status.as_str()).unwrap_or(ProjectStatus::Planning); // path 脱敏:None(未绑定)保持 None;Some 则按 locality 脱敏包 newtype let path = record .path @@ -113,7 +113,7 @@ impl MentionResolver for ProjectResolver { }).await { if !tasks.is_empty() { let mut task_lines: Vec = tasks.iter().map(|t| { - format!(" - {} ({})", t.title, t.status) + format!(" - {} ({})", t.title, t.status.as_str()) }).collect(); task_lines.insert(0, format!("进行中任务({}):", tasks.len())); lines.push(task_lines.join("\n")); @@ -126,9 +126,9 @@ impl MentionResolver for ProjectResolver { ..Default::default() }).await { let related: Vec<_> = ideas.iter() - .filter(|i| i.status == "pending_review") + .filter(|i| i.status.as_str() == "pending_review") .take(3) - .map(|i| format!(" - {} ({})", i.title, i.status)) + .map(|i| format!(" - {} ({})", i.title, i.status.as_str())) .collect(); if !related.is_empty() { let mut idea_lines = vec![format!("待评估灵感({}):", related.len())]; @@ -189,7 +189,7 @@ impl MentionResolver for TaskResolver { kind: "task".to_string(), ref_id: id.clone(), })?; - let status = TaskStatus::from_db_str(&task.status).unwrap_or(TaskStatus::Todo); + let status = TaskStatus::from_db_str(task.status.as_str()).unwrap_or(TaskStatus::Todo); // join project_name:project_id 取 ProjectRecord.name;失败/无对应 None(非错误) let project_name = { let project_repo = ProjectRepo::new(&self.db); @@ -253,7 +253,7 @@ impl MentionResolver for IdeaResolver { kind: "idea".to_string(), ref_id: id.clone(), })?; - let status = IdeaStatus::from_db_str(&record.status).unwrap_or(IdeaStatus::Draft); + let status = IdeaStatus::from_db_str(record.status.as_str()).unwrap_or(IdeaStatus::Draft); Ok(Augmentation::Idea { id: record.id, title: record.title, diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index 56ca7bc..40560fa 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -162,7 +162,7 @@ pub(crate) async fn build_system_prompt_with_excluded( if excluded_project_ids.iter().any(|id| id == &p.id) { continue; } - prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description)); + prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status.as_str(), p.description)); if let Some(ref dir) = p.path { prompt.push_str(&format!(" 目录: {}\n", dir)); } @@ -181,7 +181,7 @@ pub(crate) async fn build_system_prompt_with_excluded( if excluded_task_ids.iter().any(|id| id == &tk.id) { continue; } - prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status, tk.description)); + prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status.as_str(), tk.description)); } // 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks prompt.push_str(&tasks_listed_note(lang)); diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 4d33041..9b6595e 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -11,7 +11,7 @@ use df_execute::shell::{execute, ShellRequest}; use df_storage::db::Database; use df_storage::models::{ProjectRecord, ProjectServiceRecord, TaskRecord, IdeaRecord}; -use df_types::types::new_id; +use df_types::types::{new_id, IdeaStatus, ProjectStatus, TaskStatus}; use crate::commands::now_millis; use crate::state::AllowedDirs; @@ -573,7 +573,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc) { let repo = df_storage::crud::ProjectRepo::new(&db); let record = ProjectRecord { id: new_id(), name: name.to_string(), description: description.to_string(), - status: "planning".to_string(), idea_id: None, + status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, created_at: now_millis(), updated_at: now_millis(), }; @@ -683,7 +683,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { }; // 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled if let Some(status) = args.get("status").and_then(|v| v.as_str()) { - tasks.retain(|t| t.status == status); + tasks.retain(|t| t.status.as_str() == status); } let total = tasks.len(); let offset = args["offset"].as_u64().unwrap_or(0) as usize; @@ -767,7 +767,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { id: new_id(), project_id: project_id.to_string(), title: title.to_string(), description: args["description"].as_str().unwrap_or("").to_string(), // priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high) - status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32, + status: TaskStatus::Todo, priority: args["priority"].as_i64().unwrap_or(2) as i32, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, review_rounds: 0, output_json: None, @@ -1032,13 +1032,13 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc) "backlog" => "todo".to_string(), "active" => { if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) { - current.status.clone() + current.status.as_str().to_string() } else { "in_progress".to_string() } } "todo" => "todo".to_string(), - "decision" => current.status.clone(), + "decision" => current.status.as_str().to_string(), _ => unreachable!("queue 白名单已收口"), }; @@ -1047,7 +1047,7 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc) repo.update_field(id, "queue", &new_queue).await?; } // 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径) - if current.status != new_status { + if current.status.as_str() != new_status { repo.set_status_for_aggregation(id, &new_status).await?; } @@ -1354,7 +1354,7 @@ fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc) { id: new_id(), title: title.to_string(), description: args["description"].as_str().unwrap_or("").to_string(), // priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐) - status: "draft".to_string(), priority: args["priority"].as_i64().unwrap_or(1) as i32, + status: IdeaStatus::Draft, priority: args["priority"].as_i64().unwrap_or(1) as i32, score: None, tags: args["tags"].as_str().map(|s| s.to_string()), source: args["source"].as_str().map(|s| s.to_string()), promoted_to: None, ai_analysis: None, scores: None, diff --git a/src-tauri/src/commands/idea.rs b/src-tauri/src/commands/idea.rs index f53a75f..ad6de91 100644 --- a/src-tauri/src/commands/idea.rs +++ b/src-tauri/src/commands/idea.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use tauri::State; use df_ai::provider::LlmProvider; -use df_types::types::{new_id, Priority}; +use df_types::types::{new_id, IdeaStatus, Priority, ProjectStatus}; use df_ideas::capture::Idea; use df_storage::crud::{is_unique_constraint_err, IdeaQuery}; use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord}; @@ -87,7 +87,7 @@ pub async fn create_idea( id: new_id(), title: input.title, description: input.description, - status: "draft".to_string(), + status: IdeaStatus::Draft, priority: input.priority, score: None, tags: input.tags, @@ -190,7 +190,7 @@ pub async fn promote_idea( id: project_id.clone(), name: project.name, description: project.description, - status: "planning".to_string(), + status: ProjectStatus::Planning, idea_id: Some(id.clone()), path: None, stack: None, @@ -208,7 +208,7 @@ pub async fn promote_idea( // 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿 // 删除而非真事务(改动最小,工程投入产出比最高)。 let updated = IdeaRecord { - status: "promoted".to_string(), + status: IdeaStatus::Promoted, promoted_to: Some(project_id.clone()), updated_at: now, ..record @@ -338,7 +338,7 @@ pub async fn evaluate_idea( scores: Some(scores_json.clone()), ai_analysis: Some(ai_analysis.clone()), score: Some(score_value as f64), - status: "pending_review".to_string(), + status: IdeaStatus::PendingReview, updated_at: now_millis(), ..record }; @@ -460,7 +460,7 @@ fn record_to_idea(record: &IdeaRecord) -> Idea { title: record.title.clone(), description: record.description.clone(), // IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性) - status: status_from_str(&record.status), + status: status_from_str(record.status.as_str()), priority: priority_from_i32(record.priority), scores: None, tags, diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index ea8c5cf..dae374c 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -12,7 +12,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest}; use df_ai::router::{ select_model_id, Modality, TaskRequirements, }; -use df_types::types::new_id; +use df_types::types::{new_id, ProjectStatus}; use df_project::scan::{ collect_sample, detect_stack, discover_projects, extract_description, normalize_path, DiscoveredProject, @@ -156,7 +156,7 @@ async fn create_with_binding( id: new_id(), name, description, - status: "planning".to_string(), + status: ProjectStatus::Planning, idea_id, path, stack, diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 84ae8b9..c6d5446 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -284,7 +284,7 @@ pub async fn create_task( project_id: input.project_id, title: input.title, description: input.description, - status: "todo".to_string(), + status: TaskStatus::Todo, priority: input.priority, branch_name: input.branch_name, assignee: input.assignee, @@ -314,7 +314,7 @@ pub async fn create_task( Some("task"), Some(&record.id), None, - Some(&record.status), + Some(record.status.as_str()), ) .await; Ok(record) @@ -466,8 +466,8 @@ pub async fn advance_task( "task_advanced", Some("task"), Some(&updated.id), - from_state.as_deref(), - Some(&updated.status), + from_state.as_ref().map(TaskStatus::as_str), + Some(updated.status.as_str()), ) .await; @@ -505,7 +505,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) - .get_by_id(parent_id) .await .map_err(err_str)? - .map(|t| t.status) + .map(|t| t.status.as_str().to_string()) .ok_or_else(|| format!("父任务 {parent_id} 不存在")); } @@ -541,7 +541,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) - .await .map_err(err_str)? .ok_or_else(|| format!("父任务 {parent_id} 不存在"))?; - if current.status == new_status { + if current.status.as_str() == new_status { return Ok(new_status); } state @@ -688,13 +688,13 @@ pub async fn move_task_queue( "backlog" => "todo".to_string(), "active" => { if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) { - current.status.clone() // 已在执行中三态,保留 + current.status.as_str().to_string() // 已在执行中三态,保留 } else { "in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态) } } "todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态) - "decision" => current.status.clone(), // 待决策池保留当前 status(暂停推进不重置执行态) + "decision" => current.status.as_str().to_string(), // 待决策池保留当前 status(暂停推进不重置执行态) _ => unreachable!("validate_queue 已收口"), }; @@ -708,7 +708,7 @@ pub async fn move_task_queue( .map_err(err_str)?; } // 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径) - if current.status != new_status { + if current.status.as_str() != new_status { state .tasks .set_status_for_aggregation(&id, &new_status) diff --git a/src/components/workflow/WorkflowDagDisplay.vue b/src/components/workflow/WorkflowDagDisplay.vue index f4ec066..e79d6a0 100644 --- a/src/components/workflow/WorkflowDagDisplay.vue +++ b/src/components/workflow/WorkflowDagDisplay.vue @@ -2,7 +2,7 @@

{{ $t('taskDetail.workflowDagTitle') || '工作流结构' }}

- +
{{ $t('taskDetail.workflowLayer') || '层' }} {{ li + 1 }}
@@ -19,19 +19,41 @@ {{ statusLabel(nodeStatuses[node.id]) }}
+ +
+
+ → {{ edge.target }} + {{ edge.condition || ($t('taskDetail.workflowUnconditional') || '无条件') }} +
+
- +
- {{ $t('taskDetail.workflowEdgeDetail') || '边条件详情' }} ({{ dag.edges.length }}) + {{ $t('taskDetail.workflowEdgeDetail') || '边条件编辑' }} ({{ dag.edges.length }})
{{ edge.source }} → {{ edge.target }} - {{ edge.condition }} - {{ $t('taskDetail.workflowUnconditional') || '无条件' }} + + {{ edge.condition || ($t('taskDetail.workflowUnconditional') || '无条件') }}
@@ -42,7 +64,7 @@