修复: status→enum + type alias(SMELL-P1-6)
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 时间工具
|
// 时间工具
|
||||||
|
|||||||
51
docs/02-架构设计/已编号方案/消息级溯源P2-切读方案-2026-06-28.md
Normal file
51
docs/02-架构设计/已编号方案/消息级溯源P2-切读方案-2026-06-28.md
Normal 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 列废弃
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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(¤t.status.as_str()) {
|
if ACTIVE_OK_STATUSES.contains(¤t.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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(¤t.status.as_str()) {
|
if ACTIVE_OK_STATUSES.contains(¤t.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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user