300 lines
11 KiB
Rust
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(())
|
|
}
|