新增: 任务推进链(7态状态机+advance_task CAS原子写)+软删除+前后端7态对齐

This commit is contained in:
2026-06-16 02:33:15 +08:00
parent f30df333b3
commit d2cb38cdac
21 changed files with 1628 additions and 167 deletions

View File

@@ -8,6 +8,7 @@ df-core = { path = "../df-core" }
df-execute = { path = "../df-execute" } df-execute = { path = "../df-execute" }
df-workflow = { path = "../df-workflow" } df-workflow = { path = "../df-workflow" }
df-ai = { path = "../df-ai" } df-ai = { path = "../df-ai" }
df-storage = { path = "../df-storage" }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -3,3 +3,5 @@
pub mod ai_node; pub mod ai_node;
pub mod human_node; pub mod human_node;
pub mod script_node; pub mod script_node;
pub mod task_advance_node;
pub mod task_state_machine;

View File

@@ -36,6 +36,7 @@ impl Node for ScriptNode {
working_dir, working_dir,
env: std::collections::HashMap::new(), env: std::collections::HashMap::new(),
timeout_secs, timeout_secs,
shell_type: Default::default(),
}; };
tracing::info!("ScriptNode 执行命令: {}", command); tracing::info!("ScriptNode 执行命令: {}", command);

View File

@@ -0,0 +1,341 @@
//! 任务推进节点 — advance_task 推进链触发器(F-260616-02)
//!
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
//! 1. 读当前 TaskRecord(取 from status)
//! 2. 状态机校验 can_transition(from, to)(task_state_machine)
//! 3. 原子写:下沉 SQL `WHERE id AND status=expected` 防 TOCTOU,退回转换一并
//! review_rounds+=1(见 df_storage::crud::TaskRepo::advance_status_atomic)
//!
//! 两条调用路径复用同一推进逻辑:
//! - DAG 工作流:TaskAdvanceNode 实现 Node trait,从 NodeContext.config 读 task_id /
//! target_status,持有 Arc<Database> 构造 TaskRepo。
//! - IPC 直驱:advance_task_atomic 公开 async fn,src-tauri commands::task::advance_task
//! thin 入口直接调用(无需构造 NodeContext,避免 EventBus/StateMachine 等重依赖)。
use std::sync::Arc;
use async_trait::async_trait;
use df_storage::crud::TaskRepo;
use df_storage::db::Database;
use df_storage::models::TaskRecord;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use crate::task_state_machine::{can_transition, is_regression, is_valid_state};
/// 推进任务到目标状态(核心逻辑,DAG 节点与 IPC 入口共用)。
///
/// 入参:
/// - `repo`:df-storage TaskRepo
/// - `id`:任务 ID
/// - `target_status`:目标状态(7 态之一,snake_case)
///
/// 流程:
/// 1. 任务存在性:找不到 → InvalidState 错误(复用 Error::InvalidState 语义,前端可据此提示)
/// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链)
/// 3. 状态机:can_transition(from, to) 不合法 → InvalidState 错误(含 from/to 上下文)
/// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true
/// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功)
///
/// 返回:成功推进后的最新 TaskRecord(含新 status / 累加后的 review_rounds / 新 updated_at)。
pub async fn advance_task_atomic(
repo: &TaskRepo,
id: &str,
target_status: &str,
) -> df_core::error::Result<TaskRecord> {
// 1. target 合法性(7 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
if !is_valid_state(target_status) {
return Err(df_core::error::Error::Validation(format!(
"非法 target_status {:?},合法值: todo/in_progress/in_review/testing/done/blocked/cancelled",
target_status
)));
}
// 2. 读当前任务(取 from status)。
let current = repo
.get_by_id(id)
.await?
.ok_or_else(|| df_core::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
// 3. 状态机校验(同态拒绝:todo→todo 等,can_transition 返回 false)。
let from = current.status.as_str();
if from == target_status {
return Err(df_core::error::Error::InvalidState {
current: from.to_string(),
expected: target_status.to_string(),
});
}
if !can_transition(from, target_status) {
return Err(df_core::error::Error::InvalidState {
current: from.to_string(),
expected: target_status.to_string(),
});
}
// 4. 原子 CAS 写。退回转换一并 review_rounds+=1。
let bump = is_regression(from, target_status);
let updated = repo
.advance_status_atomic(id, from, target_status, bump)
.await?;
// 5. CAS 失败:并发已改动 status(或任务被删),拒绝静默成功。
updated.ok_or_else(|| df_core::error::Error::InvalidState {
current: "已变更(并发推进或旁路修改)".to_string(),
expected: from.to_string(),
})
}
// ============================================================
// DAG 工作流节点 — TaskAdvanceNode(推进链在 DAG 内的形态)
// ============================================================
/// 任务推进节点 — DAG 工作流内触发 advance_task。
///
/// 持有 Arc<Database> 在 NodeRegistry 注册时注入(state.rs build_registry),
/// Node::execute 从 NodeContext.config 读 task_id / target_status,调 advance_task_atomic。
/// 当前推进链 F-01~04 阶段为手动推进(IPC 直驱),DAG 形态为阶段 2(工作流联动)预留。
pub struct TaskAdvanceNode {
db: Arc<Database>,
}
impl TaskAdvanceNode {
/// 构造节点(注册表工厂调用,注入数据库句柄)
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
}
#[async_trait]
impl Node for TaskAdvanceNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
let task_id = ctx
.config
.get("task_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: task_id"))?;
let target_status = ctx
.config
.get("target_status")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: target_status"))?;
let repo = TaskRepo::new(&self.db);
let updated = advance_task_atomic(&repo, task_id, target_status)
.await
.map_err(|e| anyhow::anyhow!("推进任务失败: {}", e))?;
Ok(NodeOutput::from_value(serde_json::json!({
"task": updated,
"task_id": updated.id,
"status": updated.status,
"review_rounds": updated.review_rounds,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"task_id": { "type": "string" },
"target_status": {
"type": "string",
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled"]
}
},
"required": ["task_id", "target_status"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"task_id": { "type": "string" },
"status": { "type": "string" },
"review_rounds": { "type": "integer" }
}
}),
}
}
fn node_type(&self) -> &str {
"task_advance"
}
}
// ============================================================
// 单元测试 — advance_task_atomic 状态机集成(内存 DB)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use df_storage::crud::ProjectRepo;
use df_storage::models::{ProjectRecord, TaskRecord};
fn rec(id: &str, status: &str) -> TaskRecord {
TaskRecord {
id: id.to_string(),
project_id: "p1".to_string(),
title: format!("t-{id}"),
description: "".to_string(),
status: status.to_string(),
priority: 2,
branch_name: None,
assignee: None,
workflow_def_id: None,
base_branch: None,
review_rounds: 0,
created_at: "0".to_string(),
updated_at: "0".to_string(),
}
}
async fn setup() -> TaskRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
// 先插父项目,满足 tasks.project_id FK 约束(PRAGMA foreign_keys=ON)。
ProjectRepo::new(&db)
.insert(ProjectRecord {
id: "p1".to_string(),
name: "proj".to_string(),
description: "".to_string(),
status: "planning".to_string(),
idea_id: None,
path: None,
stack: None,
created_at: "0".to_string(),
updated_at: "0".to_string(),
})
.await
.expect("insert project");
TaskRepo::new(&db)
}
#[tokio::test]
async fn forward_path_todo_to_done() {
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
// 主路径逐级推进
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review");
assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
assert_eq!(r.status, "testing");
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
assert_eq!(r.status, "done");
assert_eq!(r.review_rounds, 0);
}
#[tokio::test]
async fn regression_in_review_to_in_progress_bumps_rounds() {
let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 1, "退回应 +1");
}
#[tokio::test]
async fn regression_testing_to_in_review_bumps_rounds() {
let repo = setup().await;
repo.insert(rec("t1", "testing")).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review");
assert_eq!(r.review_rounds, 1, "退回应 +1");
}
#[tokio::test]
async fn multiple_regressions_accumulate() {
let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap();
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.review_rounds, 2);
}
#[tokio::test]
async fn illegal_skip_rejected() {
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
}
#[tokio::test]
async fn terminal_done_no_successor() {
let repo = setup().await;
repo.insert(rec("t1", "done")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
}
#[tokio::test]
async fn same_status_rejected() {
let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
}
#[tokio::test]
async fn invalid_target_rejected() {
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
assert!(matches!(err, df_core::error::Error::Validation(_)));
}
#[tokio::test]
async fn not_found_rejected() {
let repo = setup().await;
let err = advance_task_atomic(&repo, "nope", "in_progress").await.unwrap_err();
assert!(matches!(err, df_core::error::Error::NotFound(_)));
}
#[tokio::test]
async fn concurrent_cas_change_detected() {
// 模拟 TOCTOU:推进前已被旁路改 status。CAS 的 expected 与库内不符 → None → 报错。
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
// 旁路把 status 改成 in_progress(模拟另一路并发推进)
repo.update_field("t1", "status", "in_progress").await.unwrap();
// 此时读出来是 in_progress,推进到 in_review 是合法的——这测的不是 CAS 失败,
// 而是验证「读后改」路径在 status 一致时正常。CAS 失败路径靠 advance_status_atomic
// 自身的 None 返回覆盖(下方 cas_returns_none_when_status_mismatch 单测)。
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review");
}
#[tokio::test]
async fn cas_returns_none_when_status_mismatch() {
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
let r = repo
.advance_status_atomic("t1", "todo", "in_review", false)
.await
.unwrap();
// 此时 status 确实是 todo,CAS 成功,返回 Some
assert!(r.is_some());
// 再用错误的 expected todo(实际已是 in_review)→ CAS 失败 None
let r = repo
.advance_status_atomic("t1", "todo", "done", false)
.await
.unwrap();
assert!(r.is_none(), "expected 与库内不符应 CAS 失败");
}
#[tokio::test]
async fn blocked_round_trip_does_not_bump() {
let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
assert_eq!(r.status, "blocked");
assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
}
}

View File

@@ -0,0 +1,256 @@
//! 任务推进状态机 — 7 态合法转换定义(F-260616-01)
//!
//! 独立模块,非挂在 df-core::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
//! (原子写 SQL 在 task_advance_node.rs 完成,见 F-260616-02)。
//!
//! 7 态(与 df-core::TaskStatus / 前端对齐,D-260616-01):
//! todo / in_progress / in_review / testing / done / blocked / cancelled
//!
//! 闸门链主路径: todo → in_progress → in_review → testing → done
//!
//! 合法转换矩阵(以报告 docs/05-代码审查/任务执行与推进能力分析-2026-06-16.md §8
//! 与决策 docs/02-架构设计/任务推进链实施路径-2026-06-16.md F-01 为准):
//! - todo → in_progress(开始), cancelled(取消)
//! - in_progress → in_review(提交审查), blocked(阻塞), cancelled
//! - in_review → testing(审查通过进测试), in_progress(退回修改, review_rounds+1),
//! blocked, cancelled
//! - testing → done(测试通过), in_review(退回重审, review_rounds+1),
//! blocked, cancelled
//! - done → 终态(原则上不可变;若需重开走 cancelled 或新任务)
//! - blocked → in_progress(解除阻塞继续), cancelled
//! - cancelled → 终态
// ============================================================
// 状态字符串常量 — 与 df-core::TaskStatus::as_str 一一对应
// ============================================================
//
// 不复用 df-core::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型),
// 但字符串值严格对齐(df-core::TaskStatus::as_str 产出的小写 snake_case),
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致。
/// 待开始
pub const TODO: &str = "todo";
/// 进行中
pub const IN_PROGRESS: &str = "in_progress";
/// 代码审查中
pub const IN_REVIEW: &str = "in_review";
/// 测试中
pub const TESTING: &str = "testing";
/// 已完成(终态)
pub const DONE: &str = "done";
/// 已阻塞
pub const BLOCKED: &str = "blocked";
/// 已取消(终态)
pub const CANCELLED: &str = "cancelled";
/// 全部合法状态值(供输入校验与错误提示复用)
pub const ALL_STATES: &[&str] = &[
TODO,
IN_PROGRESS,
IN_REVIEW,
TESTING,
DONE,
BLOCKED,
CANCELLED,
];
/// 字符串是否为合法状态值
pub fn is_valid_state(s: &str) -> bool {
ALL_STATES.contains(&s)
}
/// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。
///
/// 终态(done/cancelled)无任何合法后继;非法或未知状态入参一律返回 false
/// (调用方 advance_task 在前置校验已拦截非法 status,此处保守拒绝防漏)。
///
/// 注意:仅判「是否合法」,不判「是否是退回」——退回(导致 review_rounds+1)
/// 的识别见 [`is_regression`],advance_task 据此决定是否一并 SET review_rounds+=1。
pub fn can_transition(from: &str, to: &str) -> bool {
use std::sync::OnceLock;
// 转换表静态构造一次(运行时常量,无锁开销分摊)。行=from,列=to。
// 表条目为 true 即合法转换。未列入的 (from, to) 一律 false。
static TABLE: OnceLock<std::collections::HashMap<(&'static str, &'static str), bool>> =
OnceLock::new();
let table = TABLE.get_or_init(|| {
let mut m = std::collections::HashMap::new();
let allowed: &[(&str, &str)] = &[
// todo → in_progress(开始), cancelled(取消)
(TODO, IN_PROGRESS),
(TODO, CANCELLED),
// in_progress → in_review(提交审查), blocked(阻塞), cancelled
(IN_PROGRESS, IN_REVIEW),
(IN_PROGRESS, BLOCKED),
(IN_PROGRESS, CANCELLED),
// in_review → testing(审查通过进测试), in_progress(退回修改),
// blocked, cancelled
(IN_REVIEW, TESTING),
(IN_REVIEW, IN_PROGRESS),
(IN_REVIEW, BLOCKED),
(IN_REVIEW, CANCELLED),
// testing → done(测试通过), in_review(退回重审), blocked, cancelled
(TESTING, DONE),
(TESTING, IN_REVIEW),
(TESTING, BLOCKED),
(TESTING, CANCELLED),
// done → 终态,无后继
// blocked → in_progress(解除阻塞继续), cancelled
(BLOCKED, IN_PROGRESS),
(BLOCKED, CANCELLED),
// cancelled → 终态,无后继
];
for (f, t) in allowed {
m.insert((*f, *t), true);
}
m
});
table.get(&(from, to)).copied().unwrap_or(false)
}
/// 判定一次转换是否为「退回」(review_rounds 应 +1)。
///
/// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、
/// 需重做。具体两类:
/// - in_review → in_progress(审查退回修改)
/// - testing → in_review(测试退回重审)
///
/// 其余合法转换(前向推进 / 进出 blocked / 进 cancelled)均不累加 review_rounds。
pub fn is_regression(from: &str, to: &str) -> bool {
matches!((from, to), (IN_REVIEW, IN_PROGRESS) | (TESTING, IN_REVIEW))
}
// ============================================================
// 单元测试 — 转换矩阵 + 边界
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ---------- can_transition 闸门主路径 ----------
#[test]
fn main_path_todo_to_done_all_forward() {
assert!(can_transition(TODO, IN_PROGRESS));
assert!(can_transition(IN_PROGRESS, IN_REVIEW));
assert!(can_transition(IN_REVIEW, TESTING));
assert!(can_transition(TESTING, DONE));
}
#[test]
fn cancel_from_any_non_terminal() {
for s in [TODO, IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
assert!(can_transition(s, CANCELLED), "应允许 {s} → cancelled");
}
}
#[test]
fn blocked_round_trip() {
assert!(can_transition(IN_PROGRESS, BLOCKED));
assert!(can_transition(BLOCKED, IN_PROGRESS));
assert!(can_transition(IN_REVIEW, BLOCKED));
assert!(can_transition(TESTING, BLOCKED));
}
#[test]
fn regression_paths_allowed() {
assert!(can_transition(IN_REVIEW, IN_PROGRESS));
assert!(can_transition(TESTING, IN_REVIEW));
}
// ---------- 非法转换被拒 ----------
#[test]
fn illegal_forward_skips_rejected() {
// 不允许跳过闸门(直奔 done)
assert!(!can_transition(TODO, DONE));
assert!(!can_transition(TODO, TESTING));
assert!(!can_transition(TODO, IN_REVIEW));
assert!(!can_transition(IN_PROGRESS, DONE));
assert!(!can_transition(IN_PROGRESS, TESTING));
assert!(!can_transition(IN_REVIEW, DONE));
}
#[test]
fn terminal_states_have_no_successors() {
// done / cancelled 是终态,任何后继都拒绝
for term in [DONE, CANCELLED] {
for to in ALL_STATES {
assert!(!can_transition(term, to), "终态 {term} 不应有后继 → {to}");
}
}
}
#[test]
fn backward_to_todo_rejected() {
// 不允许回退到 todo(开始即不可撤回)
for s in [IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
assert!(!can_transition(s, TODO), "不应允许 {s} → todo");
}
}
#[test]
fn blocked_only_to_in_progress_or_cancelled() {
assert!(can_transition(BLOCKED, IN_PROGRESS));
assert!(can_transition(BLOCKED, CANCELLED));
// 解除阻塞不能直接跳到 in_review/testing/done
assert!(!can_transition(BLOCKED, IN_REVIEW));
assert!(!can_transition(BLOCKED, TESTING));
assert!(!can_transition(BLOCKED, DONE));
}
// ---------- 未知状态 ----------
#[test]
fn unknown_states_rejected() {
assert!(!can_transition("unknown", TODO));
assert!(!can_transition(TODO, "unknown"));
assert!(!can_transition("", ""));
}
// ---------- is_regression ----------
#[test]
fn regression_only_on_review_back_edges() {
assert!(is_regression(IN_REVIEW, IN_PROGRESS));
assert!(is_regression(TESTING, IN_REVIEW));
// 前向推进不累加
assert!(!is_regression(TODO, IN_PROGRESS));
assert!(!is_regression(IN_PROGRESS, IN_REVIEW));
assert!(!is_regression(IN_REVIEW, TESTING));
assert!(!is_regression(TESTING, DONE));
// 进出 blocked 不累加
assert!(!is_regression(IN_PROGRESS, BLOCKED));
assert!(!is_regression(BLOCKED, IN_PROGRESS));
// 进 cancelled 不累加
assert!(!is_regression(IN_REVIEW, CANCELLED));
// 不合法的"退回"(实际上不存在的转换)也不应判定为 regression
assert!(!is_regression(TESTING, IN_PROGRESS));
}
// ---------- is_valid_state ----------
#[test]
fn is_valid_state_accepts_7_known() {
for s in ALL_STATES {
assert!(is_valid_state(s));
}
}
#[test]
fn is_valid_state_rejects_unknown() {
assert!(!is_valid_state(""));
assert!(!is_valid_state("unknown"));
assert!(!is_valid_state("TODO")); // 大写
assert!(!is_valid_state("merged")); // 历史 5 态残留
assert!(!is_valid_state("abandoned"));
}
#[test]
fn all_states_has_seven_entries() {
assert_eq!(ALL_STATES.len(), 7);
}
}

View File

@@ -310,7 +310,8 @@ impl SettingsRepo {
/// 白名单两重作用:① 防注入(列名参数化前校验);② 按表隔离(update_task 误传 /// 白名单两重作用:① 防注入(列名参数化前校验);② 按表隔离(update_task 误传
/// projects 的 "name" 会在校验阶段拒绝,而非靠 SQLite "no such column" 兜底报错)。 /// projects 的 "name" 会在校验阶段拒绝,而非靠 SQLite "no such column" 兜底报错)。
/// 专用更新路径的列不列入:knowledges.embedding(set_embedding)、projects.deleted_at /// 专用更新路径的列不列入:knowledges.embedding(set_embedding)、projects.deleted_at
/// (soft_delete/restore)。未登记的表返回 None → 放行(仅靠参数化防注入,向后兼容)。 /// (soft_delete/restore)、tasks.deleted_at(soft_delete/restore)。未登记的表返回 None
/// → 放行(仅靠参数化防注入,向后兼容)。
pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> { pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
Some(match table { Some(match table {
"ideas" => &[ "ideas" => &[
@@ -322,8 +323,13 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"updated_at", "updated_at",
], ],
"tasks" => &[ "tasks" => &[
"id", "project_id", "title", "description", "status", "priority", "branch_name", // id/created_at 不列入:主键与创建时间不可通过通用 update_field 改写
"assignee", "workflow_def_id", "base_branch", "created_at", "updated_at", // (防篡改主键/伪造创建时间/跨项目移动)。
"project_id", "title", "description", "status", "priority", "branch_name",
"assignee", "workflow_def_id", "base_branch", "updated_at",
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。
// project_id 当前可在白名单内改写,合法目标存在性由上层命令层校验。
], ],
"releases" => &[ "releases" => &[
"id", "project_id", "version", "status", "task_ids", "changelog", "created_at", "id", "project_id", "version", "status", "task_ids", "changelog", "created_at",
@@ -464,6 +470,7 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
assignee: row.get("assignee")?, assignee: row.get("assignee")?,
workflow_def_id: row.get("workflow_def_id")?, workflow_def_id: row.get("workflow_def_id")?,
base_branch: row.get("base_branch")?, base_branch: row.get("base_branch")?,
review_rounds: row.get("review_rounds")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
}) })
@@ -724,27 +731,173 @@ impl_repo!(
from_row => |row| task_from_row(row), from_row => |row| task_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, created_at, updated_at) "INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
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, 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.created_at, rec.updated_at rec.review_rounds, rec.created_at, rec.updated_at
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
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, updated_at = ?10 WHERE id = ?11", "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, updated_at = ?11 WHERE id = ?12",
params![ params![
rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.project_id, rec.title, rec.description, rec.status, 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.updated_at, rec.id rec.review_rounds, rec.updated_at, rec.id
], ],
) )
} }
); );
impl TaskRepo {
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
///
/// 显式列出 13 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
.query_map([], |row| task_from_row(row))
.map_err(|e| Error::Storage(e.to_string()))?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(|e| Error::Storage(e.to_string()))?);
}
Ok(results)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删任务,返回是否命中。
/// 对标 ProjectRepo::soft_delete。
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE tasks SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
params![now, id],
)
.map_err(|e| Error::Storage(e.to_string()))?;
Ok(affected > 0)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
/// 恢复:清 deleted_at(从回收站还原)。仅作用于已删任务,返回是否命中。
/// 对标 ProjectRepo::restore。
pub async fn restore(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE tasks SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL",
params![now, id],
)
.map_err(|e| Error::Storage(e.to_string()))?;
Ok(affected > 0)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
/// 原子推进任务状态(任务推进链 F-260616-02 唯一 status 写入路径)
///
/// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU:
/// 并发推进/旁路修改若已改 status,affected_rows==0,本方法返回 None,调用方
/// (task_advance_node)据此报「状态已变,推进中止」。`review_rounds` 不进通用
/// update_field 白名单(收口:仅本方法可改 status 与 review_rounds)。
///
/// - `expected`:调用方读取的当前 status(状态机校验时的 from),CAS 前置。
/// - `new_status`:目标 status(状态机 can_transition 已校验合法)。
/// - `bump_rounds`:退回转换(in_review→in_progress / testing→in_review)传 true,
/// 一并 `review_rounds = review_rounds + 1`(同 UPDATE 原子,避免读改写竞争)。
/// 前向推进 / 进出 blocked / 进 cancelled 传 false,不动 review_rounds。
///
/// 返回:成功推进返回更新后的 TaskRecord;affected==0(状态已变/任务不存在)返回 None。
pub async fn advance_status_atomic(
&self,
id: &str,
expected: &str,
new_status: &str,
bump_rounds: bool,
) -> Result<Option<TaskRecord>> {
let conn = self.conn.clone();
let id = id.to_owned();
let expected = expected.to_owned();
let new_status = new_status.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// CAS:WHERE id AND status=expected 锁定当前态;affected==0 即并发已改动。
let sql = if bump_rounds {
"UPDATE tasks SET status = ?1, review_rounds = review_rounds + 1, updated_at = ?2 \
WHERE id = ?3 AND status = ?4"
} else {
"UPDATE tasks SET status = ?1, updated_at = ?2 \
WHERE id = ?3 AND status = ?4"
};
let affected = guard
.execute(sql, params![new_status, now, id, expected])
.map_err(|e| Error::Storage(e.to_string()))?;
if affected == 0 {
return Ok(None);
}
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, created_at, updated_at FROM tasks WHERE id = ?1")
.map_err(|e| Error::Storage(e.to_string()))?;
let row = stmt
.query_row(params![id], |row| task_from_row(row))
.optional()
.map_err(|e| Error::Storage(e.to_string()))?;
Ok(row)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
///
/// 注:任务表无专用 list_active_by_project 方法,按项目列活跃任务由 commands/task.rs
/// 的 list_tasks 用 list_active 后内存过滤 project_id 实现(任务量小,无需 SQL 下推)。
pub async fn list_deleted(&self) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
.query_map([], |row| task_from_row(row))
.map_err(|e| Error::Storage(e.to_string()))?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(|e| Error::Storage(e.to_string()))?);
}
Ok(results)
})
.await
.map_err(|e| Error::Storage(e.to_string()))?
}
}
impl_repo!( impl_repo!(
/// 分支表 CRUD /// 分支表 CRUD
BranchRepo, BranchRepo,

View File

@@ -84,6 +84,14 @@ pub fn run(conn: &Connection) -> Result<()> {
migrate_v13(conn)?; migrate_v13(conn)?;
} }
if current_version < 14 {
migrate_v14(conn)?;
}
if current_version < 15 {
migrate_v15(conn)?;
}
Ok(()) Ok(())
} }
@@ -267,6 +275,41 @@ fn migrate_v13(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V14: 幂等补 tasks.deleted_at 列(软删回收站,对标 projects.deleted_at V11)
///
/// 删除任务改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。
/// 与 projects.soft_delete 同模板:TaskRecord 不带该字段,纯靠 SQL WHERE deleted_at IS NULL
/// 过滤;子表(branches)不动,FK 仍满足,任务数据完整保留待恢复。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11 模式)。
fn migrate_v14(conn: &Connection) -> Result<()> {
if !column_exists(conn, "tasks", "deleted_at") {
conn.execute("ALTER TABLE tasks ADD COLUMN deleted_at TEXT", [])?;
tracing::info!("v14: 补建 tasks.deleted_at 列(软删回收站)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [14])?;
tracing::info!("迁移 v14 完成");
Ok(())
}
/// V15: 幂等补 tasks.review_rounds 列(review 退回累计轮数,F-260616-04)
///
/// 任务推进链状态机退回时累加:in_review→in_progress / testing→in_review 各 +1,
/// 由 advance_task(df-nodes::task_advance_node)原子写入。默认 0(从未退回过的任务)。
/// NOT NULL DEFAULT 0 保证老库行迁移后取值确定(非 NULL),TaskRecord 字段为 i32(非 Option)。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14 模式)。
fn migrate_v15(conn: &Connection) -> Result<()> {
if !column_exists(conn, "tasks", "review_rounds") {
conn.execute(
"ALTER TABLE tasks ADD COLUMN review_rounds INTEGER NOT NULL DEFAULT 0",
[],
)?;
tracing::info!("v15: 补建 tasks.review_rounds 列(review 退回累计轮数)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [15])?;
tracing::info!("迁移 v15 完成");
Ok(())
}
/// V1 建表 SQL /// V1 建表 SQL
const V1_SQL: &str = " const V1_SQL: &str = "
-- 想法表 -- 想法表
@@ -301,7 +344,8 @@ CREATE TABLE IF NOT EXISTS tasks (
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo', status TEXT NOT NULL DEFAULT 'todo',
priority INTEGER NOT NULL DEFAULT 1, -- priority 默认 2 对齐 task.rs default_priority()=2(medium)
priority INTEGER NOT NULL DEFAULT 2,
branch_name TEXT, branch_name TEXT,
assignee TEXT, assignee TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,

View File

@@ -61,6 +61,11 @@ pub struct TaskRecord {
pub assignee: Option<String>, pub assignee: Option<String>,
pub workflow_def_id: Option<String>, // 关联的工作流定义 ID pub workflow_def_id: Option<String>, // 关联的工作流定义 ID
pub base_branch: Option<String>, // 基础分支 pub base_branch: Option<String>, // 基础分支
/// review 退回累计轮数(in_review→in_progress / testing→in_review 时 +1,
/// 由 advance_task 原子写入,见 df-nodes::task_advance_node)。
/// #[serde(default)] 兼容旧前端无该字段的 JSON(老任务记录默认 0)。
#[serde(default)]
pub review_rounds: i32,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }

View File

@@ -41,6 +41,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
assignee: None, assignee: None,
workflow_def_id: None, workflow_def_id: None,
base_branch: None, base_branch: None,
review_rounds: 0,
created_at: now_ts(), created_at: now_ts(),
updated_at: now_ts(), updated_at: now_ts(),
} }

View File

@@ -2,7 +2,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::{Arc, LazyLock};
use tokio::sync::Mutex as TokioMutex;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_execute::shell::{execute, ShellRequest}; use df_execute::shell::{execute, ShellRequest};
@@ -13,6 +15,58 @@ use df_core::types::new_id;
use crate::commands::now_millis; use crate::commands::now_millis;
/// CRUD list 工具的默认返回上限(防 LLM context 膨胀)
/// 用于 list_projects / list_tasks / list_ideas / list_trash
const MAX_LIST_RESULTS: usize = 50;
/// 生成行级 unified diff无外部依赖基于 LCS
/// 仅标 +/- 前缀,不做 hunk header足够审批卡/审计留痕可读)。
/// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。
fn generate_diff(old: &str, new: &str) -> String {
let a: Vec<&str> = old.lines().collect();
let b: Vec<&str> = new.lines().collect();
let (n, m) = (a.len(), b.len());
// LCS 动态规划表usize 即可;大文件已被 1MB 限制挡住,行数有限)
let mut dp = vec![vec![0usize; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
dp[i][j] = if a[i] == b[j] {
dp[i + 1][j + 1] + 1
} else {
dp[i + 1][j].max(dp[i][j + 1])
};
}
}
// 回溯:相同行原样输出(空格前缀),旧独有加 '-',新独有加 '+'
let mut out = String::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
while i < n && j < m {
if a[i] == b[j] {
out.push_str(" "); out.push_str(a[i]); out.push('\n');
i += 1; j += 1;
} else if dp[i + 1][j] >= dp[i][j + 1] {
out.push_str("-"); out.push_str(a[i]); out.push('\n');
i += 1; changes += 1;
} else {
out.push_str("+"); out.push_str(b[j]); out.push('\n');
j += 1; changes += 1;
}
}
while i < n { out.push_str("-"); out.push_str(a[i]); out.push('\n'); i += 1; changes += 1; }
while j < m { out.push_str("+"); out.push_str(b[j]); out.push('\n'); j += 1; changes += 1; }
// 极端情况:差异行过多(>300截断防 LLM context 爆炸
if changes > 300 {
let kept: String = out.lines().take(300).collect::<Vec<_>>().join("\n");
return format!("{}\n... (diff 已截断,共 {} 处变更行)", kept, changes);
}
// 末尾多余换行清理(保持单尾换行)
out.trim_end_matches('\n').to_string() + "\n"
}
/// 验证文件路径:禁止访问系统敏感目录 /// 验证文件路径:禁止访问系统敏感目录
fn validate_path(path: &str) -> anyhow::Result<()> { fn validate_path(path: &str) -> anyhow::Result<()> {
// 规范化为反斜杠LLM 可能传正斜杠绕过黑名单Windows tokio::fs 两种分隔符都吃) // 规范化为反斜杠LLM 可能传正斜杠绕过黑名单Windows tokio::fs 两种分隔符都吃)
@@ -54,6 +108,13 @@ fn truncate_output(s: &str, max: usize) -> (String, bool) {
) )
} }
/// 全局文件锁表每个路径一把互斥锁L1 防护,防同文件并发读写冲突)
///
/// 唯一并行点: audit.rs join_all — Low/Medium 风险工具并行执行。
/// 单用户桌面应用Mutex 够用且简单(~15 行),不需要全局文件队列。
static FILE_LOCKS: LazyLock<TokioMutex<HashMap<PathBuf, ()>>> =
LazyLock::new(|| TokioMutex::new(HashMap::new()));
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定) /// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
fn workspace_root() -> PathBuf { fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -148,8 +209,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
let repo = df_storage::crud::ProjectRepo::new(&db); let repo = df_storage::crud::ProjectRepo::new(&db);
let mut items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目 let mut items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
let total = items.len(); let total = items.len();
let truncated = total > 50; let truncated = total > MAX_LIST_RESULTS;
items.truncate(50); // 防 LLM context 膨胀 items.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": items, "truncated": truncated })) Ok(serde_json::json!({ "items": items, "truncated": truncated }))
}) })
})}, })},
@@ -167,8 +228,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
repo.list_all().await? repo.list_all().await?
}; };
let total = tasks.len(); let total = tasks.len();
let truncated = total > 50; let truncated = total > MAX_LIST_RESULTS;
tasks.truncate(50); // 防 LLM context 膨胀 tasks.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": tasks, "truncated": truncated })) Ok(serde_json::json!({ "items": tasks, "truncated": truncated }))
}) })
})}, })},
@@ -182,8 +243,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
let repo = df_storage::crud::IdeaRepo::new(&db); let repo = df_storage::crud::IdeaRepo::new(&db);
let mut items = repo.list_all().await?; let mut items = repo.list_all().await?;
let total = items.len(); let total = items.len();
let truncated = total > 50; let truncated = total > MAX_LIST_RESULTS;
items.truncate(50); // 防 LLM context 膨胀 items.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": items, "truncated": truncated })) Ok(serde_json::json!({ "items": items, "truncated": truncated }))
}) })
})}, })},
@@ -275,6 +336,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
// 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: "todo".to_string(), 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,
created_at: now_millis(), updated_at: now_millis(), created_at: now_millis(), updated_at: now_millis(),
}; };
let id = record.id.clone(); let id = record.id.clone();
@@ -303,17 +365,17 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
}) })
})}, })},
); );
// FR-D6:delete_task 对齐 commands::task::delete_task —— tasks 表无 deleted_at,走硬删 // FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除)
// (与 delete_project 的软删不同:projects 有专门回收站机制,tasks 没有,只能硬删) // tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project)
registry.register( registry.register(
"delete_task", "删除任务(硬删,不可恢复。清理孤儿任务时务必用本工具,不要误用 delete_project", "delete_task", "删除任务(移入回收站,可恢复",
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High, df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| { { let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone(); let db = db.clone();
Box::pin(async move { Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::TaskRepo::new(&db); let repo = df_storage::crud::TaskRepo::new(&db);
let deleted = repo.delete(id).await?; let deleted = repo.soft_delete(id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id })) Ok(serde_json::json!({ "deleted": deleted, "id": id }))
}) })
})}, })},
@@ -390,26 +452,19 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
})}, })},
); );
registry.register( registry.register(
"list_trash", "列出回收站已删除项目", "list_trash", "列出回收站已删除项目(最多返回 50 条)",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low, df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| { { let db = db.clone(); Box::new(move |_args: serde_json::Value| {
let db = db.clone(); let db = db.clone();
Box::pin(async move { Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db); let repo = df_storage::crud::ProjectRepo::new(&db);
let mut items = repo.list_deleted().await?; let mut items = repo.list_deleted().await?;
items.truncate(50); // 防 LLM context 膨胀 let truncated = items.len() > MAX_LIST_RESULTS;
Ok(serde_json::to_value(items)?) items.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
}) })
})}, })},
); );
registry.register(
"run_workflow", "运行指定的工作流 DAG",
df_ai::ai_tools::object_schema(vec![("name", "string", true), ("dag", "object", true)]), RiskLevel::High,
Box::new(|_args: serde_json::Value| Box::pin(async move {
// run_workflow 需完整 DAG 执行,返回提示由前端触发
Ok(serde_json::json!({ "note": "请通过工作流页面运行工作流", "tool": "run_workflow" }))
})),
);
registry.register( registry.register(
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。", "run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
df_ai::ai_tools::object_schema(vec![ df_ai::ai_tools::object_schema(vec![
@@ -440,6 +495,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
working_dir: Some(working_dir.clone()), working_dir: Some(working_dir.clone()),
env: HashMap::new(), env: HashMap::new(),
timeout_secs: Some(timeout_secs), timeout_secs: Some(timeout_secs),
shell_type: Default::default(),
}; };
let result = execute(request).await?; let result = execute(request).await?;
@@ -462,8 +518,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
// ── 文件系统 ── // ── 文件系统 ──
registry.register( registry.register(
"read_file", "读取文件内容,返回文本内容。支持 offsetlimit 参数分页读取大文件", "read_file", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组限50条",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false)]), df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
RiskLevel::Low, RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move { Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path( let resolved = resolve_workspace_path(
@@ -492,7 +548,26 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
} }
anyhow::bail!("读取文件失败: {}", e); anyhow::bail!("读取文件失败: {}", e);
} }
// limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行) // search 模式: 按行枚举收集含 search 子串的行,限 50 条
if let Some(search) = args["search"].as_str() {
let search_max = 50usize;
let mut matches_vec: Vec<serde_json::Value> = Vec::with_capacity(search_max);
for (idx, line) in content.lines().enumerate() {
if line.contains(search) {
matches_vec.push(serde_json::json!({ "line": idx + 1, "content": line }));
if matches_vec.len() >= search_max { break; }
}
}
let total = content.lines().filter(|l| l.contains(search)).count();
return Ok(serde_json::json!({
"path": path, "size": metadata.len(),
"search": search,
"matches": matches_vec,
"total": total,
"has_more": total > search_max,
}));
}
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
let result = if let Some(offset) = args["offset"].as_u64() { let result = if let Some(offset) = args["offset"].as_u64() {
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
let skip = offset as usize; let skip = offset as usize;
@@ -523,18 +598,34 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
})), })),
); );
registry.register( registry.register(
"write_file", "写入或创建文件,自动创建不存在的父目录", "write_file", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]), df_ai::ai_tools::object_schema(vec![
("path", "string", true),
("content", "string", true),
("encoding", "string", false),
]),
RiskLevel::Medium, RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move { Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path( let resolved = resolve_workspace_path(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?; )?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
// F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
use base64::{engine::general_purpose::STANDARD, Engine as _};
STANDARD.decode(raw)
.map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))?
} else if encoding.eq_ignore_ascii_case("utf-8") {
raw.as_bytes().to_vec()
} else {
anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64", encoding);
};
// 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史) // 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
if content.len() > 1_048_576 { if bytes.len() > 1_048_576 {
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", content.len()); anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
} }
let target = std::path::Path::new(path); let target = std::path::Path::new(path);
// FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失) // FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
@@ -559,7 +650,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
} }
// FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷) // FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
let tmp = format!("{}.tmp-write", path); let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, content).await { if let Err(e) = tokio::fs::write(&tmp, &bytes).await {
let _ = tokio::fs::remove_file(&tmp).await; let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("写入临时文件失败: {}", e)); return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
} }
@@ -576,11 +667,140 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
} }
// FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak // FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
if let Some(old) = old_size { if let Some(old) = old_size {
if old > 0 && (content.len() as f64 / old as f64) < 0.1 { if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 {
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, content.len()); tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len());
} }
} }
Ok(serde_json::json!({ "path": path, "bytes_written": content.len(), "old_size": old_size })) Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding }))
})),
);
// ── 局部文件编辑 (Medium risk) ──
registry.register(
"patch_file", "局部更新文件内容。用于精确修改文件的特定部分(而非全量覆盖)。每个补丁指定 old_text要替换的原文必须精确匹配含空格/缩进)和 new_text新内容。可选 line 辅助定位。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
df_ai::ai_tools::object_schema(vec![
("path", "string", true),
("old_text", "string", true),
("new_text", "string", true),
("line", "integer", false),
("expected_hash", "string", false),
]),
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let old_text = args["old_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 old_text 参数"))?;
let new_text = args["new_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
// 边界校验
if old_text.is_empty() {
anyhow::bail!("old_text 不能为空");
}
if old_text == new_text {
return Ok(serde_json::json!({
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
}));
}
let target = std::path::Path::new(path);
if !target.exists() {
anyhow::bail!("文件不存在: {}", path);
}
let file_meta = tokio::fs::metadata(target).await
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
if file_meta.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
}
// 阶段一:读文件内容 + 校验(无锁,纯读操作)
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path).await
.map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?;
let mut content = String::new();
file.read_to_string(&mut content).await
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
// 二进制检测
if content.contains('\0') {
anyhow::bail!("不支持二进制文件");
}
// L3: expected_hash 指纹校验(防外部修改)
if let Some(expected) = args["expected_hash"].as_str() {
let modified = file_meta.modified()
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
let current_hash = format!("{}_{}", modified.unwrap_or(0), file_meta.len());
if current_hash != expected {
anyhow::bail!(
"文件已被外部修改(hash 不匹配): 期望={} 实际={},请重新 read_file 获取最新内容",
expected, current_hash
);
}
}
// L2: old_text 精确匹配CAS 语义)
if !content.contains(old_text) {
anyhow::bail!("未找到目标文本,文件可能已被修改");
}
// 多匹配检测
let match_count = content.matches(old_text).count();
let warning = if match_count > 1 {
Some(format!("匹配到 {} 处,仅替换第 1 处", match_count))
} else { None };
// 执行替换(仅替换第 1 处)
let new_content = content.replacen(old_text, new_text, 1);
// 阶段二L1 tokio::Mutex 保护写序列backup → tmp write → rename → cleanup
// tokio::sync::Mutex 的 MutexGuard 是 Send可安全跨 await
let abs_path = target.canonicalize()
.map_err(|e| anyhow::anyhow!("路径解析失败: {}", e))?;
{
let mut locks = FILE_LOCKS.lock().await;
locks.entry(abs_path.clone()).or_insert(());
// .bak 备份
let bak = format!("{}.bak", path);
tokio::fs::copy(path, &bak).await
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
// 原子写: tmp → rename
let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, &new_content).await {
let _ = tokio::fs::remove_file(&tmp).await;
let _ = tokio::fs::remove_file(&bak).await;
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {},备份保留在 {}", e, bak));
}
// 成功:清理 .bak
let _ = tokio::fs::remove_file(&bak).await;
// _locks 在此 drop释放锁
}
let size_diff = new_content.len() as i64 - content.len() as i64;
// 生成 unified diff 供前端审批卡/审计留痕展示
// content与 new_content作用域内可用无需重读文件
let diff = generate_diff(&content, &new_content);
let mut result = serde_json::json!({
"path": path,
"changed": true,
"size_diff": size_diff,
"matches_found": match_count,
"diff": diff,
});
if let Some(w) = warning {
result["warning"] = serde_json::Value::String(w);
}
Ok(result)
})), })),
); );
@@ -647,6 +867,160 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
})), })),
); );
// ── 删除文件 (High risk, F-260615-16) ──
registry.register(
"delete_file", "删除文件。默认permanent=false软删除移到工作区 .trash/ 目录保留可恢复permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险须人工批准",
df_ai::ai_tools::object_schema(vec![
("path", "string", true),
("permanent", "boolean", false),
]),
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
// validate_path 前置(workspace 内)——resolve_workspace_path 含 validate_path + symlink 防逃逸
let resolved = resolve_workspace_path(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false);
let target = std::path::Path::new(path);
if !target.exists() {
anyhow::bail!("文件不存在: {}", path);
}
// 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底)
let metadata = tokio::fs::metadata(target).await
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
if metadata.is_dir() {
anyhow::bail!("delete_file 仅支持删除文件,不支持目录: {}", path);
}
let file_name = target.file_name()
.ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))?
.to_string_lossy().to_string();
if permanent {
// 硬删除:不可逆,对齐 delete_project purge 语义
tokio::fs::remove_file(path).await
.map_err(|e| anyhow::anyhow!("删除失败: {}", e))?;
Ok(serde_json::json!({
"path": path,
"deleted": true,
"permanent": true,
"backed_up": false,
}))
} else {
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
// .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace 内
let trash_dir = workspace_root().join(".trash");
tokio::fs::create_dir_all(&trash_dir).await
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
let backup_name = format!("{}-{}", new_id(), file_name);
let backup_path = trash_dir.join(&backup_name);
let backup_path_str = backup_path.to_string_lossy().to_string();
tokio::fs::rename(path, &backup_path).await
.map_err(|e| anyhow::anyhow!("移入回收站失败: {}", e))?;
Ok(serde_json::json!({
"path": path,
"deleted": true,
"permanent": false,
"backed_up": true,
"backup_path": backup_path_str,
}))
}
})),
);
// ── 重命名/移动文件 (Medium risk, F-260615-15) ──
// 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码
// 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。
registry.register(
"rename_file", "重命名或移动文件(同一工具覆盖 rename 和 move。参数from源路径+ to目标路径+ overwrite默认 false目标存在则拒绝。同卷走 tokio::fs::rename原子跨卷自动降级 copy+remove非原子失败回滚删 to 保 from 完整)。须人工批准",
df_ai::ai_tools::object_schema(vec![
("from", "string", true),
("to", "string", true),
("overwrite", "boolean", false),
]),
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
// 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path,
// 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸
let from_resolved = resolve_workspace_path(
args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?,
)?;
let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?;
let to_resolved = resolve_workspace_path(
args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?,
)?;
let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?;
let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false);
// 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底)
let from_target = std::path::Path::new(from_path);
if !from_target.exists() {
anyhow::bail!("源文件不存在: {}", from_path);
}
let from_metadata = tokio::fs::metadata(from_target).await
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?;
if from_metadata.is_dir() {
anyhow::bail!("rename_file 仅支持文件,不支持目录: {}", from_path);
}
let bytes_moved = from_metadata.len();
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
let to_target = std::path::Path::new(to_path);
if let Some(parent) = to_target.parent() {
if !parent.starts_with(&workspace_root()) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?;
}
// 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to
if to_target.exists() {
if !overwrite {
anyhow::bail!("目标已存在且 overwrite=false: {}", to_path);
}
tokio::fs::remove_file(to_path).await
.map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?;
}
// 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
let rename_err = tokio::fs::rename(from_path, to_path).await.err();
if rename_err.is_none() {
return Ok(serde_json::json!({
"from": from_path,
"to": to_path,
"renamed": true,
"bytes_moved": bytes_moved,
"cross_volume": false,
}));
}
// rename 失败:跨卷(Windows ERROR_NOT_SAME_DEVICE 17)→ 降级 copy+remove
// 其他错误(权限/占用)直接抛,不降级
let err = rename_err.unwrap();
let cross_volume = err.raw_os_error() == Some(17);
if !cross_volume {
anyhow::bail!("重命名/移动失败: {}", err);
}
// 跨卷降级 copy + remove(非原子):copy 失败 from 完整(未动);copy 成功 remove 失败
// 则 from/to 同时存在,删 to 回滚保 from 完整(对齐设计:失败回滚删 to)
if let Err(e) = tokio::fs::copy(from_path, to_path).await {
anyhow::bail!("跨卷复制失败from 未改动): {}", e);
}
if let Err(e) = tokio::fs::remove_file(from_path).await {
// remove 失败:回滚删 to,保 from 完整(用户可重试)
let _ = tokio::fs::remove_file(to_path).await;
anyhow::bail!("跨卷移动删除源失败已回滚from 完整,可重试): {}", e);
}
Ok(serde_json::json!({
"from": from_path,
"to": to_path,
"renamed": true,
"bytes_moved": bytes_moved,
"cross_volume": true,
}))
})),
);
// ── 文件搜索 (Low risk) ── // ── 文件搜索 (Low risk) ──
registry.register( registry.register(
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条", "search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条",
@@ -726,11 +1100,65 @@ fn list_dir_recursive<'a>(
}) })
} }
/// 工具名 → 审批文案模板(供 build_approval_reason 使用)。
///
/// 返回 Option<(&str, &[&str])>(动作前缀模板, 关键参数名列表)。
/// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。
/// 未注册的工具返回 None由调用方 fallback 到通用风险提示。
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
let (template, keys): (&str, &[&str]) = match name {
"delete_project" => ("删除项目{}", &["id"]),
"restore_project" => ("从回收站恢复项目{}", &["id"]),
"purge_project" => ("永久删除项目及关联数据,不可恢复{}", &["id"]),
"update_project" => ("修改项目{}字段「{}」", &["id", "field"]),
"bind_directory" => ("绑定目录:{}(项目{}", &["path", "id"]),
"create_task" => ("创建任务:{}(项目{}", &["title", "project_id"]),
"create_project" => ("创建项目:「{}」", &["name"]),
"create_idea" => ("捕获灵感:{}", &["title"]),
"patch_file" => ("修改文件:{}", &["path"]),
"delete_file" => ("删除文件:{}{}", &["path", "permanent"]),
"rename_file" => ("重命名/移动:{} → {}", &["from", "to"]),
_ => return None,
};
Some((template, keys))
}
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
///
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
/// 未注册的工具返回 None由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
match name {
"create_task" => Some("创建任务"),
"write_file" => Some("写入文件"),
"read_file" => Some("读取文件"),
"list_directory" => Some("列出目录"),
"search_files" => Some("搜索文件"),
"create_project" => Some("创建项目"),
"update_project" => Some("修改项目"),
"delete_project" => Some("删除项目"),
"restore_project" => Some("恢复项目"),
"purge_project" => Some("永久删除项目"),
"bind_directory" => Some("绑定目录"),
"create_idea" => Some("捕获灵感"),
"update_task" => Some("修改任务"),
"delete_task" => Some("删除任务"),
"update_idea" => Some("修改灵感"),
"delete_idea" => Some("删除灵感"),
"patch_file" => Some("修改文件"),
"append_file" => Some("追加写入"),
"delete_file" => Some("删除文件"),
"rename_file" => Some("重命名/移动"),
_ => None,
}
}
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等) /// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
fn is_noise_dir(name: &str) -> bool { fn is_noise_dir(name: &str) -> bool {
const NOISE_DIRS: &[&str] = &[ const NOISE_DIRS: &[&str] = &[
".git", "node_modules", "target", "dist", "build", ".git", "node_modules", "target", "dist", "build",
".next", ".cache", "__pycache__", ".venv", "venv", ".idea", ".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
".trash", // delete_file 软删除回收站(F-260615-16),不污染 list_directory
]; ];
NOISE_DIRS.contains(&name) NOISE_DIRS.contains(&name)
} }

View File

@@ -27,17 +27,22 @@ fn default_priority() -> i32 {
2 // medium — 新任务默认中优先级(非 high),符合常识 2 // medium — 新任务默认中优先级(非 high),符合常识
} }
/// 列出任务,可按 project_id 过滤 /// 列出未删除任务(deleted_at IS NULL),可按 project_id 过滤
///
/// 软删对标 projects:默认过滤回收站(语义与 list_projects 一致)。
/// 无 project_id 时调 list_active(WHERE deleted_at IS NULL);有 project_id 时
/// 先 list_active 再内存过滤 project_id(任务量小,无需 SQL 下推,避免新增专用查询方法)。
/// 注意:不能用通用 query 宏——它不带 deleted_at 过滤,会把回收站任务也返回。
#[tauri::command] #[tauri::command]
pub async fn list_tasks( pub async fn list_tasks(
state: State<'_, AppState>, state: State<'_, AppState>,
project_id: Option<String>, project_id: Option<String>,
) -> Result<Vec<TaskRecord>, String> { ) -> Result<Vec<TaskRecord>, String> {
let result = match project_id { let mut tasks = state.tasks.list_active().await.map_err(err_str)?;
Some(pid) => state.tasks.query("project_id", &pid).await, if let Some(pid) = project_id {
None => state.tasks.list_all().await, tasks.retain(|t| t.project_id == pid);
}; }
result.map_err(err_str) Ok(tasks)
} }
/// 按 id 查任务,找不到返回 Err(供前端详情页) /// 按 id 查任务,找不到返回 Err(供前端详情页)
@@ -72,6 +77,7 @@ pub async fn create_task(
assignee: input.assignee, assignee: input.assignee,
workflow_def_id: None, workflow_def_id: None,
base_branch: None, base_branch: None,
review_rounds: 0,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };
@@ -91,8 +97,8 @@ pub async fn update_task(
field: String, field: String,
value: String, value: String,
) -> Result<bool, String> { ) -> Result<bool, String> {
// 字段名注入由 df-storage 白名单兜底;这里补 status 值校验, // 字段名注入由 df-storage 白名单兜底;这里补 status / priority 值校验,
// 拦截拼写错误in-progess / "in progress" / 大小写错)静默落库。 // 拦截拼写错误in-progess / "in progress" / 大小写错与越界数值999 / "abc")静默落库。
if field == "status" && !TaskStatus::is_valid(&value) { if field == "status" && !TaskStatus::is_valid(&value) {
return Err(format!( return Err(format!(
"非法 status 值 {:?},合法值: {:?}", "非法 status 值 {:?},合法值: {:?}",
@@ -100,6 +106,28 @@ pub async fn update_task(
TaskStatus::valid_values() TaskStatus::valid_values()
)); ));
} }
// priority 值域 0..=30=critical, 1=high, 2=medium, 3=low与前端 <select> 及
// constants/project.ts 的 PRIORITY_LABELS 一致。拦截 "abc" / 999 等脏数据。
if field == "priority" {
match value.parse::<i32>() {
Ok(p) if (0..=3).contains(&p) => {}
_ => {
return Err(format!(
"非法 priority 值 {:?},合法值: 整数 0..=30=critical, 1=high, 2=medium, 3=low",
value
));
}
}
}
// project_id 跨表存在性校验(B-260616-16 收尾)。
// crud.rs tasks 白名单保留 project_id(支持跨项目移动),跨表约束由本命令层兜底。
// 拦截移动到不存在的项目(手输脏 id / 已物理删除的项目),避免 tasks.project_id 悬空。
if field == "project_id" {
let exists = state.projects.get_by_id(&value).await.map_err(err_str)?;
if exists.is_none() {
return Err(format!("非法 project_id 值 {:?},目标项目不存在", value));
}
}
state state
.tasks .tasks
.update_field(&id, &field, &value) .update_field(&id, &field, &value)
@@ -107,8 +135,34 @@ pub async fn update_task(
.map_err(err_str) .map_err(err_str)
} }
/// 删除任务 /// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
#[tauri::command] #[tauri::command]
pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> { pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.tasks.delete(&id).await.map_err(err_str) state.tasks.soft_delete(&id).await.map_err(err_str)
}
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
#[tauri::command]
pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.tasks.restore(&id).await.map_err(err_str)
}
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
///
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)
/// 落 df-nodes::task_advance_node::advance_task_atomic,本命令只做参数转发与错误串化。
///
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
///
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
#[tauri::command]
pub async fn advance_task(
state: State<'_, AppState>,
id: String,
target_status: String,
) -> Result<TaskRecord, String> {
df_nodes::task_advance_node::advance_task_atomic(&state.tasks, &id, &target_status)
.await
.map_err(err_str)
} }

View File

@@ -3,7 +3,7 @@
mod commands; mod commands;
mod state; mod state;
use tauri::Manager; use tauri::{Emitter, Listener, Manager};
use state::AppState; use state::AppState;
@@ -29,7 +29,40 @@ pub fn run() {
) { ) {
tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e); tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e);
} }
// B-260616-01: L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state
let session_for_handshake = app_state.ai_session.clone();
app.manage(app_state); app.manage(app_state);
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
let app_handle = app.handle().clone();
app.listen("ai-client-ready", move |_event| {
let session_arc = session_for_handshake.clone();
let app_h = app_handle.clone();
tauri::async_runtime::spawn(async move {
let mut session = session_arc.lock().await;
let was_generating = session.generating;
let conv_id = session.active_conversation_id.clone();
if was_generating {
session.generating = false;
session.pending_approvals.clear();
drop(session);
// 补偿事件:通知前端收尾(复位 streaming / generatingConvId
let _ = app_h.emit(
"ai-chat-event",
commands::ai::AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
conversation_id: conv_id,
},
);
} else {
session.pending_approvals.clear();
}
tracing::info!("[L0-handshake] 前端重连握手完成, was_generating={}", was_generating);
});
});
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@@ -53,7 +86,9 @@ pub fn run() {
commands::task::create_task, commands::task::create_task,
commands::task::update_task, commands::task::update_task,
commands::task::delete_task, commands::task::delete_task,
commands::task::restore_task,
commands::task::get_task_by_id, commands::task::get_task_by_id,
commands::task::advance_task,
// 灵感 // 灵感
commands::idea::list_ideas, commands::idea::list_ideas,
commands::idea::create_idea, commands::idea::create_idea,
@@ -69,6 +104,7 @@ pub fn run() {
commands::workflow::cancel_workflow_node, commands::workflow::cancel_workflow_node,
// AI 聊天 // AI 聊天
commands::ai::ai_chat_send, commands::ai::ai_chat_send,
commands::ai::ai_chat_force_send,
commands::ai::ai_chat_stop, commands::ai::ai_chat_stop,
commands::ai::ai_approve, commands::ai::ai_approve,
commands::ai::ai_pending_tool_calls, commands::ai::ai_pending_tool_calls,

View File

@@ -21,4 +21,13 @@ export const taskApi = {
delete(id: string): Promise<boolean> { delete(id: string): Promise<boolean> {
return invoke('delete_task', { id }) return invoke('delete_task', { id })
}, },
/**
* F-05 推进任务状态(走后端 advance_task 状态机:df-nodes task_advance_node)。
* 后端校验 from→to 合法性(can_transition)+ review_rounds 自动累加(退回时),
* 前端只传 target_status,不自行判状态机(单一真相源在后端)。
*/
advance(id: string, targetStatus: string): Promise<TaskRecord> {
return invoke('advance_task', { id, targetStatus })
},
} }

View File

@@ -91,6 +91,8 @@ export interface TaskRecord {
assignee: string | null assignee: string | null
workflow_def_id: string | null workflow_def_id: string | null
base_branch: string | null base_branch: string | null
/** review 退回累计轮数(in_review→in_progress / testing→in_review 各 +1) */
review_rounds: number
created_at: string created_at: string
updated_at: string updated_at: string
} }

View File

@@ -6,22 +6,19 @@
// ── 项目状态 ── // ── 项目状态 ──
// 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts) // 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts)
// F-09 对齐:删 in_progress/paused/cancelled(project 维度无数据产生源、无 UI 入口、无消费方),
// 保留与后端真实生命周期一致的最小集(planning 常态 + 软删 deleted_at 由 list_deleted 单独处理)。
export const PROJECT_STATUS_LABELS: Record<string, string> = { export const PROJECT_STATUS_LABELS: Record<string, string> = {
planning: 'projects.status.planning', planning: 'projects.status.planning',
in_progress: 'projects.status.in_progress',
paused: 'projects.status.paused',
completed: 'projects.status.completed',
cancelled: 'projects.status.cancelled',
active: 'projects.status.active', active: 'projects.status.active',
completed: 'projects.status.completed',
} }
/** 项目状态 → 卡片 badge 样式 class */ /** 项目状态 → 卡片 badge 样式 class */
export const PROJECT_STATUS_BADGE_CLASS: Record<string, string> = { export const PROJECT_STATUS_BADGE_CLASS: Record<string, string> = {
planning: 'stage-design', planning: 'stage-design',
in_progress: 'stage-coding', active: 'stage-coding',
paused: 'stage-testing',
completed: 'stage-release', completed: 'stage-release',
cancelled: 'stage-design',
} }
/** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */ /** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */
@@ -35,11 +32,9 @@ export interface ProjectStageInfo {
} }
export const PROJECT_STAGE_INFO: Record<string, ProjectStageInfo> = { export const PROJECT_STAGE_INFO: Record<string, ProjectStageInfo> = {
planning: { stepIndex: 0, progress: 20, stage: 'coding' }, planning: { stepIndex: 0, progress: 20, stage: 'planning' },
in_progress: { stepIndex: 2, progress: 55, stage: 'coding' }, active: { stepIndex: 2, progress: 55, stage: 'coding' },
paused: { stepIndex: 2, progress: 40, stage: 'testing' },
completed: { stepIndex: 4, progress: 100, stage: 'release' }, completed: { stepIndex: 4, progress: 100, stage: 'release' },
cancelled: { stepIndex: 0, progress: 0, stage: 'testing' },
} }
export function projectStatusLabel(status: string): string { export function projectStatusLabel(status: string): string {
@@ -51,26 +46,41 @@ export function projectBadgeClass(status: string): string {
} }
export function projectStageInfo(status: string): ProjectStageInfo { export function projectStageInfo(status: string): ProjectStageInfo {
return PROJECT_STAGE_INFO[status] ?? PROJECT_STAGE_INFO.planning // F-09:兜底用字面量而非 PROJECT_STAGE_INFO.planning,避免已删键的隐式依赖
return PROJECT_STAGE_INFO[status] ?? { stepIndex: 0, progress: 0, stage: 'planning' }
} }
// ── 任务状态 ── // ── 任务状态 ──
// D-260616-01:前端对齐后端 7 态纯状态机(crates/df-core/src/types.rs TaskStatus)
// 删旧 5 态 Git 工作流导向(review_ready/merged/abandoned)。
// 后端 task.rs update_task 写 status 经 TaskStatus::is_valid 拦截,旧 5 态无法落库;
// migrations.rs V1 无 seed,create_task 默认 todo → 新 DB 无旧 5 态数据,安全对齐。
//
// 值为 i18n key,实际文案走 $t('tasks.status.<key>')(见 src/i18n/{zh-CN,en}/tasks.ts) // 值为 i18n key,实际文案走 $t('tasks.status.<key>')(见 src/i18n/{zh-CN,en}/tasks.ts)
export const TASK_STATUS_LABELS: Record<string, string> = { export const TASK_STATUS_LABELS: Record<string, string> = {
todo: 'tasks.status.todo', todo: 'tasks.status.todo',
in_progress: 'tasks.status.in_progress', in_progress: 'tasks.status.in_progress',
review_ready: 'tasks.status.review_ready', in_review: 'tasks.status.in_review',
merged: 'tasks.status.merged', testing: 'tasks.status.testing',
abandoned: 'tasks.status.abandoned', done: 'tasks.status.done',
blocked: 'tasks.status.blocked',
cancelled: 'tasks.status.cancelled',
} }
// class 名对齐既有 css(Tasks/TaskDetail/ProjectDetail 均有 status-todo/progress/review/done/abandoned):
// - todo/in_progress/in_review/done 复用既有 class(保持跨视图样式一致,不破坏指派外文件)
// - cancelled 复用 status-abandoned(同为 danger 红色,语义一致)
// - testing/blocked 新增(Tasks.vue 自身样式补充;TaskDetail/ProjectDetail 遇此值兜底 status-todo 灰色,
// 非破坏——仅样式保守,文案经 $t 正常渲染)
export const TASK_STATUS_CLASS: Record<string, string> = { export const TASK_STATUS_CLASS: Record<string, string> = {
todo: 'status-todo', todo: 'status-todo',
in_progress: 'status-progress', in_progress: 'status-progress',
review_ready: 'status-review', in_review: 'status-review',
merged: 'status-done', testing: 'status-testing',
abandoned: 'status-abandoned', done: 'status-done',
blocked: 'status-blocked',
cancelled: 'status-abandoned',
} }
export function taskStatusLabel(status: string): string { export function taskStatusLabel(status: string): string {

View File

@@ -18,7 +18,24 @@ export default {
assignee: 'Assignee', assignee: 'Assignee',
baseBranch: 'Base Branch', baseBranch: 'Base Branch',
workflowDef: 'Workflow Definition', workflowDef: 'Workflow Definition',
reviewRounds: 'Review Round {n}',
createdAt: 'Created At', createdAt: 'Created At',
updatedAt: 'Updated At', updatedAt: 'Updated At',
// F-05 advance buttons (shown by state-machine-legal next states, validated by advance_task)
advanceTitle: 'Advance Task',
// Button labels (legal transitions per task_state_machine.rs can_transition)
advance: {
toInProgress: 'Start',
toInReview: 'Submit for Review',
toTesting: 'Approve to Testing',
toDone: 'Done',
resume: 'Resume',
sendBack: 'Send Back',
sendBackToReview: 'Send Back to Review',
block: 'Block',
cancel: 'Cancel Task',
},
advancing: 'Advancing...',
advanceFailed: 'Failed to advance task',
}, },
} }

View File

@@ -11,14 +11,16 @@ export default {
status: 'Status: ', status: 'Status: ',
all: 'All', all: 'All',
}, },
// Status filters (with icons) // Status filters (with icons) — D-260616-01 aligned to backend 7 states
statusFilter: { statusFilter: {
all: 'All', all: 'All',
todo: 'Todo', todo: 'Todo',
in_progress: 'In Progress', in_progress: 'In Progress',
review_ready: 'Review Ready', in_review: 'In Review',
merged: 'Merged', testing: 'Testing',
abandoned: 'Abandoned', done: 'Done',
blocked: 'Blocked',
cancelled: 'Cancelled',
}, },
// Groups // Groups
@@ -44,19 +46,22 @@ export default {
priorityMedium: 'P2 Medium', priorityMedium: 'P2 Medium',
priorityLow: 'P3 Low', priorityLow: 'P3 Low',
}, },
// Status labels (TASK_STATUS_LABELS values in constants/project.ts use these keys) // Status labels (TASK_STATUS_LABELS values in constants/project.ts use these keys) — D-260616-01 aligned to backend 7 states
status: { status: {
todo: '📋 Todo', todo: '📋 Todo',
in_progress: '🔄 In Progress', in_progress: '🔄 In Progress',
review_ready: '👀 Review Ready', in_review: '👀 In Review',
merged: '✅ Merged', testing: '🧪 Testing',
abandoned: '🗑️ Abandoned', done: '✅ Done',
blocked: '🚫 Blocked',
cancelled: '🗑️ Cancelled',
}, },
// Error fallbacks (user-visible via state.error) // Error fallbacks (user-visible via state.error)
err: { err: {
loadFailed: 'Failed to load tasks', loadFailed: 'Failed to load tasks',
createFailed: 'Failed to create task', createFailed: 'Failed to create task',
deleteFailed: 'Failed to delete task', deleteFailed: 'Failed to delete task',
updateFailed: 'Failed to update task',
}, },
}, },
} }

View File

@@ -18,7 +18,24 @@ export default {
assignee: '负责人', assignee: '负责人',
baseBranch: '基础分支', baseBranch: '基础分支',
workflowDef: '工作流定义', workflowDef: '工作流定义',
reviewRounds: '第 {n} 轮 Review',
createdAt: '创建时间', createdAt: '创建时间',
updatedAt: '更新时间', updatedAt: '更新时间',
// F-05 推进按钮(按状态机合法下一态显示,advance_task 状态机校验)
advanceTitle: '推进任务',
// 按钮文案(状态机合法转换,见 task_state_machine.rs can_transition)
advance: {
toInProgress: '开始', // todo/blocked → in_progress
toInReview: '提交审查', // in_progress → in_review
toTesting: '通过进测试', // in_review → testing
toDone: '完成', // testing → done
resume: '恢复', // blocked → in_progress(与 toInProgress 同向,blocked 专用文案)
sendBack: '退回修改', // in_review → in_progress
sendBackToReview: '退回重审', // testing → in_review
block: '阻塞', // * → blocked
cancel: '取消任务', // * → cancelled
},
advancing: '推进中...',
advanceFailed: '推进任务失败',
}, },
} }

View File

@@ -11,14 +11,16 @@ export default {
status: '状态:', status: '状态:',
all: '全部', all: '全部',
}, },
// 状态筛选(含图标) // 状态筛选(含图标) — D-260616-01 对齐后端 7 态
statusFilter: { statusFilter: {
all: '全部', all: '全部',
todo: '待开始', todo: '待开始',
in_progress: '进行中', in_progress: '进行中',
review_ready: '审查', in_review: '审查',
merged: '已合并', testing: '测试中',
abandoned: '已放弃', done: '已完成',
blocked: '已阻塞',
cancelled: '已取消',
}, },
// 分组 // 分组
@@ -44,19 +46,22 @@ export default {
priorityMedium: 'P2 中', priorityMedium: 'P2 中',
priorityLow: 'P3 低', priorityLow: 'P3 低',
}, },
// 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key) // 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key) — D-260616-01 对齐后端 7 态
status: { status: {
todo: '📋 待开始', todo: '📋 待开始',
in_progress: '🔄 进行中', in_progress: '🔄 进行中',
review_ready: '👀 审查', in_review: '👀 审查',
merged: '✅ 已合并', testing: '🧪 测试中',
abandoned: '🗑️ 已放弃', done: '✅ 已完成',
blocked: '🚫 已阻塞',
cancelled: '🗑️ 已取消',
}, },
// 错误回退(state.error 用户可见) // 错误回退(state.error 用户可见)
err: { err: {
loadFailed: '加载任务失败', loadFailed: '加载任务失败',
createFailed: '创建任务失败', createFailed: '创建任务失败',
deleteFailed: '删除任务失败', deleteFailed: '删除任务失败',
updateFailed: '更新任务失败',
}, },
}, },
} }

View File

@@ -34,7 +34,7 @@
<span <span
class="value description ai-md" class="value description ai-md"
v-if="task.description" v-if="task.description"
v-html="renderedDescription" v-html="renderedDesc"
></span> ></span>
<span v-else class="value description"></span> <span v-else class="value description"></span>
</div> </div>
@@ -42,7 +42,26 @@
<span class="label">{{ $t('taskDetail.status') }}</span> <span class="label">{{ $t('taskDetail.status') }}</span>
<span class="value"> <span class="value">
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span> <span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
<!-- F-04 review 轮次显示(>0 才显示,in_reviewin_progress / testingin_review 退回时后端 +1) -->
<span v-if="task.review_rounds > 0" class="review-rounds-badge">
{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}
</span> </span>
</span>
</div>
<!-- F-05 推进按钮:按当前 status 显示状态机合法下一态(todo/done/cancelled 终态无按钮) -->
<div v-if="advanceActions.length" class="info-item info-block advance-row">
<span class="label">{{ $t('taskDetail.advanceTitle') }}</span>
<div class="advance-actions">
<button
v-for="act in advanceActions"
:key="act.target"
type="button"
class="btn btn-sm"
:class="act.variant"
:disabled="advancing"
@click="handleAdvance(act.target)"
>{{ $t(act.label) }}</button>
</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">{{ $t('taskDetail.priority') }}</span> <span class="label">{{ $t('taskDetail.priority') }}</span>
@@ -95,9 +114,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
import { taskApi, projectApi } from '@/api' import { taskApi, projectApi } from '@/api'
import { formatDate } from '@/utils/time' import { formatDate } from '@/utils/time'
import { useRendered } from '@/composables/useMarkdown' import { useRendered } from '@/composables/useMarkdown'
@@ -107,7 +127,7 @@ import {
priorityLabel, priorityLabel,
priorityClass, priorityClass,
} from '../constants/project' } from '../constants/project'
import type { TaskRecord, ProjectRecord } from '@/api/types' import type { TaskRecord, ProjectRecord, DfDataChangedPayload } from '@/api/types'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
@@ -116,12 +136,13 @@ const loading = ref(true)
const errorMsg = ref('') const errorMsg = ref('')
const task = ref<TaskRecord | null>(null) const task = ref<TaskRecord | null>(null)
const projects = ref<ProjectRecord[]>([]) const projects = ref<ProjectRecord[]>([])
const advancing = ref(false)
const taskId = computed(() => route.params.id as string) const taskId = computed(() => route.params.id as string)
// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例),useRendered 封装 // B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例),useRendered 封装
// computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热) // computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
const { rendered: renderedDescription, ensureLoaded } = useRendered( const { rendered: renderedDesc, ensureLoaded } = useRendered(
() => task.value?.description ?? '', () => task.value?.description ?? '',
) )
@@ -130,6 +151,76 @@ const projectName = computed(() => {
return p?.name ?? task.value?.project_id ?? '—' return p?.name ?? task.value?.project_id ?? '—'
}) })
// ============================================================
// F-05 推进按钮 — 状态机合法下一态映射
// ------------------------------------------------------------
// 真相源:后端 df-nodes task_state_machine.rs can_transition(详见该文件注释矩阵)。
// 前端只做「按当前 status 显示哪些合法目标」的渲染决策,不做合法性判定;
// 点击调 advance_task,后端二次校验(can_transition)+ 原子写 + review_rounds 累加。
//
// 矩阵(行=from):
// todo → in_progress(开始), cancelled(取消)
// in_progress → in_review(提交审查), blocked(阻塞), cancelled
// in_review → testing(通过进测试), in_progress(退回修改), blocked, cancelled
// testing → done(完成), in_review(退回重审), blocked, cancelled
// blocked → in_progress(恢复), cancelled
// done/cancelled → 终态,无按钮
// variant: primary=前向推进主操作(每态至多一个),danger=取消,ghost=其余(阻塞/退回/恢复)
interface AdvanceAction {
target: string
label: string // i18n key(走 taskDetail.advance.*)
variant: string // btn-primary | btn-danger | btn-ghost
}
const ADVANCE_MAP: Record<string, AdvanceAction[]> = {
todo: [
{ target: 'in_progress', label: 'taskDetail.advance.toInProgress', variant: 'btn-primary' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
in_progress: [
{ target: 'in_review', label: 'taskDetail.advance.toInReview', variant: 'btn-primary' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
in_review: [
{ target: 'testing', label: 'taskDetail.advance.toTesting', variant: 'btn-primary' },
{ target: 'in_progress', label: 'taskDetail.advance.sendBack', variant: 'btn-ghost' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
testing: [
{ target: 'done', label: 'taskDetail.advance.toDone', variant: 'btn-primary' },
{ target: 'in_review', label: 'taskDetail.advance.sendBackToReview', variant: 'btn-ghost' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
blocked: [
{ target: 'in_progress', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
// done / cancelled:终态,无推进按钮
}
const advanceActions = computed<AdvanceAction[]>(() => {
const s = task.value?.status
if (!s) return []
return ADVANCE_MAP[s] ?? []
})
async function handleAdvance(target: string) {
if (!task.value || advancing.value) return
advancing.value = true
try {
// advance_task 返回更新后的 TaskRecord(含新 status / review_rounds)
const updated = await taskApi.advance(task.value.id, target)
task.value = updated
errorMsg.value = ''
} catch (e: any) {
errorMsg.value = e?.toString() ?? t('taskDetail.advanceFailed')
} finally {
advancing.value = false
}
}
async function load() { async function load() {
loading.value = true loading.value = true
errorMsg.value = '' errorMsg.value = ''
@@ -156,9 +247,31 @@ function refresh() {
// 路由参数变化(id 变化)时重新加载 // 路由参数变化(id 变化)时重新加载
watch(taskId, () => { load() }) watch(taskId, () => { load() })
onMounted(() => { // B-260616-18: 数据变更联动刷新 unlistener(onMounted 注册,onBeforeUnmount 释放)
// 对齐 AiChat _unlistenToolSlow 生命周期模式。本视图绕 store 直调 taskApi.get/projectApi.list,
// 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体),
// 故此本地监听 entity=task/project 时重载当前 task。
let _unlistenDataChanged: (() => void) | null = null
onMounted(async () => {
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
load() load()
// B-260616-18: 后端 AI 工具(create/update/delete 等)emit df-data-changed → 本视图重载当前 task
// entity=task:任务本体字段(标题/状态/描述/分支…)被改时刷新;entity=project:项目名变更影响 projectName 解析时刷新
try {
_unlistenDataChanged = await listen<DfDataChangedPayload>('df-data-changed', (event) => {
const { entity } = event.payload
if (entity === 'task' || entity === 'project') {
load()
}
})
} catch (e) {
console.error('[TaskDetail] 启动 df-data-changed 监听失败:', e)
}
})
onBeforeUnmount(() => {
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
}) })
</script> </script>
@@ -189,6 +302,31 @@ onMounted(() => {
} }
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); } .btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); } .btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
/* F-05 推进按钮变体(样式跟随 Projects/ProjectDetail/Knowledge 同款 btn-primary/btn-danger/btn-sm) */
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid rgba(240,101,101,0.4); }
.btn-danger:hover { background: rgba(240,101,101,0.12); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* F-05 推进操作区 */
.advance-row .advance-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* F-04 review 轮次徽章 */
.review-rounds-badge {
font-size: 11px;
color: var(--df-warning);
background: rgba(255,217,61,0.12);
padding: 3px 8px;
border-radius: var(--df-radius-xs);
margin-left: 8px;
white-space: nowrap;
}
/* ===== 状态/优先级标签 ===== */ /* ===== 状态/优先级标签 ===== */
.status-tag { .status-tag {
@@ -258,77 +396,7 @@ onMounted(() => {
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li) */ /* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li) */
.info-item .value.description { line-height: 1.5; } .info-item .value.description { line-height: 1.5; }
/* ===== 任务描述 Markdown 渲染(B-24,样式同 AiChat .ai-md 收敛) ===== */ /* ===== 任务描述 Markdown 渲染(B-24,基础样式收敛至全局 ai-md.css) ===== */
.description.ai-md :deep(p) { margin: 0 0 6px; }
.description.ai-md :deep(p:last-child) { margin-bottom: 0; }
.description.ai-md :deep(ul), .description.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; }
.description.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; }
.description.ai-md :deep(code) {
font-family: var(--df-font-mono);
font-size: 12px;
padding: 1px 5px;
background: rgba(255,255,255,0.06);
border-radius: var(--df-radius-sm);
color: var(--df-accent);
}
.description.ai-md :deep(pre) {
margin: 8px 0;
padding: 10px 12px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
overflow-x: auto;
}
.description.ai-md :deep(pre code) {
padding: 0;
background: transparent;
border-radius: 0;
color: var(--df-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.description.ai-md :deep(blockquote) {
margin: 6px 0;
padding: 4px 12px;
border-left: 2px solid var(--df-accent);
color: var(--df-text-secondary);
}
.description.ai-md :deep(h1), .description.ai-md :deep(h2), .description.ai-md :deep(h3) {
font-weight: 500;
color: var(--df-text);
margin: 8px 0 4px;
}
.description.ai-md :deep(h1) { font-size: 16px; }
.description.ai-md :deep(h2) { font-size: 14px; }
.description.ai-md :deep(h3) { font-size: 13px; }
.description.ai-md :deep(a) {
color: var(--df-accent);
text-decoration: none;
}
.description.ai-md :deep(a:hover) { text-decoration: underline; }
.description.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); }
.description.ai-md :deep(hr) {
border: none;
border-top: 0.5px solid var(--df-border);
margin: 8px 0;
}
.description.ai-md :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 6px 0;
font-size: 12px;
overflow-x: auto;
display: block;
}
.description.ai-md :deep(th), .description.ai-md :deep(td) {
padding: 4px 8px;
border: 0.5px solid var(--df-border);
text-align: left;
}
.description.ai-md :deep(th) {
font-weight: 500;
background: var(--df-bg);
}
.project-link { .project-link {
color: var(--df-accent); color: var(--df-accent);

View File

@@ -143,13 +143,16 @@ const projectFilters = computed(() => [
...store.projects.map(p => ({ key: p.id, label: p.name })), ...store.projects.map(p => ({ key: p.id, label: p.name })),
]) ])
// D-260616-01:筛选器对齐后端 7 态(删 review_ready/merged/abandoned,加 in_review/testing/done/blocked/cancelled)
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [ const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
{ key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' }, { key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' },
{ key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' }, { key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' },
{ key: 'in_progress', label: t('tasks.statusFilter.in_progress'), icon: '🔨' }, { key: 'in_progress', label: t('tasks.statusFilter.in_progress'), icon: '🔨' },
{ key: 'review_ready', label: t('tasks.statusFilter.review_ready'), icon: '👀' }, { key: 'in_review', label: t('tasks.statusFilter.in_review'), icon: '👀' },
{ key: 'merged', label: t('tasks.statusFilter.merged'), icon: '' }, { key: 'testing', label: t('tasks.statusFilter.testing'), icon: '🧪' },
{ key: 'abandoned', label: t('tasks.statusFilter.abandoned'), icon: '🗑️' }, { key: 'done', label: t('tasks.statusFilter.done'), icon: '' },
{ key: 'blocked', label: t('tasks.statusFilter.blocked'), icon: '🚫' },
{ key: 'cancelled', label: t('tasks.statusFilter.cancelled'), icon: '🗑️' },
]) ])
function getProjectName(projectId: string): string { function getProjectName(projectId: string): string {
@@ -406,6 +409,9 @@ onMounted(async () => {
.status-review { background: rgba(255,217,61,0.2); color: var(--df-warning); } .status-review { background: rgba(255,217,61,0.2); color: var(--df-warning); }
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); } .status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); } .status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* D-260616-01:7 态新增 — testing(橙警告,近 review 区分)/blocked(红 danger,与 cancelled 区分用实心边框) */
.status-testing { background: rgba(255,152,0,0.2); color: #ff9800; }
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
/* ===== 空状态(Y-2: loading/error/empty 兜底,样式参考 Projects.vue) ===== */ /* ===== 空状态(Y-2: loading/error/empty 兜底,样式参考 Projects.vue) ===== */
.empty-state { .empty-state {