use std::fs::OpenOptions;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::Duration;
use crate::store::{self, Job, Result, Session, Store};
const RUNNER_LOCK: &str = "runner.lock";
const JOB_VAR: &str = "PMA_JOB";
pub fn current() -> Option<i64> {
std::env::var(JOB_VAR).ok()?.parse().ok()
}
pub fn enqueue(store: &Store) -> Result<()> {
let args: Vec<String> = std::env::args()
.skip(1)
.filter(|a| a != "--detach")
.collect();
let id = queue(store, &args, &std::env::current_dir()?)?;
println!("job {id} queued; `pma job` lists jobs and `pma job log {id}` shows its output");
Ok(())
}
pub fn queue(store: &Store, args: &[String], cwd: &std::path::Path) -> Result<i64> {
let id = store.add_job(args, cwd)?;
spawn_runner()?;
Ok(id)
}
pub fn approve(store: &Store, id: i64) -> Result<String> {
let job = store.job(id)?;
let Some(args) = job.awaiting.filter(|_| job.state == "awaiting-approval") else {
return Err(format!("job {id} is {}, not awaiting approval", job.state).into());
};
if !store.approve_job(id)? {
return Err(format!("job {id} was approved already").into());
}
let next = queue(store, &args, &job.cwd)?;
Ok(format!("job {next} queued: pma {}", args.join(" ")))
}
fn spawn_runner() -> Result<()> {
use std::os::unix::process::CommandExt;
let state = store::state_home()?;
std::fs::create_dir_all(&state).map_err(|e| format!("{}: {e}", state.display()))?;
let path = state.join("runner.log");
let log = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("{}: {e}", path.display()))?;
Command::new(std::env::current_exe()?)
.arg("runner")
.stdin(Stdio::null())
.stdout(log.try_clone()?)
.stderr(log)
.process_group(0)
.spawn()?;
Ok(())
}
pub fn run() -> Result<()> {
let home = store::home()?;
loop {
let Some(lock) = Session::try_lock(&home, RUNNER_LOCK)? else {
return Ok(());
};
let store = Store::open_default()?;
store.fail_running_jobs()?;
while let Some(job) = store.claim_job()? {
if let Err(e) = run_one(&store, &home, &job) {
eprintln!("job {}: {e}", job.id);
store.finish_job(job.id, None)?;
}
if let Err(e) = notify(&store, job.id) {
eprintln!("job {}: notify: {e}", job.id);
}
}
drop(lock);
if !store.has_queued_jobs()? {
return Ok(());
}
}
}
fn run_one(store: &Store, home: &std::path::Path, job: &Job) -> Result<()> {
while Session::try_acquire(home)?.is_none() {
if store.job(job.id)?.state == "stopped" {
return Ok(());
}
std::thread::sleep(Duration::from_secs(2));
}
let log = log_path(job.id)?;
let mut cmd = Command::new(std::env::current_exe()?);
cmd.args(&job.args)
.current_dir(&job.cwd)
.env(JOB_VAR, job.id.to_string());
let (mut child, lifeline) = crate::agent::spawn_guarded(cmd, None, &log)?;
store.set_job_pid(job.id, child.id())?;
if store.job(job.id)?.state == "stopped" {
kill_group(child.id().into());
}
let status = child.wait()?;
drop(lifeline);
store.finish_job(job.id, status.code())?;
Ok(())
}
fn notify(store: &Store, id: i64) -> Result<()> {
let rows = store.config_rows()?;
let cfg =
crate::config::Config::with_overrides(rows.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
let Some(command) = cfg.notify.filter(|c| !c.trim().is_empty()) else {
return Ok(());
};
let job = store.job(id)?;
let mut summary = format!("pma job {id} {}: pma {}", job.state, job.args.join(" "));
if job.state == "awaiting-approval" {
summary.push_str(&format!("; `pma job approve {id}` runs its plan"));
}
let mut cmd = Command::new("sh");
cmd.args(["-c", &command])
.env("PMA_JOB_ID", id.to_string())
.env("PMA_JOB_STATE", &job.state)
.env("PMA_JOB_SUMMARY", &summary);
let log = log_path(id)?.with_extension("notify.log");
crate::agent::run_limited(cmd, None, &log, Duration::from_secs(30))?;
Ok(())
}
pub fn log_path(id: i64) -> Result<PathBuf> {
let dir = store::state_home()?.join("jobs");
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
Ok(dir.join(format!("{id}.log")))
}
pub fn stop(store: &Store, id: i64) -> Result<String> {
let job = store.job(id)?;
let was = store.stop_job(id)?;
Ok(match was.as_str() {
"queued" => format!("job {id} stopped before it ran"),
"running" => {
if let Some(pid) = job.pid {
kill_group(pid);
}
format!("job {id} stopped; its agents were killed")
}
other => format!("job {id} is {other}; nothing to stop"),
})
}
fn kill_group(pid: i64) {
let _ = Command::new("kill")
.args(["-TERM", "--", &format!("-{pid}")])
.status();
}
pub fn list(jobs: &[Job]) -> String {
if jobs.is_empty() {
return "no jobs. `--detach` on `pma dispatch` or `pma workflow run` queues one\n".into();
}
let mut out = String::new();
for j in jobs {
let exit = j.exit_code.map_or("-".into(), |c| c.to_string());
out.push_str(&format!(
"#{:<4} {:<17} exit {:<3} {} pma {}\n",
j.id,
j.state,
exit,
crate::dates::format(crate::dates::day(j.created_at)),
j.args.join(" ")
));
if j.state == "awaiting-approval" {
out.push_str(&format!(
" `pma job log {}` shows the plan; `pma job approve {}` runs it\n",
j.id, j.id
));
}
}
out
}