//! 工作流相关命令 — 触发执行、查询执行记录 //! //! 事件转发:run_workflow 在执行前订阅事件总线, //! 将 WorkflowEvent 包装为 `{ execution_id, event }` 通过 `workflow-event` 事件发给前端。 use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use tokio::sync::broadcast::error::RecvError; use df_types::events::{WorkflowEvent, HumanApprovalResponse, SelectType}; use df_types::types::{new_id, NodeStatus}; use df_nodes::task_advance_node::advance_task_atomic; // F-260616-06 ①-1: 空 dag + target_status 时按目标态自动选推进链模板 use df_nodes::task_workflow_templates::template_for; use df_storage::crud::{TaskRepo, WorkflowRepo}; use df_storage::models::WorkflowRecord; use df_workflow::dag_def::DagDef; use df_workflow::executor::DagExecutor; use crate::state::AppState; use super::{err_str, now_millis}; /// 转发到前端的事件载荷 #[derive(Debug, Clone, Serialize)] struct WorkflowEventPayload { /// 工作流执行记录 ID execution_id: String, /// 原始工作流事件(serde tag = "type") event: WorkflowEvent, } /// F-260616-06 ②-4: 工作流失败时按 target_status 推算任务退回态。 /// /// 映射表(失败退一步): /// - testing → in_review /// - in_review → in_progress /// - in_progress → None(状态机禁止→todo,留 in_progress 等人介入) /// - 其他(done/blocked/cancelled/todo 等) → None(无退回映射,跳过回调) /// /// 设计选择:in_progress → None(而非 todo)。理由:状态机禁止 in_progress→todo /// (task_state_machine.rs backward_to_todo_rejected),失败退回 todo 会被 /// advance_task_atomic 的 InvalidState 拦截,任务原地保留 in_progress 且无信号; /// 改为 None 则回调跳过推进,留 in_progress 等人介入。 /// 起点状态 todo 无可退态 → None。 fn regression_target(target: &str) -> Option<&str> { match target { "testing" => Some("in_review"), "in_review" => Some("in_progress"), // in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入 "in_progress" => None, _ => None, } } /// 触发工作流执行(核心命令) /// /// 流程:build_dag 校验 → 写入执行记录(status=running) → 后台异步执行 → /// 事件经 EventBus 转发到前端 → 完成后更新执行记录状态。 /// 立即返回执行记录 ID,前端凭此关联后续事件。 /// /// F-260616-06 阶段2 工作流联动(②-2): /// - `task_id` / `target_status` 同时 Some 时,完成后按 target_status 推进任务(②-3), /// 失败时按 regression_target 退回(②-4)。 /// - Option 向后兼容:旧调用方不传即 None,不触发任何任务回调,零行为破坏。 #[tauri::command] pub async fn run_workflow( app: AppHandle, state: State<'_, AppState>, name: String, dag: DagDef, config: serde_json::Value, // F-260616-06 ②-2: 工作流联动任务的关联 ID 与目标态(同时 Some 才联动) task_id: Option, target_status: Option, ) -> Result { // 瘦转发到 run_workflow_inner(B-260617-01:抽取共用核心供 ai_approve 经 // execute_run_workflow_for_tool 直接调,无需构造 tauri::State)。 // CR-52: 命令层(前端 invoke run_workflow)是人工手动触发 → triggered_by="manual"。 run_workflow_inner(&app, &state, name, dag, config, task_id, target_status, "manual").await } /// 工作流执行核心逻辑(B-260617-01 抽取,命令层瘦转发)。 /// /// 原 `run_workflow` 命令体拆出,使 ai_approve 经 execute_run_workflow_for_tool 直接调用 /// (持 &AppState 而非 tauri::State,绕开 tauri::State 在非命令上下文不可构造的限制)。 /// 语义与原命令完全一致:模板选 DAG → 落执行记录 → spawn 事件转发 + DAG 执行 + 任务联动推进。 /// /// `triggered_by` 落入 WorkflowRecord.triggered_by,区分触发来源: /// - 命令层 run_workflow(前端 invoke,人工手动)传 "manual" /// - execute_run_workflow_for_tool(AI 工具经 ai_approve 审批后执行)传 "ai" /// CR-52:原硬编码 "manual" 致 AI 路径误标,改由调用方透传。 pub async fn run_workflow_inner( app: &AppHandle, state: &AppState, name: String, dag: DagDef, config: serde_json::Value, task_id: Option, target_status: Option, triggered_by: &str, ) -> Result { // 0. F-260616-06 ①-1: DagDef 来源选模板(方案A·最小)。 // 规则: // - dag.nodes 非空 → 用传入 dag(向后兼容,旧调用方/编辑器自定义工作流不变) // - dag.nodes 为空 + target_status Some → 调 template_for 自动选推进链模板; // 模板不存在(target 非三合法态) → Err「无对应工作流模板」 // - dag.nodes 为空 + target_status None → 原行为(空 dag 让 build_dag 自行报错,零新分支) // 前端选 target_status 后传空 dag,零模板知识零新 IPC,后端收敛选模板逻辑。 let dag = if dag.nodes.is_empty() { match target_status.as_deref() { Some(target) => template_for(target) .ok_or_else(|| format!("无对应工作流模板: target_status={}", target))?, None => dag, // 空dag+无target: 走原路径由 build_dag 校验 } } else { dag }; // 1. 先构建运行时 DAG,校验失败直接返回,不落库 let runtime_dag = state.registry.build_dag(&dag).map_err(err_str)?; // 2. 写入执行记录(status=running) let execution_id = new_id(); let dag_json = serde_json::to_string(&dag).map_err(err_str)?; let record = WorkflowRecord { id: execution_id.clone(), name, dag_json, status: "running".to_string(), // CR-52: triggered_by 由调用方透传(命令层 run_workflow="manual", // execute_run_workflow_for_tool="ai"),不再硬编码 "manual" 致 AI 路径误标。 triggered_by: Some(triggered_by.to_string()), project_id: None, task_id: task_id.clone(), created_at: now_millis(), completed_at: None, }; state .workflows .insert(record) .await .map_err(err_str)?; // 3. 订阅事件总线,把事件转发给前端(收到完成/失败事件后退出) let mut rx = state.event_bus.subscribe(); let forward_app = app.clone(); let forward_exec_id = execution_id.clone(); // B-260615-35: forward 任务内需查 DB 终态兜底 Lagged 丢终态事件,故 move 进 db 句柄 let forward_db = state.db.clone(); tauri::async_runtime::spawn(async move { // Lagged 累计次数(用于"累计达阈值"触发兜底查询) let mut lagged_total: u64 = 0; // Lagged 单次/累计触发 DB 终态查询兜底的阈值 const LAGGED_PROBE_THRESHOLD: u64 = 8; loop { match rx.recv().await { Ok(event) => { // B-03b-R10 ③(波17 治本): 完成判定加 execution_id 匹配。 // AppState.event_bus 是全局单例,并发工作流各自订阅同一总线; // 此前 matches! 只看变体不看 exec_id,任一工作流的 WorkflowCompleted/Failed // 都会触发本 forward 循环 break,过早断他人链(并发串扰根因)。 // 现在按变体+exec_id 双重匹配:只处理属于本 forward_exec_id 的终态事件, // 他人终态事件忽略(emit 给前端后继续循环),不再误 break。 // execution_id 字段 #[serde(default)] 向后兼容老事件(空串): // 自家终态事件本就带正确 exec_id;空串匹配只可能漏(自家老事件),不会误 break。 let finished = matches!( &event, WorkflowEvent::WorkflowCompleted { execution_id, .. } | WorkflowEvent::WorkflowFailed { execution_id, .. } if execution_id == &forward_exec_id ); let payload = WorkflowEventPayload { execution_id: forward_exec_id.clone(), event, }; if let Err(e) = forward_app.emit("workflow-event", &payload) { tracing::warn!("工作流事件转发失败: {}", e); } if finished { break; } } // R-PD-13: Lagged 表示消费过慢,broadcast 滑动窗口丢 n 条最旧事件。 // broadcast 不暴露被丢事件的具体类型,无法在此精确判断是否为关键终态事件。 // 风险:若关键终态事件(WorkflowCompleted/WorkflowFailed)被丢,finished 判定不触发, // 循环不会 break,前端永久收不到工作流结束信号。 // B-260615-35 兜底:收到 Lagged 时,累计次数或单次 n 达阈值则查 DB 终态, // 若工作流已终态则补发对应 workflow-event 并 break 退出 forward。 // 避免依赖实时性差的 DB 轮询兜底导致前端审批/完成弹窗卡死。 Err(RecvError::Lagged(n)) => { lagged_total = lagged_total.saturating_add(n); tracing::warn!( execution_id = %forward_exec_id, lagged = n, lagged_total = lagged_total, "工作流事件消费滞后,丢失 {} 条事件(broadcast 不暴露被丢事件类型,关键终态事件可能已丢失)", n ); if lagged_total < LAGGED_PROBE_THRESHOLD { continue; } // 累计/单次达阈值:查 DB 终态兜底 let workflows = WorkflowRepo::new(&forward_db); match workflows.get_by_id(&forward_exec_id).await { Ok(Some(record)) => { let terminal = match record.status.as_str() { "completed" => Some(WorkflowEvent::WorkflowCompleted { execution_id: forward_exec_id.clone(), total_duration_ms: 0, }), "failed" => Some(WorkflowEvent::WorkflowFailed { execution_id: forward_exec_id.clone(), error: "工作流执行失败(Lagged 兜底补发,详情见 DB)" .to_string(), failed_node: String::new(), }), "cancelled" => Some(WorkflowEvent::WorkflowFailed { execution_id: forward_exec_id.clone(), error: "工作流被取消(Lagged 兜底补发)".to_string(), failed_node: String::new(), }), _ => None, }; if let Some(synth_event) = terminal { let payload = WorkflowEventPayload { execution_id: forward_exec_id.clone(), event: synth_event.clone(), }; if let Err(e) = forward_app.emit("workflow-event", &payload) { tracing::warn!("Lagged 终态兜底事件转发失败: {}", e); } tracing::info!( execution_id = %forward_exec_id, status = %record.status, "Lagged 兜底命中终态,补发 {} 并退出 forward", match synth_event { WorkflowEvent::WorkflowCompleted { .. } => "WorkflowCompleted", _ => "WorkflowFailed", } ); break; } // DB 仍非终态(running),重置累计计数继续等待后续事件 lagged_total = 0; } Ok(None) => { tracing::warn!( execution_id = %forward_exec_id, "Lagged 兜底查询未找到执行记录,继续等待事件" ); lagged_total = 0; } Err(e) => { tracing::warn!( execution_id = %forward_exec_id, error = %e, "Lagged 兜底查询 DB 失败,继续等待事件" ); lagged_total = 0; } } } Err(RecvError::Closed) => break, } } }); // 4. 后台异步执行 DAG,完成后更新执行记录状态 let event_bus = state.event_bus.clone(); let db = state.db.clone(); let exec_id = execution_id.clone(); let state_registry = state.workflow_state_registry.clone(); // F-260616-06 ②-2: move task_id / target_status 进闭包供完成/失败回调使用 let cb_task_id = task_id.clone(); let cb_target_status = target_status.clone(); tauri::async_runtime::spawn(async move { let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone()); // 注册执行器状态机:StateMachine 内部 Arc,clone 共享底层 HashMap, // cancel_workflow_node IPC 经 execution_id 取此引用 set_cancelled,直达运行中 HumanNode state_registry .lock() .await .insert(exec_id.clone(), executor.state_machine()); let result = executor.run(&runtime_dag, config).await; let (status, error) = match &result { Ok(_) => ("completed", None), Err(e) => ("failed", Some(format!("{:#}", e))), }; // 更新执行记录(Repo 内部仅持有 Arc 连接,重建开销可忽略) let workflows = WorkflowRepo::new(&db); if let Err(e) = workflows.update_field(&exec_id, "status", status).await { tracing::error!("更新工作流状态失败: {}", e); } if let Err(e) = workflows .update_field(&exec_id, "completed_at", &now_millis()) .await { tracing::error!("更新工作流完成时间失败: {}", e); } // F-260616-06 ②-3 / ②-4: 工作流联动任务推进回调 // - 成功(completed): task_id 与 target_status 都 Some 时推进到 target_status // - 失败(failed): 按 regression_target 推算退回态推进;无映射则跳过 // task_id / target_status 任一 None → 跳过(向后兼容,旧工作流不联动任务) // 回调失败仅 tracing::warn!,不回滚(工作流成功语义与任务推进解耦 — 回调失败不撤销 // 已完成工作流,前端可后续手动处理) match (cb_task_id.as_ref(), cb_target_status.as_deref()) { (Some(tid), Some(target)) => { let advance_target = match status { "completed" => Some(target), "failed" => regression_target(target), _ => None, }; if let Some(to) = advance_target { let repo = TaskRepo::new(&db); if let Err(e) = advance_task_atomic(&repo, tid, to).await { tracing::warn!( execution_id = %exec_id, task_id = %tid, target_status = %to, workflow_status = %status, "工作流联动推进任务失败(不回滚): {}", e ); } else { tracing::info!( execution_id = %exec_id, task_id = %tid, target_status = %to, workflow_status = %status, "工作流联动推进任务成功" ); } } else { // failed 且无退回映射:warn 跳过(常见:target 是 todo 起点无可退态) tracing::warn!( execution_id = %exec_id, task_id = %tid, target_status = %target, "工作流失败但 target_status 无退回映射,跳过任务回调" ); } } _ => {} } // 执行结束(成功/失败)清理状态注册表,防内存泄漏 state_registry.lock().await.remove(&exec_id); // 执行失败时补发 WorkflowFailed(执行器内部只发 NodeFailed) // failed_node 从状态机取首个失败/取消节点(R9⑪: 原 String::new() 空值) if let Some(error) = error { let failed_node = executor .state_machine() .snapshot() .into_iter() .find(|(_, s)| matches!(s, NodeStatus::Failed | NodeStatus::Cancelled)) .map(|(id, _)| id) .unwrap_or_default(); event_bus .send(WorkflowEvent::WorkflowFailed { execution_id: exec_id.clone(), error, failed_node, }) .await; } }); Ok(execution_id) } /// 列出全部工作流执行记录 #[tauri::command] pub async fn list_workflow_executions( state: State<'_, AppState>, ) -> Result, String> { state.workflows.list_all().await.map_err(err_str) } /// 按 ID 查询工作流执行记录 #[tauri::command] pub async fn get_workflow_execution( state: State<'_, AppState>, id: String, ) -> Result, String> { state .workflows .get_by_id(&id) .await .map_err(err_str) } /// 发送人工审批响应 /// /// F-260615-01: 支持 single/multiple。 /// - single: decision 单值,decisions 为空(向后兼容); /// - multiple: decisions 数组(≥1 项),decision 留空。 /// IPC 早校验规则与 HumanNode 下游兜底一致,避免「IPC 成功+工作流失败」割裂。 #[tauri::command] pub async fn approve_human_approval( app: AppHandle, state: State<'_, AppState>, execution_id: String, node_id: String, decision: String, // F-260615-01: 多选结果数组,缺省空数组(单选调用方不传) decisions: Option>, comment: Option, // R-PD-5: options 由前端从收到的 HumanApprovalRequest 事件透传(IPC 无法访问节点 config)。 options: Vec, // F-260615-01: 选择类型,缺省 single select_type: Option, ) -> Result<(), String> { let _ = &app; // 原签名含 app 参数(未使用),保留避免 invoke_handler 注册签名变化 let select_type = match select_type.as_deref() { Some("multiple") => SelectType::Multiple, _ => SelectType::Single, }; let decisions = decisions.unwrap_or_default(); // 归一化决策集合(与 HumanNode 下游一致) let mut picked: Vec = decisions; if picked.is_empty() && !decision.is_empty() { picked.push(decision.clone()); } // 数量校验 match select_type { SelectType::Single if picked.len() != 1 => { return Err("单选审批只能提交一个决策".to_string()); } SelectType::Multiple if picked.is_empty() => { return Err("多选审批至少提交一个决策".to_string()); } _ => {} } // 每项非空 + ∈options(options 非空时) for d in &picked { if d.trim().is_empty() { return Err("审批决策不能为空".to_string()); } if !options.is_empty() && !options.contains(d) { return Err(format!("审批决策非法: {}", d)); } } // 发送审批响应到事件总线(decision 取首项保留,decisions 透传全量) let primary = picked.first().cloned().unwrap_or_default(); let response = HumanApprovalResponse { execution_id: execution_id.clone(), node_id: node_id.clone(), decision: primary, decisions: picked, comment, }; let event = WorkflowEvent::HumanApprovalResponse { execution_id: response.execution_id, node_id: response.node_id, decision: response.decision, decisions: response.decisions, comment: response.comment, }; // 直接发送到全局事件总线 state.event_bus.send(event).await; Ok(()) } /// 取消工作流节点(人工审批取消) /// /// 从执行器状态机注册表取出共享引用,调 `set_cancelled` 置目标节点为 Cancelled。 /// StateMachine 内部 Arc 共享底层 HashMap,IPC 写入直达运行中阻塞节点(HumanNode) /// 的 select! 轮询分支,其 is_cancelled 检测到后返回 Err "人工审批被取消"。 /// /// 终态前置守卫:仅 Pending(排队中)/ Running(执行中,含阻塞等审批)允许取消; /// 终态节点(Completed/Failed/Skipped/Cancelled)返 Err,避免静默覆盖终态。 /// 守卫放 IPC 层(非 set_cancelled 内):保留 set_cancelled 作为 executor 内部受控旁路语义, /// 让状态变更的合法入口收敛到 IPC 这一道。 #[tauri::command] pub async fn cancel_workflow_node( state: State<'_, AppState>, execution_id: String, node_id: String, ) -> Result<(), String> { // 经 execution_id 取执行器状态机(与 NodeContext.node_status 共享同一 HashMap) let registry = state.workflow_state_registry.lock().await; let sm = match registry.get(&execution_id) { Some(sm) => sm, None => return Err(format!("工作流 {} 不存在或已结束", execution_id)), }; // 终态守卫:get() 返回缺失条目默认 Pending(尚未执行)→ 允许取消 let current = sm.get(&node_id); match current { NodeStatus::Pending | NodeStatus::Running | NodeStatus::Waiting => { sm.set_cancelled(node_id.clone()); tracing::info!( "工作流节点取消:execution_id={}, node_id={}", execution_id, node_id ); Ok(()) } // 终态:不允许取消(避免静默覆盖终态) NodeStatus::Completed | NodeStatus::Failed | NodeStatus::Skipped | NodeStatus::Cancelled => { Err(format!( "节点 {} 已终态({}),无法取消", node_id, current.as_str() )) } } }