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 HostError::SessionTimeout { .. } => {
396 wyvern_schema::ErrorCode::SessionTimeoutError.exit_code()
397 }
398 }
399}
400
401fn load_markdown_file(command: Command) -> Result<Command, LoadError> {
407 match command {
408 Command::Markdown {
409 title,
410 file: Some(path),
411 content: None,
412 status,
413 buttons,
414 width,
415 height,
416 } => {
417 let file = File::open(&path).map_err(|err| LoadError::Io {
418 field: FieldName::new("file"),
419 message: format!("could not read path '{path}': {err}"),
420 source: Some(Box::new(err)),
421 })?;
422 let max = wyvern_schema::MARKDOWN_CONTENT_MAX_BYTES;
423 let mut buf = Vec::new();
424 let n = file
425 .take(max as u64 + 1)
426 .read_to_end(&mut buf)
427 .map_err(|err| LoadError::Io {
428 field: FieldName::new("file"),
429 message: format!("could not read path '{path}': {err}"),
430 source: Some(Box::new(err)),
431 })?;
432 if n > max {
433 return Err(LoadError::Io {
434 field: FieldName::new("file"),
435 message: format!(
436 "markdown content exceeds maximum of {max} bytes (file '{path}')"
437 ),
438 source: None,
439 });
440 }
441 let body = String::from_utf8(buf).map_err(|err| LoadError::Io {
442 field: FieldName::new("file"),
443 message: format!("markdown file '{path}' is not valid UTF-8: {err}"),
444 source: Some(Box::new(err)),
445 })?;
446 Ok(Command::Markdown {
447 title,
448 file: Some(path),
449 content: Some(body),
450 status,
451 buttons,
452 width,
453 height,
454 })
455 }
456 other => Ok(other),
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use wyvern_schema::{ButtonsPreset, ChromeTitle};
464
465 #[test]
466 fn load_markdown_file_missing_is_io() {
467 let tmp = tempfile::tempdir().expect("temp dir");
468 let missing = tmp.path().join("definitely-missing-wyvern-b5.md");
469 let cmd = Command::Markdown {
470 title: Some(ChromeTitle::new("missing.md")),
471 file: Some(missing.to_string_lossy().into_owned()),
472 content: None,
473 status: None,
474 buttons: ButtonsPreset::Ok,
475 width: None,
476 height: None,
477 };
478 let err = load_markdown_file(cmd).expect_err("missing");
479 match err {
480 LoadError::Io { field, message, .. } => {
481 assert_eq!(field, "file");
482 assert!(message.contains("could not read path"));
483 }
484 other => panic!("expected Io, got {other:?}"),
485 }
486 }
487
488 #[test]
489 fn load_markdown_file_reads_utf8() {
490 let tmp = tempfile::tempdir().expect("temp dir");
491 let path = tmp.path().join("sample.md");
492 std::fs::write(&path, "# Hello\n\n- a\n- b\n").unwrap();
493
494 let cmd = Command::Markdown {
495 title: Some(ChromeTitle::new("sample.md")),
496 file: Some(path.to_string_lossy().into_owned()),
497 content: None,
498 status: None,
499 buttons: ButtonsPreset::Ok,
500 width: None,
501 height: None,
502 };
503 let loaded = load_markdown_file(cmd).expect("read");
504 match loaded {
505 Command::Markdown {
506 content: Some(body),
507 ..
508 } => {
509 assert!(body.contains("# Hello"));
510 }
511 other => panic!("expected loaded Markdown, got {other:?}"),
512 }
513 }
514
515 #[test]
516 fn load_markdown_inline_content_passthrough() {
517 let cmd = Command::Markdown {
518 title: Some(ChromeTitle::new("Markdown")),
519 file: None,
520 content: Some("# Inline\n".into()),
521 status: None,
522 buttons: ButtonsPreset::Ok,
523 width: None,
524 height: None,
525 };
526 let loaded = load_markdown_file(cmd).expect("passthrough");
527 match loaded {
528 Command::Markdown {
529 file: None,
530 content: Some(body),
531 ..
532 } => {
533 assert_eq!(body, "# Inline\n");
534 }
535 other => panic!("expected inline Markdown, got {other:?}"),
536 }
537 }
538
539 #[test]
540 fn load_markdown_file_rejects_oversized_body() {
541 let tmp = tempfile::tempdir().expect("temp dir");
542 let path = tmp.path().join("huge.md");
543 let body = "y".repeat(wyvern_schema::MARKDOWN_CONTENT_MAX_BYTES + 1);
544 std::fs::write(&path, &body).unwrap();
545
546 let cmd = Command::Markdown {
547 title: Some(ChromeTitle::new("huge.md")),
548 file: Some(path.to_string_lossy().into_owned()),
549 content: None,
550 status: None,
551 buttons: ButtonsPreset::Ok,
552 width: None,
553 height: None,
554 };
555 let err = load_markdown_file(cmd).expect_err("oversized");
556 match err {
557 LoadError::Io { field, message, .. } => {
558 assert_eq!(field, "file");
559 assert!(message.contains("exceeds maximum"));
560 }
561 other => panic!("expected Io, got {other:?}"),
562 }
563 }
564}