Skip to main content

bnto_core/
events.rs

1// Pipeline events — structured progress reporting for multi-node pipelines.
2// Complements `progress.rs` (per-file within one node) with pipeline-level
3// events (node started/completed, file progress, pipeline result).
4
5use std::sync::Arc;
6
7use serde::Serialize;
8
9// =============================================================================
10// Pipeline Event Types
11// =============================================================================
12
13/// Lightweight node metadata included in `PipelineStarted` so the UI
14/// can show real node ids and types immediately (instead of "node-0").
15#[derive(Debug, Clone, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub struct NodeInfo {
18    pub id: String,
19    pub node_type: String,
20}
21
22/// A single output file attached to an `IterationCompleted` event
23/// when progressive output is enabled. Lets consumers write files
24/// to disk immediately instead of waiting for the full pipeline.
25///
26/// The `data` field is `#[serde(skip)]` so WASM JSON serialization
27/// doesn't try to serialize multi-GB video files.
28#[derive(Debug, Clone, PartialEq, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ProgressOutputFile {
31    pub name: String,
32    #[serde(skip)]
33    pub data: crate::processor::FileData,
34    pub mime_type: String,
35}
36
37/// Every event the pipeline executor can emit during execution.
38///
39/// Serialized as a tagged union with `"type"` discriminant and camelCase
40/// field names, matching the TypeScript `PipelineEvent` discriminated union.
41#[derive(Debug, Clone, Serialize)]
42#[serde(tag = "type")]
43pub enum PipelineEvent {
44    /// Emitted once at the very start of pipeline execution.
45    /// Tells the UI how many nodes and files to expect.
46    #[serde(rename_all = "camelCase")]
47    PipelineStarted {
48        /// How many processing nodes will run (excludes I/O markers).
49        total_nodes: usize,
50        /// How many input files were provided.
51        total_files: usize,
52        /// Metadata for each processing node — lets the UI populate
53        /// node types immediately instead of waiting for NodeStarted.
54        nodes: Vec<NodeInfo>,
55    },
56
57    /// Emitted when a processing node begins execution.
58    /// The editor uses this to highlight the active node on the canvas.
59    #[serde(rename_all = "camelCase")]
60    NodeStarted {
61        /// The unique ID of the node (from the recipe definition).
62        node_id: String,
63        /// Zero-based position of this node among the pipeline's processing
64        /// nodes — pipeline-wide even when auto-iteration splits the run.
65        /// When `parent_node_id` is set, the position is within that container.
66        node_index: usize,
67        /// Number of processing nodes in the pipeline, or in the parent
68        /// container when `parent_node_id` is set.
69        total_nodes: usize,
70        /// The type of node (e.g., "image-compress", "spreadsheet-clean").
71        node_type: String,
72        /// If this node is inside a container (loop/group), the container's ID.
73        /// `None` for top-level nodes.
74        parent_node_id: Option<String>,
75    },
76
77    /// Emitted at the start of each loop iteration.
78    /// Powers iteration count display (e.g., "3/15").
79    #[serde(rename_all = "camelCase")]
80    IterationStarted {
81        /// The loop container node that owns this iteration.
82        node_id: String,
83        /// Zero-based iteration index.
84        iteration: usize,
85        /// Total number of iterations in this loop.
86        total_iterations: usize,
87    },
88
89    /// Emitted after a loop iteration completes successfully.
90    /// Powers per-iteration progress (e.g., "3/15 done") and progressive output.
91    #[serde(rename_all = "camelCase")]
92    IterationCompleted {
93        /// The loop container node that owns this iteration.
94        node_id: String,
95        /// Zero-based iteration index.
96        iteration: usize,
97        /// Total number of iterations in this loop.
98        total_iterations: usize,
99        /// How long this iteration took, in milliseconds.
100        duration_ms: u64,
101        /// How many output files this iteration produced.
102        files_produced: usize,
103        /// Output files from this iteration (progressive mode only).
104        /// Empty when `outputPersistence` is `deferred` (default).
105        /// Serde-skipped so WASM doesn't serialize multi-GB file data.
106        #[serde(skip)]
107        output_files: Vec<ProgressOutputFile>,
108    },
109
110    /// Emitted when a loop iteration fails.
111    /// In `continue` mode, the loop keeps going after emitting this.
112    #[serde(rename_all = "camelCase")]
113    IterationFailed {
114        /// The loop container node that owns this iteration.
115        node_id: String,
116        /// Zero-based iteration index.
117        iteration: usize,
118        /// Total number of iterations in this loop.
119        total_iterations: usize,
120        /// Human-readable error message.
121        error: String,
122    },
123
124    /// Emitted during file processing within a node.
125    /// Powers progress bars and per-file status indicators.
126    #[serde(rename_all = "camelCase")]
127    FileProgress {
128        /// Which node is processing this file.
129        node_id: String,
130        /// Zero-based index of the current file within this node's batch.
131        file_index: usize,
132        /// Total files this node will process.
133        total_files: usize,
134        /// Processing progress for this specific file (0-100).
135        percent: u32,
136        /// Human-readable status message (e.g., "Compressing photo.jpg...").
137        message: String,
138    },
139
140    /// Emitted when a node finishes successfully.
141    /// The editor uses this to mark the node as "completed" with a checkmark.
142    #[serde(rename_all = "camelCase")]
143    NodeCompleted {
144        /// Which node completed.
145        node_id: String,
146        /// How long this node took, in milliseconds.
147        duration_ms: u64,
148        /// How many files this node processed.
149        files_processed: usize,
150        /// If this node is inside a container, the container's ID.
151        parent_node_id: Option<String>,
152    },
153
154    /// Emitted when a node fails.
155    /// The editor uses this to mark the node as "failed" with an error icon.
156    #[serde(rename_all = "camelCase")]
157    NodeFailed {
158        /// Which node failed.
159        node_id: String,
160        /// Human-readable error message.
161        error: String,
162        /// If this node is inside a container, the container's ID.
163        parent_node_id: Option<String>,
164    },
165
166    /// Emitted once when the entire pipeline finishes successfully.
167    #[serde(rename_all = "camelCase")]
168    PipelineCompleted {
169        /// Total wall-clock time for the entire pipeline, in milliseconds.
170        duration_ms: u64,
171        /// Total number of files processed across all nodes.
172        total_files_processed: usize,
173    },
174
175    /// Emitted when the pipeline fails (a node error stops execution).
176    #[serde(rename_all = "camelCase")]
177    PipelineFailed {
178        /// Which node caused the pipeline to fail.
179        node_id: String,
180        /// Human-readable error message.
181        error: String,
182    },
183
184    /// Emitted when a child process writes a line to stderr during execution.
185    /// Used for live progress from tools like yt-dlp and ffmpeg.
186    #[serde(rename_all = "camelCase")]
187    CommandOutput {
188        /// Which node's command produced this output.
189        node_id: String,
190        /// One line of stderr output from the child process.
191        line: String,
192    },
193}
194
195// =============================================================================
196// Pipeline Reporter
197// =============================================================================
198
199/// Emits structured pipeline events to a callback.
200///
201/// Uses `Arc` internally so it can be cloned and shared — the executor
202/// clones the reporter to move into closures that relay command output.
203pub struct PipelineReporter {
204    /// The callback that receives events. `None` = no-op mode.
205    callback: Option<Arc<dyn Fn(PipelineEvent)>>,
206}
207
208impl Clone for PipelineReporter {
209    fn clone(&self) -> Self {
210        Self {
211            callback: self.callback.clone(),
212        }
213    }
214}
215
216impl PipelineReporter {
217    /// Create a new reporter with a callback that receives pipeline events.
218    pub fn new(callback: impl Fn(PipelineEvent) + 'static) -> Self {
219        Self {
220            callback: Some(Arc::new(callback)),
221        }
222    }
223
224    /// Create a no-op reporter that discards all events.
225    /// Used in tests where we don't need event tracking.
226    pub fn new_noop() -> Self {
227        Self { callback: None }
228    }
229
230    /// Emit a pipeline event. If no callback is set (no-op mode),
231    /// the event is silently discarded.
232    pub fn emit(&self, event: PipelineEvent) {
233        if let Some(cb) = &self.callback {
234            cb(event);
235        }
236    }
237}
238
239// =============================================================================
240// Recording Reporter (for tests)
241// =============================================================================
242
243/// A reporter that records all events in a thread-safe Vec.
244/// Used in tests to verify the executor emits the right events
245/// in the right order.
246#[cfg(test)]
247pub struct RecordingReporter {
248    /// The shared, thread-safe event log.
249    events: std::sync::Arc<std::sync::Mutex<Vec<PipelineEvent>>>,
250}
251
252#[cfg(test)]
253impl Default for RecordingReporter {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259#[cfg(test)]
260impl RecordingReporter {
261    /// Create a new recording reporter.
262    pub fn new() -> Self {
263        Self {
264            events: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
265        }
266    }
267
268    /// Build a `PipelineReporter` that records events into this recorder.
269    pub fn reporter(&self) -> PipelineReporter {
270        let events = std::sync::Arc::clone(&self.events);
271        PipelineReporter::new(move |event| {
272            events.lock().unwrap().push(event);
273        })
274    }
275
276    /// Get a snapshot of all recorded events.
277    pub fn events(&self) -> Vec<PipelineEvent> {
278        self.events.lock().unwrap().clone()
279    }
280}
281
282// =============================================================================
283// Tests
284// =============================================================================
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    // --- Serialization Tests ---
291    // Verify the JSON shape matches what the TypeScript side expects.
292
293    #[test]
294    fn test_pipeline_started_serializes_correctly() {
295        let event = PipelineEvent::PipelineStarted {
296            total_nodes: 3,
297            total_files: 10,
298            nodes: vec![
299                NodeInfo {
300                    id: "n1".into(),
301                    node_type: "image-compress".into(),
302                },
303                NodeInfo {
304                    id: "n2".into(),
305                    node_type: "file-rename".into(),
306                },
307                NodeInfo {
308                    id: "n3".into(),
309                    node_type: "image-resize".into(),
310                },
311            ],
312        };
313        let json = serde_json::to_value(&event).unwrap();
314
315        assert_eq!(json["type"], "PipelineStarted");
316        assert_eq!(json["totalNodes"], 3);
317        assert_eq!(json["totalFiles"], 10);
318        let nodes = json["nodes"].as_array().unwrap();
319        assert_eq!(nodes.len(), 3);
320        assert_eq!(nodes[0]["id"], "n1");
321        assert_eq!(nodes[0]["nodeType"], "image-compress");
322    }
323
324    #[test]
325    fn test_node_started_serializes_correctly() {
326        let event = PipelineEvent::NodeStarted {
327            node_id: "node-1".to_string(),
328            node_index: 0,
329            total_nodes: 3,
330            node_type: "image-compress".to_string(),
331            parent_node_id: None,
332        };
333        let json = serde_json::to_value(&event).unwrap();
334
335        assert_eq!(json["type"], "NodeStarted");
336        assert_eq!(json["nodeId"], "node-1");
337        assert_eq!(json["nodeIndex"], 0);
338        assert_eq!(json["totalNodes"], 3);
339        assert_eq!(json["nodeType"], "image-compress");
340        assert_eq!(json["parentNodeId"], serde_json::Value::Null);
341    }
342
343    #[test]
344    fn test_node_started_with_parent_serializes_correctly() {
345        let event = PipelineEvent::NodeStarted {
346            node_id: "child-1".to_string(),
347            node_index: 0,
348            total_nodes: 1,
349            node_type: "shell-command".to_string(),
350            parent_node_id: Some("loop-1".to_string()),
351        };
352        let json = serde_json::to_value(&event).unwrap();
353
354        assert_eq!(json["type"], "NodeStarted");
355        assert_eq!(json["parentNodeId"], "loop-1");
356    }
357
358    #[test]
359    fn test_iteration_started_serializes_correctly() {
360        let event = PipelineEvent::IterationStarted {
361            node_id: "loop-1".to_string(),
362            iteration: 2,
363            total_iterations: 15,
364        };
365        let json = serde_json::to_value(&event).unwrap();
366
367        assert_eq!(json["type"], "IterationStarted");
368        assert_eq!(json["nodeId"], "loop-1");
369        assert_eq!(json["iteration"], 2);
370        assert_eq!(json["totalIterations"], 15);
371    }
372
373    #[test]
374    fn test_iteration_completed_serializes_correctly() {
375        let event = PipelineEvent::IterationCompleted {
376            node_id: "loop-1".to_string(),
377            iteration: 2,
378            total_iterations: 7,
379            duration_ms: 1500,
380            files_produced: 1,
381            output_files: Vec::new(),
382        };
383        let json = serde_json::to_value(&event).unwrap();
384
385        assert_eq!(json["type"], "IterationCompleted");
386        assert_eq!(json["nodeId"], "loop-1");
387        assert_eq!(json["iteration"], 2);
388        assert_eq!(json["totalIterations"], 7);
389        assert_eq!(json["durationMs"], 1500);
390        assert_eq!(json["filesProduced"], 1);
391    }
392
393    #[test]
394    fn test_iteration_completed_skips_output_files_in_json() {
395        let event = PipelineEvent::IterationCompleted {
396            node_id: "loop-1".to_string(),
397            iteration: 0,
398            total_iterations: 1,
399            duration_ms: 100,
400            files_produced: 1,
401            output_files: vec![ProgressOutputFile {
402                name: "video.mp4".to_string(),
403                data: crate::processor::FileData::Bytes(vec![0u8; 100]),
404                mime_type: "video/mp4".to_string(),
405            }],
406        };
407        let json = serde_json::to_value(&event).unwrap();
408
409        // output_files is serde(skip) — should NOT appear in JSON.
410        assert!(json.get("outputFiles").is_none());
411        assert_eq!(json["filesProduced"], 1);
412    }
413
414    #[test]
415    fn test_progress_output_file_serializes_without_data() {
416        let file = ProgressOutputFile {
417            name: "video.mp4".to_string(),
418            data: crate::processor::FileData::Bytes(vec![0u8; 1024]),
419            mime_type: "video/mp4".to_string(),
420        };
421        let json = serde_json::to_value(&file).unwrap();
422
423        assert_eq!(json["name"], "video.mp4");
424        assert_eq!(json["mimeType"], "video/mp4");
425        // data is serde(skip) — should NOT appear in JSON.
426        assert!(json.get("data").is_none());
427    }
428
429    #[test]
430    fn test_iteration_failed_serializes_correctly() {
431        let event = PipelineEvent::IterationFailed {
432            node_id: "loop-1".to_string(),
433            iteration: 3,
434            total_iterations: 10,
435            error: "Download timed out".to_string(),
436        };
437        let json = serde_json::to_value(&event).unwrap();
438
439        assert_eq!(json["type"], "IterationFailed");
440        assert_eq!(json["nodeId"], "loop-1");
441        assert_eq!(json["iteration"], 3);
442        assert_eq!(json["totalIterations"], 10);
443        assert_eq!(json["error"], "Download timed out");
444    }
445
446    #[test]
447    fn test_file_progress_serializes_correctly() {
448        let event = PipelineEvent::FileProgress {
449            node_id: "node-2".to_string(),
450            file_index: 2,
451            total_files: 5,
452            percent: 75,
453            message: "Compressing photo.jpg...".to_string(),
454        };
455        let json = serde_json::to_value(&event).unwrap();
456
457        assert_eq!(json["type"], "FileProgress");
458        assert_eq!(json["nodeId"], "node-2");
459        assert_eq!(json["fileIndex"], 2);
460        assert_eq!(json["totalFiles"], 5);
461        assert_eq!(json["percent"], 75);
462        assert_eq!(json["message"], "Compressing photo.jpg...");
463    }
464
465    #[test]
466    fn test_node_completed_serializes_correctly() {
467        let event = PipelineEvent::NodeCompleted {
468            node_id: "node-1".to_string(),
469            duration_ms: 1234,
470            files_processed: 5,
471            parent_node_id: None,
472        };
473        let json = serde_json::to_value(&event).unwrap();
474
475        assert_eq!(json["type"], "NodeCompleted");
476        assert_eq!(json["nodeId"], "node-1");
477        assert_eq!(json["durationMs"], 1234);
478        assert_eq!(json["filesProcessed"], 5);
479    }
480
481    #[test]
482    fn test_node_failed_serializes_correctly() {
483        let event = PipelineEvent::NodeFailed {
484            node_id: "node-3".to_string(),
485            error: "Unsupported format: BMP".to_string(),
486            parent_node_id: None,
487        };
488        let json = serde_json::to_value(&event).unwrap();
489
490        assert_eq!(json["type"], "NodeFailed");
491        assert_eq!(json["nodeId"], "node-3");
492        assert_eq!(json["error"], "Unsupported format: BMP");
493    }
494
495    #[test]
496    fn test_pipeline_completed_serializes_correctly() {
497        let event = PipelineEvent::PipelineCompleted {
498            duration_ms: 5678,
499            total_files_processed: 10,
500        };
501        let json = serde_json::to_value(&event).unwrap();
502
503        assert_eq!(json["type"], "PipelineCompleted");
504        assert_eq!(json["durationMs"], 5678);
505        assert_eq!(json["totalFilesProcessed"], 10);
506    }
507
508    #[test]
509    fn test_pipeline_failed_serializes_correctly() {
510        let event = PipelineEvent::PipelineFailed {
511            node_id: "node-2".to_string(),
512            error: "Processing failed: out of memory".to_string(),
513        };
514        let json = serde_json::to_value(&event).unwrap();
515
516        assert_eq!(json["type"], "PipelineFailed");
517        assert_eq!(json["nodeId"], "node-2");
518        assert_eq!(json["error"], "Processing failed: out of memory");
519    }
520
521    #[test]
522    fn test_command_output_serializes_correctly() {
523        let event = PipelineEvent::CommandOutput {
524            node_id: "node-0".to_string(),
525            line: "[download]  34.2% of ~150MiB".to_string(),
526        };
527        let json = serde_json::to_value(&event).unwrap();
528        assert_eq!(json["type"], "CommandOutput");
529        assert_eq!(json["nodeId"], "node-0");
530        assert_eq!(json["line"], "[download]  34.2% of ~150MiB");
531    }
532
533    #[test]
534    fn test_pipeline_reporter_is_cloneable() {
535        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
536        let received_clone = std::sync::Arc::clone(&received);
537
538        let reporter = PipelineReporter::new(move |event| {
539            received_clone.lock().unwrap().push(event);
540        });
541        let reporter2 = reporter.clone();
542
543        reporter.emit(PipelineEvent::PipelineStarted {
544            total_nodes: 1,
545            total_files: 1,
546            nodes: vec![],
547        });
548        reporter2.emit(PipelineEvent::CommandOutput {
549            node_id: "n".to_string(),
550            line: "hello".to_string(),
551        });
552
553        let events = received.lock().unwrap();
554        assert_eq!(events.len(), 2);
555    }
556
557    // --- Reporter Tests ---
558
559    #[test]
560    fn test_noop_reporter_doesnt_panic() {
561        // No-op reporter should silently discard all events.
562        let reporter = PipelineReporter::new_noop();
563        reporter.emit(PipelineEvent::PipelineStarted {
564            total_nodes: 1,
565            total_files: 1,
566            nodes: vec![],
567        });
568        reporter.emit(PipelineEvent::PipelineCompleted {
569            duration_ms: 100,
570            total_files_processed: 1,
571        });
572        // No panic = success.
573    }
574
575    #[test]
576    fn test_recording_reporter_captures_events() {
577        // Create a recording reporter and emit some events.
578        let recorder = RecordingReporter::new();
579        let reporter = recorder.reporter();
580
581        reporter.emit(PipelineEvent::PipelineStarted {
582            total_nodes: 2,
583            total_files: 3,
584            nodes: vec![],
585        });
586        reporter.emit(PipelineEvent::NodeStarted {
587            node_id: "n1".to_string(),
588            node_index: 0,
589            total_nodes: 2,
590            node_type: "image-compress".to_string(),
591            parent_node_id: None,
592        });
593        reporter.emit(PipelineEvent::PipelineCompleted {
594            duration_ms: 500,
595            total_files_processed: 3,
596        });
597
598        // Verify all 3 events were captured in order.
599        let events = recorder.events();
600        assert_eq!(events.len(), 3);
601
602        // Check types using pattern matching.
603        assert!(matches!(events[0], PipelineEvent::PipelineStarted { .. }));
604        assert!(matches!(events[1], PipelineEvent::NodeStarted { .. }));
605        assert!(matches!(events[2], PipelineEvent::PipelineCompleted { .. }));
606    }
607
608    #[test]
609    fn test_reporter_calls_callback() {
610        // Verify the callback receives the exact event we emit.
611        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
612        let received_clone = std::sync::Arc::clone(&received);
613
614        let reporter = PipelineReporter::new(move |event| {
615            received_clone.lock().unwrap().push(event);
616        });
617
618        reporter.emit(PipelineEvent::PipelineStarted {
619            total_nodes: 1,
620            total_files: 1,
621            nodes: vec![],
622        });
623
624        let events = received.lock().unwrap();
625        assert_eq!(events.len(), 1);
626        if let PipelineEvent::PipelineStarted {
627            total_nodes,
628            total_files,
629            ..
630        } = &events[0]
631        {
632            assert_eq!(*total_nodes, 1);
633            assert_eq!(*total_files, 1);
634        } else {
635            panic!("Expected PipelineStarted event");
636        }
637    }
638}