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, EmitError, LoadError,
15};
16use crate::observability;
17use crate::viewer_spawn::{spawn_embedded_viewer, wait_for_viewer_exit, ViewerSpawnError};
18
19#[derive(Debug)]
21pub enum PipelineError {
22 Stage { stderr: String, exit_code: i32 },
24 Emit(EmitError),
26}
27
28pub 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 let _ = handle.viewer_exited_without_result();
125 return Err(PipelineHostError::Viewer(err));
126 }
127 };
128
129 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 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 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
250fn 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}