[WIP] workflow builder

This commit is contained in:
2026-02-23 20:45:10 -06:00
parent d629da32fa
commit 53a3fbb6b1
66 changed files with 7887 additions and 1608 deletions

View File

@@ -3,6 +3,12 @@
//! This module builds executable task graphs from workflow definitions.
//! Workflows are directed graphs where tasks are nodes and transitions are edges.
//! Execution follows transitions from completed tasks, naturally supporting cycles.
//!
//! Uses the Orquesta-style `next` transition model where each task has an ordered
//! list of transitions. Each transition can specify:
//! - `when` — a condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}")
//! - `publish` — variables to publish into the workflow context
//! - `do` — next tasks to invoke when the condition is met
use attune_common::workflow::{Task, TaskType, WorkflowDefinition};
use std::collections::{HashMap, HashSet};
@@ -51,7 +57,7 @@ pub struct TaskNode {
/// Input template
pub input: serde_json::Value,
/// Conditional execution
/// Conditional execution (task-level — controls whether the task runs at all)
pub when: Option<String>,
/// With-items iteration
@@ -63,17 +69,14 @@ pub struct TaskNode {
/// Concurrency limit
pub concurrency: Option<usize>,
/// Variable publishing directives
pub publish: Vec<String>,
/// Retry configuration
pub retry: Option<RetryConfig>,
/// Timeout in seconds
pub timeout: Option<u32>,
/// Transitions
pub transitions: TaskTransitions,
/// Orquesta-style transitions — evaluated in order after task completes
pub transitions: Vec<GraphTransition>,
/// Sub-tasks (for parallel tasks)
pub sub_tasks: Option<Vec<TaskNode>>,
@@ -85,22 +88,27 @@ pub struct TaskNode {
pub join: Option<usize>,
}
/// Task transitions
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct TaskTransitions {
pub on_success: Option<String>,
pub on_failure: Option<String>,
pub on_complete: Option<String>,
pub on_timeout: Option<String>,
pub decision: Vec<DecisionBranch>,
/// A single transition in the task graph (Orquesta-style).
///
/// Transitions are evaluated in order after a task completes. When `when` is
/// `None` the transition is unconditional.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GraphTransition {
/// Condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}")
pub when: Option<String>,
/// Variable publishing directives (key-value pairs)
pub publish: Vec<PublishVar>,
/// Next tasks to invoke when transition criteria is met
pub do_tasks: Vec<String>,
}
/// Decision branch
/// A single publish variable (key = expression)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DecisionBranch {
pub when: Option<String>,
pub next: String,
pub default: bool,
pub struct PublishVar {
pub name: String,
pub expression: String,
}
/// Retry configuration
@@ -121,8 +129,56 @@ pub enum BackoffStrategy {
Exponential,
}
// ---------------------------------------------------------------------------
// Transition classification helpers
// ---------------------------------------------------------------------------
/// Classify a `when` expression for quick matching.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransitionKind {
/// Matches `succeeded()` expressions
Succeeded,
/// Matches `failed()` expressions
Failed,
/// Matches `timed_out()` expressions
TimedOut,
/// No condition — fires on any completion
Always,
/// Custom condition expression
Custom,
}
impl GraphTransition {
/// Classify this transition's `when` expression into a [`TransitionKind`].
pub fn kind(&self) -> TransitionKind {
match &self.when {
None => TransitionKind::Always,
Some(expr) => {
let normalized = expr.to_lowercase().replace(|c: char| c.is_whitespace(), "");
if normalized.contains("succeeded()") {
TransitionKind::Succeeded
} else if normalized.contains("failed()") {
TransitionKind::Failed
} else if normalized.contains("timed_out()") {
TransitionKind::TimedOut
} else {
TransitionKind::Custom
}
}
}
}
}
// ---------------------------------------------------------------------------
// TaskGraph implementation
// ---------------------------------------------------------------------------
impl TaskGraph {
/// Create a graph from a workflow definition
/// Create a graph from a workflow definition.
///
/// The workflow's tasks should already have their transitions normalized
/// (legacy `on_success`/`on_failure` fields merged into `next`) — this is
/// done automatically by [`attune_common::workflow::parse_workflow_yaml`].
pub fn from_workflow(workflow: &WorkflowDefinition) -> GraphResult<Self> {
let mut builder = GraphBuilder::new();
@@ -149,40 +205,93 @@ impl TaskGraph {
}
/// Get the next tasks to execute after a task completes.
/// Evaluates transitions based on task status.
///
/// Evaluates transitions in order based on the task's completion status.
/// A transition fires if its `when` condition matches the task status:
/// - `succeeded()` fires when `success == true`
/// - `failed()` fires when `success == false`
/// - No condition (always) fires regardless
/// - Custom conditions are included (actual expression evaluation
/// happens in the workflow coordinator with runtime context)
///
/// Multiple transitions can fire — they are independent of each other.
///
/// # Arguments
/// * `task_name` - The name of the task that completed
/// * `success` - Whether the task succeeded
///
/// # Returns
/// A vector of task names to schedule next
/// A vector of (task_name, publish_vars) tuples to schedule next
pub fn next_tasks(&self, task_name: &str, success: bool) -> Vec<String> {
let mut next = Vec::new();
if let Some(node) = self.nodes.get(task_name) {
// Check explicit transitions based on task status
if success {
if let Some(ref next_task) = node.transitions.on_success {
next.push(next_task.clone());
for transition in &node.transitions {
let should_fire = match transition.kind() {
TransitionKind::Succeeded => success,
TransitionKind::Failed => !success,
TransitionKind::TimedOut => !success, // timeout is a form of failure
TransitionKind::Always => true,
TransitionKind::Custom => true, // include custom — real eval in coordinator
};
if should_fire {
for target in &transition.do_tasks {
if !next.contains(target) {
next.push(target.clone());
}
}
}
} else if let Some(ref next_task) = node.transitions.on_failure {
next.push(next_task.clone());
}
// on_complete runs regardless of success/failure
if let Some(ref next_task) = node.transitions.on_complete {
next.push(next_task.clone());
}
// Decision branches (evaluated separately in coordinator with context)
// We don't evaluate them here since they need runtime context
}
next
}
/// Get the next tasks with full transition information.
///
/// Returns matching transitions with their publish directives and targets,
/// giving the coordinator full context for variable publishing.
pub fn matching_transitions(&self, task_name: &str, success: bool) -> Vec<&GraphTransition> {
let mut matching = Vec::new();
if let Some(node) = self.nodes.get(task_name) {
for transition in &node.transitions {
let should_fire = match transition.kind() {
TransitionKind::Succeeded => success,
TransitionKind::Failed => !success,
TransitionKind::TimedOut => !success,
TransitionKind::Always => true,
TransitionKind::Custom => true,
};
if should_fire {
matching.push(transition);
}
}
}
matching
}
/// Collect all unique target task names from all transitions of a given task.
pub fn all_transition_targets(&self, task_name: &str) -> HashSet<String> {
let mut targets = HashSet::new();
if let Some(node) = self.nodes.get(task_name) {
for transition in &node.transitions {
for target in &transition.do_tasks {
targets.insert(target.clone());
}
}
}
targets
}
}
// ---------------------------------------------------------------------------
// Graph builder
// ---------------------------------------------------------------------------
/// Graph builder helper
struct GraphBuilder {
nodes: HashMap<String, TaskNode>,
@@ -198,14 +307,12 @@ impl GraphBuilder {
}
fn add_task(&mut self, task: &Task) -> GraphResult<()> {
let node = self.task_to_node(task)?;
let node = Self::task_to_node(task)?;
self.nodes.insert(task.name.clone(), node);
Ok(())
}
fn task_to_node(&self, task: &Task) -> GraphResult<TaskNode> {
let publish = extract_publish_vars(&task.publish);
fn task_to_node(task: &Task) -> GraphResult<TaskNode> {
let retry = task.retry.as_ref().map(|r| RetryConfig {
count: r.count,
delay: r.delay,
@@ -220,26 +327,21 @@ impl GraphBuilder {
on_error: r.on_error.clone(),
});
let transitions = TaskTransitions {
on_success: task.on_success.clone(),
on_failure: task.on_failure.clone(),
on_complete: task.on_complete.clone(),
on_timeout: task.on_timeout.clone(),
decision: task
.decision
.iter()
.map(|d| DecisionBranch {
when: d.when.clone(),
next: d.next.clone(),
default: d.default,
})
.collect(),
};
// Convert parser TaskTransition list → graph GraphTransition list
let transitions: Vec<GraphTransition> = task
.next
.iter()
.map(|t| GraphTransition {
when: t.when.clone(),
publish: extract_publish_vars(&t.publish),
do_tasks: t.r#do.clone().unwrap_or_default(),
})
.collect();
let sub_tasks = if let Some(ref tasks) = task.tasks {
let mut sub_nodes = Vec::new();
for subtask in tasks {
sub_nodes.push(self.task_to_node(subtask)?);
sub_nodes.push(Self::task_to_node(subtask)?);
}
Some(sub_nodes)
} else {
@@ -255,7 +357,6 @@ impl GraphBuilder {
with_items: task.with_items.clone(),
batch_size: task.batch_size,
concurrency: task.concurrency,
publish,
retry,
timeout: task.timeout,
transitions,
@@ -268,7 +369,6 @@ impl GraphBuilder {
fn build(mut self) -> GraphResult<Self> {
// Compute inbound edges from transitions
self.compute_inbound_edges()?;
Ok(self)
}
@@ -276,44 +376,27 @@ impl GraphBuilder {
let node_names: Vec<String> = self.nodes.keys().cloned().collect();
for node_name in &node_names {
if let Some(node) = self.nodes.get(node_name) {
// Collect all tasks this task can transition to
let successors = vec![
node.transitions.on_success.as_ref(),
node.transitions.on_failure.as_ref(),
node.transitions.on_complete.as_ref(),
node.transitions.on_timeout.as_ref(),
];
// Collect all successor task names from this node's transitions
let successors: Vec<String> = {
let node = self.nodes.get(node_name).unwrap();
node.transitions
.iter()
.flat_map(|t| t.do_tasks.iter().cloned())
.collect()
};
// For each successor, record this task as an inbound edge
for successor in successors.into_iter().flatten() {
if !self.nodes.contains_key(successor) {
return Err(GraphError::InvalidTaskReference(format!(
"Task '{}' references non-existent task '{}'",
node_name, successor
)));
}
self.inbound_edges
.entry(successor.clone())
.or_insert_with(HashSet::new)
.insert(node_name.clone());
for successor in &successors {
if !self.nodes.contains_key(successor) {
return Err(GraphError::InvalidTaskReference(format!(
"Task '{}' references non-existent task '{}'",
node_name, successor
)));
}
// Add decision branch edges
for branch in &node.transitions.decision {
if !self.nodes.contains_key(&branch.next) {
return Err(GraphError::InvalidTaskReference(format!(
"Task '{}' decision references non-existent task '{}'",
node_name, branch.next
)));
}
self.inbound_edges
.entry(branch.next.clone())
.or_insert_with(HashSet::new)
.insert(node_name.clone());
}
self.inbound_edges
.entry(successor.clone())
.or_default()
.insert(node_name.clone());
}
}
@@ -350,7 +433,7 @@ impl From<GraphBuilder> for TaskGraph {
for source in inbound {
outbound_edges
.entry(source.clone())
.or_insert_with(HashSet::new)
.or_default()
.insert(task.clone());
}
}
@@ -364,24 +447,40 @@ impl From<GraphBuilder> for TaskGraph {
}
}
/// Extract variable names from publish directives
fn extract_publish_vars(publish: &[attune_common::workflow::PublishDirective]) -> Vec<String> {
// ---------------------------------------------------------------------------
// Publish variable extraction
// ---------------------------------------------------------------------------
/// Extract publish variable names and expressions from parser publish directives.
fn extract_publish_vars(publish: &[attune_common::workflow::PublishDirective]) -> Vec<PublishVar> {
use attune_common::workflow::PublishDirective;
let mut vars = Vec::new();
for directive in publish {
match directive {
PublishDirective::Simple(map) => {
vars.extend(map.keys().cloned());
for (key, value) in map {
vars.push(PublishVar {
name: key.clone(),
expression: value.clone(),
});
}
}
PublishDirective::Key(key) => {
vars.push(key.clone());
vars.push(PublishVar {
name: key.clone(),
expression: "{{ result() }}".to_string(),
});
}
}
}
vars
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
@@ -396,10 +495,16 @@ version: 1.0.0
tasks:
- name: task1
action: core.echo
on_success: task2
next:
- when: "{{ succeeded() }}"
do:
- task2
- name: task2
action: core.echo
on_success: task3
next:
- when: "{{ succeeded() }}"
do:
- task3
- name: task3
action: core.echo
"#;
@@ -422,7 +527,7 @@ tasks:
assert_eq!(graph.inbound_edges["task3"].len(), 1);
assert!(graph.inbound_edges["task3"].contains("task2"));
// Check transitions
// Check transitions via next_tasks
let next = graph.next_tasks("task1", true);
assert_eq!(next.len(), 1);
assert_eq!(next[0], "task2");
@@ -433,40 +538,11 @@ tasks:
}
#[test]
fn test_parallel_entry_points() {
fn test_simple_sequential_graph_legacy() {
// Legacy format should still work (parser normalizes to `next`)
let yaml = r#"
ref: test.parallel_start
label: Parallel Start
version: 1.0.0
tasks:
- name: task1
action: core.echo
on_success: final
- name: task2
action: core.echo
on_success: final
- name: final
action: core.complete
"#;
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
let graph = TaskGraph::from_workflow(&workflow).unwrap();
assert_eq!(graph.entry_points.len(), 2);
assert!(graph.entry_points.contains(&"task1".to_string()));
assert!(graph.entry_points.contains(&"task2".to_string()));
// final task should have both as inbound edges
assert_eq!(graph.inbound_edges["final"].len(), 2);
assert!(graph.inbound_edges["final"].contains("task1"));
assert!(graph.inbound_edges["final"].contains("task2"));
}
#[test]
fn test_transitions() {
let yaml = r#"
ref: test.transitions
label: Transition Test
ref: test.sequential_legacy
label: Sequential Workflow (Legacy)
version: 1.0.0
tasks:
- name: task1
@@ -482,18 +558,155 @@ tasks:
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
let graph = TaskGraph::from_workflow(&workflow).unwrap();
// Test next_tasks follows transitions
assert_eq!(graph.nodes.len(), 3);
assert_eq!(graph.entry_points.len(), 1);
let next = graph.next_tasks("task1", true);
assert_eq!(next, vec!["task2"]);
let next = graph.next_tasks("task2", true);
assert_eq!(next, vec!["task3"]);
}
// task3 has no transitions
let next = graph.next_tasks("task3", true);
#[test]
fn test_parallel_entry_points() {
let yaml = r#"
ref: test.parallel_start
label: Parallel Start
version: 1.0.0
tasks:
- name: task1
action: core.echo
next:
- when: "{{ succeeded() }}"
do:
- final_task
- name: task2
action: core.echo
next:
- when: "{{ succeeded() }}"
do:
- final_task
- name: final_task
action: core.complete
"#;
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
let graph = TaskGraph::from_workflow(&workflow).unwrap();
assert_eq!(graph.entry_points.len(), 2);
assert!(graph.entry_points.contains(&"task1".to_string()));
assert!(graph.entry_points.contains(&"task2".to_string()));
// final_task should have both as inbound edges
assert_eq!(graph.inbound_edges["final_task"].len(), 2);
assert!(graph.inbound_edges["final_task"].contains("task1"));
assert!(graph.inbound_edges["final_task"].contains("task2"));
}
#[test]
fn test_transitions_success_and_failure() {
let yaml = r#"
ref: test.transitions
label: Transition Test
version: 1.0.0
tasks:
- name: task1
action: core.echo
next:
- when: "{{ succeeded() }}"
do:
- task2
- when: "{{ failed() }}"
do:
- error_handler
- name: task2
action: core.echo
- name: error_handler
action: core.handle_error
"#;
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
let graph = TaskGraph::from_workflow(&workflow).unwrap();
// On success, should go to task2
let next = graph.next_tasks("task1", true);
assert_eq!(next, vec!["task2"]);
// On failure, should go to error_handler
let next = graph.next_tasks("task1", false);
assert_eq!(next, vec!["error_handler"]);
// task2 has no transitions
let next = graph.next_tasks("task2", true);
assert!(next.is_empty());
}
#[test]
fn test_multiple_do_targets() {
let yaml = r#"
ref: test.multi_do
label: Multi Do Targets
version: 1.0.0
tasks:
- name: task1
action: core.echo
next:
- when: "{{ succeeded() }}"
publish:
- msg: "task1 done"
do:
- log
- task2
- name: task2
action: core.echo
- name: log
action: core.log
"#;
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
let graph = TaskGraph::from_workflow(&workflow).unwrap();
let next = graph.next_tasks("task1", true);
assert_eq!(next.len(), 2);
assert!(next.contains(&"log".to_string()));
assert!(next.contains(&"task2".to_string()));
// Check publish vars
let transitions = graph.matching_transitions("task1", true);
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].publish.len(), 1);
assert_eq!(transitions[0].publish[0].name, "msg");
assert_eq!(transitions[0].publish[0].expression, "task1 done");
}
#[test]
fn test_unconditional_transition() {
let yaml = r#"
ref: test.unconditional
label: Unconditional
version: 1.0.0
tasks:
- name: task1
action: core.echo
next:
- do:
- task2
- name: task2
action: core.echo
"#;
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
let graph = TaskGraph::from_workflow(&workflow).unwrap();
// Unconditional fires on both success and failure
let next = graph.next_tasks("task1", true);
assert_eq!(next, vec!["task2"]);
let next = graph.next_tasks("task1", false);
assert_eq!(next, vec!["task2"]);
}
#[test]
fn test_cycle_support() {
let yaml = r#"
@@ -503,8 +716,13 @@ version: 1.0.0
tasks:
- name: check
action: core.check
on_success: process
on_failure: check
next:
- when: "{{ succeeded() }}"
do:
- process
- when: "{{ failed() }}"
do:
- check
- name: process
action: core.process
"#;
@@ -513,13 +731,12 @@ tasks:
// Should not error on cycles
let graph = TaskGraph::from_workflow(&workflow).unwrap();
// Note: check has a self-reference (check -> check on failure)
// check has a self-reference (check -> check on failure)
// So it has an inbound edge and is not an entry point
// process also has an inbound edge (check -> process on success)
// Therefore, there are no entry points in this workflow
assert_eq!(graph.entry_points.len(), 0);
// check can transition to itself on failure (cycle)
// check transitions to itself on failure (cycle)
let next = graph.next_tasks("check", false);
assert_eq!(next, vec!["check"]);
@@ -537,18 +754,24 @@ version: 1.0.0
tasks:
- name: task1
action: core.echo
on_success: final
next:
- when: "{{ succeeded() }}"
do:
- final_task
- name: task2
action: core.echo
on_success: final
- name: final
next:
- when: "{{ succeeded() }}"
do:
- final_task
- name: final_task
action: core.complete
"#;
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
let graph = TaskGraph::from_workflow(&workflow).unwrap();
let inbound = graph.get_inbound_tasks("final");
let inbound = graph.get_inbound_tasks("final_task");
assert_eq!(inbound.len(), 2);
assert!(inbound.contains(&"task1".to_string()));
assert!(inbound.contains(&"task2".to_string()));
@@ -556,4 +779,156 @@ tasks:
let inbound = graph.get_inbound_tasks("task1");
assert_eq!(inbound.len(), 0);
}
#[test]
fn test_transition_kind_classification() {
let succeeded = GraphTransition {
when: Some("{{ succeeded() }}".to_string()),
publish: vec![],
do_tasks: vec!["t".to_string()],
};
assert_eq!(succeeded.kind(), TransitionKind::Succeeded);
let failed = GraphTransition {
when: Some("{{ failed() }}".to_string()),
publish: vec![],
do_tasks: vec!["t".to_string()],
};
assert_eq!(failed.kind(), TransitionKind::Failed);
let timed_out = GraphTransition {
when: Some("{{ timed_out() }}".to_string()),
publish: vec![],
do_tasks: vec!["t".to_string()],
};
assert_eq!(timed_out.kind(), TransitionKind::TimedOut);
let always = GraphTransition {
when: None,
publish: vec![],
do_tasks: vec!["t".to_string()],
};
assert_eq!(always.kind(), TransitionKind::Always);
let custom = GraphTransition {
when: Some("{{ result().status == 'ok' }}".to_string()),
publish: vec![],
do_tasks: vec!["t".to_string()],
};
assert_eq!(custom.kind(), TransitionKind::Custom);
}
#[test]
fn test_publish_extraction() {
let yaml = r#"
ref: test.publish
label: Publish Test
version: 1.0.0
tasks:
- name: task1
action: core.echo
next:
- when: "{{ succeeded() }}"
publish:
- result_val: "{{ result() }}"
- msg: "done"
do:
- task2
- name: task2
action: core.echo
"#;
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
let graph = TaskGraph::from_workflow(&workflow).unwrap();
let task1 = graph.get_task("task1").unwrap();
assert_eq!(task1.transitions.len(), 1);
assert_eq!(task1.transitions[0].publish.len(), 2);
// Note: HashMap ordering is not guaranteed, so just check both exist
let publish_names: Vec<&str> = task1.transitions[0]
.publish
.iter()
.map(|p| p.name.as_str())
.collect();
assert!(publish_names.contains(&"result_val"));
assert!(publish_names.contains(&"msg"));
}
#[test]
fn test_all_transition_targets() {
let yaml = r#"
ref: test.all_targets
label: All Targets Test
version: 1.0.0
tasks:
- name: task1
action: core.echo
next:
- when: "{{ succeeded() }}"
do:
- task2
- task3
- when: "{{ failed() }}"
do:
- error_handler
- name: task2
action: core.echo
- name: task3
action: core.echo
- name: error_handler
action: core.handle_error
"#;
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
let graph = TaskGraph::from_workflow(&workflow).unwrap();
let targets = graph.all_transition_targets("task1");
assert_eq!(targets.len(), 3);
assert!(targets.contains("task2"));
assert!(targets.contains("task3"));
assert!(targets.contains("error_handler"));
}
#[test]
fn test_mixed_success_failure_and_always() {
let yaml = r#"
ref: test.mixed
label: Mixed Transitions
version: 1.0.0
tasks:
- name: task1
action: core.echo
next:
- when: "{{ succeeded() }}"
do:
- success_task
- when: "{{ failed() }}"
do:
- failure_task
- do:
- always_task
- name: success_task
action: core.echo
- name: failure_task
action: core.echo
- name: always_task
action: core.echo
"#;
let workflow = workflow::parse_workflow_yaml(yaml).unwrap();
let graph = TaskGraph::from_workflow(&workflow).unwrap();
// On success: succeeded + always fire
let next = graph.next_tasks("task1", true);
assert_eq!(next.len(), 2);
assert!(next.contains(&"success_task".to_string()));
assert!(next.contains(&"always_task".to_string()));
// On failure: failed + always fire
let next = graph.next_tasks("task1", false);
assert_eq!(next.len(), 2);
assert!(next.contains(&"failure_task".to_string()));
assert!(next.contains(&"always_task".to_string()));
}
}

View File

@@ -53,7 +53,7 @@ pub use coordinator::{
WorkflowCoordinator, WorkflowExecutionHandle, WorkflowExecutionResult, WorkflowExecutionState,
WorkflowExecutionStatus,
};
pub use graph::{GraphError, GraphResult, TaskGraph, TaskNode, TaskTransitions};
pub use graph::{GraphError, GraphResult, GraphTransition, TaskGraph, TaskNode};
pub use task_executor::{
TaskExecutionError, TaskExecutionResult, TaskExecutionStatus, TaskExecutor,
};

View File

@@ -132,10 +132,22 @@ impl TaskExecutor {
if let Some(ref output) = result.output {
context.set_task_result(&task.name, output.clone());
// Publish variables
if !task.publish.is_empty() {
if let Err(e) = context.publish_from_result(output, &task.publish, None) {
warn!("Failed to publish variables for task {}: {}", task.name, e);
// Publish variables from matching transitions
let success = matches!(result.status, TaskExecutionStatus::Success);
for transition in &task.transitions {
let should_fire = match transition.kind() {
super::graph::TransitionKind::Succeeded => success,
super::graph::TransitionKind::Failed => !success,
super::graph::TransitionKind::TimedOut => !success,
super::graph::TransitionKind::Always => true,
super::graph::TransitionKind::Custom => true,
};
if should_fire && !transition.publish.is_empty() {
let var_names: Vec<String> =
transition.publish.iter().map(|p| p.name.clone()).collect();
if let Err(e) = context.publish_from_result(output, &var_names, None) {
warn!("Failed to publish variables for task {}: {}", task.name, e);
}
}
}
}