优化: miniapp渲染(图片/checkbox/代码高亮) + MCP规则共享下沉 + 文档重写 + 节点风格对齐
This commit is contained in:
+42
-29
@@ -110,13 +110,13 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
|
||||
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
|
||||
// ─── 任务 ───
|
||||
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks),
|
||||
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 parent_id 父任务 ID,限 1 级嵌套,父任务自身不能是子任务)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)"), "parent_id": opt_str_field("父任务 ID(可空)")}), &["project_id", "title"]), Medium, create_task),
|
||||
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 idea_id 关联灵感、queue 管理池(默认 todo,新建仅 backlog/todo/decision)、parent_id 父任务 ID(限 1 级嵌套,父任务自身不能是子任务)、content_json 结构化需求规格(须合法 JSON))", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 2=medium,值域 0..=3)"), "idea_id": opt_str_field("关联灵感 ID(可空)"), "queue": opt_str_field("管理池(可空,默认 todo,新建仅 backlog/todo/decision)"), "parent_id": opt_str_field("父任务 ID(可空,限 1 级嵌套)"), "content_json": opt_str_field("结构化需求规格 JSON(可空,须合法 JSON)")}), &["project_id", "title"]), Medium, create_task),
|
||||
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_task),
|
||||
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
|
||||
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
|
||||
// ─── 灵感 ───
|
||||
spec("list_ideas", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_ideas),
|
||||
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title"]), Medium, create_idea),
|
||||
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 1)")}), &["title"]), Medium, create_idea),
|
||||
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_idea),
|
||||
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
|
||||
spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
|
||||
@@ -572,31 +572,45 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let priority = arg_int_or(&args, "priority", 0);
|
||||
medium_audit("create_task", &format!("{project_id}/{title}"));
|
||||
Box::pin(async move {
|
||||
// parent_id 可选(arg_str_or 给 "" 哨兵,空串视为 None)。非空时校验 1 级嵌套铁律:
|
||||
// 父任务存在 + 父任务自身无 parent_id(防孙任务),违反返回明确错误(与 IPC create_task 同规则)。
|
||||
let parent_id_raw = arg_str_or(&args, "parent_id", "");
|
||||
let parent_id = if parent_id_raw.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
let pid = parent_id_raw.trim();
|
||||
let repo = TaskRepo::new(&db);
|
||||
match repo.get_by_id(pid).await {
|
||||
Ok(Some(parent)) => {
|
||||
if parent.parent_id.is_some() {
|
||||
return CallToolResult::error(format!(
|
||||
"父任务不能是子任务(1 级嵌套限制): {pid} 自身有 parent_id={:?}",
|
||||
parent.parent_id
|
||||
));
|
||||
}
|
||||
Some(pid.to_string())
|
||||
}
|
||||
Ok(None) => return CallToolResult::error(format!("父任务不存在: {pid}")),
|
||||
Err(e) => return err_str(e),
|
||||
}
|
||||
let repo = TaskRepo::new(&db);
|
||||
|
||||
// 共享校验(与 GUI/IPC 同源,一处修改全局生效):queue / parent_id / content_json / priority 值域
|
||||
let queue = match df_storage::crud::normalize_queue_for_create(
|
||||
args.get("queue").and_then(|v| v.as_str()),
|
||||
) {
|
||||
Ok(q) => q,
|
||||
Err(e) => return CallToolResult::error(e),
|
||||
};
|
||||
let parent_id = match df_storage::crud::validate_parent_id(
|
||||
&repo,
|
||||
args.get("parent_id").and_then(|v| v.as_str()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pid) => pid,
|
||||
Err(e) => return CallToolResult::error(e),
|
||||
};
|
||||
let content_json = match df_storage::crud::validate_content_json(
|
||||
args.get("content_json").and_then(|v| v.as_str()),
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return CallToolResult::error(e),
|
||||
};
|
||||
let priority = match df_storage::crud::normalize_priority(
|
||||
args.get("priority").and_then(|v| v.as_i64()),
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return CallToolResult::error(e),
|
||||
};
|
||||
// 可选关联灵感(空串/缺省视为不关联,与 GUI create_task 同语义)
|
||||
let idea_id = args
|
||||
.get("idea_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from);
|
||||
|
||||
let now = now_millis();
|
||||
let rec = TaskRecord {
|
||||
id: new_id(),
|
||||
@@ -611,15 +625,14 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
idea_id,
|
||||
module_id: None,
|
||||
queue: "todo".to_string(),
|
||||
queue,
|
||||
parent_id,
|
||||
content_json: None,
|
||||
content_json,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
let repo = TaskRepo::new(&db);
|
||||
match repo.insert(rec).await {
|
||||
Ok(id) => {
|
||||
let created = repo.get_by_id(&id).await.ok().flatten();
|
||||
@@ -766,7 +779,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let priority = arg_int_or(&args, "priority", 0);
|
||||
let priority = arg_int_or(&args, "priority", 1);
|
||||
medium_audit("create_idea", &title);
|
||||
Box::pin(async move {
|
||||
let now = now_millis();
|
||||
|
||||
@@ -56,9 +56,7 @@ impl Node for ScriptNode {
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
// 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。
|
||||
// - 白名单非空时:命令首词不在白名单 → 直接拒绝执行
|
||||
// - 黑名单匹配时:直接拒绝执行
|
||||
// 命令执行安全:白/黑名单校验(运行时配置优先,回退环境变量/默认黑名单)。
|
||||
// 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。
|
||||
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||
if let Some(denied) = check_command_policy(&cmd_name) {
|
||||
@@ -70,7 +68,7 @@ impl Node for ScriptNode {
|
||||
anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command);
|
||||
}
|
||||
|
||||
// 危险命令告警:匹配已知危险关键词,仅告警不阻止执行
|
||||
// 危险关键词告警:匹配到仅告警不阻止执行(与硬拒绝的白/黑名单正交)
|
||||
let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"];
|
||||
for &kw in &dangerous_keywords {
|
||||
if command.contains(kw) {
|
||||
@@ -143,36 +141,30 @@ const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs",
|
||||
|
||||
/// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。
|
||||
///
|
||||
/// 优先级(高→低):
|
||||
/// 1. 运行时配置(set_script_safety_config,前端设置页写入)
|
||||
/// 2. 环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST
|
||||
/// 3. 默认黑名单兜底(DEFAULT_BLACKLIST)
|
||||
///
|
||||
/// 黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
|
||||
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
|
||||
/// 优先级(高→低):运行时配置(前端设置页写入) > 环境变量 > 默认黑名单兜底。
|
||||
/// 黑名单优先于白名单(命中黑名单总是拒绝,即便同时在白名单);命令名比较前 trim + 小写。
|
||||
///
|
||||
/// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。
|
||||
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
||||
// 步骤1:尝试运行时黑名单(前端设置页写入,优先级最高)
|
||||
// 运行时配置优先(前端设置页写入,优先级最高)
|
||||
if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() {
|
||||
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
if list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
|
||||
return Some("命令在黑名单中(运行时配置)");
|
||||
}
|
||||
}
|
||||
// 步骤2:尝试运行时白名单(非空时放行) — 仅当运行时黑名单未命中时才检查
|
||||
// 运行时白名单非空时,命令须在名单内(命中即放行,跳过环境变量)
|
||||
if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() {
|
||||
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
if !list.is_empty() && !list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
|
||||
return Some("命令不在白名单中(运行时配置)");
|
||||
}
|
||||
// 运行时白名单非空且命令在其中 → 直接放行(跳过 env var)
|
||||
if !list.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤3:回退到环境变量(兼容旧部署)
|
||||
// 回退环境变量(兼容旧部署);黑名单缺省用默认黑名单兜底
|
||||
let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok();
|
||||
let blacklist: Vec<String> = match env_blacklist {
|
||||
Some(raw) => raw
|
||||
@@ -186,7 +178,7 @@ fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
||||
return Some("命令在黑名单中");
|
||||
}
|
||||
|
||||
// 白名单:非空时命令名必须在白名单中才放行
|
||||
// 环境变量白名单非空时,命令名须在名单内
|
||||
if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") {
|
||||
let whitelist: Vec<String> = whitelist_raw
|
||||
.split(',')
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! SubflowNode — 嵌套子工作流节点
|
||||
//! 子工作流节点 — 加载一个子 DAG 并在当前执行上下文中递归执行
|
||||
//!
|
||||
//! 加载一个子模板(DagDef)并在当前执行上下文中递归执行。
|
||||
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
|
||||
//! 执行结果不直接展开节点,而是返回子 DAG 的 JSON 快照,由 DagExecutor 消费展开。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::dag_def::DagDef;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SubflowNode 配置
|
||||
/// 子工作流配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SubflowConfig {
|
||||
/// 子工作流 DAG 定义(内联 JSON)
|
||||
@@ -21,13 +21,13 @@ struct SubflowConfig {
|
||||
/// 最大递归深度(安全阀)
|
||||
const MAX_DEPTH: u32 = 10;
|
||||
|
||||
/// SubflowNode — 嵌套子工作流
|
||||
/// 子工作流节点
|
||||
pub struct SubflowNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for SubflowNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("SubflowNode 执行: {}", ctx.node_id);
|
||||
tracing::info!("SubflowNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let config: SubflowConfig = serde_json::from_value(ctx.config.clone())
|
||||
.map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?;
|
||||
@@ -84,11 +84,20 @@ impl Node for SubflowNode {
|
||||
},
|
||||
"required": ["dag"]
|
||||
}),
|
||||
output: serde_json::json!({}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subflow": { "type": "boolean" },
|
||||
"node_count": { "type": "integer" },
|
||||
"edge_count": { "type": "integer" },
|
||||
"max_depth": { "type": "integer" },
|
||||
"dag": { "type": "object" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &'static str {
|
||||
fn node_type(&self) -> &str {
|
||||
"subflow"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
|
||||
//! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo
|
||||
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||
//! - [`mod@task_validation`]:任务创建参数校验(queue 白名单/parent_id 嵌套铁律/content_json),GUI 与 MCP 工具共享
|
||||
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
|
||||
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
|
||||
//! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3)
|
||||
@@ -31,6 +32,7 @@ mod project_service_repo;
|
||||
mod settings;
|
||||
mod task_link_repo;
|
||||
mod task_repo;
|
||||
mod task_validation;
|
||||
|
||||
pub use conversation_repo::*;
|
||||
pub use idea_eval_repo::*;
|
||||
@@ -43,6 +45,7 @@ pub use project_service_repo::*;
|
||||
pub use settings::*;
|
||||
pub use task_link_repo::*;
|
||||
pub use task_repo::*;
|
||||
pub use task_validation::*;
|
||||
pub use module_dependency_repo::*;
|
||||
pub use plan_repo::*;
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! 任务创建参数校验(共享层)
|
||||
//!
|
||||
//! GUI AI 工具(ai/tools/task.rs)与 MCP 工具(crates/df-mcp/src/tools.rs)曾各自内联
|
||||
//! 一套 create_task 校验(queue 白名单 / parent_id 嵌套铁律 / content_json 合法性),
|
||||
//! 演进易漂移。抽到本模块后两套工具同源复用,一处修改全局生效。
|
||||
|
||||
use crate::crud::task_repo::TaskRepo;
|
||||
|
||||
/// queue 合法值白名单(queue 是独立管理维度,与 status 执行维度正交)
|
||||
pub const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
|
||||
|
||||
/// 默认优先级(medium)— 新任务默认中优先级,与 IPC create_task 一致
|
||||
pub const DEFAULT_TASK_PRIORITY: i32 = 2;
|
||||
|
||||
/// 校验并归一化 create_task 的 queue:空串/缺省 → "todo";
|
||||
/// 白名单校验 + create 一致性(新建仅 backlog/todo/decision,active/done 须经 move_task_queue 流转)。
|
||||
pub fn normalize_queue_for_create(queue: Option<&str>) -> Result<String, String> {
|
||||
match queue {
|
||||
None | Some("") => Ok("todo".to_string()),
|
||||
Some(q) => {
|
||||
let q = q.trim();
|
||||
if !TASK_QUEUE_VALUES.contains(&q) {
|
||||
return Err(format!("非法 queue 值 {:?},合法值: {:?}", q, TASK_QUEUE_VALUES));
|
||||
}
|
||||
match q {
|
||||
"backlog" | "todo" | "decision" => Ok(q.to_string()),
|
||||
other => Err(format!(
|
||||
"新建任务 queue 不可直接落 {:?}(须经 move_task_queue 流转),当前仅允许 backlog/todo/decision",
|
||||
other
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 content_json(非空时须是合法 JSON),返回规范化值(None=未传)。
|
||||
pub fn validate_content_json(content: Option<&str>) -> Result<Option<String>, String> {
|
||||
match content {
|
||||
Some(c) if !c.trim().is_empty() => {
|
||||
serde_json::from_str::<serde_json::Value>(c)
|
||||
.map_err(|e| format!("content_json 不是合法 JSON: {e}"))?;
|
||||
Ok(Some(c.to_string()))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 parent_id 1 级嵌套铁律:父任务存在 + 父任务自身不是子任务(防孙任务)。
|
||||
/// 返回归一化 parent_id(空串/缺省 → None)。
|
||||
pub async fn validate_parent_id(
|
||||
repo: &TaskRepo,
|
||||
parent_id: Option<&str>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let pid = parent_id
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(pid) = &pid {
|
||||
let parent = repo
|
||||
.get_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("父任务 {pid} 不存在,无法创建子任务"))?;
|
||||
if parent.parent_id.is_some() {
|
||||
return Err(format!(
|
||||
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
|
||||
parent.parent_id
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
/// 校验并归一化 create_task 的 priority:缺省 → DEFAULT_TASK_PRIORITY;越界值拒绝(值域 0..=3)。
|
||||
pub fn normalize_priority(priority: Option<i64>) -> Result<i32, String> {
|
||||
match priority {
|
||||
None => Ok(DEFAULT_TASK_PRIORITY),
|
||||
Some(p) if (0..=3).contains(&p) => Ok(p as i32),
|
||||
Some(p) => Err(format!(
|
||||
"非法 priority 值 {p},合法值: 整数 0..=3(0=critical, 1=high, 2=medium, 3=low)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user