| 1 | use std::{io, os::unix::net::UnixStream, path::PathBuf}; |
| 2 | |
| 3 | use pound::Parse; |
| 4 | |
| 5 | use crate::{ |
| 6 | artifacts, client, |
| 7 | config::Config, |
| 8 | daemon, history, hook, paths, |
| 9 | proto::{self, Event, Reader, Request}, |
| 10 | run, |
| 11 | units::{Amount, Bytes, Dur}, |
| 12 | }; |
| 13 | |
| 14 | /// one build at a time, please |
| 15 | #[derive(Parse)] |
| 16 | #[pound(name = "roundabout", version = env!("CARGO_PKG_VERSION"))] |
| 17 | pub enum Rabt { |
| 18 | /// wait your turn, then run the thing |
| 19 | Run { |
| 20 | /// preset from config, s m l xl out of the box |
| 21 | #[pound(short, long)] |
| 22 | size: Option<String>, |
| 23 | #[pound(long)] |
| 24 | ram: Option<Bytes>, |
| 25 | #[pound(long)] |
| 26 | disk: Option<Bytes>, |
| 27 | /// count or percentage of the machine |
| 28 | #[pound(long)] |
| 29 | cores: Option<Amount<u32>>, |
| 30 | /// how long you reckon it'll take |
| 31 | #[pound(short, long)] |
| 32 | time: Option<Dur>, |
| 33 | /// kill it past this, exit 124 |
| 34 | #[pound(long)] |
| 35 | deadline: Option<Dur>, |
| 36 | /// stop queueing past this, exit 75 |
| 37 | #[pound(long)] |
| 38 | max_wait: Option<Dur>, |
| 39 | #[pound(long, hidden)] |
| 40 | session: Option<String>, |
| 41 | /// exactly the cores asked for, no spares |
| 42 | #[pound(long)] |
| 43 | exact: bool, |
| 44 | #[pound(trailing)] |
| 45 | cmd: Vec<String>, |
| 46 | }, |
| 47 | /// say you'll be building soon, so others leave you room |
| 48 | Expect { |
| 49 | #[pound(short, long)] |
| 50 | size: Option<String>, |
| 51 | #[pound(long)] |
| 52 | ram: Option<Bytes>, |
| 53 | #[pound(long)] |
| 54 | cores: Option<Amount<u32>>, |
| 55 | /// changed your mind |
| 56 | #[pound(long)] |
| 57 | done: bool, |
| 58 | #[pound(long, hidden)] |
| 59 | session: Option<String>, |
| 60 | }, |
| 61 | /// who's running, who's waiting |
| 62 | Status { |
| 63 | #[pound(long)] |
| 64 | json: bool, |
| 65 | }, |
| 66 | /// a size guess from past runs |
| 67 | Suggest { |
| 68 | #[pound(trailing)] |
| 69 | cmd: Vec<String>, |
| 70 | }, |
| 71 | /// stop a lease's build |
| 72 | Kill { lease: u64 }, |
| 73 | /// buy an overdue build more time |
| 74 | Extend { lease: u64, by: Dur }, |
| 75 | /// exit when a lease goes overdue or finishes |
| 76 | Watch { lease: u64 }, |
| 77 | /// build output and how much of it there is |
| 78 | Artifacts { |
| 79 | #[pound(subcommand)] |
| 80 | action: Artifacts, |
| 81 | }, |
| 82 | /// agent hook entrypoint, json on stdin |
| 83 | Hook { |
| 84 | /// pre-tool-use post-tool-use prompt-submit stop subagent-stop session-end, or codex-prefixed variants |
| 85 | event: String, |
| 86 | }, |
| 87 | #[pound(hidden)] |
| 88 | Daemon, |
| 89 | } |
| 90 | |
| 91 | #[derive(Parse)] |
| 92 | pub enum Artifacts { |
| 93 | /// sizes against the caps, exits 1 when over |
| 94 | Check { |
| 95 | /// re-measure instead of trusting the cache |
| 96 | #[pound(long)] |
| 97 | fresh: bool, |
| 98 | }, |
| 99 | /// everything tracked, biggest first |
| 100 | List, |
| 101 | /// delete idle artifacts |
| 102 | Gc { |
| 103 | /// keep going, oldest first, until under the caps |
| 104 | #[pound(long)] |
| 105 | cap: bool, |
| 106 | /// leave other projects alone |
| 107 | #[pound(long)] |
| 108 | project: bool, |
| 109 | #[pound(short = 'n', long)] |
| 110 | dry_run: bool, |
| 111 | }, |
| 112 | /// never gc this |
| 113 | Pin { |
| 114 | path: PathBuf, |
| 115 | }, |
| 116 | Unpin { |
| 117 | path: PathBuf, |
| 118 | }, |
| 119 | } |
| 120 | |
| 121 | fn artifacts(cfg: &Config, action: Artifacts) -> i32 { |
| 122 | let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); |
| 123 | let root = paths::project_root(&cwd); |
| 124 | match action { |
| 125 | Artifacts::Check { fresh } => { |
| 126 | let r = artifacts::check(cfg, Some(&root), fresh); |
| 127 | if let Some((root, size)) = &r.project { |
| 128 | println!( |
| 129 | "{}: {size} of {}", |
| 130 | root.display(), |
| 131 | cfg.artifacts.project_cap |
| 132 | ); |
| 133 | } |
| 134 | println!("everything: {} of {}", r.global, cfg.artifacts.global_cap); |
| 135 | if r.over() { |
| 136 | println!("over. `{}`", r.remedy()); |
| 137 | 1 |
| 138 | } else { |
| 139 | 0 |
| 140 | } |
| 141 | } |
| 142 | Artifacts::List => { |
| 143 | let r = artifacts::check(cfg, Some(&root), false); |
| 144 | let mut rows: Vec<_> = r.entries.iter().collect(); |
| 145 | rows.sort_by_key(|(_, e)| std::cmp::Reverse(e.size)); |
| 146 | for (path, e) in rows { |
| 147 | println!( |
| 148 | "{:>10} {}{}", |
| 149 | e.size.to_string(), |
| 150 | path.display(), |
| 151 | if e.pinned { " (pinned)" } else { "" } |
| 152 | ); |
| 153 | } |
| 154 | 0 |
| 155 | } |
| 156 | Artifacts::Gc { |
| 157 | cap, |
| 158 | project, |
| 159 | dry_run, |
| 160 | } => { |
| 161 | let g = artifacts::gc(cfg, project.then_some(root.as_path()), cap, dry_run); |
| 162 | for p in &g.removed { |
| 163 | println!( |
| 164 | "{}{}", |
| 165 | if dry_run { "would remove " } else { "removed " }, |
| 166 | p.display() |
| 167 | ); |
| 168 | } |
| 169 | for f in &g.failed { |
| 170 | eprintln!("roundabout: {f}"); |
| 171 | } |
| 172 | println!( |
| 173 | "{} {}", |
| 174 | if dry_run { "would free" } else { "freed" }, |
| 175 | g.freed |
| 176 | ); |
| 177 | i32::from(!g.failed.is_empty()) |
| 178 | } |
| 179 | Artifacts::Pin { path } | Artifacts::Unpin { path } if !path.exists() => { |
| 180 | eprintln!("roundabout: {} doesn't exist", path.display()); |
| 181 | 1 |
| 182 | } |
| 183 | Artifacts::Pin { path } => report(artifacts::pin(&path, true)), |
| 184 | Artifacts::Unpin { path } => report(artifacts::pin(&path, false)), |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | fn report(r: Result<(), String>) -> i32 { |
| 189 | r.map_or_else( |
| 190 | |e| { |
| 191 | eprintln!("roundabout: {e}"); |
| 192 | 1 |
| 193 | }, |
| 194 | |()| 0, |
| 195 | ) |
| 196 | } |
| 197 | |
| 198 | fn ask(req: &Request) -> io::Result<Option<Event>> { |
| 199 | let Some(mut s) = client::connect() else { |
| 200 | return Ok(None); |
| 201 | }; |
| 202 | proto::send(&mut s, req)?; |
| 203 | Reader::new(&s)?.recv() |
| 204 | } |
| 205 | |
| 206 | fn reply(ev: io::Result<Option<Event>>) -> i32 { |
| 207 | match ev { |
| 208 | Ok(Some(Event::Ok)) => 0, |
| 209 | Ok(Some(Event::Err { msg })) => { |
| 210 | eprintln!("roundabout: {msg}"); |
| 211 | 1 |
| 212 | } |
| 213 | Ok(None) => { |
| 214 | eprintln!("roundabout: nothing running"); |
| 215 | 1 |
| 216 | } |
| 217 | Ok(Some(_)) => 1, |
| 218 | Err(e) => { |
| 219 | eprintln!("roundabout: {e}"); |
| 220 | 1 |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | fn agent_session(session: Option<String>) -> Option<String> { |
| 226 | session.or_else(|| { |
| 227 | [ |
| 228 | "ROUNDABOUT_SESSION_ID", |
| 229 | "CLAUDE_CODE_SESSION_ID", |
| 230 | "CODEX_THREAD_ID", |
| 231 | "CODEX_SESSION_ID", |
| 232 | ] |
| 233 | .into_iter() |
| 234 | .find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty())) |
| 235 | }) |
| 236 | } |
| 237 | |
| 238 | fn expect(cfg: &Config, session: Option<String>, done: bool, a: &run::Args) -> i32 { |
| 239 | let Some(session) = session.filter(|s| !s.is_empty()) else { |
| 240 | eprintln!("roundabout: no agent session id, use --session to name one"); |
| 241 | return 2; |
| 242 | }; |
| 243 | let need = if done { |
| 244 | None |
| 245 | } else { |
| 246 | match run::need(cfg, a) { |
| 247 | Ok(n) => Some(n), |
| 248 | Err(e) => { |
| 249 | eprintln!("roundabout: {e}"); |
| 250 | return 2; |
| 251 | } |
| 252 | } |
| 253 | }; |
| 254 | let mut s = match client::connect_or_spawn() { |
| 255 | Ok(s) => s, |
| 256 | Err(e) => { |
| 257 | eprintln!("roundabout: can't reach the daemon: {e}"); |
| 258 | return 1; |
| 259 | } |
| 260 | }; |
| 261 | if let Err(e) = proto::send(&mut s, &Request::Expect { session, need }) { |
| 262 | eprintln!("roundabout: {e}"); |
| 263 | return 1; |
| 264 | } |
| 265 | reply(Reader::new(&s).and_then(|mut r| r.recv())) |
| 266 | } |
| 267 | |
| 268 | fn status(json: bool) -> i32 { |
| 269 | let Ok(Some(Event::Status(st))) = ask(&Request::Status) else { |
| 270 | if json { |
| 271 | println!("null"); |
| 272 | } else { |
| 273 | println!("idle"); |
| 274 | } |
| 275 | return 0; |
| 276 | }; |
| 277 | if json { |
| 278 | println!("{}", serde_json::to_string(&st).unwrap_or_default()); |
| 279 | return 0; |
| 280 | } |
| 281 | println!( |
| 282 | "ram {} free of {}, cores {} free of {}", |
| 283 | st.ram_free, st.ram_budget, st.cores_free, st.cores |
| 284 | ); |
| 285 | for l in &st.leases { |
| 286 | let state = if l.running { |
| 287 | format!( |
| 288 | "running {}{}", |
| 289 | Dur::secs(l.elapsed), |
| 290 | if l.overdue { " OVERDUE" } else { "" } |
| 291 | ) |
| 292 | } else { |
| 293 | format!( |
| 294 | "#{} {}", |
| 295 | l.position, |
| 296 | l.waiting.map(|r| r.to_string()).unwrap_or_default() |
| 297 | ) |
| 298 | }; |
| 299 | let cores = if l.granted > l.need.cores { |
| 300 | format!("{}+{}", l.need.cores, l.granted - l.need.cores) |
| 301 | } else { |
| 302 | l.need.cores.to_string() |
| 303 | }; |
| 304 | println!( |
| 305 | "{:>4} {}{} {} ram {} cores {} rss {} {}", |
| 306 | l.id, |
| 307 | if l.heavy { "H " } else { "" }, |
| 308 | state, |
| 309 | Dur(l.need.time), |
| 310 | l.need.ram, |
| 311 | cores, |
| 312 | l.rss, |
| 313 | l.cmd.join(" ") |
| 314 | ); |
| 315 | } |
| 316 | for i in &st.intents { |
| 317 | println!( |
| 318 | " expect {} ram {} cores {}, {} left", |
| 319 | &i.session[..i.session.len().min(8)], |
| 320 | i.need.ram, |
| 321 | i.need.cores, |
| 322 | Dur::secs(i.expires_in) |
| 323 | ); |
| 324 | } |
| 325 | 0 |
| 326 | } |
| 327 | |
| 328 | fn suggest(cfg: &Config, cmd: &[String]) -> i32 { |
| 329 | let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); |
| 330 | let key = history::key(&paths::project_root(&cwd), cmd); |
| 331 | let Some(rec) = history::lookup(&key) else { |
| 332 | println!("never seen it, pick a --size"); |
| 333 | return 1; |
| 334 | }; |
| 335 | let base = match run::base(cfg) { |
| 336 | Ok(b) => b, |
| 337 | Err(e) => { |
| 338 | eprintln!("roundabout: {e}"); |
| 339 | return 2; |
| 340 | } |
| 341 | }; |
| 342 | let n = history::suggest(&rec, base, cmd); |
| 343 | println!( |
| 344 | "--ram {} --cores {} --time {} --disk {}", |
| 345 | n.ram, |
| 346 | n.cores, |
| 347 | Dur(n.time), |
| 348 | n.disk |
| 349 | ); |
| 350 | 0 |
| 351 | } |
| 352 | |
| 353 | fn watch(lease: u64) -> i32 { |
| 354 | let Some(mut s): Option<UnixStream> = client::connect() else { |
| 355 | return 0; |
| 356 | }; |
| 357 | if proto::send(&mut s, &Request::Watch { lease }).is_err() { |
| 358 | return 0; |
| 359 | } |
| 360 | if matches!( |
| 361 | Reader::new(&s).and_then(|mut r| r.recv::<Event>()), |
| 362 | Ok(Some(Event::Fired { overdue: true })) |
| 363 | ) { |
| 364 | println!("lease {lease} is overdue, `roundabout kill {lease}` or `roundabout extend {lease} <dur>`"); |
| 365 | 3 |
| 366 | } else { |
| 367 | println!("lease {lease} is done"); |
| 368 | 0 |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | pub fn dispatch(cfg: &Config, cmd: Rabt) -> i32 { |
| 373 | match cmd { |
| 374 | Rabt::Run { |
| 375 | size, |
| 376 | ram, |
| 377 | disk, |
| 378 | cores, |
| 379 | time, |
| 380 | deadline, |
| 381 | max_wait, |
| 382 | session, |
| 383 | exact, |
| 384 | cmd, |
| 385 | } => run::run( |
| 386 | cfg, |
| 387 | &run::Args { |
| 388 | size, |
| 389 | ram, |
| 390 | disk, |
| 391 | cores, |
| 392 | time, |
| 393 | deadline, |
| 394 | max_wait, |
| 395 | session: agent_session(session), |
| 396 | exact, |
| 397 | cmd, |
| 398 | }, |
| 399 | ), |
| 400 | Rabt::Expect { |
| 401 | size, |
| 402 | ram, |
| 403 | cores, |
| 404 | done, |
| 405 | session, |
| 406 | } => expect( |
| 407 | cfg, |
| 408 | agent_session(session), |
| 409 | done, |
| 410 | &run::Args { |
| 411 | size, |
| 412 | ram, |
| 413 | cores, |
| 414 | ..run::Args::default() |
| 415 | }, |
| 416 | ), |
| 417 | Rabt::Status { json } => status(json), |
| 418 | Rabt::Suggest { cmd } => suggest(cfg, &cmd), |
| 419 | Rabt::Kill { lease } => reply(ask(&Request::Kill { lease })), |
| 420 | Rabt::Extend { lease, by } => reply(ask(&Request::Extend { lease, by })), |
| 421 | Rabt::Watch { lease } => watch(lease), |
| 422 | Rabt::Artifacts { action } => artifacts(cfg, action), |
| 423 | Rabt::Hook { event } => hook::main(cfg, &event), |
| 424 | Rabt::Daemon => daemon::main(Config::load().unwrap_or_default()), |
| 425 | } |
| 426 | } |