cargo.build and the runner (review T22R): builds use the network again; core.unlock and core.notify in order under the runner; quiet test output; manifest preparation keeps [workspace], target tables, renamed and optional entries; cargo.rpc.discover; -32004 with data.output; retention of 10 builds; light, empty test worlds with an in-memory journal; only CARGO_/RUST_/SAUSAGE_ variables; stdin is null; the runner through --config. A compile cache that cannot be created is not fatal. Activation replaces plugin.wasm without a gap. Console and main: init ships the workspace Cargo.lock and creates the world atomically; each console command runs in its own task, so ps, stop, and kill work during a slow call; SAUSAGE_HEADLESS; the terminal mode is restored; signals during boot; chat text is not taken as a command. Tests: 20 more tests for PROTOCOL.md sections 2 to 10; the cargo tests choose the libtest binary from cargo's JSON output and time out.
349 lines
11 KiB
Rust
349 lines
11 KiB
Rust
//! Binary contracts for atomic init, environment flags, signals, and the piped console.
|
|
use sausage::{Host, world::World};
|
|
use serde_json::{Value, json};
|
|
use std::{
|
|
path::{Path, PathBuf},
|
|
process::Stdio,
|
|
sync::OnceLock,
|
|
time::Duration,
|
|
};
|
|
use tokio::{
|
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
|
process::{Child, Command},
|
|
};
|
|
|
|
fn binary() -> Command {
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_sausage"));
|
|
command.env_remove("SAUSAGE_HEADLESS").kill_on_drop(true);
|
|
command
|
|
}
|
|
fn world() -> (tempfile::TempDir, PathBuf) {
|
|
let folder = tempfile::tempdir().unwrap();
|
|
let world = World::create(&folder.path().join("world")).unwrap();
|
|
(folder, world.root)
|
|
}
|
|
async fn status(child: &mut Child) -> std::process::ExitStatus {
|
|
tokio::time::timeout(Duration::from_secs(15), child.wait())
|
|
.await
|
|
.expect("CLI did not exit")
|
|
.unwrap()
|
|
}
|
|
async fn journal_exists(world: &Path) {
|
|
tokio::time::timeout(Duration::from_secs(15), async {
|
|
while !world.join("state/core/journal.sqlite").exists() {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
fn signal(child: &Child, signal: nix::sys::signal::Signal) {
|
|
nix::sys::signal::kill(
|
|
nix::unistd::Pid::from_raw(child.id().unwrap() as i32),
|
|
signal,
|
|
)
|
|
.unwrap();
|
|
}
|
|
fn probe() -> PathBuf {
|
|
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
|
WASM.get_or_init(|| {
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
|
let target = root.join("target/core/console-guests");
|
|
assert!(
|
|
std::process::Command::new("cargo")
|
|
.args([
|
|
"build",
|
|
"--offline",
|
|
"--target",
|
|
"wasm32-wasip2",
|
|
"--manifest-path"
|
|
])
|
|
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
|
|
.arg("--target-dir")
|
|
.arg(&target)
|
|
.status()
|
|
.unwrap()
|
|
.success()
|
|
);
|
|
target.join("wasm32-wasip2/debug/probe.wasm")
|
|
})
|
|
.clone()
|
|
}
|
|
fn install_probe(world: &Path) {
|
|
std::fs::create_dir_all(world.join("plugins/probe")).unwrap();
|
|
std::fs::copy(probe(), world.join("plugins/probe/plugin.wasm")).unwrap();
|
|
}
|
|
|
|
/// A failed agent build leaves no world. Bare sausage retries and publishes a complete world.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn init_builds_agent() {
|
|
let temporary = tempfile::tempdir().unwrap();
|
|
let world = temporary.path().join("world");
|
|
let configured = || {
|
|
let mut command = binary();
|
|
command
|
|
.current_dir(temporary.path())
|
|
.env("SAUSAGE_API", "anthropic-messages")
|
|
.env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/")
|
|
.env("SAUSAGE_MODEL", "init-test")
|
|
.env("SAUSAGE_API_TOKEN", "fake-token")
|
|
.stdin(Stdio::null());
|
|
command
|
|
};
|
|
let failed = configured()
|
|
.arg("init")
|
|
.env("RUSTC", "/nonexistent-sausage-rustc")
|
|
.output()
|
|
.await
|
|
.unwrap();
|
|
assert!(!failed.status.success());
|
|
assert!(!world.exists());
|
|
let output = configured().output().await.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"stdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes")
|
|
);
|
|
let config: Value =
|
|
serde_json::from_slice(&std::fs::read(world.join("config/agent.json")).unwrap()).unwrap();
|
|
assert_eq!(
|
|
config,
|
|
json!({"api":"anthropic-messages","base_url":"http://127.0.0.1:1","model":"init-test","token":"fake-token"})
|
|
);
|
|
assert!(String::from_utf8_lossy(&output.stdout).contains("sausage run"));
|
|
assert_eq!(
|
|
serde_json::from_slice::<Value>(&std::fs::read(world.join("config/boot.json")).unwrap())
|
|
.unwrap(),
|
|
json!([])
|
|
);
|
|
for file in [
|
|
"plugins/cargo/sdk/Cargo.toml",
|
|
"plugins/cargo/sdk/src/lib.rs",
|
|
"plugins/agent/source/Cargo.toml",
|
|
"plugins/agent/source/src/main.rs",
|
|
"plugins/agent/plugin.wasm",
|
|
] {
|
|
assert!(world.join(file).is_file(), "missing {file}");
|
|
}
|
|
assert_eq!(
|
|
std::fs::read(world.join("plugins/agent/source/Cargo.lock")).unwrap(),
|
|
include_bytes!("../../Cargo.lock")
|
|
);
|
|
let host = Host::start_with_cache(
|
|
&world,
|
|
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/core/cli-cache"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
|
|
assert_eq!(doc["x-sausage-grace"], 600);
|
|
assert!(
|
|
doc["methods"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|method| method["name"] == "chat")
|
|
);
|
|
host.stop().await;
|
|
}
|
|
|
|
/// Both headless forms ignore EOF. False and empty values preserve the plain console.
|
|
#[tokio::test]
|
|
async fn headless_flag_and_environment() {
|
|
for value in [None, Some("1"), Some("true")] {
|
|
let (_folder, world) = world();
|
|
let mut command = binary();
|
|
command.arg("run").arg(&world).stdin(Stdio::null());
|
|
if let Some(value) = value {
|
|
command.env("SAUSAGE_HEADLESS", value);
|
|
} else {
|
|
command.arg("--headless");
|
|
}
|
|
let mut child = command.spawn().unwrap();
|
|
journal_exists(&world).await;
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(300), child.wait())
|
|
.await
|
|
.is_err()
|
|
);
|
|
signal(&child, nix::sys::signal::Signal::SIGTERM);
|
|
assert_eq!(status(&mut child).await.code(), Some(0));
|
|
}
|
|
for value in ["0", ""] {
|
|
let (_folder, world) = world();
|
|
let mut child = binary()
|
|
.arg("run")
|
|
.arg(world)
|
|
.env("SAUSAGE_HEADLESS", value)
|
|
.stdin(Stdio::null())
|
|
.spawn()
|
|
.unwrap();
|
|
assert_eq!(status(&mut child).await.code(), Some(0));
|
|
}
|
|
}
|
|
|
|
/// Signals interrupt an unanswered boot service instead of waiting forever.
|
|
#[tokio::test]
|
|
async fn signals_work_during_boot() {
|
|
for sig in [
|
|
nix::sys::signal::Signal::SIGTERM,
|
|
nix::sys::signal::Signal::SIGINT,
|
|
] {
|
|
let (_folder, world) = world();
|
|
std::fs::write(
|
|
world.join("config/boot.json"),
|
|
json!([
|
|
{"method":"core.lock","params":{"path":"boot"}},
|
|
{"method":"core.lock","params":{"path":"boot"}}
|
|
])
|
|
.to_string(),
|
|
)
|
|
.unwrap();
|
|
let mut child = binary()
|
|
.arg("run")
|
|
.arg(&world)
|
|
.arg("--headless")
|
|
.stdin(Stdio::null())
|
|
.spawn()
|
|
.unwrap();
|
|
journal_exists(&world).await;
|
|
tokio::time::timeout(Duration::from_secs(15), async {
|
|
loop {
|
|
let db =
|
|
rusqlite::Connection::open(world.join("state/core/journal.sqlite")).unwrap();
|
|
if let Ok(2) = db.query_row(
|
|
"SELECT count(*) FROM journal WHERE line='service start core.lock'",
|
|
[],
|
|
|row| row.get::<_, i64>(0),
|
|
) {
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
signal(&child, sig);
|
|
assert_eq!(status(&mut child).await.code(), Some(0));
|
|
}
|
|
}
|
|
|
|
/// Piped input supports commands, quit, and the restart exit code.
|
|
#[tokio::test]
|
|
async fn piped_console_and_exit_codes() {
|
|
for (line, expected) in [("help\n", 0), ("quit\n", 0), ("core.exit\n", 75)] {
|
|
let (_folder, world) = world();
|
|
let mut child = binary()
|
|
.arg("run")
|
|
.arg(world)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
child
|
|
.stdin
|
|
.take()
|
|
.unwrap()
|
|
.write_all(line.as_bytes())
|
|
.await
|
|
.unwrap();
|
|
let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(output.status.code(), Some(expected));
|
|
if expected == 0 {
|
|
assert!(!output.stdout.is_empty());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A console input error still stops live actors and flushes the journal.
|
|
#[tokio::test]
|
|
async fn console_error_stops_host() {
|
|
let (_folder, world) = world();
|
|
install_probe(&world);
|
|
std::fs::write(
|
|
world.join("config/boot.json"),
|
|
json!([{"method":"probe.serve","params":{}}]).to_string(),
|
|
)
|
|
.unwrap();
|
|
let mut child = binary()
|
|
.arg("run")
|
|
.arg(&world)
|
|
.stdin(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
child
|
|
.stdin
|
|
.take()
|
|
.unwrap()
|
|
.write_all(&[255, 10])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(status(&mut child).await.code(), Some(1));
|
|
let db = rusqlite::Connection::open(world.join("state/core/journal.sqlite")).unwrap();
|
|
let stopped: i64 = db
|
|
.query_row(
|
|
"SELECT count(*) FROM journal WHERE plugin='probe' AND line='stop'",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert!(stopped > 0);
|
|
}
|
|
|
|
/// Ps and kill remain available before a slow command returns.
|
|
#[tokio::test]
|
|
async fn piped_console_accepts_commands_during_a_call() {
|
|
let (_folder, world) = world();
|
|
install_probe(&world);
|
|
let mut child = binary()
|
|
.arg("run")
|
|
.arg(world)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
let mut input = child.stdin.take().unwrap();
|
|
let mut output = BufReader::new(child.stdout.take().unwrap()).lines();
|
|
input.write_all(b"probe.busy\n").await.unwrap();
|
|
let mut pid = None;
|
|
tokio::time::timeout(Duration::from_secs(15), async {
|
|
while pid.is_none() {
|
|
input.write_all(b"ps\n").await.unwrap();
|
|
while let Some(line) = output.next_line().await.unwrap() {
|
|
if line.contains("probe busy console") {
|
|
pid = line.split_whitespace().next().map(str::to_owned);
|
|
break;
|
|
}
|
|
if line.contains("PID PLUGIN METHOD") {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
input
|
|
.write_all(format!("kill {}\n", pid.unwrap()).as_bytes())
|
|
.await
|
|
.unwrap();
|
|
tokio::time::timeout(Duration::from_secs(15), async {
|
|
while let Some(line) = output.next_line().await.unwrap() {
|
|
if line.starts_with("> kill ") {
|
|
break;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
input.write_all(b"quit\n").await.unwrap();
|
|
assert_eq!(status(&mut child).await.code(), Some(0));
|
|
}
|