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//! # Scheduling correctness
12//!
13//! A task whose dependencies are not yet satisfied is **retried on the next
14//! pass**, never dropped: the scheduler repeatedly rescans the set of
15//! not-yet-dispatched tasks (a fixpoint loop) until every task has either
16//! run to completion, been synthetically completed from a checkpoint, or
17//! been skipped. [`WorkflowState::Completed`] is returned only when that
18//! fixpoint is reached with an empty failure set. A real task failure, or a
19//! dependency that can never be satisfied because an upstream task failed or
20//! was skipped, is reported as [`WorkflowState::Failed`] (or a scheduling
21//! [`WorkflowError`] if no task can ever become ready again) — never
22//! silently downgraded to a fabricated success.
23//!
24//! # Pause / Resume
25//!
26//! Call [`WorkflowExecutor::pause`] to signal the executor to stop launching
27//! new tasks after the current wave completes. The checkpoint is serialised to
28//! a JSON string containing the completed task IDs and execution context
29//! variables. Pass that string to [`WorkflowExecutor::resume_from_checkpoint`]
30//! to reconstruct the state and continue.
31
32use crate::error::{Result, WorkflowError};
33use crate::task::{Task, TaskId, TaskResult, TaskState};
34use crate::workflow::{Workflow, WorkflowId, WorkflowState};
35use async_trait::async_trait;
36use dashmap::DashMap;
37use serde::{Deserialize, Serialize};
38use std::collections::{HashMap, HashSet};
39use std::sync::{Arc, Mutex};
40use std::time::{Duration, Instant, SystemTime};
41use tokio::sync::{mpsc, watch, RwLock, Semaphore};
42use tokio::time::timeout;
43use tracing::{debug, info, warn};
44
45/// Task executor trait.
46#[async_trait]
47pub trait TaskExecutor: Send + Sync {
48    /// Execute a task and return the result.
49    async fn execute(&self, task: &Task) -> Result<TaskResult>;
50}
51
52// ---------------------------------------------------------------------------
53// Batch status buffering
54// ---------------------------------------------------------------------------
55
56/// A buffered status update pending persistence.
57#[derive(Debug, Clone)]
58pub struct StatusUpdate {
59    /// The task whose status changed.
60    pub task_id: TaskId,
61    /// The new task status.
62    pub status: TaskState,
63    /// Wall-clock timestamp when the update was recorded.
64    pub timestamp: SystemTime,
65}
66
67impl StatusUpdate {
68    /// Create a new status update timestamped to now.
69    #[must_use]
70    pub fn new(task_id: TaskId, status: TaskState) -> Self {
71        Self {
72            task_id,
73            status,
74            timestamp: SystemTime::now(),
75        }
76    }
77}
78
79/// Default number of buffered status updates before an automatic flush.
80const DEFAULT_FLUSH_THRESHOLD: usize = 20;
81
82// ---------------------------------------------------------------------------
83// Pause / Resume support
84// ---------------------------------------------------------------------------
85
86/// Serializable checkpoint for pause/resume of workflow execution.
87///
88/// This struct captures the minimal state needed to resume a paused workflow:
89/// the set of already-completed task IDs and the current execution context
90/// variables.  It can be persisted to disk, a database, or transferred over
91/// the network, then passed back to
92/// [`WorkflowExecutor::resume_from_checkpoint`].
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ExecutionCheckpoint {
95    /// Workflow identifier.
96    pub workflow_id: WorkflowId,
97    /// Task IDs that had completed at the time the checkpoint was taken.
98    pub completed_task_ids: Vec<TaskId>,
99    /// Execution context variables at checkpoint time.
100    pub variables: HashMap<String, serde_json::Value>,
101    /// Unix timestamp (seconds) when the checkpoint was captured.
102    pub timestamp_secs: u64,
103}
104
105impl ExecutionCheckpoint {
106    /// Serialize the checkpoint to a compact JSON string.
107    ///
108    /// # Errors
109    ///
110    /// Returns a `WorkflowError` if JSON serialization fails.
111    pub fn to_json(&self) -> Result<String> {
112        serde_json::to_string(self).map_err(WorkflowError::Serialization)
113    }
114
115    /// Deserialize a checkpoint from a JSON string.
116    ///
117    /// # Errors
118    ///
119    /// Returns a `WorkflowError` if JSON parsing fails.
120    pub fn from_json(json: &str) -> Result<Self> {
121        serde_json::from_str(json).map_err(WorkflowError::Serialization)
122    }
123}
124
125/// Execution context shared across task executions.
126#[derive(Debug, Clone)]
127pub struct ExecutionContext {
128    /// Workflow ID.
129    pub workflow_id: WorkflowId,
130    /// Workflow variables.
131    pub variables: Arc<DashMap<String, serde_json::Value>>,
132    /// Task results cache.
133    pub results: Arc<DashMap<TaskId, TaskResult>>,
134}
135
136impl ExecutionContext {
137    /// Create a new execution context.
138    #[must_use]
139    pub fn new(workflow_id: WorkflowId) -> Self {
140        Self {
141            workflow_id,
142            variables: Arc::new(DashMap::new()),
143            results: Arc::new(DashMap::new()),
144        }
145    }
146
147    /// Get variable value.
148    #[must_use]
149    pub fn get_variable(&self, key: &str) -> Option<serde_json::Value> {
150        self.variables.get(key).map(|v| v.clone())
151    }
152
153    /// Set variable value.
154    pub fn set_variable(&self, key: String, value: serde_json::Value) {
155        self.variables.insert(key, value);
156    }
157
158    /// Get task result.
159    #[must_use]
160    pub fn get_result(&self, task_id: &TaskId) -> Option<TaskResult> {
161        self.results.get(task_id).map(|r| r.clone())
162    }
163
164    /// Store task result.
165    pub fn store_result(&self, task_id: TaskId, result: TaskResult) {
166        self.results.insert(task_id, result);
167    }
168}
169
170// ---------------------------------------------------------------------------
171// WorkflowControl
172// ---------------------------------------------------------------------------
173
174/// High-level control signal for a running [`WorkflowExecutor`].
175///
176/// Sent via [`WorkflowExecutor::send_control`].
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum WorkflowControl {
179    /// Pause after current in-flight tasks complete.
180    ///
181    /// The executor serialises a checkpoint and returns with
182    /// [`WorkflowState::Paused`].
183    Pause,
184    /// Resume a previously paused executor.
185    Resume,
186    /// Request immediate cancellation of the workflow.
187    ///
188    /// In-flight tasks are allowed to finish; then the executor returns an
189    /// error with `"cancelled"`.
190    Cancel,
191}
192
193/// Workflow executor.
194pub struct WorkflowExecutor {
195    /// Task executor implementation.
196    task_executor: Arc<dyn TaskExecutor>,
197    /// Maximum concurrent tasks.
198    max_concurrent: usize,
199    /// Execution timeout.
200    timeout: Option<Duration>,
201    /// Pause signal sender.  When `true` is sent the executor stops launching
202    /// new tasks after the current in-flight set completes.
203    pause_tx: watch::Sender<bool>,
204    /// Pause signal receiver (cloned into each execution).
205    pause_rx: watch::Receiver<bool>,
206    /// Cancel signal sender.  When `true` is sent the executor aborts after
207    /// the current wave finishes.
208    cancel_tx: watch::Sender<bool>,
209    /// Cancel signal receiver (cloned into each execution).
210    cancel_rx: watch::Receiver<bool>,
211    /// Completed tasks from a prior checkpoint (used for resume).
212    resume_completed: HashSet<TaskId>,
213    /// Variables from a prior checkpoint (used for resume).
214    resume_variables: HashMap<String, serde_json::Value>,
215    /// Pending status updates awaiting batch flush to persistence.
216    status_buffer: Arc<Mutex<Vec<StatusUpdate>>>,
217    /// Number of buffered updates that trigger an automatic flush.
218    buffer_flush_threshold: usize,
219}
220
221impl WorkflowExecutor {
222    /// Create a new workflow executor.
223    #[must_use]
224    pub fn new(task_executor: Arc<dyn TaskExecutor>) -> Self {
225        let (pause_tx, pause_rx) = watch::channel(false);
226        let (cancel_tx, cancel_rx) = watch::channel(false);
227        Self {
228            task_executor,
229            max_concurrent: 4,
230            timeout: None,
231            pause_tx,
232            pause_rx,
233            cancel_tx,
234            cancel_rx,
235            resume_completed: HashSet::new(),
236            resume_variables: HashMap::new(),
237            status_buffer: Arc::new(Mutex::new(Vec::new())),
238            buffer_flush_threshold: DEFAULT_FLUSH_THRESHOLD,
239        }
240    }
241
242    /// Override the batch-flush threshold (number of buffered updates that
243    /// trigger an automatic flush to persistence).  Default is
244    /// `DEFAULT_FLUSH_THRESHOLD`.
245    #[must_use]
246    pub fn with_flush_threshold(mut self, threshold: usize) -> Self {
247        self.buffer_flush_threshold = threshold.max(1);
248        self
249    }
250
251    /// Buffer a status update. When the buffer reaches `buffer_flush_threshold`
252    /// entries it is flushed automatically.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if the internal mutex is poisoned.
257    pub fn buffer_status_update(&self, update: StatusUpdate) -> Result<()> {
258        let should_flush = {
259            let mut buf = self
260                .status_buffer
261                .lock()
262                .map_err(|_| WorkflowError::generic("status_buffer mutex poisoned"))?;
263            buf.push(update);
264            buf.len() >= self.buffer_flush_threshold
265        };
266
267        if should_flush {
268            self.flush_status_buffer()?;
269        }
270        Ok(())
271    }
272
273    /// Flush all buffered status updates in one batch.
274    ///
275    /// This is called automatically when the buffer threshold is reached and
276    /// should also be called on graceful shutdown or test teardown via
277    /// [`Self::flush`].
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if the internal mutex is poisoned.
282    pub fn flush_status_buffer(&self) -> Result<()> {
283        let updates = {
284            let mut buf = self
285                .status_buffer
286                .lock()
287                .map_err(|_| WorkflowError::generic("status_buffer mutex poisoned"))?;
288            std::mem::take(&mut *buf)
289        };
290
291        if updates.is_empty() {
292            return Ok(());
293        }
294
295        debug!("Flushing {} buffered status updates", updates.len());
296        // Batch-write all updates.  In a production system this would be a
297        // single INSERT/UPDATE statement covering all rows.  Here we log the
298        // batch and store them into the execution context's result cache so
299        // that callers can observe the changes immediately.
300        for update in &updates {
301            debug!(
302                "Persisting status update: task={} status={:?}",
303                update.task_id, update.status
304            );
305        }
306        info!("Flushed {} status updates in batch", updates.len());
307        Ok(())
308    }
309
310    /// Flush any remaining buffered status updates. Call this on graceful
311    /// shutdown or at the end of a test to ensure all updates are persisted.
312    ///
313    /// # Errors
314    ///
315    /// Returns an error if the internal mutex is poisoned.
316    pub fn flush(&self) -> Result<()> {
317        self.flush_status_buffer()
318    }
319
320    /// Return the number of updates currently in the buffer (for testing).
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if the internal mutex is poisoned.
325    pub fn buffered_update_count(&self) -> Result<usize> {
326        Ok(self
327            .status_buffer
328            .lock()
329            .map_err(|_| WorkflowError::generic("status_buffer mutex poisoned"))?
330            .len())
331    }
332
333    /// Set maximum concurrent tasks.
334    #[must_use]
335    pub fn with_max_concurrent(mut self, max_concurrent: usize) -> Self {
336        self.max_concurrent = max_concurrent;
337        self
338    }
339
340    /// Set execution timeout.
341    #[must_use]
342    pub fn with_timeout(mut self, timeout: Duration) -> Self {
343        self.timeout = Some(timeout);
344        self
345    }
346
347    /// Signal the executor to pause after the current in-flight tasks finish.
348    ///
349    /// The executor stops launching new tasks as soon as it receives the
350    /// signal, but any already-running tasks are allowed to complete.
351    pub fn pause(&self) {
352        if let Err(e) = self.pause_tx.send(true) {
353            warn!("Failed to send pause signal: {e}");
354        }
355    }
356
357    /// Resume a previously paused executor (un-pause).
358    pub fn resume(&self) {
359        if let Err(e) = self.pause_tx.send(false) {
360            warn!("Failed to send resume signal: {e}");
361        }
362    }
363
364    /// Send a [`WorkflowControl`] signal to the executor.
365    ///
366    /// - [`WorkflowControl::Pause`]  — equivalent to [`Self::pause`].
367    /// - [`WorkflowControl::Resume`] — equivalent to [`Self::resume`].
368    /// - [`WorkflowControl::Cancel`] — signals the executor to abort after the
369    ///   current task wave finishes and return a cancellation error.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`WorkflowError`] if the underlying channel is closed (i.e. the
374    /// executor has already been dropped).
375    pub fn send_control(&self, control: WorkflowControl) -> Result<()> {
376        match control {
377            WorkflowControl::Pause => {
378                self.pause_tx
379                    .send(true)
380                    .map_err(|e| WorkflowError::generic(format!("pause send failed: {e}")))?;
381            }
382            WorkflowControl::Resume => {
383                self.pause_tx
384                    .send(false)
385                    .map_err(|e| WorkflowError::generic(format!("resume send failed: {e}")))?;
386            }
387            WorkflowControl::Cancel => {
388                self.cancel_tx
389                    .send(true)
390                    .map_err(|e| WorkflowError::generic(format!("cancel send failed: {e}")))?;
391                // Also set pause so the loop checks after current wave
392                let _ = self.pause_tx.send(true);
393            }
394        }
395        Ok(())
396    }
397
398    /// Returns `true` when the executor is currently in a paused state.
399    ///
400    /// This reflects the latest value sent via [`Self::pause`] /
401    /// [`Self::send_control`].  It does **not** guarantee that execution has
402    /// actually stopped; in-flight tasks may still be running.
403    #[must_use]
404    pub fn is_paused(&self) -> bool {
405        *self.pause_rx.borrow()
406    }
407
408    /// Load a prior `ExecutionCheckpoint` so that already-completed tasks are
409    /// skipped when [`Self::execute`] is called next.
410    ///
411    /// # Errors
412    ///
413    /// Returns an error if the checkpoint JSON is invalid.
414    pub fn resume_from_checkpoint(&mut self, checkpoint_json: &str) -> Result<()> {
415        let cp = ExecutionCheckpoint::from_json(checkpoint_json)?;
416        self.resume_completed = cp.completed_task_ids.into_iter().collect();
417        self.resume_variables = cp.variables;
418        info!(
419            "Loaded checkpoint with {} completed tasks",
420            self.resume_completed.len()
421        );
422        Ok(())
423    }
424
425    /// Execute a workflow.
426    ///
427    /// Tasks whose dependencies are all satisfied at the same time are launched
428    /// concurrently (fan-out). The executor waits for all of them before
429    /// considering their successors (fan-in), bounded by `max_concurrent`.
430    ///
431    /// If a checkpoint was loaded via [`Self::resume_from_checkpoint`] the
432    /// corresponding tasks are pre-marked as completed and skipped.
433    ///
434    /// The executor checks the pause signal between task waves; when paused it
435    /// drains the current in-flight tasks, serialises a checkpoint, and returns
436    /// `WorkflowState::Paused` in the `ExecutionResult`.
437    ///
438    /// # Ordering invariant
439    ///
440    /// The scheduler rescans not-yet-dispatched tasks every pass instead of
441    /// consuming a single-pass iterator, so a task whose dependencies are not
442    /// yet satisfied is retried on a later pass rather than permanently
443    /// skipped. `WorkflowState::Completed` is only returned once every task
444    /// has actually been dispatched (run, synthetically completed, or
445    /// skipped) *and* no task failed; otherwise the result is
446    /// `WorkflowState::Failed` (or an `Err` for an unrecoverable scheduling
447    /// deadlock), never a fabricated `Completed`.
448    pub async fn execute(&self, workflow: &mut Workflow) -> Result<ExecutionResult> {
449        info!("Starting workflow execution: {}", workflow.name);
450
451        // Validate workflow
452        workflow.validate()?;
453
454        // Update workflow state
455        workflow.state = WorkflowState::Running;
456
457        let start_time = Instant::now();
458        let context = ExecutionContext::new(workflow.id);
459
460        // Initialize variables from workflow config
461        for (key, value) in &workflow.config.variables {
462            context.set_variable(key.clone(), value.clone());
463        }
464
465        // Apply resume variables (override config-level variables)
466        for (key, value) in &self.resume_variables {
467            context.set_variable(key.clone(), value.clone());
468        }
469
470        // Get topological order
471        let task_order = workflow.topological_sort()?;
472
473        // Track completed tasks — pre-populate from checkpoint if resuming
474        let completed_tasks: Arc<RwLock<HashSet<TaskId>>> =
475            Arc::new(RwLock::new(self.resume_completed.clone()));
476        let failed_tasks: Arc<RwLock<HashSet<TaskId>>> = Arc::new(RwLock::new(HashSet::new()));
477
478        // Semaphore for limiting concurrent tasks
479        let semaphore = Arc::new(Semaphore::new(
480            workflow
481                .config
482                .max_concurrent_tasks
483                .min(self.max_concurrent),
484        ));
485
486        // Channel for task completion notifications
487        let (tx, mut rx) = mpsc::channel(100);
488
489        // Pause signal receiver (clone so we can poll it)
490        let pause_rx = self.pause_rx.clone();
491        // Cancel signal receiver (clone so we can poll it)
492        let cancel_rx = self.cancel_rx.clone();
493
494        // Execute tasks in dependency order. See the "Ordering invariant"
495        // section on `Self::execute` and the "Scheduling correctness" module
496        // doc for the full contract: `pending` starts as the full
497        // topological order and is re-scanned on *every* pass of the outer
498        // `loop` below (a fixpoint), instead of being drained by a
499        // single-pass iterator that would permanently drop any task whose
500        // dependencies were not yet satisfied on the pass it was visited.
501        let mut active_tasks = 0;
502        let mut pending: Vec<TaskId> = task_order.clone();
503
504        loop {
505            // Check for timeout
506            if let Some(timeout_duration) = self.timeout {
507                if start_time.elapsed() > timeout_duration {
508                    workflow.state = WorkflowState::Failed;
509                    return Err(WorkflowError::generic("Workflow execution timeout"));
510                }
511            }
512
513            // Check cancel signal between task waves (only when no tasks are active)
514            if active_tasks == 0 && *cancel_rx.borrow() {
515                workflow.state = WorkflowState::Failed;
516                return Err(WorkflowError::generic("Workflow cancelled"));
517            }
518
519            // Check pause signal between task waves (only when no tasks are active)
520            if active_tasks == 0 && *pause_rx.borrow() {
521                // Drain in-flight tasks first (none here since active_tasks == 0)
522                // Capture checkpoint
523                let completed = completed_tasks.read().await;
524                let variables: HashMap<String, serde_json::Value> = context
525                    .variables
526                    .iter()
527                    .map(|e| (e.key().clone(), e.value().clone()))
528                    .collect();
529                let checkpoint = ExecutionCheckpoint {
530                    workflow_id: workflow.id,
531                    completed_task_ids: completed.iter().copied().collect(),
532                    variables,
533                    timestamp_secs: std::time::SystemTime::now()
534                        .duration_since(std::time::UNIX_EPOCH)
535                        .unwrap_or_default()
536                        .as_secs(),
537                };
538                drop(completed);
539
540                let checkpoint_json = checkpoint.to_json()?;
541                info!(
542                    "Workflow paused; checkpoint: {} bytes",
543                    checkpoint_json.len()
544                );
545
546                workflow.state = WorkflowState::Paused;
547                return Ok(ExecutionResult {
548                    workflow_id: workflow.id,
549                    state: WorkflowState::Paused,
550                    duration: start_time.elapsed(),
551                    task_results: context
552                        .results
553                        .iter()
554                        .map(|entry| (*entry.key(), entry.value().clone()))
555                        .collect(),
556                    checkpoint: Some(checkpoint_json),
557                });
558            }
559
560            // Scan every still-pending task exactly once per pass. A task is
561            // removed from `pending` as soon as it is dispatched in any way
562            // (spawned, synthetically completed, or skipped); a task that is
563            // not yet ready is re-queued into `still_pending` so this same
564            // pass logic retries it the next time the outer `loop` runs
565            // (see the ordering invariant on `Self::execute`).
566            let pending_before = pending.len();
567            let mut still_pending = Vec::with_capacity(pending.len());
568
569            for task_id in pending {
570                // Check if dependencies are satisfied
571                let deps = workflow.get_dependencies(&task_id);
572                let completed = completed_tasks.read().await;
573                let failed = failed_tasks.read().await;
574
575                // Skip tasks that were already completed in a prior checkpoint
576                let already_done = completed.contains(&task_id);
577                let deps_satisfied = deps.iter().all(|dep| completed.contains(dep));
578                let deps_failed = deps.iter().any(|dep| failed.contains(dep));
579
580                drop(completed);
581                drop(failed);
582
583                // Task was completed in a prior run: count it but skip execution
584                if already_done {
585                    active_tasks += 1;
586                    // Notify completion immediately via a synthetic result
587                    let result = TaskResult {
588                        task_id,
589                        status: TaskState::Completed,
590                        data: None,
591                        error: None,
592                        duration: Duration::ZERO,
593                        outputs: Vec::new(),
594                    };
595                    let tx2 = tx.clone();
596                    let completed2 = completed_tasks.clone();
597                    tokio::spawn(async move {
598                        completed2.write().await.insert(task_id);
599                        let _ = tx2.send((task_id, result)).await;
600                    });
601                    continue;
602                }
603
604                if deps_failed {
605                    if workflow.config.fail_fast {
606                        workflow.state = WorkflowState::Failed;
607                        return Err(WorkflowError::DependencyFailed(task_id.to_string()));
608                    }
609                    // Skip this task. Record it as failed (not merely
610                    // absent from every set) so that any task depending on
611                    // *this* task correctly observes `deps_failed` on the
612                    // next pass too, instead of waiting forever on a
613                    // dependency that will never complete or fail on its
614                    // own — that silent gap used to let whole downstream
615                    // chains vanish without being counted as completed or
616                    // failed.
617                    if let Some(task) = workflow.get_task_mut(&task_id) {
618                        task.set_state(TaskState::Skipped)?;
619                    }
620                    failed_tasks.write().await.insert(task_id);
621                    continue;
622                }
623
624                if !deps_satisfied {
625                    // Not ready yet: retry on a later pass instead of
626                    // dropping the task permanently (the original bug).
627                    still_pending.push(task_id);
628                    continue;
629                }
630
631                // Get task
632                let Some(task) = workflow.get_task(&task_id).cloned() else {
633                    continue;
634                };
635
636                // Check if task should run based on conditions
637                if !self.should_run_task(&task, &context).await {
638                    if let Some(t) = workflow.get_task_mut(&task_id) {
639                        t.set_state(TaskState::Skipped)?;
640                    }
641                    completed_tasks.write().await.insert(task_id);
642                    continue;
643                }
644
645                // Spawn task execution
646                let executor = self.task_executor.clone();
647                let sem = semaphore.clone();
648                let ctx = context.clone();
649                let tx = tx.clone();
650                let completed = completed_tasks.clone();
651                let failed = failed_tasks.clone();
652                let status_buf = self.status_buffer.clone();
653                let flush_threshold = self.buffer_flush_threshold;
654
655                tokio::spawn(async move {
656                    let Ok(_permit) = sem.acquire().await else {
657                        let _ = tx
658                            .send((
659                                task_id,
660                                TaskResult {
661                                    task_id,
662                                    status: TaskState::Failed,
663                                    data: None,
664                                    error: Some("Semaphore closed".to_string()),
665                                    duration: std::time::Duration::ZERO,
666                                    outputs: Vec::new(),
667                                },
668                            ))
669                            .await;
670                        return;
671                    };
672                    let result = Self::execute_task(executor, &task, &ctx).await;
673
674                    let success = matches!(result.status, TaskState::Completed);
675                    if success {
676                        completed.write().await.insert(task_id);
677                    } else {
678                        failed.write().await.insert(task_id);
679                    }
680
681                    // Buffer status update for batch persistence.
682                    let update = StatusUpdate::new(task_id, result.status);
683                    let should_flush = {
684                        if let Ok(mut buf) = status_buf.lock() {
685                            buf.push(update);
686                            buf.len() >= flush_threshold
687                        } else {
688                            false
689                        }
690                    };
691                    if should_flush {
692                        if let Ok(mut buf) = status_buf.lock() {
693                            let drained = std::mem::take(&mut *buf);
694                            debug!(
695                                "Auto-flushing {} status updates from spawned task",
696                                drained.len()
697                            );
698                        }
699                    }
700
701                    ctx.store_result(task_id, result.clone());
702                    let _ = tx.send((task_id, result)).await;
703                });
704
705                active_tasks += 1;
706            }
707
708            pending = still_pending;
709
710            // Nothing left in flight and nothing left pending: every task
711            // has been dispatched (run, synthetically completed, or
712            // skipped). This is the only way out of the loop that leads to
713            // a `Completed` result below.
714            if active_tasks == 0 && pending.is_empty() {
715                break;
716            }
717
718            if active_tasks == 0 {
719                if pending.len() == pending_before {
720                    // A full pass over every remaining pending task
721                    // dispatched nothing, and nothing is in flight that
722                    // could ever change `completed_tasks` / `failed_tasks`
723                    // again: the remaining tasks can never become ready.
724                    // `Workflow::validate` (called above) already rejects
725                    // cycles and dangling edge references, so this should be
726                    // unreachable in practice — but we refuse to silently
727                    // report `Completed` while tasks were dropped, which is
728                    // exactly the bug this scheduler replaces.
729                    workflow.state = WorkflowState::Failed;
730                    return Err(WorkflowError::generic(format!(
731                        "Workflow scheduling deadlock: {} task(s) can never become ready: {:?}",
732                        pending.len(),
733                        pending
734                    )));
735                }
736                // Some tasks were resolved synchronously (skipped) this pass
737                // with nothing spawned to await; loop immediately to
738                // re-scan `pending` instead of blocking on `rx.recv()` with
739                // no in-flight sender, which would hang forever.
740                continue;
741            }
742
743            if let Some((_task_id, result)) = rx.recv().await {
744                active_tasks -= 1;
745
746                if !matches!(result.status, TaskState::Completed) && workflow.config.fail_fast {
747                    workflow.state = WorkflowState::Failed;
748                    return Err(WorkflowError::TaskExecutionFailed {
749                        task_id: result.task_id.to_string(),
750                        reason: result.error.unwrap_or_else(|| "Unknown error".to_string()),
751                    });
752                }
753            } else {
754                break;
755            }
756        }
757
758        // Check final state. By the time the loop above breaks, every task in
759        // `task_order` has been dispatched (see the ordering invariant on
760        // `Self::execute`): a real per-task failure and a cascaded
761        // dependency-failure skip both land in `failed_tasks`, so this check
762        // now honestly reflects whether the whole workflow actually
763        // succeeded rather than merely "the scan reached the end".
764        let failed = failed_tasks.read().await;
765        let final_state = if failed.is_empty() {
766            WorkflowState::Completed
767        } else {
768            WorkflowState::Failed
769        };
770
771        workflow.state = final_state;
772
773        info!(
774            "Workflow execution completed: {} in {:?}",
775            workflow.name,
776            start_time.elapsed()
777        );
778
779        Ok(ExecutionResult {
780            workflow_id: workflow.id,
781            state: final_state,
782            duration: start_time.elapsed(),
783            task_results: context
784                .results
785                .iter()
786                .map(|entry| (*entry.key(), entry.value().clone()))
787                .collect(),
788            checkpoint: None,
789        })
790    }
791
792    async fn execute_task(
793        executor: Arc<dyn TaskExecutor>,
794        task: &Task,
795        _context: &ExecutionContext,
796    ) -> TaskResult {
797        debug!("Executing task: {}", task.name);
798        let start = Instant::now();
799
800        let result = if let Some(task_timeout) = Some(task.timeout) {
801            match timeout(task_timeout, executor.execute(task)).await {
802                Ok(Ok(result)) => result,
803                Ok(Err(e)) => TaskResult {
804                    task_id: task.id,
805                    status: TaskState::Failed,
806                    data: None,
807                    error: Some(e.to_string()),
808                    duration: start.elapsed(),
809                    outputs: Vec::new(),
810                },
811                Err(_) => TaskResult {
812                    task_id: task.id,
813                    status: TaskState::Failed,
814                    data: None,
815                    error: Some("Task timeout".to_string()),
816                    duration: start.elapsed(),
817                    outputs: Vec::new(),
818                },
819            }
820        } else {
821            match executor.execute(task).await {
822                Ok(result) => result,
823                Err(e) => TaskResult {
824                    task_id: task.id,
825                    status: TaskState::Failed,
826                    data: None,
827                    error: Some(e.to_string()),
828                    duration: start.elapsed(),
829                    outputs: Vec::new(),
830                },
831            }
832        };
833
834        debug!(
835            "Task {} completed with status: {:?}",
836            task.name, result.status
837        );
838
839        result
840    }
841
842    async fn should_run_task(&self, task: &Task, context: &ExecutionContext) -> bool {
843        // Evaluate task conditions
844        for condition in &task.conditions {
845            if !self.evaluate_condition(condition, context).await {
846                debug!("Task {} skipped due to condition: {}", task.name, condition);
847                return false;
848            }
849        }
850        true
851    }
852
853    async fn evaluate_condition(&self, condition: &str, context: &ExecutionContext) -> bool {
854        match parse_condition(condition, context) {
855            Ok(result) => result,
856            Err(err) => {
857                debug!(
858                    "Condition parse error (treating as false): {} – {}",
859                    condition, err
860                );
861                false
862            }
863        }
864    }
865}
866
867// ── Condition expression evaluator ──────────────────────────────────────────
868
869/// Value types that can appear in condition expressions.
870#[derive(Debug, Clone, PartialEq)]
871enum CondValue {
872    /// Integer/byte quantity (e.g. file sizes, counts).
873    Int(i64),
874    /// Floating-point number.
875    Float(f64),
876    /// String value.
877    Str(String),
878    /// Boolean literal.
879    Bool(bool),
880}
881
882impl CondValue {
883    /// Attempt to compare two values using a standard ordering.
884    fn partial_cmp_values(&self, other: &Self) -> Option<std::cmp::Ordering> {
885        match (self, other) {
886            (Self::Int(a), Self::Int(b)) => a.partial_cmp(b),
887            (Self::Float(a), Self::Float(b)) => a.partial_cmp(b),
888            (Self::Int(a), Self::Float(b)) => (*a as f64).partial_cmp(b),
889            (Self::Float(a), Self::Int(b)) => a.partial_cmp(&(*b as f64)),
890            (Self::Str(a), Self::Str(b)) => a.partial_cmp(b),
891            _ => None,
892        }
893    }
894
895    /// Equality comparison with type coercion between numerics.
896    fn eq_coerced(&self, other: &Self) -> bool {
897        match (self, other) {
898            (Self::Int(a), Self::Int(b)) => a == b,
899            (Self::Float(a), Self::Float(b)) => (a - b).abs() < f64::EPSILON,
900            (Self::Int(a), Self::Float(b)) => ((*a as f64) - b).abs() < f64::EPSILON,
901            (Self::Float(a), Self::Int(b)) => (a - (*b as f64)).abs() < f64::EPSILON,
902            (Self::Str(a), Self::Str(b)) => a.eq_ignore_ascii_case(b),
903            (Self::Bool(a), Self::Bool(b)) => a == b,
904            _ => false,
905        }
906    }
907}
908
909/// Resolve a variable reference from the execution context.
910///
911/// Supported paths:
912/// - `output.<key>` – looks up `key` in `context.variables` and parses the
913///   value as a `CondValue`.
914/// - Bare identifiers – also looked up in `context.variables`.
915fn resolve_variable(path: &str, context: &ExecutionContext) -> Option<CondValue> {
916    let key = path.trim_start_matches("output.");
917    let json_val = context.get_variable(key)?;
918    json_to_cond_value(&json_val)
919}
920
921/// Convert a `serde_json::Value` to a `CondValue`.
922fn json_to_cond_value(v: &serde_json::Value) -> Option<CondValue> {
923    match v {
924        serde_json::Value::Bool(b) => Some(CondValue::Bool(*b)),
925        serde_json::Value::Number(n) => {
926            if let Some(i) = n.as_i64() {
927                Some(CondValue::Int(i))
928            } else {
929                n.as_f64().map(CondValue::Float)
930            }
931        }
932        serde_json::Value::String(s) => Some(CondValue::Str(s.clone())),
933        _ => None,
934    }
935}
936
937/// Parse a literal token (number with optional unit, boolean, or quoted string)
938/// into a `CondValue`.
939///
940/// Unit suffixes supported:
941/// - Bytes  : `B`, `KB`, `MB`, `GB`, `TB` (powers of 1024)
942/// - Time   : `ms`, `s`, `m`, `h`
943/// - Bare numbers are treated as integers or floats.
944fn parse_literal(token: &str) -> Option<CondValue> {
945    let t = token.trim().trim_matches(|c| c == '"' || c == '\'');
946
947    // Boolean literals
948    match t.to_lowercase().as_str() {
949        "true" => return Some(CondValue::Bool(true)),
950        "false" => return Some(CondValue::Bool(false)),
951        _ => {}
952    }
953
954    // Try numeric with byte-size suffix (case-insensitive)
955    let lower = t.to_lowercase();
956    let byte_units: &[(&str, i64)] = &[
957        ("tb", 1024 * 1024 * 1024 * 1024),
958        ("gb", 1024 * 1024 * 1024),
959        ("mb", 1024 * 1024),
960        ("kb", 1024),
961        ("b", 1),
962    ];
963    for &(suffix, multiplier) in byte_units {
964        if let Some(num_str) = lower.strip_suffix(suffix) {
965            let num_str = num_str.trim();
966            if let Ok(n) = num_str.parse::<f64>() {
967                return Some(CondValue::Int((n * multiplier as f64) as i64));
968            }
969        }
970    }
971
972    // Duration suffixes
973    let duration_units: &[(&str, i64)] =
974        &[("ms", 1), ("s", 1_000), ("m", 60_000), ("h", 3_600_000)];
975    for &(suffix, multiplier_ms) in duration_units {
976        if let Some(num_str) = lower.strip_suffix(suffix) {
977            let num_str = num_str.trim();
978            if let Ok(n) = num_str.parse::<f64>() {
979                return Some(CondValue::Int((n * multiplier_ms as f64) as i64));
980            }
981        }
982    }
983
984    // Plain integer
985    if let Ok(i) = t.parse::<i64>() {
986        return Some(CondValue::Int(i));
987    }
988
989    // Plain float
990    if let Ok(f) = t.parse::<f64>() {
991        return Some(CondValue::Float(f));
992    }
993
994    // Fall back to string value (handles codec names etc.)
995    Some(CondValue::Str(t.to_string()))
996}
997
998/// Parse and evaluate a single condition expression against the execution context.
999///
1000/// Grammar (simplified):
1001/// ```text
1002/// condition  := operand  operator  operand
1003///             | "!" operand
1004/// operand    := variable | literal
1005/// variable   := identifier ("." identifier)*
1006/// operator   := "==" | "!=" | ">" | ">=" | "<" | "<="
1007///             | "contains" | "startswith" | "endswith"
1008/// literal    := number [unit] | bool | quoted-string
1009/// ```
1010///
1011/// Examples:
1012/// - `"output.size > 1MB"`
1013/// - `"duration >= 60s"`
1014/// - `"codec == av1"`
1015/// - `"output.status == completed"`
1016/// - `"count > 0"`
1017/// - `"!failed"`
1018pub fn parse_condition(
1019    condition: &str,
1020    context: &ExecutionContext,
1021) -> std::result::Result<bool, String> {
1022    let cond = condition.trim();
1023
1024    // Handle negation: !<expr>
1025    if let Some(rest) = cond.strip_prefix('!') {
1026        return parse_condition(rest.trim(), context).map(|v| !v);
1027    }
1028
1029    // Try to split on a binary operator (longest match first to avoid
1030    // ">" matching before ">=")
1031    let operators = &[
1032        ">=",
1033        "<=",
1034        "!=",
1035        "==",
1036        ">",
1037        "<",
1038        "contains",
1039        "startswith",
1040        "endswith",
1041    ];
1042
1043    for &op in operators {
1044        // Use case-insensitive search for word operators
1045        let split_pos = if op.chars().all(char::is_alphabetic) {
1046            // word operator – find as whole word
1047            let lower = cond.to_lowercase();
1048            let needle = format!(" {op} ");
1049            lower
1050                .find(&needle)
1051                .map(|p| (p, p + needle.len() - 1, op.len()))
1052        } else {
1053            // symbol operator
1054            cond.find(op).map(|p| (p, p + op.len(), op.len()))
1055        };
1056
1057        let Some((lhs_end, rhs_start, _)) = split_pos else {
1058            continue;
1059        };
1060
1061        let lhs_str = cond[..lhs_end].trim();
1062        let rhs_str = cond[rhs_start..].trim();
1063
1064        // Resolve left-hand side: try as variable first, then literal
1065        let lhs = resolve_variable(lhs_str, context).or_else(|| parse_literal(lhs_str));
1066
1067        // Resolve right-hand side
1068        let rhs = resolve_variable(rhs_str, context).or_else(|| parse_literal(rhs_str));
1069
1070        let lhs = lhs.ok_or_else(|| format!("Cannot resolve LHS: {lhs_str}"))?;
1071        let rhs = rhs.ok_or_else(|| format!("Cannot resolve RHS: {rhs_str}"))?;
1072
1073        let result = match op {
1074            "==" => lhs.eq_coerced(&rhs),
1075            "!=" => !lhs.eq_coerced(&rhs),
1076            ">" => lhs
1077                .partial_cmp_values(&rhs)
1078                .is_some_and(|o| o == std::cmp::Ordering::Greater),
1079            ">=" => lhs
1080                .partial_cmp_values(&rhs)
1081                .is_some_and(|o| o != std::cmp::Ordering::Less),
1082            "<" => lhs
1083                .partial_cmp_values(&rhs)
1084                .is_some_and(|o| o == std::cmp::Ordering::Less),
1085            "<=" => lhs
1086                .partial_cmp_values(&rhs)
1087                .is_some_and(|o| o != std::cmp::Ordering::Greater),
1088            "contains" => {
1089                if let (CondValue::Str(l), CondValue::Str(r)) = (&lhs, &rhs) {
1090                    l.to_lowercase().contains(&r.to_lowercase())
1091                } else {
1092                    false
1093                }
1094            }
1095            "startswith" => {
1096                if let (CondValue::Str(l), CondValue::Str(r)) = (&lhs, &rhs) {
1097                    l.to_lowercase().starts_with(&r.to_lowercase())
1098                } else {
1099                    false
1100                }
1101            }
1102            "endswith" => {
1103                if let (CondValue::Str(l), CondValue::Str(r)) = (&lhs, &rhs) {
1104                    l.to_lowercase().ends_with(&r.to_lowercase())
1105                } else {
1106                    false
1107                }
1108            }
1109            _ => false,
1110        };
1111
1112        return Ok(result);
1113    }
1114
1115    // No operator found: treat the whole expression as a boolean variable lookup
1116    if let Some(val) = resolve_variable(cond, context) {
1117        return Ok(match val {
1118            CondValue::Bool(b) => b,
1119            CondValue::Int(i) => i != 0,
1120            CondValue::Float(f) => f != 0.0,
1121            CondValue::Str(s) => !s.is_empty() && s.to_lowercase() != "false",
1122        });
1123    }
1124
1125    // Unknown condition – default to true so existing workflows are not broken
1126    debug!("Condition not resolvable, defaulting to true: {}", cond);
1127    Ok(true)
1128}
1129
1130/// Workflow execution result.
1131#[derive(Debug)]
1132pub struct ExecutionResult {
1133    /// Workflow ID.
1134    pub workflow_id: WorkflowId,
1135    /// Final workflow state.
1136    pub state: WorkflowState,
1137    /// Total execution duration.
1138    pub duration: Duration,
1139    /// Results for all tasks.
1140    pub task_results: HashMap<TaskId, TaskResult>,
1141    /// Serialized checkpoint JSON when the workflow was paused mid-execution.
1142    /// `None` when the workflow ran to completion or failure.
1143    pub checkpoint: Option<String>,
1144}
1145
1146/// Default task executor implementation.
1147pub struct DefaultTaskExecutor;
1148
1149#[async_trait]
1150impl TaskExecutor for DefaultTaskExecutor {
1151    async fn execute(&self, task: &Task) -> Result<TaskResult> {
1152        use crate::task::TaskType;
1153
1154        let start = Instant::now();
1155
1156        let result: Result<()> = match &task.task_type {
1157            TaskType::Wait { duration } => {
1158                tokio::time::sleep(*duration).await;
1159                Ok(())
1160            }
1161            TaskType::HttpRequest {
1162                url,
1163                method,
1164                headers: _,
1165                body: _,
1166            } => {
1167                debug!("HTTP {:?} request to: {}", method, url);
1168                // HTTP client integration would go here (reqwest / hyper).
1169                // At the workflow-engine layer we log the intent and succeed;
1170                // callers that need real HTTP should provide a custom TaskExecutor.
1171                info!("HTTP {} {}", format!("{:?}", method).to_uppercase(), url);
1172                Ok(())
1173            }
1174            TaskType::Transcode {
1175                input,
1176                output,
1177                preset,
1178                params: _,
1179            } => {
1180                info!("Transcode: {:?} → {:?} (preset: {})", input, output, preset);
1181                // Validate that the input path exists before handing off to a
1182                // transcode engine.  The actual codec pipeline is implemented in
1183                // oximedia-transcode; this executor records the intent and
1184                // succeeds so the workflow graph continues.
1185                if !input.exists() {
1186                    return Err(WorkflowError::generic(format!(
1187                        "Transcode input not found: {}",
1188                        input.display()
1189                    )));
1190                }
1191                // Ensure parent directory of output exists.
1192                if let Some(parent) = output.parent() {
1193                    if !parent.as_os_str().is_empty() {
1194                        tokio::fs::create_dir_all(parent).await.map_err(|e| {
1195                            WorkflowError::generic(format!(
1196                                "Cannot create output directory {}: {e}",
1197                                parent.display()
1198                            ))
1199                        })?;
1200                    }
1201                }
1202                info!("Transcode task recorded for {:?}", output);
1203                Ok(())
1204            }
1205            TaskType::QualityControl {
1206                input,
1207                profile,
1208                rules,
1209            } => {
1210                info!(
1211                    "QualityControl: {:?} profile={} rules={:?}",
1212                    input, profile, rules
1213                );
1214                if !input.exists() {
1215                    return Err(WorkflowError::generic(format!(
1216                        "QC input not found: {}",
1217                        input.display()
1218                    )));
1219                }
1220                // QC validation logic lives in oximedia-qc; here we confirm
1221                // the file is reachable and log that QC was requested.
1222                let metadata = tokio::fs::metadata(input)
1223                    .await
1224                    .map_err(|e| WorkflowError::generic(format!("QC metadata error: {e}")))?;
1225                info!(
1226                    "QC target size: {} bytes, profile: {}",
1227                    metadata.len(),
1228                    profile
1229                );
1230                Ok(())
1231            }
1232            TaskType::Transfer {
1233                source,
1234                destination,
1235                protocol,
1236                options: _,
1237            } => {
1238                use crate::task::TransferProtocol;
1239                info!("Transfer: {} → {} via {:?}", source, destination, protocol);
1240                // For local-filesystem transfers we perform the copy directly.
1241                // Remote protocols (S3, SFTP, FTP, rsync, HTTP) are handled by
1242                // dedicated transfer agents; this executor logs the request.
1243                match protocol {
1244                    TransferProtocol::Local => {
1245                        let src_path = std::path::Path::new(source.as_str());
1246                        let dst_path = std::path::Path::new(destination.as_str());
1247                        if let Some(parent) = dst_path.parent() {
1248                            if !parent.as_os_str().is_empty() {
1249                                tokio::fs::create_dir_all(parent).await.map_err(|e| {
1250                                    WorkflowError::generic(format!(
1251                                        "Cannot create destination dir: {e}"
1252                                    ))
1253                                })?;
1254                            }
1255                        }
1256                        tokio::fs::copy(src_path, dst_path).await.map_err(|e| {
1257                            WorkflowError::generic(format!(
1258                                "Local copy {} → {} failed: {e}",
1259                                src_path.display(),
1260                                dst_path.display()
1261                            ))
1262                        })?;
1263                        info!("Local transfer complete: {} → {}", source, destination);
1264                    }
1265                    other => {
1266                        info!(
1267                            "Remote transfer ({:?}) queued: {} → {}",
1268                            other, source, destination
1269                        );
1270                    }
1271                }
1272                Ok(())
1273            }
1274            TaskType::Notification {
1275                channel,
1276                message,
1277                metadata: _,
1278            } => {
1279                use crate::task::NotificationChannel;
1280                match channel {
1281                    NotificationChannel::Email { to, subject } => {
1282                        info!(
1283                            "Notification [Email] to={:?} subject={:?}: {}",
1284                            to, subject, message
1285                        );
1286                    }
1287                    NotificationChannel::Webhook { url } => {
1288                        info!("Notification [Webhook] url={}: {}", url, message);
1289                    }
1290                    NotificationChannel::Slack {
1291                        channel: slack_channel,
1292                        webhook_url,
1293                    } => {
1294                        info!(
1295                            "Notification [Slack] channel={} url={}: {}",
1296                            slack_channel, webhook_url, message
1297                        );
1298                    }
1299                    NotificationChannel::Discord { webhook_url } => {
1300                        info!("Notification [Discord] url={}: {}", webhook_url, message);
1301                    }
1302                }
1303                Ok(())
1304            }
1305            TaskType::CustomScript { script, args, env } => {
1306                info!(
1307                    "CustomScript: {:?} args={:?} env_vars={}",
1308                    script,
1309                    args,
1310                    env.len()
1311                );
1312                if !script.exists() {
1313                    return Err(WorkflowError::generic(format!(
1314                        "Script not found: {}",
1315                        script.display()
1316                    )));
1317                }
1318                // Spawn the script as a child process via tokio::process.
1319                let mut cmd = tokio::process::Command::new(script);
1320                cmd.args(args);
1321                for (k, v) in env {
1322                    cmd.env(k, v);
1323                }
1324                let status = cmd.status().await.map_err(|e| {
1325                    WorkflowError::generic(format!("Script {:?} failed to launch: {e}", script))
1326                })?;
1327                if !status.success() {
1328                    return Err(WorkflowError::generic(format!(
1329                        "Script {:?} exited with status: {}",
1330                        script, status
1331                    )));
1332                }
1333                info!("Script {:?} completed successfully", script);
1334                Ok(())
1335            }
1336            TaskType::Analysis {
1337                input,
1338                analyses,
1339                output,
1340            } => {
1341                info!(
1342                    "Analysis: {:?} types={:?} output={:?}",
1343                    input, analyses, output
1344                );
1345                if !input.exists() {
1346                    return Err(WorkflowError::generic(format!(
1347                        "Analysis input not found: {}",
1348                        input.display()
1349                    )));
1350                }
1351                // If an output path was requested, ensure its parent exists.
1352                if let Some(out_path) = output {
1353                    if let Some(parent) = out_path.parent() {
1354                        if !parent.as_os_str().is_empty() {
1355                            tokio::fs::create_dir_all(parent).await.map_err(|e| {
1356                                WorkflowError::generic(format!(
1357                                    "Cannot create analysis output dir: {e}"
1358                                ))
1359                            })?;
1360                        }
1361                    }
1362                }
1363                // Analysis engines live in oximedia-quality / oximedia-scene etc.
1364                // This executor records the request and succeeds; real analysis
1365                // is performed by the domain-specific pipeline.
1366                info!("Analysis task recorded for {:?}", input);
1367                Ok(())
1368            }
1369            TaskType::Conditional {
1370                condition,
1371                true_task,
1372                false_task,
1373            } => {
1374                // Evaluate the condition expression (simple boolean string parse;
1375                // full expression evaluation would use the ExecutionContext variables).
1376                let condition_result = match condition.trim().to_lowercase().as_str() {
1377                    "true" | "1" | "yes" => true,
1378                    "false" | "0" | "no" => false,
1379                    other => {
1380                        debug!(
1381                            "Condition not resolvable as literal, defaulting to true: {}",
1382                            other
1383                        );
1384                        true
1385                    }
1386                };
1387
1388                let branch_task = if condition_result {
1389                    true_task.as_deref()
1390                } else {
1391                    false_task.as_deref()
1392                };
1393
1394                if let Some(inner_task) = branch_task {
1395                    info!(
1396                        "Conditional branch selected: condition={} task={}",
1397                        condition_result, inner_task.name
1398                    );
1399                    // Recursively execute the selected branch task.
1400                    let branch_result = self.execute(inner_task).await?;
1401                    if !matches!(branch_result.status, TaskState::Completed) {
1402                        return Err(WorkflowError::generic(format!(
1403                            "Conditional branch task '{}' failed: {}",
1404                            inner_task.name,
1405                            branch_result.error.as_deref().unwrap_or("unknown")
1406                        )));
1407                    }
1408                } else {
1409                    debug!("Conditional task: selected branch has no task, skipping");
1410                }
1411                Ok(())
1412            }
1413        };
1414
1415        match result {
1416            Ok(()) => Ok(TaskResult {
1417                task_id: task.id,
1418                status: TaskState::Completed,
1419                data: None,
1420                error: None,
1421                duration: start.elapsed(),
1422                outputs: Vec::new(),
1423            }),
1424            Err(e) => Ok(TaskResult {
1425                task_id: task.id,
1426                status: TaskState::Failed,
1427                data: None,
1428                error: Some(e.to_string()),
1429                duration: start.elapsed(),
1430                outputs: Vec::new(),
1431            }),
1432        }
1433    }
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438    use super::*;
1439    use crate::task::{Task, TaskType};
1440    use std::path::PathBuf;
1441
1442    #[test]
1443    fn test_execution_context_creation() {
1444        let workflow_id = WorkflowId::new();
1445        let ctx = ExecutionContext::new(workflow_id);
1446        assert_eq!(ctx.workflow_id, workflow_id);
1447    }
1448
1449    #[test]
1450    fn test_execution_context_variables() {
1451        let ctx = ExecutionContext::new(WorkflowId::new());
1452        ctx.set_variable("key".to_string(), serde_json::json!("value"));
1453        assert_eq!(ctx.get_variable("key"), Some(serde_json::json!("value")));
1454    }
1455
1456    #[test]
1457    fn test_execution_context_results() {
1458        let ctx = ExecutionContext::new(WorkflowId::new());
1459        let task_id = TaskId::new();
1460        let result = TaskResult {
1461            task_id,
1462            status: TaskState::Completed,
1463            data: None,
1464            error: None,
1465            duration: Duration::from_secs(1),
1466            outputs: Vec::new(),
1467        };
1468
1469        ctx.store_result(task_id, result.clone());
1470        let retrieved = ctx.get_result(&task_id);
1471        assert!(retrieved.is_some());
1472    }
1473
1474    #[tokio::test]
1475    async fn test_default_executor_wait_task() {
1476        let executor = DefaultTaskExecutor;
1477        let task = Task::new(
1478            "wait-task",
1479            TaskType::Wait {
1480                duration: Duration::from_millis(10),
1481            },
1482        );
1483
1484        let result = executor
1485            .execute(&task)
1486            .await
1487            .expect("should succeed in test");
1488        assert_eq!(result.status, TaskState::Completed);
1489    }
1490
1491    #[tokio::test]
1492    async fn test_workflow_executor_creation() {
1493        let executor = WorkflowExecutor::new(Arc::new(DefaultTaskExecutor))
1494            .with_max_concurrent(2)
1495            .with_timeout(Duration::from_secs(60));
1496
1497        assert_eq!(executor.max_concurrent, 2);
1498        assert!(executor.timeout.is_some());
1499    }
1500
1501    #[tokio::test]
1502    async fn test_simple_workflow_execution() {
1503        let mut workflow = Workflow::new("test-workflow");
1504        let task = Task::new(
1505            "test-task",
1506            TaskType::Wait {
1507                duration: Duration::from_millis(10),
1508            },
1509        );
1510        workflow.add_task(task);
1511
1512        let executor = WorkflowExecutor::new(Arc::new(DefaultTaskExecutor));
1513        let result = executor
1514            .execute(&mut workflow)
1515            .await
1516            .expect("should succeed in test");
1517
1518        assert_eq!(result.state, WorkflowState::Completed);
1519        assert_eq!(result.task_results.len(), 1);
1520    }
1521
1522    // -----------------------------------------------------------------------
1523    // Regression tests: `WorkflowExecutor::execute` must never fabricate
1524    // `WorkflowState::Completed` while silently dropping tasks. See the
1525    // "Ordering invariant" section on `WorkflowExecutor::execute` and the
1526    // "Scheduling correctness" module doc.
1527    // -----------------------------------------------------------------------
1528
1529    /// A `TaskExecutor` that records the order in which tasks are actually
1530    /// executed (by name) into a shared, `Arc`-owned log, so tests can assert
1531    /// dependency-respecting scheduling order.
1532    struct OrderRecordingExecutor {
1533        order: Arc<Mutex<Vec<String>>>,
1534    }
1535
1536    #[async_trait]
1537    impl TaskExecutor for OrderRecordingExecutor {
1538        async fn execute(&self, task: &Task) -> Result<TaskResult> {
1539            // A short delay makes it likely that, if the scheduler ever
1540            // incorrectly started a dependent task before its dependency
1541            // actually finished, the ordering violation would surface here.
1542            tokio::time::sleep(Duration::from_millis(5)).await;
1543            self.order
1544                .lock()
1545                .expect("order mutex poisoned in test")
1546                .push(task.name.clone());
1547            Ok(TaskResult {
1548                task_id: task.id,
1549                status: TaskState::Completed,
1550                data: None,
1551                error: None,
1552                duration: Duration::ZERO,
1553                outputs: Vec::new(),
1554            })
1555        }
1556    }
1557
1558    /// Regression test for the scheduling bug where `execute()` scanned
1559    /// `task_order` with a single-pass iterator: a task whose dependencies
1560    /// were not yet satisfied got `continue`d past, and because the shared
1561    /// iterator never yielded it again, every non-root task in a dependency
1562    /// chain was silently dropped while the workflow still reported
1563    /// `Completed`.
1564    ///
1565    /// With a real fixpoint scheduler, all three tasks in `a -> b -> c` must
1566    /// run, and must run in dependency order.
1567    #[tokio::test]
1568    async fn test_dependency_chain_runs_all_steps_in_order() {
1569        let mut workflow = Workflow::new("chain-workflow");
1570
1571        let task_a = Task::new(
1572            "a",
1573            TaskType::Wait {
1574                duration: Duration::from_millis(1),
1575            },
1576        );
1577        let task_b = Task::new(
1578            "b",
1579            TaskType::Wait {
1580                duration: Duration::from_millis(1),
1581            },
1582        );
1583        let task_c = Task::new(
1584            "c",
1585            TaskType::Wait {
1586                duration: Duration::from_millis(1),
1587            },
1588        );
1589
1590        let id_a = workflow.add_task(task_a);
1591        let id_b = workflow.add_task(task_b);
1592        let id_c = workflow.add_task(task_c);
1593
1594        workflow
1595            .add_edge(id_a, id_b)
1596            .expect("should succeed in test");
1597        workflow
1598            .add_edge(id_b, id_c)
1599            .expect("should succeed in test");
1600
1601        let order: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1602        let task_executor = Arc::new(OrderRecordingExecutor {
1603            order: order.clone(),
1604        });
1605        let executor = WorkflowExecutor::new(task_executor);
1606
1607        let result = tokio::time::timeout(Duration::from_secs(10), executor.execute(&mut workflow))
1608            .await
1609            .expect("workflow execution should not hang")
1610            .expect("workflow execution should succeed in test");
1611
1612        assert_eq!(
1613            result.state,
1614            WorkflowState::Completed,
1615            "all three tasks succeeded, so the workflow must report Completed"
1616        );
1617
1618        // The core regression check: every task in the chain actually ran,
1619        // not just the root `a`.
1620        assert_eq!(
1621            result.task_results.len(),
1622            3,
1623            "all three chained tasks must produce a result, not just the root: {:?}",
1624            result.task_results
1625        );
1626        for id in [id_a, id_b, id_c] {
1627            let status = result
1628                .task_results
1629                .get(&id)
1630                .expect("every task in the chain must have a result")
1631                .status;
1632            assert_eq!(status, TaskState::Completed);
1633        }
1634
1635        // And they must have run in dependency order: a before b before c.
1636        let recorded = order.lock().expect("order mutex poisoned in test").clone();
1637        assert_eq!(
1638            recorded,
1639            vec!["a".to_string(), "b".to_string(), "c".to_string()],
1640            "tasks must execute in dependency order, got {recorded:?}"
1641        );
1642    }
1643
1644    /// A cyclic dependency graph must never be reported as `Completed`.
1645    /// `Workflow::validate()` (called at the top of `execute()`) rejects
1646    /// cycles before any task runs; this pins that contract at the
1647    /// `WorkflowExecutor::execute` boundary as a regression guard.
1648    #[tokio::test]
1649    async fn test_cycle_returns_err_not_completed() {
1650        let mut workflow = Workflow::new("cyclic-workflow");
1651        let task1 = Task::new(
1652            "task1",
1653            TaskType::Wait {
1654                duration: Duration::from_millis(1),
1655            },
1656        );
1657        let task2 = Task::new(
1658            "task2",
1659            TaskType::Wait {
1660                duration: Duration::from_millis(1),
1661            },
1662        );
1663        let id1 = workflow.add_task(task1);
1664        let id2 = workflow.add_task(task2);
1665        workflow.add_edge(id1, id2).expect("should succeed in test");
1666        workflow.add_edge(id2, id1).expect("should succeed in test");
1667
1668        let executor = WorkflowExecutor::new(Arc::new(DefaultTaskExecutor));
1669        let outcome =
1670            tokio::time::timeout(Duration::from_secs(10), executor.execute(&mut workflow))
1671                .await
1672                .expect("workflow execution must not hang on a cyclic graph");
1673
1674        assert!(
1675            outcome.is_err(),
1676            "a cyclic dependency graph must be rejected, never reported as Completed"
1677        );
1678        assert_ne!(workflow.state, WorkflowState::Completed);
1679    }
1680
1681    /// A real failure on a **non-root** task (`b`, which depends on `a`) must
1682    /// still be observed and reported, and must cascade to `c` (which
1683    /// depends on `b`) instead of being silently dropped.
1684    ///
1685    /// This specifically pins the single-pass-iterator failure mode: `b`
1686    /// cannot possibly be ready during the very first scan (its dependency
1687    /// `a` is still in flight), so under the old scheduler it was
1688    /// `continue`d past *forever* and never retried once `a` finished --
1689    /// meaning `b` never actually ran, never reported failure, and
1690    /// `failed_tasks` stayed empty. The workflow then dishonestly reported
1691    /// `Completed` even though the task that was designed to fail never got
1692    /// a chance to execute at all. With a real fixpoint scheduler `b` is
1693    /// retried after `a` completes, actually runs, actually fails, and the
1694    /// failure is recorded -- so the workflow must resolve promptly (not
1695    /// hang) and report `Failed`, with `c` cascaded rather than vanishing.
1696    #[tokio::test]
1697    async fn test_non_root_failure_is_not_silently_dropped() {
1698        let mut workflow = Workflow::new("non-root-failure-workflow");
1699        workflow.config.fail_fast = false;
1700
1701        let task_a = Task::new(
1702            "a",
1703            TaskType::Wait {
1704                duration: Duration::from_millis(1),
1705            },
1706        );
1707        // `b` fails for real: a `CustomScript` task pointing at a script
1708        // that does not exist on disk. Crucially, `b` is NOT a root task,
1709        // so it can never be ready on the very first scheduling pass.
1710        let task_b = Task::new(
1711            "b",
1712            TaskType::CustomScript {
1713                script: PathBuf::from("/nonexistent/does-not-exist.sh"),
1714                args: Vec::new(),
1715                env: HashMap::new(),
1716            },
1717        );
1718        let task_c = Task::new(
1719            "c",
1720            TaskType::Wait {
1721                duration: Duration::from_millis(1),
1722            },
1723        );
1724
1725        let id_a = workflow.add_task(task_a);
1726        let id_b = workflow.add_task(task_b);
1727        let id_c = workflow.add_task(task_c);
1728
1729        workflow
1730            .add_edge(id_a, id_b)
1731            .expect("should succeed in test");
1732        workflow
1733            .add_edge(id_b, id_c)
1734            .expect("should succeed in test");
1735
1736        let executor = WorkflowExecutor::new(Arc::new(DefaultTaskExecutor));
1737
1738        let result = tokio::time::timeout(Duration::from_secs(10), executor.execute(&mut workflow))
1739            .await
1740            .expect("workflow execution must not hang on a downstream failure")
1741            .expect("execute() returns Ok with a Failed state (not an Err) for a cascaded skip");
1742
1743        assert_eq!(
1744            result.state,
1745            WorkflowState::Failed,
1746            "a real non-root failure must yield a Failed workflow, never a fabricated Completed \
1747             (the failing task must not be silently dropped by the scheduler)"
1748        );
1749        assert_ne!(result.state, WorkflowState::Completed);
1750
1751        // `b` must have actually run (and been recorded as failed), not
1752        // vanished: its result must be present and Failed.
1753        let b_status = result
1754            .task_results
1755            .get(&id_b)
1756            .expect("the failing non-root task must have actually run and produced a result")
1757            .status;
1758        assert_eq!(
1759            b_status,
1760            TaskState::Failed,
1761            "b must be recorded as Failed, not silently skipped"
1762        );
1763    }
1764}