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, emit_workflow_error,
15    EmitError, LoadError,
16};
17use crate::extensions::resolve_wyvern_share;
18use crate::observability;
19use crate::viewer_spawn::{spawn_embedded_viewer, wait_for_viewer_exit, ViewerSpawnError};
20use crate::workflow::{
21    check_chain_depth, merge_wizard_config, resolve_next_wizard, Allowlist, WorkflowError,
22    WorkflowRunner, NEXT_WIZARD_MAX_DEPTH, WORKFLOW_SCRIPT_TIMEOUT,
23};
24
25/// Pipeline failure after load: stage stderr + exit, or emit-boundary serialize failure.
26#[derive(Debug)]
27pub enum PipelineError {
28    /// Stage failed after structured stderr was built successfully.
29    Stage { stderr: String, exit_code: i32 },
30    /// Stdout or stage stderr JSON could not be serialized.
31    Emit(EmitError),
32}
33
34/// Validate `value`, run the host, and return stdout JSON on success.
35///
36/// # Errors
37///
38/// Returns [`PipelineError::Stage`] with stderr JSON and a non-zero exit code on
39/// validation, markdown I/O, or host failure. Returns [`PipelineError::Emit`] when
40/// structured JSON serialization fails (REQ-0078).
41pub fn run_from_loaded(
42    value: Value,
43    host: HostOptions,
44    dry_run: bool,
45) -> Result<String, PipelineError> {
46    observability::log_command_received(&value);
47    let command = match wyvern_schema::validate(&value) {
48        Ok(cmd) => {
49            observability::log_validation_result(true);
50            cmd
51        }
52        Err(e) => {
53            observability::log_validation_result(false);
54            observability::log_error("validate", &format!("{e:?}"));
55            let stderr = emit_validation_error(&e).map_err(PipelineError::Emit)?;
56            return Err(PipelineError::Stage {
57                stderr,
58                exit_code: e.exit_code(),
59            });
60        }
61    };
62
63    if matches!(command, Command::Wizard(_)) {
64        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
65        let runner = WorkflowRunner {
66            allowlist: Allowlist {
67                share_root: resolve_wyvern_share(),
68                cwd,
69                wizard_dir: host.ui_root.clone(),
70            },
71            timeout: WORKFLOW_SCRIPT_TIMEOUT,
72            extra_env: Vec::new(),
73        };
74        return run_wizard_workflow_loop(value, host, &runner, dry_run);
75    }
76
77    let command = match load_markdown_file(command) {
78        Ok(cmd) => cmd,
79        Err(e) => {
80            observability::log_error("load_markdown", &format!("{e:?}"));
81            let stderr = emit_io_error(&e).map_err(PipelineError::Emit)?;
82            return Err(PipelineError::Stage {
83                stderr,
84                exit_code: e.exit_code(),
85            });
86        }
87    };
88
89    observability::log_host_start(command_type_name(&command));
90    finish_host_result(run_validated_host(command, host))
91}
92
93/// Every `Command::Wizard` one-shot enters this loop (REQ-0124–0126).
94///
95/// Other dialog types stay on the existing host path in [`run_from_loaded`].
96///
97/// # Errors
98///
99/// Returns [`PipelineError`] on validation, workflow, host, or emit failure.
100pub fn run_wizard_workflow_loop(
101    first: Value,
102    mut host: HostOptions,
103    runner: &WorkflowRunner,
104    dry_run: bool,
105) -> Result<String, PipelineError> {
106    let mut command_json = first;
107    let mut input = serde_json::json!({});
108    let mut allowlist = runner.allowlist.clone();
109    let mut last_result: Option<wyvern_schema::WizardResult> = None;
110
111    for hop in 1..=NEXT_WIZARD_MAX_DEPTH + 1 {
112        check_chain_depth(hop).map_err(workflow_stage)?;
113
114        let command = match wyvern_schema::validate(&command_json) {
115            Ok(cmd) => cmd,
116            Err(e) => {
117                observability::log_validation_result(false);
118                let stderr = emit_validation_error(&e).map_err(PipelineError::Emit)?;
119                return Err(PipelineError::Stage {
120                    stderr,
121                    exit_code: e.exit_code(),
122                });
123            }
124        };
125        let Command::Wizard(mut wizard) = command else {
126            return Err(workflow_stage(WorkflowError::Resolve {
127                path: String::new(),
128                cause: "next_wizard path did not expand to a wizard command".into(),
129            }));
130        };
131
132        wizard.config =
133            merge_wizard_config(wizard.config, input.clone(), None).map_err(workflow_stage)?;
134        let spec = wizard.workflow.clone().unwrap_or_default();
135        let hop_runner = WorkflowRunner {
136            allowlist: allowlist.clone(),
137            timeout: runner.timeout,
138            extra_env: runner.extra_env.clone(),
139        };
140        hop_runner
141            .run_pre(&spec, &mut wizard.config, dry_run)
142            .map_err(workflow_stage)?;
143
144        observability::log_host_start("wizard");
145        let result =
146            match finish_host_command(run_validated_host(Command::Wizard(wizard), host.clone()))? {
147                wyvern_schema::CommandResult::Wizard(wizard_result) => wizard_result,
148                other => return emit_stdout(&other).map_err(PipelineError::Emit),
149            };
150
151        let finish_value = serde_json::to_value(&result).map_err(|err| {
152            PipelineError::Emit(EmitError::Serialize(wyvern_schema::SerializeError {
153                message: err.to_string(),
154            }))
155        })?;
156
157        if result.button.as_str() != "finish" {
158            return emit_wizard_stdout(result);
159        }
160
161        hop_runner
162            .run_post(&spec, &finish_value, dry_run)
163            .map_err(workflow_stage)?;
164
165        match resolve_next_wizard(&finish_value, &allowlist).map_err(workflow_stage)? {
166            None => return emit_wizard_stdout(result),
167            Some(next) => {
168                input = next.input;
169                command_json = next.command;
170                host.ui_root = next.ui_root;
171                allowlist.wizard_dir = next.wizard_dir;
172                last_result = Some(result);
173            }
174        }
175    }
176
177    let _ = last_result;
178    Err(workflow_stage(WorkflowError::ChainDepth {
179        max: NEXT_WIZARD_MAX_DEPTH,
180    }))
181}
182
183fn workflow_stage(err: WorkflowError) -> PipelineError {
184    observability::log_error("workflow", &format!("{err:?}"));
185    match emit_workflow_error(&err) {
186        Ok(stderr) => PipelineError::Stage {
187            stderr,
188            exit_code: wyvern_schema::ErrorCode::WorkflowError.exit_code(),
189        },
190        Err(emit) => PipelineError::Emit(emit),
191    }
192}
193
194fn emit_wizard_stdout(mut result: wyvern_schema::WizardResult) -> Result<String, PipelineError> {
195    result.next_wizard = None;
196    emit_stdout(&wyvern_schema::CommandResult::Wizard(result)).map_err(PipelineError::Emit)
197}
198
199fn run_validated_host(
200    command: Command,
201    host: HostOptions,
202) -> Result<wyvern_schema::CommandResult, PipelineHostError> {
203    match host.viewer {
204        ViewerMode::Embedded => run_embedded(command, host),
205        ViewerMode::None | ViewerMode::System | ViewerMode::Named(_) => {
206            host_run(command, host).map_err(PipelineHostError::Host)
207        }
208    }
209}
210
211fn finish_host_command(
212    result: Result<wyvern_schema::CommandResult, PipelineHostError>,
213) -> Result<wyvern_schema::CommandResult, PipelineError> {
214    match result {
215        Ok(result) => {
216            observability::log_host_result(true);
217            Ok(result)
218        }
219        Err(PipelineHostError::Host(err)) => {
220            observability::log_error("host", &format!("{err:?}"));
221            observability::log_host_result(false);
222            let exit_code = host_error_exit_code(&err);
223            let stderr = emit_host_error(&err).map_err(PipelineError::Emit)?;
224            Err(PipelineError::Stage { stderr, exit_code })
225        }
226        Err(PipelineHostError::Viewer(err)) => {
227            observability::log_error("viewer_spawn", &format!("{err:?}"));
228            observability::log_host_result(false);
229            let stderr = emit_viewer_spawn_error(&err).map_err(PipelineError::Emit)?;
230            Err(PipelineError::Stage {
231                stderr,
232                exit_code: wyvern_schema::ErrorCode::HostViewerError.exit_code(),
233            })
234        }
235    }
236}
237
238fn finish_host_result(
239    result: Result<wyvern_schema::CommandResult, PipelineHostError>,
240) -> Result<String, PipelineError> {
241    emit_stdout(&finish_host_command(result)?).map_err(PipelineError::Emit)
242}
243
244enum PipelineHostError {
245    Host(HostError),
246    Viewer(ViewerSpawnError),
247}
248
249struct JoinOnDrop(Option<thread::JoinHandle<()>>);
250
251impl Drop for JoinOnDrop {
252    fn drop(&mut self) {
253        if let Some(handle) = self.0.take() {
254            let _ = handle.join();
255        }
256    }
257}
258
259fn run_embedded(
260    command: Command,
261    host: HostOptions,
262) -> Result<wyvern_schema::CommandResult, PipelineHostError> {
263    #[cfg(target_os = "macos")]
264    let picker_pump = wyvern_host::MacosPickerPump::install();
265
266    let mut handle = begin(command, host).map_err(PipelineHostError::Host)?;
267    let child = match spawn_embedded_viewer(&handle.dialog_url, &handle.viewer_options) {
268        Ok(child) => child,
269        Err(err) => {
270            // Shut down the host session — no viewer will post a result.
271            let _ = handle.viewer_exited_without_result();
272            return Err(PipelineHostError::Viewer(err));
273        }
274    };
275
276    // Arc<Mutex<Child>> lets the monitor thread call try_wait while the
277    // session thread later calls wait_for_viewer_exit. Child::try_wait
278    // needs &mut self; the mutex is the explicit sharing seam (RBP-F005).
279    let child = Arc::new(Mutex::new(child));
280    let dismiss_tx = handle.take_viewer_exit_signal();
281    let monitor_handle = if let Some(tx) = dismiss_tx {
282        let child_for_wait = Arc::clone(&child);
283        thread::spawn(move || {
284            loop {
285                let exited = match child_for_wait.lock() {
286                    Ok(mut c) => c.try_wait().ok().flatten().is_some(),
287                    Err(_) => true,
288                };
289                if exited {
290                    break;
291                }
292                thread::sleep(Duration::from_millis(50));
293            }
294            let _ = tx.send(());
295        })
296    } else {
297        let child_for_wait = Arc::clone(&child);
298        thread::spawn(move || loop {
299            let exited = match child_for_wait.lock() {
300                Ok(mut c) => c.try_wait().ok().flatten().is_some(),
301                Err(_) => true,
302            };
303            if exited {
304                break;
305            }
306            thread::sleep(Duration::from_millis(50));
307        })
308    };
309    let _monitor_join = JoinOnDrop(Some(monitor_handle));
310
311    // Give the child a brief moment to fail-fast (missing display, etc.).
312    thread::sleep(Duration::from_millis(50));
313
314    let result = {
315        #[cfg(target_os = "macos")]
316        {
317            loop {
318                picker_pump.drain(Duration::from_millis(50));
319                if let Some(result) = handle.try_recv_result() {
320                    let mapped = result.map_err(PipelineHostError::Host);
321                    handle.join_host_worker();
322                    break mapped;
323                }
324            }
325        }
326        #[cfg(not(target_os = "macos"))]
327        {
328            handle.await_result().map_err(PipelineHostError::Host)
329        }
330    }?;
331
332    // Parent-controlled viewer shutdown after host graceful stop (page only POSTs result).
333    if let Ok(mut c) = child.lock() {
334        wait_for_viewer_exit(&mut c);
335    }
336
337    Ok(result)
338}
339
340fn emit_viewer_spawn_error(err: &ViewerSpawnError) -> Result<String, EmitError> {
341    use wyvern_schema::{ErrorCode, StderrError};
342    let (message, cause, recovery) = match err {
343        ViewerSpawnError::NotFound { hint } => (
344            "wyvern-viewer binary not found".to_string(),
345            hint.clone(),
346            vec![
347                "Build or install wyvern-viewer next to the wyvern binary".to_string(),
348                "Set WYVERN_VIEWER_BIN to the viewer executable".to_string(),
349                "Use --viewer none for headless / CI".to_string(),
350            ],
351        ),
352        ViewerSpawnError::Io { message } => (
353            format!("failed to spawn wyvern-viewer: {message}"),
354            "Could not start the embedded viewer process".to_string(),
355            vec![
356                "Verify wyvern-viewer is executable".to_string(),
357                "Use --viewer none for headless / CI".to_string(),
358            ],
359        ),
360    };
361    let mut envelope = StderrError::new(ErrorCode::HostViewerError, message)
362        .cause(cause)
363        .docs("docs/plans/phase-C/http-viewer-contract.md");
364    for step in recovery {
365        envelope = envelope.recovery(step);
366    }
367    envelope.to_json_string().map_err(EmitError::Serialize)
368}
369
370fn command_type_name(command: &Command) -> &'static str {
371    match command {
372        Command::Chrome { .. } => "chrome",
373        Command::Message { .. } => "message",
374        Command::Input { .. } => "input",
375        Command::Markdown { .. } => "markdown",
376        Command::Question { .. } => "question",
377        Command::Wizard(_) => "wizard",
378        Command::Report(_) => "report",
379    }
380}
381
382fn host_error_exit_code(err: &HostError) -> i32 {
383    match err {
384        HostError::Bind { .. } => wyvern_schema::ErrorCode::HostBindError.exit_code(),
385        HostError::UiNotFound { .. } | HostError::UnsupportedType { .. } => {
386            wyvern_schema::ErrorCode::HostError.exit_code()
387        }
388        HostError::ViewerNotFound { .. } | HostError::ViewerUnsupported { .. } => {
389            wyvern_schema::ErrorCode::HostViewerError.exit_code()
390        }
391        HostError::InvalidResult { .. }
392        | HostError::Registry { .. }
393        | HostError::Internal { .. }
394        | HostError::Wizard { .. } => wyvern_schema::ErrorCode::HostError.exit_code(),
395    }
396}
397
398/// Read markdown `file` into `content` before the host opens (REQ-0071).
399///
400/// Missing or unreadable paths return [`LoadError::Io`] so the CLI emits `io`
401/// stderr without opening a dialog. Oversized file bodies are rejected at the
402/// CLI boundary using the same limit as schema validation.
403fn load_markdown_file(command: Command) -> Result<Command, LoadError> {
404    match command {
405        Command::Markdown {
406            title,
407            file: Some(path),
408            content: None,
409            status,
410            buttons,
411            width,
412            height,
413        } => {
414            let file = File::open(&path).map_err(|err| LoadError::Io {
415                field: FieldName::new("file"),
416                message: format!("could not read path '{path}': {err}"),
417                source: Some(Box::new(err)),
418            })?;
419            let max = wyvern_schema::MARKDOWN_CONTENT_MAX_BYTES;
420            let mut buf = Vec::new();
421            let n = file
422                .take(max as u64 + 1)
423                .read_to_end(&mut buf)
424                .map_err(|err| LoadError::Io {
425                    field: FieldName::new("file"),
426                    message: format!("could not read path '{path}': {err}"),
427                    source: Some(Box::new(err)),
428                })?;
429            if n > max {
430                return Err(LoadError::Io {
431                    field: FieldName::new("file"),
432                    message: format!(
433                        "markdown content exceeds maximum of {max} bytes (file '{path}')"
434                    ),
435                    source: None,
436                });
437            }
438            let body = String::from_utf8(buf).map_err(|err| LoadError::Io {
439                field: FieldName::new("file"),
440                message: format!("markdown file '{path}' is not valid UTF-8: {err}"),
441                source: Some(Box::new(err)),
442            })?;
443            Ok(Command::Markdown {
444                title,
445                file: Some(path),
446                content: Some(body),
447                status,
448                buttons,
449                width,
450                height,
451            })
452        }
453        other => Ok(other),
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use wyvern_schema::{ButtonsPreset, ChromeTitle};
461
462    #[test]
463    fn load_markdown_file_missing_is_io() {
464        let tmp = tempfile::tempdir().expect("temp dir");
465        let missing = tmp.path().join("definitely-missing-wyvern-b5.md");
466        let cmd = Command::Markdown {
467            title: Some(ChromeTitle::new("missing.md")),
468            file: Some(missing.to_string_lossy().into_owned()),
469            content: None,
470            status: None,
471            buttons: ButtonsPreset::Ok,
472            width: None,
473            height: None,
474        };
475        let err = load_markdown_file(cmd).expect_err("missing");
476        match err {
477            LoadError::Io { field, message, .. } => {
478                assert_eq!(field, "file");
479                assert!(message.contains("could not read path"));
480            }
481            other => panic!("expected Io, got {other:?}"),
482        }
483    }
484
485    #[test]
486    fn load_markdown_file_reads_utf8() {
487        let tmp = tempfile::tempdir().expect("temp dir");
488        let path = tmp.path().join("sample.md");
489        std::fs::write(&path, "# Hello\n\n- a\n- b\n").unwrap();
490
491        let cmd = Command::Markdown {
492            title: Some(ChromeTitle::new("sample.md")),
493            file: Some(path.to_string_lossy().into_owned()),
494            content: None,
495            status: None,
496            buttons: ButtonsPreset::Ok,
497            width: None,
498            height: None,
499        };
500        let loaded = load_markdown_file(cmd).expect("read");
501        match loaded {
502            Command::Markdown {
503                content: Some(body),
504                ..
505            } => {
506                assert!(body.contains("# Hello"));
507            }
508            other => panic!("expected loaded Markdown, got {other:?}"),
509        }
510    }
511
512    #[test]
513    fn load_markdown_inline_content_passthrough() {
514        let cmd = Command::Markdown {
515            title: Some(ChromeTitle::new("Markdown")),
516            file: None,
517            content: Some("# Inline\n".into()),
518            status: None,
519            buttons: ButtonsPreset::Ok,
520            width: None,
521            height: None,
522        };
523        let loaded = load_markdown_file(cmd).expect("passthrough");
524        match loaded {
525            Command::Markdown {
526                file: None,
527                content: Some(body),
528                ..
529            } => {
530                assert_eq!(body, "# Inline\n");
531            }
532            other => panic!("expected inline Markdown, got {other:?}"),
533        }
534    }
535
536    #[test]
537    fn load_markdown_file_rejects_oversized_body() {
538        let tmp = tempfile::tempdir().expect("temp dir");
539        let path = tmp.path().join("huge.md");
540        let body = "y".repeat(wyvern_schema::MARKDOWN_CONTENT_MAX_BYTES + 1);
541        std::fs::write(&path, &body).unwrap();
542
543        let cmd = Command::Markdown {
544            title: Some(ChromeTitle::new("huge.md")),
545            file: Some(path.to_string_lossy().into_owned()),
546            content: None,
547            status: None,
548            buttons: ButtonsPreset::Ok,
549            width: None,
550            height: None,
551        };
552        let err = load_markdown_file(cmd).expect_err("oversized");
553        match err {
554            LoadError::Io { field, message, .. } => {
555                assert_eq!(field, "file");
556                assert!(message.contains("exceeds maximum"));
557            }
558            other => panic!("expected Io, got {other:?}"),
559        }
560    }
561}