修复: status→enum + type alias(SMELL-P1-6)

This commit is contained in:
2026-06-28 04:42:59 +08:00
parent 48c966f6f7
commit d1b9488853
21 changed files with 304 additions and 126 deletions

View File

@@ -17,7 +17,7 @@ use std::sync::Arc;
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo}; use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; 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 futures::future::BoxFuture;
use serde_json::{json, Value}; 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)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
let description = arg_str_or(&args, "description", ""); 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); medium_audit("create_project", &name);
Box::pin(async move { Box::pin(async move {
let now = now_millis(); let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord { let rec = ProjectRecord {
id: new_id(), id: new_id(),
name, name,
@@ -269,7 +270,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
}; };
let name = arg_str_or(&args, "name", ""); let name = arg_str_or(&args, "name", "");
let description = arg_str_or(&args, "description", ""); 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); medium_audit("update_project", &id);
Box::pin(async move { Box::pin(async move {
let repo = ProjectRepo::new(&db); 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), Err(e) => return err_str(e),
}; };
let now = now_millis(); let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord { let rec = ProjectRecord {
id: id.clone(), id: id.clone(),
name, name,
@@ -355,7 +357,7 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
list.retain(|t| t.project_id == *pid); list.retain(|t| t.project_id == *pid);
} }
if let Some(st) = &status_filter { 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() })) json_ok(json!({ "tasks": list, "count": list.len() }))
} }
@@ -384,7 +386,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
project_id, project_id,
title, title,
description, description,
status: "todo".to_owned(), status: TaskStatus::Todo,
priority, priority,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -529,7 +531,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
id: new_id(), id: new_id(),
title, title,
description, description,
status: "draft".to_owned(), status: IdeaStatus::Draft,
priority, priority,
score: None, score: None,
tags: None, tags: None,

View File

@@ -273,6 +273,7 @@ mod tests {
use df_storage::crud::{ProjectRepo, TaskRepo}; use df_storage::crud::{ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord}; use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
use serde_json::json; use serde_json::json;
// ============================================================ // ============================================================
@@ -287,7 +288,7 @@ mod tests {
id: "p1".to_string(), id: "p1".to_string(),
name: "proj".to_string(), name: "proj".to_string(),
description: "".to_string(), description: "".to_string(),
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -302,7 +303,7 @@ mod tests {
project_id: "p1".to_string(), project_id: "p1".to_string(),
title: "t1".to_string(), title: "t1".to_string(),
description: "实现登录接口".to_string(), description: "实现登录接口".to_string(),
status: "testing".to_string(), status: TaskStatus::Testing,
priority: 2, priority: 2,
branch_name: None, branch_name: None,
assignee: None, assignee: None,

View File

@@ -174,14 +174,15 @@ mod tests {
use super::*; use super::*;
use df_storage::crud::ProjectRepo; use df_storage::crud::ProjectRepo;
use df_storage::models::{ProjectRecord, TaskRecord}; 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 { TaskRecord {
id: id.to_string(), id: id.to_string(),
project_id: "p1".to_string(), project_id: "p1".to_string(),
title: format!("t-{id}"), title: format!("t-{id}"),
description: "".to_string(), description: "".to_string(),
status: status.to_string(), status,
priority: 2, priority: 2,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -206,7 +207,7 @@ mod tests {
id: "p1".to_string(), id: "p1".to_string(),
name: "proj".to_string(), name: "proj".to_string(),
description: "".to_string(), description: "".to_string(),
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -221,43 +222,43 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn forward_path_todo_to_done() { async fn forward_path_todo_to_done() {
let repo = setup().await; 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(); 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); assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "in_review").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, 0); assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap(); 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(); 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); assert_eq!(r.review_rounds, 0);
} }
#[tokio::test] #[tokio::test]
async fn regression_in_review_to_in_progress_bumps_rounds() { async fn regression_in_review_to_in_progress_bumps_rounds() {
let repo = setup().await; 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(); 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"); assert_eq!(r.review_rounds, 1, "退回应 +1");
} }
#[tokio::test] #[tokio::test]
async fn regression_testing_to_in_review_bumps_rounds() { async fn regression_testing_to_in_review_bumps_rounds() {
let repo = setup().await; 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(); 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"); assert_eq!(r.review_rounds, 1, "退回应 +1");
} }
#[tokio::test] #[tokio::test]
async fn multiple_regressions_accumulate() { async fn multiple_regressions_accumulate() {
let repo = setup().await; 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) // 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_progress").await.unwrap();
advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
@@ -269,7 +270,7 @@ mod tests {
async fn illegal_skip_rejected() { async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。 // CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await; 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(); let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
match err { match err {
df_types::error::Error::InvalidState { current, expected } => { df_types::error::Error::InvalidState { current, expected } => {
@@ -285,7 +286,7 @@ mod tests {
async fn terminal_done_no_successor() { async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。 // CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await; 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(); let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
match err { match err {
df_types::error::Error::InvalidState { current, .. } => { df_types::error::Error::InvalidState { current, .. } => {
@@ -301,7 +302,7 @@ mod tests {
// 同态属空操作,归 Validation「相同状态,无需推进」; // 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。 // 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await; 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(); let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err { match err {
df_types::error::Error::Validation(msg) => { df_types::error::Error::Validation(msg) => {
@@ -314,7 +315,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn invalid_target_rejected() { async fn invalid_target_rejected() {
let repo = setup().await; 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(); let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
assert!(matches!(err, df_types::error::Error::Validation(_))); assert!(matches!(err, df_types::error::Error::Validation(_)));
} }
@@ -332,7 +333,7 @@ mod tests {
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖), // 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
// 而是验证读后改路径在 from=当前库内 status 时正常推进。 // 而是验证读后改路径在 from=当前库内 status 时正常推进。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径; // 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic) // F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
repo.advance_status_atomic("t1", "todo", "in_progress", false) repo.advance_status_atomic("t1", "todo", "in_progress", false)
@@ -340,13 +341,13 @@ mod tests {
.unwrap(); .unwrap();
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功 // 读出来是 in_progress,推进到 in_review 合法 → 正常成功
let r = advance_task_atomic(&repo, "t1", "in_review").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");
} }
#[tokio::test] #[tokio::test]
async fn cas_returns_none_when_status_mismatch() { async fn cas_returns_none_when_status_mismatch() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress) // 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
let r = repo let r = repo
.advance_status_atomic("t1", "todo", "in_review", false) .advance_status_atomic("t1", "todo", "in_review", false)
@@ -365,12 +366,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn blocked_round_trip_does_not_bump() { async fn blocked_round_trip_does_not_bump() {
let repo = setup().await; 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(); 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 不累加"); assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
let r = advance_task_atomic(&repo, "t1", "in_progress").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, "解除 blocked 不累加"); assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
} }
@@ -460,24 +461,24 @@ mod tests {
async fn callback_completed_advance_lands_in_db() { async fn callback_completed_advance_lands_in_db() {
// in_progress: todo → in_progress(②-3 in_progress 模板完成) // in_progress: todo → in_progress(②-3 in_progress 模板完成)
let repo = setup().await; 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 to = callback_advance_target("completed", "in_progress").unwrap();
let r = advance_task_atomic(&repo, "c1", &to).await.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"); assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
// testing: in_review → testing(②-3 testing 模板自审+核对通过) // 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 to = callback_advance_target("completed", "testing").unwrap();
let r = advance_task_atomic(&repo, "c2", &to).await.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); assert_eq!(r.review_rounds, 0);
// done: testing → done(②-3 done 模板最终核对通过) // 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 to = callback_advance_target("completed", "done").unwrap();
let r = advance_task_atomic(&repo, "c3", &to).await.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); assert_eq!(r.review_rounds, 0);
} }
@@ -487,32 +488,32 @@ mod tests {
async fn callback_failed_regression_lands_in_db_with_rounds_bump() { async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1) // testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
let repo = setup().await; 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(); let to = callback_advance_target("failed", "testing").unwrap();
assert_eq!(to, "in_review", "testing 失败应退回 in_review"); assert_eq!(to, "in_review", "testing 失败应退回 in_review");
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap(); 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)"); assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定): // in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射, // in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。 // 即便当前推进链 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(); let to = callback_advance_target("failed", "in_review").unwrap();
assert_eq!(to, "in_progress"); assert_eq!(to, "in_progress");
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap(); 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); assert_eq!(r.review_rounds, 1);
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b), // in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。 // 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
// 验证:callback 返回 None,不调 advance_task_atomic。 // 验证: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"); let to = callback_advance_target("failed", "in_progress");
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}"); assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
// 任务状态未被改动(仍是 in_progress) // 任务状态未被改动(仍是 in_progress)
let still = repo.get_by_id("f3").await.unwrap().unwrap(); 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。 /// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。

View File

@@ -9,6 +9,7 @@ use df_types::error::Result;
use crate::db::Database; use crate::db::Database;
use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord}; use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
use df_types::types::IdeaStatus;
use super::impl_repo; use super::impl_repo;
use super::{now_millis_str, storage_err, validate_column_name}; use super::{now_millis_str, storage_err, validate_column_name};
@@ -93,7 +94,10 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
id: row.get("id")?, id: row.get("id")?,
title: row.get("title")?, title: row.get("title")?,
description: row.get("description")?, description: row.get("description")?,
status: row.get("status")?, status: {
let s: String = row.get("status")?;
IdeaStatus::from_db_str(&s).unwrap_or_default()
},
priority: row.get("priority")?, priority: row.get("priority")?,
score: row.get("score")?, score: row.get("score")?,
tags: row.get("tags")?, tags: row.get("tags")?,
@@ -200,7 +204,7 @@ impl_repo!(
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at) "INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![ params![
rec.id, rec.title, rec.description, rec.status, rec.priority, rec.id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis, rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.created_at, rec.updated_at rec.scores, rec.related_ids, rec.created_at, rec.updated_at
], ],
@@ -210,7 +214,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13", "UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13",
params![ params![
rec.title, rec.description, rec.status, rec.priority, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis, rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id rec.scores, rec.related_ids, rec.updated_at, rec.id
], ],
@@ -1403,7 +1407,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
title: title.to_string(), title: title.to_string(),
description: String::new(), description: String::new(),
status: "draft".to_string(), status: IdeaStatus::Draft,
priority: 1, priority: 1,
score: None, score: None,
tags: None, tags: None,

View File

@@ -219,6 +219,7 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::ProjectStatus;
/// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。 /// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectEventRepo) { async fn setup() -> (crate::db::Database, ProjectEventRepo) {
@@ -232,7 +233,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,

View File

@@ -6,6 +6,7 @@ use rusqlite::{params, Connection, OptionalExtension, Row};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_types::error::Result; use df_types::error::Result;
use df_types::types::ProjectStatus;
use crate::db::Database; use crate::db::Database;
use crate::models::{ use crate::models::{
@@ -80,7 +81,10 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
id: row.get("id")?, id: row.get("id")?,
name: row.get("name")?, name: row.get("name")?,
description: row.get("description")?, description: row.get("description")?,
status: row.get("status")?, status: {
let s: String = row.get("status")?;
ProjectStatus::from_db_str(&s).unwrap_or_default()
},
idea_id: row.get("idea_id")?, idea_id: row.get("idea_id")?,
path: row.get("path")?, path: row.get("path")?,
stack: row.get("stack")?, stack: row.get("stack")?,
@@ -162,7 +166,7 @@ impl_repo!(
"INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at) "INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![ params![
rec.id, rec.name, rec.description, rec.status, rec.idea_id, rec.id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.created_at, rec.updated_at rec.path, rec.stack, rec.created_at, rec.updated_at
], ],
) )
@@ -171,7 +175,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8", "UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8",
params![ params![
rec.name, rec.description, rec.status, rec.idea_id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.updated_at, rec.id rec.path, rec.stack, rec.updated_at, rec.id
], ],
) )

View File

@@ -295,6 +295,7 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::ProjectStatus;
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。 /// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectServiceRepo) { async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
@@ -308,7 +309,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,

View File

@@ -328,6 +328,7 @@ mod tests {
use super::*; use super::*;
use crate::crud::TaskRepo; use crate::crud::TaskRepo;
use crate::models::TaskRecord; use crate::models::TaskRecord;
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。 /// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。
fn trec(id: &str, project_id: &str) -> TaskRecord { fn trec(id: &str, project_id: &str) -> TaskRecord {
@@ -336,7 +337,7 @@ mod tests {
project_id: project_id.to_string(), project_id: project_id.to_string(),
title: format!("task-{id}"), title: format!("task-{id}"),
description: String::new(), description: String::new(),
status: "todo".to_string(), status: TaskStatus::Todo,
priority: 1, priority: 1,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -369,7 +370,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,

View File

@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_types::error::Result; use df_types::error::Result;
use df_types::types::TaskStatus;
use crate::db::Database; use crate::db::Database;
use crate::models::TaskRecord; use crate::models::TaskRecord;
@@ -24,7 +25,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
project_id: row.get("project_id")?, project_id: row.get("project_id")?,
title: row.get("title")?, title: row.get("title")?,
description: row.get("description")?, description: row.get("description")?,
status: row.get("status")?, status: {
let s: String = row.get("status")?;
TaskStatus::from_db_str(&s).unwrap_or_default()
},
priority: row.get("priority")?, priority: row.get("priority")?,
branch_name: row.get("branch_name")?, branch_name: row.get("branch_name")?,
assignee: row.get("assignee")?, assignee: row.get("assignee")?,
@@ -122,7 +126,7 @@ impl_repo!(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at) "INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![ params![
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.queue, rec.parent_id, rec.content_json,
@@ -134,7 +138,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17", "UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17",
params![ params![
rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.queue, rec.parent_id, rec.content_json,
@@ -566,20 +570,21 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。 /// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord { fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord {
trec_full(id, queue, parent_id, "todo") trec_full(id, queue, parent_id, TaskStatus::Todo)
} }
/// 全参 fixture(聚合测试需自定义 status 时用)。 /// 全参 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 { TaskRecord {
id: id.to_string(), id: id.to_string(),
project_id: "proj-1".to_string(), project_id: "proj-1".to_string(),
title: format!("task-{id}"), title: format!("task-{id}"),
description: String::new(), description: String::new(),
status: status.to_string(), status,
priority: 1, priority: 1,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -605,7 +610,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -778,13 +783,13 @@ mod tests {
async fn set_status_for_aggregation_writes_status() { async fn set_status_for_aggregation_writes_status() {
let repo = setup().await; let repo = setup().await;
// 父任务初始 todo(queue=todo, parent_id=None 容器模型) // 父任务初始 todo(queue=todo, parent_id=None 容器模型)
repo.insert(trec_full("parent", "todo", None, "todo")) repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await .await
.unwrap(); .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, "应命中写入"); assert!(ok, "应命中写入");
let after = repo.get_by_id("parent").await.unwrap().unwrap(); 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"); assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds");
} }
@@ -792,7 +797,7 @@ mod tests {
async fn set_status_for_aggregation_skips_soft_deleted() { async fn set_status_for_aggregation_skips_soft_deleted() {
// 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false // 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false
let repo = setup().await; let repo = setup().await;
repo.insert(trec_full("parent", "todo", None, "todo")) repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await .await
.unwrap(); .unwrap();
repo.soft_delete("parent").await.unwrap(); repo.soft_delete("parent").await.unwrap();

View File

@@ -1,6 +1,7 @@
//! 数据模型定义 — 与数据库表对应的 Rust 结构体 //! 数据模型定义 — 与数据库表对应的 Rust 结构体
use df_ai_core::model::{deserialize_model_configs, ModelConfig}; use df_ai_core::model::{deserialize_model_configs, ModelConfig};
use df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ============================================================ // ============================================================
@@ -13,7 +14,7 @@ pub struct IdeaRecord {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub status: String, pub status: IdeaStatus,
pub priority: i32, pub priority: i32,
pub score: Option<f64>, pub score: Option<f64>,
pub tags: Option<String>, // JSON 数组 pub tags: Option<String>, // JSON 数组
@@ -53,7 +54,7 @@ pub struct ProjectRecord {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub description: String, pub description: String,
pub status: String, pub status: ProjectStatus,
pub idea_id: Option<String>, pub idea_id: Option<String>,
/// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用) /// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用)
pub path: Option<String>, pub path: Option<String>,
@@ -74,7 +75,7 @@ pub struct TaskRecord {
pub project_id: String, pub project_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub status: String, pub status: TaskStatus,
pub priority: i32, pub priority: i32,
pub branch_name: Option<String>, pub branch_name: Option<String>,
pub assignee: Option<String>, pub assignee: Option<String>,
@@ -134,14 +135,14 @@ pub struct TaskRecord {
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明 /// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
/// - `relates_to`:弱关联,无执行约束 → 上下文提示 /// - `relates_to`:弱关联,无执行约束 → 上下文提示
/// - `remark`:可选备注。 /// - `remark`:可选备注。
/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束, /// 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
/// 对标设计 D8:task_links 数据量小,检测成本)。跨项目依赖允许。 /// 对标设计 D8:task_links 数据量小,检测成本)。跨项目依赖允许。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskLinkRecord { pub struct TaskLinkRecord {
pub id: String, pub id: String,
pub source_id: String, pub source_id: String,
pub target_id: String, pub target_id: String,
pub link_type: String, pub link_type: LinkType,
pub remark: Option<String>, pub remark: Option<String>,
pub created_at: String, pub created_at: String,
} }
@@ -265,7 +266,7 @@ pub struct NodeExecutionRecord {
pub id: String, pub id: String,
pub workflow_id: String, pub workflow_id: String,
pub node_id: String, pub node_id: String,
pub node_type: String, pub node_type: NodeType,
pub status: String, pub status: String,
pub input_json: Option<String>, pub input_json: Option<String>,
pub output_json: Option<String>, pub output_json: Option<String>,

View File

@@ -8,6 +8,7 @@
use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo}; use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord}; use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
// ---------- fixtures ---------- // ---------- fixtures ----------
@@ -20,7 +21,7 @@ fn project(id: &str) -> ProjectRecord {
id: id.to_string(), id: id.to_string(),
name: format!("proj-{id}"), name: format!("proj-{id}"),
description: "desc".to_string(), description: "desc".to_string(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -35,7 +36,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
project_id: project_id.to_string(), project_id: project_id.to_string(),
title: format!("task-{id}"), title: format!("task-{id}"),
description: "desc".to_string(), description: "desc".to_string(),
status: "todo".to_string(), status: TaskStatus::Todo,
priority: 1, priority: 1,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -268,7 +269,7 @@ async fn update_field_rejects_tasks_status() {
// 对照:status 未被改写,仍为初始值(task fixture 的初始 status) // 对照:status 未被改写,仍为初始值(task fixture 的初始 status)
let rec = tasks.get_by_id("t1").await.unwrap().unwrap(); 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] #[tokio::test]

View File

@@ -26,6 +26,12 @@ pub type PluginId = String;
pub type ExecutionId = String; pub type ExecutionId = String;
/// 决策 ID /// 决策 ID
pub type DecisionId = String; 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;
// ============================================================ // ============================================================
// 时间工具 // 时间工具

View File

@@ -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 列废弃

View File

@@ -142,6 +142,7 @@ mod idea_source_test_helpers {
use df_storage::crud::IdeaRepo; use df_storage::crud::IdeaRepo;
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::IdeaRecord; use df_storage::models::IdeaRecord;
use df_types::types::IdeaStatus;
/// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。 /// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。
/// ///
@@ -155,7 +156,7 @@ mod idea_source_test_helpers {
id: id.to_string(), id: id.to_string(),
title: format!("fixture-{}", id), title: format!("fixture-{}", id),
description: String::new(), description: String::new(),
status: "draft".to_string(), status: IdeaStatus::Draft,
priority: 1, priority: 1,
score: None, score: None,
tags: None, tags: None,

View File

@@ -88,7 +88,7 @@ impl MentionResolver for ProjectResolver {
ref_id: id.clone(), ref_id: id.clone(),
})?; })?;
// status: String → ProjectStatus(未知字符串按 Planning 兜底,不阻断注入) // 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 // path 脱敏:None(未绑定)保持 None;Some 则按 locality 脱敏包 newtype
let path = record let path = record
.path .path
@@ -113,7 +113,7 @@ impl MentionResolver for ProjectResolver {
}).await { }).await {
if !tasks.is_empty() { if !tasks.is_empty() {
let mut task_lines: Vec<String> = tasks.iter().map(|t| { let mut task_lines: Vec<String> = tasks.iter().map(|t| {
format!(" - {} ({})", t.title, t.status) format!(" - {} ({})", t.title, t.status.as_str())
}).collect(); }).collect();
task_lines.insert(0, format!("进行中任务({}):", tasks.len())); task_lines.insert(0, format!("进行中任务({}):", tasks.len()));
lines.push(task_lines.join("\n")); lines.push(task_lines.join("\n"));
@@ -126,9 +126,9 @@ impl MentionResolver for ProjectResolver {
..Default::default() ..Default::default()
}).await { }).await {
let related: Vec<_> = ideas.iter() let related: Vec<_> = ideas.iter()
.filter(|i| i.status == "pending_review") .filter(|i| i.status.as_str() == "pending_review")
.take(3) .take(3)
.map(|i| format!(" - {} ({})", i.title, i.status)) .map(|i| format!(" - {} ({})", i.title, i.status.as_str()))
.collect(); .collect();
if !related.is_empty() { if !related.is_empty() {
let mut idea_lines = vec![format!("待评估灵感({}):", related.len())]; let mut idea_lines = vec![format!("待评估灵感({}):", related.len())];
@@ -189,7 +189,7 @@ impl MentionResolver for TaskResolver {
kind: "task".to_string(), kind: "task".to_string(),
ref_id: id.clone(), 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(非错误) // join project_name:project_id 取 ProjectRecord.name;失败/无对应 None(非错误)
let project_name = { let project_name = {
let project_repo = ProjectRepo::new(&self.db); let project_repo = ProjectRepo::new(&self.db);
@@ -253,7 +253,7 @@ impl MentionResolver for IdeaResolver {
kind: "idea".to_string(), kind: "idea".to_string(),
ref_id: id.clone(), 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 { Ok(Augmentation::Idea {
id: record.id, id: record.id,
title: record.title, title: record.title,

View File

@@ -162,7 +162,7 @@ pub(crate) async fn build_system_prompt_with_excluded(
if excluded_project_ids.iter().any(|id| id == &p.id) { if excluded_project_ids.iter().any(|id| id == &p.id) {
continue; 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 { if let Some(ref dir) = p.path {
prompt.push_str(&format!(" 目录: {}\n", dir)); 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) { if excluded_task_ids.iter().any(|id| id == &tk.id) {
continue; 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 // 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
prompt.push_str(&tasks_listed_note(lang)); prompt.push_str(&tasks_listed_note(lang));

View File

@@ -11,7 +11,7 @@ use df_execute::shell::{execute, ShellRequest};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{ProjectRecord, ProjectServiceRecord, TaskRecord, IdeaRecord}; 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::commands::now_millis;
use crate::state::AllowedDirs; use crate::state::AllowedDirs;
@@ -573,7 +573,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let repo = df_storage::crud::ProjectRepo::new(&db); let repo = df_storage::crud::ProjectRepo::new(&db);
let record = ProjectRecord { let record = ProjectRecord {
id: new_id(), name: name.to_string(), description: description.to_string(), 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, path: None, stack: None,
created_at: now_millis(), updated_at: now_millis(), created_at: now_millis(), updated_at: now_millis(),
}; };
@@ -683,7 +683,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
}; };
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled // 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled
if let Some(status) = args.get("status").and_then(|v| v.as_str()) { 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 total = tasks.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize; let offset = args["offset"].as_u64().unwrap_or(0) as usize;
@@ -767,7 +767,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
id: new_id(), project_id: project_id.to_string(), title: title.to_string(), id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(), description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high) // 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, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
@@ -1032,13 +1032,13 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
"backlog" => "todo".to_string(), "backlog" => "todo".to_string(),
"active" => { "active" => {
if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) { if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) {
current.status.clone() current.status.as_str().to_string()
} else { } else {
"in_progress".to_string() "in_progress".to_string()
} }
} }
"todo" => "todo".to_string(), "todo" => "todo".to_string(),
"decision" => current.status.clone(), "decision" => current.status.as_str().to_string(),
_ => unreachable!("queue 白名单已收口"), _ => unreachable!("queue 白名单已收口"),
}; };
@@ -1047,7 +1047,7 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
repo.update_field(id, "queue", &new_queue).await?; repo.update_field(id, "queue", &new_queue).await?;
} }
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径) // 写 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?; repo.set_status_for_aggregation(id, &new_status).await?;
} }
@@ -1354,7 +1354,7 @@ fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
id: new_id(), title: title.to_string(), id: new_id(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(), description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐) // 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()), score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
source: args["source"].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, promoted_to: None, ai_analysis: None, scores: None,

View File

@@ -6,7 +6,7 @@ use serde::Deserialize;
use tauri::State; use tauri::State;
use df_ai::provider::LlmProvider; 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_ideas::capture::Idea;
use df_storage::crud::{is_unique_constraint_err, IdeaQuery}; use df_storage::crud::{is_unique_constraint_err, IdeaQuery};
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord}; use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord};
@@ -87,7 +87,7 @@ pub async fn create_idea(
id: new_id(), id: new_id(),
title: input.title, title: input.title,
description: input.description, description: input.description,
status: "draft".to_string(), status: IdeaStatus::Draft,
priority: input.priority, priority: input.priority,
score: None, score: None,
tags: input.tags, tags: input.tags,
@@ -190,7 +190,7 @@ pub async fn promote_idea(
id: project_id.clone(), id: project_id.clone(),
name: project.name, name: project.name,
description: project.description, description: project.description,
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: Some(id.clone()), idea_id: Some(id.clone()),
path: None, path: None,
stack: None, stack: None,
@@ -208,7 +208,7 @@ pub async fn promote_idea(
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿 // 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿
// 删除而非真事务(改动最小,工程投入产出比最高)。 // 删除而非真事务(改动最小,工程投入产出比最高)。
let updated = IdeaRecord { let updated = IdeaRecord {
status: "promoted".to_string(), status: IdeaStatus::Promoted,
promoted_to: Some(project_id.clone()), promoted_to: Some(project_id.clone()),
updated_at: now, updated_at: now,
..record ..record
@@ -338,7 +338,7 @@ pub async fn evaluate_idea(
scores: Some(scores_json.clone()), scores: Some(scores_json.clone()),
ai_analysis: Some(ai_analysis.clone()), ai_analysis: Some(ai_analysis.clone()),
score: Some(score_value as f64), score: Some(score_value as f64),
status: "pending_review".to_string(), status: IdeaStatus::PendingReview,
updated_at: now_millis(), updated_at: now_millis(),
..record ..record
}; };
@@ -460,7 +460,7 @@ fn record_to_idea(record: &IdeaRecord) -> Idea {
title: record.title.clone(), title: record.title.clone(),
description: record.description.clone(), description: record.description.clone(),
// IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性) // 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), priority: priority_from_i32(record.priority),
scores: None, scores: None,
tags, tags,

View File

@@ -12,7 +12,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest};
use df_ai::router::{ use df_ai::router::{
select_model_id, Modality, TaskRequirements, select_model_id, Modality, TaskRequirements,
}; };
use df_types::types::new_id; use df_types::types::{new_id, ProjectStatus};
use df_project::scan::{ use df_project::scan::{
collect_sample, detect_stack, discover_projects, extract_description, collect_sample, detect_stack, discover_projects, extract_description,
normalize_path, DiscoveredProject, normalize_path, DiscoveredProject,
@@ -156,7 +156,7 @@ async fn create_with_binding(
id: new_id(), id: new_id(),
name, name,
description, description,
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id, idea_id,
path, path,
stack, stack,

View File

@@ -284,7 +284,7 @@ pub async fn create_task(
project_id: input.project_id, project_id: input.project_id,
title: input.title, title: input.title,
description: input.description, description: input.description,
status: "todo".to_string(), status: TaskStatus::Todo,
priority: input.priority, priority: input.priority,
branch_name: input.branch_name, branch_name: input.branch_name,
assignee: input.assignee, assignee: input.assignee,
@@ -314,7 +314,7 @@ pub async fn create_task(
Some("task"), Some("task"),
Some(&record.id), Some(&record.id),
None, None,
Some(&record.status), Some(record.status.as_str()),
) )
.await; .await;
Ok(record) Ok(record)
@@ -466,8 +466,8 @@ pub async fn advance_task(
"task_advanced", "task_advanced",
Some("task"), Some("task"),
Some(&updated.id), Some(&updated.id),
from_state.as_deref(), from_state.as_ref().map(TaskStatus::as_str),
Some(&updated.status), Some(updated.status.as_str()),
) )
.await; .await;
@@ -505,7 +505,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -
.get_by_id(parent_id) .get_by_id(parent_id)
.await .await
.map_err(err_str)? .map_err(err_str)?
.map(|t| t.status) .map(|t| t.status.as_str().to_string())
.ok_or_else(|| format!("父任务 {parent_id} 不存在")); .ok_or_else(|| format!("父任务 {parent_id} 不存在"));
} }
@@ -541,7 +541,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -
.await .await
.map_err(err_str)? .map_err(err_str)?
.ok_or_else(|| format!("父任务 {parent_id} 不存在"))?; .ok_or_else(|| format!("父任务 {parent_id} 不存在"))?;
if current.status == new_status { if current.status.as_str() == new_status {
return Ok(new_status); return Ok(new_status);
} }
state state
@@ -688,13 +688,13 @@ pub async fn move_task_queue(
"backlog" => "todo".to_string(), "backlog" => "todo".to_string(),
"active" => { "active" => {
if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) { if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) {
current.status.clone() // 已在执行中三态,保留 current.status.as_str().to_string() // 已在执行中三态,保留
} else { } else {
"in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态) "in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态)
} }
} }
"todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态) "todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态)
"decision" => current.status.clone(), // 待决策池保留当前 status(暂停推进不重置执行态) "decision" => current.status.as_str().to_string(), // 待决策池保留当前 status(暂停推进不重置执行态)
_ => unreachable!("validate_queue 已收口"), _ => unreachable!("validate_queue 已收口"),
}; };
@@ -708,7 +708,7 @@ pub async fn move_task_queue(
.map_err(err_str)?; .map_err(err_str)?;
} }
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径) // 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径)
if current.status != new_status { if current.status.as_str() != new_status {
state state
.tasks .tasks
.set_status_for_aggregation(&id, &new_status) .set_status_for_aggregation(&id, &new_status)

View File

@@ -2,7 +2,7 @@
<div v-if="dag" class="wf-dag"> <div v-if="dag" class="wf-dag">
<h4 class="wf-dag-title">{{ $t('taskDetail.workflowDagTitle') || '工作流结构' }}</h4> <h4 class="wf-dag-title">{{ $t('taskDetail.workflowDagTitle') || '工作流结构' }}</h4>
<!-- 拓扑分层渲染同层并行层间串行 --> <!-- 拓扑分层渲染 -->
<div class="wf-dag-layers"> <div class="wf-dag-layers">
<div v-for="(layer, li) in layers" :key="li" class="wf-dag-layer"> <div v-for="(layer, li) in layers" :key="li" class="wf-dag-layer">
<div class="wf-dag-layer-label">{{ $t('taskDetail.workflowLayer') || '层' }} {{ li + 1 }}</div> <div class="wf-dag-layer-label">{{ $t('taskDetail.workflowLayer') || '层' }} {{ li + 1 }}</div>
@@ -19,19 +19,41 @@
<span v-if="nodeStatuses[node.id]" class="wf-dag-node-status">{{ statusLabel(nodeStatuses[node.id]) }}</span> <span v-if="nodeStatuses[node.id]" class="wf-dag-node-status">{{ statusLabel(nodeStatuses[node.id]) }}</span>
</div> </div>
</div> </div>
<!-- 出边条件标签同层节点出站边展示条件 -->
<div v-if="layerOutEdges[li]" class="wf-dag-outedges">
<div v-for="edge in layerOutEdges[li]" :key="edge.source + '→' + edge.target" class="wf-dag-outedge">
<span class="wf-dag-outedge-arrow"> {{ edge.target }}</span>
<span class="wf-dag-cond-label" :class="{ 'wf-dag-cond-label--uncond': !edge.condition }">{{ edge.condition || ($t('taskDetail.workflowUnconditional') || '无条件') }}</span>
</div>
</div>
<!-- 层间箭头 --> <!-- 层间箭头 -->
<div v-if="li < layers.length - 1" class="wf-dag-layer-arrow"></div> <div v-if="li < layers.length - 1" class="wf-dag-layer-arrow"></div>
</div> </div>
</div> </div>
<!-- 边条件列表折叠态hover 节点时参考 --> <!-- 边条件编辑列表 -->
<details class="wf-dag-edges-detail"> <details class="wf-dag-edges-detail">
<summary class="wf-dag-edges-summary">{{ $t('taskDetail.workflowEdgeDetail') || '边条件详情' }} ({{ dag.edges.length }})</summary> <summary class="wf-dag-edges-summary">{{ $t('taskDetail.workflowEdgeDetail') || '边条件编辑' }} ({{ dag.edges.length }})</summary>
<div class="wf-dag-edges"> <div class="wf-dag-edges">
<div v-for="(edge, i) in dag.edges" :key="i" class="wf-dag-edge"> <div v-for="(edge, i) in dag.edges" :key="i" class="wf-dag-edge">
<span class="wf-dag-edge-arrow">{{ edge.source }} {{ edge.target }}</span> <span class="wf-dag-edge-arrow">{{ edge.source }} {{ edge.target }}</span>
<span v-if="edge.condition" class="wf-dag-edge-cond" :title="edge.condition">{{ edge.condition }}</span> <input
<span v-else class="wf-dag-edge-cond wf-dag-edge-cond--uncond">{{ $t('taskDetail.workflowUnconditional') || '无条件' }}</span> v-if="editingIndex === i"
ref="editInputRef"
class="wf-dag-edge-input"
:value="editValue"
@input="editValue = ($event.target as HTMLInputElement).value"
@keydown.enter="saveCondition(i)"
@keydown.escape="cancelEdit"
@blur="saveCondition(i)"
:placeholder="$t('taskDetail.workflowCondPlaceholder') || '输入条件表达式'"
/>
<span
v-else
class="wf-dag-cond-label"
:class="{ 'wf-dag-cond-label--uncond': !edge.condition, 'wf-dag-cond-label--editable': true }"
@click="startEdit(i, edge)"
>{{ edge.condition || ($t('taskDetail.workflowUnconditional') || '无条件') }}</span>
</div> </div>
</div> </div>
</details> </details>
@@ -42,7 +64,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, computed, nextTick } from 'vue'
interface DagNode { interface DagNode {
id: string id: string
@@ -63,6 +85,10 @@ const props = withDefaults(defineProps<{
nodeStatuses: () => ({}), nodeStatuses: () => ({}),
}) })
const emit = defineEmits<{
(e: 'update:dagJson', val: string): void
}>()
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => { const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
try { return JSON.parse(props.dagJson) } try { return JSON.parse(props.dagJson) }
catch { return null } catch { return null }
@@ -89,11 +115,10 @@ function nodeClass(id: string, statuses: Record<string, NodeStatus>): Record<str
} }
} }
/** 拓扑分层:从无入边的根节点开始,逐层推进 */ /** 拓扑分层 */
const layers = computed<(DagNode & { deps?: string[] })[][]>(() => { const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
if (!dag.value) return [] if (!dag.value) return []
const d = dag.value const d = dag.value
// 入边计数
const inDegree: Record<string, number> = {} const inDegree: Record<string, number> = {}
const edgeMap: Record<string, string[]> = {} const edgeMap: Record<string, string[]> = {}
for (const n of d.nodes) { for (const n of d.nodes) {
@@ -105,8 +130,7 @@ const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
edgeMap[e.source].push(e.target) edgeMap[e.source].push(e.target)
inDegree[e.target] = (inDegree[e.target] || 0) + 1 inDegree[e.target] = (inDegree[e.target] || 0) + 1
} }
// Kahn 拓扑排序 const result: (DagNode & { deps?: string[] })[][] = []
const layers: (DagNode & { deps?: string[] })[][] = []
let queue = Object.entries(inDegree).filter(([, d]) => d === 0).map(([id]) => id) let queue = Object.entries(inDegree).filter(([, d]) => d === 0).map(([id]) => id)
const visited = new Set<string>() const visited = new Set<string>()
while (queue.length > 0) { while (queue.length > 0) {
@@ -122,11 +146,59 @@ const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
if (inDegree[target] === 0) next.push(target) if (inDegree[target] === 0) next.push(target)
} }
} }
if (layer.length > 0) layers.push(layer) if (layer.length > 0) result.push(layer)
queue = next queue = next
} }
return layers return result
}) })
/**
* 每层出边索引缓存版layers 变化时重新计算,避免模板内重复调用函数。
* 返回 { [layerIndex]: EdgeDef[] },仅含出边非空的层。
*/
const layerOutEdges = computed<Record<number, EdgeDef[]>>(() => {
if (!dag.value) return {}
const bySource: Record<string, EdgeDef[]> = {}
for (const e of dag.value.edges) {
if (!bySource[e.source]) bySource[e.source] = []
bySource[e.source].push(e)
}
const result: Record<number, EdgeDef[]> = {}
for (let li = 0; li < layers.value.length; li++) {
const edges: EdgeDef[] = []
for (const node of layers.value[li]) {
const out = bySource[node.id]
if (out) edges.push(...out)
}
if (edges.length > 0) result[li] = edges
}
return result
})
// ── 条件编辑 ──
const editingIndex = ref<number | null>(null)
const editValue = ref('')
const editInputRef = ref<HTMLInputElement | null>(null)
function startEdit(i: number, edge: EdgeDef) {
editingIndex.value = i
editValue.value = edge.condition || ''
nextTick(() => editInputRef.value?.focus())
}
function saveCondition(i: number) {
if (!dag.value || editingIndex.value !== i) return
const edges = [...dag.value.edges]
const val = editValue.value.trim()
edges[i] = { ...edges[i], condition: val || null }
const updated = { ...dag.value, edges }
emit('update:dagJson', JSON.stringify(updated, null, 2))
editingIndex.value = null
}
function cancelEdit() {
editingIndex.value = null
}
</script> </script>
<style scoped> <style scoped>
@@ -180,14 +252,40 @@ const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* 出边条件标签 */
.wf-dag-outedges {
display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 12px;
padding: 2px 0;
}
.wf-dag-outedge {
display: inline-flex; align-items: center; gap: 4px; font-size: 10px;
}
.wf-dag-outedge-arrow { color: var(--df-text-primary); white-space: nowrap; }
/* 条件标签(共享:出边展示 + 编辑列表) */
.wf-dag-cond-label {
font-family: var(--df-font-mono); font-size: 10px; padding: 0 4px;
background: rgba(74,144,226,0.08); border-radius: 2px; color: var(--df-info, #4a90e2);
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wf-dag-cond-label--uncond { background: transparent; color: var(--df-text-dim, #aaa); }
.wf-dag-cond-label--editable {
cursor: pointer; font-size: 11px; padding: 1px 6px; border-radius: 3px;
transition: background 0.15s;
}
.wf-dag-cond-label--editable:hover { background: rgba(74,144,226,0.18); }
.wf-dag-cond-label--uncond.wf-dag-cond-label--editable:hover { background: var(--df-bg-card); }
/* 边条件编辑列表 */
.wf-dag-edges-detail { margin-top: 8px; } .wf-dag-edges-detail { margin-top: 8px; }
.wf-dag-edges-summary { cursor: pointer; font-size: 11px; color: var(--df-info, #4a90e2); } .wf-dag-edges-summary { cursor: pointer; font-size: 11px; color: var(--df-info, #4a90e2); }
.wf-dag-edges { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; } .wf-dag-edges { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
.wf-dag-edge { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; } .wf-dag-edge { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; }
.wf-dag-edge-arrow { color: var(--df-text-primary); } .wf-dag-edge-arrow { color: var(--df-text-primary); white-space: nowrap; }
.wf-dag-edge-cond { .wf-dag-edge-input {
font-family: var(--df-font-mono); font-size: 11px; padding: 1px 6px; flex: 1; min-width: 0; font-family: var(--df-font-mono); font-size: 11px;
background: rgba(74,144,226,0.08); border-radius: 3px; color: var(--df-info, #4a90e2); padding: 2px 6px; border: 0.5px solid var(--df-border); border-radius: 3px;
background: var(--df-bg); color: var(--df-text); outline: none;
} }
.wf-dag-edge-cond--uncond { background: transparent; color: var(--df-text-dim, #aaa); } .wf-dag-edge-input:focus { border-color: var(--df-accent); }
</style> </style>