use crate::session::{ActivityState, Session};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const KEEP: usize = 20;
const UNSEEN_GRACE: Duration = Duration::from_secs(60);
const NAMELESS_COOLDOWN: Duration = Duration::from_secs(10);
const LOCK_PATIENCE: Duration = Duration::from_millis(500);
const STATE: &str = "yolo.json";
const LOCK: &str = "yolo.lock";
const OWNER: &str = "yolo.owner";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Allowed {
pub at: String,
pub ask: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entry {
pub since: String,
#[serde(default)]
pub allowed: Vec<Allowed>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct State {
#[serde(default)]
sessions: BTreeMap<String, Entry>,
}
fn dir() -> PathBuf {
crate::config::runtime_base().join("cctop")
}
fn stamp_now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn read_state(dir: &Path) -> State {
std::fs::read(dir.join(STATE))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
fn flock(file: &std::fs::File) -> bool {
use std::os::fd::AsRawFd;
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) == 0 }
}
fn open_lock(path: &Path) -> std::io::Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(path)
}
fn update(dir: &Path, change: impl FnOnce(&mut State)) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let lock = open_lock(&dir.join(LOCK))?;
let started = Instant::now();
while !flock(&lock) {
if started.elapsed() >= LOCK_PATIENCE {
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"another cctop is holding the YOLO file",
));
}
std::thread::sleep(Duration::from_millis(2));
}
let mut state = read_state(dir);
change(&mut state);
let json = serde_json::to_vec(&state).map_err(std::io::Error::other)?;
let tmp = dir.join("yolo.json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, dir.join(STATE))
}
pub fn set(session_id: &str, on: bool) -> std::io::Result<()> {
set_in(&dir(), session_id, on)
}
fn set_in(dir: &Path, session_id: &str, on: bool) -> std::io::Result<()> {
let id = session_id.to_string();
let result = update(dir, |state| match on {
true => {
state.sessions.entry(id).or_insert_with(|| Entry {
since: stamp_now(),
allowed: Vec::new(),
});
}
false => {
state.sessions.remove(&id);
}
});
crate::elog::event(
"yolo",
if on { "on" } else { "off" },
serde_json::json!({ "session": session_id, "ok": result.is_ok() }),
);
result
}
#[derive(Debug, Clone)]
struct Seen {
ask: Option<String>,
at: Instant,
cleared: bool,
}
fn due(seen: &mut HashMap<String, Seen>, session: &Session, now: Instant) -> bool {
let asking = session.activity_state == ActivityState::Asking && !session.asking_question;
if !asking || session.yolo.is_none() {
if let Some(prev) = seen.get_mut(&session.session_id) {
prev.cleared = true;
}
return false;
}
match seen.get(&session.session_id) {
None => true,
Some(prev) if !prev.cleared && prev.ask == session.asking_for => false,
Some(prev) if session.asking_for.is_none() => {
now.duration_since(prev.at) >= NAMELESS_COOLDOWN
}
Some(_) => true,
}
}
pub struct Auto {
dir: PathBuf,
may_answer: bool,
owner: Option<std::fs::File>,
read: Option<(u64, u64, Option<std::time::SystemTime>)>,
entries: HashMap<String, Arc<Entry>>,
seen: HashMap<String, Seen>,
}
type Press<'a> = dyn FnMut(&Session) -> Result<(), String> + 'a;
impl Auto {
pub fn new(may_answer: bool) -> Auto {
Auto::in_dir(dir(), may_answer)
}
fn in_dir(dir: PathBuf, may_answer: bool) -> Auto {
Auto {
dir,
may_answer,
owner: None,
read: None,
entries: HashMap::new(),
seen: HashMap::new(),
}
}
fn reload(&mut self) -> bool {
use std::os::unix::fs::MetadataExt;
let stamp = std::fs::metadata(self.dir.join(STATE))
.ok()
.map(|m| (m.ino(), m.len(), m.modified().ok()));
if stamp == self.read {
return false;
}
self.read = stamp;
let entries: HashMap<String, Arc<Entry>> = read_state(&self.dir)
.sessions
.into_iter()
.map(|(id, entry)| (id, Arc::new(entry)))
.collect();
let changed = entries != self.entries;
self.entries = entries;
changed
}
pub fn stamp(&self, sessions: &mut [Session]) -> bool {
let mut changed = false;
for session in sessions {
let want = match session.remote.is_none() && session.is_running() {
true => self.entries.get(&session.session_id).cloned(),
false => None,
};
if want != session.yolo {
session.yolo = want;
changed = true;
}
}
changed
}
pub fn tick(&mut self, sessions: &mut [Session]) -> bool {
self.tick_with(sessions, &mut |session| {
crate::serve::actions::answer(session, "allow")
.map(|_| ())
.map_err(|(_, why)| why)
})
}
fn tick_with(&mut self, sessions: &mut [Session], press: &mut Press<'_>) -> bool {
let mut changed = self.reload();
changed |= self.stamp(sessions);
self.seen
.retain(|id, _| sessions.iter().any(|s| &s.session_id == id));
if self.entries.is_empty() || !self.may_answer || !self.own() {
return changed;
}
let now = Instant::now();
let mut allowed: Vec<(String, Allowed)> = Vec::new();
for session in sessions.iter_mut() {
if !due(&mut self.seen, session, now) {
continue;
}
self.seen.insert(
session.session_id.clone(),
Seen {
ask: session.asking_for.clone(),
at: now,
cleared: false,
},
);
let ask = session
.asking_for
.clone()
.unwrap_or_else(|| "a permission prompt".to_string());
match press(session) {
Ok(()) => {
crate::elog::event(
"yolo",
"allowed",
serde_json::json!({ "session": session.session_id, "ask": ask }),
);
session.activity_state = ActivityState::Working;
session.asking_for = None;
session.asking_question = false;
allowed.push((
session.session_id.clone(),
Allowed {
at: stamp_now(),
ask,
},
));
changed = true;
}
Err(why) => crate::elog::event(
"yolo",
"failed",
serde_json::json!({ "session": session.session_id, "ask": ask, "why": why }),
),
}
}
let ended = self.ended(sessions);
if !allowed.is_empty() || !ended.is_empty() {
let result = update(&self.dir, |state| {
for id in &ended {
state.sessions.remove(id);
}
for (id, answer) in allowed {
if let Some(entry) = state.sessions.get_mut(&id) {
entry.allowed.push(answer);
let over = entry.allowed.len().saturating_sub(KEEP);
entry.allowed.drain(..over);
}
}
});
if !ended.is_empty() {
crate::elog::event(
"yolo",
"ended",
serde_json::json!({ "sessions": ended, "ok": result.is_ok() }),
);
}
changed |= self.reload();
changed |= self.stamp(sessions);
}
changed
}
fn ended(&self, sessions: &[Session]) -> Vec<String> {
let now = chrono::Utc::now();
self.entries
.iter()
.filter(|(id, entry)| {
let running = sessions
.iter()
.any(|s| &s.session_id == *id && s.remote.is_none() && s.is_running());
let old = chrono::DateTime::parse_from_rfc3339(&entry.since)
.map(|since| {
(now - since.with_timezone(&chrono::Utc))
.to_std()
.unwrap_or_default()
>= UNSEEN_GRACE
})
.unwrap_or(true);
!running && old
})
.map(|(id, _)| id.clone())
.collect()
}
fn own(&mut self) -> bool {
if self.owner.is_some() {
return true;
}
let _ = std::fs::create_dir_all(&self.dir);
let Ok(file) = open_lock(&self.dir.join(OWNER)) else {
return false;
};
if !flock(&file) {
return false;
}
crate::elog::event(
"yolo",
"owner",
serde_json::json!({ "pid": std::process::id() }),
);
self.owner = Some(file);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pricing::Provider;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"cctop-yolo-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn asking(id: &str, ask: Option<&str>) -> Session {
let mut s = Session::new(Provider::Claude, id.into());
s.process = Some(crate::proc::ProcInfo {
pids: 1,
process_list: vec![crate::proc::ProcEntry {
pid: 999_999_999,
cpu: 0.0,
memory: 0,
args: "claude".into(),
is_root: true,
ghost: false,
}],
..Default::default()
});
s.activity_state = ActivityState::Asking;
s.asking_for = ask.map(str::to_string);
s
}
fn quiet(s: &mut Session) {
s.activity_state = ActivityState::Working;
s.asking_for = None;
}
#[test]
fn a_prompt_is_answered_once_and_the_next_one_again() {
let mut seen = HashMap::new();
let mut s = asking("a", Some("Bash: ls"));
s.yolo = Some(Arc::default());
let t = Instant::now();
let answer = |seen: &mut HashMap<String, Seen>, s: &Session, at| {
let due = due(seen, s, at);
if due {
seen.insert(
s.session_id.clone(),
Seen {
ask: s.asking_for.clone(),
at,
cleared: false,
},
);
}
due
};
assert!(answer(&mut seen, &s, t));
for _ in 0..5 {
assert!(
!answer(&mut seen, &s, t),
"the same prompt was pressed twice"
);
}
s.asking_for = Some("Bash: rm -rf build".into());
assert!(answer(&mut seen, &s, t));
quiet(&mut s);
assert!(!answer(&mut seen, &s, t));
s.activity_state = ActivityState::Asking;
s.asking_for = Some("Bash: rm -rf build".into());
assert!(answer(&mut seen, &s, t));
quiet(&mut s);
assert!(!answer(&mut seen, &s, t));
s.activity_state = ActivityState::Asking;
assert!(!answer(&mut seen, &s, t + Duration::from_secs(1)));
assert!(answer(&mut seen, &s, t + NAMELESS_COOLDOWN));
}
#[test]
fn questions_and_sessions_without_yolo_are_left_alone() {
let mut seen = HashMap::new();
let mut s = asking("q", Some("Which framework?"));
s.yolo = Some(Arc::default());
s.asking_question = true;
assert!(!due(&mut seen, &s, Instant::now()));
let plain = asking("p", Some("Bash: ls"));
assert!(!due(&mut seen, &plain, Instant::now()));
}
#[test]
fn the_switch_round_trips_and_ends_with_its_session() {
let dir = scratch("roundtrip");
set_in(&dir, "a", true).unwrap();
set_in(&dir, "b", true).unwrap();
set_in(&dir, "c", true).unwrap();
set_in(&dir, "c", false).unwrap();
let state = read_state(&dir);
assert_eq!(state.sessions.keys().collect::<Vec<_>>(), ["a", "b"]);
let mut auto = Auto::in_dir(dir.clone(), true);
let mut running = asking("a", None);
quiet(&mut running);
let stopped = Session::new(Provider::Claude, "b".into());
let mut rows = vec![running, stopped];
let mut never = |_: &Session| -> Result<(), String> { panic!("nothing is asking") };
assert!(auto.tick_with(&mut rows, &mut never));
assert!(rows[0].yolo.is_some(), "a running row carries its switch");
assert!(rows[1].yolo.is_none(), "a stopped row does not");
assert_eq!(read_state(&dir).sessions.len(), 2);
update(&dir, |s| {
for entry in s.sessions.values_mut() {
entry.since = "2000-01-01T00:00:00Z".into();
}
})
.unwrap();
auto.tick_with(&mut rows, &mut never);
assert_eq!(
read_state(&dir).sessions.keys().collect::<Vec<_>>(),
["a"],
"the ended session kept its switch"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn only_one_process_answers() {
let dir = scratch("owner");
set_in(&dir, "a", true).unwrap();
let mut first = Auto::in_dir(dir.clone(), true);
let mut second = Auto::in_dir(dir.clone(), true);
let mut presses = 0;
let mut rows = vec![asking("a", Some("Bash: ls"))];
first.tick_with(&mut rows, &mut |_| {
presses += 1;
Ok(())
});
let mut rows = vec![asking("a", Some("Bash: make"))];
second.tick_with(&mut rows, &mut |_| {
presses += 10;
Ok(())
});
assert_eq!(presses, 1, "the second cctop pressed too");
assert!(rows[0].yolo.is_some(), "the second cctop still shows it");
drop(first);
second.tick_with(&mut rows, &mut |_| {
presses += 10;
Ok(())
});
assert_eq!(presses, 11);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_read_only_process_never_presses() {
let dir = scratch("readonly");
set_in(&dir, "a", true).unwrap();
let mut auto = Auto::in_dir(dir.clone(), false);
let mut rows = vec![asking("a", Some("Bash: ls"))];
auto.tick_with(&mut rows, &mut |_| panic!("a read-only serve pressed"));
assert!(rows[0].yolo.is_some());
assert!(auto.owner.is_none(), "it competed for the job");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_answer_is_recorded_and_the_prompt_comes_down() {
let dir = scratch("record");
set_in(&dir, "a", true).unwrap();
let mut auto = Auto::in_dir(dir.clone(), true);
let mut rows = vec![asking("a", Some("Bash: cargo test"))];
assert!(auto.tick_with(&mut rows, &mut |_| Ok(())));
assert_eq!(rows[0].activity_state, ActivityState::Working);
let entry = rows[0].yolo.clone().expect("still on");
assert_eq!(entry.allowed.len(), 1);
assert_eq!(entry.allowed[0].ask, "Bash: cargo test");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn one_keypress_per_prompt_through_the_real_answer() {
use std::io::Read;
let _base = crate::config::claim_test_runtime_base("yolo-e2e");
let pid = 999_999_999u32;
let path = crate::shim::socket_path(pid).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let _ = std::fs::remove_file(&path);
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
listener.set_nonblocking(true).unwrap();
let drain = |listener: &std::os::unix::net::UnixListener| {
let mut keys = Vec::new();
while let Ok((mut conn, _)) = listener.accept() {
conn.set_nonblocking(false).unwrap();
let mut got = Vec::new();
let _ = conn.read_to_end(&mut got);
keys.extend(got);
}
keys
};
set("e2e", true).unwrap();
let mut auto = Auto::new(true);
let mut row = asking("e2e", Some("Bash: ls"));
for _ in 0..4 {
let mut rows = vec![row.clone()];
auto.tick(&mut rows);
}
assert_eq!(drain(&listener), b"1", "one prompt, one press");
quiet(&mut row);
auto.tick(&mut [row.clone()]);
row.activity_state = ActivityState::Asking;
row.asking_for = Some("Bash: ls -la".into());
for _ in 0..3 {
auto.tick(&mut [row.clone()]);
}
assert_eq!(drain(&listener), b"1", "the next prompt, one more press");
let allowed = &auto.entries["e2e"].allowed;
assert_eq!(allowed.len(), 2);
let _ = std::fs::remove_file(&path);
}
}