use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, OnceLock};
use serde::{Deserialize, Serialize};
pub const NAME: &str = "job";
pub const MODE: &str = "__jobs";
pub const VAR_BASH_ENV: &str = "BASH_ENV";
pub const VAR_BIN: &str = "RHEMA_JOBS_BIN";
pub const VAR_SESSION: &str = "RHEMA_SESSION";
pub const BACKEND: &str = "gob";
const MAX_ID_CHARS: usize = 16;
pub const EXIT_NOT_WIRED: i32 = 4;
pub const EXIT_NO_BACKEND: i32 = 5;
pub const PROMPT_SECTION: &str = "You can start background jobs that outlive a bash call: a server, a watcher, a build. Start one, then read it on a later turn instead of blocking:
job run \"npm run dev\" # starts it, prints a job id
job ls # the jobs this session started
job logs <id> # its output so far
job stop <id> # stop it
Use it for anything long-running. A foreground call that waits on a server times out and wastes the turn.";
const USAGE: &str = "job: background shells for this session
job run <command> start a shell line in the background
job ls [--dir] [--all] this session's jobs (--dir: this directory's,
--all: the machine's)
job logs <id> a job's output so far
job logs every job's, here
job stop <id> stop it
job wait <id> block until it finishes
job rm <id> forget a stopped job
job shutdown stop every job this session started";
pub struct Wiring {
pub shell_env: BTreeMap<String, String>,
pub section: &'static str,
pub init_path: PathBuf,
dir: PathBuf,
}
impl Drop for Wiring {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
static WIRED: OnceLock<Option<Arc<Wiring>>> = OnceLock::new();
pub fn wire(bash_env: Option<OsString>, session: &str) -> Option<&'static Arc<Wiring>> {
WIRED
.get_or_init(|| {
if !available() {
return None;
}
let bin = std::env::current_exe().ok()?;
provision_with_bin(bash_env, &bin, session)
.map(Arc::new)
.ok()
})
.as_ref()
}
enum Installed {
Absent,
Ready,
TooOld(&'static str),
}
fn installed() -> &'static Installed {
static STATE: OnceLock<Installed> = OnceLock::new();
STATE.get_or_init(|| probe(Path::new(BACKEND)))
}
fn probe(backend: &Path) -> Installed {
let Ok(help) = Command::new(backend).arg("help").arg("add").output() else {
return Installed::Absent;
};
let text = String::from_utf8_lossy(&help.stdout).into_owned();
if help.status.success() && text.contains("--description") {
return Installed::Ready;
}
Installed::TooOld(Box::leak(
format!(
"`{BACKEND}` is too old: `{NAME}` needs `{BACKEND} add --description` (gob 3.x). Install the release tarball or the Homebrew tap; `go install ...@latest` fetches 1.x, because the module path has no major-version suffix."
)
.into_boxed_str(),
))
}
pub fn available() -> bool {
matches!(installed(), Installed::Ready)
}
pub fn backend_problem() -> Option<&'static str> {
match installed() {
Installed::TooOld(reason) => Some(reason),
Installed::Absent | Installed::Ready => None,
}
}
pub fn provision_with_bin(
bash_env: Option<OsString>,
bin: &Path,
session: &str,
) -> std::io::Result<Wiring> {
let dir = std::env::temp_dir().join(format!(
"rhema-jobs-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
}
let init_path = dir.join("init.bash");
let mut text = String::from("# generated by rhema-agent-tools; do not edit\n");
if let Some(inherited) = bash_env {
let quoted = inherited.to_string_lossy().replace('\'', "'\\''");
text.push_str(&format!("source '{quoted}' || true\n"));
}
text.push_str(&format!("{NAME}() {{ \"${VAR_BIN}\" {MODE} \"$@\"; }}\n"));
std::fs::write(&init_path, text)?;
let mut shell_env = BTreeMap::new();
shell_env.insert(VAR_BASH_ENV.to_owned(), init_path.display().to_string());
shell_env.insert(VAR_BIN.to_owned(), bin.display().to_string());
shell_env.insert(VAR_SESSION.to_owned(), session.to_owned());
Ok(Wiring {
shell_env,
section: PROMPT_SECTION,
init_path,
dir,
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobRow {
pub id: String,
pub status: String,
pub exit_code: Option<i32>,
pub command: String,
pub session: Option<String>,
pub started_at: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct BackendJob {
id: String,
status: String,
command: Vec<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
exit_code: Option<i32>,
#[serde(default)]
started_at: Option<String>,
#[serde(default)]
workdir: Option<String>,
}
impl BackendJob {
fn row(&self) -> JobRow {
JobRow {
id: self.id.clone(),
status: self.status.clone(),
exit_code: self.exit_code,
command: unwrap_command(&self.command),
session: owner_of(self.description.as_deref()),
started_at: self.started_at.clone(),
}
}
}
fn unwrap_command(command: &[String]) -> String {
match command {
[shell, flag, line] if shell == "bash" && flag == "-c" => line.clone(),
_ => command.join(" "),
}
}
fn owner_of(description: Option<&str>) -> Option<String> {
let rest = description?.strip_prefix('[')?;
let (id, _) = rest.split_once(']')?;
let id = id.trim();
(!id.is_empty() && id.chars().count() <= MAX_ID_CHARS).then(|| id.to_owned())
}
fn tagged(session: &str, command: &str) -> String {
format!("[{session}] {command}")
}
pub fn cli_run(argv: &[OsString]) -> i32 {
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(err) => {
eprintln!("{NAME}: cannot read the working directory: {err}");
return 1;
}
};
let session = std::env::var(VAR_SESSION).ok();
run_cli(argv, Path::new(BACKEND), &cwd, session.as_deref())
}
pub fn run_cli(argv: &[OsString], backend: &Path, cwd: &Path, session: Option<&str>) -> i32 {
match argv.get(1).and_then(|verb| verb.to_str()) {
Some("run") => run_command(&argv[2..], backend, cwd, session),
Some("ls" | "list") => ls_command(&argv[2..], backend, cwd, session),
Some("logs") => forward(backend, cwd, "logs", &argv[2..]),
Some("stop") => forward(backend, cwd, "stop", &argv[2..]),
Some("wait") => forward(backend, cwd, "await", &argv[2..]),
Some("rm" | "remove") => forward(backend, cwd, "remove", &argv[2..]),
Some("shutdown") => shutdown_command(backend, cwd, session),
Some("help" | "--help" | "-h") | None => {
println!("{USAGE}");
0
}
Some(other) => {
eprintln!("{NAME}: unknown verb `{other}`\n\n{USAGE}");
2
}
}
}
fn run_command(args: &[OsString], backend: &Path, cwd: &Path, session: Option<&str>) -> i32 {
let Some(session) = session.filter(|session| !session.is_empty()) else {
eprintln!("{NAME}: not wired in this shell (no {VAR_SESSION})");
return EXIT_NOT_WIRED;
};
let command = args
.iter()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>()
.join(" ");
let command = command.trim();
if command.is_empty() {
eprintln!("{NAME}: run needs a command\n\n{USAGE}");
return 2;
}
let jobs = match list_backend(backend, cwd, true) {
Ok(jobs) => jobs,
Err(code) => return code,
};
if let Some(running) = jobs.iter().find(|job| {
job.status.starts_with("running")
&& unwrap_command(&job.command) == command
&& same_directory(job, cwd)
}) {
match owner_of(running.description.as_deref()) {
Some(owner) if owner != session => println!(
"already running as {} (started by session {owner}); \
not starting a second one",
running.id
),
_ => println!("already running as {}", running.id),
}
println!(" {NAME} logs {}\n {NAME} stop {}", running.id, running.id);
return 0;
}
let description = tagged(session, command);
let output = Command::new(backend)
.current_dir(cwd)
.arg("add")
.arg("--description")
.arg(&description)
.arg("--")
.arg("bash")
.arg("-c")
.arg(command)
.output();
let id = match output {
Ok(out) if out.status.success() => {
let text = String::from_utf8_lossy(&out.stdout).into_owned();
parse_added_id(&text).or_else(|| find_by_description(backend, cwd, &description))
}
Ok(out) => {
let text = String::from_utf8_lossy(&out.stderr);
eprintln!("{NAME}: the backend refused it: {}", text.trim());
return 1;
}
Err(err) => {
eprintln!("{NAME}: cannot run `{BACKEND}`: {err}");
return EXIT_NO_BACKEND;
}
};
let Some(id) = id else {
println!("started `{command}` (id unknown; `{NAME} ls`)");
return 0;
};
println!("started {id}: {command}");
println!(" {NAME} logs {id}\n {NAME} stop {id}");
0
}
fn same_directory(job: &BackendJob, cwd: &Path) -> bool {
job.workdir
.as_deref()
.is_none_or(|dir| Path::new(dir) == cwd)
}
fn parse_added_id(text: &str) -> Option<String> {
for line in text.lines() {
let mut words = line.split_whitespace();
match (words.next(), words.next()) {
(Some("Added"), Some("job")) => {
if let Some(id) = words.next() {
return Some(id.to_owned());
}
}
(Some("Job"), Some(id)) if line.contains("already running") => {
return Some(id.to_owned());
}
_ => {}
}
}
None
}
fn find_by_description(backend: &Path, cwd: &Path, description: &str) -> Option<String> {
list_backend(backend, cwd, true)
.ok()?
.iter()
.find_map(|job| (job.description.as_deref() == Some(description)).then(|| job.id.clone()))
}
fn ls_command(args: &[OsString], backend: &Path, cwd: &Path, session: Option<&str>) -> i32 {
let mut scope = Scope::Session;
let mut json = false;
let mut filter_session = session.map(str::to_owned);
let mut index = 0;
while index < args.len() {
match args[index].to_str() {
Some("--all" | "-a") => scope = Scope::Machine,
Some("--dir") => scope = Scope::Directory,
Some("--json") => json = true,
Some("--session") => {
index += 1;
filter_session = args
.get(index)
.map(|value| value.to_string_lossy().into_owned());
}
Some(other) => {
eprintln!("{NAME}: ls does not take `{other}`");
return 2;
}
None => return 2,
}
index += 1;
}
let rows = match owned_rows(backend, cwd, scope, filter_session.as_deref()) {
Ok(rows) => rows,
Err(code) => return code,
};
if json {
match serde_json::to_string(&rows) {
Ok(text) => println!("{text}"),
Err(err) => {
eprintln!("{NAME}: cannot write the listing: {err}");
return 1;
}
}
return 0;
}
if rows.is_empty() {
println!("no jobs");
return 0;
}
for row in &rows {
let exit = match row.exit_code {
_ if row.status.starts_with("running") => String::new(),
Some(code) => format!(" ({code})"),
None => String::new(),
};
let owner = match (&row.session, scope == Scope::Session) {
(Some(owner), false) => format!(" [{owner}]"),
_ => String::new(),
};
println!("{} {}{exit}{owner}: {}", row.id, row.status, row.command);
}
0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scope {
Session,
Directory,
Machine,
}
fn owned_rows(
backend: &Path,
cwd: &Path,
scope: Scope,
session: Option<&str>,
) -> Result<Vec<JobRow>, i32> {
let jobs = list_backend(backend, cwd, scope != Scope::Directory)?;
Ok(jobs
.iter()
.filter(|job| match scope {
Scope::Session => match (session, owner_of(job.description.as_deref())) {
(Some(want), Some(owner)) => want == owner,
_ => false,
},
Scope::Directory | Scope::Machine => true,
})
.map(BackendJob::row)
.collect())
}
fn shutdown_command(backend: &Path, cwd: &Path, session: Option<&str>) -> i32 {
let Some(session) = session.filter(|session| !session.is_empty()) else {
eprintln!("{NAME}: not wired in this shell (no {VAR_SESSION})");
return EXIT_NOT_WIRED;
};
let jobs = match list_backend(backend, cwd, true) {
Ok(jobs) => jobs,
Err(code) => return code,
};
let mut stopped = 0;
let mut failed = 0;
for job in jobs.iter().filter(|job| {
job.status.starts_with("running")
&& owner_of(job.description.as_deref()).as_deref() == Some(session)
}) {
match Command::new(backend)
.current_dir(cwd)
.arg("stop")
.arg(&job.id)
.output()
{
Ok(out) if out.status.success() => stopped += 1,
_ => failed += 1,
}
}
println!("stopped {stopped} job(s)");
if failed > 0 {
eprintln!("{NAME}: {failed} job(s) would not stop");
return 1;
}
0
}
fn forward(backend: &Path, cwd: &Path, verb: &str, args: &[OsString]) -> i32 {
match Command::new(backend)
.current_dir(cwd)
.arg(verb)
.args(args)
.status()
{
Ok(status) => status.code().unwrap_or(1),
Err(err) => {
eprintln!("{NAME}: cannot run `{BACKEND}`: {err}");
EXIT_NO_BACKEND
}
}
}
fn list_backend(backend: &Path, cwd: &Path, all_dirs: bool) -> Result<Vec<BackendJob>, i32> {
let mut command = Command::new(backend);
command.current_dir(cwd).arg("list").arg("--json");
if all_dirs {
command.arg("--all");
}
let output = match command.output() {
Ok(output) => output,
Err(err) => {
eprintln!("{NAME}: cannot run `{BACKEND}`: {err}");
return Err(EXIT_NO_BACKEND);
}
};
if !output.status.success() {
eprintln!(
"{NAME}: `{BACKEND} list` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
return Err(1);
}
let text = String::from_utf8_lossy(&output.stdout);
if text.trim().is_empty() {
return Ok(Vec::new());
}
serde_json::from_str(&text).map_err(|err| {
eprintln!("{NAME}: cannot read `{BACKEND} list --json`: {err}");
1
})
}
#[cfg(test)]
mod tests;