//! Sausage: grow a system of WASI actors by talking to it. mod broker; mod candidate; mod cargo_actor; mod console; mod fs; mod runtime; mod world; use std::io::Write; use std::path::PathBuf; use std::sync::Arc; use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; use include_dir::{Dir, include_dir}; use serde_json::json; use crate::broker::Broker; use crate::world::{NATIVE, Registry, VersionMeta, World}; /// The source of the agent actor. `init` builds it like any other actor. static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent"); #[derive(Parser)] #[command(version, about)] struct Cli { #[command(subcommand)] command: Cmd, } #[derive(Subcommand)] enum Cmd { /// Create a new world and build the agent actor. Init { /// Folder of the new world. #[arg(default_value = "world")] dir: PathBuf, /// API shape: `openai-responses` or `anthropic-messages`. #[arg(long)] api: Option, /// Base URL of the inference API (for example `https://api.openai.com`). #[arg(long)] base_url: Option, /// Model ID. #[arg(long)] model: Option, }, /// Start the broker, all actors, and the console. Run { #[arg(default_value = "world")] dir: PathBuf, }, } fn prompt(question: &str, default: Option<&str>) -> Result { match default { Some(d) => print!("{question} [{d}]: "), None => print!("{question}: "), } std::io::stdout().flush()?; let mut line = String::new(); std::io::stdin().read_line(&mut line)?; let v = line.trim().to_string(); match (v.is_empty(), default) { (true, Some(d)) => Ok(d.to_string()), (true, None) => bail!("{question} is required"), _ => Ok(v), } } async fn init(dir: PathBuf, api: Option, base_url: Option, model: Option) -> Result<()> { let world = World::new(std::path::absolute(&dir)?); if world.exists() { bail!("{} is already a Sausage world", dir.display()); } let api = match api { Some(a) => a, None => prompt("API shape (openai-responses or anthropic-messages)", Some("openai-responses"))?, }; if api != "openai-responses" && api != "anthropic-messages" { bail!("unknown API shape {api:?}"); } let base_url = match base_url { Some(u) => u, None => prompt("API base URL", None)?, }; let model = match model { Some(m) => m, None => prompt("Model", None)?, }; let token = match std::env::var("SAUSAGE_API_TOKEN") { Ok(t) if !t.is_empty() => t, _ => prompt("API token", None)?, }; std::fs::create_dir_all(&world.root)?; let mut reg = Registry::default(); reg.subscriptions.entry("agent".into()).or_default().insert("console".into()); world.save_registry(®)?; for id in NATIVE { world.ensure_actor_dirs(id)?; world::write_atomic( &world.actor_dir(id).join("status.json"), serde_json::to_string_pretty(&json!({"id": id, "kind": "native", "state": "built-in"}))?.as_bytes(), )?; } // The agent's private configuration. Only the agent can read /data. world.ensure_actor_dirs("agent")?; let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token}); let config_path = world.data_dir("agent").join("config.json"); std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))?; } // Build the agent with the same path as every other Rust actor. let source = world.root.join("broker").join("seed").join("agent"); std::fs::create_dir_all(&source)?; AGENT_SOURCE.extract(&source).context("extract agent source")?; println!("Building the agent actor (the first build takes a few minutes) …"); let cargo = cargo_actor::Cargo::new(world.clone()); let out = cargo.build(&source).await.context("build agent")?; println!("Built in {:.1} s.", out.seconds); let runtime = runtime::Runtime::new()?; let broker = Broker::new(world.clone(), runtime)?; let meta = VersionMeta { version: 1, wasm: world.global_path(&out.wasm).unwrap_or_default(), source: Some(source.display().to_string()), built_by: Some(cargo_actor::ID.into()), requested_by: "init".into(), created_at: world::now(), }; broker.create_actor("agent", &out.wasm, Some(&source), meta, false).await?; println!("World ready at {}. Start it with: sausage run {}", world.root.display(), dir.display()); Ok(()) } async fn run(dir: PathBuf) -> Result<()> { let world = World::new(std::path::absolute(&dir)?); if !world.exists() { bail!("{} is not a Sausage world. Create one with: sausage init {}", dir.display(), dir.display()); } // One host process per world. let lock = std::fs::File::create(world.root.join(".lock"))?; if lock.try_lock().is_err() { bail!("another sausage process runs {}", dir.display()); } let runtime = runtime::Runtime::new()?; let broker = Broker::new(world.clone(), runtime)?; let rx = broker.open_mailbox("broker"); tokio::spawn(broker.clone().run_actor(rx)); let rx = broker.open_mailbox(cargo_actor::ID); let cargo = Arc::new(cargo_actor::Cargo::new(world.clone())); tokio::spawn(cargo.run_actor(broker.clone(), rx)); let rx = broker.open_mailbox(console::ID); let console = console::Console::new(broker.clone()); tokio::spawn(console.clone().run_mailbox(rx)); broker.start_all(); console.run_input().await; println!("Stopping actors …"); broker.shutdown_all().await; drop(lock); Ok(()) } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Cmd::Init { dir, api, base_url, model } => init(dir, api, base_url, model).await, Cmd::Run { dir } => run(dir).await, } }