Files
sausage/host/tests/workspaces.rs
T

300 lines
11 KiB
Rust

//! World worktrees, landing checks, and SIGKILL recovery before push.
use sausage::{harness::run_module, world::World};
use std::{
io::Write,
path::Path,
process::{Command, Stdio},
sync::Arc,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/workspaces-test-bundle.js");
fn git(path: &Path, args: &[&str]) -> anyhow::Result<()> {
assert!(
Command::new("git")
.args(args)
.current_dir(path)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()?
.success()
);
Ok(())
}
fn prepare(dir: &Path) -> anyhow::Result<()> {
let disk = dir.join("world");
let repo = disk.join("src/sausage");
if repo.join(".git").exists() {
return Ok(());
}
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
if !target.join("wasm32-wasip2/release/brush.wasm").exists() {
assert!(
Command::new("nu")
.arg("brush/build.nu")
.env("CARGO_TARGET_DIR", target)
.current_dir(&userland)
.status()?
.success()
);
}
std::fs::create_dir_all(disk.join("bin"))?;
for (binary, name) in [("git", "git"), ("brush", "sh")] {
std::fs::copy(
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
disk.join(format!("bin/{name}.wasm")),
)?;
}
for extension in [
"subagents",
"models",
"workspaces",
"messages",
"lineage",
"conversations",
] {
let dest = repo.join("extensions").join(extension);
std::fs::create_dir_all(&dest)?;
for entry in std::fs::read_dir(source.join("extensions").join(extension))? {
let entry = entry?;
if entry.path().extension().is_some_and(|ext| ext == "ts") {
std::fs::copy(entry.path(), dest.join(entry.file_name()))?;
}
}
}
let server = repo.join("extensions/workspaces/server.ts");
let code = std::fs::read_to_string(&server)?;
std::fs::write(server,code.replace("const service=workspaces(host)","globalThis.workspaceExecutions??=[]; const exec=host.exec; host.exec=(command,options)=>{globalThis.workspaceExecutions.push({command,options});return exec(command,options)}; const service=workspaces(host)"))?;
std::fs::create_dir_all(repo.join("extensions/core"))?;
std::fs::copy(
source.join("extensions/core/agents.ts"),
repo.join("extensions/core/agents.ts"),
)?;
std::fs::create_dir_all(repo.join("extensions/probe"))?;
std::fs::write(
repo.join("extensions/probe/server.ts"),
"export default host => { globalThis.workspaceService=host.need({name:'workspaces'}); globalThis.workspaceHost=host; return {name:'probe'}; }",
)?;
std::fs::create_dir_all(repo.join("agents"))?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"}}"#,
)?;
std::fs::write(
repo.join("agents/worker.md"),
"---\nname: worker\ndescription: Implement\nisolate: worktree\n---\nImplement\n",
)?;
std::fs::write(
repo.join("settings.json"),
r#"{"lander":{"push":"origin","checks":["sh -c 'test -f feature.txt'"]}}"#,
)?;
let project = disk.join("project");
std::fs::create_dir_all(&project)?;
std::fs::write(project.join("base.txt"), "base")?;
for path in [&project, &repo] {
git(path, &["init", "-b", "main"])?;
git(path, &["config", "user.name", "Test"])?;
git(path, &["config", "user.email", "test@example.com"])?;
git(path, &["add", "."])?;
git(path, &["commit", "--no-gpg-sign", "-m", "fixture"])?;
}
assert!(
Command::new("git")
.args(["clone", "--bare"])
.arg(&project)
.arg(disk.join("remote.git"))
.status()?
.success()
);
git(&project, &["remote", "add", "origin", "/remote.git"])?;
Ok(())
}
// The world Git supports smart HTTP and SSH, not file remotes.
struct GitServer(Arc<tiny_http::Server>, Option<std::thread::JoinHandle<()>>);
impl Drop for GitServer {
fn drop(&mut self) {
self.0.unblock();
self.1.take().unwrap().join().unwrap();
}
}
fn serve(dir: &Path) -> anyhow::Result<GitServer> {
let root = dir.join("world");
git(
&root.join("remote.git"),
&["config", "http.receivepack", "true"],
)?;
let server =
Arc::new(tiny_http::Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!("{e}"))?);
let port = server.server_addr().to_ip().unwrap().port();
git(
&root.join("project"),
&[
"remote",
"set-url",
"origin",
&format!("http://127.0.0.1:{port}/remote.git"),
],
)?;
let serving = server.clone();
let thread = std::thread::spawn(move || {
while let Ok(mut request) = serving.recv() {
let url = request.url().to_owned();
let (path, query) = url.split_once('?').unwrap_or((&url, ""));
let content_type = request
.headers()
.iter()
.find(|h| h.field.equiv("Content-Type"))
.map(|h| h.value.as_str().to_owned())
.unwrap_or_default();
let mut body = Vec::new();
request.as_reader().read_to_end(&mut body).unwrap();
let mut process = Command::new("git")
.arg("http-backend")
.env("GIT_PROJECT_ROOT", &root)
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("REQUEST_METHOD", request.method().as_str())
.env("PATH_INFO", path)
.env("QUERY_STRING", query)
.env("CONTENT_TYPE", content_type)
.env("CONTENT_LENGTH", body.len().to_string())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
process.stdin.take().unwrap().write_all(&body).unwrap();
let output = process.wait_with_output().unwrap();
assert!(output.status.success());
let split = output
.stdout
.windows(4)
.position(|p| p == b"\r\n\r\n")
.unwrap();
let headers = std::str::from_utf8(&output.stdout[..split]).unwrap();
let mut response = tiny_http::Response::from_data(output.stdout[split + 4..].to_vec());
for line in headers.split("\r\n") {
let (name, value) = line.split_once(':').unwrap();
if name.eq_ignore_ascii_case("status") {
response = response.with_status_code(
value
.trim()
.split(' ')
.next()
.unwrap()
.parse::<u16>()
.unwrap(),
);
} else {
response = response
.with_header(tiny_http::Header::from_bytes(name, value.trim()).unwrap());
}
}
let _ = request.respond(response);
}
});
Ok(GitServer(server, Some(thread)))
}
async fn run(dir: &Path, mode: &str) -> anyhow::Result<()> {
let world = World::open(dir)?;
prepare(dir)?;
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
run_module(
world,
exec,
&format!("globalThis.workspacesMode={mode:?};\n{BUNDLE}"),
)
.await
}
#[tokio::test(flavor = "current_thread")]
async fn coordination() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
World::open(dir.path())?;
prepare(dir.path())?;
let _server = serve(dir.path())?;
run(dir.path(), "normal").await
}
#[tokio::test(flavor = "current_thread")]
async fn restart_child() -> anyhow::Result<()> {
if let Ok(dir) = std::env::var("WORKSPACES_DIR") {
run(Path::new(&dir), &std::env::var("WORKSPACES_MODE")?).await?;
}
Ok(())
}
#[test]
fn sigkill_between_fast_forward_and_push() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
World::open(dir.path())?;
prepare(dir.path())?;
let _server = serve(dir.path())?;
let spawn = |mode: &str| {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "restart_child", "--nocapture"])
.env("WORKSPACES_DIR", dir.path())
.env("WORKSPACES_MODE", mode)
.spawn()
};
let mut child = spawn("kill")?;
let deadline = Instant::now() + Duration::from_secs(60);
while !dir.path().join("world/push-ready").exists() {
if let Some(status) = child.try_wait()? {
anyhow::bail!("Child exited before push: {status}");
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Lander did not reach push");
}
std::thread::yield_now();
}
child.kill()?;
child.wait()?;
// A later target commit must not replace the recorded SHA in the push or report.
git(
&dir.path().join("world/project"),
&["commit", "--allow-empty", "--no-gpg-sign", "-m", "later"],
)?;
for _ in 0..2 {
let mut child = spawn("resume")?;
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if let Some(status) = child.try_wait()? {
assert!(status.success());
break;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Recovery timed out");
}
std::thread::yield_now();
}
}
Ok(())
}