Skip to main content

flowcat_core/pipeline/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! `Pipeline` + `ParallelPipeline` + `PipelineTask` + `PipelineRunner`
4//! (PROCESSOR-DESIGN §3/§4).
5//!
6//! A [`Pipeline`] is a linear chain of [`FrameProcessor`] tasks connected by
7//! bounded/unbounded mpsc channels (the channel runtime, `processor::runtime`).
8//! It wraps the user processors with an internal **Source** (head) and **Sink**
9//! (tail) so a [`PipelineTask`] can inject downstream frames at the head and
10//! observe terminal frames at the tail. `Pipeline` is itself a `FrameProcessor`
11//! (so it nests, pipecat `Pipeline(BasePipeline)`).
12//!
13//! The live call orchestration is the S2S/cascaded processor pipelines —
14//! [`build_s2s_task`] (realtime) and [`build_cascaded_task`] (STT→LLM→TTS) — both
15//! assembled on this framework.
16
17pub mod cascaded;
18pub mod parallel;
19pub mod runner;
20pub mod s2s;
21pub mod source_pump;
22pub mod task;
23
24/// Shared scripted mock seams for the S2S pipeline tests (test-only). Drive the
25/// processor [`PipelineTask`] (and the equivalence/fixture tests) off scripted
26/// inputs. Gated `#[cfg(test)]` so it never ships.
27#[cfg(test)]
28pub(crate) mod s2s_test_mocks;
29
30use std::sync::Arc;
31
32use async_trait::async_trait;
33use tokio::task::JoinSet;
34
35use crate::error::Result;
36use crate::processor::frame::{Direction, Frame, StartParams};
37use crate::processor::runtime::{channel, ProcessorTx, NORMAL_CHAN_CAP};
38use crate::processor::{
39    into_topology, run_processor, Envelope, FrameProcessor, Link, ProcessorSetup, StopReason,
40    Topology,
41};
42
43pub use cascaded::{
44    build_cascaded_call_with_observers, build_cascaded_pipeline, build_cascaded_task,
45    build_cascaded_task_with_observers, CascadedConfig, CascadedTask, ContextSummarizer,
46    SummarizerConfig,
47};
48pub use parallel::ParallelPipeline;
49pub use runner::PipelineRunner;
50pub use source_pump::{SourceHandle, SourcePump};
51pub use task::{PipelineTask, PipelineTaskParams};
52
53/// A linear chain of linked processor tasks (PROCESSOR-DESIGN §3.1).
54///
55/// `Pipeline::new([a, b, c])` builds the chain `[Source, a, b, c, Sink]` when
56/// linked. It is a [`FrameProcessor`] so it nests inside another `Pipeline` or a
57/// [`ParallelPipeline`].
58pub struct Pipeline {
59    processors: Vec<Box<dyn FrameProcessor>>,
60}
61
62impl Pipeline {
63    /// Build a pipeline over `processors` (wrapped with Source/Sink at link time).
64    pub fn new(processors: Vec<Box<dyn FrameProcessor>>) -> Self {
65        Self { processors }
66    }
67
68    /// Number of user processors (excludes the Source/Sink wrap).
69    pub fn len(&self) -> usize {
70        self.processors.len()
71    }
72
73    pub fn is_empty(&self) -> bool {
74        self.processors.is_empty()
75    }
76}
77
78#[async_trait]
79impl FrameProcessor for Pipeline {
80    fn name(&self) -> &str {
81        "Pipeline"
82    }
83
84    fn explode(&mut self) -> Option<Topology> {
85        let procs = std::mem::take(&mut self.processors);
86        Some(Topology::Chain(
87            procs.into_iter().map(into_topology).collect(),
88        ))
89    }
90}
91
92// ---------------------------------------------------------------------------
93// The linking machinery: turn a `Topology` into spawned, wired tasks.
94// ---------------------------------------------------------------------------
95
96/// A spawned, linked subgraph's external wiring handles.
97pub(crate) struct Linked {
98    /// Send a frame INTO this subgraph flowing downstream (its head input).
99    pub head: ProcessorTx,
100    /// Send a frame INTO this subgraph flowing upstream (its tail input). Part of
101    /// the wiring contract for upstream injection at a composite's tail; not yet
102    /// consumed by `PipelineTask` (which injects only at the head), so allow it.
103    #[allow(dead_code)]
104    pub tail: ProcessorTx,
105}
106
107/// Build a [`Link`] for one processor task.
108fn make_link(
109    name: Arc<str>,
110    next: Option<ProcessorTx>,
111    prev: Option<ProcessorTx>,
112    setup: &ProcessorSetup,
113) -> Link {
114    Link {
115        next,
116        prev,
117        name,
118        clock: setup.clock.clone(),
119        observer: setup.observer.clone(),
120        enable_metrics: setup.enable_metrics,
121        enable_usage_metrics: setup.enable_usage_metrics,
122        ttfb_start: Arc::new(std::sync::atomic::AtomicI64::new(0)),
123        processing_start: Arc::new(std::sync::atomic::AtomicI64::new(0)),
124    }
125}
126
127/// Recursively spawn `topo` into wired tasks. `prev`/`next` are the senders to the
128/// upstream/downstream neighbours *outside* this subgraph. Returns the subgraph's
129/// external head/tail input senders. All spawned tasks are tracked in `tasks`.
130pub(crate) fn link_topology(
131    topo: Topology,
132    prev: Option<ProcessorTx>,
133    next: Option<ProcessorTx>,
134    setup: &ProcessorSetup,
135    tasks: &mut JoinSet<()>,
136) -> Linked {
137    match topo {
138        Topology::Leaf(p) => {
139            let name: Arc<str> = Arc::from(p.name());
140            let (tx, rx) = channel(name.clone(), NORMAL_CHAN_CAP);
141            let link = make_link(name, next, prev, setup);
142            let setup2 = setup.clone();
143            tasks.spawn(async move {
144                run_processor(p, rx, link, setup2).await;
145            });
146            Linked {
147                head: tx.clone(),
148                tail: tx,
149            }
150        }
151        Topology::Chain(elems) => link_chain(elems, prev, next, setup, tasks),
152        Topology::Parallel(branches) => parallel::link_parallel(branches, prev, next, setup, tasks),
153    }
154}
155
156/// Wire `elems` as a head→tail chain (PROCESSOR-DESIGN §3.1). Empty chain
157/// degenerates to a pass-through no-op processor so the channels still connect.
158///
159/// Each element is linked first (so its external head/tail input senders exist),
160/// then neighbours are wired in a second pass by re-spawning each element with the
161/// adjacent head sender. To make wiring uniform across leaves and nested
162/// composites, every element exposes a single *input* sender via a thin per-joint
163/// forwarding channel — the chain wires those, and a forwarder bridges each into
164/// the element's real head and the element's tail output back out.
165fn link_chain(
166    elems: Vec<Topology>,
167    prev: Option<ProcessorTx>,
168    next: Option<ProcessorTx>,
169    setup: &ProcessorSetup,
170    tasks: &mut JoinSet<()>,
171) -> Linked {
172    if elems.is_empty() {
173        return link_topology(
174            Topology::Leaf(Box::new(PassThrough)),
175            prev,
176            next,
177            setup,
178            tasks,
179        );
180    }
181    let n = elems.len();
182
183    // Per-element *input* sender that neighbours target. For a leaf this is the
184    // processor's own channel; for a nested composite it is a forwarding channel
185    // bridged to the composite's real head. We create them up front so that
186    // neighbour wiring (each element's prev/next) is fully known before spawning.
187    let mut inputs: Vec<ProcessorTx> = Vec::with_capacity(n);
188    let mut pending: Vec<PendingElem> = Vec::with_capacity(n);
189    for elem in elems {
190        match elem {
191            Topology::Leaf(p) => {
192                let name: Arc<str> = Arc::from(p.name());
193                let (tx, rx) = channel(name.clone(), NORMAL_CHAN_CAP);
194                inputs.push(tx);
195                pending.push(PendingElem::Leaf { name, p, rx });
196            }
197            other => {
198                // Forwarding input channel for the composite; bridged below.
199                let name: Arc<str> = Arc::from("nested");
200                let (tx, rx) = channel(name, NORMAL_CHAN_CAP);
201                inputs.push(tx);
202                pending.push(PendingElem::Composite {
203                    topo: other,
204                    in_rx: rx,
205                });
206            }
207        }
208    }
209
210    // Second pass: resolve neighbours and spawn.
211    for (i, elem) in pending.into_iter().enumerate() {
212        let next_tx = if i + 1 < n {
213            Some(inputs[i + 1].clone())
214        } else {
215            next.clone()
216        };
217        let prev_tx = if i > 0 {
218            Some(inputs[i - 1].clone())
219        } else {
220            prev.clone()
221        };
222        match elem {
223            PendingElem::Leaf { name, p, rx } => {
224                let link = make_link(name, next_tx, prev_tx, setup);
225                let setup2 = setup.clone();
226                tasks.spawn(async move {
227                    run_processor(p, rx, link, setup2).await;
228                });
229            }
230            PendingElem::Composite { topo, in_rx } => {
231                // Link the composite with the resolved neighbours, then bridge the
232                // forwarding input channel into its real head.
233                let linked = link_topology(topo, prev_tx, next_tx, setup, tasks);
234                let real_head = linked.head;
235                tasks.spawn(bridge(in_rx, real_head));
236            }
237        }
238    }
239
240    Linked {
241        head: inputs[0].clone(),
242        tail: inputs[n - 1].clone(),
243    }
244}
245
246/// A chain element awaiting its second-pass neighbour wiring.
247enum PendingElem {
248    Leaf {
249        name: Arc<str>,
250        p: Box<dyn FrameProcessor>,
251        rx: crate::processor::runtime::ProcessorRx,
252    },
253    Composite {
254        topo: Topology,
255        in_rx: crate::processor::runtime::ProcessorRx,
256    },
257}
258
259/// Forward every envelope from `rx` into `dst` (bridges a forwarding input channel
260/// to a nested composite's real head input). Exits after a terminal frame so the
261/// task tree winds down.
262pub(crate) async fn bridge(mut rx: crate::processor::runtime::ProcessorRx, dst: ProcessorTx) {
263    loop {
264        let env = tokio::select! {
265            biased;
266            Some(e) = rx.system.recv() => e,
267            Some(e) = rx.normal.recv() => e,
268            else => break,
269        };
270        let terminal = env.frame.is_terminal();
271        dst.send(env).await;
272        if terminal {
273            break;
274        }
275    }
276}
277
278/// A no-op pass-through processor (default `process_frame` forwards).
279struct PassThrough;
280
281#[async_trait]
282impl FrameProcessor for PassThrough {
283    fn name(&self) -> &str {
284        "PassThrough"
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Source / Sink — the internal head/tail wrap (PROCESSOR-DESIGN §3.1).
290// ---------------------------------------------------------------------------
291
292/// The pipeline head wrapper (pipecat `PipelineSource`, `pipeline.py:21`).
293///
294/// The `PipelineTask` injects downstream frames here; it converts an **upstream**
295/// `End`/`Stop`/`Cancel` *request* from an inner processor into the corresponding
296/// downstream lifecycle frame (PROCESSOR-DESIGN §4.1 step 4, `_source_push_frame`).
297pub(crate) struct Source;
298
299#[async_trait]
300impl FrameProcessor for Source {
301    fn name(&self) -> &str {
302        "Source"
303    }
304
305    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
306        // Upstream lifecycle request → convert to a downstream lifecycle frame so
307        // a processor wanting to end the call drives the whole chain to drain.
308        if env.direction == Direction::Upstream {
309            match &env.frame {
310                Frame::End { .. } | Frame::Stop | Frame::Cancel { .. } => {
311                    link.push_down(env.frame).await;
312                    return Ok(());
313                }
314                _ => {}
315            }
316        }
317        link.push(env.meta, env.frame, env.direction).await;
318        Ok(())
319    }
320}
321
322/// The pipeline tail wrapper (pipecat `PipelineSink`, `pipeline.py:55`).
323///
324/// Mirrors downstream frames out to the `PipelineTask`'s observation channel so it
325/// can detect ready/terminal/heartbeat frames. Forwards everything else.
326pub(crate) struct Sink {
327    /// A clone of the task's "sink observation" sender.
328    pub(crate) tap: tokio::sync::mpsc::UnboundedSender<Frame>,
329}
330
331#[async_trait]
332impl FrameProcessor for Sink {
333    fn name(&self) -> &str {
334        "Sink"
335    }
336
337    /// Lifecycle frames (`Start`, `End`/`Stop`/`Cancel`) are handled by the
338    /// framework loop (`run_processor`) via the `start`/`stop` hooks and **never
339    /// reach `process_frame`** (PROCESSOR-DESIGN §2.2). So the Sink must tap them
340    /// here, from the hooks — otherwise the `PipelineTask` ready-handshake (it
341    /// blocks until `Start` reaches the Sink) and its terminal-frame detection
342    /// would never fire. Tapping `Start` from the hook makes the handshake work;
343    /// tapping the reconstructed terminal frame from `stop` gives the task the
344    /// correct `StopReason` (End→EndOfTask, Stop→Stopped, Cancel→Cancelled).
345    async fn start(&mut self, _setup: &ProcessorSetup, params: &StartParams) -> Result<()> {
346        let _ = self.tap.send(Frame::Start(params.clone()));
347        Ok(())
348    }
349
350    async fn stop(&mut self, reason: StopReason) -> Result<()> {
351        let frame = match reason {
352            StopReason::EndOfTask => Frame::End { reason: None },
353            StopReason::Stopped => Frame::Stop,
354            StopReason::Cancelled => Frame::Cancel { reason: None },
355        };
356        let _ = self.tap.send(frame);
357        Ok(())
358    }
359
360    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
361        if env.direction == Direction::Downstream {
362            let _ = self.tap.send(env.frame.clone());
363        }
364        link.push(env.meta, env.frame, env.direction).await;
365        Ok(())
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::processor::frame::{Frame, StartParams};
373    use crate::processor::Clock;
374    use std::sync::atomic::{AtomicUsize, Ordering};
375    use std::sync::Arc as StdArc;
376    use tokio::sync::mpsc;
377
378    // A processor that records the order of Text frames it sees, then forwards.
379    struct Recorder {
380        name: &'static str,
381        log: StdArc<std::sync::Mutex<Vec<String>>>,
382    }
383    #[async_trait]
384    impl FrameProcessor for Recorder {
385        fn name(&self) -> &str {
386            self.name
387        }
388        async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
389            if let Frame::Text(t) = &env.frame {
390                self.log
391                    .lock()
392                    .unwrap()
393                    .push(format!("{}:{}", self.name, t));
394            }
395            link.push(env.meta, env.frame, env.direction).await;
396            Ok(())
397        }
398    }
399
400    fn setup() -> ProcessorSetup {
401        ProcessorSetup {
402            clock: Clock::new(),
403            observer: None,
404            cancel: tokio_util::sync::CancellationToken::new(),
405            enable_metrics: false,
406            enable_usage_metrics: false,
407        }
408    }
409
410    #[tokio::test]
411    async fn pipeline_is_a_processor_and_nests_reaching_the_sink() {
412        let log = StdArc::new(std::sync::Mutex::new(Vec::new()));
413        // Outer pipeline contains an inner pipeline → tests nesting.
414        let inner = Pipeline::new(vec![Box::new(Recorder {
415            name: "inner",
416            log: log.clone(),
417        })]);
418        let outer = Pipeline::new(vec![
419            Box::new(Recorder {
420                name: "a",
421                log: log.clone(),
422            }),
423            Box::new(inner),
424            Box::new(Recorder {
425                name: "b",
426                log: log.clone(),
427            }),
428        ]);
429
430        let (sink_tap_tx, mut sink_tap_rx) = mpsc::unbounded_channel::<Frame>();
431        // Build [Source, outer..., Sink] and link.
432        let topo = Topology::Chain(vec![
433            Topology::Leaf(Box::new(Source)),
434            into_topology(Box::new(outer)),
435            Topology::Leaf(Box::new(Sink { tap: sink_tap_tx })),
436        ]);
437        let st = setup();
438        let mut tasks = JoinSet::new();
439        let linked = link_topology(topo, None, None, &st, &mut tasks);
440
441        // Inject a downstream Text at the head.
442        linked
443            .head
444            .send(Envelope::new(
445                Frame::Text("hi".into()),
446                Direction::Downstream,
447            ))
448            .await;
449        // It should reach the Sink (tapped) after passing a, inner, b in order.
450        let got = tokio::time::timeout(std::time::Duration::from_secs(2), sink_tap_rx.recv())
451            .await
452            .expect("sink frame timed out")
453            .expect("sink closed");
454        assert!(matches!(got, Frame::Text(t) if t == "hi"));
455
456        // Send End to drain.
457        linked
458            .head
459            .send(Envelope::new(
460                Frame::End { reason: None },
461                Direction::Downstream,
462            ))
463            .await;
464        let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
465            while tasks.join_next().await.is_some() {}
466        })
467        .await;
468
469        let order = log.lock().unwrap().clone();
470        assert_eq!(order, vec!["a:hi", "inner:hi", "b:hi"]);
471    }
472
473    // A leaf that counts Start frames it received before any data frame.
474    struct StartCounter {
475        seen_data_before_start: StdArc<AtomicUsize>,
476        started: StdArc<AtomicUsize>,
477    }
478    #[async_trait]
479    impl FrameProcessor for StartCounter {
480        fn name(&self) -> &str {
481            "StartCounter"
482        }
483        async fn start(&mut self, _s: &ProcessorSetup, _p: &StartParams) -> Result<()> {
484            self.started.fetch_add(1, Ordering::Relaxed);
485            Ok(())
486        }
487        async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
488            if matches!(env.frame, Frame::Text(_)) && self.started.load(Ordering::Relaxed) == 0 {
489                self.seen_data_before_start.fetch_add(1, Ordering::Relaxed);
490            }
491            link.push(env.meta, env.frame, env.direction).await;
492            Ok(())
493        }
494    }
495
496    #[tokio::test]
497    async fn start_runs_before_data_in_a_linked_chain() {
498        let started = StdArc::new(AtomicUsize::new(0));
499        let bad = StdArc::new(AtomicUsize::new(0));
500        let (sink_tap_tx, _rx) = mpsc::unbounded_channel::<Frame>();
501        let topo = Topology::Chain(vec![
502            Topology::Leaf(Box::new(Source)),
503            Topology::Leaf(Box::new(StartCounter {
504                seen_data_before_start: bad.clone(),
505                started: started.clone(),
506            })),
507            Topology::Leaf(Box::new(Sink { tap: sink_tap_tx })),
508        ]);
509        let st = setup();
510        let mut tasks = JoinSet::new();
511        let linked = link_topology(topo, None, None, &st, &mut tasks);
512        linked
513            .head
514            .send(Envelope::new(
515                Frame::Start(StartParams::default()),
516                Direction::Downstream,
517            ))
518            .await;
519        // small yield so Start propagates
520        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
521        linked
522            .head
523            .send(Envelope::new(
524                Frame::Text("data".into()),
525                Direction::Downstream,
526            ))
527            .await;
528        linked
529            .head
530            .send(Envelope::new(
531                Frame::End { reason: None },
532                Direction::Downstream,
533            ))
534            .await;
535        let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
536            while tasks.join_next().await.is_some() {}
537        })
538        .await;
539        assert_eq!(started.load(Ordering::Relaxed), 1);
540        assert_eq!(
541            bad.load(Ordering::Relaxed),
542            0,
543            "data must not precede start"
544        );
545    }
546}