use crate::config::Config;
use crate::paths::Paths;
use crate::util::Level;
use crate::{seed, session, tick, util};
use anyhow::Result;
use std::fs;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
pub(crate) fn session_ttl_secs(paths: &Paths) -> u64 {
const DEFAULT: u64 = 3 * 24 * 60 * 60; if let Ok(v) = std::env::var("LOOOP_SESSION_TTL")
&& let Ok(n) = v.trim().parse::<u64>()
{
return n;
}
Config::load(paths)
.ok()
.and_then(|c| {
c.root
.get("session_ttl")
.and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
})
.unwrap_or(DEFAULT)
}
fn interval(env: &str, cfg: &Config, key: &str, fallback: u64) -> u64 {
if let Ok(v) = std::env::var(env)
&& let Ok(n) = v.trim().parse::<u64>()
{
return n;
}
cfg.root
.get(key)
.and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
.unwrap_or(fallback)
}
struct LockGuard {
path: PathBuf,
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn acquire_lock(paths: &Paths) -> Option<LockGuard> {
let lock = paths.lock();
if fs::create_dir(&lock).is_err() {
let oldpid = fs::read_to_string(lock.join("pid")).unwrap_or_default();
if util::pid_alive(oldpid.trim()) {
return None;
}
let _ = fs::remove_dir_all(&lock);
if fs::create_dir(&lock).is_err() {
return None;
}
}
let _ = fs::write(lock.join("pid"), format!("{}\n", std::process::id()));
Some(LockGuard { path: lock })
}
pub fn cmd_run(paths: &Paths) -> Result<ExitCode> {
seed::ensure_dirs(paths)?;
let cfg = Config::load(paths)?;
let idle = interval("LOOOP_INTERVAL", &cfg, "interval", 60);
let busy = interval("LOOOP_BUSY_INTERVAL", &cfg, "busy_interval", idle);
let active = interval("LOOOP_ACTIVE_INTERVAL", &cfg, "active_interval", idle);
let Some(_guard) = acquire_lock(paths) else {
let oldpid = fs::read_to_string(paths.lock().join("pid")).unwrap_or_default();
eprintln!("looop: already running (pid {})", oldpid.trim());
return Ok(ExitCode::from(1));
};
let runner_name = cfg.default_runner().unwrap_or_else(|| "?".into());
util::event(
Level::Ok,
"pulse.start",
&format!("pulse started · idle {idle}s / busy {busy}s · runner {runner_name}"),
&[
("idle", serde_json::json!(idle)),
("busy", serde_json::json!(busy)),
("active", serde_json::json!(active)),
("runner", serde_json::json!(runner_name)),
],
);
if !paths.default_profile {
util::event(
Level::Info,
"pulse.profile",
&format!(
"this profile's sessions live under {d} (LOOOP_DATA_DIR={d} looop ls)",
d = paths.data_dir.display()
),
&[(
"data_dir",
serde_json::json!(paths.data_dir.display().to_string()),
)],
);
}
loop {
let acted = tick::tick(paths);
let mut want = if acted {
busy
} else if session::any_worker_alive(paths) {
active
} else {
idle
};
let reqf = paths.data_dir.join(".next-interval");
if let Ok(raw) = fs::read_to_string(&reqf) {
let digits: String = raw.chars().filter(|c| c.is_ascii_digit()).collect();
let _ = fs::remove_file(&reqf);
if let Ok(mut req) = digits.parse::<u64>() {
req = req.clamp(5, 3600);
util::event(
Level::Info,
"cadence",
&format!("AI cadence override: next beat in {req}s (default {want}s)"),
&[
("secs", serde_json::json!(req)),
("default", serde_json::json!(want)),
],
);
want = req;
}
}
let reason = if acted {
"busy"
} else if session::any_worker_alive(paths) {
"active"
} else {
"idle"
};
util::event(
Level::Info,
"sleep",
&format!("next beat in {want}s ({reason})"),
&[
("secs", serde_json::json!(want)),
("reason", serde_json::json!(reason)),
],
);
std::thread::sleep(Duration::from_secs(want));
}
}