- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰) - 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch - TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重 - 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible - UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认) - 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化 - 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs - 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传 - crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
186 lines
5.9 KiB
Rust
186 lines
5.9 KiB
Rust
//! 子工作流节点 — 加载并校验一个子 DAG,返回其 JSON 快照
|
|
//!
|
|
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
|
|
//! 注意:本节点不递归执行子 DAG,只做配置/深度校验后返回子 DAG 快照;
|
|
//! 快照的展开执行需调用方自行处理(DagExecutor 当前不会自动执行返回的 subflow)。
|
|
|
|
use async_trait::async_trait;
|
|
use df_workflow::dag_def::DagDef;
|
|
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// 子工作流配置
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct SubflowConfig {
|
|
/// 子工作流 DAG 定义(内联 JSON)
|
|
dag: serde_json::Value,
|
|
/// 嵌套深度限制(防无限递归,默认 MAX_DEPTH)
|
|
#[serde(default)]
|
|
max_depth: Option<u32>,
|
|
}
|
|
|
|
/// 最大递归深度(安全阀)
|
|
const MAX_DEPTH: u32 = 10;
|
|
|
|
/// 子工作流节点
|
|
pub struct SubflowNode;
|
|
|
|
#[async_trait]
|
|
impl Node for SubflowNode {
|
|
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
|
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))?;
|
|
|
|
let max_depth = config.max_depth.unwrap_or(MAX_DEPTH).min(MAX_DEPTH);
|
|
|
|
// 反序列化子 DAG
|
|
let sub_dag: DagDef = serde_json::from_value(config.dag)
|
|
.map_err(|e| anyhow::anyhow!("子工作流 DAG 解析失败: {}", e))?;
|
|
|
|
if sub_dag.nodes.is_empty() {
|
|
anyhow::bail!("子工作流节点不能为空");
|
|
}
|
|
|
|
if max_depth == 0 {
|
|
anyhow::bail!("SubflowNode 超过最大嵌套深度");
|
|
}
|
|
|
|
tracing::info!(
|
|
node_count = sub_dag.nodes.len(),
|
|
edge_count = sub_dag.edges.len(),
|
|
max_depth,
|
|
"SubflowNode 加载子工作流"
|
|
);
|
|
|
|
// 返回子 DAG 的 JSON 快照供 DagExecutor 消费
|
|
Ok(NodeOutput::from_value(serde_json::json!({
|
|
"subflow": true,
|
|
"node_count": sub_dag.nodes.len(),
|
|
"edge_count": sub_dag.edges.len(),
|
|
"max_depth": max_depth,
|
|
"dag": sub_dag,
|
|
})))
|
|
}
|
|
|
|
fn schema(&self) -> NodeSchema {
|
|
NodeSchema {
|
|
params: serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"dag": {
|
|
"type": "object",
|
|
"description": "子工作流 DAG 定义(nodes + edges)",
|
|
"properties": {
|
|
"nodes": { "type": "object" },
|
|
"edges": { "type": "array" }
|
|
}
|
|
},
|
|
"max_depth": {
|
|
"type": "integer",
|
|
"description": "最大嵌套深度(默认10)",
|
|
"default": 10
|
|
}
|
|
},
|
|
"required": ["dag"]
|
|
}),
|
|
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) -> &str {
|
|
"subflow"
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use df_workflow::eventbus::EventBus;
|
|
use df_workflow::state::StateMachine;
|
|
|
|
fn make_ctx(config: serde_json::Value) -> NodeContext {
|
|
NodeContext {
|
|
node_id: "test_subflow".into(),
|
|
inputs: Default::default(),
|
|
config,
|
|
execution_id: "exec_1".into(),
|
|
event_bus: EventBus::new(),
|
|
node_status: StateMachine::new(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sub_01_valid_dag_returns_metadata() {
|
|
let node = SubflowNode;
|
|
let ctx = make_ctx(serde_json::json!({
|
|
"dag": {
|
|
"nodes": {
|
|
"child": {
|
|
"id": "child",
|
|
"node_type": "script",
|
|
"config": {}
|
|
}
|
|
},
|
|
"edges": []
|
|
}
|
|
}));
|
|
let result = node.execute(ctx).await.unwrap();
|
|
assert_eq!(result.data["subflow"], true);
|
|
assert_eq!(result.data["node_count"], 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sub_02_empty_dag_rejected() {
|
|
let node = SubflowNode;
|
|
let ctx = make_ctx(serde_json::json!({
|
|
"dag": { "nodes": {}, "edges": [] }
|
|
}));
|
|
let result = node.execute(ctx).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("不能为空"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sub_03_max_depth_zero_guard() {
|
|
let node = SubflowNode;
|
|
let ctx = make_ctx(serde_json::json!({
|
|
"dag": {
|
|
"nodes": { "c": { "id": "c", "node_type": "script", "config": {} } },
|
|
"edges": []
|
|
},
|
|
"max_depth": 0
|
|
}));
|
|
let result = node.execute(ctx).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("最大嵌套深度"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sub_04_missing_dag_field_errors() {
|
|
let node = SubflowNode;
|
|
let ctx = make_ctx(serde_json::json!({}));
|
|
let result = node.execute(ctx).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sub_05_schema_has_required_fields() {
|
|
let node = SubflowNode;
|
|
let schema = node.schema();
|
|
let params = &schema.params;
|
|
assert!(params["properties"]["dag"].is_object());
|
|
assert!(params["required"].as_array().unwrap().contains(&serde_json::json!("dag")));
|
|
}
|
|
}
|