atagen

roundabout

public
Star0
Watch0
ecc1cf8df0f5b88c6bf2cd07254c98d13827cd26
1
ssh://brewery@pls.lobotomise.me:33/atagen/roundabout.git
src/cli.rsRust406 lines10.9 KB
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 = "rabt", 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, env = "CLAUDE_CODE_SESSION_ID")]
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, env = "CLAUDE_CODE_SESSION_ID")]
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
    /// claude code hook entrypoint, json on stdin
83
    Hook {
84
        /// pre-tool-use post-tool-use prompt-submit stop subagent-stop session-end
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
        #[pound(short = 'n', long)]
107
        dry_run: bool,
108
    },
109
    /// never gc this
110
    Pin {
111
        path: PathBuf,
112
    },
113
    Unpin {
114
        path: PathBuf,
115
    },
116
}
117
118
fn artifacts(cfg: &Config, action: Artifacts) -> i32 {
119
    let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
120
    let root = paths::project_root(&cwd);
121
    match action {
122
        Artifacts::Check { fresh } => {
123
            let r = artifacts::check(cfg, Some(&root), fresh);
124
            if let Some((root, size)) = &r.project {
125
                println!(
126
                    "{}: {size} of {}",
127
                    root.display(),
128
                    cfg.artifacts.project_cap
129
                );
130
            }
131
            println!("everything: {} of {}", r.global, cfg.artifacts.global_cap);
132
            if r.over() {
133
                println!("over. `rabt artifacts gc --cap`");
134
                1
135
            } else {
136
                0
137
            }
138
        }
139
        Artifacts::List => {
140
            let r = artifacts::check(cfg, Some(&root), false);
141
            let mut rows: Vec<_> = r.entries.iter().collect();
142
            rows.sort_by_key(|(_, e)| std::cmp::Reverse(e.size));
143
            for (path, e) in rows {
144
                println!(
145
                    "{:>10}  {}{}",
146
                    e.size.to_string(),
147
                    path.display(),
148
                    if e.pinned { "  (pinned)" } else { "" }
149
                );
150
            }
151
            0
152
        }
153
        Artifacts::Gc { cap, dry_run } => {
154
            let g = artifacts::gc(cfg, cap, dry_run);
155
            for p in &g.removed {
156
                println!(
157
                    "{}{}",
158
                    if dry_run { "would remove " } else { "removed " },
159
                    p.display()
160
                );
161
            }
162
            for f in &g.failed {
163
                eprintln!("rabt: {f}");
164
            }
165
            println!(
166
                "{} {}",
167
                if dry_run { "would free" } else { "freed" },
168
                g.freed
169
            );
170
            i32::from(!g.failed.is_empty())
171
        }
172
        Artifacts::Pin { path } | Artifacts::Unpin { path } if !path.exists() => {
173
            eprintln!("rabt: {} doesn't exist", path.display());
174
            1
175
        }
176
        Artifacts::Pin { path } => report(artifacts::pin(&path, true)),
177
        Artifacts::Unpin { path } => report(artifacts::pin(&path, false)),
178
    }
179
}
180
181
fn report(r: Result<(), String>) -> i32 {
182
    r.map_or_else(
183
        |e| {
184
            eprintln!("rabt: {e}");
185
            1
186
        },
187
        |()| 0,
188
    )
189
}
190
191
fn ask(req: &Request) -> io::Result<Option<Event>> {
192
    let Some(mut s) = client::connect() else {
193
        return Ok(None);
194
    };
195
    proto::send(&mut s, req)?;
196
    Reader::new(&s)?.recv()
197
}
198
199
fn reply(ev: io::Result<Option<Event>>) -> i32 {
200
    match ev {
201
        Ok(Some(Event::Ok)) => 0,
202
        Ok(Some(Event::Err { msg })) => {
203
            eprintln!("rabt: {msg}");
204
            1
205
        }
206
        Ok(None) => {
207
            eprintln!("rabt: nothing running");
208
            1
209
        }
210
        Ok(Some(_)) => 1,
211
        Err(e) => {
212
            eprintln!("rabt: {e}");
213
            1
214
        }
215
    }
216
}
217
218
fn expect(cfg: &Config, session: Option<String>, done: bool, a: &run::Args) -> i32 {
219
    let Some(session) = session.filter(|s| !s.is_empty()) else {
220
        eprintln!("rabt: no CLAUDE_CODE_SESSION_ID, so nobody to expect on behalf of");
221
        return 2;
222
    };
223
    let need = if done {
224
        None
225
    } else {
226
        match run::need(cfg, a) {
227
            Ok(n) => Some(n),
228
            Err(e) => {
229
                eprintln!("rabt: {e}");
230
                return 2;
231
            }
232
        }
233
    };
234
    let mut s = match client::connect_or_spawn() {
235
        Ok(s) => s,
236
        Err(e) => {
237
            eprintln!("rabt: can't reach the daemon: {e}");
238
            return 1;
239
        }
240
    };
241
    if let Err(e) = proto::send(&mut s, &Request::Expect { session, need }) {
242
        eprintln!("rabt: {e}");
243
        return 1;
244
    }
245
    reply(Reader::new(&s).and_then(|mut r| r.recv()))
246
}
247
248
fn status(json: bool) -> i32 {
249
    let Ok(Some(Event::Status(st))) = ask(&Request::Status) else {
250
        if json {
251
            println!("null");
252
        } else {
253
            println!("idle");
254
        }
255
        return 0;
256
    };
257
    if json {
258
        println!("{}", serde_json::to_string(&st).unwrap_or_default());
259
        return 0;
260
    }
261
    println!(
262
        "ram {} free of {}, cores {} free of {}",
263
        st.ram_free, st.ram_budget, st.cores_free, st.cores
264
    );
265
    for l in &st.leases {
266
        let state = if l.running {
267
            format!(
268
                "running {}{}",
269
                Dur::secs(l.elapsed),
270
                if l.overdue { " OVERDUE" } else { "" }
271
            )
272
        } else {
273
            format!(
274
                "#{} {}",
275
                l.position,
276
                l.waiting.map(|r| r.to_string()).unwrap_or_default()
277
            )
278
        };
279
        let cores = if l.granted > l.need.cores {
280
            format!("{}+{}", l.need.cores, l.granted - l.need.cores)
281
        } else {
282
            l.need.cores.to_string()
283
        };
284
        println!(
285
            "{:>4} {}{} {} ram {} cores {} rss {}  {}",
286
            l.id,
287
            if l.heavy { "H " } else { "" },
288
            state,
289
            Dur(l.need.time),
290
            l.need.ram,
291
            cores,
292
            l.rss,
293
            l.cmd.join(" ")
294
        );
295
    }
296
    for i in &st.intents {
297
        println!(
298
            "     expect {} ram {} cores {}, {} left",
299
            &i.session[..i.session.len().min(8)],
300
            i.need.ram,
301
            i.need.cores,
302
            Dur::secs(i.expires_in)
303
        );
304
    }
305
    0
306
}
307
308
fn suggest(cfg: &Config, cmd: &[String]) -> i32 {
309
    let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
310
    let key = history::key(&paths::project_root(&cwd), cmd);
311
    let Some(rec) = history::lookup(&key) else {
312
        println!("never seen it, pick a --size");
313
        return 1;
314
    };
315
    let base = match run::base(cfg) {
316
        Ok(b) => b,
317
        Err(e) => {
318
            eprintln!("rabt: {e}");
319
            return 2;
320
        }
321
    };
322
    let n = history::suggest(&rec, base, cmd);
323
    println!(
324
        "--ram {} --cores {} --time {} --disk {}",
325
        n.ram,
326
        n.cores,
327
        Dur(n.time),
328
        n.disk
329
    );
330
    0
331
}
332
333
fn watch(lease: u64) -> i32 {
334
    let Some(mut s): Option<UnixStream> = client::connect() else {
335
        return 0;
336
    };
337
    if proto::send(&mut s, &Request::Watch { lease }).is_err() {
338
        return 0;
339
    }
340
    if matches!(
341
        Reader::new(&s).and_then(|mut r| r.recv::<Event>()),
342
        Ok(Some(Event::Fired { overdue: true }))
343
    ) {
344
        println!("lease {lease} is overdue, `rabt kill {lease}` or `rabt extend {lease} <dur>`");
345
        3
346
    } else {
347
        println!("lease {lease} is done");
348
        0
349
    }
350
}
351
352
pub fn dispatch(cfg: &Config, cmd: Rabt) -> i32 {
353
    match cmd {
354
        Rabt::Run {
355
            size,
356
            ram,
357
            disk,
358
            cores,
359
            time,
360
            deadline,
361
            max_wait,
362
            session,
363
            exact,
364
            cmd,
365
        } => run::run(
366
            cfg,
367
            &run::Args {
368
                size,
369
                ram,
370
                disk,
371
                cores,
372
                time,
373
                deadline,
374
                max_wait,
375
                session,
376
                exact,
377
                cmd,
378
            },
379
        ),
380
        Rabt::Expect {
381
            size,
382
            ram,
383
            cores,
384
            done,
385
            session,
386
        } => expect(
387
            cfg,
388
            session,
389
            done,
390
            &run::Args {
391
                size,
392
                ram,
393
                cores,
394
                ..run::Args::default()
395
            },
396
        ),
397
        Rabt::Status { json } => status(json),
398
        Rabt::Suggest { cmd } => suggest(cfg, &cmd),
399
        Rabt::Kill { lease } => reply(ask(&Request::Kill { lease })),
400
        Rabt::Extend { lease, by } => reply(ask(&Request::Extend { lease, by })),
401
        Rabt::Watch { lease } => watch(lease),
402
        Rabt::Artifacts { action } => artifacts(cfg, action),
403
        Rabt::Hook { event } => hook::main(cfg, &event),
404
        Rabt::Daemon => daemon::main(Config::load().unwrap_or_default()),
405
    }
406
}