use std::fs::File;
use std::io::Read;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use serde_json::Value;
use wyvern_host::{begin, run as host_run, HostError, HostOptions, ViewerMode};
use wyvern_schema::{Command, FieldName};
use crate::error::{
emit_host_error, emit_io_error, emit_stdout, emit_validation_error, emit_workflow_error,
EmitError, LoadError,
};
use crate::extensions::resolve_wyvern_share;
use crate::observability;
use crate::viewer_spawn::{spawn_embedded_viewer, wait_for_viewer_exit, ViewerSpawnError};
use crate::workflow::{
check_chain_depth, merge_wizard_config, resolve_next_wizard, Allowlist, WorkflowError,
WorkflowRunner, NEXT_WIZARD_MAX_DEPTH, WORKFLOW_SCRIPT_TIMEOUT,
};
#[derive(Debug)]
pub enum PipelineError {
Stage { stderr: String, exit_code: i32 },
Emit(EmitError),
}
pub fn run_from_loaded(
value: Value,
host: HostOptions,
dry_run: bool,
) -> Result<String, PipelineError> {
observability::log_command_received(&value);
let command = match wyvern_schema::validate(&value) {
Ok(cmd) => {
observability::log_validation_result(true);
cmd
}
Err(e) => {
observability::log_validation_result(false);
observability::log_error("validate", &format!("{e:?}"));
let stderr = emit_validation_error(&e).map_err(PipelineError::Emit)?;
return Err(PipelineError::Stage {
stderr,
exit_code: e.exit_code(),
});
}
};
if matches!(command, Command::Wizard(_)) {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let runner = WorkflowRunner {
allowlist: Allowlist {
share_root: resolve_wyvern_share(),
cwd,
wizard_dir: host.ui_root.clone(),
},
timeout: WORKFLOW_SCRIPT_TIMEOUT,
extra_env: Vec::new(),
};
return run_wizard_workflow_loop(value, host, &runner, dry_run);
}
let command = match load_markdown_file(command) {
Ok(cmd) => cmd,
Err(e) => {
observability::log_error("load_markdown", &format!("{e:?}"));
let stderr = emit_io_error(&e).map_err(PipelineError::Emit)?;
return Err(PipelineError::Stage {
stderr,
exit_code: e.exit_code(),
});
}
};
observability::log_host_start(command_type_name(&command));
finish_host_result(run_validated_host(command, host))
}
pub fn run_wizard_workflow_loop(
first: Value,
mut host: HostOptions,
runner: &WorkflowRunner,
dry_run: bool,
) -> Result<String, PipelineError> {
let mut command_json = first;
let mut input = serde_json::json!({});
let mut allowlist = runner.allowlist.clone();
let mut last_result: Option<wyvern_schema::WizardResult> = None;
for hop in 1..=NEXT_WIZARD_MAX_DEPTH + 1 {
check_chain_depth(hop).map_err(workflow_stage)?;
let command = match wyvern_schema::validate(&command_json) {
Ok(cmd) => cmd,
Err(e) => {
observability::log_validation_result(false);
let stderr = emit_validation_error(&e).map_err(PipelineError::Emit)?;
return Err(PipelineError::Stage {
stderr,
exit_code: e.exit_code(),
});
}
};
let Command::Wizard(mut wizard) = command else {
return Err(workflow_stage(WorkflowError::Resolve {
path: String::new(),
cause: "next_wizard path did not expand to a wizard command".into(),
}));
};
wizard.config =
merge_wizard_config(wizard.config, input.clone(), None).map_err(workflow_stage)?;
let spec = wizard.workflow.clone().unwrap_or_default();
let hop_runner = WorkflowRunner {
allowlist: allowlist.clone(),
timeout: runner.timeout,
extra_env: runner.extra_env.clone(),
};
hop_runner
.run_pre(&spec, &mut wizard.config, dry_run)
.map_err(workflow_stage)?;
observability::log_host_start("wizard");
let result =
match finish_host_command(run_validated_host(Command::Wizard(wizard), host.clone()))? {
wyvern_schema::CommandResult::Wizard(wizard_result) => wizard_result,
other => return emit_stdout(&other).map_err(PipelineError::Emit),
};
let finish_value = serde_json::to_value(&result).map_err(|err| {
PipelineError::Emit(EmitError::Serialize(wyvern_schema::SerializeError {
message: err.to_string(),
}))
})?;
if result.button.as_str() != "finish" {
return emit_wizard_stdout(result);
}
hop_runner
.run_post(&spec, &finish_value, dry_run)
.map_err(workflow_stage)?;
match resolve_next_wizard(&finish_value, &allowlist).map_err(workflow_stage)? {
None => return emit_wizard_stdout(result),
Some(next) => {
input = next.input;
command_json = next.command;
host.ui_root = next.ui_root;
allowlist.wizard_dir = next.wizard_dir;
last_result = Some(result);
}
}
}
let _ = last_result;
Err(workflow_stage(WorkflowError::ChainDepth {
max: NEXT_WIZARD_MAX_DEPTH,
}))
}
fn workflow_stage(err: WorkflowError) -> PipelineError {
observability::log_error("workflow", &format!("{err:?}"));
match emit_workflow_error(&err) {
Ok(stderr) => PipelineError::Stage {
stderr,
exit_code: wyvern_schema::ErrorCode::WorkflowError.exit_code(),
},
Err(emit) => PipelineError::Emit(emit),
}
}
fn emit_wizard_stdout(mut result: wyvern_schema::WizardResult) -> Result<String, PipelineError> {
result.next_wizard = None;
emit_stdout(&wyvern_schema::CommandResult::Wizard(result)).map_err(PipelineError::Emit)
}
fn run_validated_host(
command: Command,
host: HostOptions,
) -> Result<wyvern_schema::CommandResult, PipelineHostError> {
match host.viewer {
ViewerMode::Embedded => run_embedded(command, host),
ViewerMode::None | ViewerMode::System | ViewerMode::Named(_) => {
host_run(command, host).map_err(PipelineHostError::Host)
}
}
}
fn finish_host_command(
result: Result<wyvern_schema::CommandResult, PipelineHostError>,
) -> Result<wyvern_schema::CommandResult, PipelineError> {
match result {
Ok(result) => {
observability::log_host_result(true);
Ok(result)
}
Err(PipelineHostError::Host(err)) => {
observability::log_error("host", &format!("{err:?}"));
observability::log_host_result(false);
let exit_code = host_error_exit_code(&err);
let stderr = emit_host_error(&err).map_err(PipelineError::Emit)?;
Err(PipelineError::Stage { stderr, exit_code })
}
Err(PipelineHostError::Viewer(err)) => {
observability::log_error("viewer_spawn", &format!("{err:?}"));
observability::log_host_result(false);
let stderr = emit_viewer_spawn_error(&err).map_err(PipelineError::Emit)?;
Err(PipelineError::Stage {
stderr,
exit_code: wyvern_schema::ErrorCode::HostViewerError.exit_code(),
})
}
}
}
fn finish_host_result(
result: Result<wyvern_schema::CommandResult, PipelineHostError>,
) -> Result<String, PipelineError> {
emit_stdout(&finish_host_command(result)?).map_err(PipelineError::Emit)
}
enum PipelineHostError {
Host(HostError),
Viewer(ViewerSpawnError),
}
struct JoinOnDrop(Option<thread::JoinHandle<()>>);
impl Drop for JoinOnDrop {
fn drop(&mut self) {
if let Some(handle) = self.0.take() {
let _ = handle.join();
}
}
}
fn run_embedded(
command: Command,
host: HostOptions,
) -> Result<wyvern_schema::CommandResult, PipelineHostError> {
#[cfg(target_os = "macos")]
let picker_pump = wyvern_host::MacosPickerPump::install();
let mut handle = begin(command, host).map_err(PipelineHostError::Host)?;
let child = match spawn_embedded_viewer(&handle.dialog_url, &handle.viewer_options) {
Ok(child) => child,
Err(err) => {
let _ = handle.viewer_exited_without_result();
return Err(PipelineHostError::Viewer(err));
}
};
let child = Arc::new(Mutex::new(child));
let dismiss_tx = handle.take_viewer_exit_signal();
let monitor_handle = if let Some(tx) = dismiss_tx {
let child_for_wait = Arc::clone(&child);
thread::spawn(move || {
loop {
let exited = match child_for_wait.lock() {
Ok(mut c) => c.try_wait().ok().flatten().is_some(),
Err(_) => true,
};
if exited {
break;
}
thread::sleep(Duration::from_millis(50));
}
let _ = tx.send(());
})
} else {
let child_for_wait = Arc::clone(&child);
thread::spawn(move || loop {
let exited = match child_for_wait.lock() {
Ok(mut c) => c.try_wait().ok().flatten().is_some(),
Err(_) => true,
};
if exited {
break;
}
thread::sleep(Duration::from_millis(50));
})
};
let _monitor_join = JoinOnDrop(Some(monitor_handle));
thread::sleep(Duration::from_millis(50));
let result = {
#[cfg(target_os = "macos")]
{
loop {
picker_pump.drain(Duration::from_millis(50));
if let Some(result) = handle.try_recv_result() {
let mapped = result.map_err(PipelineHostError::Host);
handle.join_host_worker();
break mapped;
}
}
}
#[cfg(not(target_os = "macos"))]
{
handle.await_result().map_err(PipelineHostError::Host)
}
}?;
if let Ok(mut c) = child.lock() {
wait_for_viewer_exit(&mut c);
}
Ok(result)
}
fn emit_viewer_spawn_error(err: &ViewerSpawnError) -> Result<String, EmitError> {
use wyvern_schema::{ErrorCode, StderrError};
let (message, cause, recovery) = match err {
ViewerSpawnError::NotFound { hint } => (
"wyvern-viewer binary not found".to_string(),
hint.clone(),
vec![
"Build or install wyvern-viewer next to the wyvern binary".to_string(),
"Set WYVERN_VIEWER_BIN to the viewer executable".to_string(),
"Use --viewer none for headless / CI".to_string(),
],
),
ViewerSpawnError::Io { message } => (
format!("failed to spawn wyvern-viewer: {message}"),
"Could not start the embedded viewer process".to_string(),
vec![
"Verify wyvern-viewer is executable".to_string(),
"Use --viewer none for headless / CI".to_string(),
],
),
};
let mut envelope = StderrError::new(ErrorCode::HostViewerError, message)
.cause(cause)
.docs("docs/plans/phase-C/http-viewer-contract.md");
for step in recovery {
envelope = envelope.recovery(step);
}
envelope.to_json_string().map_err(EmitError::Serialize)
}
fn command_type_name(command: &Command) -> &'static str {
match command {
Command::Chrome { .. } => "chrome",
Command::Message { .. } => "message",
Command::Input { .. } => "input",
Command::Markdown { .. } => "markdown",
Command::Question { .. } => "question",
Command::Wizard(_) => "wizard",
Command::Report(_) => "report",
}
}
fn host_error_exit_code(err: &HostError) -> i32 {
match err {
HostError::Bind { .. } => wyvern_schema::ErrorCode::HostBindError.exit_code(),
HostError::UiNotFound { .. } | HostError::UnsupportedType { .. } => {
wyvern_schema::ErrorCode::HostError.exit_code()
}
HostError::ViewerNotFound { .. } | HostError::ViewerUnsupported { .. } => {
wyvern_schema::ErrorCode::HostViewerError.exit_code()
}
HostError::InvalidResult { .. }
| HostError::Registry { .. }
| HostError::Internal { .. }
| HostError::Wizard { .. } => wyvern_schema::ErrorCode::HostError.exit_code(),
}
}
fn load_markdown_file(command: Command) -> Result<Command, LoadError> {
match command {
Command::Markdown {
title,
file: Some(path),
content: None,
status,
buttons,
width,
height,
} => {
let file = File::open(&path).map_err(|err| LoadError::Io {
field: FieldName::new("file"),
message: format!("could not read path '{path}': {err}"),
source: Some(Box::new(err)),
})?;
let max = wyvern_schema::MARKDOWN_CONTENT_MAX_BYTES;
let mut buf = Vec::new();
let n = file
.take(max as u64 + 1)
.read_to_end(&mut buf)
.map_err(|err| LoadError::Io {
field: FieldName::new("file"),
message: format!("could not read path '{path}': {err}"),
source: Some(Box::new(err)),
})?;
if n > max {
return Err(LoadError::Io {
field: FieldName::new("file"),
message: format!(
"markdown content exceeds maximum of {max} bytes (file '{path}')"
),
source: None,
});
}
let body = String::from_utf8(buf).map_err(|err| LoadError::Io {
field: FieldName::new("file"),
message: format!("markdown file '{path}' is not valid UTF-8: {err}"),
source: Some(Box::new(err)),
})?;
Ok(Command::Markdown {
title,
file: Some(path),
content: Some(body),
status,
buttons,
width,
height,
})
}
other => Ok(other),
}
}
#[cfg(test)]
mod tests {
use super::*;
use wyvern_schema::{ButtonsPreset, ChromeTitle};
#[test]
fn load_markdown_file_missing_is_io() {
let tmp = tempfile::tempdir().expect("temp dir");
let missing = tmp.path().join("definitely-missing-wyvern-b5.md");
let cmd = Command::Markdown {
title: Some(ChromeTitle::new("missing.md")),
file: Some(missing.to_string_lossy().into_owned()),
content: None,
status: None,
buttons: ButtonsPreset::Ok,
width: None,
height: None,
};
let err = load_markdown_file(cmd).expect_err("missing");
match err {
LoadError::Io { field, message, .. } => {
assert_eq!(field, "file");
assert!(message.contains("could not read path"));
}
other => panic!("expected Io, got {other:?}"),
}
}
#[test]
fn load_markdown_file_reads_utf8() {
let tmp = tempfile::tempdir().expect("temp dir");
let path = tmp.path().join("sample.md");
std::fs::write(&path, "# Hello\n\n- a\n- b\n").unwrap();
let cmd = Command::Markdown {
title: Some(ChromeTitle::new("sample.md")),
file: Some(path.to_string_lossy().into_owned()),
content: None,
status: None,
buttons: ButtonsPreset::Ok,
width: None,
height: None,
};
let loaded = load_markdown_file(cmd).expect("read");
match loaded {
Command::Markdown {
content: Some(body),
..
} => {
assert!(body.contains("# Hello"));
}
other => panic!("expected loaded Markdown, got {other:?}"),
}
}
#[test]
fn load_markdown_inline_content_passthrough() {
let cmd = Command::Markdown {
title: Some(ChromeTitle::new("Markdown")),
file: None,
content: Some("# Inline\n".into()),
status: None,
buttons: ButtonsPreset::Ok,
width: None,
height: None,
};
let loaded = load_markdown_file(cmd).expect("passthrough");
match loaded {
Command::Markdown {
file: None,
content: Some(body),
..
} => {
assert_eq!(body, "# Inline\n");
}
other => panic!("expected inline Markdown, got {other:?}"),
}
}
#[test]
fn load_markdown_file_rejects_oversized_body() {
let tmp = tempfile::tempdir().expect("temp dir");
let path = tmp.path().join("huge.md");
let body = "y".repeat(wyvern_schema::MARKDOWN_CONTENT_MAX_BYTES + 1);
std::fs::write(&path, &body).unwrap();
let cmd = Command::Markdown {
title: Some(ChromeTitle::new("huge.md")),
file: Some(path.to_string_lossy().into_owned()),
content: None,
status: None,
buttons: ButtonsPreset::Ok,
width: None,
height: None,
};
let err = load_markdown_file(cmd).expect_err("oversized");
match err {
LoadError::Io { field, message, .. } => {
assert_eq!(field, "file");
assert!(message.contains("exceeds maximum"));
}
other => panic!("expected Io, got {other:?}"),
}
}
}