Skip to main content

luft_runtime/
pipeline.rs

1//! Streaming Pipeline — M2 implementation.
2//!
3//! A pipeline is a multi-stage streaming processor: items flow through stages
4//! sequentially, and each item progresses to the next stage as soon as the
5//! current stage completes (no barrier between stages).
6//!
7//! # Architecture
8//!
9//! ```text
10//! Input  → [Stage 0] → [Stage 1] → ... → [Stage N] → Output
11//!          (worker 0)   (worker 1)         (worker N)
12//! ```
13//!
14//! Each stage runs an independent tokio task connected by channels.
15//! Items flow through stages one at a time — item A can be in stage 1
16//! while item B is still in stage 0 (streaming, not barrier).
17//!
18//! # Comparison to parallel()
19//!
20//! - `parallel()`: barrier — all items must finish before proceeding.
21//! - `pipeline()`: streaming — items pass through each stage independently.
22
23use luft_core::contract::backend::AgentStatus;
24use luft_core::contract::event::{AgentEvent, EventSender};
25use luft_core::contract::ids::TokenUsage;
26use std::sync::Arc;
27use std::time::Instant;
28use thiserror::Error;
29
30// ============================================================================
31// Errors
32// ============================================================================
33
34#[derive(Error, Debug)]
35pub enum PipelineError {
36    #[error("no items provided")]
37    NoItems,
38    #[error("no stages configured")]
39    NoStages,
40    #[error("pipeline already executed (state: {state:?})")]
41    AlreadyExecuted { state: String },
42    #[error("pipeline was cancelled")]
43    Cancelled,
44    #[error("stage handler error: {0}")]
45    StageError(String),
46}
47
48// ============================================================================
49// Pipeline Config
50// ============================================================================
51
52/// Configuration for a pipeline execution.
53#[derive(Debug, Clone)]
54pub struct PipelineConfig {
55    /// Ordered list of stage definitions.
56    pub stages: Vec<PipelineStage>,
57    /// Timeout in milliseconds for the entire pipeline.
58    pub timeout_ms: u64,
59}
60
61impl Default for PipelineConfig {
62    fn default() -> Self {
63        Self {
64            stages: Vec::new(),
65            timeout_ms: 300_000, // 5 minutes
66        }
67    }
68}
69
70/// A single pipeline stage with its handler.
71#[derive(Clone)]
72pub struct PipelineStage {
73    /// Human-readable label for this stage.
74    pub label: String,
75    /// Handler function: takes item JSON, returns processed JSON or error.
76    /// This is the bridge between the Rust pipeline framework and the Lua SDK.
77    pub handler: Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
78}
79
80impl std::fmt::Debug for PipelineStage {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct("PipelineStage")
83            .field("label", &self.label)
84            .field("handler", &"<fn>")
85            .finish()
86    }
87}
88
89impl PipelineStage {
90    pub fn new<F>(label: &str, handler: F) -> Self
91    where
92        F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
93    {
94        Self {
95            label: label.to_string(),
96            handler: Arc::new(handler),
97        }
98    }
99}
100
101// ============================================================================
102// Pipeline Item
103// ============================================================================
104
105/// A single item flowing through the pipeline.
106#[derive(Debug, Clone)]
107pub struct PipelineItem {
108    /// Original item index (0-based).
109    pub index: usize,
110    /// Current stage index this item is at.
111    pub stage_index: usize,
112    /// The item's data as a JSON value.
113    pub data: serde_json::Value,
114    /// Per-stage status tracking.
115    pub stage_statuses: Vec<StageStatus>,
116    /// Elapsed time per stage in milliseconds.
117    pub stage_elapsed: Vec<u64>,
118}
119
120/// Status of an item at a specific stage.
121#[derive(Debug, Clone, PartialEq)]
122pub enum StageStatus {
123    Pending,
124    Running,
125    Ok,
126    Failed(String),
127}
128
129impl PipelineItem {
130    pub fn new(index: usize, data: serde_json::Value, n_stages: usize) -> Self {
131        Self {
132            index,
133            stage_index: 0,
134            data,
135            stage_statuses: vec![StageStatus::Pending; n_stages],
136            stage_elapsed: vec![0; n_stages],
137        }
138    }
139}
140
141// ============================================================================
142// Pipeline Result
143// ============================================================================
144
145/// Final result of a pipeline execution.
146#[derive(Debug, Clone)]
147pub struct PipelineResult {
148    /// Per-item results.
149    pub items: Vec<PipelineItemResult>,
150    /// Aggregate statistics.
151    pub stats: PipelineStats,
152}
153
154#[derive(Debug, Clone)]
155pub struct PipelineItemResult {
156    /// Original item index.
157    pub item_index: usize,
158    /// Final output data (after all stages).
159    pub output: serde_json::Value,
160    /// Per-stage outcomes.
161    pub stage_results: Vec<StageResult>,
162}
163
164#[derive(Debug, Clone)]
165pub struct StageResult {
166    pub label: String,
167    pub status: StageStatus,
168    pub elapsed_ms: u64,
169}
170
171#[derive(Debug, Clone, Default)]
172pub struct PipelineStats {
173    pub total_items: usize,
174    pub total_stages: usize,
175    pub ok: usize,
176    pub failed: usize,
177    pub total_elapsed_ms: u64,
178}
179
180// ============================================================================
181// Pipeline Executor
182// ============================================================================
183
184/// The core pipeline execution engine.
185///
186/// Manages the flow of items through stages using tokio channels.
187pub struct PipelineExecutor {
188    config: PipelineConfig,
189    event_tx: Option<EventSender>,
190    run_id: uuid::Uuid,
191}
192
193impl PipelineExecutor {
194    /// Create a new pipeline executor.
195    pub fn new(config: PipelineConfig, event_tx: Option<EventSender>, run_id: uuid::Uuid) -> Self {
196        Self {
197            config,
198            event_tx,
199            run_id,
200        }
201    }
202
203    /// Execute the pipeline with the given items.
204    ///
205    /// Items flow through each stage sequentially. Stage 0 processes all items
206    /// first, then each item moves to Stage 1, etc. Within each stage, items
207    /// are processed sequentially through each stage.
208    pub fn execute(&self, items: Vec<serde_json::Value>) -> Result<PipelineResult, PipelineError> {
209        if items.is_empty() {
210            return Err(PipelineError::NoItems);
211        }
212        if self.config.stages.is_empty() {
213            return Err(PipelineError::NoStages);
214        }
215
216        let n_stages = self.config.stages.len();
217        let n_items = items.len();
218        tracing::info!(n_stages, n_items, "pipeline execution started");
219        let run_id = self.run_id;
220
221        // Emit PipelineStarted event
222        self.emit(AgentEvent::PipelineStarted {
223            run_id,
224            total_stages: n_stages,
225            items: n_items,
226        });
227
228        let pipeline_start = Instant::now();
229
230        // Create initial PipelineItem wrappers
231        let mut current_items: Vec<PipelineItem> = items
232            .into_iter()
233            .enumerate()
234            .map(|(i, data)| PipelineItem::new(i, data, n_stages))
235            .collect();
236
237        // Process stages sequentially
238        for (stage_idx, stage) in self.config.stages.iter().enumerate() {
239            let stage_start = Instant::now();
240            let stage_label = stage.label.clone();
241            tracing::info!(stage_idx, %stage_label, items = current_items.len(), "pipeline stage started");
242
243            // Emit PipelineStageStarted event
244            self.emit(AgentEvent::PipelineStageStarted {
245                run_id,
246                stage_index: stage_idx,
247                label: stage_label.clone(),
248                agents_in_stage: current_items.len(),
249            });
250
251            // Process each item through this stage. Handlers run inline (not on
252            // a separate task): they are synchronous closures that may call back
253            // into the Lua VM, which is single-threaded and already held by the
254            // caller — spawning them onto worker threads would deadlock on the
255            // VM lock. Stages remain a barrier (all items finish stage N before
256            // stage N+1), matching the documented per-stage progression.
257            for item in current_items.iter_mut() {
258                item.stage_index = stage_idx;
259                item.stage_statuses[stage_idx] = StageStatus::Running;
260
261                let item_start = Instant::now();
262                let result = (stage.handler)(item.data.clone());
263                let elapsed = item_start.elapsed().as_millis() as u64;
264
265                if let Some(ref tx) = self.event_tx {
266                    let status = match &result {
267                        Ok(_) => AgentStatus::Ok,
268                        Err(_) => AgentStatus::Error,
269                    };
270                    let _ = tx.send(AgentEvent::PipelineItemDone {
271                        run_id,
272                        stage_index: stage_idx,
273                        item_index: item.index,
274                        status,
275                        tokens: TokenUsage::default(),
276                        elapsed_ms: elapsed,
277                    });
278                }
279
280                item.stage_elapsed[stage_idx] = elapsed;
281                match result {
282                    Ok(output) => {
283                        item.data = output;
284                        item.stage_statuses[stage_idx] = StageStatus::Ok;
285                    }
286                    Err(e) => {
287                        tracing::warn!(item_index = item.index, stage_idx, error = %e, "pipeline item failed at stage");
288                        item.stage_statuses[stage_idx] = StageStatus::Failed(e);
289                    }
290                }
291            }
292
293            let stage_elapsed = stage_start.elapsed();
294            tracing::info!(stage_idx, %stage_label, elapsed_ms = stage_elapsed.as_millis() as u64, "pipeline stage finished");
295        }
296
297        let total_elapsed = pipeline_start.elapsed().as_millis() as u64;
298
299        // Build results
300        let mut ok_count = 0;
301        let mut failed_count = 0;
302        let mut item_results = Vec::with_capacity(n_items);
303
304        for item in current_items {
305            let is_ok = item.stage_statuses.iter().all(|s| *s == StageStatus::Ok);
306            if is_ok {
307                ok_count += 1;
308            } else {
309                failed_count += 1;
310            }
311
312            let mut stage_results = Vec::with_capacity(n_stages);
313            for (i, status) in item.stage_statuses.iter().enumerate() {
314                stage_results.push(StageResult {
315                    label: self.config.stages[i].label.clone(),
316                    status: status.clone(),
317                    elapsed_ms: item.stage_elapsed[i],
318                });
319            }
320
321            item_results.push(PipelineItemResult {
322                item_index: item.index,
323                output: item.data,
324                stage_results,
325            });
326        }
327
328        // Emit PipelineDone event
329        self.emit(AgentEvent::PipelineDone {
330            run_id,
331            stages_completed: n_stages,
332            total_ok: ok_count,
333            total_failed: failed_count,
334        });
335
336        tracing::info!(
337            total_elapsed_ms = total_elapsed,
338            ok = ok_count,
339            failed = failed_count,
340            "pipeline execution finished"
341        );
342
343        Ok(PipelineResult {
344            items: item_results,
345            stats: PipelineStats {
346                total_items: n_items,
347                total_stages: n_stages,
348                ok: ok_count,
349                failed: failed_count,
350                total_elapsed_ms: total_elapsed,
351            },
352        })
353    }
354
355    fn emit(&self, event: AgentEvent) {
356        if let Some(ref tx) = self.event_tx {
357            let _ = tx.send(event);
358        }
359    }
360}
361
362// ============================================================================
363// Lua SDK Bridge
364// ============================================================================
365
366use crate::sdk::convert::{lua_value_from_json, value_to_json, JsonArg};
367use crate::sdk::SdkContext;
368use mlua::{Function, Lua, Table, Value};
369
370/// Register the `pipeline(params)` SDK function in Lua.
371///
372/// `params` carries an `items` array, a `stages` array (each a function or a
373/// `{ label, handler }` table). The handlers run
374/// inside the executor, blocking on the shared tokio runtime.
375pub(crate) fn register_pipeline_sdk(lua: &Lua, cx: &SdkContext) -> mlua::Result<()> {
376    let globals = lua.globals();
377    let run_id = cx.run_id();
378    let events = cx.events();
379
380    let pipeline_fn = lua.create_function(move |lua, params: Table| {
381        let events = events.clone();
382
383        let items_raw: Vec<Value> = params.get("items").map_err(|e| {
384            mlua::Error::RuntimeError(format!("pipeline: missing 'items' array: {}", e))
385        })?;
386        let items: Vec<serde_json::Value> = items_raw
387            .into_iter()
388            .map(value_to_json)
389            .collect::<Result<Vec<_>, _>>()
390            .map_err(|e| {
391                mlua::Error::RuntimeError(format!("pipeline: item conversion error: {}", e))
392            })?;
393
394        if items.is_empty() {
395            let t = lua.create_table()?;
396            t.set("items", lua.create_table()?)?;
397            t.set("ok", 0)?;
398            t.set("failed", 0)?;
399            return Ok(t);
400        }
401
402        let stages_table: Table = params.get("stages").map_err(|e| {
403            mlua::Error::RuntimeError(format!("pipeline: missing 'stages' array: {}", e))
404        })?;
405
406        let mut stages = Vec::new();
407        for i in 1..=stages_table.len()? {
408            let stage_val: Value = stages_table.get(i)?;
409            let (label, handler): (String, Function) = match stage_val {
410                Value::Function(func) => (format!("stage_{}", i), func),
411                Value::Table(tbl) => (tbl.get("label")?, tbl.get("handler")?),
412                _ => {
413                    return Err(mlua::Error::RuntimeError(format!(
414                        "pipeline: stage {} must be a function or table",
415                        i
416                    )))
417                }
418            };
419            let label_c = label.clone();
420            let stage = PipelineStage::new(&label, move |data| {
421                // `JsonArg` converts against the handler's own Lua VM (not a
422                // throwaway one), so the value is valid for `handler.call`.
423                match handler.call::<Value>(JsonArg(data)) {
424                    Ok(Value::Nil) => Ok(serde_json::Value::Null),
425                    Ok(v) => {
426                        value_to_json(v).map_err(|e| format!("pipeline: result conversion: {}", e))
427                    }
428                    Err(e) => Err(format!("pipeline: stage '{}' error: {}", label_c, e)),
429                }
430            });
431            stages.push(stage);
432        }
433
434        if stages.is_empty() {
435            return Err(mlua::Error::RuntimeError(
436                "pipeline: at least one stage required".into(),
437            ));
438        }
439
440        let n_stages = stages.len();
441        let config = PipelineConfig {
442            stages,
443            ..Default::default()
444        };
445
446        let executor = PipelineExecutor::new(config, Some(events), run_id);
447        tracing::debug!(n_items = items.len(), n_stages, "pipeline SDK invoked");
448        let result = executor.execute(items).map_err(|e| {
449            tracing::error!(error = %e, "pipeline execution failed");
450            mlua::Error::RuntimeError(format!("pipeline: execution error: {}", e))
451        })?;
452
453        let t = lua.create_table()?;
454        let items_t = lua.create_table()?;
455        for (i, item) in result.items.iter().enumerate() {
456            let item_t = lua.create_table()?;
457            item_t.set("index", item.item_index as i64)?;
458            item_t.set("output", lua_value_from_json(lua, item.output.clone())?)?;
459            let stages_t = lua.create_table()?;
460            for (j, sr) in item.stage_results.iter().enumerate() {
461                let sr_t = lua.create_table()?;
462                sr_t.set("label", sr.label.as_str())?;
463                sr_t.set("status", format!("{:?}", sr.status))?;
464                sr_t.set("elapsed_ms", sr.elapsed_ms as i64)?;
465                stages_t.set(j + 1, sr_t)?;
466            }
467            item_t.set("stages", stages_t)?;
468            items_t.set(i + 1, item_t)?;
469        }
470        t.set("items", items_t)?;
471        t.set("ok", result.stats.ok as i64)?;
472        t.set("failed", result.stats.failed as i64)?;
473        t.set("total_stages", result.stats.total_stages as i64)?;
474        t.set("total_elapsed_ms", result.stats.total_elapsed_ms as i64)?;
475        Ok(t)
476    })?;
477    globals.set("pipeline", pipeline_fn)?;
478
479    Ok(())
480}
481
482// ============================================================================
483// Tests
484// ============================================================================
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use serde_json::json;
490
491    /// Stage handler that appends a marker and prepends the stage name.
492    fn append_marker(
493        stage_name: &str,
494    ) -> impl Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static
495    {
496        let name = stage_name.to_string();
497        move |data| {
498            if let Some(obj) = data.as_object() {
499                let mut result = obj.clone();
500                result.insert("last_stage".to_string(), json!(name));
501                // Track visited stages
502                let mut visited: Vec<String> =
503                    serde_json::from_value(result.get("visited").cloned().unwrap_or(json!([])))
504                        .unwrap_or_default();
505                visited.push(name.clone());
506                result.insert("visited".to_string(), json!(visited));
507                Ok(serde_json::Value::Object(result))
508            } else {
509                Ok(json!({ "data": data, "last_stage": name }))
510            }
511        }
512    }
513
514    #[tokio::test]
515    async fn test_pipeline_basic() {
516        let config = PipelineConfig {
517            stages: vec![
518                PipelineStage::new("extract", append_marker("extract")),
519                PipelineStage::new("analyze", append_marker("analyze")),
520                PipelineStage::new("report", append_marker("report")),
521            ],
522            timeout_ms: 10000,
523        };
524
525        let items = vec![
526            json!({"id": 1, "text": "hello"}),
527            json!({"id": 2, "text": "world"}),
528        ];
529
530        let executor = PipelineExecutor::new(config, None, uuid::Uuid::nil());
531        let result = executor.execute(items).unwrap();
532
533        assert_eq!(result.items.len(), 2);
534        assert_eq!(result.stats.ok, 2);
535        assert_eq!(result.stats.failed, 0);
536        assert_eq!(result.stats.total_stages, 3);
537
538        // Verify first item passed through all 3 stages
539        let item0 = &result.items[0];
540        assert_eq!(item0.output["last_stage"], json!("report"));
541        let visited: Vec<String> = serde_json::from_value(item0.output["visited"].clone()).unwrap();
542        assert_eq!(visited, vec!["extract", "analyze", "report"]);
543    }
544
545    #[tokio::test]
546    async fn test_pipeline_stage_failure() {
547        let config = PipelineConfig {
548            stages: vec![
549                PipelineStage::new("ok", append_marker("ok")),
550                PipelineStage::new("fail", |data| {
551                    // Fail if data contains "break"
552                    if data.to_string().contains("break") {
553                        Err("intentional failure".to_string())
554                    } else {
555                        append_marker("fail")(data)
556                    }
557                }),
558            ],
559            timeout_ms: 10000,
560        };
561
562        let items = vec![
563            json!({"id": 1, "text": "good"}),
564            json!({"id": 2, "text": "break"}),
565        ];
566
567        let executor = PipelineExecutor::new(config, None, uuid::Uuid::nil());
568        let result = executor.execute(items).unwrap();
569
570        assert_eq!(result.items.len(), 2);
571        // Item 0 should succeed, item 1 should fail
572        assert_eq!(result.stats.ok, 1);
573        assert_eq!(result.stats.failed, 1);
574
575        assert_eq!(
576            result.items[1].stage_results[1].status,
577            StageStatus::Failed("intentional failure".to_string())
578        );
579    }
580
581    #[tokio::test]
582    async fn test_pipeline_empty_items() {
583        let config = PipelineConfig::default();
584        let executor = PipelineExecutor::new(config, None, uuid::Uuid::nil());
585        let result = executor.execute(vec![]);
586        assert!(matches!(result, Err(PipelineError::NoItems)));
587    }
588
589    #[tokio::test]
590    async fn test_pipeline_empty_stages() {
591        let config = PipelineConfig {
592            stages: vec![],
593            ..Default::default()
594        };
595        let items = vec![json!({"id": 1})];
596        let executor = PipelineExecutor::new(config, None, uuid::Uuid::nil());
597        let result = executor.execute(items);
598        assert!(matches!(result, Err(PipelineError::NoStages)));
599    }
600}