Merge v3-t52 (T52)
# Conflicts: # host/js/package.json
This commit is contained in:
@@ -41,6 +41,45 @@ async fn command(runtime: &Arc<Runtime>, args: &[&str]) -> String {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn tools_restore_one_call_or_the_whole_turn() {
|
||||
let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap());
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
assert!(
|
||||
std::process::Command::new("nu")
|
||||
.args(["--no-config-file", "userland/cwd-init/build.nu"])
|
||||
.current_dir(repo)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--locked",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"-p",
|
||||
"git",
|
||||
"-p",
|
||||
"undo",
|
||||
"--target-dir"
|
||||
])
|
||||
.arg(&target)
|
||||
.args(["-j", "2"])
|
||||
.current_dir(repo.join("userland"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
std::process::Command::new("nu")
|
||||
.args(["--no-config-file", "userland/brush/build.nu"])
|
||||
.env("CARGO_TARGET_DIR", &target)
|
||||
.current_dir(repo)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
for since in [false, true] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let world = World::open(temp.path()).unwrap();
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user