From 2e80085451d82d215da3152de5b7029462ac5a19 Mon Sep 17 00:00:00 2001 From: Surma Date: Thu, 12 Nov 2026 08:33:55 +0000 Subject: [PATCH] Show init steps and tool precompile progress --- host/src/main.rs | 33 ++++++++++++++- host/src/process/cache.rs | 59 +++++++++++++++++++++++++++ host/src/process/mod.rs | 2 +- host/src/world.rs | 67 ++++++++++++++++++++++++++----- host/tests/it/core_ext.rs | 84 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 229 insertions(+), 16 deletions(-) diff --git a/host/src/main.rs b/host/src/main.rs index 65992e0..e95df79 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -44,10 +44,39 @@ async fn main() -> anyhow::Result<()> { .or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from)) .ok_or_else(|| { anyhow::anyhow!( - "Default tools unavailable. Set SAUSAGE_USERLAND or pass --userland." + "Copy tools failed: default tools unavailable. Set SAUSAGE_USERLAND or pass --userland." ) })?; - sausage::world::World::init(&world, &bundle).await?; + use std::io::{IsTerminal, Write}; + let terminal = std::io::stderr().is_terminal(); + let mut progress_line = false; + let initialized = sausage::world::World::init_with_progress(&world, &bundle, |line| { + if terminal && line.starts_with("Precompile tools ") { + eprint!("\r\x1b[2K{line}"); + let _ = std::io::stderr().flush(); + progress_line = true; + } else { + if progress_line { + eprintln!(); + progress_line = false; + } + eprintln!("{line}"); + } + }) + .await; + if progress_line { + eprintln!(); + } + initialized?; + eprintln!("Next steps:"); + eprintln!( + " Edit {} to configure providers and API keys.", + world.join("world/etc/sausage/config.yaml").display() + ); + eprintln!( + " sausage run '{}'", + world.to_string_lossy().replace('\'', "'\\''") + ); Ok(()) } Command::Run { world } => { diff --git a/host/src/process/cache.rs b/host/src/process/cache.rs index a997a45..315cf5d 100644 --- a/host/src/process/cache.rs +++ b/host/src/process/cache.rs @@ -130,6 +130,38 @@ impl Compiler { } } } +/// Finish precompilation during init and report each bundled tool, excluding executable aliases. +pub async fn precompile_with_progress( + root: &dyn sausage_vfs::Layer, + mut progress: impl FnMut(usize, usize) + Send, +) -> anyhow::Result<()> { + use anyhow::Context; + let compiler = shared()?; + let mut paths = Vec::new(); + for folder in ["/bin", "/usr/local/bin"] { + for entry in root.list(folder).await? { + if !entry.directory && entry.name.ends_with(".wasm") { + paths.push(format!("{folder}/{}", entry.name)); + } + } + } + paths.sort_by_key(|path| priority(path.rsplit('/').next().unwrap())); + progress(0, paths.len()); + for (index, path) in paths.iter().enumerate() { + let file = root + .open(path, Default::default()) + .await + .with_context(|| format!("Precompile {path} failed"))?; + let bytes = file.read(0, file.stat().await?.size as usize).await?; + compiler + .get(bytes) + .await + .with_context(|| format!("Precompile {path} failed"))?; + progress(index + 1, paths.len()); + } + Ok(()) +} + fn priority(name: &str) -> usize { [ "sh", "git", "rg", "grep", "ls", "cat", "sed", "find", "cargo", @@ -196,6 +228,33 @@ mod tests { use crate::exec::{Command, Exec, Options}; use std::{future::Future, sync::atomic::Ordering}; + #[tokio::test] + async fn init_progress_counts_tools_not_aliases() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let world = crate::world::World::open(dir.path())?; + let bin = dir.path().join("world/bin"); + std::fs::create_dir_all(&bin)?; + let mut progress = Vec::new(); + precompile_with_progress(world.root.as_ref(), |done, total| { + progress.push((done, total)) + }) + .await?; + assert_eq!(progress, [(0, 0)]); + let bytes = + wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)?; + std::fs::write(bin.join("one.wasm"), &bytes)?; + std::fs::write(dir.path().join("world/usr/local/bin/two.wasm"), &bytes)?; + std::fs::write(bin.join("README"), "Not a program")?; + std::os::unix::fs::symlink("one.wasm", bin.join("one"))?; + progress.clear(); + precompile_with_progress(world.root.as_ref(), |done, total| { + progress.push((done, total)) + }) + .await?; + assert_eq!(progress, [(0, 2), (1, 2), (2, 2)]); + Ok(()) + } + #[test] fn common_tools_precede_other_programs() { let mut names = [ diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index f9f95b9..1d7339a 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -55,7 +55,7 @@ pub mod net; pub fn default_script() -> Arc { Arc::new(crate::cred::quickjs::QuickJs::default()) } -pub use cache::configure as configure_cache; +pub use cache::{configure as configure_cache, precompile_with_progress}; mod services; mod watcher; pub use watcher::Watcher; diff --git a/host/src/world.rs b/host/src/world.rs index 8123f46..461b162 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -3,6 +3,7 @@ //! Layout of a world directory: //! - `world/`: the virtual root `/` that every guest sees. Agents change everything in it. //! - `host/`: host state outside the sandbox (harness storage, later the vault). No guest sees it. +use anyhow::Context; use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Process}; use std::{ os::unix::fs::PermissionsExt, @@ -45,17 +46,34 @@ fn copy_tree(source: &Path, destination: &Path) -> std::io::Result<()> { impl World { /// Initialize a fresh world and commit its plain bundled source on main. pub async fn init(dir: &Path, bundle: &Path) -> anyhow::Result { + Self::init_with_progress(dir, bundle, |_| {}).await + } + + /// Initialize a world and report each step without changing normal startup output. + pub async fn init_with_progress( + dir: &Path, + bundle: &Path, + mut progress: impl FnMut(&str) + Send, + ) -> anyhow::Result { anyhow::ensure!( !dir.join("world").exists(), - "World already exists: {}", + "Create world folder failed: World already exists: {}", dir.display() ); - let world = Self::open_with_userland(dir, Some(bundle))?; + let world = Self::open_with_progress(dir, Some(bundle), &mut progress) + .context("Create world folder and seed files failed")?; + crate::process::precompile_with_progress(world.root.as_ref(), |done, total| { + progress(&format!("Precompile tools {done}/{total}")); + }) + .await + .context("Precompile tools failed")?; + progress("Create the first commit in /src/sausage"); let exec = crate::process::exec(Self { root: world.root.clone(), host_dir: world.host_dir.clone(), processes: world.processes.clone(), - })?; + }) + .context("Create source repository failed")?; for args in [ vec!["init", "-b", "main"], vec!["add", "."], @@ -76,7 +94,8 @@ impl World { }, &mut |_, bytes| output.extend_from_slice(bytes), ) - .await?; + .await + .context("Create source repository failed")?; anyhow::ensure!( code == 0, "Cannot initialize source repository: {}", @@ -97,22 +116,48 @@ impl World { /// `/dev` exposes generated null, zero, and random byte devices in every world. /// Missing `/etc/gitconfig` defaults to Sausage . Local repository config overrides it. pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result { + Self::open_with_progress(dir, userland, &mut |_| {}) + } + + fn open_with_progress( + dir: &Path, + userland: Option<&Path>, + progress: &mut dyn FnMut(&str), + ) -> anyhow::Result { + progress(&format!("Create world folder: {}", dir.display())); let root_dir = dir.join("world"); let host_dir = dir.join("host"); let fresh = !root_dir.exists(); - std::fs::create_dir_all(root_dir.join("usr/local/bin"))?; + std::fs::create_dir_all(root_dir.join("usr/local/bin")) + .context("Create world folder failed")?; for path in ["tmp", "run", "var/tmp"] { - std::fs::create_dir_all(root_dir.join(path))?; + std::fs::create_dir_all(root_dir.join(path)).context("Create world folder failed")?; } if fresh && let Some(bundle) = userland { - for folder in ["bin", "src"] { - copy_tree(&bundle.join(folder), &root_dir.join(folder))?; - } + let tools = std::fs::read_dir(bundle.join("bin")) + .context("Copy tools failed")? + .collect::, _>>() + .context("Copy tools failed")? + .iter() + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wasm")) + .count(); + progress(&format!("Copy tools: {tools}")); + copy_tree(&bundle.join("bin"), &root_dir.join("bin")).context("Copy tools failed")?; + progress("Create /src/sausage"); + copy_tree(&bundle.join("src"), &root_dir.join("src")) + .context("Create /src/sausage failed")?; + progress("Seed /etc and /srv/www"); if bundle.join("srv").is_dir() { - copy_tree(&bundle.join("srv"), &root_dir.join("srv"))?; + copy_tree(&bundle.join("srv"), &root_dir.join("srv")) + .context("Seed /srv/www failed")?; } if bundle.join("etc").is_dir() { - copy_tree(&bundle.join("etc"), &root_dir.join("etc"))?; + progress(&format!( + "Write config: {}", + root_dir.join("etc/sausage/config.yaml").display() + )); + copy_tree(&bundle.join("etc"), &root_dir.join("etc")) + .context("Seed /etc and write config failed")?; } // Keep independent .wasm files and expose conventional executable names. for entry in std::fs::read_dir(root_dir.join("bin"))? { diff --git a/host/tests/it/core_ext.rs b/host/tests/it/core_ext.rs index 962bdb2..ce29376 100644 --- a/host/tests/it/core_ext.rs +++ b/host/tests/it/core_ext.rs @@ -60,7 +60,8 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> { "etc/sausage/config.yaml", include_str!("../../../config/config.yaml"), )?; - let destination = dir.path().join("new-world"); + write(&bundle, "srv/www/index.html", "World page")?; + let destination = dir.path().join("new world"); let initialized = std::process::Command::new(env!("CARGO_BIN_EXE_sausage")) .arg("init") .arg(&destination) @@ -75,9 +76,42 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> { "{}", String::from_utf8_lossy(&initialized.stderr) ); + assert!(initialized.stdout.is_empty()); + let stderr = String::from_utf8(initialized.stderr)?; + let lines: Vec<_> = stderr.lines().collect(); + assert_eq!( + lines, + [ + format!("Create world folder: {}", destination.display()), + "Copy tools: 1".into(), + "Create /src/sausage".into(), + "Seed /etc and /srv/www".into(), + format!( + "Write config: {}", + destination.join("world/etc/sausage/config.yaml").display() + ), + "Precompile tools 0/1".into(), + "Precompile tools 1/1".into(), + "Create the first commit in /src/sausage".into(), + "Next steps:".into(), + format!( + " Edit {} to configure providers and API keys.", + destination.join("world/etc/sausage/config.yaml").display() + ), + format!(" sausage run '{}'", destination.display()), + ] + ); + assert!(!stderr.contains(['\r', '\x1b'])); + assert_eq!( + std::fs::read_to_string(destination.join("world/srv/www/index.html"))?, + "World page" + ); let world = World::open(&destination)?; assert!(destination.join("world/bin/git").exists()); - let config = world.root.open("/etc/sausage/config.yaml", Default::default()).await?; + let config = world + .root + .open("/etc/sausage/config.yaml", Default::default()) + .await?; let config = String::from_utf8(config.read(0, 65536).await?)?; assert!(!config.contains("https://proxy.test")); assert!(!config.contains("fixture-key")); @@ -109,6 +143,52 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> { Ok(()) } +#[test] +fn init_errors_name_the_failed_step() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let bundle = dir.path().join("bundle"); + for (name, expected) in [ + ("missing", "Copy tools failed"), + ("invalid", "Precompile tools failed"), + ] { + if name == "invalid" { + write(&bundle, "bin/invalid.wasm", "not a component")?; + write(&bundle, "src/sausage/README.md", "Source")?; + } + let output = std::process::Command::new(env!("CARGO_BIN_EXE_sausage")) + .arg("init") + .arg(dir.path().join(name)) + .arg("--userland") + .arg(&bundle) + .output()?; + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr)?; + assert!(stderr.contains(expected), "{stderr}"); + assert!(!stderr.contains("Next steps:"), "{stderr}"); + if name == "invalid" { + assert!(stderr.contains("/bin/invalid.wasm"), "{stderr}"); + } + } + Ok(()) +} + +#[test] +fn run_does_not_print_init_progress() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let output = std::process::Command::new(env!("CARGO_BIN_EXE_sausage")) + .arg("run") + .arg(dir.path()) + .output()?; + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr)?; + assert!(stderr.starts_with("Error: No world at "), "{stderr}"); + assert!(!stderr.contains("Create world folder")); + assert!(!stderr.contains("Next steps:")); + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn core_extensions() -> anyhow::Result<()> { let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();