1use 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#[derive(Debug)]
27pub enum PipelineError {
28 Stage { stderr: String, exit_code: i32 },
30 Emit(EmitError),
32}
33
34pub 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
93pub 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 let _ = handle.viewer_exited_without_result();
272 return Err(PipelineHostError::Viewer(err));
273 }
274 };
275
276 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 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 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
398fn 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}