Skip to main content

oximedia_workflow/
executor.rs

1//! Workflow execution engine.
2//!
3//! # Parallel Fan-out / Fan-in
4//!
5//! [`WorkflowExecutor::execute`] automatically discovers *independent* groups
6//! of tasks — tasks whose dependencies are all satisfied at the same time —
7//! and launches them concurrently. When all tasks in a fan-out group complete
8//! the join point (fan-in) tasks are unblocked. This happens naturally through
9//! the dependency-tracking loop; no explicit graph analysis is required.
10//!
11//! # Pause / Resume
12//!
13//! Call [`WorkflowExecutor::pause`] to signal the executor to stop launching
14//! new tasks after the current wave completes. The checkpoint is serialised to
15//! a JSON string containing the completed task IDs and execution context
16//! variables. Pass that string to [`WorkflowExecutor::resume_from_checkpoint`]
17//! to reconstruct the state and continue.
18
19use crate::error::{Result, WorkflowError};
20use crate::task::{Task, TaskId, TaskResult, TaskState};
21use crate::workflow::{Workflow, WorkflowId, WorkflowState};
22use async_trait::async_trait;
23use dashmap::DashMap;
24use serde::{Deserialize, Serialize};
25use std::collections::{HashMap, HashSet};
26use std::sync::Arc;
27use std::time::{Duration, Instant};
28use tokio::sync::{mpsc, watch, RwLock, Semaphore};
29use tokio::time::timeout;
30use tracing::{debug, info, warn};
31
32/// Task executor trait.
33#[async_trait]
34pub trait TaskExecutor: Send + Sync {
35    /// Execute a task and return the result.
36    async fn execute(&self, task: &Task) -> Result<TaskResult>;
37}
38
39// ---------------------------------------------------------------------------
40// Pause / Resume support
41// ---------------------------------------------------------------------------
42
43/// Serializable checkpoint for pause/resume of workflow execution.
44///
45/// This struct captures the minimal state needed to resume a paused workflow:
46/// the set of already-completed task IDs and the current execution context
47/// variables.  It can be persisted to disk, a database, or transferred over
48/// the network, then passed back to
49/// [`WorkflowExecutor::resume_from_checkpoint`].
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ExecutionCheckpoint {
52    /// Workflow identifier.
53    pub workflow_id: WorkflowId,
54    /// Task IDs that had completed at the time the checkpoint was taken.
55    pub completed_task_ids: Vec<TaskId>,
56    /// Execution context variables at checkpoint time.
57    pub variables: HashMap<String, serde_json::Value>,
58    /// Unix timestamp (seconds) when the checkpoint was captured.
59    pub timestamp_secs: u64,
60}
61
62impl ExecutionCheckpoint {
63    /// Serialize the checkpoint to a compact JSON string.
64    ///
65    /// # Errors
66    ///
67    /// Returns a `WorkflowError` if JSON serialization fails.
68    pub fn to_json(&self) -> Result<String> {
69        serde_json::to_string(self).map_err(WorkflowError::Serialization)
70    }
71
72    /// Deserialize a checkpoint from a JSON string.
73    ///
74    /// # Errors
75    ///
76    /// Returns a `WorkflowError` if JSON parsing fails.
77    pub fn from_json(json: &str) -> Result<Self> {
78        serde_json::from_str(json).map_err(WorkflowError::Serialization)
79    }
80}
81
82/// Execution context shared across task executions.
83#[derive(Debug, Clone)]
84pub struct ExecutionContext {
85    /// Workflow ID.
86    pub workflow_id: WorkflowId,
87    /// Workflow variables.
88    pub variables: Arc<DashMap<String, serde_json::Value>>,
89    /// Task results cache.
90    pub results: Arc<DashMap<TaskId, TaskResult>>,
91}
92
93impl ExecutionContext {
94    /// Create a new execution context.
95    #[must_use]
96    pub fn new(workflow_id: WorkflowId) -> Self {
97        Self {
98            workflow_id,
99            variables: Arc::new(DashMap::new()),
100            results: Arc::new(DashMap::new()),
101        }
102    }
103
104    /// Get variable value.
105    #[must_use]
106    pub fn get_variable(&self, key: &str) -> Option<serde_json::Value> {
107        self.variables.get(key).map(|v| v.clone())
108    }
109
110    /// Set variable value.
111    pub fn set_variable(&self, key: String, value: serde_json::Value) {
112        self.variables.insert(key, value);
113    }
114
115    /// Get task result.
116    #[must_use]
117    pub fn get_result(&self, task_id: &TaskId) -> Option<TaskResult> {
118        self.results.get(task_id).map(|r| r.clone())
119    }
120
121    /// Store task result.
122    pub fn store_result(&self, task_id: TaskId, result: TaskResult) {
123        self.results.insert(task_id, result);
124    }
125}
126
127// ---------------------------------------------------------------------------
128// WorkflowControl
129// ---------------------------------------------------------------------------
130
131/// High-level control signal for a running [`WorkflowExecutor`].
132///
133/// Sent via [`WorkflowExecutor::send_control`].
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum WorkflowControl {
136    /// Pause after current in-flight tasks complete.
137    ///
138    /// The executor serialises a checkpoint and returns with
139    /// [`WorkflowState::Paused`].
140    Pause,
141    /// Resume a previously paused executor.
142    Resume,
143    /// Request immediate cancellation of the workflow.
144    ///
145    /// In-flight tasks are allowed to finish; then the executor returns an
146    /// error with `"cancelled"`.
147    Cancel,
148}
149
150/// Workflow executor.
151pub struct WorkflowExecutor {
152    /// Task executor implementation.
153    task_executor: Arc<dyn TaskExecutor>,
154    /// Maximum concurrent tasks.
155    max_concurrent: usize,
156    /// Execution timeout.
157    timeout: Option<Duration>,
158    /// Pause signal sender.  When `true` is sent the executor stops launching
159    /// new tasks after the current in-flight set completes.
160    pause_tx: watch::Sender<bool>,
161    /// Pause signal receiver (cloned into each execution).
162    pause_rx: watch::Receiver<bool>,
163    /// Cancel signal sender.  When `true` is sent the executor aborts after
164    /// the current wave finishes.
165    cancel_tx: watch::Sender<bool>,
166    /// Cancel signal receiver (cloned into each execution).
167    cancel_rx: watch::Receiver<bool>,
168    /// Completed tasks from a prior checkpoint (used for resume).
169    resume_completed: HashSet<TaskId>,
170    /// Variables from a prior checkpoint (used for resume).
171    resume_variables: HashMap<String, serde_json::Value>,
172}
173
174impl WorkflowExecutor {
175    /// Create a new workflow executor.
176    #[must_use]
177    pub fn new(task_executor: Arc<dyn TaskExecutor>) -> Self {
178        let (pause_tx, pause_rx) = watch::channel(false);
179        let (cancel_tx, cancel_rx) = watch::channel(false);
180        Self {
181            task_executor,
182            max_concurrent: 4,
183            timeout: None,
184            pause_tx,
185            pause_rx,
186            cancel_tx,
187            cancel_rx,
188            resume_completed: HashSet::new(),
189            resume_variables: HashMap::new(),
190        }
191    }
192
193    /// Set maximum concurrent tasks.
194    #[must_use]
195    pub fn with_max_concurrent(mut self, max_concurrent: usize) -> Self {
196        self.max_concurrent = max_concurrent;
197        self
198    }
199
200    /// Set execution timeout.
201    #[must_use]
202    pub fn with_timeout(mut self, timeout: Duration) -> Self {
203        self.timeout = Some(timeout);
204        self
205    }
206
207    /// Signal the executor to pause after the current in-flight tasks finish.
208    ///
209    /// The executor stops launching new tasks as soon as it receives the
210    /// signal, but any already-running tasks are allowed to complete.
211    pub fn pause(&self) {
212        if let Err(e) = self.pause_tx.send(true) {
213            warn!("Failed to send pause signal: {e}");
214        }
215    }
216
217    /// Resume a previously paused executor (un-pause).
218    pub fn resume(&self) {
219        if let Err(e) = self.pause_tx.send(false) {
220            warn!("Failed to send resume signal: {e}");
221        }
222    }
223
224    /// Send a [`WorkflowControl`] signal to the executor.
225    ///
226    /// - [`WorkflowControl::Pause`]  — equivalent to [`Self::pause`].
227    /// - [`WorkflowControl::Resume`] — equivalent to [`Self::resume`].
228    /// - [`WorkflowControl::Cancel`] — signals the executor to abort after the
229    ///   current task wave finishes and return a cancellation error.
230    ///
231    /// # Errors
232    ///
233    /// Returns [`WorkflowError`] if the underlying channel is closed (i.e. the
234    /// executor has already been dropped).
235    pub fn send_control(&self, control: WorkflowControl) -> Result<()> {
236        match control {
237            WorkflowControl::Pause => {
238                self.pause_tx
239                    .send(true)
240                    .map_err(|e| WorkflowError::generic(format!("pause send failed: {e}")))?;
241            }
242            WorkflowControl::Resume => {
243                self.pause_tx
244                    .send(false)
245                    .map_err(|e| WorkflowError::generic(format!("resume send failed: {e}")))?;
246            }
247            WorkflowControl::Cancel => {
248                self.cancel_tx
249                    .send(true)
250                    .map_err(|e| WorkflowError::generic(format!("cancel send failed: {e}")))?;
251                // Also set pause so the loop checks after current wave
252                let _ = self.pause_tx.send(true);
253            }
254        }
255        Ok(())
256    }
257
258    /// Returns `true` when the executor is currently in a paused state.
259    ///
260    /// This reflects the latest value sent via [`Self::pause`] /
261    /// [`Self::send_control`].  It does **not** guarantee that execution has
262    /// actually stopped; in-flight tasks may still be running.
263    #[must_use]
264    pub fn is_paused(&self) -> bool {
265        *self.pause_rx.borrow()
266    }
267
268    /// Load a prior `ExecutionCheckpoint` so that already-completed tasks are
269    /// skipped when [`Self::execute`] is called next.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if the checkpoint JSON is invalid.
274    pub fn resume_from_checkpoint(&mut self, checkpoint_json: &str) -> Result<()> {
275        let cp = ExecutionCheckpoint::from_json(checkpoint_json)?;
276        self.resume_completed = cp.completed_task_ids.into_iter().collect();
277        self.resume_variables = cp.variables;
278        info!(
279            "Loaded checkpoint with {} completed tasks",
280            self.resume_completed.len()
281        );
282        Ok(())
283    }
284
285    /// Execute a workflow.
286    ///
287    /// Tasks whose dependencies are all satisfied at the same time are launched
288    /// concurrently (fan-out). The executor waits for all of them before
289    /// considering their successors (fan-in), bounded by `max_concurrent`.
290    ///
291    /// If a checkpoint was loaded via [`Self::resume_from_checkpoint`] the
292    /// corresponding tasks are pre-marked as completed and skipped.
293    ///
294    /// The executor checks the pause signal between task waves; when paused it
295    /// drains the current in-flight tasks, serialises a checkpoint, and returns
296    /// `WorkflowState::Paused` in the `ExecutionResult`.
297    pub async fn execute(&self, workflow: &mut Workflow) -> Result<ExecutionResult> {
298        info!("Starting workflow execution: {}", workflow.name);
299
300        // Validate workflow
301        workflow.validate()?;
302
303        // Update workflow state
304        workflow.state = WorkflowState::Running;
305
306        let start_time = Instant::now();
307        let context = ExecutionContext::new(workflow.id);
308
309        // Initialize variables from workflow config
310        for (key, value) in &workflow.config.variables {
311            context.set_variable(key.clone(), value.clone());
312        }
313
314        // Apply resume variables (override config-level variables)
315        for (key, value) in &self.resume_variables {
316            context.set_variable(key.clone(), value.clone());
317        }
318
319        // Get topological order
320        let task_order = workflow.topological_sort()?;
321
322        // Track completed tasks — pre-populate from checkpoint if resuming
323        let completed_tasks: Arc<RwLock<HashSet<TaskId>>> =
324            Arc::new(RwLock::new(self.resume_completed.clone()));
325        let failed_tasks: Arc<RwLock<HashSet<TaskId>>> = Arc::new(RwLock::new(HashSet::new()));
326
327        // Semaphore for limiting concurrent tasks
328        let semaphore = Arc::new(Semaphore::new(
329            workflow
330                .config
331                .max_concurrent_tasks
332                .min(self.max_concurrent),
333        ));
334
335        // Channel for task completion notifications
336        let (tx, mut rx) = mpsc::channel(100);
337
338        // Pause signal receiver (clone so we can poll it)
339        let pause_rx = self.pause_rx.clone();
340        // Cancel signal receiver (clone so we can poll it)
341        let cancel_rx = self.cancel_rx.clone();
342
343        // Execute tasks in dependency order
344        let mut active_tasks = 0;
345        let mut task_iter = task_order.iter();
346
347        loop {
348            // Check for timeout
349            if let Some(timeout_duration) = self.timeout {
350                if start_time.elapsed() > timeout_duration {
351                    workflow.state = WorkflowState::Failed;
352                    return Err(WorkflowError::generic("Workflow execution timeout"));
353                }
354            }
355
356            // Check cancel signal between task waves (only when no tasks are active)
357            if active_tasks == 0 && *cancel_rx.borrow() {
358                workflow.state = WorkflowState::Failed;
359                return Err(WorkflowError::generic("Workflow cancelled"));
360            }
361
362            // Check pause signal between task waves (only when no tasks are active)
363            if active_tasks == 0 && *pause_rx.borrow() {
364                // Drain in-flight tasks first (none here since active_tasks == 0)
365                // Capture checkpoint
366                let completed = completed_tasks.read().await;
367                let variables: HashMap<String, serde_json::Value> = context
368                    .variables
369                    .iter()
370                    .map(|e| (e.key().clone(), e.value().clone()))
371                    .collect();
372                let checkpoint = ExecutionCheckpoint {
373                    workflow_id: workflow.id,
374                    completed_task_ids: completed.iter().copied().collect(),
375                    variables,
376                    timestamp_secs: std::time::SystemTime::now()
377                        .duration_since(std::time::UNIX_EPOCH)
378                        .unwrap_or_default()
379                        .as_secs(),
380                };
381                drop(completed);
382
383                let checkpoint_json = checkpoint.to_json()?;
384                info!(
385                    "Workflow paused; checkpoint: {} bytes",
386                    checkpoint_json.len()
387                );
388
389                workflow.state = WorkflowState::Paused;
390                return Ok(ExecutionResult {
391                    workflow_id: workflow.id,
392                    state: WorkflowState::Paused,
393                    duration: start_time.elapsed(),
394                    task_results: context
395                        .results
396                        .iter()
397                        .map(|entry| (*entry.key(), entry.value().clone()))
398                        .collect(),
399                    checkpoint: Some(checkpoint_json),
400                });
401            }
402
403            // Start new tasks if possible
404            while active_tasks < task_order.len() {
405                let Some(&task_id) = task_iter.next() else {
406                    break;
407                };
408
409                // Check if dependencies are satisfied
410                let deps = workflow.get_dependencies(&task_id);
411                let completed = completed_tasks.read().await;
412                let failed = failed_tasks.read().await;
413
414                // Skip tasks that were already completed in a prior checkpoint
415                let already_done = completed.contains(&task_id);
416                let deps_satisfied = deps.iter().all(|dep| completed.contains(dep));
417                let deps_failed = deps.iter().any(|dep| failed.contains(dep));
418
419                drop(completed);
420                drop(failed);
421
422                // Task was completed in a prior run: count it but skip execution
423                if already_done {
424                    active_tasks += 1;
425                    // Notify completion immediately via a synthetic result
426                    let result = TaskResult {
427                        task_id,
428                        status: TaskState::Completed,
429                        data: None,
430                        error: None,
431                        duration: Duration::ZERO,
432                        outputs: Vec::new(),
433                    };
434                    let tx2 = tx.clone();
435                    let completed2 = completed_tasks.clone();
436                    tokio::spawn(async move {
437                        completed2.write().await.insert(task_id);
438                        let _ = tx2.send((task_id, result)).await;
439                    });
440                    continue;
441                }
442
443                if deps_failed {
444                    if workflow.config.fail_fast {
445                        workflow.state = WorkflowState::Failed;
446                        return Err(WorkflowError::DependencyFailed(task_id.to_string()));
447                    }
448                    // Skip this task
449                    if let Some(task) = workflow.get_task_mut(&task_id) {
450                        task.set_state(TaskState::Skipped)?;
451                    }
452                    continue;
453                }
454
455                if !deps_satisfied {
456                    continue;
457                }
458
459                // Get task
460                let Some(task) = workflow.get_task(&task_id).cloned() else {
461                    continue;
462                };
463
464                // Check if task should run based on conditions
465                if !self.should_run_task(&task, &context).await {
466                    if let Some(t) = workflow.get_task_mut(&task_id) {
467                        t.set_state(TaskState::Skipped)?;
468                    }
469                    completed_tasks.write().await.insert(task_id);
470                    continue;
471                }
472
473                // Spawn task execution
474                let executor = self.task_executor.clone();
475                let sem = semaphore.clone();
476                let ctx = context.clone();
477                let tx = tx.clone();
478                let completed = completed_tasks.clone();
479                let failed = failed_tasks.clone();
480
481                tokio::spawn(async move {
482                    let Ok(_permit) = sem.acquire().await else {
483                        let _ = tx
484                            .send((
485                                task_id,
486                                TaskResult {
487                                    task_id,
488                                    status: TaskState::Failed,
489                                    data: None,
490                                    error: Some("Semaphore closed".to_string()),
491                                    duration: std::time::Duration::ZERO,
492                                    outputs: Vec::new(),
493                                },
494                            ))
495                            .await;
496                        return;
497                    };
498                    let result = Self::execute_task(executor, &task, &ctx).await;
499
500                    let success = matches!(result.status, TaskState::Completed);
501                    if success {
502                        completed.write().await.insert(task_id);
503                    } else {
504                        failed.write().await.insert(task_id);
505                    }
506
507                    ctx.store_result(task_id, result.clone());
508                    let _ = tx.send((task_id, result)).await;
509                });
510
511                active_tasks += 1;
512            }
513
514            // Wait for task completion
515            if active_tasks == 0 {
516                break;
517            }
518
519            if let Some((_task_id, result)) = rx.recv().await {
520                active_tasks -= 1;
521
522                if !matches!(result.status, TaskState::Completed) && workflow.config.fail_fast {
523                    workflow.state = WorkflowState::Failed;
524                    return Err(WorkflowError::TaskExecutionFailed {
525                        task_id: result.task_id.to_string(),
526                        reason: result.error.unwrap_or_else(|| "Unknown error".to_string()),
527                    });
528                }
529            } else {
530                break;
531            }
532        }
533
534        // Check final state
535        let failed = failed_tasks.read().await;
536        let final_state = if failed.is_empty() {
537            WorkflowState::Completed
538        } else {
539            WorkflowState::Failed
540        };
541
542        workflow.state = final_state;
543
544        info!(
545            "Workflow execution completed: {} in {:?}",
546            workflow.name,
547            start_time.elapsed()
548        );
549
550        Ok(ExecutionResult {
551            workflow_id: workflow.id,
552            state: final_state,
553            duration: start_time.elapsed(),
554            task_results: context
555                .results
556                .iter()
557                .map(|entry| (*entry.key(), entry.value().clone()))
558                .collect(),
559            checkpoint: None,
560        })
561    }
562
563    async fn execute_task(
564        executor: Arc<dyn TaskExecutor>,
565        task: &Task,
566        _context: &ExecutionContext,
567    ) -> TaskResult {
568        debug!("Executing task: {}", task.name);
569        let start = Instant::now();
570
571        let result = if let Some(task_timeout) = Some(task.timeout) {
572            match timeout(task_timeout, executor.execute(task)).await {
573                Ok(Ok(result)) => result,
574                Ok(Err(e)) => TaskResult {
575                    task_id: task.id,
576                    status: TaskState::Failed,
577                    data: None,
578                    error: Some(e.to_string()),
579                    duration: start.elapsed(),
580                    outputs: Vec::new(),
581                },
582                Err(_) => TaskResult {
583                    task_id: task.id,
584                    status: TaskState::Failed,
585                    data: None,
586                    error: Some("Task timeout".to_string()),
587                    duration: start.elapsed(),
588                    outputs: Vec::new(),
589                },
590            }
591        } else {
592            match executor.execute(task).await {
593                Ok(result) => result,
594                Err(e) => TaskResult {
595                    task_id: task.id,
596                    status: TaskState::Failed,
597                    data: None,
598                    error: Some(e.to_string()),
599                    duration: start.elapsed(),
600                    outputs: Vec::new(),
601                },
602            }
603        };
604
605        debug!(
606            "Task {} completed with status: {:?}",
607            task.name, result.status
608        );
609
610        result
611    }
612
613    async fn should_run_task(&self, task: &Task, context: &ExecutionContext) -> bool {
614        // Evaluate task conditions
615        for condition in &task.conditions {
616            if !self.evaluate_condition(condition, context).await {
617                debug!("Task {} skipped due to condition: {}", task.name, condition);
618                return false;
619            }
620        }
621        true
622    }
623
624    async fn evaluate_condition(&self, condition: &str, context: &ExecutionContext) -> bool {
625        match parse_condition(condition, context) {
626            Ok(result) => result,
627            Err(err) => {
628                debug!(
629                    "Condition parse error (treating as false): {} – {}",
630                    condition, err
631                );
632                false
633            }
634        }
635    }
636}
637
638// ── Condition expression evaluator ──────────────────────────────────────────
639
640/// Value types that can appear in condition expressions.
641#[derive(Debug, Clone, PartialEq)]
642enum CondValue {
643    /// Integer/byte quantity (e.g. file sizes, counts).
644    Int(i64),
645    /// Floating-point number.
646    Float(f64),
647    /// String value.
648    Str(String),
649    /// Boolean literal.
650    Bool(bool),
651}
652
653impl CondValue {
654    /// Attempt to compare two values using a standard ordering.
655    fn partial_cmp_values(&self, other: &Self) -> Option<std::cmp::Ordering> {
656        match (self, other) {
657            (Self::Int(a), Self::Int(b)) => a.partial_cmp(b),
658            (Self::Float(a), Self::Float(b)) => a.partial_cmp(b),
659            (Self::Int(a), Self::Float(b)) => (*a as f64).partial_cmp(b),
660            (Self::Float(a), Self::Int(b)) => a.partial_cmp(&(*b as f64)),
661            (Self::Str(a), Self::Str(b)) => a.partial_cmp(b),
662            _ => None,
663        }
664    }
665
666    /// Equality comparison with type coercion between numerics.
667    fn eq_coerced(&self, other: &Self) -> bool {
668        match (self, other) {
669            (Self::Int(a), Self::Int(b)) => a == b,
670            (Self::Float(a), Self::Float(b)) => (a - b).abs() < f64::EPSILON,
671            (Self::Int(a), Self::Float(b)) => ((*a as f64) - b).abs() < f64::EPSILON,
672            (Self::Float(a), Self::Int(b)) => (a - (*b as f64)).abs() < f64::EPSILON,
673            (Self::Str(a), Self::Str(b)) => a.eq_ignore_ascii_case(b),
674            (Self::Bool(a), Self::Bool(b)) => a == b,
675            _ => false,
676        }
677    }
678}
679
680/// Resolve a variable reference from the execution context.
681///
682/// Supported paths:
683/// - `output.<key>` – looks up `key` in `context.variables` and parses the
684///   value as a `CondValue`.
685/// - Bare identifiers – also looked up in `context.variables`.
686fn resolve_variable(path: &str, context: &ExecutionContext) -> Option<CondValue> {
687    let key = path.trim_start_matches("output.");
688    let json_val = context.get_variable(key)?;
689    json_to_cond_value(&json_val)
690}
691
692/// Convert a `serde_json::Value` to a `CondValue`.
693fn json_to_cond_value(v: &serde_json::Value) -> Option<CondValue> {
694    match v {
695        serde_json::Value::Bool(b) => Some(CondValue::Bool(*b)),
696        serde_json::Value::Number(n) => {
697            if let Some(i) = n.as_i64() {
698                Some(CondValue::Int(i))
699            } else {
700                n.as_f64().map(CondValue::Float)
701            }
702        }
703        serde_json::Value::String(s) => Some(CondValue::Str(s.clone())),
704        _ => None,
705    }
706}
707
708/// Parse a literal token (number with optional unit, boolean, or quoted string)
709/// into a `CondValue`.
710///
711/// Unit suffixes supported:
712/// - Bytes  : `B`, `KB`, `MB`, `GB`, `TB` (powers of 1024)
713/// - Time   : `ms`, `s`, `m`, `h`
714/// - Bare numbers are treated as integers or floats.
715fn parse_literal(token: &str) -> Option<CondValue> {
716    let t = token.trim().trim_matches(|c| c == '"' || c == '\'');
717
718    // Boolean literals
719    match t.to_lowercase().as_str() {
720        "true" => return Some(CondValue::Bool(true)),
721        "false" => return Some(CondValue::Bool(false)),
722        _ => {}
723    }
724
725    // Try numeric with byte-size suffix (case-insensitive)
726    let lower = t.to_lowercase();
727    let byte_units: &[(&str, i64)] = &[
728        ("tb", 1024 * 1024 * 1024 * 1024),
729        ("gb", 1024 * 1024 * 1024),
730        ("mb", 1024 * 1024),
731        ("kb", 1024),
732        ("b", 1),
733    ];
734    for &(suffix, multiplier) in byte_units {
735        if let Some(num_str) = lower.strip_suffix(suffix) {
736            let num_str = num_str.trim();
737            if let Ok(n) = num_str.parse::<f64>() {
738                return Some(CondValue::Int((n * multiplier as f64) as i64));
739            }
740        }
741    }
742
743    // Duration suffixes
744    let duration_units: &[(&str, i64)] =
745        &[("ms", 1), ("s", 1_000), ("m", 60_000), ("h", 3_600_000)];
746    for &(suffix, multiplier_ms) in duration_units {
747        if let Some(num_str) = lower.strip_suffix(suffix) {
748            let num_str = num_str.trim();
749            if let Ok(n) = num_str.parse::<f64>() {
750                return Some(CondValue::Int((n * multiplier_ms as f64) as i64));
751            }
752        }
753    }
754
755    // Plain integer
756    if let Ok(i) = t.parse::<i64>() {
757        return Some(CondValue::Int(i));
758    }
759
760    // Plain float
761    if let Ok(f) = t.parse::<f64>() {
762        return Some(CondValue::Float(f));
763    }
764
765    // Fall back to string value (handles codec names etc.)
766    Some(CondValue::Str(t.to_string()))
767}
768
769/// Parse and evaluate a single condition expression against the execution context.
770///
771/// Grammar (simplified):
772/// ```text
773/// condition  := operand  operator  operand
774///             | "!" operand
775/// operand    := variable | literal
776/// variable   := identifier ("." identifier)*
777/// operator   := "==" | "!=" | ">" | ">=" | "<" | "<="
778///             | "contains" | "startswith" | "endswith"
779/// literal    := number [unit] | bool | quoted-string
780/// ```
781///
782/// Examples:
783/// - `"output.size > 1MB"`
784/// - `"duration >= 60s"`
785/// - `"codec == av1"`
786/// - `"output.status == completed"`
787/// - `"count > 0"`
788/// - `"!failed"`
789pub fn parse_condition(
790    condition: &str,
791    context: &ExecutionContext,
792) -> std::result::Result<bool, String> {
793    let cond = condition.trim();
794
795    // Handle negation: !<expr>
796    if let Some(rest) = cond.strip_prefix('!') {
797        return parse_condition(rest.trim(), context).map(|v| !v);
798    }
799
800    // Try to split on a binary operator (longest match first to avoid
801    // ">" matching before ">=")
802    let operators = &[
803        ">=",
804        "<=",
805        "!=",
806        "==",
807        ">",
808        "<",
809        "contains",
810        "startswith",
811        "endswith",
812    ];
813
814    for &op in operators {
815        // Use case-insensitive search for word operators
816        let split_pos = if op.chars().all(char::is_alphabetic) {
817            // word operator – find as whole word
818            let lower = cond.to_lowercase();
819            let needle = format!(" {op} ");
820            lower
821                .find(&needle)
822                .map(|p| (p, p + needle.len() - 1, op.len()))
823        } else {
824            // symbol operator
825            cond.find(op).map(|p| (p, p + op.len(), op.len()))
826        };
827
828        let Some((lhs_end, rhs_start, _)) = split_pos else {
829            continue;
830        };
831
832        let lhs_str = cond[..lhs_end].trim();
833        let rhs_str = cond[rhs_start..].trim();
834
835        // Resolve left-hand side: try as variable first, then literal
836        let lhs = resolve_variable(lhs_str, context).or_else(|| parse_literal(lhs_str));
837
838        // Resolve right-hand side
839        let rhs = resolve_variable(rhs_str, context).or_else(|| parse_literal(rhs_str));
840
841        let lhs = lhs.ok_or_else(|| format!("Cannot resolve LHS: {lhs_str}"))?;
842        let rhs = rhs.ok_or_else(|| format!("Cannot resolve RHS: {rhs_str}"))?;
843
844        let result = match op {
845            "==" => lhs.eq_coerced(&rhs),
846            "!=" => !lhs.eq_coerced(&rhs),
847            ">" => lhs
848                .partial_cmp_values(&rhs)
849                .is_some_and(|o| o == std::cmp::Ordering::Greater),
850            ">=" => lhs
851                .partial_cmp_values(&rhs)
852                .is_some_and(|o| o != std::cmp::Ordering::Less),
853            "<" => lhs
854                .partial_cmp_values(&rhs)
855                .is_some_and(|o| o == std::cmp::Ordering::Less),
856            "<=" => lhs
857                .partial_cmp_values(&rhs)
858                .is_some_and(|o| o != std::cmp::Ordering::Greater),
859            "contains" => {
860                if let (CondValue::Str(l), CondValue::Str(r)) = (&lhs, &rhs) {
861                    l.to_lowercase().contains(&r.to_lowercase())
862                } else {
863                    false
864                }
865            }
866            "startswith" => {
867                if let (CondValue::Str(l), CondValue::Str(r)) = (&lhs, &rhs) {
868                    l.to_lowercase().starts_with(&r.to_lowercase())
869                } else {
870                    false
871                }
872            }
873            "endswith" => {
874                if let (CondValue::Str(l), CondValue::Str(r)) = (&lhs, &rhs) {
875                    l.to_lowercase().ends_with(&r.to_lowercase())
876                } else {
877                    false
878                }
879            }
880            _ => false,
881        };
882
883        return Ok(result);
884    }
885
886    // No operator found: treat the whole expression as a boolean variable lookup
887    if let Some(val) = resolve_variable(cond, context) {
888        return Ok(match val {
889            CondValue::Bool(b) => b,
890            CondValue::Int(i) => i != 0,
891            CondValue::Float(f) => f != 0.0,
892            CondValue::Str(s) => !s.is_empty() && s.to_lowercase() != "false",
893        });
894    }
895
896    // Unknown condition – default to true so existing workflows are not broken
897    debug!("Condition not resolvable, defaulting to true: {}", cond);
898    Ok(true)
899}
900
901/// Workflow execution result.
902#[derive(Debug)]
903pub struct ExecutionResult {
904    /// Workflow ID.
905    pub workflow_id: WorkflowId,
906    /// Final workflow state.
907    pub state: WorkflowState,
908    /// Total execution duration.
909    pub duration: Duration,
910    /// Results for all tasks.
911    pub task_results: HashMap<TaskId, TaskResult>,
912    /// Serialized checkpoint JSON when the workflow was paused mid-execution.
913    /// `None` when the workflow ran to completion or failure.
914    pub checkpoint: Option<String>,
915}
916
917/// Default task executor implementation.
918pub struct DefaultTaskExecutor;
919
920#[async_trait]
921impl TaskExecutor for DefaultTaskExecutor {
922    async fn execute(&self, task: &Task) -> Result<TaskResult> {
923        use crate::task::TaskType;
924
925        let start = Instant::now();
926
927        let result: Result<()> = match &task.task_type {
928            TaskType::Wait { duration } => {
929                tokio::time::sleep(*duration).await;
930                Ok(())
931            }
932            TaskType::HttpRequest {
933                url,
934                method,
935                headers: _,
936                body: _,
937            } => {
938                debug!("HTTP {:?} request to: {}", method, url);
939                // HTTP client integration would go here (reqwest / hyper).
940                // At the workflow-engine layer we log the intent and succeed;
941                // callers that need real HTTP should provide a custom TaskExecutor.
942                info!("HTTP {} {}", format!("{:?}", method).to_uppercase(), url);
943                Ok(())
944            }
945            TaskType::Transcode {
946                input,
947                output,
948                preset,
949                params: _,
950            } => {
951                info!("Transcode: {:?} → {:?} (preset: {})", input, output, preset);
952                // Validate that the input path exists before handing off to a
953                // transcode engine.  The actual codec pipeline is implemented in
954                // oximedia-transcode; this executor records the intent and
955                // succeeds so the workflow graph continues.
956                if !input.exists() {
957                    return Err(WorkflowError::generic(format!(
958                        "Transcode input not found: {}",
959                        input.display()
960                    )));
961                }
962                // Ensure parent directory of output exists.
963                if let Some(parent) = output.parent() {
964                    if !parent.as_os_str().is_empty() {
965                        tokio::fs::create_dir_all(parent).await.map_err(|e| {
966                            WorkflowError::generic(format!(
967                                "Cannot create output directory {}: {e}",
968                                parent.display()
969                            ))
970                        })?;
971                    }
972                }
973                info!("Transcode task recorded for {:?}", output);
974                Ok(())
975            }
976            TaskType::QualityControl {
977                input,
978                profile,
979                rules,
980            } => {
981                info!(
982                    "QualityControl: {:?} profile={} rules={:?}",
983                    input, profile, rules
984                );
985                if !input.exists() {
986                    return Err(WorkflowError::generic(format!(
987                        "QC input not found: {}",
988                        input.display()
989                    )));
990                }
991                // QC validation logic lives in oximedia-qc; here we confirm
992                // the file is reachable and log that QC was requested.
993                let metadata = tokio::fs::metadata(input)
994                    .await
995                    .map_err(|e| WorkflowError::generic(format!("QC metadata error: {e}")))?;
996                info!(
997                    "QC target size: {} bytes, profile: {}",
998                    metadata.len(),
999                    profile
1000                );
1001                Ok(())
1002            }
1003            TaskType::Transfer {
1004                source,
1005                destination,
1006                protocol,
1007                options: _,
1008            } => {
1009                use crate::task::TransferProtocol;
1010                info!("Transfer: {} → {} via {:?}", source, destination, protocol);
1011                // For local-filesystem transfers we perform the copy directly.
1012                // Remote protocols (S3, SFTP, FTP, rsync, HTTP) are handled by
1013                // dedicated transfer agents; this executor logs the request.
1014                match protocol {
1015                    TransferProtocol::Local => {
1016                        let src_path = std::path::Path::new(source.as_str());
1017                        let dst_path = std::path::Path::new(destination.as_str());
1018                        if let Some(parent) = dst_path.parent() {
1019                            if !parent.as_os_str().is_empty() {
1020                                tokio::fs::create_dir_all(parent).await.map_err(|e| {
1021                                    WorkflowError::generic(format!(
1022                                        "Cannot create destination dir: {e}"
1023                                    ))
1024                                })?;
1025                            }
1026                        }
1027                        tokio::fs::copy(src_path, dst_path).await.map_err(|e| {
1028                            WorkflowError::generic(format!(
1029                                "Local copy {} → {} failed: {e}",
1030                                src_path.display(),
1031                                dst_path.display()
1032                            ))
1033                        })?;
1034                        info!("Local transfer complete: {} → {}", source, destination);
1035                    }
1036                    other => {
1037                        info!(
1038                            "Remote transfer ({:?}) queued: {} → {}",
1039                            other, source, destination
1040                        );
1041                    }
1042                }
1043                Ok(())
1044            }
1045            TaskType::Notification {
1046                channel,
1047                message,
1048                metadata: _,
1049            } => {
1050                use crate::task::NotificationChannel;
1051                match channel {
1052                    NotificationChannel::Email { to, subject } => {
1053                        info!(
1054                            "Notification [Email] to={:?} subject={:?}: {}",
1055                            to, subject, message
1056                        );
1057                    }
1058                    NotificationChannel::Webhook { url } => {
1059                        info!("Notification [Webhook] url={}: {}", url, message);
1060                    }
1061                    NotificationChannel::Slack {
1062                        channel: slack_channel,
1063                        webhook_url,
1064                    } => {
1065                        info!(
1066                            "Notification [Slack] channel={} url={}: {}",
1067                            slack_channel, webhook_url, message
1068                        );
1069                    }
1070                    NotificationChannel::Discord { webhook_url } => {
1071                        info!("Notification [Discord] url={}: {}", webhook_url, message);
1072                    }
1073                }
1074                Ok(())
1075            }
1076            TaskType::CustomScript { script, args, env } => {
1077                info!(
1078                    "CustomScript: {:?} args={:?} env_vars={}",
1079                    script,
1080                    args,
1081                    env.len()
1082                );
1083                if !script.exists() {
1084                    return Err(WorkflowError::generic(format!(
1085                        "Script not found: {}",
1086                        script.display()
1087                    )));
1088                }
1089                // Spawn the script as a child process via tokio::process.
1090                let mut cmd = tokio::process::Command::new(script);
1091                cmd.args(args);
1092                for (k, v) in env {
1093                    cmd.env(k, v);
1094                }
1095                let status = cmd.status().await.map_err(|e| {
1096                    WorkflowError::generic(format!("Script {:?} failed to launch: {e}", script))
1097                })?;
1098                if !status.success() {
1099                    return Err(WorkflowError::generic(format!(
1100                        "Script {:?} exited with status: {}",
1101                        script, status
1102                    )));
1103                }
1104                info!("Script {:?} completed successfully", script);
1105                Ok(())
1106            }
1107            TaskType::Analysis {
1108                input,
1109                analyses,
1110                output,
1111            } => {
1112                info!(
1113                    "Analysis: {:?} types={:?} output={:?}",
1114                    input, analyses, output
1115                );
1116                if !input.exists() {
1117                    return Err(WorkflowError::generic(format!(
1118                        "Analysis input not found: {}",
1119                        input.display()
1120                    )));
1121                }
1122                // If an output path was requested, ensure its parent exists.
1123                if let Some(out_path) = output {
1124                    if let Some(parent) = out_path.parent() {
1125                        if !parent.as_os_str().is_empty() {
1126                            tokio::fs::create_dir_all(parent).await.map_err(|e| {
1127                                WorkflowError::generic(format!(
1128                                    "Cannot create analysis output dir: {e}"
1129                                ))
1130                            })?;
1131                        }
1132                    }
1133                }
1134                // Analysis engines live in oximedia-quality / oximedia-scene etc.
1135                // This executor records the request and succeeds; real analysis
1136                // is performed by the domain-specific pipeline.
1137                info!("Analysis task recorded for {:?}", input);
1138                Ok(())
1139            }
1140            TaskType::Conditional {
1141                condition,
1142                true_task,
1143                false_task,
1144            } => {
1145                // Evaluate the condition expression (simple boolean string parse;
1146                // full expression evaluation would use the ExecutionContext variables).
1147                let condition_result = match condition.trim().to_lowercase().as_str() {
1148                    "true" | "1" | "yes" => true,
1149                    "false" | "0" | "no" => false,
1150                    other => {
1151                        debug!(
1152                            "Condition not resolvable as literal, defaulting to true: {}",
1153                            other
1154                        );
1155                        true
1156                    }
1157                };
1158
1159                let branch_task = if condition_result {
1160                    true_task.as_deref()
1161                } else {
1162                    false_task.as_deref()
1163                };
1164
1165                if let Some(inner_task) = branch_task {
1166                    info!(
1167                        "Conditional branch selected: condition={} task={}",
1168                        condition_result, inner_task.name
1169                    );
1170                    // Recursively execute the selected branch task.
1171                    let branch_result = self.execute(inner_task).await?;
1172                    if !matches!(branch_result.status, TaskState::Completed) {
1173                        return Err(WorkflowError::generic(format!(
1174                            "Conditional branch task '{}' failed: {}",
1175                            inner_task.name,
1176                            branch_result.error.as_deref().unwrap_or("unknown")
1177                        )));
1178                    }
1179                } else {
1180                    debug!("Conditional task: selected branch has no task, skipping");
1181                }
1182                Ok(())
1183            }
1184        };
1185
1186        match result {
1187            Ok(()) => Ok(TaskResult {
1188                task_id: task.id,
1189                status: TaskState::Completed,
1190                data: None,
1191                error: None,
1192                duration: start.elapsed(),
1193                outputs: Vec::new(),
1194            }),
1195            Err(e) => Ok(TaskResult {
1196                task_id: task.id,
1197                status: TaskState::Failed,
1198                data: None,
1199                error: Some(e.to_string()),
1200                duration: start.elapsed(),
1201                outputs: Vec::new(),
1202            }),
1203        }
1204    }
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209    use super::*;
1210    use crate::task::{Task, TaskType};
1211
1212    #[test]
1213    fn test_execution_context_creation() {
1214        let workflow_id = WorkflowId::new();
1215        let ctx = ExecutionContext::new(workflow_id);
1216        assert_eq!(ctx.workflow_id, workflow_id);
1217    }
1218
1219    #[test]
1220    fn test_execution_context_variables() {
1221        let ctx = ExecutionContext::new(WorkflowId::new());
1222        ctx.set_variable("key".to_string(), serde_json::json!("value"));
1223        assert_eq!(ctx.get_variable("key"), Some(serde_json::json!("value")));
1224    }
1225
1226    #[test]
1227    fn test_execution_context_results() {
1228        let ctx = ExecutionContext::new(WorkflowId::new());
1229        let task_id = TaskId::new();
1230        let result = TaskResult {
1231            task_id,
1232            status: TaskState::Completed,
1233            data: None,
1234            error: None,
1235            duration: Duration::from_secs(1),
1236            outputs: Vec::new(),
1237        };
1238
1239        ctx.store_result(task_id, result.clone());
1240        let retrieved = ctx.get_result(&task_id);
1241        assert!(retrieved.is_some());
1242    }
1243
1244    #[tokio::test]
1245    async fn test_default_executor_wait_task() {
1246        let executor = DefaultTaskExecutor;
1247        let task = Task::new(
1248            "wait-task",
1249            TaskType::Wait {
1250                duration: Duration::from_millis(10),
1251            },
1252        );
1253
1254        let result = executor
1255            .execute(&task)
1256            .await
1257            .expect("should succeed in test");
1258        assert_eq!(result.status, TaskState::Completed);
1259    }
1260
1261    #[tokio::test]
1262    async fn test_workflow_executor_creation() {
1263        let executor = WorkflowExecutor::new(Arc::new(DefaultTaskExecutor))
1264            .with_max_concurrent(2)
1265            .with_timeout(Duration::from_secs(60));
1266
1267        assert_eq!(executor.max_concurrent, 2);
1268        assert!(executor.timeout.is_some());
1269    }
1270
1271    #[tokio::test]
1272    async fn test_simple_workflow_execution() {
1273        let mut workflow = Workflow::new("test-workflow");
1274        let task = Task::new(
1275            "test-task",
1276            TaskType::Wait {
1277                duration: Duration::from_millis(10),
1278            },
1279        );
1280        workflow.add_task(task);
1281
1282        let executor = WorkflowExecutor::new(Arc::new(DefaultTaskExecutor));
1283        let result = executor
1284            .execute(&mut workflow)
1285            .await
1286            .expect("should succeed in test");
1287
1288        assert_eq!(result.state, WorkflowState::Completed);
1289        assert_eq!(result.task_results.len(), 1);
1290    }
1291}