1use 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#[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#[derive(Debug, Clone)]
54pub struct PipelineConfig {
55 pub stages: Vec<PipelineStage>,
57 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, }
67 }
68}
69
70#[derive(Clone)]
72pub struct PipelineStage {
73 pub label: String,
75 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#[derive(Debug, Clone)]
107pub struct PipelineItem {
108 pub index: usize,
110 pub stage_index: usize,
112 pub data: serde_json::Value,
114 pub stage_statuses: Vec<StageStatus>,
116 pub stage_elapsed: Vec<u64>,
118}
119
120#[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#[derive(Debug, Clone)]
147pub struct PipelineResult {
148 pub items: Vec<PipelineItemResult>,
150 pub stats: PipelineStats,
152}
153
154#[derive(Debug, Clone)]
155pub struct PipelineItemResult {
156 pub item_index: usize,
158 pub output: serde_json::Value,
160 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
180pub struct PipelineExecutor {
188 config: PipelineConfig,
189 event_tx: Option<EventSender>,
190 run_id: uuid::Uuid,
191}
192
193impl PipelineExecutor {
194 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 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 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 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 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 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 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 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 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
362use crate::sdk::convert::{lua_value_from_json, value_to_json, JsonArg};
367use crate::sdk::SdkContext;
368use mlua::{Function, Lua, Table, Value};
369
370pub(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 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#[cfg(test)]
487mod tests {
488 use super::*;
489 use serde_json::json;
490
491 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 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 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 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 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}