diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1d1743..32dba44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,15 @@ name: CI # First CI for command-center (Lane C / roadmap C8). -# On every push and PR: run the embargo guard, run the Rust workspace tests, then -# build the frontend + fleetd sidecar and produce Tauri bundles on all three target -# OSes. Bundles are uploaded as workflow artifacts so a reviewer can smoke-test a -# real build. +# On every push and PR: run the embargo guard, the lint/type gates (rustfmt, +# clippy, svelte-check + tsc), the Rust tests for both cargo workspaces and the +# frontend's vitest suite, then build the frontend + fleetd sidecar and produce +# Tauri bundles on all three target OSes. Bundles are uploaded as workflow +# artifacts so a reviewer can smoke-test a real build. +# +# TWO CARGO WORKSPACES: the root one (crates/fleet-core, crates/fleetd) and the +# standalone cockpit/ui/src-tauri crate. Nothing run from the repo root reaches +# the latter, so the fmt, clippy and test gates each run once per manifest. # # COVERAGE GAP (intentional): the real-Docker integration tests in # crates/fleetd/tests/ (local_docker_it.rs, preflight_it.rs, swarm_smoke_it.rs) @@ -74,9 +79,151 @@ jobs: fi node scripts/embargo-guard.mjs --message "$RUNNER_TEMP/msgs.txt" + # --------------------------------------------------------------------------- + # rustfmt + clippy, over BOTH cargo workspaces. + # + # cockpit/ui/src-tauri declares its own empty `[workspace]` table, so it is a + # standalone workspace that the root manifest does not list as a member. That + # makes it invisible to `--workspace` / `--all` run from the repo root, which + # is why every gate here runs twice — once per manifest. Dropping either half + # silently leaves that crate ungated. + # + # rustfmt only needs the sources, so both fmt checks run first and fail fast. + # Clippy has to actually compile, and compiling the tauri crate needs two + # things the root workspace does not: the WebKitGTK system deps (same list the + # build job installs) and the fleetd sidecar binary, because tauri-build + # resolves the `externalBin` resource at compile time and hard-errors when it + # is absent. + # --------------------------------------------------------------------------- + lint: + name: fmt + clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo registry + target + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + cockpit/ui/src-tauri + + - name: Check formatting (root workspace) + run: cargo fmt --all -- --check + + - name: Check formatting (cockpit/ui/src-tauri) + run: cargo fmt --all --manifest-path cockpit/ui/src-tauri/Cargo.toml -- --check + + - name: Clippy (root workspace) + run: cargo clippy --workspace --all-targets -- -D warnings + + # Everything below exists only so the tauri crate can be compiled. + - name: Install Tauri Linux system deps + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + build-essential \ + curl \ + wget \ + file \ + libssl-dev + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: cockpit/ui/package-lock.json + + - name: Install frontend dependencies + run: npm ci + working-directory: cockpit/ui + + # tauri-build fails the compile without this — see the note above. + - name: Build fleetd sidecar + run: npm run sidecar + working-directory: cockpit/ui + + - name: Clippy (cockpit/ui/src-tauri) + run: cargo clippy --all-targets -- -D warnings + working-directory: cockpit/ui/src-tauri + + # --------------------------------------------------------------------------- + # Frontend type gate: `npm run check` is svelte-check over tsconfig.app.json + # followed by `tsc -p tsconfig.node.json`. Pure type-checking — no Rust, no + # system deps, so it stands alone and finishes in well under a minute. + # --------------------------------------------------------------------------- + check: + name: svelte-check + tsc + runs-on: ubuntu-latest + defaults: + run: + working-directory: cockpit/ui + steps: + - uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: cockpit/ui/package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Type-check the frontend + run: npm run check + + # --------------------------------------------------------------------------- + # Frontend unit tests (vitest + jsdom). Same shape as the type gate above: no + # Rust, no system deps. + # + # This is half of a pair. The regression pin for the plugin_launch main-thread + # freeze is split across two suites — a Rust test, gated by `cargo test + # (cockpit)` below, and cockpit/ui/src/App.appPlugin.test.ts, gated here. That + # file arrives with the plugin-runtime work and is not on main yet; wiring the + # job now means the JS half is covered the moment it lands, instead of the pin + # being half-enforced and quietly rotting. + # --------------------------------------------------------------------------- + test-ui: + name: vitest (cockpit/ui) + runs-on: ubuntu-latest + defaults: + run: + working-directory: cockpit/ui + steps: + - uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: cockpit/ui/package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Run frontend unit tests + run: npm test + # --------------------------------------------------------------------------- # Rust workspace tests. Fast, OS-independent gate — runs once on Linux. # Does NOT run the `#[ignore]`d real-Docker ITs (see header note above). + # + # ROOT WORKSPACE ONLY. The cockpit crate's tests are a separate job below — + # see the standalone-workspace note on the `lint` job for why they have to be. # --------------------------------------------------------------------------- test: name: cargo test (workspace) @@ -95,6 +242,66 @@ jobs: - name: Run workspace tests (Docker ITs stay --ignored) run: cargo test --workspace + # --------------------------------------------------------------------------- + # The cockpit crate's own tests — the app-plugin state machine, manifest + # validation and discovery. `cargo test --workspace` above runs from the repo + # root and cannot reach them: cockpit/ui/src-tauri is a standalone workspace + # (see the `lint` job note), so until this job existed that whole suite was + # verified only by hand on a developer's machine. + # + # Same compile prerequisites as clippy: WebKitGTK system deps plus the fleetd + # sidecar binary that tauri-build resolves at compile time. + # --------------------------------------------------------------------------- + test-cockpit: + name: cargo test (cockpit) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + target + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + cockpit/ui/src-tauri + + - name: Install Tauri Linux system deps + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + build-essential \ + curl \ + wget \ + file \ + libssl-dev + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: cockpit/ui/package-lock.json + + - name: Install frontend dependencies + run: npm ci + working-directory: cockpit/ui + + - name: Build fleetd sidecar + run: npm run sidecar + working-directory: cockpit/ui + + - name: Run cockpit crate tests + run: cargo test + working-directory: cockpit/ui/src-tauri + # --------------------------------------------------------------------------- # Cross-platform Tauri build matrix. Builds the SvelteKit frontend, then the # fleetd sidecar (MUST precede `tauri build` — externalBin is resolved at diff --git a/cockpit/ui/src-tauri/build.rs b/cockpit/ui/src-tauri/build.rs index 795b9b7..d860e1e 100644 --- a/cockpit/ui/src-tauri/build.rs +++ b/cockpit/ui/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/cockpit/ui/src-tauri/src/dashboard.rs b/cockpit/ui/src-tauri/src/dashboard.rs index 554348d..df92984 100644 --- a/cockpit/ui/src-tauri/src/dashboard.rs +++ b/cockpit/ui/src-tauri/src/dashboard.rs @@ -43,7 +43,10 @@ fn run_halyard(subcommand: &str) -> Result { if !out.status.success() { let code = out.status.code().unwrap_or(-1); let stderr = String::from_utf8_lossy(&out.stderr); - return Err(format!("halyard {subcommand} exited {code}: {}", stderr.trim())); + return Err(format!( + "halyard {subcommand} exited {code}: {}", + stderr.trim() + )); } let stdout = String::from_utf8_lossy(&out.stdout); diff --git a/cockpit/ui/src-tauri/src/lib.rs b/cockpit/ui/src-tauri/src/lib.rs index a9412e7..c5c21aa 100644 --- a/cockpit/ui/src-tauri/src/lib.rs +++ b/cockpit/ui/src-tauri/src/lib.rs @@ -63,9 +63,7 @@ pub fn run() { // LANE-B → HOST: stop the fleetd-serve sidecar first so the // supervisor doesn't respawn it as we tear down, and no // orphaned process is left behind. - app_handle - .state::() - .shutdown(); + app_handle.state::().shutdown(); let mgr = app_handle.state::(); mgr.stop_all_owned(30_000); // total budget; kept under the OS force-kill ceiling app_handle.exit(0); diff --git a/cockpit/ui/src-tauri/src/local_projects.rs b/cockpit/ui/src-tauri/src/local_projects.rs index 822acd8..c1e6a14 100644 --- a/cockpit/ui/src-tauri/src/local_projects.rs +++ b/cockpit/ui/src-tauri/src/local_projects.rs @@ -20,7 +20,9 @@ pub struct ScanConfig { #[serde(default)] pub excludes: Vec, } -fn default_depth() -> usize { 5 } +fn default_depth() -> usize { + 5 +} #[derive(Serialize, PartialEq, Debug)] #[serde(rename_all = "camelCase")] @@ -40,7 +42,9 @@ fn normalize(p: &Path) -> String { fn is_excluded(path: &str, excludes: &[String]) -> bool { let p = path.to_lowercase(); - excludes.iter().any(|e| p.contains(&e.replace('\\', "/").to_lowercase())) + excludes + .iter() + .any(|e| p.contains(&e.replace('\\', "/").to_lowercase())) } /// Read a project dir's STATUS.md/ROADMAP.md into a doc (raw text; hash over raw bytes). @@ -71,8 +75,8 @@ fn discover(root: &Path, max_depth: usize, excludes: &[String], out: &mut Vec Result, String> { let mut dirs: Vec = Vec::new(); for root in &config.scan_roots { - discover(Path::new(root), config.max_depth, &config.excludes, &mut dirs); + discover( + Path::new(root), + config.max_depth, + &config.excludes, + &mut dirs, + ); } - let discovered: std::collections::HashSet = - dirs.iter().map(|p| normalize(p)).collect(); + let discovered: std::collections::HashSet = dirs.iter().map(|p| normalize(p)).collect(); let mut docs: Vec = dirs.iter().map(|d| read_project(d, false)).collect(); // Pins: included even without a marker; skip a pin already auto-discovered. @@ -108,7 +116,12 @@ mod tests { use std::fs; fn tmp() -> PathBuf { - let d = std::env::temp_dir().join(format!("cc-scan-{}", std::process::id())); + // Unique per call. Keying only on the pid gave every test in this binary the + // same directory, and they run concurrently — one test's `remove_dir_all` + // raced another's `create_dir_all` and failed the run about 1 time in 4. + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("cc-scan-{}-{n}", std::process::id())); let _ = fs::remove_dir_all(&d); fs::create_dir_all(&d).unwrap(); d @@ -126,7 +139,12 @@ mod tests { make_project(&root, "mono/services/api", "---\nstage: Spec\n---\n"); // depth-3 nested fs::create_dir_all(root.join("node_modules/pkg/docs")).unwrap(); fs::write(root.join("node_modules/pkg/docs/STATUS.md"), "x").unwrap(); // must be pruned - let cfg = ScanConfig { scan_roots: vec![root.to_string_lossy().into()], max_depth: 5, pins: vec![], excludes: vec![] }; + let cfg = ScanConfig { + scan_roots: vec![root.to_string_lossy().into()], + max_depth: 5, + pins: vec![], + excludes: vec![], + }; let docs = scan_local_projects(cfg).unwrap(); let dirs: Vec<&str> = docs.iter().map(|d| d.project_dir.as_str()).collect(); assert!(dirs.iter().any(|d| d.ends_with("/alpha"))); @@ -138,10 +156,22 @@ mod tests { fn hashes_roadmap_over_raw_bytes() { let root = tmp(); make_project(&root, "beta", "---\nstage: Build\n---\n"); - fs::write(root.join("beta/ROADMAP.md"), "## X\n\n").unwrap(); - let cfg = ScanConfig { scan_roots: vec![root.to_string_lossy().into()], max_depth: 5, pins: vec![], excludes: vec![] }; + fs::write( + root.join("beta/ROADMAP.md"), + "## X\n\n", + ) + .unwrap(); + let cfg = ScanConfig { + scan_roots: vec![root.to_string_lossy().into()], + max_depth: 5, + pins: vec![], + excludes: vec![], + }; let docs = scan_local_projects(cfg).unwrap(); - let beta = docs.iter().find(|d| d.project_dir.ends_with("/beta")).unwrap(); + let beta = docs + .iter() + .find(|d| d.project_dir.ends_with("/beta")) + .unwrap(); assert!(beta.roadmap_hash.as_ref().unwrap().len() == 64); // hex sha256 } @@ -149,10 +179,22 @@ mod tests { fn roadmap_hash_is_over_raw_bytes_even_when_not_utf8() { let root = tmp(); make_project(&root, "gamma", "---\nstage: Build\n---\n"); - std::fs::write(root.join("gamma/ROADMAP.md"), [0x23, 0x20, 0xff, 0xfe, 0x0a]).unwrap(); - let cfg = ScanConfig { scan_roots: vec![root.to_string_lossy().into()], max_depth: 5, pins: vec![], excludes: vec![] }; + std::fs::write( + root.join("gamma/ROADMAP.md"), + [0x23, 0x20, 0xff, 0xfe, 0x0a], + ) + .unwrap(); + let cfg = ScanConfig { + scan_roots: vec![root.to_string_lossy().into()], + max_depth: 5, + pins: vec![], + excludes: vec![], + }; let docs = scan_local_projects(cfg).unwrap(); - let gamma = docs.iter().find(|d| d.project_dir.ends_with("/gamma")).unwrap(); + let gamma = docs + .iter() + .find(|d| d.project_dir.ends_with("/gamma")) + .unwrap(); assert!(gamma.roadmap_hash.as_ref().unwrap().len() == 64); // hash computed despite invalid UTF-8 assert!(gamma.roadmap_text.is_none()); // decode fails, proving hash path is independent of decode } @@ -162,7 +204,12 @@ mod tests { let root = tmp(); let pin = root.join("pinned-no-marker"); fs::create_dir_all(&pin).unwrap(); - let cfg = ScanConfig { scan_roots: vec![], max_depth: 5, pins: vec![pin.to_string_lossy().into()], excludes: vec![] }; + let cfg = ScanConfig { + scan_roots: vec![], + max_depth: 5, + pins: vec![pin.to_string_lossy().into()], + excludes: vec![], + }; let docs = scan_local_projects(cfg).unwrap(); assert_eq!(docs.len(), 1); assert!(docs[0].is_pinned); diff --git a/cockpit/ui/src-tauri/src/main.rs b/cockpit/ui/src-tauri/src/main.rs index ad5fe83..69c3a72 100644 --- a/cockpit/ui/src-tauri/src/main.rs +++ b/cockpit/ui/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - app_lib::run(); + app_lib::run(); } diff --git a/cockpit/ui/src-tauri/src/plugins/discovery.rs b/cockpit/ui/src-tauri/src/plugins/discovery.rs index 7d81383..0c16994 100644 --- a/cockpit/ui/src-tauri/src/plugins/discovery.rs +++ b/cockpit/ui/src-tauri/src/plugins/discovery.rs @@ -22,19 +22,37 @@ pub fn discover(roots: &[&Path]) -> Vec { let mani_path = entry.path().join("app-plugin.json"); let text = match std::fs::read_to_string(&mani_path) { Ok(t) => t, - Err(e) => { log::warn!("app-plugins: skipping {} — unreadable: {e}", mani_path.display()); continue } + Err(e) => { + log::warn!( + "app-plugins: skipping {} — unreadable: {e}", + mani_path.display() + ); + continue; + } }; let manifest = match Manifest::from_json(&text) { Ok(m) => m, - Err(e) => { log::warn!("app-plugins: skipping {} — parse error: {e}", mani_path.display()); continue } + Err(e) => { + log::warn!( + "app-plugins: skipping {} — parse error: {e}", + mani_path.display() + ); + continue; + } }; if let Err(e) = manifest.validate() { - log::warn!("app-plugins: skipping {} — validation failed: {e}", mani_path.display()); + log::warn!( + "app-plugins: skipping {} — validation failed: {e}", + mani_path.display() + ); continue; } by_id.insert( manifest.id.clone(), - DiscoveredPlugin { dir: entry.path(), manifest }, + DiscoveredPlugin { + dir: entry.path(), + manifest, + }, ); } } @@ -57,8 +75,16 @@ mod tests { // both define id "audience"; user dir should win let base = r#"{"id":"audience","name":"NAME","apiVersion":1,"url":"http://localhost:3000", "lifecycle":{"start":"x","health":{"url":"h"},"ready":{"url":"r"}}}"#; - fs::write(packaged.join("audience/app-plugin.json"), base.replace("NAME", "Packaged")).unwrap(); - fs::write(user.join("audience/app-plugin.json"), base.replace("NAME", "User")).unwrap(); + fs::write( + packaged.join("audience/app-plugin.json"), + base.replace("NAME", "Packaged"), + ) + .unwrap(); + fs::write( + user.join("audience/app-plugin.json"), + base.replace("NAME", "User"), + ) + .unwrap(); let found = discover(&[packaged.as_path(), user.as_path()]); assert_eq!(found.len(), 1); @@ -78,7 +104,13 @@ mod tests { fs::write(root.join("good/app-plugin.json"), valid).unwrap(); fs::write(root.join("garbage/app-plugin.json"), "{ not json").unwrap(); // parses fine but validate() refuses the unsupported apiVersion → skipped - fs::write(root.join("badversion/app-plugin.json"), valid.replace("\"apiVersion\":1", "\"apiVersion\":99").replace("\"id\":\"good\"", "\"id\":\"bad\"")).unwrap(); + fs::write( + root.join("badversion/app-plugin.json"), + valid + .replace("\"apiVersion\":1", "\"apiVersion\":99") + .replace("\"id\":\"good\"", "\"id\":\"bad\""), + ) + .unwrap(); // "nomanifest" dir has no app-plugin.json at all → skipped let found = discover(&[root.as_path()]); diff --git a/cockpit/ui/src-tauri/src/plugins/manager.rs b/cockpit/ui/src-tauri/src/plugins/manager.rs index 10c3618..c5ab8f2 100644 --- a/cockpit/ui/src-tauri/src/plugins/manager.rs +++ b/cockpit/ui/src-tauri/src/plugins/manager.rs @@ -11,6 +11,9 @@ use tauri::{AppHandle, State}; /// One launched plugin's runtime record. pub struct Running { + // Recorded at launch so the Phase-6 crash watcher can poll this child; teardown + // itself goes through `lifecycle.stop`, so nothing reads it back yet. + #[allow(dead_code)] pub child_id: Option, pub owned: bool, } @@ -33,6 +36,8 @@ impl PluginManager { } /// The head URL for a discovered plugin (used by the Phase-6 embedding layer). + // That layer is the only caller and has not landed yet. + #[allow(dead_code)] pub fn url_for(&self, id: &str) -> Option { self.discovered .lock() diff --git a/cockpit/ui/src-tauri/src/plugins/manifest.rs b/cockpit/ui/src-tauri/src/plugins/manifest.rs index aaeed59..39c9bf0 100644 --- a/cockpit/ui/src-tauri/src/plugins/manifest.rs +++ b/cockpit/ui/src-tauri/src/plugins/manifest.rs @@ -12,12 +12,19 @@ pub struct Manifest { pub icon: String, pub url: String, pub lifecycle: Lifecycle, + // UNWIRED FEATURE — deserialized, then read by nothing but test assertions, on + // main and on every in-flight branch alike. The manifest advertises these keys + // and the app currently ignores them. See WebviewCfg below. + #[allow(dead_code)] #[serde(default)] pub webview: WebviewCfg, } #[derive(Debug, Clone, Deserialize)] pub struct Lifecycle { + // Manifest surface that is accepted today but not yet acted on: every plugin + // is treated as managed until adopt-only stacks are wired up. + #[allow(dead_code)] #[serde(default)] pub managed: bool, #[serde(default)] @@ -41,7 +48,9 @@ pub struct BuildStep { #[serde(default = "default_build_timeout")] pub timeout: u64, } -fn default_build_timeout() -> u64 { 1_200_000 } +fn default_build_timeout() -> u64 { + 1_200_000 +} #[derive(Debug, Clone, Deserialize)] pub struct Probe { @@ -53,10 +62,22 @@ pub struct Probe { #[serde(default = "default_probe_interval")] pub interval: u64, } -fn default_ok_status() -> Vec { vec![200] } -fn default_probe_timeout() -> u64 { 180_000 } -fn default_probe_interval() -> u64 { 1_000 } +fn default_ok_status() -> Vec { + vec![200] +} +fn default_probe_timeout() -> u64 { + 180_000 +} +fn default_probe_interval() -> u64 { + 1_000 +} +// UNWIRED FEATURE — `popups`, `external_links` and `title` are parsed from the +// manifest and asserted on in tests, and that is the whole of their use. No +// production code reads any of them on any current branch, so a plugin author who +// sets `popups: block` gets no blocking. The allow keeps the parsed shape around; +// it does not mean someone is about to honor it. +#[allow(dead_code)] #[derive(Debug, Clone, Deserialize, Default)] pub struct WebviewCfg { #[serde(default)] @@ -69,11 +90,19 @@ pub struct WebviewCfg { #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(rename_all = "lowercase")] -pub enum Popups { #[default] Allow, Block } +pub enum Popups { + #[default] + Allow, + Block, +} #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(rename_all = "kebab-case")] -pub enum ExternalLinks { #[default] InApp, SystemBrowser } +pub enum ExternalLinks { + #[default] + InApp, + SystemBrowser, +} pub const SUPPORTED_API_VERSIONS: &[u32] = &[1]; @@ -88,8 +117,14 @@ impl Manifest { serde_json::from_str(s) } /// Effective window title (defaults to `name`). + // UNWIRED FEATURE — the only call is a test assertion; no production caller + // exists on any current branch. Nothing creates a window from this yet. + #[allow(dead_code)] pub fn window_title(&self) -> String { - self.webview.title.clone().unwrap_or_else(|| self.name.clone()) + self.webview + .title + .clone() + .unwrap_or_else(|| self.name.clone()) } pub fn validate(&self) -> Result<(), ManifestError> { if !SUPPORTED_API_VERSIONS.contains(&self.api_version) { @@ -104,7 +139,11 @@ impl Manifest { None => manifest_dir.to_path_buf(), Some(c) => { let p = Path::new(c); - if p.is_absolute() { p.to_path_buf() } else { manifest_dir.join(p) } + if p.is_absolute() { + p.to_path_buf() + } else { + manifest_dir.join(p) + } } } } @@ -152,7 +191,10 @@ mod tests { fn rejects_unknown_api_version() { let json = AUDIENCE_JSON.replace("\"apiVersion\": 1", "\"apiVersion\": 99"); let m = Manifest::from_json(&json).unwrap(); - assert!(matches!(m.validate(), Err(ManifestError::UnsupportedApiVersion(99)))); + assert!(matches!( + m.validate(), + Err(ManifestError::UnsupportedApiVersion(99)) + )); } #[test] @@ -171,15 +213,27 @@ mod tests { #[test] fn keeps_absolute_cwd_as_is() { - let m = Manifest::from_json(AUDIENCE_JSON).unwrap(); // cwd is absolute D:/... + // `Path::is_absolute` is platform-specific: the fixture's "D:/…" drive path + // is absolute only on Windows, and elsewhere gets joined to the manifest dir. + // Pick a path the host actually agrees is absolute so the intent holds on both. + let abs = if cfg!(windows) { + "D:/MajorProjects/CURRENT/audience" + } else { + "/srv/audience" + }; + let mut m = Manifest::from_json(AUDIENCE_JSON).unwrap(); + m.lifecycle.cwd = Some(abs.into()); let resolved = m.resolved_cwd(Path::new("/plugins/audience")); - assert_eq!(resolved, Path::new("D:/MajorProjects/CURRENT/audience")); + assert_eq!(resolved, Path::new(abs)); } #[test] fn uses_manifest_dir_when_cwd_is_absent() { let mut m = Manifest::from_json(AUDIENCE_JSON).unwrap(); m.lifecycle.cwd = None; - assert_eq!(m.resolved_cwd(Path::new("/plugins/audience")), Path::new("/plugins/audience")); + assert_eq!( + m.resolved_cwd(Path::new("/plugins/audience")), + Path::new("/plugins/audience") + ); } } diff --git a/cockpit/ui/src-tauri/src/plugins/mod.rs b/cockpit/ui/src-tauri/src/plugins/mod.rs index ada8e7a..1ec1253 100644 --- a/cockpit/ui/src-tauri/src/plugins/mod.rs +++ b/cockpit/ui/src-tauri/src/plugins/mod.rs @@ -1,6 +1,6 @@ -pub mod manifest; pub mod discovery; +pub mod manager; +pub mod manifest; pub mod seams; pub mod seams_impl; pub mod state; -pub mod manager; diff --git a/cockpit/ui/src-tauri/src/plugins/seams.rs b/cockpit/ui/src-tauri/src/plugins/seams.rs index baf5839..5c8896e 100644 --- a/cockpit/ui/src-tauri/src/plugins/seams.rs +++ b/cockpit/ui/src-tauri/src/plugins/seams.rs @@ -13,12 +13,25 @@ pub trait Spawner: Send + Sync { /// `vars` are interpreted by the impl: for `start`/`stop` they are process /// env; for the build step they are the manifest's `build.args` (which the /// real impl must surface as Docker `--build-arg`, not env — see Phase 4). - fn run_to_completion(&self, cmd: &str, cwd: &Path, vars: &BTreeMap, timeout_ms: u64) -> i32; + fn run_to_completion( + &self, + cmd: &str, + cwd: &Path, + vars: &BTreeMap, + timeout_ms: u64, + ) -> i32; /// Spawn a long-running command (start). Returns a child id. fn spawn(&self, cmd: &str, cwd: &Path, env: &BTreeMap) -> u64; /// Has the spawned child exited? (drives crash→error) + // Reachable only through `state::check_crash`, which itself has no caller on any + // current branch — so this seam is dead for the same reason, and the real impl in + // seams_impl.rs has never run outside a test. See the note on check_crash. + #[allow(dead_code)] fn has_exited(&self, child_id: u64) -> bool; /// Force-kill a child (teardown fallback). + // Teardown currently goes through the manifest's `lifecycle.stop` command + // (see manager::stop_one); this stays as the documented force-kill fallback. + #[allow(dead_code)] fn kill(&self, child_id: u64); } @@ -39,26 +52,42 @@ pub mod fakes { use std::sync::Mutex; /// Probe that returns a scripted sequence of statuses per URL. - pub struct ScriptedProbe { pub responses: Mutex>>> } + pub struct ScriptedProbe { + pub responses: Mutex>>>, + } impl Probe for ScriptedProbe { fn probe(&self, url: &str) -> Option { let mut map = self.responses.lock().unwrap(); let q = map.get_mut(url).expect("no script for url"); - if q.len() == 1 { q[0] } else { q.remove(0) } + if q.len() == 1 { + q[0] + } else { + q.remove(0) + } } } #[derive(Default)] - pub struct FakeClock { pub t: Mutex } + pub struct FakeClock { + pub t: Mutex, + } impl Clock for FakeClock { - fn now_ms(&self) -> u64 { *self.t.lock().unwrap() } - fn sleep_ms(&self, ms: u64) { *self.t.lock().unwrap() += ms; } // advance, don't block + fn now_ms(&self) -> u64 { + *self.t.lock().unwrap() + } + fn sleep_ms(&self, ms: u64) { + *self.t.lock().unwrap() += ms; + } // advance, don't block } #[derive(Default)] - pub struct RecordingSink { pub states: Mutex> } + pub struct RecordingSink { + pub states: Mutex>, + } impl EventSink for RecordingSink { - fn emit_state(&self, id: &str, s: &str) { self.states.lock().unwrap().push((id.into(), s.into())); } + fn emit_state(&self, id: &str, s: &str) { + self.states.lock().unwrap().push((id.into(), s.into())); + } } pub struct FakeSpawner { @@ -67,9 +96,21 @@ pub mod fakes { pub exited: Mutex, } impl Spawner for FakeSpawner { - fn run_to_completion(&self, _c: &str, _w: &Path, _e: &BTreeMap, _t: u64) -> i32 { self.build_exit } - fn spawn(&self, _c: &str, _w: &Path, _e: &BTreeMap) -> u64 { self.start_child_id } - fn has_exited(&self, _id: u64) -> bool { *self.exited.lock().unwrap() } + fn run_to_completion( + &self, + _c: &str, + _w: &Path, + _e: &BTreeMap, + _t: u64, + ) -> i32 { + self.build_exit + } + fn spawn(&self, _c: &str, _w: &Path, _e: &BTreeMap) -> u64 { + self.start_child_id + } + fn has_exited(&self, _id: u64) -> bool { + *self.exited.lock().unwrap() + } fn kill(&self, _id: u64) {} } } diff --git a/cockpit/ui/src-tauri/src/plugins/seams_impl.rs b/cockpit/ui/src-tauri/src/plugins/seams_impl.rs index 518cc5f..0e3ebd4 100644 --- a/cockpit/ui/src-tauri/src/plugins/seams_impl.rs +++ b/cockpit/ui/src-tauri/src/plugins/seams_impl.rs @@ -11,7 +11,7 @@ impl Probe for HttpProbe { match ureq::get(url).timeout(Duration::from_millis(2000)).call() { Ok(resp) => Some(resp.status()), Err(ureq::Error::Status(code, _)) => Some(code), // 3xx/4xx still a status - Err(_) => None, // connection refused etc. + Err(_) => None, // connection refused etc. } } } @@ -74,9 +74,7 @@ impl Spawner for ShellSpawner { for (k, v) in vars { c.arg("--build-arg").arg(format!("{k}={v}")); } - c.status() - .map(|s| s.code().unwrap_or(-1)) - .unwrap_or(-1) + c.status().map(|s| s.code().unwrap_or(-1)).unwrap_or(-1) } /// Used for the START step: `env` are runtime env vars applied to the process. diff --git a/cockpit/ui/src-tauri/src/plugins/state.rs b/cockpit/ui/src-tauri/src/plugins/state.rs index 84bb1d3..70340aa 100644 --- a/cockpit/ui/src-tauri/src/plugins/state.rs +++ b/cockpit/ui/src-tauri/src/plugins/state.rs @@ -2,6 +2,9 @@ use crate::plugins::manifest::{Manifest, Probe as ProbeCfg}; use crate::plugins::seams::{Clock, EventSink, Probe, Spawner}; use std::path::Path; +// The initial state of the §4 state machine. Nothing emits it yet — the machine +// only reports transitions away from rest — but it belongs with its siblings. +#[allow(dead_code)] pub const STOPPED: &str = "stopped"; pub const BUILDING: &str = "building"; pub const STARTING: &str = "starting"; @@ -24,25 +27,39 @@ fn poll_until_ok(cfg: &ProbeCfg, probe: &dyn Probe, clock: &dyn Clock) -> bool { let start = clock.now_ms(); loop { if let Some(code) = probe.probe(&cfg.url) { - if cfg.ok_status.contains(&code) { return true; } + if cfg.ok_status.contains(&code) { + return true; + } + } + if clock.now_ms().saturating_sub(start) >= cfg.timeout { + return false; } - if clock.now_ms().saturating_sub(start) >= cfg.timeout { return false; } clock.sleep_ms(cfg.interval); } } fn both_probes_pass(m: &Manifest, probe: &dyn Probe) -> bool { - let h = probe.probe(&m.lifecycle.health.url) - .map(|c| m.lifecycle.health.ok_status.contains(&c)).unwrap_or(false); - if !h { return false; } - probe.probe(&m.lifecycle.ready.url) - .map(|c| m.lifecycle.ready.ok_status.contains(&c)).unwrap_or(false) + let h = probe + .probe(&m.lifecycle.health.url) + .map(|c| m.lifecycle.health.ok_status.contains(&c)) + .unwrap_or(false); + if !h { + return false; + } + probe + .probe(&m.lifecycle.ready.url) + .map(|c| m.lifecycle.ready.ok_status.contains(&c)) + .unwrap_or(false) } #[allow(clippy::too_many_arguments)] pub fn run_start_sequence( - m: &Manifest, manifest_dir: &Path, - probe: &dyn Probe, spawner: &dyn Spawner, clock: &dyn Clock, sink: &dyn EventSink, + m: &Manifest, + manifest_dir: &Path, + probe: &dyn Probe, + spawner: &dyn Spawner, + clock: &dyn Clock, + sink: &dyn EventSink, images_present: bool, ) -> StartOutcome { let cwd = m.resolved_cwd(manifest_dir); @@ -52,14 +69,20 @@ pub fn run_start_sequence( if !images_present { sink.emit_state(&m.id, BUILDING); let code = spawner.run_to_completion(&build.cmd, &cwd, &build.args, build.timeout); - if code != 0 { sink.emit_state(&m.id, ERROR); return StartOutcome::Error(format!("build exited {code}")); } + if code != 0 { + sink.emit_state(&m.id, ERROR); + return StartOutcome::Error(format!("build exited {code}")); + } } } // Step 1: adopt check — both probes already up → adopt (not owned) if both_probes_pass(m, probe) { sink.emit_state(&m.id, HEALTHY); - return StartOutcome::Healthy { owned: false, child_id: None }; + return StartOutcome::Healthy { + owned: false, + child_id: None, + }; } // Step 2: spawn start (owned) @@ -69,21 +92,37 @@ pub fn run_start_sequence( // Step 3: health then ready sink.emit_state(&m.id, HEALTH_PROBING); if !poll_until_ok(&m.lifecycle.health, probe, clock) { - sink.emit_state(&m.id, ERROR); return StartOutcome::Error("health probe timed out".into()); + sink.emit_state(&m.id, ERROR); + return StartOutcome::Error("health probe timed out".into()); } sink.emit_state(&m.id, READY_PROBING); if !poll_until_ok(&m.lifecycle.ready, probe, clock) { - sink.emit_state(&m.id, ERROR); return StartOutcome::Error("ready probe timed out".into()); + sink.emit_state(&m.id, ERROR); + return StartOutcome::Error("ready probe timed out".into()); } // Step 4: healthy sink.emit_state(&m.id, HEALTHY); - StartOutcome::Healthy { owned: true, child_id: Some(child_id) } + StartOutcome::Healthy { + owned: true, + child_id: Some(child_id), + } } /// If the owned child has exited, emit `error` and return true. The caller is /// responsible for destroying the kept-alive webview on a true return (§4). -pub fn check_crash(plugin_id: &str, child_id: u64, spawner: &dyn Spawner, sink: &dyn EventSink) -> bool { +// UNWIRED FEATURE — no caller anywhere. Not on main, not on any in-flight branch; +// the two unit tests below are the only things that exercise it. The crash watcher +// described above has not been written, so nothing destroys the webview of a plugin +// whose process dies. Kept because the behavior is still wanted, but this allow is +// hiding missing work, not a caller that is about to arrive. +#[allow(dead_code)] +pub fn check_crash( + plugin_id: &str, + child_id: u64, + spawner: &dyn Spawner, + sink: &dyn EventSink, +) -> bool { if spawner.has_exited(child_id) { sink.emit_state(plugin_id, ERROR); true @@ -101,71 +140,165 @@ mod tests { use std::sync::Mutex; fn manifest() -> Manifest { - Manifest::from_json(r#"{"id":"audience","name":"Audience","apiVersion":1, + Manifest::from_json( + r#"{"id":"audience","name":"Audience","apiVersion":1, "url":"http://localhost:3000", "lifecycle":{"cwd":"/x","build":{"cmd":"build","timeout":1000}, "start":"up","stop":"down","env":{}, "health":{"url":"h","okStatus":[200],"timeout":5000,"interval":1000}, - "ready":{"url":"r","okStatus":[200,302],"timeout":5000,"interval":1000}}}"#).unwrap() + "ready":{"url":"r","okStatus":[200,302],"timeout":5000,"interval":1000}}}"#, + ) + .unwrap() } #[test] fn cold_start_walks_building_to_healthy_and_owns_the_stack() { - let probe = ScriptedProbe { responses: Mutex::new(HashMap::from([ - // adopt check: both down at first - ("h".to_string(), vec![None, None, Some(200), Some(200)]), - ("r".to_string(), vec![None, None, Some(302)]), - ])) }; - let spawner = FakeSpawner { start_child_id: 7, build_exit: 0, exited: Mutex::new(false) }; + let probe = ScriptedProbe { + responses: Mutex::new(HashMap::from([ + // adopt check: both down at first + ("h".to_string(), vec![None, None, Some(200), Some(200)]), + ("r".to_string(), vec![None, None, Some(302)]), + ])), + }; + let spawner = FakeSpawner { + start_child_id: 7, + build_exit: 0, + exited: Mutex::new(false), + }; let clock = FakeClock::default(); let sink = RecordingSink::default(); - let outcome = run_start_sequence(&manifest(), std::path::Path::new("/x"), - &probe, &spawner, &clock, &sink, /*images_present=*/false); + let outcome = run_start_sequence( + &manifest(), + std::path::Path::new("/x"), + &probe, + &spawner, + &clock, + &sink, + /*images_present=*/ false, + ); - assert_eq!(outcome, StartOutcome::Healthy { owned: true, child_id: Some(7) }); - let states: Vec = sink.states.lock().unwrap().iter().map(|(_, s)| s.clone()).collect(); - assert_eq!(states, vec!["building","starting","health-probing","ready-probing","healthy"]); + assert_eq!( + outcome, + StartOutcome::Healthy { + owned: true, + child_id: Some(7) + } + ); + let states: Vec = sink + .states + .lock() + .unwrap() + .iter() + .map(|(_, s)| s.clone()) + .collect(); + assert_eq!( + states, + vec![ + "building", + "starting", + "health-probing", + "ready-probing", + "healthy" + ] + ); } #[test] fn adopts_when_both_probes_already_pass_and_marks_not_owned() { - let probe = ScriptedProbe { responses: Mutex::new(HashMap::from([ - ("h".to_string(), vec![Some(200)]), - ("r".to_string(), vec![Some(200)]), - ])) }; - let spawner = FakeSpawner { start_child_id: 7, build_exit: 0, exited: Mutex::new(false) }; - let out = run_start_sequence(&manifest(), std::path::Path::new("/x"), - &probe, &spawner, &FakeClock::default(), &RecordingSink::default(), /*images_present=*/true); - assert_eq!(out, StartOutcome::Healthy { owned: false, child_id: None }); + let probe = ScriptedProbe { + responses: Mutex::new(HashMap::from([ + ("h".to_string(), vec![Some(200)]), + ("r".to_string(), vec![Some(200)]), + ])), + }; + let spawner = FakeSpawner { + start_child_id: 7, + build_exit: 0, + exited: Mutex::new(false), + }; + let out = run_start_sequence( + &manifest(), + std::path::Path::new("/x"), + &probe, + &spawner, + &FakeClock::default(), + &RecordingSink::default(), + /*images_present=*/ true, + ); + assert_eq!( + out, + StartOutcome::Healthy { + owned: false, + child_id: None + } + ); } #[test] fn partial_stack_health_only_falls_through_to_spawn() { // health up, ready down at adopt → must NOT adopt; spawn then both come up - let probe = ScriptedProbe { responses: Mutex::new(HashMap::from([ - ("h".to_string(), vec![Some(200), Some(200)]), // adopt(h) ok, later health-poll ok - ("r".to_string(), vec![None, Some(200)]), // adopt(r) down → fall through; ready-poll ok - ])) }; - let spawner = FakeSpawner { start_child_id: 7, build_exit: 0, exited: Mutex::new(false) }; + let probe = ScriptedProbe { + responses: Mutex::new(HashMap::from([ + ("h".to_string(), vec![Some(200), Some(200)]), // adopt(h) ok, later health-poll ok + ("r".to_string(), vec![None, Some(200)]), // adopt(r) down → fall through; ready-poll ok + ])), + }; + let spawner = FakeSpawner { + start_child_id: 7, + build_exit: 0, + exited: Mutex::new(false), + }; let sink = RecordingSink::default(); - let out = run_start_sequence(&manifest(), std::path::Path::new("/x"), - &probe, &spawner, &FakeClock::default(), &sink, true); - assert_eq!(out, StartOutcome::Healthy { owned: true, child_id: Some(7) }); // spawned → owned - let states: Vec = sink.states.lock().unwrap().iter().map(|(_, s)| s.clone()).collect(); + let out = run_start_sequence( + &manifest(), + std::path::Path::new("/x"), + &probe, + &spawner, + &FakeClock::default(), + &sink, + true, + ); + assert_eq!( + out, + StartOutcome::Healthy { + owned: true, + child_id: Some(7) + } + ); // spawned → owned + let states: Vec = sink + .states + .lock() + .unwrap() + .iter() + .map(|(_, s)| s.clone()) + .collect(); assert!(states.contains(&"starting".to_string())); } #[test] fn health_timeout_yields_error() { - let probe = ScriptedProbe { responses: Mutex::new(HashMap::from([ - ("h".to_string(), vec![None]), // never comes up - ("r".to_string(), vec![None]), - ])) }; - let spawner = FakeSpawner { start_child_id: 7, build_exit: 0, exited: Mutex::new(false) }; + let probe = ScriptedProbe { + responses: Mutex::new(HashMap::from([ + ("h".to_string(), vec![None]), // never comes up + ("r".to_string(), vec![None]), + ])), + }; + let spawner = FakeSpawner { + start_child_id: 7, + build_exit: 0, + exited: Mutex::new(false), + }; let sink = RecordingSink::default(); - let out = run_start_sequence(&manifest(), std::path::Path::new("/x"), - &probe, &spawner, &FakeClock::default(), &sink, true); + let out = run_start_sequence( + &manifest(), + std::path::Path::new("/x"), + &probe, + &spawner, + &FakeClock::default(), + &sink, + true, + ); assert!(matches!(out, StartOutcome::Error(_))); assert_eq!(sink.states.lock().unwrap().last().unwrap().1, "error"); } @@ -173,16 +306,35 @@ mod tests { #[test] fn ready_timeout_yields_error_after_health_passes() { // health comes up, but ready never does → error on the ready path - let probe = ScriptedProbe { responses: Mutex::new(HashMap::from([ - ("h".to_string(), vec![None, Some(200)]), // adopt(h) down → fall through; health-poll up - ("r".to_string(), vec![None]), // ready never ok → times out - ])) }; - let spawner = FakeSpawner { start_child_id: 7, build_exit: 0, exited: Mutex::new(false) }; + let probe = ScriptedProbe { + responses: Mutex::new(HashMap::from([ + ("h".to_string(), vec![None, Some(200)]), // adopt(h) down → fall through; health-poll up + ("r".to_string(), vec![None]), // ready never ok → times out + ])), + }; + let spawner = FakeSpawner { + start_child_id: 7, + build_exit: 0, + exited: Mutex::new(false), + }; let sink = RecordingSink::default(); - let out = run_start_sequence(&manifest(), std::path::Path::new("/x"), - &probe, &spawner, &FakeClock::default(), &sink, true); + let out = run_start_sequence( + &manifest(), + std::path::Path::new("/x"), + &probe, + &spawner, + &FakeClock::default(), + &sink, + true, + ); assert!(matches!(out, StartOutcome::Error(_))); - let states: Vec = sink.states.lock().unwrap().iter().map(|(_, s)| s.clone()).collect(); + let states: Vec = sink + .states + .lock() + .unwrap() + .iter() + .map(|(_, s)| s.clone()) + .collect(); // it must have gotten past health-probing to ready-probing before erroring assert!(states.contains(&"ready-probing".to_string())); assert_eq!(states.last().unwrap(), "error"); @@ -190,30 +342,51 @@ mod tests { #[test] fn build_failure_yields_error_before_spawn() { - let probe = ScriptedProbe { responses: Mutex::new(HashMap::new()) }; - let spawner = FakeSpawner { start_child_id: 7, build_exit: 2, exited: Mutex::new(false) }; + let probe = ScriptedProbe { + responses: Mutex::new(HashMap::new()), + }; + let spawner = FakeSpawner { + start_child_id: 7, + build_exit: 2, + exited: Mutex::new(false), + }; let sink = RecordingSink::default(); - let out = run_start_sequence(&manifest(), std::path::Path::new("/x"), - &probe, &spawner, &FakeClock::default(), &sink, /*images_present=*/false); + let out = run_start_sequence( + &manifest(), + std::path::Path::new("/x"), + &probe, + &spawner, + &FakeClock::default(), + &sink, + /*images_present=*/ false, + ); assert!(matches!(out, StartOutcome::Error(_))); assert_eq!(sink.states.lock().unwrap()[0].1, "building"); } #[test] fn crash_while_healthy_transitions_to_error() { - let spawner = FakeSpawner { start_child_id: 7, build_exit: 0, exited: Mutex::new(true) }; + let spawner = FakeSpawner { + start_child_id: 7, + build_exit: 0, + exited: Mutex::new(true), + }; let sink = RecordingSink::default(); // Given an owned, healthy plugin whose child has exited, the watcher flips to error. - let flipped = check_crash(&"audience".to_string(), 7, &spawner, &sink); + let flipped = check_crash("audience", 7, &spawner, &sink); assert!(flipped); assert_eq!(sink.states.lock().unwrap().last().unwrap().1, "error"); } #[test] fn no_crash_when_child_alive() { - let spawner = FakeSpawner { start_child_id: 7, build_exit: 0, exited: Mutex::new(false) }; + let spawner = FakeSpawner { + start_child_id: 7, + build_exit: 0, + exited: Mutex::new(false), + }; let sink = RecordingSink::default(); - let flipped = check_crash(&"audience".to_string(), 7, &spawner, &sink); + let flipped = check_crash("audience", 7, &spawner, &sink); assert!(!flipped); assert!(sink.states.lock().unwrap().is_empty()); } diff --git a/crates/fleet-core/src/event.rs b/crates/fleet-core/src/event.rs index a58f15d..de6d8fc 100644 --- a/crates/fleet-core/src/event.rs +++ b/crates/fleet-core/src/event.rs @@ -114,18 +114,28 @@ pub enum ErrorScope { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "command", rename_all = "snake_case")] pub enum Command { - Halt { cmd_id: String }, - Resume { cmd_id: String }, - Abandon { cmd_id: String }, + Halt { + cmd_id: String, + }, + Resume { + cmd_id: String, + }, + Abandon { + cmd_id: String, + }, /// The T3 final gate: human ships the PR from `NeedsHuman`. - Ship { cmd_id: String }, + Ship { + cmd_id: String, + }, /// Approve the frozen test set; may carry an edited set (T2/T3). ApproveOracle { cmd_id: String, #[serde(skip_serializing_if = "Option::is_none")] edited_test_files: Option>, }, - RejectOracle { cmd_id: String }, + RejectOracle { + cmd_id: String, + }, } impl Command { @@ -188,7 +198,12 @@ mod tests { #[test] fn metric_roundtrips() { - let e = Event::Metric { tokens_in: 9457, tokens_out: 4, cost_usd: 0.207, elapsed_ms: 6734 }; + let e = Event::Metric { + tokens_in: 9457, + tokens_out: 4, + cost_usd: 0.207, + elapsed_ms: 6734, + }; let json = serde_json::to_string(&e).unwrap(); let back: Event = serde_json::from_str(&json).unwrap(); assert_eq!(e, back); @@ -203,7 +218,10 @@ mod tests { #[test] fn approve_oracle_optional_edits_omitted_when_none() { - let c = Command::ApproveOracle { cmd_id: "x".into(), edited_test_files: None }; + let c = Command::ApproveOracle { + cmd_id: "x".into(), + edited_test_files: None, + }; let v = serde_json::to_value(&c).unwrap(); assert_eq!(v["command"], "approve_oracle"); assert!(v.get("edited_test_files").is_none()); diff --git a/crates/fleet-core/src/gate.rs b/crates/fleet-core/src/gate.rs index a83be3d..4994e4d 100644 --- a/crates/fleet-core/src/gate.rs +++ b/crates/fleet-core/src/gate.rs @@ -12,7 +12,9 @@ pub struct GateConfig { impl Default for GateConfig { fn default() -> Self { - Self { min_review_rounds: 3 } + Self { + min_review_rounds: 3, + } } } @@ -82,13 +84,17 @@ mod tests { #[test] fn first_round_has_no_prior_to_compare() { // round floor of 1 to isolate the prev=None branch - let cfg = GateConfig { min_review_rounds: 1 }; + let cfg = GateConfig { + min_review_rounds: 1, + }; assert!(gate_met(cfg, snap(1, 0, None, true))); } #[test] fn respects_custom_floor() { - let cfg = GateConfig { min_review_rounds: 1 }; + let cfg = GateConfig { + min_review_rounds: 1, + }; assert!(gate_met(cfg, snap(1, 0, Some(0), true))); } } diff --git a/crates/fleet-core/src/lib.rs b/crates/fleet-core/src/lib.rs index db7c56b..fc61ec0 100644 --- a/crates/fleet-core/src/lib.rs +++ b/crates/fleet-core/src/lib.rs @@ -11,9 +11,7 @@ mod phase; mod tier; mod transition; -pub use event::{ - ArtifactKind, Command, ErrorScope, Event, IterationKind, LogStream, Severity, -}; +pub use event::{ArtifactKind, Command, ErrorScope, Event, IterationKind, LogStream, Severity}; pub use gate::{gate_met, GateConfig, ReviewSnapshot}; pub use phase::{Phase, TERMINAL_PHASE_STRS}; pub use tier::Tier; diff --git a/crates/fleet-core/src/phase.rs b/crates/fleet-core/src/phase.rs index 4f5b250..4a97c11 100644 --- a/crates/fleet-core/src/phase.rs +++ b/crates/fleet-core/src/phase.rs @@ -36,7 +36,10 @@ impl Phase { /// Phases in which the agent/container is actively running, so cost/stall /// caps and oracle-tampering apply. pub fn is_agent_active(self) -> bool { - matches!(self, Phase::Spec | Phase::Building | Phase::Checking | Phase::Reviewing) + matches!( + self, + Phase::Spec | Phase::Building | Phase::Checking | Phase::Reviewing + ) } /// Non-terminal phases can always be halted or fail fatally. @@ -73,9 +76,16 @@ mod tests { Phase::NeedsHuman, Phase::Halted, ] { - let s = serde_json::to_value(p).unwrap().as_str().unwrap().to_string(); - assert_eq!(TERMINAL_PHASE_STRS.contains(&s.as_str()), p.is_terminal(), - "{s} membership must equal is_terminal()"); + let s = serde_json::to_value(p) + .unwrap() + .as_str() + .unwrap() + .to_string(); + assert_eq!( + TERMINAL_PHASE_STRS.contains(&s.as_str()), + p.is_terminal(), + "{s} membership must equal is_terminal()" + ); } } } diff --git a/crates/fleet-core/src/transition.rs b/crates/fleet-core/src/transition.rs index 45dc153..5ca0689 100644 --- a/crates/fleet-core/src/transition.rs +++ b/crates/fleet-core/src/transition.rs @@ -25,7 +25,9 @@ pub enum Trigger { EmptyDiff, /// A review round finished; `gate_met` is the evidence-based gate verdict /// (green + no unresolved blockers + non-increasing + round floor). - ReviewFinished { gate_met: bool }, + ReviewFinished { + gate_met: bool, + }, /// Trial merge into a fresh base succeeded. MergeClean, /// Trial merge hit conflicts. @@ -189,36 +191,55 @@ mod tests { #[test] fn checks_fail_loops_back_to_building() { - assert_eq!(transition(Checking, Tier::T1, Trigger::ChecksFailed), Some(Building)); + assert_eq!( + transition(Checking, Tier::T1, Trigger::ChecksFailed), + Some(Building) + ); } #[test] fn review_not_met_loops_back_to_building() { assert_eq!( - transition(Reviewing, Tier::T1, Trigger::ReviewFinished { gate_met: false }), + transition( + Reviewing, + Tier::T1, + Trigger::ReviewFinished { gate_met: false } + ), Some(Building) ); } #[test] fn empty_diff_is_no_change_not_failure() { - assert_eq!(transition(Checking, Tier::T1, Trigger::EmptyDiff), Some(NoChange)); + assert_eq!( + transition(Checking, Tier::T1, Trigger::EmptyDiff), + Some(NoChange) + ); } #[test] fn merge_conflict_needs_human() { - assert_eq!(transition(MergeCheck, Tier::T1, Trigger::MergeConflict), Some(NeedsHuman)); + assert_eq!( + transition(MergeCheck, Tier::T1, Trigger::MergeConflict), + Some(NeedsHuman) + ); } #[test] fn pr_dirty_after_open_needs_human() { - assert_eq!(transition(PrOpen, Tier::T1, Trigger::PrDirty), Some(NeedsHuman)); + assert_eq!( + transition(PrOpen, Tier::T1, Trigger::PrDirty), + Some(NeedsHuman) + ); } #[test] fn cap_breach_only_interrupts_agent_active_phases() { // Active phase: interrupted. - assert_eq!(transition(Building, Tier::T1, Trigger::CapBreach), Some(NeedsHuman)); + assert_eq!( + transition(Building, Tier::T1, Trigger::CapBreach), + Some(NeedsHuman) + ); // Daemon-only phase: cap breach is not meaningful → invalid. assert_eq!(transition(MergeCheck, Tier::T1, Trigger::CapBreach), None); } @@ -230,7 +251,15 @@ mod tests { #[test] fn fatal_error_from_any_active_phase_fails() { - for p in [Provisioning, Spec, Building, Checking, Reviewing, MergeCheck, PrOpen] { + for p in [ + Provisioning, + Spec, + Building, + Checking, + Reviewing, + MergeCheck, + PrOpen, + ] { assert_eq!(transition(p, Tier::T1, Trigger::FatalError), Some(Failed)); } } @@ -239,25 +268,46 @@ mod tests { fn halt_and_resume_round_trip() { assert_eq!(transition(Building, Tier::T1, Trigger::Halt), Some(Halted)); // Resume re-provisions (reuses the volume) before re-entering the loop. - assert_eq!(transition(Halted, Tier::T1, Trigger::Resume), Some(Provisioning)); + assert_eq!( + transition(Halted, Tier::T1, Trigger::Resume), + Some(Provisioning) + ); } #[test] fn resume_goes_to_provisioning_not_building() { - assert_eq!(transition(NeedsHuman, Tier::T1, Trigger::Resume), Some(Provisioning)); - assert_eq!(transition(Halted, Tier::T1, Trigger::Resume), Some(Provisioning)); + assert_eq!( + transition(NeedsHuman, Tier::T1, Trigger::Resume), + Some(Provisioning) + ); + assert_eq!( + transition(Halted, Tier::T1, Trigger::Resume), + Some(Provisioning) + ); } #[test] fn abandon_from_needs_human_fails() { - assert_eq!(transition(NeedsHuman, Tier::T1, Trigger::Abandon), Some(Failed)); + assert_eq!( + transition(NeedsHuman, Tier::T1, Trigger::Abandon), + Some(Failed) + ); } #[test] fn terminal_phases_reject_everything() { for p in [Done, NoChange, Failed] { - for t in [Trigger::Resume, Trigger::Start, Trigger::Halt, Trigger::FatalError] { - assert_eq!(transition(p, Tier::T1, t), None, "{p:?} should reject {t:?}"); + for t in [ + Trigger::Resume, + Trigger::Start, + Trigger::Halt, + Trigger::FatalError, + ] { + assert_eq!( + transition(p, Tier::T1, t), + None, + "{p:?} should reject {t:?}" + ); } } } @@ -273,9 +323,15 @@ mod tests { #[test] fn retries_exhausted_parks_agent_phases_at_needs_human() { for p in [Spec, Building, Reviewing] { - assert_eq!(transition(p, Tier::T1, Trigger::RetriesExhausted), Some(NeedsHuman)); + assert_eq!( + transition(p, Tier::T1, Trigger::RetriesExhausted), + Some(NeedsHuman) + ); } // Not meaningful from a daemon-only phase → invalid. - assert_eq!(transition(MergeCheck, Tier::T1, Trigger::RetriesExhausted), None); + assert_eq!( + transition(MergeCheck, Tier::T1, Trigger::RetriesExhausted), + None + ); } } diff --git a/crates/fleetd/src/bin/run_once.rs b/crates/fleetd/src/bin/run_once.rs index b2d0648..bf9bd03 100644 --- a/crates/fleetd/src/bin/run_once.rs +++ b/crates/fleetd/src/bin/run_once.rs @@ -39,7 +39,10 @@ async fn main() { .to_string() }); - let millis = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis(); + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); let unit_id = format!("u{millis}"); let branch = format!("agent/{unit_id}"); @@ -86,7 +89,14 @@ async fn main() { drop(cmd_tx); // T1 run: no interactive commands println!("== run-once: unit {unit_id} on {branch} (cap ${usd_cap}) =="); - let driver = tokio::spawn(run(runner, forge, spec, RunCtx::standalone(), cmd_rx, evt_tx)); + let driver = tokio::spawn(run( + runner, + forge, + spec, + RunCtx::standalone(), + cmd_rx, + evt_tx, + )); while let Some(env) = evt_rx.recv().await { print_event(&env); @@ -100,21 +110,44 @@ async fn main() { fn print_event(env: &EventEnvelope) { match &env.event { - Event::PhaseChanged { from, to, reason, .. } => { - let why = reason.as_deref().map(|r| format!(" ({r})")).unwrap_or_default(); + Event::PhaseChanged { + from, to, reason, .. + } => { + let why = reason + .as_deref() + .map(|r| format!(" ({r})")) + .unwrap_or_default(); println!("[{:>3}] {from:?} -> {to:?}{why}", env.seq); } Event::Iteration { kind, n } => println!("[{:>3}] iteration {kind:?} #{n}", env.seq), - Event::Metric { cost_usd, tokens_in, tokens_out, .. } => { - println!("[{:>3}] $ {cost_usd:.4} (in {tokens_in} / out {tokens_out})", env.seq) + Event::Metric { + cost_usd, + tokens_in, + tokens_out, + .. + } => { + println!( + "[{:>3}] $ {cost_usd:.4} (in {tokens_in} / out {tokens_out})", + env.seq + ) + } + Event::Finding { round, title, .. } => { + println!("[{:>3}] review r{round}: {title}", env.seq) + } + Event::Artifact { kind, reference } => { + println!("[{:>3}] artifact {kind:?}: {reference}", env.seq) } - Event::Finding { round, title, .. } => println!("[{:>3}] review r{round}: {title}", env.seq), - Event::Artifact { kind, reference } => println!("[{:>3}] artifact {kind:?}: {reference}", env.seq), Event::OracleProposed { test_files, .. } => { - println!("[{:>3}] oracle proposed {} test line(s)", env.seq, test_files.len()) + println!( + "[{:>3}] oracle proposed {} test line(s)", + env.seq, + test_files.len() + ) } Event::Blocked { reason, .. } => println!("[{:>3}] BLOCKED: {reason}", env.seq), - Event::Error { scope, detail, .. } => println!("[{:>3}] ERROR {scope:?}: {detail}", env.seq), + Event::Error { scope, detail, .. } => { + println!("[{:>3}] ERROR {scope:?}: {detail}", env.seq) + } Event::Done { result } => println!("[{:>3}] DONE: {result}", env.seq), Event::Log { stream, line } => println!("[{:>3}] {stream:?}| {line}", env.seq), } diff --git a/crates/fleetd/src/claude_meter.rs b/crates/fleetd/src/claude_meter.rs index 4e4679d..11f150a 100644 --- a/crates/fleetd/src/claude_meter.rs +++ b/crates/fleetd/src/claude_meter.rs @@ -27,8 +27,15 @@ pub fn parse_usage(stdout: &[String]) -> Option { .and_then(|u| u.get("output_tokens")) .and_then(|n| n.as_u64()) .unwrap_or(0); - let cost_usd = v.get("total_cost_usd").and_then(|c| c.as_f64()).unwrap_or(0.0); - return Some(Usage { tokens_in, tokens_out, cost_usd }); + let cost_usd = v + .get("total_cost_usd") + .and_then(|c| c.as_f64()) + .unwrap_or(0.0); + return Some(Usage { + tokens_in, + tokens_out, + cost_usd, + }); } None } diff --git a/crates/fleetd/src/docsource.rs b/crates/fleetd/src/docsource.rs index 93d991f..e8ae50f 100644 --- a/crates/fleetd/src/docsource.rs +++ b/crates/fleetd/src/docsource.rs @@ -14,8 +14,12 @@ pub enum DocError { #[async_trait] pub trait DocSource: Send + Sync { - async fn read(&self, repo_url: &str, base_branch: &str, doc_path: &str) - -> Result; + async fn read( + &self, + repo_url: &str, + base_branch: &str, + doc_path: &str, + ) -> Result; } /// Canned content keyed only by `doc_path`; a path of "missing.md" → NotFound. @@ -23,7 +27,11 @@ pub struct FakeDocSource { pub content: String, } impl FakeDocSource { - pub fn new(content: &str) -> Self { Self { content: content.into() } } + pub fn new(content: &str) -> Self { + Self { + content: content.into(), + } + } } #[async_trait] impl DocSource for FakeDocSource { @@ -37,9 +45,15 @@ impl DocSource for FakeDocSource { /// Validate clone inputs before handing them to `git`. Rejects flag-smuggling /// (leading `-`), non-https schemes, and unsafe doc paths (absolute or `..`). -fn validate_clone_inputs(repo_url: &str, base_branch: &str, doc_path: &str) -> Result<(), DocError> { +fn validate_clone_inputs( + repo_url: &str, + base_branch: &str, + doc_path: &str, +) -> Result<(), DocError> { if repo_url.starts_with('-') || base_branch.starts_with('-') { - return Err(DocError::Failed("repo_url/base_branch must not start with '-'".into())); + return Err(DocError::Failed( + "repo_url/base_branch must not start with '-'".into(), + )); } if !repo_url.starts_with("https://") { return Err(DocError::Failed("repo_url must be an https:// URL".into())); @@ -51,9 +65,15 @@ fn validate_clone_inputs(repo_url: &str, base_branch: &str, doc_path: &str) -> R let p = std::path::Path::new(doc_path); if p.is_absolute() || doc_path.is_empty() - || p.components().any(|c| matches!(c, std::path::Component::ParentDir - | std::path::Component::Prefix(_) - | std::path::Component::RootDir)) { + || p.components().any(|c| { + matches!( + c, + std::path::Component::ParentDir + | std::path::Component::Prefix(_) + | std::path::Component::RootDir + ) + }) + { return Err(DocError::NotFound(doc_path.into())); } Ok(()) @@ -62,8 +82,16 @@ fn validate_clone_inputs(repo_url: &str, base_branch: &str, doc_path: &str) -> R /// Shallow-clones the base branch to a temp dir, reads the file, and removes the /// dir on drop regardless of outcome (a failed/empty swarm has no driver to clean up). pub struct GitDocSource; -impl GitDocSource { pub fn new() -> Self { Self } } -impl Default for GitDocSource { fn default() -> Self { Self::new() } } +impl GitDocSource { + pub fn new() -> Self { + Self + } +} +impl Default for GitDocSource { + fn default() -> Self { + Self::new() + } +} /// Per-call sequence so two concurrent real swarms never collide on the same temp /// clone dir (which would make one's drop-guard delete the other's clone). @@ -71,15 +99,25 @@ static CLONE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::n struct TempClone(std::path::PathBuf); impl Drop for TempClone { - fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } } #[async_trait] impl DocSource for GitDocSource { - async fn read(&self, repo_url: &str, base_branch: &str, doc_path: &str) -> Result { + async fn read( + &self, + repo_url: &str, + base_branch: &str, + doc_path: &str, + ) -> Result { validate_clone_inputs(repo_url, base_branch, doc_path)?; - let dir = std::env::temp_dir().join(format!("cc-plan-{}-{}", std::process::id(), - CLONE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + let dir = std::env::temp_dir().join(format!( + "cc-plan-{}-{}", + std::process::id(), + CLONE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); let _guard = TempClone(dir.clone()); let ok = tokio::process::Command::new("git") .args(["clone", "--depth", "1", "--branch"]) @@ -87,19 +125,23 @@ impl DocSource for GitDocSource { .arg("--") .arg(repo_url) .arg(&dir) - .output().await + .output() + .await .map_err(|e| DocError::Failed(e.to_string()))?; if !ok.status.success() { return Err(DocError::Failed(String::from_utf8_lossy(&ok.stderr).into())); } - let base = tokio::fs::canonicalize(&dir).await + let base = tokio::fs::canonicalize(&dir) + .await .map_err(|e| DocError::Failed(e.to_string()))?; - let target = tokio::fs::canonicalize(base.join(doc_path)).await + let target = tokio::fs::canonicalize(base.join(doc_path)) + .await .map_err(|_| DocError::NotFound(doc_path.into()))?; if !target.starts_with(&base) { return Err(DocError::NotFound(doc_path.into())); } - tokio::fs::read_to_string(&target).await + tokio::fs::read_to_string(&target) + .await .map_err(|_| DocError::NotFound(doc_path.into())) // _guard drops here → temp dir removed. } @@ -112,18 +154,25 @@ mod tests { #[tokio::test] async fn fake_doc_source_returns_content_or_not_found() { let d = FakeDocSource::new("# spec\n- a\n- b\n"); - assert!(d.read("u", "main", "spec.md").await.unwrap().contains("spec")); - assert!(matches!(d.read("u", "main", "missing.md").await, Err(DocError::NotFound(_)))); + assert!(d + .read("u", "main", "spec.md") + .await + .unwrap() + .contains("spec")); + assert!(matches!( + d.read("u", "main", "missing.md").await, + Err(DocError::NotFound(_)) + )); } #[test] fn validate_clone_inputs_rejects_flag_and_traversal_and_scheme() { assert!(validate_clone_inputs("https://h/r", "main", "spec.md").is_ok()); - assert!(validate_clone_inputs("-x", "main", "spec.md").is_err()); // flag smuggle (url) + assert!(validate_clone_inputs("-x", "main", "spec.md").is_err()); // flag smuggle (url) assert!(validate_clone_inputs("https://h/r", "--upload-pack=x", "s").is_err()); // flag smuggle (branch) - assert!(validate_clone_inputs("git@h:r", "main", "spec.md").is_err()); // non-https + assert!(validate_clone_inputs("git@h:r", "main", "spec.md").is_err()); // non-https assert!(validate_clone_inputs("https://h/r", "main", "../etc/passwd").is_err()); // traversal - assert!(validate_clone_inputs("https://h/r", "main", "/etc/passwd").is_err()); // absolute - assert!(validate_clone_inputs("https://h/r", "main", "").is_err()); // empty + assert!(validate_clone_inputs("https://h/r", "main", "/etc/passwd").is_err()); // absolute + assert!(validate_clone_inputs("https://h/r", "main", "").is_err()); // empty } } diff --git a/crates/fleetd/src/driver.rs b/crates/fleetd/src/driver.rs index 4d60150..cd50363 100644 --- a/crates/fleetd/src/driver.rs +++ b/crates/fleetd/src/driver.rs @@ -3,7 +3,9 @@ //! contract and honoring inbound commands. Fully exercisable against the fakes. use crate::forge::{Forge, MergeResult, Mergeability}; -use crate::retry::{classify, rl_base_secs, rl_cap_secs, rl_max_wait_secs, Backoff, StepOutcome, RL_REASON}; +use crate::retry::{ + classify, rl_base_secs, rl_cap_secs, rl_max_wait_secs, Backoff, StepOutcome, RL_REASON, +}; use crate::runner::{ExecOutput, Handle, Runner, UnitSpec}; use crate::{claude_meter, steps}; use fleet_core::{ @@ -152,7 +154,12 @@ impl Run { match transition(from, self.spec.tier, trigger) { Some(next) => { self.phase = next; - self.emit(Event::PhaseChanged { from, to: next, reason, cmd_id }); + self.emit(Event::PhaseChanged { + from, + to: next, + reason, + cmd_id, + }); } None => { self.emit(Event::Error { @@ -262,7 +269,10 @@ impl Run { } }; for line in &out.stdout { - self.emit(Event::Log { stream, line: line.clone() }); + self.emit(Event::Log { + stream, + line: line.clone(), + }); } if out.usage.is_none() { out.usage = claude_meter::parse_usage(&out.stdout); @@ -316,7 +326,8 @@ impl Run { retryable: false, detail: format!( "command {} not valid in {:?}", - other.cmd_id(), self.phase + other.cmd_id(), + self.phase ), }), // keep waiting on the same `until` // Channel closed (no interactive commands, e.g. run_once): @@ -340,7 +351,10 @@ impl Run { async fn read_oracle_files(&mut self) -> Option> { // Scope the immutable field borrows so they end before the &mut self emit/goto. let result = { - let handle = self.handle.as_ref().expect("handle present in agent-active phase"); + let handle = self + .handle + .as_ref() + .expect("handle present in agent-active phase"); self.runner.read_files(handle, "*.test.js").await }; match result { @@ -351,7 +365,11 @@ impl Run { retryable: false, detail: format!("oracle read failed: {e}"), }); - self.goto(Trigger::OracleTampering, Some("oracle unreadable".into()), None); + self.goto( + Trigger::OracleTampering, + Some("oracle unreadable".into()), + None, + ); None } } @@ -392,8 +410,13 @@ impl Run { cap: None, detail: String::new(), }); - self.permit = - Some(self.permits.clone().acquire_owned().await.expect("semaphore")); + self.permit = Some( + self.permits + .clone() + .acquire_owned() + .await + .expect("semaphore"), + ); } match self.runner.provision(&self.spec).await { Ok(h) => { @@ -416,19 +439,26 @@ impl Run { // On resume the test set is already frozen in the reused volume; // don't re-run/re-charge the oracle or re-trigger approval. if self.resume && self.spec.oracle_frozen { - self.goto(Trigger::OracleFrozen, Some("oracle already frozen".into()), None); + self.goto( + Trigger::OracleFrozen, + Some("oracle already frozen".into()), + None, + ); continue; } if self.check_halt() { continue; } let argv = steps::oracle(&self.spec, self.remaining()); - let Some(out) = - self.agent_exec(IterationKind::Review, 0, LogStream::Agent, true, &argv).await + let Some(out) = self + .agent_exec(IterationKind::Review, 0, LogStream::Agent, true, &argv) + .await else { continue; }; - let Some(frozen) = self.read_oracle_files().await else { continue }; + let Some(frozen) = self.read_oracle_files().await else { + continue; + }; if frozen.is_empty() { self.emit(Event::Error { scope: ErrorScope::System, @@ -464,14 +494,15 @@ impl Run { Command::RejectOracle { .. } => { self.goto(Trigger::OracleRejected, None, Some(cid)) } - Command::Abandon { .. } => { - self.goto(Trigger::Abandon, None, Some(cid)) - } + Command::Abandon { .. } => self.goto(Trigger::Abandon, None, Some(cid)), Command::Halt { .. } => self.goto(Trigger::Halt, None, Some(cid)), other => self.emit(Event::Error { scope: ErrorScope::System, retryable: false, - detail: format!("{} not valid while awaiting oracle", other.cmd_id()), + detail: format!( + "{} not valid while awaiting oracle", + other.cmd_id() + ), }), } } @@ -489,8 +520,9 @@ impl Run { _ => "none".into(), }; let argv = steps::build(&self.spec, &findings, self.remaining()); - let Some(out) = - self.agent_exec(IterationKind::Build, n, LogStream::Agent, true, &argv).await + let Some(out) = self + .agent_exec(IterationKind::Build, n, LogStream::Agent, true, &argv) + .await else { continue; }; @@ -501,7 +533,11 @@ impl Run { // The daemon commits the agent's work (agents edit but don't commit), // so the branch carries the change into the bundle/PR. let handle = self.handle.clone().expect("commit without a handle"); - if let Err(e) = self.runner.commit_all(&handle, &format!("wip: build {n}")).await { + if let Err(e) = self + .runner + .commit_all(&handle, &format!("wip: build {n}")) + .await + { self.emit(Event::Error { scope: ErrorScope::System, retryable: false, @@ -516,22 +552,29 @@ impl Run { continue; } if let Some(frozen_hash) = self.oracle_hash.clone() { - let Some(current) = self.read_oracle_files().await else { continue }; + let Some(current) = self.read_oracle_files().await else { + continue; + }; if hash_oracle(¤t) != frozen_hash { self.emit(Event::Blocked { reason: "frozen oracle tests were modified".into(), cap: None, detail: String::new(), }); - self.goto(Trigger::OracleTampering, Some("oracle tampering".into()), None); + self.goto( + Trigger::OracleTampering, + Some("oracle tampering".into()), + None, + ); continue; } } self.n_check += 1; let n = self.n_check; let argv = steps::check(&self.spec); - let Some(out) = - self.agent_exec(IterationKind::Check, n, LogStream::Check, false, &argv).await + let Some(out) = self + .agent_exec(IterationKind::Check, n, LogStream::Check, false, &argv) + .await else { continue; }; @@ -549,7 +592,9 @@ impl Run { let base = self.spec.base_branch.clone(); let branch = self.spec.branch.clone(); match self.runner.has_diff(&handle, &base, &branch).await { - Ok(false) => self.goto(Trigger::EmptyDiff, Some("no changes vs base".into()), None), + Ok(false) => { + self.goto(Trigger::EmptyDiff, Some("no changes vs base".into()), None) + } Ok(true) => self.goto(Trigger::ChecksPassed, None, None), Err(e) => { // On a diff-check error, proceed rather than stall. @@ -570,8 +615,9 @@ impl Run { self.n_review += 1; let round = self.n_review; let argv = steps::review(self.remaining(), self.spec.wall_clock_secs); - let Some(out) = - self.agent_exec(IterationKind::Review, round, LogStream::Agent, true, &argv).await + let Some(out) = self + .agent_exec(IterationKind::Review, round, LogStream::Agent, true, &argv) + .await else { continue; }; @@ -694,8 +740,14 @@ impl Run { let _ = self.runner.discard(&h).await; } self.permit = None; - let result = if self.phase == Phase::Done { "done" } else { "no_change" }; - self.emit(Event::Done { result: result.into() }); + let result = if self.phase == Phase::Done { + "done" + } else { + "no_change" + }; + self.emit(Event::Done { + result: result.into(), + }); return self.phase; } Phase::Failed => { @@ -704,7 +756,9 @@ impl Run { let _ = self.runner.teardown(&h).await; } self.permit = None; - self.emit(Event::Done { result: "failed".into() }); + self.emit(Event::Done { + result: "failed".into(), + }); return self.phase; } } @@ -738,7 +792,11 @@ impl Run { cap: None, detail: format!("after {MAX_MERGEABLE_POLLS} polls"), }); - self.goto(Trigger::PrDirty, Some("mergeable poll timeout".into()), None); + self.goto( + Trigger::PrDirty, + Some("mergeable poll timeout".into()), + None, + ); } fn fail_closed(&mut self) { @@ -784,7 +842,9 @@ mod tests { task: "do a thing".into(), usd_cap, wall_clock_secs: 0, - gate: GateConfig { min_review_rounds: floor }, + gate: GateConfig { + min_review_rounds: floor, + }, repo_url: "https://github.com/x/y".into(), repo_slug: "x/y".into(), base_branch: "main".into(), @@ -826,8 +886,15 @@ mod tests { let (ctx, crx) = mpsc::unbounded_channel(); let (etx, mut erx) = mpsc::unbounded_channel(); drop(ctx); - let _final_phase = - run(runner, FakeForge::default(), spec, RunCtx::standalone(), crx, etx).await; + let _final_phase = run( + runner, + FakeForge::default(), + spec, + RunCtx::standalone(), + crx, + etx, + ) + .await; drain(&mut erx) } @@ -897,8 +964,16 @@ mod tests { ) .await; assert_eq!(phase, Phase::Done); - assert_eq!(discards.load(std::sync::atomic::Ordering::Relaxed), 1, "Done discards the volume"); - assert_eq!(teardowns.load(std::sync::atomic::Ordering::Relaxed), 0, "Done keeps no volume"); + assert_eq!( + discards.load(std::sync::atomic::Ordering::Relaxed), + 1, + "Done discards the volume" + ); + assert_eq!( + teardowns.load(std::sync::atomic::Ordering::Relaxed), + 0, + "Done keeps no volume" + ); } #[tokio::test] @@ -934,11 +1009,15 @@ mod tests { let evs = drain(&mut erx); // (a) the oracle was NOT re-run on resume assert!( - !evs.iter().any(|e| matches!(e.event, Event::OracleProposed { .. })), + !evs.iter() + .any(|e| matches!(e.event, Event::OracleProposed { .. })), "resume must not re-run the oracle" ); // (b) seq continues from start_seq (first emitted event has seq > 5) - assert!(evs.first().unwrap().seq > 5, "seq must continue from start_seq"); + assert!( + evs.first().unwrap().seq > 5, + "seq must continue from start_seq" + ); // (c) cost continues from start_cost (a metric reflects >= 0.5) let max_cost = evs .iter() @@ -947,9 +1026,16 @@ mod tests { _ => None, }) .fold(0.0, f64::max); - assert!(max_cost >= 0.5, "cost must continue from start_cost, got {max_cost}"); + assert!( + max_cost >= 0.5, + "cost must continue from start_cost, got {max_cost}" + ); // (d) the permit is released once the unit finishes - assert_eq!(permits.available_permits(), 1, "permit released at terminal"); + assert_eq!( + permits.available_permits(), + 1, + "permit released at terminal" + ); } #[tokio::test] @@ -963,8 +1049,8 @@ mod tests { // re-arms on resume without a live Spec phase. let good = vec!["test('x', () => assert(sum(2,3)===5))".to_string()]; let tampered = vec!["test('x', () => assert(true))".to_string()]; // gutted at build time - // Resumed unit: the oracle is already frozen, so the script has NO oracle - // call — just one build/check/review cycle (floor 1 opens the gate). + // Resumed unit: the oracle is already frozen, so the script has NO oracle + // call — just one build/check/review cycle (floor 1 opens the gate). let script = cycle(0); // Only ONE scripted read: the Checking-phase read (Spec is skipped on resume). let runner = FakeRunner::new(script).oracle_contents(vec![tampered]); @@ -993,13 +1079,20 @@ mod tests { let phase = loop { let e = erx.recv().await.expect("stream closed before NeedsHuman"); - if let Event::PhaseChanged { to: Phase::NeedsHuman, .. } = e.event { + if let Event::PhaseChanged { + to: Phase::NeedsHuman, + .. + } = e.event + { break Phase::NeedsHuman; } }; assert_eq!(phase, Phase::NeedsHuman); - ctx.send(Command::Abandon { cmd_id: "cleanup".into() }).unwrap(); + ctx.send(Command::Abandon { + cmd_id: "cleanup".into(), + }) + .unwrap(); let _ = handle.await; } @@ -1035,11 +1128,18 @@ mod tests { // Provisioning (no container/cost) while the only slot is held. loop { let e = erx.recv().await.unwrap(); - if matches!(&e.event, Event::Blocked { reason, .. } if reason == "awaiting concurrency slot") { + if matches!(&e.event, Event::Blocked { reason, .. } if reason == "awaiting concurrency slot") + { break; } assert!( - !matches!(e.event, Event::PhaseChanged { from: Phase::Provisioning, .. }), + !matches!( + e.event, + Event::PhaseChanged { + from: Phase::Provisioning, + .. + } + ), "must not leave Provisioning before acquiring a slot" ); } @@ -1048,7 +1148,11 @@ mod tests { // Free the slot → the driver acquires it and runs to completion. drop(held); assert_eq!(h.await.unwrap(), Phase::Done); - assert_eq!(permits.available_permits(), 1, "permit released at terminal"); + assert_eq!( + permits.available_permits(), + 1, + "permit released at terminal" + ); } #[tokio::test] @@ -1094,14 +1198,27 @@ mod tests { // Wait until the unit parks at AwaitingOracleApproval, accumulating events. let mut all = vec![]; loop { - let e = erx.recv().await.expect("stream closed before approval gate"); - let park = matches!(e.event, Event::PhaseChanged { to: Phase::AwaitingOracleApproval, .. }); + let e = erx + .recv() + .await + .expect("stream closed before approval gate"); + let park = matches!( + e.event, + Event::PhaseChanged { + to: Phase::AwaitingOracleApproval, + .. + } + ); all.push(e); if park { break; } } - ctx.send(Command::ApproveOracle { cmd_id: "c1".into(), edited_test_files: None }).unwrap(); + ctx.send(Command::ApproveOracle { + cmd_id: "c1".into(), + edited_test_files: None, + }) + .unwrap(); let final_phase = handle.await.unwrap(); assert_eq!(final_phase, Phase::Done); @@ -1126,7 +1243,10 @@ mod tests { #[tokio::test] async fn cap_breach_routes_to_needs_human() { // oracle is cheap; the first build blows the $0.5 cap. - let script = vec![FakeRunner::ok(0.1, &["test_a.rs"]), FakeRunner::ok(1.0, &["built"])]; + let script = vec![ + FakeRunner::ok(0.1, &["test_a.rs"]), + FakeRunner::ok(1.0, &["built"]), + ]; let (ctx, crx) = mpsc::unbounded_channel(); let (etx, mut erx) = mpsc::unbounded_channel(); @@ -1143,11 +1263,20 @@ mod tests { // It should park at NeedsHuman; then we abandon to terminate. loop { let e = erx.recv().await.expect("stream closed before cap breach"); - if matches!(e.event, Event::PhaseChanged { to: Phase::NeedsHuman, .. }) { + if matches!( + e.event, + Event::PhaseChanged { + to: Phase::NeedsHuman, + .. + } + ) { break; } } - ctx.send(Command::Abandon { cmd_id: "c2".into() }).unwrap(); + ctx.send(Command::Abandon { + cmd_id: "c2".into(), + }) + .unwrap(); let final_phase = handle.await.unwrap(); assert_eq!(final_phase, Phase::Failed); @@ -1160,7 +1289,10 @@ mod tests { let (etx, mut erx) = mpsc::unbounded_channel(); // Pre-queue a halt; the first agent-active phase (Spec) will pick it up. - ctx.send(Command::Halt { cmd_id: "h1".into() }).unwrap(); + ctx.send(Command::Halt { + cmd_id: "h1".into(), + }) + .unwrap(); let handle = tokio::spawn(run( FakeRunner::new(script), @@ -1173,11 +1305,20 @@ mod tests { loop { let e = erx.recv().await.expect("stream closed before halt"); - if matches!(e.event, Event::PhaseChanged { to: Phase::Halted, .. }) { + if matches!( + e.event, + Event::PhaseChanged { + to: Phase::Halted, + .. + } + ) { break; } } - ctx.send(Command::Abandon { cmd_id: "a1".into() }).unwrap(); + ctx.send(Command::Abandon { + cmd_id: "a1".into(), + }) + .unwrap(); assert_eq!(handle.await.unwrap(), Phase::Failed); } @@ -1190,7 +1331,10 @@ mod tests { #[tokio::test(start_paused = true)] async fn rate_limited_step_retries_then_succeeds() { // Oracle rate-limits once (signal on stderr), then succeeds; floor-1 cycle. - let mut script = vec![FakeRunner::rate_limited(), FakeRunner::ok(0.01, &["test_a.rs"])]; + let mut script = vec![ + FakeRunner::rate_limited(), + FakeRunner::ok(0.01, &["test_a.rs"]), + ]; script.extend(cycle(0)); let (ctx, crx) = mpsc::unbounded_channel(); let (etx, mut erx) = mpsc::unbounded_channel(); @@ -1204,7 +1348,11 @@ mod tests { etx, ) .await; - assert_eq!(final_phase, Phase::Done, "a single rate-limit is retried, not fatal"); + assert_eq!( + final_phase, + Phase::Done, + "a single rate-limit is retried, not fatal" + ); let evs = drain(&mut erx); // The retry surfaced a "rate limited" Blocked. assert!( @@ -1213,9 +1361,22 @@ mod tests { "a rate-limit retry emits a Blocked(\"rate limited\")" ); // The oracle Iteration{Review,0} was emitted exactly once (not per retry). - let oracle_iters = evs.iter().filter(|e| - matches!(e.event, Event::Iteration { kind: IterationKind::Review, n: 0 })).count(); - assert_eq!(oracle_iters, 1, "Iteration emitted once, before the retry loop"); + let oracle_iters = evs + .iter() + .filter(|e| { + matches!( + e.event, + Event::Iteration { + kind: IterationKind::Review, + n: 0 + } + ) + }) + .count(); + assert_eq!( + oracle_iters, 1, + "Iteration emitted once, before the retry loop" + ); } #[tokio::test(start_paused = true)] @@ -1242,12 +1403,22 @@ mod tests { // Drive virtual time forward until it parks at NeedsHuman. loop { let e = erx.recv().await.expect("stream closed before NeedsHuman"); - if matches!(e.event, Event::PhaseChanged { to: Phase::NeedsHuman, .. }) { + if matches!( + e.event, + Event::PhaseChanged { + to: Phase::NeedsHuman, + .. + } + ) { break; } } // Entry cleanup released the permit when parking. - assert_eq!(permits.available_permits(), 1, "permit released at NeedsHuman"); + assert_eq!( + permits.available_permits(), + 1, + "permit released at NeedsHuman" + ); ctx.send(Command::Abandon { cmd_id: "a".into() }).unwrap(); let _ = h.await; } @@ -1260,7 +1431,11 @@ mod tests { exit_code: 1, stdout: vec![], stderr: vec!["API Error: 429 rate limit exceeded".into()], - usage: Some(crate::runner::Usage { tokens_in: 0, tokens_out: 0, cost_usd: 1.0 }), + usage: Some(crate::runner::Usage { + tokens_in: 0, + tokens_out: 0, + cost_usd: 1.0, + }), }; let (ctx, crx) = mpsc::unbounded_channel(); let (etx, mut erx) = mpsc::unbounded_channel(); @@ -1275,15 +1450,28 @@ mod tests { let mut saw_rl_blocked = false; loop { let e = erx.recv().await.expect("stream closed before NeedsHuman"); - if matches!(&e.event, Event::Blocked { reason, .. } if reason == crate::retry::RL_REASON) { + if matches!(&e.event, Event::Blocked { reason, .. } if reason == crate::retry::RL_REASON) + { saw_rl_blocked = true; } - if let Event::PhaseChanged { to: Phase::NeedsHuman, reason, .. } = &e.event { - assert_eq!(reason.as_deref(), Some("usd cap"), "parked via CapBreach, not RetriesExhausted"); + if let Event::PhaseChanged { + to: Phase::NeedsHuman, + reason, + .. + } = &e.event + { + assert_eq!( + reason.as_deref(), + Some("usd cap"), + "parked via CapBreach, not RetriesExhausted" + ); break; } } - assert!(!saw_rl_blocked, "cap breach short-circuits before any backoff Blocked"); + assert!( + !saw_rl_blocked, + "cap breach short-circuits before any backoff Blocked" + ); ctx.send(Command::Abandon { cmd_id: "a".into() }).unwrap(); let _ = h.await; } @@ -1303,14 +1491,21 @@ mod tests { // Wait until it's backing off, then halt mid-wait. loop { let e = erx.recv().await.expect("closed before backoff"); - if matches!(&e.event, Event::Blocked { reason, .. } if reason == crate::retry::RL_REASON) { + if matches!(&e.event, Event::Blocked { reason, .. } if reason == crate::retry::RL_REASON) + { break; } } ctx.send(Command::Halt { cmd_id: "h".into() }).unwrap(); loop { let e = erx.recv().await.expect("closed before Halted"); - if matches!(e.event, Event::PhaseChanged { to: Phase::Halted, .. }) { + if matches!( + e.event, + Event::PhaseChanged { + to: Phase::Halted, + .. + } + ) { break; } } @@ -1333,7 +1528,8 @@ mod tests { // Wait for the first backoff, then send a NON-Halt command into the wait. loop { let e = erx.recv().await.expect("closed before backoff"); - if matches!(&e.event, Event::Blocked { reason, .. } if reason == crate::retry::RL_REASON) { + if matches!(&e.event, Event::Blocked { reason, .. } if reason == crate::retry::RL_REASON) + { break; } } @@ -1345,11 +1541,20 @@ mod tests { if matches!(&e.event, Event::Error { detail, .. } if detail.contains("not valid")) { saw_invalid = true; } - if matches!(e.event, Event::PhaseChanged { to: Phase::NeedsHuman, .. }) { + if matches!( + e.event, + Event::PhaseChanged { + to: Phase::NeedsHuman, + .. + } + ) { break; } } - assert!(saw_invalid, "a non-Halt command during backoff emits a 'not valid' error"); + assert!( + saw_invalid, + "a non-Halt command during backoff emits a 'not valid' error" + ); ctx.send(Command::Abandon { cmd_id: "a".into() }).unwrap(); let _ = h.await; } @@ -1377,7 +1582,7 @@ mod tests { let tampered = vec!["test('x', () => assert(true))".to_string()]; // gutted at build time let mut script = vec![FakeRunner::ok(0.01, &["lru.test.js"])]; // oracle step script.extend(cycle(0)); // one clean build/check/review round (floor 1) - // oracle read #1 (Spec freeze) = good; read #2 (Checking) = tampered + // oracle read #1 (Spec freeze) = good; read #2 (Checking) = tampered let runner = FakeRunner::new(script).oracle_contents(vec![good, tampered]); let (ctx, crx) = mpsc::unbounded_channel(); let (etx, mut erx) = mpsc::unbounded_channel(); @@ -1393,13 +1598,20 @@ mod tests { let phase = loop { let e = erx.recv().await.expect("stream closed before NeedsHuman"); - if let Event::PhaseChanged { to: Phase::NeedsHuman, .. } = e.event { + if let Event::PhaseChanged { + to: Phase::NeedsHuman, + .. + } = e.event + { break Phase::NeedsHuman; } }; assert_eq!(phase, Phase::NeedsHuman); - ctx.send(Command::Abandon { cmd_id: "cleanup".into() }).unwrap(); + ctx.send(Command::Abandon { + cmd_id: "cleanup".into(), + }) + .unwrap(); let _ = handle.await; } @@ -1450,13 +1662,20 @@ mod tests { let phase = loop { let e = erx.recv().await.expect("stream closed before NeedsHuman"); - if let Event::PhaseChanged { to: Phase::NeedsHuman, .. } = e.event { + if let Event::PhaseChanged { + to: Phase::NeedsHuman, + .. + } = e.event + { break Phase::NeedsHuman; } }; assert_eq!(phase, Phase::NeedsHuman); - ctx.send(Command::Abandon { cmd_id: "cleanup".into() }).unwrap(); + ctx.send(Command::Abandon { + cmd_id: "cleanup".into(), + }) + .unwrap(); let _ = handle.await; } @@ -1495,14 +1714,24 @@ mod tests { saw_empty_error = true; } } - if let Event::PhaseChanged { to: Phase::NeedsHuman, .. } = e.event { + if let Event::PhaseChanged { + to: Phase::NeedsHuman, + .. + } = e.event + { break Phase::NeedsHuman; } }; assert_eq!(phase, Phase::NeedsHuman); - assert!(saw_empty_error, "expected an empty-oracle Error event before parking"); + assert!( + saw_empty_error, + "expected an empty-oracle Error event before parking" + ); - ctx.send(Command::Abandon { cmd_id: "cleanup".into() }).unwrap(); + ctx.send(Command::Abandon { + cmd_id: "cleanup".into(), + }) + .unwrap(); let _ = handle.await; } } diff --git a/crates/fleetd/src/fake.rs b/crates/fleetd/src/fake.rs index 4ee5003..dc23038 100644 --- a/crates/fleetd/src/fake.rs +++ b/crates/fleetd/src/fake.rs @@ -82,7 +82,11 @@ impl FakeRunner { exit_code: 0, stdout: stdout.iter().map(|s| s.to_string()).collect(), stderr: vec![], - usage: Some(Usage { tokens_in: 100, tokens_out: 10, cost_usd }), + usage: Some(Usage { + tokens_in: 100, + tokens_out: 10, + cost_usd, + }), } } @@ -92,7 +96,10 @@ impl FakeRunner { exit_code: 1, stdout: vec![], stderr: vec![], - usage: Some(Usage { cost_usd, ..Default::default() }), + usage: Some(Usage { + cost_usd, + ..Default::default() + }), } } @@ -111,7 +118,9 @@ impl FakeRunner { #[async_trait] impl Runner for FakeRunner { async fn provision(&self, _spec: &UnitSpec) -> Result { - Ok(Handle { id: "fake-container".into() }) + Ok(Handle { + id: "fake-container".into(), + }) } async fn exec( @@ -172,7 +181,11 @@ impl Runner for FakeRunner { // Pop the next scripted read; default to a STABLE constant so unscripted tests // see an unchanged oracle and never trip tamper detection. let mut q = self.oracle_reads.lock().unwrap(); - Ok(if q.is_empty() { vec!["".to_string()] } else { q.remove(0) }) + Ok(if q.is_empty() { + vec!["".to_string()] + } else { + q.remove(0) + }) } } @@ -184,7 +197,10 @@ pub struct FakeForge { impl Default for FakeForge { fn default() -> Self { - Self { merge: MergeResult::Clean, mergeable: Mergeability::Mergeable } + Self { + merge: MergeResult::Clean, + mergeable: Mergeability::Mergeable, + } } } diff --git a/crates/fleetd/src/gh_forge.rs b/crates/fleetd/src/gh_forge.rs index 1d72cc6..e90ce24 100644 --- a/crates/fleetd/src/gh_forge.rs +++ b/crates/fleetd/src/gh_forge.rs @@ -37,7 +37,11 @@ impl GhForge { if self.host_clone.join(".git").is_dir() { return Ok(()); } - run("git", &["clone", &self.repo_url, &self.host_clone.to_string_lossy()]).await?; + run( + "git", + &["clone", &self.repo_url, &self.host_clone.to_string_lossy()], + ) + .await?; Ok(()) } @@ -55,11 +59,25 @@ impl Forge for GhForge { let bundle = bundle.to_string_lossy().into_owned(); // Import the agent branch from the bundle and refresh the base. - run("git", &["-C", &dir, "fetch", &bundle, &format!("{branch}:{branch}")]).await?; + run( + "git", + &["-C", &dir, "fetch", &bundle, &format!("{branch}:{branch}")], + ) + .await?; run("git", &["-C", &dir, "fetch", "origin", &self.base_branch]).await?; // Trial-merge the agent branch onto a fresh base; clean exit == mergeable. - run("git", &["-C", &dir, "checkout", "-f", &format!("origin/{}", self.base_branch)]).await?; + run( + "git", + &[ + "-C", + &dir, + "checkout", + "-f", + &format!("origin/{}", self.base_branch), + ], + ) + .await?; let merged = run_status( "git", &["-C", &dir, "merge", "--no-commit", "--no-ff", branch], @@ -68,13 +86,21 @@ impl Forge for GhForge { // Always abort/clean the trial, regardless of outcome. let _ = run_status("git", &["-C", &dir, "merge", "--abort"]).await; - Ok(if merged { MergeResult::Clean } else { MergeResult::Conflict }) + Ok(if merged { + MergeResult::Clean + } else { + MergeResult::Conflict + }) } async fn open_pr(&self, branch: &str) -> Result { guard_branch(branch)?; let dir = self.git_dir(); - run("git", &["-C", &dir, "push", "origin", &format!("{branch}:{branch}")]).await?; + run( + "git", + &["-C", &dir, "push", "origin", &format!("{branch}:{branch}")], + ) + .await?; let url = run( "gh", &[ @@ -97,7 +123,19 @@ impl Forge for GhForge { } async fn poll_mergeable(&self, pr_url: &str) -> Result { - let out = run("gh", &["pr", "view", pr_url, "--json", "mergeable", "-q", ".mergeable"]).await?; + let out = run( + "gh", + &[ + "pr", + "view", + pr_url, + "--json", + "mergeable", + "-q", + ".mergeable", + ], + ) + .await?; Ok(map_mergeable(out.trim())) } } @@ -116,7 +154,8 @@ fn guard_branch(b: &str) -> Result<(), ForgeError> { let ok = !b.is_empty() && !b.starts_with('-') && !b.contains("..") - && b.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-')); + && b.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-')); if ok { Ok(()) } else { diff --git a/crates/fleetd/src/local_docker.rs b/crates/fleetd/src/local_docker.rs index 30b5980..0fbb7eb 100644 --- a/crates/fleetd/src/local_docker.rs +++ b/crates/fleetd/src/local_docker.rs @@ -17,7 +17,9 @@ pub struct LocalDockerRunner { impl LocalDockerRunner { pub fn new(image: impl Into) -> Self { - Self { image: image.into() } + Self { + image: image.into(), + } } fn container_name(unit_id: &str) -> String { @@ -31,7 +33,12 @@ impl LocalDockerRunner { /// `docker exec -w `, failing on non-zero exit. async fn exec_in(name: &str, workdir: &str, argv: &[&str]) -> Result<(), RunnerError> { - let mut a = vec!["exec".to_string(), "-w".to_string(), workdir.to_string(), name.to_string()]; + let mut a = vec![ + "exec".to_string(), + "-w".to_string(), + workdir.to_string(), + name.to_string(), + ]; a.extend(argv.iter().map(|s| s.to_string())); docker_ok(a).await.map(|_| ()) } @@ -52,13 +59,20 @@ fn valid_git_branch(b: &str) -> bool { !b.is_empty() && !b.starts_with('-') && !b.contains("..") - && b.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-')) + && b.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-')) } /// Keep only docker-name-safe characters. fn sanitize(s: &str) -> String { s.chars() - .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '_' }) + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { + c + } else { + '_' + } + }) .collect() } @@ -80,7 +94,9 @@ async fn docker(args: Vec) -> Result<(i32, String, String), RunnerError> async fn docker_ok(args: Vec) -> Result { let (code, out, err) = docker(args.clone()).await?; if code != 0 { - return Err(RunnerError::Failed(format!("docker {args:?} exited {code}: {err}"))); + return Err(RunnerError::Failed(format!( + "docker {args:?} exited {code}: {err}" + ))); } Ok(out) } @@ -114,14 +130,19 @@ impl Runner for LocalDockerRunner { // Validate inputs that flow into git as args (no shell is used). if !valid_repo_url(&spec.repo_url) { - return Err(RunnerError::Failed(format!("unsafe repo url: {:?}", spec.repo_url))); + return Err(RunnerError::Failed(format!( + "unsafe repo url: {:?}", + spec.repo_url + ))); } if !valid_git_branch(&spec.base_branch) || !valid_git_branch(&spec.branch) { return Err(RunnerError::Failed("unsafe branch name".into())); } // Reuse detection: a persisted volume already has the repo (true resume). - let reused = exec_in(&name, "/work", &["test", "-d", "repo/.git"]).await.is_ok(); + let reused = exec_in(&name, "/work", &["test", "-d", "repo/.git"]) + .await + .is_ok(); if !reused { exec_in(&name, "/work", &["git", "clone", &spec.repo_url, "repo"]).await?; @@ -139,10 +160,20 @@ impl Runner for LocalDockerRunner { // Continue the persisted work: clear a crash-left lock and reset the // branch ref over the existing (possibly dirty) tree. `-b` would fail. let _ = exec_in(&name, "/work/repo", &["rm", "-f", ".git/index.lock"]).await; - exec_in(&name, "/work/repo", &["git", "checkout", "-B", &spec.branch]).await?; + exec_in( + &name, + "/work/repo", + &["git", "checkout", "-B", &spec.branch], + ) + .await?; } else { exec_in(&name, "/work/repo", &["git", "checkout", &spec.base_branch]).await?; - exec_in(&name, "/work/repo", &["git", "checkout", "-b", &spec.branch]).await?; + exec_in( + &name, + "/work/repo", + &["git", "checkout", "-b", &spec.branch], + ) + .await?; } Ok(Handle { id: name }) @@ -154,7 +185,12 @@ impl Runner for LocalDockerRunner { workdir: &str, argv: &[String], ) -> Result { - let mut args = vec!["exec".to_string(), "-w".to_string(), workdir.to_string(), handle.id.clone()]; + let mut args = vec![ + "exec".to_string(), + "-w".to_string(), + workdir.to_string(), + handle.id.clone(), + ]; args.extend(argv.iter().cloned()); let (code, out, err) = docker(args).await?; Ok(ExecOutput { @@ -173,7 +209,11 @@ impl Runner for LocalDockerRunner { handle.id.clone(), ]) .await?; - Ok(if code == 0 && out.trim() == "true" { Liveness::Alive } else { Liveness::Stalled }) + Ok(if code == 0 && out.trim() == "true" { + Liveness::Alive + } else { + Liveness::Stalled + }) } async fn commit_all(&self, handle: &Handle, message: &str) -> Result { @@ -194,7 +234,12 @@ impl Runner for LocalDockerRunner { Ok(code == 0) } - async fn has_diff(&self, handle: &Handle, base: &str, branch: &str) -> Result { + async fn has_diff( + &self, + handle: &Handle, + base: &str, + branch: &str, + ) -> Result { if !valid_git_branch(base) || !valid_git_branch(branch) { return Err(RunnerError::Failed("unsafe ref name".into())); } @@ -218,7 +263,9 @@ impl Runner for LocalDockerRunner { // pass it as its own argv element (no `sh -c` interpolation) to avoid // command/option injection once `branch` becomes unit-derived. if !valid_git_branch(branch) { - return Err(RunnerError::Failed(format!("unsafe branch name: {branch:?}"))); + return Err(RunnerError::Failed(format!( + "unsafe branch name: {branch:?}" + ))); } // Complete, self-contained bundle (Spike 1: no prerequisites). docker_ok(vec![ @@ -257,7 +304,12 @@ impl Runner for LocalDockerRunner { if code != 0 { return Ok(vec![]); } - Ok(out.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect()) + Ok(out + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(String::from) + .collect()) } async fn teardown(&self, handle: &Handle) -> Result<(), RunnerError> { @@ -276,7 +328,12 @@ impl Runner for LocalDockerRunner { async fn reap_unit(&self, unit_id: &str) -> Result<(), RunnerError> { // Reap by unit-id, keeping the volume (resume after restart). - let _ = docker(vec!["rm".into(), "-f".into(), Self::container_name(unit_id)]).await; + let _ = docker(vec![ + "rm".into(), + "-f".into(), + Self::container_name(unit_id), + ]) + .await; Ok(()) } diff --git a/crates/fleetd/src/planner.rs b/crates/fleetd/src/planner.rs index 053ee2c..e904209 100644 --- a/crates/fleetd/src/planner.rs +++ b/crates/fleetd/src/planner.rs @@ -31,9 +31,15 @@ pub struct FakePlanner { } impl FakePlanner { pub fn ok(lanes: Vec, cost_usd: f64) -> Self { - Self { outcome: Ok(PlanOutcome { lanes, cost_usd }) } + Self { + outcome: Ok(PlanOutcome { lanes, cost_usd }), + } + } + pub fn err(msg: &str) -> Self { + Self { + outcome: Err(msg.into()), + } } - pub fn err(msg: &str) -> Self { Self { outcome: Err(msg.into()) } } } #[async_trait] impl Planner for FakePlanner { @@ -51,8 +57,16 @@ impl Planner for FakePlanner { /// Real planner: a read-only Claude call that emits a JSON lane array. Cost is /// parsed from the CLI `result` record via `crate::claude_meter`. pub struct ClaudePlanner; -impl ClaudePlanner { pub fn new() -> Self { Self } } -impl Default for ClaudePlanner { fn default() -> Self { Self::new() } } +impl ClaudePlanner { + pub fn new() -> Self { + Self + } +} +impl Default for ClaudePlanner { + fn default() -> Self { + Self::new() + } +} #[async_trait] impl Planner for ClaudePlanner { @@ -63,20 +77,47 @@ impl Planner for ClaudePlanner { {{\"title\":..,\"task\":..,\"rationale\":..}}. Spec:\n\n{doc}" ); let out = tokio::process::Command::new("claude") - .args(["-p", &prompt, "--output-format", "stream-json", "--max-budget-usd", "1.0"]) - .output().await.map_err(|e| PlanError::Failed(e.to_string()))?; + .args([ + "-p", + &prompt, + "--output-format", + "stream-json", + "--max-budget-usd", + "1.0", + ]) + .output() + .await + .map_err(|e| PlanError::Failed(e.to_string()))?; let stdout = String::from_utf8_lossy(&out.stdout); // Reuse the existing meter: parse_usage(&[String]) -> Option; Usage.cost_usd. let lines: Vec = stdout.lines().map(|l| l.to_string()).collect(); - let cost = crate::claude_meter::parse_usage(&lines).map(|u| u.cost_usd).unwrap_or(0.0); - let json_slice = extract_json_array(&stdout).ok_or_else(|| PlanError::Failed("no JSON array".into()))?; + let cost = crate::claude_meter::parse_usage(&lines) + .map(|u| u.cost_usd) + .unwrap_or(0.0); + let json_slice = + extract_json_array(&stdout).ok_or_else(|| PlanError::Failed("no JSON array".into()))?; #[derive(serde::Deserialize)] - struct RawLane { title: String, task: String, #[serde(default)] rationale: String } + struct RawLane { + title: String, + task: String, + #[serde(default)] + rationale: String, + } let raw: Vec = serde_json::from_str(json_slice) .map_err(|e| PlanError::Failed(format!("bad JSON: {e}")))?; - let lanes = raw.into_iter().take(lane_cap) - .map(|r| Lane { title: r.title, task: r.task, rationale: r.rationale }).collect(); - Ok(PlanOutcome { lanes, cost_usd: cost }) + let lanes = raw + .into_iter() + .take(lane_cap) + .map(|r| Lane { + title: r.title, + task: r.task, + rationale: r.rationale, + }) + .collect(); + Ok(PlanOutcome { + lanes, + cost_usd: cost, + }) } } @@ -84,7 +125,11 @@ impl Planner for ClaudePlanner { fn extract_json_array(s: &str) -> Option<&str> { let start = s.find('[')?; let end = s.rfind(']')?; - if end > start { Some(&s[start..=end]) } else { None } + if end > start { + Some(&s[start..=end]) + } else { + None + } } #[cfg(test)] @@ -94,8 +139,16 @@ mod tests { #[tokio::test] async fn fake_planner_clamps_to_lane_cap_and_can_error() { let lanes = vec![ - Lane { title: "a".into(), task: "ta".into(), rationale: "r".into() }, - Lane { title: "b".into(), task: "tb".into(), rationale: "r".into() }, + Lane { + title: "a".into(), + task: "ta".into(), + rationale: "r".into(), + }, + Lane { + title: "b".into(), + task: "tb".into(), + rationale: "r".into(), + }, ]; let p = FakePlanner::ok(lanes, 0.5); let out = p.plan("doc", 1).await.unwrap(); diff --git a/crates/fleetd/src/reconcile.rs b/crates/fleetd/src/reconcile.rs index fef6407..26270e4 100644 --- a/crates/fleetd/src/reconcile.rs +++ b/crates/fleetd/src/reconcile.rs @@ -102,7 +102,10 @@ mod tests { let actions = reconcile(&["x".into(), "y".into()], &[]); assert_eq!( actions, - vec![Action::HaltNoContainer("x".into()), Action::HaltNoContainer("y".into())] + vec![ + Action::HaltNoContainer("x".into()), + Action::HaltNoContainer("y".into()) + ] ); } @@ -116,9 +119,19 @@ mod tests { &["u1".into()], // live drivers &["u1".into(), "u3".into()], // running containers ); - assert_eq!(actions.len(), 2, "healthy u1 must produce no action: {actions:?}"); - assert!(actions.contains(&Action::HaltNoContainer("u2".into())), "stranded u2 halted"); - assert!(actions.contains(&Action::ReapStray("u3".into())), "stray u3 reaped"); + assert_eq!( + actions.len(), + 2, + "healthy u1 must produce no action: {actions:?}" + ); + assert!( + actions.contains(&Action::HaltNoContainer("u2".into())), + "stranded u2 halted" + ); + assert!( + actions.contains(&Action::ReapStray("u3".into())), + "stray u3 reaped" + ); assert!( !actions.iter().any(|a| matches!(a, Action::HaltWithContainer(x) | Action::HaltNoContainer(x) | Action::ReapStray(x) @@ -137,8 +150,14 @@ mod tests { #[test] fn steady_state_no_drift_is_a_no_op() { // Every non-terminal unit is live and has its container; nothing to do. - let actions = - reconcile_live(&["u1".into(), "u2".into()], &["u1".into(), "u2".into()], &["u1".into(), "u2".into()]); - assert!(actions.is_empty(), "steady state with no drift yields no actions: {actions:?}"); + let actions = reconcile_live( + &["u1".into(), "u2".into()], + &["u1".into(), "u2".into()], + &["u1".into(), "u2".into()], + ); + assert!( + actions.is_empty(), + "steady state with no drift yields no actions: {actions:?}" + ); } } diff --git a/crates/fleetd/src/retry.rs b/crates/fleetd/src/retry.rs index f6a99e4..03489dc 100644 --- a/crates/fleetd/src/retry.rs +++ b/crates/fleetd/src/retry.rs @@ -21,8 +21,14 @@ pub enum StepOutcome { /// Case-insensitive substrings that mark an Anthropic throttle (Task-1 spike may /// extend these). Only consulted on a NON-ZERO exit, so a clean run is never a /// false positive. -const RL_PATTERNS: &[&str] = - &["rate limit", "rate_limit", "overloaded", "429", "529", "usage limit"]; +const RL_PATTERNS: &[&str] = &[ + "rate limit", + "rate_limit", + "overloaded", + "429", + "529", + "usage limit", +]; /// Classify an exec output. Conservative: `Ok` unless a non-zero exit carries a /// recognized rate-limit signal on stdout or stderr. @@ -30,14 +36,10 @@ pub fn classify(out: &ExecOutput) -> StepOutcome { if out.exit_code == 0 { return StepOutcome::Ok; } - let hit = out - .stdout - .iter() - .chain(out.stderr.iter()) - .any(|line| { - let l = line.to_lowercase(); - RL_PATTERNS.iter().any(|p| l.contains(p)) - }); + let hit = out.stdout.iter().chain(out.stderr.iter()).any(|line| { + let l = line.to_lowercase(); + RL_PATTERNS.iter().any(|p| l.contains(p)) + }); if hit { StepOutcome::RateLimited } else { @@ -55,7 +57,11 @@ pub struct Backoff { impl Backoff { pub fn new(base_secs: u64, cap_secs: u64) -> Self { - Self { attempt: 0, base_secs, cap_secs } + Self { + attempt: 0, + base_secs, + cap_secs, + } } pub fn next_delay(&mut self) -> Duration { @@ -73,19 +79,28 @@ pub fn wall_clock_exceeded(elapsed: Duration, rl_elapsed: Duration, cap_secs: u6 } fn env_secs(key: &str, default: u64) -> u64 { - std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default) + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) } /// Backoff/envelope knobs (env-tunable; defaults chosen for SP1). -pub fn rl_base_secs() -> u64 { env_secs("CC_RL_BASE_SECS", 2) } -pub fn rl_cap_secs() -> u64 { env_secs("CC_RL_CAP_SECS", 300) } -pub fn rl_max_wait_secs() -> u64 { env_secs("CC_RL_MAX_WAIT_SECS", 3600) } +pub fn rl_base_secs() -> u64 { + env_secs("CC_RL_BASE_SECS", 2) +} +pub fn rl_cap_secs() -> u64 { + env_secs("CC_RL_CAP_SECS", 300) +} +pub fn rl_max_wait_secs() -> u64 { + env_secs("CC_RL_MAX_WAIT_SECS", 3600) +} #[cfg(test)] mod tests { use super::*; - use std::time::Duration; use crate::runner::Usage; + use std::time::Duration; fn out(code: i32, stdout: &[&str], stderr: &[&str]) -> ExecOutput { ExecOutput { @@ -110,24 +125,42 @@ mod tests { #[test] fn overloaded_on_stdout_is_detected() { - assert_eq!(classify(&out(1, &["Error: Overloaded (529)"], &[])), StepOutcome::RateLimited); + assert_eq!( + classify(&out(1, &["Error: Overloaded (529)"], &[])), + StepOutcome::RateLimited + ); } #[test] fn unrelated_nonzero_exit_is_ok() { // e.g. a timeout-kill (124) or a normal agent failure — preserved as today. - assert_eq!(classify(&out(124, &["compilation failed"], &[])), StepOutcome::Ok); + assert_eq!( + classify(&out(124, &["compilation failed"], &[])), + StepOutcome::Ok + ); } #[test] fn wall_clock_exempts_rate_limit_time() { let cap = 30; // 30s cap - // 100s elapsed but 80s of it was rate-limit waiting => 20s effective <= 30s. - assert!(!wall_clock_exceeded(Duration::from_secs(100), Duration::from_secs(80), cap)); + // 100s elapsed but 80s of it was rate-limit waiting => 20s effective <= 30s. + assert!(!wall_clock_exceeded( + Duration::from_secs(100), + Duration::from_secs(80), + cap + )); // Same elapsed, no exemption => 100s > 30s => exceeded. - assert!(wall_clock_exceeded(Duration::from_secs(100), Duration::ZERO, cap)); + assert!(wall_clock_exceeded( + Duration::from_secs(100), + Duration::ZERO, + cap + )); // cap 0 disables the check entirely. - assert!(!wall_clock_exceeded(Duration::from_secs(9999), Duration::ZERO, 0)); + assert!(!wall_clock_exceeded( + Duration::from_secs(9999), + Duration::ZERO, + 0 + )); } #[test] diff --git a/crates/fleetd/src/runner.rs b/crates/fleetd/src/runner.rs index 7d747ec..1b94897 100644 --- a/crates/fleetd/src/runner.rs +++ b/crates/fleetd/src/runner.rs @@ -96,11 +96,7 @@ pub trait Runner: Send + Sync { branch: &str, ) -> Result; /// `git bundle` the branch and `docker cp` it to a host path (Spike 1). - async fn export_bundle( - &self, - handle: &Handle, - branch: &str, - ) -> Result; + async fn export_bundle(&self, handle: &Handle, branch: &str) -> Result; /// Unit-ids of currently-running containers labeled `cc.unit_id` (for startup /// reconciliation of orphans after a daemon restart). async fn list_unit_containers(&self) -> Result, RunnerError>; diff --git a/crates/fleetd/src/server.rs b/crates/fleetd/src/server.rs index 8c3f4c2..146461a 100644 --- a/crates/fleetd/src/server.rs +++ b/crates/fleetd/src/server.rs @@ -22,8 +22,8 @@ use crate::fake::{FakeForge, FakeRunner}; use crate::planner::Planner; use crate::reconcile::{reconcile, reconcile_live, Action}; use crate::runner::{ExecOutput, Runner, UnitSpec}; -use crate::swarm::{admit_lanes, slug, AdmissionConfig, LaneDecision}; use crate::store::{Store, UnitRow}; +use crate::swarm::{admit_lanes, slug, AdmissionConfig, LaneDecision}; use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, @@ -45,11 +45,18 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{broadcast, mpsc, Semaphore}; fn now_ms() -> i64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as i64 + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as i64 } fn phase_str(p: &Phase) -> String { - serde_json::to_value(p).unwrap().as_str().unwrap_or("unknown").to_string() + serde_json::to_value(p) + .unwrap() + .as_str() + .unwrap_or("unknown") + .to_string() } /// Per-unit live state held by the server (for broadcast/commands). Event @@ -79,24 +86,44 @@ const DEFAULT_MAX_CONCURRENT: usize = 3; const DEFAULT_GLOBAL_USD_CAP: f64 = 20.0; fn env_usize(key: &str, default: usize) -> usize { - std::env::var(key).ok().and_then(|v| v.parse().ok()).filter(|&n| n > 0).unwrap_or(default) + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .filter(|&n| n > 0) + .unwrap_or(default) } fn env_f64(key: &str, default: f64) -> f64 { - std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default) + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) } impl AppState { pub fn new(store: Arc>) -> Self { let max_concurrent = env_usize("CC_MAX_CONCURRENT", DEFAULT_MAX_CONCURRENT); - let next = store.lock().unwrap().max_unit_seq().expect("seed next_id from max_unit_seq") + 1; - let next_sw = store.lock().unwrap().max_swarm_seq().expect("seed next_swarm from max_swarm_seq") + 1; + let next = store + .lock() + .unwrap() + .max_unit_seq() + .expect("seed next_id from max_unit_seq") + + 1; + let next_sw = store + .lock() + .unwrap() + .max_swarm_seq() + .expect("seed next_swarm from max_swarm_seq") + + 1; Self { units: Arc::new(Mutex::new(HashMap::new())), next_id: Arc::new(AtomicU64::new(next)), next_swarm: Arc::new(AtomicU64::new(next_sw)), store, // Start "stale" so the first /health does a real probe. - docker: Arc::new(Mutex::new((Instant::now() - Duration::from_secs(60), false))), + docker: Arc::new(Mutex::new(( + Instant::now() - Duration::from_secs(60), + false, + ))), permits: Arc::new(Semaphore::new(max_concurrent)), global_cap: env_f64("CC_GLOBAL_USD_CAP", DEFAULT_GLOBAL_USD_CAP), } @@ -105,7 +132,9 @@ impl AppState { impl Default for AppState { fn default() -> Self { - Self::new(Arc::new(Mutex::new(Store::open_memory().expect("memory store")))) + Self::new(Arc::new(Mutex::new( + Store::open_memory().expect("memory store"), + ))) } } @@ -221,8 +250,12 @@ pub enum SpawnError { /// Register the per-unit handle and spawn its driver. The row must already be /// persisted by the caller (create_mission, or fan-out via /// commit_lane_unit). Returns Err if a driver already exists (no double-spawn). -fn spawn_driver_for(st: &AppState, spec: UnitSpec, mode: &str, unit_id: &str) - -> Result<(), SpawnError> { +fn spawn_driver_for( + st: &AppState, + spec: UnitSpec, + mode: &str, + unit_id: &str, +) -> Result<(), SpawnError> { let (cmd_rx, evt_tx) = match register_unit_if_absent(st, unit_id) { Some(ch) => ch, None => return Err(SpawnError::AlreadyRegistered), @@ -230,15 +263,34 @@ fn spawn_driver_for(st: &AppState, spec: UnitSpec, mode: &str, unit_id: &str) match mode { "demo" => { let runner = FakeRunner::new(demo_script(&spec)); - tokio::spawn(run(runner, FakeForge::default(), spec, fresh_ctx(st), cmd_rx, evt_tx)); + tokio::spawn(run( + runner, + FakeForge::default(), + spec, + fresh_ctx(st), + cmd_rx, + evt_tx, + )); } _ => { use crate::gh_forge::GhForge; use crate::local_docker::LocalDockerRunner; let host_clone = std::env::temp_dir().join(format!("cc-host-{unit_id}")); - let forge = GhForge::new(spec.repo_url.clone(), spec.repo_slug.clone(), - spec.base_branch.clone(), host_clone, format!("command-center SP1: {unit_id}")); - tokio::spawn(run(LocalDockerRunner::new("cc-agent:dev"), forge, spec, fresh_ctx(st), cmd_rx, evt_tx)); + let forge = GhForge::new( + spec.repo_url.clone(), + spec.repo_slug.clone(), + spec.base_branch.clone(), + host_clone, + format!("command-center SP1: {unit_id}"), + ); + tokio::spawn(run( + LocalDockerRunner::new("cc-agent:dev"), + forge, + spec, + fresh_ctx(st), + cmd_rx, + evt_tx, + )); } } Ok(()) @@ -256,7 +308,9 @@ async fn create_mission( task: req.task, usd_cap: 5.0, wall_clock_secs: 1800, - gate: GateConfig { min_review_rounds: req.min_review_rounds.max(1) }, + gate: GateConfig { + min_review_rounds: req.min_review_rounds.max(1), + }, repo_url: "https://github.com/adbarc92/command-center-agent-sandbox".into(), repo_slug: "adbarc92/command-center-agent-sandbox".into(), base_branch: "main".into(), @@ -283,7 +337,10 @@ async fn create_mission( let s = st.store.lock().unwrap(); let since = now_ms() - 24 * 3600 * 1000; if s.committed_spend(since).unwrap_or(0.0) >= st.global_cap { - return Err((StatusCode::TOO_MANY_REQUESTS, "global daily cost cap reached".into())); + return Err(( + StatusCode::TOO_MANY_REQUESTS, + "global daily cost cap reached".into(), + )); } let mut row = row_from_spec(&spec, &runner_mode); row.swarm_id = None; @@ -304,7 +361,10 @@ async fn create_mission( fn register_unit_if_absent( st: &AppState, unit_id: &str, -) -> Option<(mpsc::UnboundedReceiver, mpsc::UnboundedSender)> { +) -> Option<( + mpsc::UnboundedReceiver, + mpsc::UnboundedSender, +)> { let mut units = st.units.lock().unwrap(); if units.contains_key(unit_id) { return None; @@ -375,7 +435,9 @@ fn spec_from_row(r: &UnitRow) -> UnitSpec { task: r.task.clone(), usd_cap: r.usd_cap, wall_clock_secs: r.wall_clock_secs, - gate: GateConfig { min_review_rounds: r.min_review_rounds.max(1) }, + gate: GateConfig { + min_review_rounds: r.min_review_rounds.max(1), + }, repo_url: r.repo_url.clone(), repo_slug: r.repo_slug.clone(), base_branch: r.base_branch.clone(), @@ -419,8 +481,15 @@ fn spawn_forwarder( { let s = store.lock().unwrap(); let _ = s.append_event(&unit_id, env.seq, ts, &json); - let _ = s.update_unit(&unit_id, &cur_phase, cur_cost, env.seq, - terminal_reason.as_deref(), oracle_hash.as_deref(), ts); + let _ = s.update_unit( + &unit_id, + &cur_phase, + cur_cost, + env.seq, + terminal_reason.as_deref(), + oracle_hash.as_deref(), + ts, + ); } let _ = bcast.send(env); } @@ -468,7 +537,11 @@ async fn get_unit( Path(id): Path, ) -> Result, StatusCode> { let s = st.store.lock().unwrap(); - let row = s.get_unit(&id).ok().flatten().ok_or(StatusCode::NOT_FOUND)?; + let row = s + .get_unit(&id) + .ok() + .flatten() + .ok_or(StatusCode::NOT_FOUND)?; let events = s .events_since(&id, 0) .unwrap_or_default() @@ -542,7 +615,12 @@ async fn stream_to_socket(mut socket: WebSocket, id: String, since: u64, st: App let (replay, mut rx) = { let units = st.units.lock().unwrap(); let rx = units.get(&id).map(|h| h.bcast.subscribe()); - let replay = st.store.lock().unwrap().events_since(&id, since).unwrap_or_default(); + let replay = st + .store + .lock() + .unwrap() + .events_since(&id, since) + .unwrap_or_default(); (replay, rx) }; @@ -580,7 +658,10 @@ async fn stream_to_socket(mut socket: WebSocket, id: String, since: u64, st: App } fn seq_of(json: &str) -> Option { - serde_json::from_str::(json).ok()?.get("seq")?.as_u64() + serde_json::from_str::(json) + .ok()? + .get("seq")? + .as_u64() } #[derive(Serialize)] @@ -645,12 +726,29 @@ pub async fn reconcile_on_startup(state: &AppState, runner: &R) { } // Swarm reconcile: planning → failed; fanning_out → resume missing lanes. - let swarms = state.store.lock().unwrap().list_swarms().unwrap_or_default(); + let swarms = state + .store + .lock() + .unwrap() + .list_swarms() + .unwrap_or_default(); for sw in swarms { match sw.status.as_str() { "planning" => { - state.store.lock().unwrap().update_swarm(&sw.swarm_id, "failed", sw.planner_cost, - sw.lanes_launched, sw.lanes_dropped, Some("daemon restarted during planning"), now_ms()).ok(); + state + .store + .lock() + .unwrap() + .update_swarm( + &sw.swarm_id, + "failed", + sw.planner_cost, + sw.lanes_launched, + sw.lanes_dropped, + Some("daemon restarted during planning"), + now_ms(), + ) + .ok(); } "fanning_out" => resume_fan_out(state, &sw), _ => {} @@ -692,23 +790,55 @@ pub async fn reconcile_tick(state: &AppState, runner: &R) { /// A lane counts as launched only if its `unit_id` is set AND a unit row actually /// exists (defensive against a crash between minting the id and committing the row). fn resume_fan_out(st: &AppState, sw: &crate::store::SwarmRow) { - let lanes = st.store.lock().unwrap().lanes_for_swarm(&sw.swarm_id).unwrap_or_default(); + let lanes = st + .store + .lock() + .unwrap() + .lanes_for_swarm(&sw.swarm_id) + .unwrap_or_default(); let mut launched = 0u32; for l in lanes.into_iter().filter(|l| l.decision == "admit") { - let exists = l.unit_id.as_ref() - .and_then(|id| st.store.lock().unwrap().get_unit(id).ok().flatten()).is_some(); - if exists { launched += 1; continue; } + let exists = l + .unit_id + .as_ref() + .and_then(|id| st.store.lock().unwrap().get_unit(id).ok().flatten()) + .is_some(); + if exists { + launched += 1; + continue; + } let n = st.next_id.fetch_add(1, Ordering::Relaxed); let unit_id = format!("u{n}"); - let lane = crate::swarm::Lane { title: l.title.clone(), task: l.task.clone(), rationale: l.rationale.clone() }; + let lane = crate::swarm::Lane { + title: l.title.clone(), + task: l.task.clone(), + rationale: l.rationale.clone(), + }; let spec = lane_spec(sw, &unit_id, l.idx as usize, &lane); let mut row = row_from_spec(&spec, &sw.mode); row.swarm_id = Some(sw.swarm_id.clone()); - st.store.lock().unwrap().commit_lane_unit(&sw.swarm_id, l.idx, &row, now_ms()).ok(); - if spawn_driver_for(st, spec, &sw.mode, &unit_id).is_ok() { launched += 1; } + st.store + .lock() + .unwrap() + .commit_lane_unit(&sw.swarm_id, l.idx, &row, now_ms()) + .ok(); + if spawn_driver_for(st, spec, &sw.mode, &unit_id).is_ok() { + launched += 1; + } } - st.store.lock().unwrap().update_swarm(&sw.swarm_id, "running", sw.planner_cost, - launched, sw.lanes_dropped, None, now_ms()).ok(); + st.store + .lock() + .unwrap() + .update_swarm( + &sw.swarm_id, + "running", + sw.planner_cost, + launched, + sw.lanes_dropped, + None, + now_ms(), + ) + .ok(); } /// Append a synthetic `Halted` event + update the row (one store write, no await). @@ -727,8 +857,21 @@ fn halt_in_store(state: &AppState, id: &str) { }, }; let ts = now_ms(); - let _ = s.append_event(id, seq, ts, &serde_json::to_string(&env).unwrap_or_default()); - let _ = s.update_unit(id, "halted", row.cost, seq, Some("daemon restarted"), None, ts); + let _ = s.append_event( + id, + seq, + ts, + &serde_json::to_string(&env).unwrap_or_default(), + ); + let _ = s.update_unit( + id, + "halted", + row.cost, + seq, + Some("daemon restarted"), + None, + ts, + ); } } @@ -746,7 +889,10 @@ fn demo_script(spec: &UnitSpec) -> Vec { for remaining in (0..floor).rev() { s.push(FakeRunner::ok(0.03, &["implementing the change"])); s.push(FakeRunner::ok(0.0, &["tests: 1 passing"])); - s.push(FakeRunner::ok(0.04, &[&format!("review done\nBLOCKERS={remaining}")])); + s.push(FakeRunner::ok( + 0.04, + &[&format!("review done\nBLOCKERS={remaining}")], + )); } s } @@ -758,22 +904,35 @@ fn demo_script(spec: &UnitSpec) -> Vec { #[derive(Deserialize, Default)] struct CreateSwarmReq { doc_path: String, - #[serde(default)] tier: TierReq, - #[serde(default = "default_mode")] mode: String, - #[serde(default)] max_lanes: Option, - #[serde(default)] usd_budget: Option, - #[serde(default)] per_lane_cap: Option, - #[serde(default = "default_floor")] min_review_rounds: u32, - #[serde(default)] repo_url: Option, - #[serde(default)] repo_slug: Option, - #[serde(default)] base_branch: Option, + #[serde(default)] + tier: TierReq, + #[serde(default = "default_mode")] + mode: String, + #[serde(default)] + max_lanes: Option, + #[serde(default)] + usd_budget: Option, + #[serde(default)] + per_lane_cap: Option, + #[serde(default = "default_floor")] + min_review_rounds: u32, + #[serde(default)] + repo_url: Option, + #[serde(default)] + repo_slug: Option, + #[serde(default)] + base_branch: Option, } #[derive(Serialize)] -struct CreateSwarmResp { swarm_id: String } +struct CreateSwarmResp { + swarm_id: String, +} -async fn create_swarm(State(st): State, Json(req): Json) - -> Result, (StatusCode, String)> { +async fn create_swarm( + State(st): State, + Json(req): Json, +) -> Result, (StatusCode, String)> { // Step 0 — synchronous validation (no 4xx can occur in the spawned task). if req.doc_path.trim().is_empty() { return Err((StatusCode::BAD_REQUEST, "doc_path is required".into())); @@ -785,7 +944,10 @@ async fn create_swarm(State(st): State, Json(req): Json return Err((StatusCode::BAD_REQUEST, format!("unknown mode: {other}"))), @@ -793,25 +955,48 @@ async fn create_swarm(State(st): State, Json(req): Json= st.global_cap { - return Err((StatusCode::TOO_MANY_REQUESTS, "global daily cost cap reached".into())); + return Err(( + StatusCode::TOO_MANY_REQUESTS, + "global daily cost cap reached".into(), + )); } - let usd_budget = req.usd_budget.unwrap_or_else(|| (st.global_cap - committed).clamp(0.0, 15.0)); + let usd_budget = req + .usd_budget + .unwrap_or_else(|| (st.global_cap - committed).clamp(0.0, 15.0)); let row = crate::store::SwarmRow { - swarm_id: swarm_id.clone(), repo_url, repo_slug, base_branch, - doc_path: req.doc_path.clone(), tier: phase_tier(req.tier.into()), mode: req.mode.clone(), - lane_cap, usd_budget, per_lane_cap, status: "planning".into(), planner_cost: 0.0, - lanes_launched: 0, lanes_dropped: 0, min_review_rounds: req.min_review_rounds.max(1), + swarm_id: swarm_id.clone(), + repo_url, + repo_slug, + base_branch, + doc_path: req.doc_path.clone(), + tier: phase_tier(req.tier.into()), + mode: req.mode.clone(), + lane_cap, + usd_budget, + per_lane_cap, + status: "planning".into(), + planner_cost: 0.0, + lanes_launched: 0, + lanes_dropped: 0, + min_review_rounds: req.min_review_rounds.max(1), terminal_reason: None, }; s.upsert_swarm(&row, now_ms()).ok(); @@ -826,10 +1011,24 @@ async fn create_swarm(State(st): State, Json(req): Json { use crate::{docsource::FakeDocSource, planner::FakePlanner, swarm::Lane}; let lanes = vec![ - Lane { title: "Lane One".into(), task: "demo task 1".into(), rationale: "indep".into() }, - Lane { title: "Lane Two".into(), task: "demo task 2".into(), rationale: "indep".into() }, + Lane { + title: "Lane One".into(), + task: "demo task 1".into(), + rationale: "indep".into(), + }, + Lane { + title: "Lane Two".into(), + task: "demo task 2".into(), + rationale: "indep".into(), + }, ]; - run_swarm(st2, id2, FakePlanner::ok(lanes, 0.01), FakeDocSource::new("# demo spec")).await; + run_swarm( + st2, + id2, + FakePlanner::ok(lanes, 0.01), + FakeDocSource::new("# demo spec"), + ) + .await; } _ => { use crate::{docsource::GitDocSource, planner::ClaudePlanner}; @@ -843,50 +1042,93 @@ async fn create_swarm(State(st): State, Json(req): Json) -> Json> { let rows = st.store.lock().unwrap().list_swarms().unwrap_or_default(); - Json(rows.into_iter().map(|r| SwarmSummary { - swarm_id: r.swarm_id, status: r.status, lanes_launched: r.lanes_launched, - lanes_dropped: r.lanes_dropped, planner_cost: r.planner_cost, doc_path: r.doc_path, - }).collect()) + Json( + rows.into_iter() + .map(|r| SwarmSummary { + swarm_id: r.swarm_id, + status: r.status, + lanes_launched: r.lanes_launched, + lanes_dropped: r.lanes_dropped, + planner_cost: r.planner_cost, + doc_path: r.doc_path, + }) + .collect(), + ) } #[derive(Serialize)] struct SwarmDetail { - swarm_id: String, status: String, planner_cost: f64, - lanes_launched: u32, lanes_dropped: u32, awaiting_human: u64, - spent_so_far: f64, lanes: Vec, units: Vec, + swarm_id: String, + status: String, + planner_cost: f64, + lanes_launched: u32, + lanes_dropped: u32, + awaiting_human: u64, + spent_so_far: f64, + lanes: Vec, + units: Vec, } #[derive(Serialize)] -struct LaneView { idx: u32, title: String, decision: String, unit_id: Option } +struct LaneView { + idx: u32, + title: String, + decision: String, + unit_id: Option, +} -async fn get_swarm(State(st): State, Path(id): Path) - -> Result, StatusCode> { +async fn get_swarm( + State(st): State, + Path(id): Path, +) -> Result, StatusCode> { let s = st.store.lock().unwrap(); - let sw = s.get_swarm(&id).ok().flatten().ok_or(StatusCode::NOT_FOUND)?; + let sw = s + .get_swarm(&id) + .ok() + .flatten() + .ok_or(StatusCode::NOT_FOUND)?; let lanes = s.lanes_for_swarm(&id).unwrap_or_default(); let (total, terminal, awaiting) = s.swarm_rollup(&id).unwrap_or((0, 0, 0)); // Computed status: running→done only when every child unit is terminal. let status = if sw.status == "running" && total > 0 && terminal == total { "done".to_string() - } else { sw.status.clone() }; + } else { + sw.status.clone() + }; // "spent so far" = actual child cost + planner cost (NOT reservations). let unit_ids: Vec = lanes.iter().filter_map(|l| l.unit_id.clone()).collect(); let mut spent = sw.planner_cost; for uid in &unit_ids { - if let Ok(Some(u)) = s.get_unit(uid) { spent += u.cost; } + if let Ok(Some(u)) = s.get_unit(uid) { + spent += u.cost; + } } Ok(Json(SwarmDetail { - swarm_id: id, status, planner_cost: sw.planner_cost, - lanes_launched: sw.lanes_launched, lanes_dropped: sw.lanes_dropped, awaiting_human: awaiting, + swarm_id: id, + status, + planner_cost: sw.planner_cost, + lanes_launched: sw.lanes_launched, + lanes_dropped: sw.lanes_dropped, + awaiting_human: awaiting, spent_so_far: spent, - lanes: lanes.iter().map(|l| LaneView { - idx: l.idx, title: l.title.clone(), decision: l.decision.clone(), unit_id: l.unit_id.clone(), - }).collect(), + lanes: lanes + .iter() + .map(|l| LaneView { + idx: l.idx, + title: l.title.clone(), + decision: l.decision.clone(), + unit_id: l.unit_id.clone(), + }) + .collect(), units: unit_ids, })) } @@ -896,8 +1138,15 @@ async fn get_swarm(State(st): State, Path(id): Path) /// sync critical section. Admission here mirrors the mission path: the /// committed-spend check and the unit-row insert happen under ONE store-lock span /// (per-lane re-check + commit_lane_unit), so the cap binds atomically (P2). -pub async fn run_swarm(st: AppState, swarm_id: String, planner: P, doc: D) { - let Some(sw) = st.store.lock().unwrap().get_swarm(&swarm_id).ok().flatten() else { return }; +pub async fn run_swarm( + st: AppState, + swarm_id: String, + planner: P, + doc: D, +) { + let Some(sw) = st.store.lock().unwrap().get_swarm(&swarm_id).ok().flatten() else { + return; + }; // 2. Plan (read doc, then decompose). let doc_text = match doc.read(&sw.repo_url, &sw.base_branch, &sw.doc_path).await { @@ -908,16 +1157,34 @@ pub async fn run_swarm(st: AppState, swarm_id: String, Ok(o) => o, Err(e) => return fail_swarm(&st, &swarm_id, 0.0, &format!("planner: {e}")), }; - st.store.lock().unwrap() - .update_swarm(&swarm_id, "planning", outcome.cost_usd, 0, 0, None, now_ms()).ok(); + st.store + .lock() + .unwrap() + .update_swarm( + &swarm_id, + "planning", + outcome.cost_usd, + 0, + 0, + None, + now_ms(), + ) + .ok(); if outcome.lanes.is_empty() { - return fail_swarm(&st, &swarm_id, outcome.cost_usd, "planner returned zero lanes"); + return fail_swarm( + &st, + &swarm_id, + outcome.cost_usd, + "planner returned zero lanes", + ); } // 3. Admit (pure) + persist every lane's decision. let cfg = AdmissionConfig { - lane_cap: sw.lane_cap as usize, usd_budget: sw.usd_budget, - per_lane_cap: sw.per_lane_cap, planner_cost: outcome.cost_usd, + lane_cap: sw.lane_cap as usize, + usd_budget: sw.usd_budget, + per_lane_cap: sw.per_lane_cap, + planner_cost: outcome.cost_usd, }; let decisions = admit_lanes(&outcome.lanes, &cfg); let mut dropped = 0u32; @@ -928,21 +1195,59 @@ pub async fn run_swarm(st: AppState, swarm_id: String, LaneDecision::DropOverLaneCap => "drop_lane_cap", LaneDecision::DropOverBudget => "drop_budget", }; - if !matches!(d, LaneDecision::Admit) { dropped += 1; } - st.store.lock().unwrap() - .upsert_lane(&swarm_id, *i as u32, &lane.title, &lane.task, &lane.rationale, dstr, None).ok(); + if !matches!(d, LaneDecision::Admit) { + dropped += 1; + } + st.store + .lock() + .unwrap() + .upsert_lane( + &swarm_id, + *i as u32, + &lane.title, + &lane.task, + &lane.rationale, + dstr, + None, + ) + .ok(); } - let admitted_idxs: Vec = decisions.iter() - .filter(|(_, d)| matches!(d, LaneDecision::Admit)).map(|(i, _)| *i).collect(); + let admitted_idxs: Vec = decisions + .iter() + .filter(|(_, d)| matches!(d, LaneDecision::Admit)) + .map(|(i, _)| *i) + .collect(); if admitted_idxs.is_empty() { - st.store.lock().unwrap() - .update_swarm(&swarm_id, "empty", outcome.cost_usd, 0, dropped, Some("no lanes admitted"), now_ms()).ok(); + st.store + .lock() + .unwrap() + .update_swarm( + &swarm_id, + "empty", + outcome.cost_usd, + 0, + dropped, + Some("no lanes admitted"), + now_ms(), + ) + .ok(); return; } // 4. Fan out (idempotent per-lane). - st.store.lock().unwrap() - .update_swarm(&swarm_id, "fanning_out", outcome.cost_usd, 0, dropped, None, now_ms()).ok(); + st.store + .lock() + .unwrap() + .update_swarm( + &swarm_id, + "fanning_out", + outcome.cost_usd, + 0, + dropped, + None, + now_ms(), + ) + .ok(); let since = now_ms() - 24 * 3600 * 1000; let mut launched = 0u32; 'fanout: for (pos, idx) in admitted_idxs.iter().copied().enumerate() { @@ -955,8 +1260,16 @@ pub async fn run_swarm(st: AppState, swarm_id: String, // drop_global_cap so no admit row is left dangling with unit_id = NULL. for rem_idx in admitted_idxs[pos..].iter().copied() { let rem_lane = &outcome.lanes[rem_idx]; - s.upsert_lane(&swarm_id, rem_idx as u32, &rem_lane.title, &rem_lane.task, - &rem_lane.rationale, "drop_global_cap", None).ok(); + s.upsert_lane( + &swarm_id, + rem_idx as u32, + &rem_lane.title, + &rem_lane.task, + &rem_lane.rationale, + "drop_global_cap", + None, + ) + .ok(); dropped += 1; } break 'fanout; @@ -966,7 +1279,8 @@ pub async fn run_swarm(st: AppState, swarm_id: String, let spec = lane_spec(&sw, &unit_id, idx, lane); let mut row = row_from_spec(&spec, &sw.mode); row.swarm_id = Some(swarm_id.clone()); - s.commit_lane_unit(&swarm_id, idx as u32, &row, now_ms()).ok(); + s.commit_lane_unit(&swarm_id, idx as u32, &row, now_ms()) + .ok(); (unit_id, spec) }; // Spawn the driver outside the lock; the row already exists. @@ -974,24 +1288,53 @@ pub async fn run_swarm(st: AppState, swarm_id: String, launched += 1; } } - st.store.lock().unwrap() - .update_swarm(&swarm_id, "running", outcome.cost_usd, launched, dropped, None, now_ms()).ok(); + st.store + .lock() + .unwrap() + .update_swarm( + &swarm_id, + "running", + outcome.cost_usd, + launched, + dropped, + None, + now_ms(), + ) + .ok(); } fn fail_swarm(st: &AppState, swarm_id: &str, planner_cost: f64, reason: &str) { - st.store.lock().unwrap() - .update_swarm(swarm_id, "failed", planner_cost, 0, 0, Some(reason), now_ms()).ok(); + st.store + .lock() + .unwrap() + .update_swarm( + swarm_id, + "failed", + planner_cost, + 0, + 0, + Some(reason), + now_ms(), + ) + .ok(); } /// Build a lane's UnitSpec from the swarm config. -fn lane_spec(sw: &crate::store::SwarmRow, unit_id: &str, idx: usize, lane: &crate::swarm::Lane) -> UnitSpec { +fn lane_spec( + sw: &crate::store::SwarmRow, + unit_id: &str, + idx: usize, + lane: &crate::swarm::Lane, +) -> UnitSpec { UnitSpec { unit_id: unit_id.into(), tier: parse_tier(&sw.tier), task: lane.task.clone(), usd_cap: sw.per_lane_cap, wall_clock_secs: 1800, - gate: GateConfig { min_review_rounds: sw.min_review_rounds.max(1) }, + gate: GateConfig { + min_review_rounds: sw.min_review_rounds.max(1), + }, repo_url: sw.repo_url.clone(), repo_slug: sw.repo_slug.clone(), base_branch: sw.base_branch.clone(), @@ -1060,13 +1403,20 @@ mod tests { let row = store.lock().unwrap().get_unit("u1").unwrap().unwrap(); assert_eq!(row.oracle_hash.as_deref(), Some("h0000000000000abc")); - assert!(row.oracle_frozen, "oracle_frozen flips true once the fold sees OracleProposed"); + assert!( + row.oracle_frozen, + "oracle_frozen flips true once the fold sees OracleProposed" + ); } #[tokio::test] async fn reconcile_halts_stranded_unit_with_coherent_event() { let store = Arc::new(Mutex::new(Store::open_memory().unwrap())); - store.lock().unwrap().upsert_unit(&building_row("u1"), 1).unwrap(); + store + .lock() + .unwrap() + .upsert_unit(&building_row("u1"), 1) + .unwrap(); let state = AppState::new(store.clone()); // FakeRunner reports no running containers → unit is a stranded orphan. let runner = FakeRunner::new(vec![]); @@ -1090,20 +1440,29 @@ mod tests { // genuine orphan container (u2, no unit row, no driver) lingers. One tick // must converge by reaping ONLY the orphan — never the live unit's container. let store = Arc::new(Mutex::new(Store::open_memory().unwrap())); - store.lock().unwrap().upsert_unit(&building_row("u1"), 1).unwrap(); + store + .lock() + .unwrap() + .upsert_unit(&building_row("u1"), 1) + .unwrap(); let state = AppState::new(store.clone()); // u1 has a live in-memory driver → healthy. register_unit_if_absent(&state, "u1"); // The runner sees u1 (healthy) + u2 (stray). - let runner = - FakeRunner::new(vec![]).with_unit_containers(vec!["u1".into(), "u2".into()]); + let runner = FakeRunner::new(vec![]).with_unit_containers(vec!["u1".into(), "u2".into()]); reconcile_tick(&state, &runner).await; // u1 untouched: still non-terminal, driver still live. let row = store.lock().unwrap().get_unit("u1").unwrap().unwrap(); - assert_eq!(row.phase, "building", "healthy live unit must not be halted"); - assert!(state.units.lock().unwrap().contains_key("u1"), "live driver retained"); + assert_eq!( + row.phase, "building", + "healthy live unit must not be halted" + ); + assert!( + state.units.lock().unwrap().contains_key("u1"), + "live driver retained" + ); // Exactly one reap — the stray u2 (`reap_unit` bumps `teardowns`). assert_eq!( runner.teardowns.load(Ordering::Relaxed), @@ -1159,7 +1518,9 @@ mod tests { // A real WebSocket client subscribes from seq 0 and drains the stream. let url = format!("ws://127.0.0.1:{port}/units/{unit_id}/stream?since=0"); - let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.expect("ws connect"); + let (mut ws, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("ws connect"); let mut frames: Vec = Vec::new(); // The server holds the live tail open after replay (the unit's broadcast @@ -1195,7 +1556,11 @@ mod tests { // A non-terminal unit with no live driver and no container is stranded (its // driver died). The steady-state tick converges it to halted. let store = Arc::new(Mutex::new(Store::open_memory().unwrap())); - store.lock().unwrap().upsert_unit(&building_row("u1"), 1).unwrap(); + store + .lock() + .unwrap() + .upsert_unit(&building_row("u1"), 1) + .unwrap(); let state = AppState::new(store.clone()); // No driver registered for u1, no containers running. let runner = FakeRunner::new(vec![]); @@ -1247,7 +1612,11 @@ mod tests { let h1 = tokio::spawn(async move { register_unit_if_absent(&a, "u1").is_some() }); let h2 = tokio::spawn(async move { register_unit_if_absent(&b, "u1").is_some() }); let won = [h1.await.unwrap(), h2.await.unwrap()]; - assert_eq!(won.iter().filter(|&&w| w).count(), 1, "exactly one registration wins"); + assert_eq!( + won.iter().filter(|&&w| w).count(), + 1, + "exactly one registration wins" + ); assert_eq!(state.units.lock().unwrap().len(), 1, "no duplicate handle"); } @@ -1285,7 +1654,10 @@ mod tests { let h = units.get("u1").expect("rehydrated handle present"); (h.bcast.subscribe(), h.cmd_tx.clone()) }; - tx.send(Command::Resume { cmd_id: "r1".into() }).expect("driver alive"); + tx.send(Command::Resume { + cmd_id: "r1".into(), + }) + .expect("driver alive"); let mut saw_oracle = false; let mut done = false; @@ -1336,7 +1708,10 @@ mod tests { let h = units.get("u1").expect("rehydrated handle present"); (h.bcast.subscribe(), h.cmd_tx.clone()) }; - tx.send(Command::Resume { cmd_id: "r1".into() }).expect("driver alive"); + tx.send(Command::Resume { + cmd_id: "r1".into(), + }) + .expect("driver alive"); let mut needs_human = false; for _ in 0..200 { @@ -1348,19 +1723,33 @@ mod tests { .await .expect("timed out waiting for NeedsHuman: tamper gate did not re-arm on resume") .expect("event stream closed before NeedsHuman"); - if let Event::PhaseChanged { to: Phase::NeedsHuman, .. } = env.event { + if let Event::PhaseChanged { + to: Phase::NeedsHuman, + .. + } = env.event + { needs_human = true; break; } } - assert!(needs_human, "a stale reloaded oracle_hash must re-arm the tamper gate on resume"); - tx.send(Command::Abandon { cmd_id: "cleanup".into() }).expect("driver alive"); + assert!( + needs_human, + "a stale reloaded oracle_hash must re-arm the tamper gate on resume" + ); + tx.send(Command::Abandon { + cmd_id: "cleanup".into(), + }) + .expect("driver alive"); } #[tokio::test] async fn next_id_seeds_above_persisted_units() { let store = Arc::new(Mutex::new(Store::open_memory().unwrap())); - store.lock().unwrap().upsert_unit(&building_row("u5"), 1).unwrap(); + store + .lock() + .unwrap() + .upsert_unit(&building_row("u5"), 1) + .unwrap(); let state = AppState::new(store); // Fresh allocation must not collide with u5. let n = state.next_id.fetch_add(1, Ordering::Relaxed); @@ -1369,17 +1758,33 @@ mod tests { fn swarm_row_srv(id: &str, status: &str) -> crate::store::SwarmRow { crate::store::SwarmRow { - swarm_id: id.into(), repo_url: "u".into(), repo_slug: "s".into(), base_branch: "main".into(), - doc_path: "spec.md".into(), tier: "t1".into(), mode: "demo".into(), lane_cap: 8, usd_budget: 15.0, - per_lane_cap: 5.0, status: status.into(), planner_cost: 0.0, lanes_launched: 0, - lanes_dropped: 0, min_review_rounds: 1, terminal_reason: None, + swarm_id: id.into(), + repo_url: "u".into(), + repo_slug: "s".into(), + base_branch: "main".into(), + doc_path: "spec.md".into(), + tier: "t1".into(), + mode: "demo".into(), + lane_cap: 8, + usd_budget: 15.0, + per_lane_cap: 5.0, + status: status.into(), + planner_cost: 0.0, + lanes_launched: 0, + lanes_dropped: 0, + min_review_rounds: 1, + terminal_reason: None, } } #[tokio::test] async fn next_swarm_seeds_above_persisted_swarms() { let store = Arc::new(Mutex::new(Store::open_memory().unwrap())); - store.lock().unwrap().upsert_swarm(&swarm_row_srv("sw5", "running"), 1).unwrap(); + store + .lock() + .unwrap() + .upsert_swarm(&swarm_row_srv("sw5", "running"), 1) + .unwrap(); let state = AppState::new(store); let n = state.next_swarm.fetch_add(1, Ordering::Relaxed); assert_eq!(n, 6, "next swarm mint is sw6, never an existing id"); @@ -1395,13 +1800,20 @@ mod tests { let mut r = building_row("rsv"); r.phase = "building".into(); // non-terminal r.cost = 0.1; - r.usd_cap = 25.0; // reservation alone exceeds the $20 cap + r.usd_cap = 25.0; // reservation alone exceeds the $20 cap s.upsert_unit(&r, now_ms()).unwrap(); } let state = AppState::new(store); - let resp = create_mission(State(state), Json(CreateReq { - task: "t".into(), tier: TierReq::T1, mode: "demo".into(), min_review_rounds: 1, - })).await; + let resp = create_mission( + State(state), + Json(CreateReq { + task: "t".into(), + tier: TierReq::T1, + mode: "demo".into(), + min_review_rounds: 1, + }), + ) + .await; match resp { Err((code, _)) => assert_eq!(code, StatusCode::TOO_MANY_REQUESTS), Ok(_) => panic!("expected 429 — committed reservation breaches the cap"), @@ -1417,20 +1829,49 @@ mod tests { let store = std::sync::Arc::new(std::sync::Mutex::new(Store::open_memory().unwrap())); { let s = store.lock().unwrap(); - let mut r = building_row("seed"); r.phase = "building".into(); r.usd_cap = 16.0; r.cost = 0.0; + let mut r = building_row("seed"); + r.phase = "building".into(); + r.usd_cap = 16.0; + r.cost = 0.0; s.upsert_unit(&r, now_ms()).unwrap(); } let state = AppState::new(store.clone()); let a = state.clone(); let b = state.clone(); let h1 = tokio::spawn(async move { - create_mission(State(a), Json(CreateReq { task: "t".into(), tier: TierReq::T1, mode: "demo".into(), min_review_rounds: 1 })).await.is_ok() + create_mission( + State(a), + Json(CreateReq { + task: "t".into(), + tier: TierReq::T1, + mode: "demo".into(), + min_review_rounds: 1, + }), + ) + .await + .is_ok() }); let h2 = tokio::spawn(async move { - create_mission(State(b), Json(CreateReq { task: "t".into(), tier: TierReq::T1, mode: "demo".into(), min_review_rounds: 1 })).await.is_ok() + create_mission( + State(b), + Json(CreateReq { + task: "t".into(), + tier: TierReq::T1, + mode: "demo".into(), + min_review_rounds: 1, + }), + ) + .await + .is_ok() }); - let wins = [h1.await.unwrap(), h2.await.unwrap()].iter().filter(|&&w| w).count(); - assert_eq!(wins, 1, "exactly one mission admitted; the cap binds atomically"); + let wins = [h1.await.unwrap(), h2.await.unwrap()] + .iter() + .filter(|&&w| w) + .count(); + assert_eq!( + wins, 1, + "exactly one mission admitted; the cap binds atomically" + ); } #[test] @@ -1441,7 +1882,9 @@ mod tests { task: "t".into(), usd_cap: 5.0, wall_clock_secs: 0, - gate: GateConfig { min_review_rounds: 2 }, + gate: GateConfig { + min_review_rounds: 2, + }, repo_url: "https://github.com/x/y".into(), repo_slug: "x/y".into(), base_branch: "main".into(), @@ -1460,17 +1903,41 @@ mod tests { let state = AppState::default(); let sw = crate::store::SwarmRow { - swarm_id: "sw1".into(), repo_url: "https://github.com/x/y".into(), repo_slug: "x/y".into(), - base_branch: "main".into(), doc_path: "spec.md".into(), tier: "t1".into(), mode: "demo".into(), - lane_cap: 8, usd_budget: 100.0, per_lane_cap: 5.0, status: "planning".into(), - planner_cost: 0.0, lanes_launched: 0, lanes_dropped: 0, min_review_rounds: 1, + swarm_id: "sw1".into(), + repo_url: "https://github.com/x/y".into(), + repo_slug: "x/y".into(), + base_branch: "main".into(), + doc_path: "spec.md".into(), + tier: "t1".into(), + mode: "demo".into(), + lane_cap: 8, + usd_budget: 100.0, + per_lane_cap: 5.0, + status: "planning".into(), + planner_cost: 0.0, + lanes_launched: 0, + lanes_dropped: 0, + min_review_rounds: 1, terminal_reason: None, }; - state.store.lock().unwrap().upsert_swarm(&sw, now_ms()).unwrap(); + state + .store + .lock() + .unwrap() + .upsert_swarm(&sw, now_ms()) + .unwrap(); let lanes = vec![ - Lane { title: "Add A".into(), task: "do A".into(), rationale: "indep".into() }, - Lane { title: "Add B".into(), task: "do B".into(), rationale: "indep".into() }, + Lane { + title: "Add A".into(), + task: "do A".into(), + rationale: "indep".into(), + }, + Lane { + title: "Add B".into(), + task: "do B".into(), + rationale: "indep".into(), + }, ]; let planner = FakePlanner::ok(lanes, 0.2); let doc = FakeDocSource::new("# spec"); @@ -1478,21 +1945,39 @@ mod tests { run_swarm(state.clone(), "sw1".into(), planner, doc).await; let units = state.store.lock().unwrap().list_units().unwrap(); - let mine: Vec<_> = units.iter().filter(|u| u.swarm_id.as_deref() == Some("sw1")).cloned().collect(); + let mine: Vec<_> = units + .iter() + .filter(|u| u.swarm_id.as_deref() == Some("sw1")) + .cloned() + .collect(); assert_eq!(mine.len(), 2); assert!(mine.iter().all(|u| (u.usd_cap - 5.0).abs() < 1e-9)); - let branches: std::collections::HashSet<_> = mine.iter().map(|u| u.branch.clone()).collect(); + let branches: std::collections::HashSet<_> = + mine.iter().map(|u| u.branch.clone()).collect(); assert_eq!(branches.len(), 2, "unique branches"); assert!(branches.iter().all(|b| b.starts_with("agent/sw1/"))); let mut done = false; for _ in 0..500 { let (total, term, _) = state.store.lock().unwrap().swarm_rollup("sw1").unwrap(); - if total == 2 && term == 2 { done = true; break; } + if total == 2 && term == 2 { + done = true; + break; + } tokio::time::sleep(std::time::Duration::from_millis(10)).await; } assert!(done, "all lanes reached terminal"); - assert_eq!(state.store.lock().unwrap().get_swarm("sw1").unwrap().unwrap().status, "running"); + assert_eq!( + state + .store + .lock() + .unwrap() + .get_swarm("sw1") + .unwrap() + .unwrap() + .status, + "running" + ); } #[tokio::test] @@ -1502,17 +1987,55 @@ mod tests { use crate::swarm::Lane; let state = AppState::default(); let sw = crate::store::SwarmRow { - swarm_id: "sw2".into(), repo_url: "u".into(), repo_slug: "s".into(), base_branch: "main".into(), - doc_path: "spec.md".into(), tier: "t1".into(), mode: "demo".into(), - lane_cap: 8, usd_budget: 1.0, per_lane_cap: 5.0, status: "planning".into(), - planner_cost: 0.0, lanes_launched: 0, lanes_dropped: 0, min_review_rounds: 1, + swarm_id: "sw2".into(), + repo_url: "u".into(), + repo_slug: "s".into(), + base_branch: "main".into(), + doc_path: "spec.md".into(), + tier: "t1".into(), + mode: "demo".into(), + lane_cap: 8, + usd_budget: 1.0, + per_lane_cap: 5.0, + status: "planning".into(), + planner_cost: 0.0, + lanes_launched: 0, + lanes_dropped: 0, + min_review_rounds: 1, terminal_reason: None, }; - state.store.lock().unwrap().upsert_swarm(&sw, now_ms()).unwrap(); - let planner = FakePlanner::ok(vec![Lane { title: "A".into(), task: "a".into(), rationale: "r".into() }], 0.0); - run_swarm(state.clone(), "sw2".into(), planner, FakeDocSource::new("# spec")).await; - let got = state.store.lock().unwrap().get_swarm("sw2").unwrap().unwrap(); - assert_eq!(got.status, "empty", "lanes produced but none admitted ⇒ empty, never done"); + state + .store + .lock() + .unwrap() + .upsert_swarm(&sw, now_ms()) + .unwrap(); + let planner = FakePlanner::ok( + vec![Lane { + title: "A".into(), + task: "a".into(), + rationale: "r".into(), + }], + 0.0, + ); + run_swarm( + state.clone(), + "sw2".into(), + planner, + FakeDocSource::new("# spec"), + ) + .await; + let got = state + .store + .lock() + .unwrap() + .get_swarm("sw2") + .unwrap() + .unwrap(); + assert_eq!( + got.status, "empty", + "lanes produced but none admitted ⇒ empty, never done" + ); } #[tokio::test] @@ -1521,28 +2044,74 @@ mod tests { use crate::planner::FakePlanner; let state = AppState::default(); let sw = crate::store::SwarmRow { - swarm_id: "sw3".into(), repo_url: "u".into(), repo_slug: "s".into(), base_branch: "main".into(), - doc_path: "spec.md".into(), tier: "t1".into(), mode: "demo".into(), lane_cap: 8, usd_budget: 15.0, - per_lane_cap: 5.0, status: "planning".into(), planner_cost: 0.0, lanes_launched: 0, - lanes_dropped: 0, min_review_rounds: 1, terminal_reason: None, + swarm_id: "sw3".into(), + repo_url: "u".into(), + repo_slug: "s".into(), + base_branch: "main".into(), + doc_path: "spec.md".into(), + tier: "t1".into(), + mode: "demo".into(), + lane_cap: 8, + usd_budget: 15.0, + per_lane_cap: 5.0, + status: "planning".into(), + planner_cost: 0.0, + lanes_launched: 0, + lanes_dropped: 0, + min_review_rounds: 1, + terminal_reason: None, }; - state.store.lock().unwrap().upsert_swarm(&sw, now_ms()).unwrap(); - run_swarm(state.clone(), "sw3".into(), FakePlanner::err("boom"), FakeDocSource::new("x")).await; - assert_eq!(state.store.lock().unwrap().get_swarm("sw3").unwrap().unwrap().status, "failed"); + state + .store + .lock() + .unwrap() + .upsert_swarm(&sw, now_ms()) + .unwrap(); + run_swarm( + state.clone(), + "sw3".into(), + FakePlanner::err("boom"), + FakeDocSource::new("x"), + ) + .await; + assert_eq!( + state + .store + .lock() + .unwrap() + .get_swarm("sw3") + .unwrap() + .unwrap() + .status, + "failed" + ); } #[tokio::test] async fn post_swarms_validates_then_returns_id() { let state = AppState::default(); // unknown mode → error, no row created - let bad = create_swarm(State(state.clone()), Json(CreateSwarmReq { - doc_path: "spec.md".into(), mode: "weird".into(), ..Default::default() - })).await; + let bad = create_swarm( + State(state.clone()), + Json(CreateSwarmReq { + doc_path: "spec.md".into(), + mode: "weird".into(), + ..Default::default() + }), + ) + .await; assert!(bad.is_err()); // demo → ok, returns an id - let ok = create_swarm(State(state.clone()), Json(CreateSwarmReq { - doc_path: "spec.md".into(), mode: "demo".into(), ..Default::default() - })).await.expect("ok"); + let ok = create_swarm( + State(state.clone()), + Json(CreateSwarmReq { + doc_path: "spec.md".into(), + mode: "demo".into(), + ..Default::default() + }), + ) + .await + .expect("ok"); assert!(ok.0.swarm_id.starts_with("sw")); } @@ -1552,14 +2121,21 @@ mod tests { { let s = store.lock().unwrap(); // A swarm stuck mid-planning at crash → must become failed. - s.upsert_swarm(&swarm_row_srv("swP", "planning"), now_ms()).unwrap(); + s.upsert_swarm(&swarm_row_srv("swP", "planning"), now_ms()) + .unwrap(); // A swarm mid-fan-out: lane 0 launched (unit exists), lane 1 not. - let mut f = swarm_row_srv("swF", "fanning_out"); f.mode = "demo".into(); f.min_review_rounds = 1; + let mut f = swarm_row_srv("swF", "fanning_out"); + f.mode = "demo".into(); + f.min_review_rounds = 1; s.upsert_swarm(&f, now_ms()).unwrap(); - s.upsert_lane("swF", 0, "A", "ta", "r", "admit", Some("u1")).unwrap(); - let mut u = building_row("u1"); u.swarm_id = Some("swF".into()); u.phase = "building".into(); + s.upsert_lane("swF", 0, "A", "ta", "r", "admit", Some("u1")) + .unwrap(); + let mut u = building_row("u1"); + u.swarm_id = Some("swF".into()); + u.phase = "building".into(); s.upsert_unit(&u, now_ms()).unwrap(); - s.upsert_lane("swF", 1, "B", "tb", "r", "admit", None).unwrap(); + s.upsert_lane("swF", 1, "B", "tb", "r", "admit", None) + .unwrap(); } let state = AppState::new(store.clone()); let runner = FakeRunner::new(vec![]); @@ -1569,7 +2145,10 @@ mod tests { assert_eq!(s.get_swarm("swP").unwrap().unwrap().status, "failed"); // swF resumed: lane 1 now has a unit_id, status running. let lanes = s.lanes_for_swarm("swF").unwrap(); - assert!(lanes[1].unit_id.is_some(), "missing lane was committed on resume"); + assert!( + lanes[1].unit_id.is_some(), + "missing lane was committed on resume" + ); assert_eq!(s.get_swarm("swF").unwrap().unwrap().status, "running"); } @@ -1590,21 +2169,58 @@ mod tests { s.upsert_unit(&r, now_ms()).unwrap(); } let sw = crate::store::SwarmRow { - swarm_id: "swG".into(), repo_url: "u".into(), repo_slug: "s".into(), base_branch: "main".into(), - doc_path: "spec.md".into(), tier: "t1".into(), mode: "demo".into(), lane_cap: 8, usd_budget: 100.0, - per_lane_cap: 5.0, status: "planning".into(), planner_cost: 0.0, lanes_launched: 0, - lanes_dropped: 0, min_review_rounds: 1, terminal_reason: None, + swarm_id: "swG".into(), + repo_url: "u".into(), + repo_slug: "s".into(), + base_branch: "main".into(), + doc_path: "spec.md".into(), + tier: "t1".into(), + mode: "demo".into(), + lane_cap: 8, + usd_budget: 100.0, + per_lane_cap: 5.0, + status: "planning".into(), + planner_cost: 0.0, + lanes_launched: 0, + lanes_dropped: 0, + min_review_rounds: 1, + terminal_reason: None, }; - state.store.lock().unwrap().upsert_swarm(&sw, now_ms()).unwrap(); + state + .store + .lock() + .unwrap() + .upsert_swarm(&sw, now_ms()) + .unwrap(); let lanes = vec![ - Lane { title: "A".into(), task: "a".into(), rationale: "r".into() }, - Lane { title: "B".into(), task: "b".into(), rationale: "r".into() }, + Lane { + title: "A".into(), + task: "a".into(), + rationale: "r".into(), + }, + Lane { + title: "B".into(), + task: "b".into(), + rationale: "r".into(), + }, ]; - run_swarm(state.clone(), "swG".into(), FakePlanner::ok(lanes, 0.0), FakeDocSource::new("# spec")).await; + run_swarm( + state.clone(), + "swG".into(), + FakePlanner::ok(lanes, 0.0), + FakeDocSource::new("# spec"), + ) + .await; // Both lanes were admitted by admit_lanes (budget 100 allows them) but the global // cap trips at fan-out, so NEITHER launches and BOTH are recorded drop_global_cap. let lanes = state.store.lock().unwrap().lanes_for_swarm("swG").unwrap(); - assert!(lanes.iter().all(|l| l.decision == "drop_global_cap"), "no admit rows left dangling"); - assert!(lanes.iter().all(|l| l.unit_id.is_none()), "nothing launched"); + assert!( + lanes.iter().all(|l| l.decision == "drop_global_cap"), + "no admit rows left dangling" + ); + assert!( + lanes.iter().all(|l| l.unit_id.is_none()), + "nothing launched" + ); } } diff --git a/crates/fleetd/src/store.rs b/crates/fleetd/src/store.rs index bc7f6ad..a8c1f04 100644 --- a/crates/fleetd/src/store.rs +++ b/crates/fleetd/src/store.rs @@ -124,12 +124,26 @@ impl Store { oracle_frozen=(oracle_frozen OR (?6 IS NOT NULL)), updated_ts=?7 WHERE unit_id=?1", - params![unit_id, phase, cost, last_seq, terminal_reason, oracle_hash, now], + params![ + unit_id, + phase, + cost, + last_seq, + terminal_reason, + oracle_hash, + now + ], )?; Ok(()) } - pub fn append_event(&self, unit_id: &str, seq: u64, ts: i64, json: &str) -> rusqlite::Result<()> { + pub fn append_event( + &self, + unit_id: &str, + seq: u64, + ts: i64, + json: &str, + ) -> rusqlite::Result<()> { self.conn.execute( "INSERT OR IGNORE INTO events(unit_id,seq,ts,json) VALUES(?1,?2,?3,?4)", params![unit_id, seq, ts, json], @@ -195,7 +209,10 @@ impl Store { /// count their `planner_cost`. Partitioned by the authoritative terminal list. pub fn committed_spend(&self, since_ts: i64) -> rusqlite::Result { let terminal = fleet_core::TERMINAL_PHASE_STRS - .iter().map(|p| format!("'{p}'")).collect::>().join(","); + .iter() + .map(|p| format!("'{p}'")) + .collect::>() + .join(","); let sql = format!( "SELECT COALESCE((SELECT SUM(cost) FROM units @@ -268,28 +285,63 @@ impl Store { ON CONFLICT(swarm_id) DO UPDATE SET status=?11, planner_cost=?12, lanes_launched=?13, lanes_dropped=?14, terminal_reason=?16, updated_ts=?17", - params![r.swarm_id, r.repo_url, r.repo_slug, r.base_branch, r.doc_path, r.tier, r.mode, - r.lane_cap, r.usd_budget, r.per_lane_cap, r.status, r.planner_cost, - r.lanes_launched, r.lanes_dropped, r.min_review_rounds, r.terminal_reason, now], + params![ + r.swarm_id, + r.repo_url, + r.repo_slug, + r.base_branch, + r.doc_path, + r.tier, + r.mode, + r.lane_cap, + r.usd_budget, + r.per_lane_cap, + r.status, + r.planner_cost, + r.lanes_launched, + r.lanes_dropped, + r.min_review_rounds, + r.terminal_reason, + now + ], )?; Ok(()) } #[allow(clippy::too_many_arguments)] - pub fn update_swarm(&self, id: &str, status: &str, planner_cost: f64, lanes_launched: u32, - lanes_dropped: u32, terminal_reason: Option<&str>, now: i64) -> rusqlite::Result<()> { + pub fn update_swarm( + &self, + id: &str, + status: &str, + planner_cost: f64, + lanes_launched: u32, + lanes_dropped: u32, + terminal_reason: Option<&str>, + now: i64, + ) -> rusqlite::Result<()> { self.conn.execute( "UPDATE swarms SET status=?2, planner_cost=?3, lanes_launched=?4, lanes_dropped=?5, terminal_reason=?6, updated_ts=?7 WHERE swarm_id=?1", - params![id, status, planner_cost, lanes_launched, lanes_dropped, terminal_reason, now], + params![ + id, + status, + planner_cost, + lanes_launched, + lanes_dropped, + terminal_reason, + now + ], )?; Ok(()) } pub fn get_swarm(&self, id: &str) -> rusqlite::Result> { - self.conn.query_row(SELECT_SWARM_WHERE_ID, params![id], Self::map_swarm) - .map(Some).or_else(|e| match e { - rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), + self.conn + .query_row(SELECT_SWARM_WHERE_ID, params![id], Self::map_swarm) + .map(Some) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(other), }) } @@ -303,7 +355,10 @@ impl Store { /// = non-terminal parked phases (`needs_human`/`halted`). pub fn swarm_rollup(&self, swarm_id: &str) -> rusqlite::Result<(u64, u64, u64)> { let terminal = fleet_core::TERMINAL_PHASE_STRS - .iter().map(|p| format!("'{p}'")).collect::>().join(","); + .iter() + .map(|p| format!("'{p}'")) + .collect::>() + .join(","); let sql = format!( "SELECT COUNT(*), @@ -312,18 +367,32 @@ impl Store { FROM units WHERE swarm_id=?1" ); self.conn.query_row(&sql, params![swarm_id], |r| { - Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as u64, r.get::<_, i64>(2)? as u64)) + Ok(( + r.get::<_, i64>(0)? as u64, + r.get::<_, i64>(1)? as u64, + r.get::<_, i64>(2)? as u64, + )) }) } fn map_swarm(r: &rusqlite::Row) -> rusqlite::Result { Ok(SwarmRow { - swarm_id: r.get(0)?, repo_url: r.get(1)?, repo_slug: r.get(2)?, base_branch: r.get(3)?, - doc_path: r.get(4)?, tier: r.get(5)?, mode: r.get(6)?, - lane_cap: r.get::<_, i64>(7)? as u32, usd_budget: r.get(8)?, per_lane_cap: r.get(9)?, - status: r.get(10)?, planner_cost: r.get(11)?, - lanes_launched: r.get::<_, i64>(12)? as u32, lanes_dropped: r.get::<_, i64>(13)? as u32, - min_review_rounds: r.get::<_, i64>(14)? as u32, terminal_reason: r.get(15)?, + swarm_id: r.get(0)?, + repo_url: r.get(1)?, + repo_slug: r.get(2)?, + base_branch: r.get(3)?, + doc_path: r.get(4)?, + tier: r.get(5)?, + mode: r.get(6)?, + lane_cap: r.get::<_, i64>(7)? as u32, + usd_budget: r.get(8)?, + per_lane_cap: r.get(9)?, + status: r.get(10)?, + planner_cost: r.get(11)?, + lanes_launched: r.get::<_, i64>(12)? as u32, + lanes_dropped: r.get::<_, i64>(13)? as u32, + min_review_rounds: r.get::<_, i64>(14)? as u32, + terminal_reason: r.get(15)?, }) } } @@ -342,8 +411,16 @@ pub struct LaneRow { impl Store { #[allow(clippy::too_many_arguments)] - pub fn upsert_lane(&self, swarm_id: &str, idx: u32, title: &str, task: &str, - rationale: &str, decision: &str, unit_id: Option<&str>) -> rusqlite::Result<()> { + pub fn upsert_lane( + &self, + swarm_id: &str, + idx: u32, + title: &str, + task: &str, + rationale: &str, + decision: &str, + unit_id: Option<&str>, + ) -> rusqlite::Result<()> { self.conn.execute( "INSERT INTO swarm_lanes(swarm_id,idx,title,task,rationale,decision,unit_id) VALUES(?1,?2,?3,?4,?5,?6,?7) @@ -356,18 +433,31 @@ impl Store { pub fn lanes_for_swarm(&self, swarm_id: &str) -> rusqlite::Result> { let mut s = self.conn.prepare( "SELECT swarm_id,idx,title,task,rationale,decision,unit_id - FROM swarm_lanes WHERE swarm_id=?1 ORDER BY idx")?; - let rows = s.query_map(params![swarm_id], |r| Ok(LaneRow { - swarm_id: r.get(0)?, idx: r.get::<_, i64>(1)? as u32, title: r.get(2)?, - task: r.get(3)?, rationale: r.get(4)?, decision: r.get(5)?, unit_id: r.get(6)?, - }))?; + FROM swarm_lanes WHERE swarm_id=?1 ORDER BY idx", + )?; + let rows = s.query_map(params![swarm_id], |r| { + Ok(LaneRow { + swarm_id: r.get(0)?, + idx: r.get::<_, i64>(1)? as u32, + title: r.get(2)?, + task: r.get(3)?, + rationale: r.get(4)?, + decision: r.get(5)?, + unit_id: r.get(6)?, + }) + })?; rows.collect() } /// Insert the lane's unit row AND set `swarm_lanes.unit_id` in ONE transaction, /// so a crash never leaves a dangling back-link or an orphan row (spec R2 #5). - pub fn commit_lane_unit(&self, swarm_id: &str, idx: u32, u: &UnitRow, now: i64) - -> rusqlite::Result<()> { + pub fn commit_lane_unit( + &self, + swarm_id: &str, + idx: u32, + u: &UnitRow, + now: i64, + ) -> rusqlite::Result<()> { self.conn.execute_batch("BEGIN IMMEDIATE")?; let r: rusqlite::Result<()> = (|| { self.upsert_unit(u, now)?; @@ -378,8 +468,14 @@ impl Store { Ok(()) })(); match r { - Ok(()) => { self.conn.execute_batch("COMMIT")?; Ok(()) } - Err(e) => { let _ = self.conn.execute_batch("ROLLBACK"); Err(e) } + Ok(()) => { + self.conn.execute_batch("COMMIT")?; + Ok(()) + } + Err(e) => { + let _ = self.conn.execute_batch("ROLLBACK"); + Err(e) + } } } } @@ -416,8 +512,10 @@ mod tests { fn upsert_append_list_since_spend() { let s = Store::open_memory().unwrap(); s.upsert_unit(&row("u1"), 1000).unwrap(); - s.append_event("u1", 1, 1000, r#"{"type":"phase_changed"}"#).unwrap(); - s.append_event("u1", 2, 1001, r#"{"type":"metric"}"#).unwrap(); + s.append_event("u1", 1, 1000, r#"{"type":"phase_changed"}"#) + .unwrap(); + s.append_event("u1", 2, 1001, r#"{"type":"metric"}"#) + .unwrap(); assert_eq!(s.events_since("u1", 0).unwrap().len(), 2); assert_eq!(s.events_since("u1", 1).unwrap().len(), 1); @@ -448,7 +546,10 @@ mod tests { s.upsert_unit(&upd, 1001).unwrap(); let got = s.get_unit("u1").unwrap().unwrap(); assert_eq!(got.mode, "real", "mode is set-once at create"); - assert_eq!(got.min_review_rounds, 3, "review floor is set-once at create"); + assert_eq!( + got.min_review_rounds, 3, + "review floor is set-once at create" + ); assert_eq!(got.phase, "building", "projection columns still update"); } @@ -461,21 +562,52 @@ mod tests { r.oracle_hash = Some("h0000000000000abc".into()); s.upsert_unit(&r, 1000).unwrap(); let got = s.get_unit("u1").unwrap().unwrap(); - assert_eq!(got.oracle_hash.as_deref(), Some("h0000000000000abc"), "oracle_hash round-trips"); - assert!(!got.oracle_frozen, "upsert_unit alone does not flip oracle_frozen"); + assert_eq!( + got.oracle_hash.as_deref(), + Some("h0000000000000abc"), + "oracle_hash round-trips" + ); + assert!( + !got.oracle_frozen, + "upsert_unit alone does not flip oracle_frozen" + ); - s.update_unit("u1", "checking", got.cost, got.last_seq + 1, None, - Some("h0000000000000abc"), 1001).unwrap(); + s.update_unit( + "u1", + "checking", + got.cost, + got.last_seq + 1, + None, + Some("h0000000000000abc"), + 1001, + ) + .unwrap(); let got2 = s.get_unit("u1").unwrap().unwrap(); assert_eq!(got2.oracle_hash.as_deref(), Some("h0000000000000abc")); - assert!(got2.oracle_frozen, "oracle_frozen flips true once update_unit sees a hash"); + assert!( + got2.oracle_frozen, + "oracle_frozen flips true once update_unit sees a hash" + ); // A later update_unit call with oracle_hash=None (the common case — most // phase transitions don't carry a fresh hash) must NOT clobber the // already-persisted hash/frozen state (COALESCE / OR are no-ops on None). - s.update_unit("u1", "reviewing", got2.cost, got2.last_seq + 1, None, None, 1002).unwrap(); + s.update_unit( + "u1", + "reviewing", + got2.cost, + got2.last_seq + 1, + None, + None, + 1002, + ) + .unwrap(); let got3 = s.get_unit("u1").unwrap().unwrap(); - assert_eq!(got3.oracle_hash.as_deref(), Some("h0000000000000abc"), "None must not wipe a prior hash"); + assert_eq!( + got3.oracle_hash.as_deref(), + Some("h0000000000000abc"), + "None must not wipe a prior hash" + ); assert!(got3.oracle_frozen, "None must not un-freeze"); } @@ -501,7 +633,9 @@ mod tests { [], ).unwrap(); // units.swarm_id column present: - s.conn.execute("UPDATE units SET swarm_id='s1' WHERE unit_id='nope'", []).unwrap(); + s.conn + .execute("UPDATE units SET swarm_id='s1' WHERE unit_id='nope'", []) + .unwrap(); } #[test] @@ -510,7 +644,10 @@ mod tests { let mut r = row("u1"); r.swarm_id = Some("sw1".into()); s.upsert_unit(&r, 1000).unwrap(); - assert_eq!(s.get_unit("u1").unwrap().unwrap().swarm_id.as_deref(), Some("sw1")); + assert_eq!( + s.get_unit("u1").unwrap().unwrap().swarm_id.as_deref(), + Some("sw1") + ); } #[test] @@ -529,20 +666,33 @@ mod tests { s.upsert_unit(&row("u3"), 1).unwrap(); s.upsert_unit(&row("u10"), 1).unwrap(); s.upsert_unit(&row("u2"), 1).unwrap(); - assert_eq!(s.max_unit_seq().unwrap(), 10, "parses the numeric suffix, not lexical max"); + assert_eq!( + s.max_unit_seq().unwrap(), + 10, + "parses the numeric suffix, not lexical max" + ); } #[test] fn committed_spend_counts_reservations_and_overcap_and_planner() { let s = Store::open_memory().unwrap(); // A terminal unit contributes its final cost. - let mut done = row("done"); done.phase = "done".into(); done.cost = 1.0; done.usd_cap = 5.0; + let mut done = row("done"); + done.phase = "done".into(); + done.cost = 1.0; + done.usd_cap = 5.0; s.upsert_unit(&done, 1000).unwrap(); // A non-terminal unit under cap contributes its usd_cap (reservation). - let mut run = row("run"); run.phase = "building".into(); run.cost = 0.5; run.usd_cap = 5.0; + let mut run = row("run"); + run.phase = "building".into(); + run.cost = 0.5; + run.usd_cap = 5.0; s.upsert_unit(&run, 1000).unwrap(); // A non-terminal unit that BILLED PAST its cap contributes cost (MAX), not usd_cap. - let mut over = row("over"); over.phase = "building".into(); over.cost = 9.0; over.usd_cap = 5.0; + let mut over = row("over"); + over.phase = "building".into(); + over.cost = 9.0; + over.usd_cap = 5.0; s.upsert_unit(&over, 1000).unwrap(); // Planner cost of a swarm counts too. s.conn.execute( @@ -556,21 +706,38 @@ mod tests { #[test] fn committed_spend_window_excludes_old() { let s = Store::open_memory().unwrap(); - let mut old = row("old"); old.phase = "building".into(); old.usd_cap = 5.0; + let mut old = row("old"); + old.phase = "building".into(); + old.usd_cap = 5.0; s.upsert_unit(&old, 100).unwrap(); s.conn.execute( "INSERT INTO swarms(swarm_id,status,planner_cost,created_ts,updated_ts) VALUES('oldsw','failed',7.0,100,100)", [], ).unwrap(); - assert_eq!(s.committed_spend(500).unwrap(), 0.0, "created before the window is excluded"); + assert_eq!( + s.committed_spend(500).unwrap(), + 0.0, + "created before the window is excluded" + ); } fn swarm_row(id: &str, status: &str) -> SwarmRow { SwarmRow { - swarm_id: id.into(), repo_url: "u".into(), repo_slug: "s".into(), base_branch: "main".into(), - doc_path: "spec.md".into(), tier: "t1".into(), mode: "demo".into(), - lane_cap: 8, usd_budget: 15.0, per_lane_cap: 5.0, status: status.into(), - planner_cost: 0.0, lanes_launched: 0, lanes_dropped: 0, min_review_rounds: 2, + swarm_id: id.into(), + repo_url: "u".into(), + repo_slug: "s".into(), + base_branch: "main".into(), + doc_path: "spec.md".into(), + tier: "t1".into(), + mode: "demo".into(), + lane_cap: 8, + usd_budget: 15.0, + per_lane_cap: 5.0, + status: status.into(), + planner_cost: 0.0, + lanes_launched: 0, + lanes_dropped: 0, + min_review_rounds: 2, terminal_reason: None, } } @@ -581,7 +748,8 @@ mod tests { let sw = swarm_row("sw1", "planning"); s.upsert_swarm(&sw, 1000).unwrap(); assert_eq!(s.get_swarm("sw1").unwrap().unwrap().status, "planning"); - s.update_swarm("sw1", "running", 0.4, 3, 0, None, 1001).unwrap(); + s.update_swarm("sw1", "running", 0.4, 3, 0, None, 1001) + .unwrap(); let got = s.get_swarm("sw1").unwrap().unwrap(); assert_eq!(got.status, "running"); assert_eq!(got.planner_cost, 0.4); @@ -592,31 +760,45 @@ mod tests { #[test] fn lane_crud_and_commit_is_atomic() { let s = Store::open_memory().unwrap(); - s.upsert_swarm(&swarm_row("sw1", "fanning_out"), 1000).unwrap(); + s.upsert_swarm(&swarm_row("sw1", "fanning_out"), 1000) + .unwrap(); // Persist a lane with no unit yet. - s.upsert_lane("sw1", 0, "Add auth", "do auth", "indep", "admit", None).unwrap(); + s.upsert_lane("sw1", 0, "Add auth", "do auth", "indep", "admit", None) + .unwrap(); let lanes = s.lanes_for_swarm("sw1").unwrap(); assert_eq!(lanes.len(), 1); assert_eq!(lanes[0].decision, "admit"); assert!(lanes[0].unit_id.is_none()); // commit_lane_unit inserts the unit row AND sets the back-link in one txn. - let mut u = row("u1"); u.swarm_id = Some("sw1".into()); + let mut u = row("u1"); + u.swarm_id = Some("sw1".into()); s.commit_lane_unit("sw1", 0, &u, 1001).unwrap(); assert!(s.get_unit("u1").unwrap().is_some(), "unit row inserted"); - assert_eq!(s.lanes_for_swarm("sw1").unwrap()[0].unit_id.as_deref(), Some("u1"), "back-link set"); + assert_eq!( + s.lanes_for_swarm("sw1").unwrap()[0].unit_id.as_deref(), + Some("u1"), + "back-link set" + ); } #[test] fn swarm_rollup_counts_terminal_and_awaiting_human() { let s = Store::open_memory().unwrap(); - for (id, phase) in [("u1","done"), ("u2","failed"), ("u3","building"), ("u4","needs_human")] { - let mut r = row(id); r.phase = phase.into(); r.swarm_id = Some("sw1".into()); + for (id, phase) in [ + ("u1", "done"), + ("u2", "failed"), + ("u3", "building"), + ("u4", "needs_human"), + ] { + let mut r = row(id); + r.phase = phase.into(); + r.swarm_id = Some("sw1".into()); s.upsert_unit(&r, 1000).unwrap(); } let (total, terminal, awaiting) = s.swarm_rollup("sw1").unwrap(); assert_eq!(total, 4); - assert_eq!(terminal, 2); // done + failed - assert_eq!(awaiting, 1); // needs_human (halted would also count) + assert_eq!(terminal, 2); // done + failed + assert_eq!(awaiting, 1); // needs_human (halted would also count) } } diff --git a/crates/fleetd/src/swarm.rs b/crates/fleetd/src/swarm.rs index 7ad9ec1..2d410bd 100644 --- a/crates/fleetd/src/swarm.rs +++ b/crates/fleetd/src/swarm.rs @@ -22,7 +22,11 @@ pub struct AdmissionConfig { /// Per-lane admission verdict. `DropOverGlobalCap` is set later by the fan-out /// loop (a runtime re-check), never by `admit_lanes`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LaneDecision { Admit, DropOverLaneCap, DropOverBudget } +pub enum LaneDecision { + Admit, + DropOverLaneCap, + DropOverBudget, +} /// Walk lanes in order; admit while BOTH the count cap and the /// (usd_budget − planner_cost) envelope hold. Conservative: each admitted lane @@ -30,17 +34,21 @@ pub enum LaneDecision { Admit, DropOverLaneCap, DropOverBudget } pub fn admit_lanes(lanes: &[Lane], cfg: &AdmissionConfig) -> Vec<(usize, LaneDecision)> { let envelope = (cfg.usd_budget - cfg.planner_cost).max(0.0); let mut admitted = 0usize; - lanes.iter().enumerate().map(|(i, _)| { - let decision = if admitted >= cfg.lane_cap { - LaneDecision::DropOverLaneCap - } else if (admitted as f64 + 1.0) * cfg.per_lane_cap > envelope { - LaneDecision::DropOverBudget - } else { - admitted += 1; - LaneDecision::Admit - }; - (i, decision) - }).collect() + lanes + .iter() + .enumerate() + .map(|(i, _)| { + let decision = if admitted >= cfg.lane_cap { + LaneDecision::DropOverLaneCap + } else if (admitted as f64 + 1.0) * cfg.per_lane_cap > envelope { + LaneDecision::DropOverBudget + } else { + admitted += 1; + LaneDecision::Admit + }; + (i, decision) + }) + .collect() } /// Sanitize a planner-chosen lane title into a git-ref-safe, length-bounded @@ -61,7 +69,11 @@ pub fn slug(title: &str) -> String { } let trimmed: String = out.trim_matches('-').chars().take(32).collect(); let trimmed = trimmed.trim_matches('-').to_string(); - if trimmed.is_empty() { "lane".into() } else { trimmed } + if trimmed.is_empty() { + "lane".into() + } else { + trimmed + } } #[cfg(test)] @@ -72,19 +84,37 @@ mod tests { fn slug_sanitizes_charset_length_and_empty() { assert_eq!(slug("Add Auth!!"), "add-auth"); assert_eq!(slug(" spaced out "), "spaced-out"); - assert_eq!(slug("🚀🚀🚀"), "lane"); // non-ascii → fallback + assert_eq!(slug("🚀🚀🚀"), "lane"); // non-ascii → fallback assert_eq!(slug(""), "lane"); assert_eq!(slug(&"x".repeat(100)).len(), 32); // truncated } fn lanes(n: usize) -> Vec { - (0..n).map(|i| Lane { title: format!("l{i}"), task: "t".into(), rationale: "r".into() }).collect() + (0..n) + .map(|i| Lane { + title: format!("l{i}"), + task: "t".into(), + rationale: "r".into(), + }) + .collect() } - fn cfg(lane_cap: usize, usd_budget: f64, per_lane_cap: f64, planner_cost: f64) -> AdmissionConfig { - AdmissionConfig { lane_cap, usd_budget, per_lane_cap, planner_cost } + fn cfg( + lane_cap: usize, + usd_budget: f64, + per_lane_cap: f64, + planner_cost: f64, + ) -> AdmissionConfig { + AdmissionConfig { + lane_cap, + usd_budget, + per_lane_cap, + planner_cost, + } } fn admits(d: &[(usize, LaneDecision)]) -> usize { - d.iter().filter(|(_, x)| matches!(x, LaneDecision::Admit)).count() + d.iter() + .filter(|(_, x)| matches!(x, LaneDecision::Admit)) + .count() } #[test] @@ -114,7 +144,9 @@ mod tests { fn planner_over_budget_admits_zero() { let d = admit_lanes(&lanes(4), &cfg(8, 4.0, 5.0, 5.0)); assert_eq!(admits(&d), 0); - assert!(d.iter().all(|(_, x)| matches!(x, LaneDecision::DropOverBudget))); + assert!(d + .iter() + .all(|(_, x)| matches!(x, LaneDecision::DropOverBudget))); } #[test] diff --git a/crates/fleetd/tests/demo_mode_it.rs b/crates/fleetd/tests/demo_mode_it.rs index 5eff200..2e08224 100644 --- a/crates/fleetd/tests/demo_mode_it.rs +++ b/crates/fleetd/tests/demo_mode_it.rs @@ -41,7 +41,10 @@ fn demo_script(spec: &UnitSpec) -> Vec { for remaining in (0..floor).rev() { s.push(FakeRunner::ok(0.03, &["implementing the change"])); s.push(FakeRunner::ok(0.0, &["tests: 1 passing"])); - s.push(FakeRunner::ok(0.04, &[&format!("review done\nBLOCKERS={remaining}")])); + s.push(FakeRunner::ok( + 0.04, + &[&format!("review done\nBLOCKERS={remaining}")], + )); } s } @@ -55,7 +58,9 @@ fn demo_spec(floor: u32) -> UnitSpec { task: "add a sum() helper".into(), usd_cap: 5.0, wall_clock_secs: 1800, - gate: GateConfig { min_review_rounds: floor.max(1) }, + gate: GateConfig { + min_review_rounds: floor.max(1), + }, repo_url: "https://github.com/adbarc92/command-center-agent-sandbox".into(), repo_slug: "adbarc92/command-center-agent-sandbox".into(), base_branch: "main".into(), @@ -128,24 +133,37 @@ async fn demo_mission_reaches_terminal_on_fakes_no_docker_no_real_pr() { Phase::PrOpen, Phase::Done, ] { - assert!(seq.contains(&required), "missing phase {required:?} in {seq:?}"); + assert!( + seq.contains(&required), + "missing phase {required:?} in {seq:?}" + ); } // T1 freezes the oracle automatically — never parks for human approval in demo. assert!( !seq.contains(&Phase::AwaitingOracleApproval), "T1 demo auto-freezes the oracle; no human gate" ); - assert!(!seq.contains(&Phase::Failed), "demo happy path never fails: {seq:?}"); + assert!( + !seq.contains(&Phase::Failed), + "demo happy path never fails: {seq:?}" + ); // 3. No REAL PR: the only PR artifact is FakeForge's faked url, never github.com. let pr_refs: Vec<&str> = events .iter() .filter_map(|e| match &e.event { - Event::Artifact { kind: fleet_core::ArtifactKind::Pr, reference } => Some(reference.as_str()), + Event::Artifact { + kind: fleet_core::ArtifactKind::Pr, + reference, + } => Some(reference.as_str()), _ => None, }) .collect(); - assert_eq!(pr_refs, vec![FAKE_PR_URL], "demo opens only a FAKED PR, never a real one"); + assert_eq!( + pr_refs, + vec![FAKE_PR_URL], + "demo opens only a FAKED PR, never a real one" + ); assert!( !pr_refs.iter().any(|r| r.contains("github.com")), "no real github.com PR may be opened in demo: {pr_refs:?}" @@ -162,13 +180,21 @@ async fn demo_mission_reaches_terminal_on_fakes_no_docker_no_real_pr() { _ => None, }) .fold(0.0_f64, f64::max); - assert!((final_cost - 0.16).abs() < 1e-9, "demo metered fake cost is 0.16, got {final_cost}"); - assert!(final_cost < 5.0, "demo never approaches the usd cap — no real spend"); + assert!( + (final_cost - 0.16).abs() < 1e-9, + "demo metered fake cost is 0.16, got {final_cost}" + ); + assert!( + final_cost < 5.0, + "demo never approaches the usd cap — no real spend" + ); // 5. The event stream emitted normally (the cockpit has something to render): // iterations, logs, findings, a terminal Done. assert!( - events.iter().any(|e| matches!(e.event, Event::Iteration { .. })), + events + .iter() + .any(|e| matches!(e.event, Event::Iteration { .. })), "demo emits Iteration events" ); assert!( @@ -176,12 +202,17 @@ async fn demo_mission_reaches_terminal_on_fakes_no_docker_no_real_pr() { "demo emits Log events" ); assert!( - events.iter().any(|e| matches!(&e.event, Event::Done { result } if result == "done")), + events + .iter() + .any(|e| matches!(&e.event, Event::Done { result } if result == "done")), "demo emits a terminal Done(done)" ); // Seqs are strictly increasing — a coherent stream for the WS replay/dedup. let seqs: Vec = events.iter().map(|e| e.seq).collect(); - assert!(seqs.windows(2).all(|w| w[0] < w[1]), "event seqs strictly increase: {seqs:?}"); + assert!( + seqs.windows(2).all(|w| w[0] < w[1]), + "event seqs strictly increase: {seqs:?}" + ); } /// The review gate opens exactly on the floor: floor=3 runs three review rounds @@ -195,7 +226,10 @@ async fn demo_runs_full_review_rounds_to_the_floor() { .iter() .filter(|e| matches!(e.event, Event::Iteration { kind: fleet_core::IterationKind::Review, n } if n >= 1)) .count(); - assert_eq!(review_rounds, 3, "floor=3 demo runs three review rounds before the gate opens"); + assert_eq!( + review_rounds, 3, + "floor=3 demo runs three review rounds before the gate opens" + ); } /// Guard against drift: this test's mirrored `demo_script` must match the daemon's diff --git a/crates/fleetd/tests/local_docker_it.rs b/crates/fleetd/tests/local_docker_it.rs index d08b220..099a929 100644 --- a/crates/fleetd/tests/local_docker_it.rs +++ b/crates/fleetd/tests/local_docker_it.rs @@ -44,7 +44,9 @@ async fn provision_commit_export_roundtrip() { git commit -q -m base; git checkout -q -b agent/it; \ printf 'x\\ny\\n' > f.txt; git add f.txt; git commit -q -m feat; \ git rev-parse agent/it"; - let exec = runner.exec(&handle, "/work", &["sh".into(), "-c".into(), script.into()]).await; + let exec = runner + .exec(&handle, "/work", &["sh".into(), "-c".into(), script.into()]) + .await; let bundle = match &exec { Ok(o) if o.exit_code == 0 => runner.export_bundle(&handle, "agent/it").await.ok(), _ => None, @@ -59,7 +61,12 @@ async fn provision_commit_export_roundtrip() { .status() .ok()?; let o = StdCommand::new("git") - .args(["-C", &dir.to_string_lossy(), "rev-parse", "refs/remotes/origin/agent/it"]) + .args([ + "-C", + &dir.to_string_lossy(), + "rev-parse", + "refs/remotes/origin/agent/it", + ]) .output() .ok()?; Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) @@ -75,6 +82,14 @@ async fn provision_commit_export_roundtrip() { let out = exec.expect("exec git script"); assert_eq!(out.exit_code, 0, "git script failed: {:?}", out.stdout); let container_sha = out.stdout.last().expect("a sha line").trim().to_string(); - assert_eq!(container_sha.len(), 40, "expected a full sha, got {container_sha:?}"); - assert_eq!(host_sha.as_deref(), Some(container_sha.as_str()), "host SHA must match container"); + assert_eq!( + container_sha.len(), + 40, + "expected a full sha, got {container_sha:?}" + ); + assert_eq!( + host_sha.as_deref(), + Some(container_sha.as_str()), + "host SHA must match container" + ); } diff --git a/crates/fleetd/tests/preflight_it.rs b/crates/fleetd/tests/preflight_it.rs index 740b1a1..ba43bef 100644 --- a/crates/fleetd/tests/preflight_it.rs +++ b/crates/fleetd/tests/preflight_it.rs @@ -22,7 +22,10 @@ const URL: &str = "https://github.com/adbarc92/command-center-agent-sandbox"; #[tokio::test] #[ignore = "requires Docker + cc-agent:dev + authed gh; opens a real PR"] async fn full_pipeline_opens_a_real_mergeable_pr() { - let millis = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis(); + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); let unit_id = format!("preflight-{millis}"); let branch = format!("agent/{unit_id}"); @@ -42,7 +45,10 @@ async fn full_pipeline_opens_a_real_mergeable_pr() { }; let runner = LocalDockerRunner::new("cc-agent:dev"); - let handle = runner.provision(&spec).await.expect("provision (clone sandbox + branch)"); + let handle = runner + .provision(&spec) + .await + .expect("provision (clone sandbox + branch)"); // ── stub the agent: write impl + its test (what oracle+build would do) ── let work = "set -e; cd /work/repo; \ @@ -51,30 +57,60 @@ async fn full_pipeline_opens_a_real_mergeable_pr() { printf 'const test=require(\"node:test\");const assert=require(\"node:assert\");\ const {sum}=require(\"./src/index.js\");\ test(\"sum adds\",()=>assert.strictEqual(sum(2,3),5));\\n' > sum.test.js"; - let w = runner.exec(&handle, "/work/repo", &["sh".into(), "-c".into(), work.into()]).await + let w = runner + .exec( + &handle, + "/work/repo", + &["sh".into(), "-c".into(), work.into()], + ) + .await .expect("write stub files"); assert_eq!(w.exit_code, 0, "stub write failed: {:?}", w.stdout); // ── daemon commits the work ── - let committed = runner.commit_all(&handle, "feat: implement sum").await.expect("commit_all"); + let committed = runner + .commit_all(&handle, "feat: implement sum") + .await + .expect("commit_all"); assert!(committed, "expected a commit to be created"); // ── checks (the objective signal) ── - let check = runner.exec(&handle, "/work/repo", &["node".into(), "--test".into()]).await + let check = runner + .exec(&handle, "/work/repo", &["node".into(), "--test".into()]) + .await .expect("run checks"); assert_eq!(check.exit_code, 0, "checks should pass: {:?}", check.stdout); // ── non-empty diff vs base ── - let diff = runner.has_diff(&handle, "main", &branch).await.expect("has_diff"); + let diff = runner + .has_diff(&handle, "main", &branch) + .await + .expect("has_diff"); assert!(diff, "expected a non-empty diff vs main"); // ── escape the branch + open a real PR via GhForge ── - let bundle = runner.export_bundle(&handle, &branch).await.expect("export bundle"); + let bundle = runner + .export_bundle(&handle, &branch) + .await + .expect("export bundle"); let host_clone = std::env::temp_dir().join(format!("cc-preflight-{millis}")); - let forge = GhForge::new(URL, SLUG, "main", host_clone.clone(), format!("pre-flight: {unit_id}")); + let forge = GhForge::new( + URL, + SLUG, + "main", + host_clone.clone(), + format!("pre-flight: {unit_id}"), + ); - let merge = forge.trial_merge(&bundle, &branch).await.expect("trial merge"); - assert_eq!(merge, MergeResult::Clean, "branch should merge cleanly onto main"); + let merge = forge + .trial_merge(&bundle, &branch) + .await + .expect("trial merge"); + assert_eq!( + merge, + MergeResult::Clean, + "branch should merge cleanly onto main" + ); let pr = forge.open_pr(&branch).await.expect("open PR"); println!("PR: {pr}"); @@ -89,7 +125,11 @@ test(\"sum adds\",()=>assert.strictEqual(sum(2,3),5));\\n' > sum.test.js"; } tokio::time::sleep(Duration::from_secs(2)).await; } - assert_eq!(verdict, Mergeability::Mergeable, "GitHub should report the PR mergeable"); + assert_eq!( + verdict, + Mergeability::Mergeable, + "GitHub should report the PR mergeable" + ); // ── cleanup the container + host clone (leave the PR as proof) ── runner.teardown(&handle).await.expect("teardown"); diff --git a/crates/fleetd/tests/swarm_smoke_it.rs b/crates/fleetd/tests/swarm_smoke_it.rs index 4aa2089..4af624f 100644 --- a/crates/fleetd/tests/swarm_smoke_it.rs +++ b/crates/fleetd/tests/swarm_smoke_it.rs @@ -5,6 +5,12 @@ async fn git_doc_source_clones_reads_and_cleans_up() { use fleetd::docsource::{DocSource, GitDocSource}; let d = GitDocSource::new(); - let out = d.read("https://github.com/adbarc92/command-center-agent-sandbox", "main", "README.md").await; + let out = d + .read( + "https://github.com/adbarc92/command-center-agent-sandbox", + "main", + "README.md", + ) + .await; assert!(out.is_ok(), "reads a known file: {out:?}"); }