Skip to main content

wyvern/
pipeline.rs

1//! CLI pipeline: validate → load markdown files → host run / embedded spawn → emit.
2
3use std::fs::File;
4use std::io::Read;
5use std::sync::{Arc, Mutex};
6use std::thread;
7use std::time::Duration;
8
9use serde_json::Value;
10use wyvern_host::{begin, run as host_run, HostError, HostOptions, ViewerMode};
11use wyvern_schema::{Command, FieldName};
12
13use crate::error::{
14    emit_host_error, emit_io_error, emit_stdout, emit_validation_error, EmitError, LoadError,
15};
16use crate::observability;
17use crate::viewer_spawn::{spawn_embedded_viewer, wait_for_viewer_exit, ViewerSpawnError};
18
19/// Pipeline failure after load: stage stderr + exit, or emit-boundary serialize failure.
20#[derive(Debug)]
21pub enum PipelineError {
22    /// Stage failed after structured stderr was built successfully.
23    Stage { stderr: String, exit_code: i32 },
24    /// Stdout or stage stderr JSON could not be serialized.
25    Emit(EmitError),
26}
27
28/// Validate `value`, run the host, and return stdout JSON on success.
29///
30/// # Errors
31///
32/// Returns [`PipelineError::Stage`] with stderr JSON and a non-zero exit code on
33/// validation, markdown I/O, or host failure. Returns [`PipelineError::Emit`] when
34/// structured JSON serialization fails (REQ-0078).
35pub fn run_from_loaded(value: Value, host: HostOptions) -> Result<String, PipelineError> {
36    observability::log_command_received(&value);
37    let command = match wyvern_schema::validate(&value) {
38        Ok(cmd) => {
39            observability::log_validation_result(true);
40            cmd
41        }
42        Err(e) => {
43            observability::log_validation_result(false);
44            observability::log_error("validate", &format!("{e:?}"));
45            let stderr = emit_validation_error(&e).map_err(PipelineError::Emit)?;
46            return Err(PipelineError::Stage {
47                stderr,
48                exit_code: e.exit_code(),
49            });
50        }
51    };
52
53    let command = match load_markdown_file(command) {
54        Ok(cmd) => cmd,
55        Err(e) => {
56            observability::log_error("load_markdown", &format!("{e:?}"));
57            let stderr = emit_io_error(&e).map_err(PipelineError::Emit)?;
58            return Err(PipelineError::Stage {
59                stderr,
60                exit_code: e.exit_code(),
61            });
62        }
63    };
64
65    observability::log_host_start(command_type_name(&command));
66    let result = match host.viewer {
67        ViewerMode::Embedded => run_embedded(command, host),
68        ViewerMode::None | ViewerMode::System | ViewerMode::Named(_) => {
69            host_run(command, host).map_err(PipelineHostError::Host)
70        }
71    };
72
73    match result {
74        Ok(result) => {
75            observability::log_host_result(true);
76            emit_stdout(&result).map_err(PipelineError::Emit)
77        }
78        Err(PipelineHostError::Host(err)) => {
79            observability::log_error("host", &format!("{err:?}"));
80            observability::log_host_result(false);
81            let exit_code = host_error_exit_code(&err);
82            let stderr = emit_host_error(&err).map_err(PipelineError::Emit)?;
83            Err(PipelineError::Stage { stderr, exit_code })
84        }
85        Err(PipelineHostError::Viewer(err)) => {
86            observability::log_error("viewer_spawn", &format!("{err:?}"));
87            observability::log_host_result(false);
88            let stderr = emit_viewer_spawn_error(&err).map_err(PipelineError::Emit)?;
89            Err(PipelineError::Stage {
90                stderr,
91                exit_code: wyvern_schema::ErrorCode::HostViewerError.exit_code(),
92            })
93        }
94    }
95}
96
97enum PipelineHostError {
98    Host(HostError),
99    Viewer(ViewerSpawnError),
100}
101
102struct JoinOnDrop(Option<thread::JoinHandle<()>>);
103
104impl Drop for JoinOnDrop {
105    fn drop(&mut self) {
106        if let Some(handle) = self.0.take() {
107            let _ = handle.join();
108        }
109    }
110}
111
112fn run_embedded(
113    command: Command,
114    host: HostOptions,
115) -> Result<wyvern_schema::CommandResult, PipelineHostError> {
116    #[cfg(target_os = "macos")]
117    let picker_pump = wyvern_host::MacosPickerPump::install();
118
119    let mut handle = begin(command, host).map_err(PipelineHostError::Host)?;
120    let child = match spawn_embedded_viewer(&handle.dialog_url, &handle.viewer_options) {
121        Ok(child) => child,
122        Err(err) => {
123            // Shut down the host session — no viewer will post a result.
124            let _ = handle.viewer_exited_without_result();
125            return Err(PipelineHostError::Viewer(err));
126        }
127    };
128
129    // Arc<Mutex<Child>> lets the monitor thread call try_wait while the
130    // session thread later calls wait_for_viewer_exit. Child::try_wait
131    // needs &mut self; the mutex is the explicit sharing seam (RBP-F005).
132    let child = Arc::new(Mutex::new(child));
133    let dismiss_tx = handle.take_viewer_exit_signal();
134    let monitor_handle = if let Some(tx) = dismiss_tx {
135        let child_for_wait = Arc::clone(&child);
136        thread::spawn(move || {
137            loop {
138                let exited = match child_for_wait.lock() {
139                    Ok(mut c) => c.try_wait().ok().flatten().is_some(),
140                    Err(_) => true,
141                };
142                if exited {
143                    break;
144                }
145                thread::sleep(Duration::from_millis(50));
146            }
147            let _ = tx.send(());
148        })
149    } else {
150        let child_for_wait = Arc::clone(&child);
151        thread::spawn(move || loop {
152            let exited = match child_for_wait.lock() {
153                Ok(mut c) => c.try_wait().ok().flatten().is_some(),
154                Err(_) => true,
155            };
156            if exited {
157                break;
158            }
159            thread::sleep(Duration::from_millis(50));
160        })
161    };
162    let _monitor_join = JoinOnDrop(Some(monitor_handle));
163
164    // Give the child a brief moment to fail-fast (missing display, etc.).
165    thread::sleep(Duration::from_millis(50));
166
167    let result = {
168        #[cfg(target_os = "macos")]
169        {
170            loop {
171                picker_pump.drain(Duration::from_millis(50));
172                if let Some(result) = handle.try_recv_result() {
173                    let mapped = result.map_err(PipelineHostError::Host);
174                    handle.join_host_worker();
175                    break mapped;
176                }
177            }
178        }
179        #[cfg(not(target_os = "macos"))]
180        {
181            handle.await_result().map_err(PipelineHostError::Host)
182        }
183    }?;
184
185    // Parent-controlled viewer shutdown after host graceful stop (page only POSTs result).
186    if let Ok(mut c) = child.lock() {
187        wait_for_viewer_exit(&mut c);
188    }
189
190    Ok(result)
191}
192
193fn emit_viewer_spawn_error(err: &ViewerSpawnError) -> Result<String, EmitError> {
194    use wyvern_schema::{ErrorCode, StderrError};
195    let (message, cause, recovery) = match err {
196        ViewerSpawnError::NotFound { hint } => (
197            "wyvern-viewer binary not found".to_string(),
198            hint.clone(),
199            vec![
200                "Build or install wyvern-viewer next to the wyvern binary".to_string(),
201                "Set WYVERN_VIEWER_BIN to the viewer executable".to_string(),
202                "Use --viewer none for headless / CI".to_string(),
203            ],
204        ),
205        ViewerSpawnError::Io { message } => (
206            format!("failed to spawn wyvern-viewer: {message}"),
207            "Could not start the embedded viewer process".to_string(),
208            vec![
209                "Verify wyvern-viewer is executable".to_string(),
210                "Use --viewer none for headless / CI".to_string(),
211            ],
212        ),
213    };
214    let mut envelope = StderrError::new(ErrorCode::HostViewerError, message)
215        .cause(cause)
216        .docs("docs/plans/phase-C/http-viewer-contract.md");
217    for step in recovery {
218        envelope = envelope.recovery(step);
219    }
220    envelope.to_json_string().map_err(EmitError::Serialize)
221}
222
223fn command_type_name(command: &Command) -> &'static str {
224    match command {
225        Command::Chrome { .. } => "chrome",
226        Command::Message { .. } => "message",
227        Command::Input { .. } => "input",
228        Command::Markdown { .. } => "markdown",
229        Command::Question { .. } => "question",
230        Command::Wizard(_) => "wizard",
231    }
232}
233
234fn host_error_exit_code(err: &HostError) -> i32 {
235    match err {
236        HostError::Bind { .. } => wyvern_schema::ErrorCode::HostBindError.exit_code(),
237        HostError::UiNotFound { .. } | HostError::UnsupportedType { .. } => {
238            wyvern_schema::ErrorCode::HostError.exit_code()
239        }
240        HostError::ViewerNotFound { .. } | HostError::ViewerUnsupported { .. } => {
241            wyvern_schema::ErrorCode::HostViewerError.exit_code()
242        }
243        HostError::InvalidResult { .. }
244        | HostError::Registry { .. }
245        | HostError::Internal { .. }
246        | HostError::Wizard { .. } => wyvern_schema::ErrorCode::HostError.exit_code(),
247    }
248}
249
250/// Read markdown `file` into `content` before the host opens (REQ-0071).
251///
252/// Missing or unreadable paths return [`LoadError::Io`] so the CLI emits `io`
253/// stderr without opening a dialog. Oversized file bodies are rejected at the
254/// CLI boundary using the same limit as schema validation.
255fn load_markdown_file(command: Command) -> Result<Command, LoadError> {
256    match command {
257        Command::Markdown {
258            title,
259            file: Some(path),
260            content: None,
261            status,
262            buttons,
263            width,
264            height,
265        } => {
266            let file = File::open(&path).map_err(|err| LoadError::Io {
267                field: FieldName::new("file"),
268                message: format!("could not read path '{path}': {err}"),
269                source: Some(Box::new(err)),
270            })?;
271            let max = wyvern_schema::MARKDOWN_CONTENT_MAX_BYTES;
272            let mut buf = Vec::new();
273            let n = file
274                .take(max as u64 + 1)
275                .read_to_end(&mut buf)
276                .map_err(|err| LoadError::Io {
277                    field: FieldName::new("file"),
278                    message: format!("could not read path '{path}': {err}"),
279                    source: Some(Box::new(err)),
280                })?;
281            if n > max {
282                return Err(LoadError::Io {
283                    field: FieldName::new("file"),
284                    message: format!(
285                        "markdown content exceeds maximum of {max} bytes (file '{path}')"
286                    ),
287                    source: None,
288                });
289            }
290            let body = String::from_utf8(buf).map_err(|err| LoadError::Io {
291                field: FieldName::new("file"),
292                message: format!("markdown file '{path}' is not valid UTF-8: {err}"),
293                source: Some(Box::new(err)),
294            })?;
295            Ok(Command::Markdown {
296                title,
297                file: Some(path),
298                content: Some(body),
299                status,
300                buttons,
301                width,
302                height,
303            })
304        }
305        other => Ok(other),
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use wyvern_schema::{ButtonsPreset, ChromeTitle};
313
314    #[test]
315    fn load_markdown_file_missing_is_io() {
316        let tmp = tempfile::tempdir().expect("temp dir");
317        let missing = tmp.path().join("definitely-missing-wyvern-b5.md");
318        let cmd = Command::Markdown {
319            title: Some(ChromeTitle::new("missing.md")),
320            file: Some(missing.to_string_lossy().into_owned()),
321            content: None,
322            status: None,
323            buttons: ButtonsPreset::Ok,
324            width: None,
325            height: None,
326        };
327        let err = load_markdown_file(cmd).expect_err("missing");
328        match err {
329            LoadError::Io { field, message, .. } => {
330                assert_eq!(field, "file");
331                assert!(message.contains("could not read path"));
332            }
333            other => panic!("expected Io, got {other:?}"),
334        }
335    }
336
337    #[test]
338    fn load_markdown_file_reads_utf8() {
339        let tmp = tempfile::tempdir().expect("temp dir");
340        let path = tmp.path().join("sample.md");
341        std::fs::write(&path, "# Hello\n\n- a\n- b\n").unwrap();
342
343        let cmd = Command::Markdown {
344            title: Some(ChromeTitle::new("sample.md")),
345            file: Some(path.to_string_lossy().into_owned()),
346            content: None,
347            status: None,
348            buttons: ButtonsPreset::Ok,
349            width: None,
350            height: None,
351        };
352        let loaded = load_markdown_file(cmd).expect("read");
353        match loaded {
354            Command::Markdown {
355                content: Some(body),
356                ..
357            } => {
358                assert!(body.contains("# Hello"));
359            }
360            other => panic!("expected loaded Markdown, got {other:?}"),
361        }
362    }
363
364    #[test]
365    fn load_markdown_inline_content_passthrough() {
366        let cmd = Command::Markdown {
367            title: Some(ChromeTitle::new("Markdown")),
368            file: None,
369            content: Some("# Inline\n".into()),
370            status: None,
371            buttons: ButtonsPreset::Ok,
372            width: None,
373            height: None,
374        };
375        let loaded = load_markdown_file(cmd).expect("passthrough");
376        match loaded {
377            Command::Markdown {
378                file: None,
379                content: Some(body),
380                ..
381            } => {
382                assert_eq!(body, "# Inline\n");
383            }
384            other => panic!("expected inline Markdown, got {other:?}"),
385        }
386    }
387
388    #[test]
389    fn load_markdown_file_rejects_oversized_body() {
390        let tmp = tempfile::tempdir().expect("temp dir");
391        let path = tmp.path().join("huge.md");
392        let body = "y".repeat(wyvern_schema::MARKDOWN_CONTENT_MAX_BYTES + 1);
393        std::fs::write(&path, &body).unwrap();
394
395        let cmd = Command::Markdown {
396            title: Some(ChromeTitle::new("huge.md")),
397            file: Some(path.to_string_lossy().into_owned()),
398            content: None,
399            status: None,
400            buttons: ButtonsPreset::Ok,
401            width: None,
402            height: None,
403        };
404        let err = load_markdown_file(cmd).expect_err("oversized");
405        match err {
406            LoadError::Io { field, message, .. } => {
407                assert_eq!(field, "file");
408                assert!(message.contains("exceeds maximum"));
409            }
410            other => panic!("expected Io, got {other:?}"),
411        }
412    }
413}