#[cfg(not(unix))]
compile_error!(
"qex builds for Linux and macOS only.\n\n qex controls processes with the session and the process group of Unix, and it holds a job \
with `waitpid` on one process id. Windows has no equivalent of those, so this is a port \
and not an option that somebody can turn on.\n\n ON WINDOWS, USE WSL2. qex builds and operates there with no change, and the jobs that an \
agent starts (make, cargo, uv) are usually in WSL2 as well.\n\n See https://github.com/stephenc/qex for the reason in full."
);
#[cfg(unix)]
mod capabilities;
#[cfg(unix)]
mod claim;
#[cfg(unix)]
mod cli;
#[cfg(unix)]
mod client;
#[cfg(unix)]
mod commands;
#[cfg(unix)]
mod config;
#[cfg(unix)]
mod daemon;
#[cfg(unix)]
mod enforce;
#[cfg(unix)]
mod help;
#[cfg(unix)]
mod history;
#[cfg(unix)]
mod job;
#[cfg(unix)]
mod keys;
#[cfg(unix)]
mod lifecycle;
#[cfg(unix)]
mod logsel;
#[cfg(unix)]
mod paths;
#[cfg(unix)]
mod peers;
#[cfg(unix)]
mod pipeline;
#[cfg(unix)]
mod proto;
#[cfg(unix)]
mod sched;
#[cfg(unix)]
mod schema;
#[cfg(unix)]
mod spec;
#[cfg(unix)]
mod style;
#[cfg(unix)]
mod supervisor;
#[cfg(unix)]
mod sys;
#[cfg(test)]
#[cfg(unix)]
mod testutil;
#[cfg(unix)]
mod top;
#[cfg(unix)]
mod units;
#[cfg(unix)]
mod usage;
#[cfg(unix)]
mod version;
#[cfg(unix)]
mod watchers;
#[cfg(unix)]
use anyhow::{Context, Result};
#[cfg(unix)]
use clap::{CommandFactory, Parser};
#[cfg(unix)]
use cli::{Cli, Command};
#[cfg(unix)]
const EXIT_USAGE: i32 = 2;
#[cfg(not(unix))]
fn main() {}
#[cfg(unix)]
fn main() {
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let code = match run() {
Ok(code) => code,
Err(e) => {
eprintln!("qex: {e:#}");
1
}
};
std::process::exit(code);
}
#[cfg(unix)]
fn run() -> Result<i32> {
let cli = Cli::parse();
let Some(command) = cli.command else {
print_root_help();
return Ok(0);
};
match command {
Command::Help(args) => cmd_help(args.topic.as_deref()),
Command::Schema(args) => cmd_schema(args.which.as_deref()),
Command::Config(args) => cmd_config(args),
Command::Submit(args) => commands::submit(args),
Command::Run(args) => commands::run(args),
Command::Pipeline(args) => commands::pipeline(args),
Command::List(args) => commands::list(args),
Command::Status(args) => commands::status(args),
Command::Wait(args) => commands::wait(args),
Command::Logs(args) => commands::logs(args),
Command::Kill(args) => commands::kill(args),
Command::Cancel(args) => commands::cancel(args),
Command::Rerun(args) => commands::rerun(args),
Command::Clean(args) => commands::clean(args),
Command::Gc(args) => commands::gc(args),
Command::Du(args) => commands::du(args),
Command::Info(args) => commands::info(args),
Command::Version(args) => commands::version(args),
Command::Watchers(args) => watchers::report(args.json),
Command::Completions(args) => cmd_completions(args.shell),
Command::Complete(args) => commands::complete(args),
Command::Top(args) => top::run(args),
Command::Daemon(_) => {
daemon::run()?;
Ok(0)
}
Command::Supervise(args) => {
let id = args
.id
.parse::<uuid::Uuid>()
.context("the supervise command needs a job id")?;
supervisor::main(id)
}
}
}
#[cfg(unix)]
fn cmd_completions(shell: clap_complete::Shell) -> Result<i32> {
use std::io::Write;
let mut command = Cli::command();
let hidden: Vec<String> = command
.get_subcommands()
.filter(|c| c.is_hide_set())
.map(|c| c.get_name().to_string())
.collect();
let valued = options_that_take_a_value(&command);
let mut out = Vec::new();
clap_complete::generate(shell, &mut command, "qex", &mut out);
let text = remove_hidden(&String::from_utf8_lossy(&out), &hidden, shell);
let text = zsh_jobs(&text, shell);
let mut out = text.into_bytes();
out.extend_from_slice(dynamic_part(shell, &valued).as_bytes());
std::io::stdout().write_all(&out)?;
Ok(0)
}
#[cfg(unix)]
fn remove_hidden(text: &str, hidden: &[String], shell: clap_complete::Shell) -> String {
use clap_complete::Shell;
if hidden.is_empty() {
return text.to_string();
}
match shell {
Shell::Bash => text
.lines()
.map(|line| {
if !line.trim_start().starts_with("opts=\"") {
return line.to_string();
}
let mut kept = line.to_string();
for name in hidden {
kept = kept.replace(&format!(" {name}\""), "\"");
kept = kept.replace(&format!(" {name} "), " ");
}
kept
})
.collect::<Vec<_>>()
.join("\n"),
Shell::Zsh | Shell::Fish => text
.lines()
.filter(|line| {
!hidden.iter().any(|name| {
line.contains(&format!("'{name}:")) || line.contains(&format!("-a \"{name}\""))
})
})
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n"),
Shell::Elvish | Shell::PowerShell => text
.lines()
.filter(|line| {
!hidden.iter().any(|name| {
line.trim_start().starts_with(&format!("cand {name} "))
|| line.contains(&format!("::new('{name}', '{name}',"))
})
})
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n"),
_ => text.to_string(),
}
}
#[cfg(unix)]
const READS: &str = "status wait logs rerun clean";
#[cfg(unix)]
const ACTIVE: &str = "kill";
#[cfg(unix)]
const QUEUED: &str = "cancel";
#[cfg(unix)]
fn options_that_take_a_value(command: &clap::Command) -> String {
let mut words: Vec<String> = Vec::new();
let takes_a_job = |name: &str| {
READS.split(' ').any(|c| c == name)
|| ACTIVE.split(' ').any(|c| c == name)
|| QUEUED.split(' ').any(|c| c == name)
};
for sub in command
.get_subcommands()
.filter(|s| takes_a_job(s.get_name()))
{
for arg in sub.get_arguments() {
if !arg.get_action().takes_values() {
continue;
}
if let Some(long) = arg.get_long() {
words.push(format!("--{long}"));
}
for long in arg.get_all_aliases().unwrap_or_default() {
words.push(format!("--{long}"));
}
if let Some(short) = arg.get_short() {
words.push(format!("-{short}"));
}
}
}
words.sort();
words.dedup();
words.join(" ")
}
#[cfg(unix)]
fn zsh_jobs(text: &str, shell: clap_complete::Shell) -> String {
if shell != clap_complete::Shell::Zsh {
return text.to_string();
}
let helper = r#"
# The ids and the names of the jobs. `qex __complete` reads the records on the
# disk, so a press of TAB starts no coordinator.
_qex_jobs() {
local -a candidates
candidates=(${(f)"$(qex __complete $1 2>/dev/null)"})
(( ${#candidates} )) && compadd -a candidates
}
"#;
let set_of = |name: &str| -> Option<&'static str> {
if READS.split(' ').any(|c| c == name) {
Some("ids")
} else if ACTIVE.split(' ').any(|c| c == name) {
Some("active")
} else if QUEUED.split(' ').any(|c| c == name) {
Some("queued")
} else {
None
}
};
let mut done = false;
let mut here: Option<&'static str> = None;
let mut lines: Vec<String> = Vec::new();
for line in text.lines() {
if !done && line.trim() == "#compdef qex" {
lines.push(line.to_string());
lines.push(helper.to_string());
done = true;
continue;
}
if line.starts_with('(') && line.ends_with(')') {
here = set_of(line.trim_matches(['(', ')']));
}
let positional =
line.starts_with("':") || line.starts_with("'::") || line.starts_with("'*::");
match here {
Some(what) if positional && line.ends_with(":_default' \\") => {
let head = &line[..line.len() - ":_default' \\".len()];
lines.push(format!("{head}: _qex_jobs {what}' \\"));
}
_ => lines.push(line.to_string()),
}
}
lines.push(String::new());
lines.join("\n")
}
#[cfg(unix)]
fn dynamic_part(shell: clap_complete::Shell, valued: &str) -> String {
use clap_complete::Shell;
#[allow(non_snake_case)]
let VALUED = valued;
match shell {
Shell::Bash => format!(
r#"
# The ids and the names of the jobs. `qex __complete` reads the records on the
# disk, so a press of TAB starts no coordinator.
_qex_jobs() {{
local subcommand="" word
for word in "${{COMP_WORDS[@]:1}}"; do
case "$word" in
-*) ;;
*) subcommand="$word"; break ;;
esac
done
local what=""
case " {READS} " in *" $subcommand "*) what="ids" ;; esac
case " {ACTIVE} " in *" $subcommand "*) what="active" ;; esac
case " {QUEUED} " in *" $subcommand "*) what="queued" ;; esac
[ -n "$what" ] || return 0
local cur="${{COMP_WORDS[COMP_CWORD]}}"
# An option, and not a job. Leave those to the part above.
case "$cur" in -*) return 0 ;; esac
# The word before takes a VALUE, so this word is that value and not a job:
# `qex kill --signal <TAB>` must offer a signal.
#
# The test names the options that take a value, and it does not ask whether
# the word starts with a dash. A flag such as `--json` takes no value, so
# `qex status --json <TAB>` is still a job.
local prev="${{COMP_WORDS[COMP_CWORD-1]}}"
case " {VALUED} " in *" $prev "*) return 0 ;; esac
# Read the candidates one line at a time, and compare them as text.
#
# `compgen -W` EXPANDS ITS WORD LIST AGAIN, so a job named
# `$(rm -rf ~)` would run when somebody pressed TAB. An agent chooses the
# names of the jobs, and a person presses the TAB, so that name is not
# trusted text. This loop expands nothing, and it keeps a name that holds a
# space in one piece.
local candidate
while IFS= read -r candidate; do
[ -n "$candidate" ] || continue
case "$candidate" in "$cur"*) ;; *) continue ;; esac
# Put the name on the line in a form that the shell reads as ONE word.
#
# bash writes a candidate to the line as it stands, so the name
# `x; rm -rf ~` would put `; rm -rf ~` on the line beside the command,
# and the next press of ENTER would then run it. `printf %q` puts a
# backslash before each character that the shell reads, so the name
# stays one argument of qex.
#
# `compopt -o filenames` asks bash to do the same work, and this code
# used it first. bash then treats each name as a FILE: a job with the
# name of a directory got a `/` after it, `$HOME` became a directory as
# well, and a name that starts with `~` was expanded to a home
# directory. Each of the three gave qex a name that no job has.
# `printf %q` has none of that behaviour. It was measured on bash
# 5.1.16 and on bash 3.2.57, and it gave the stored name in each.
printf -v candidate '%q' "$candidate"
# A leading `~` as well. bash 5.1 escapes it above and bash 3.2 does
# NOT, and bash then reads `~/x` on the line as a home directory. A
# name is not a path, so the word keeps the character that it holds.
case "$candidate" in "~"*) candidate="\\$candidate" ;; esac
COMPREPLY[${{#COMPREPLY[@]}}]="$candidate"
done < <(qex __complete "$what" 2>/dev/null)
}}
_qex_with_jobs() {{
_qex "$@"
_qex_jobs
}}
# `-o nosort` came with bash 4.4, and `complete` refuses the WHOLE command when
# it meets an option name that it does not know. Without this test, sourcing
# this file on the bash 3.2 of macOS wrote `complete: nosort: invalid option
# name`, bound nothing, and the jobs never arrived. clap gates its own line in
# the same way, above.
#
# Do not answer that by dropping `-o nosort`: it is what keeps the newest job
# first, and bash sorts the candidates again without it.
if [[ "${{BASH_VERSINFO[0]}}" -eq 4 && "${{BASH_VERSINFO[1]}}" -ge 4 || "${{BASH_VERSINFO[0]}}" -gt 4 ]]; then
complete -F _qex_with_jobs -o nosort -o bashdefault -o default qex
else
complete -F _qex_with_jobs -o bashdefault -o default qex
fi
"#
),
Shell::Zsh => String::new(),
Shell::Fish => format!(
r#"
# The ids and the names of the jobs. `qex __complete` reads the records on the
# disk, so a press of TAB starts no coordinator.
complete -c qex -n "__fish_seen_subcommand_from {READS}" -f -a "(qex __complete ids)"
complete -c qex -n "__fish_seen_subcommand_from {ACTIVE}" -f -a "(qex __complete active)"
complete -c qex -n "__fish_seen_subcommand_from {QUEUED}" -f -a "(qex __complete queued)"
"#
),
_ => String::new(),
}
}
#[cfg(unix)]
fn print_root_help() {
print!("{}", help::banner());
println!();
let mut cmd = Cli::command();
cmd.print_help().ok();
println!();
println!("Topics for `qex help <topic>`: {}", help::TOPICS.join(", "));
}
#[cfg(unix)]
fn cmd_help(topic: Option<&str>) -> Result<i32> {
let Some(name) = topic else {
print_root_help();
return Ok(0);
};
match help::topic(name) {
Some(text) => {
print!("{text}");
Ok(0)
}
None => {
eprintln!(
"qex: there is no help topic `{name}`.\n\nThe topics are: {}\n\nAgents: run `qex help agents`.",
help::TOPICS.join(", ")
);
Ok(EXIT_USAGE)
}
}
}
#[cfg(unix)]
fn cmd_schema(which: Option<&str>) -> Result<i32> {
let Some(name) = which else {
eprintln!(
"qex: name a schema. The schemas are: {}\n\nExample: qex schema job",
schema::NAMES.join(", ")
);
return Ok(EXIT_USAGE);
};
match schema::schema(name) {
Some(text) => {
print!("{text}");
Ok(0)
}
None => {
eprintln!(
"qex: there is no schema `{name}`. The schemas are: {}",
schema::NAMES.join(", ")
);
Ok(EXIT_USAGE)
}
}
}
#[cfg(unix)]
fn cmd_config(args: cli::ConfigArgs) -> Result<i32> {
use cli::ConfigAction;
let json_flag = args.json;
match args
.action
.unwrap_or(ConfigAction::Show { json: json_flag })
{
ConfigAction::Path => {
let path = paths::config_file()?;
let exists = path.exists();
println!("{}", path.display());
if !exists {
eprintln!("qex: this file does not exist. qex uses the default values.");
}
Ok(0)
}
ConfigAction::Show { json } => {
let cfg = config::Config::load()?;
cfg.validate()?;
if json || json_flag {
println!("{}", serde_json::to_string_pretty(&cfg)?);
} else {
print_config_summary(&cfg)?;
}
Ok(0)
}
}
}
#[cfg(unix)]
fn print_config_summary(cfg: &config::Config) -> Result<()> {
let path = paths::config_file()?;
println!(
"config file: {} ({})",
path.display(),
if path.exists() {
"read"
} else {
"absent; qex uses the default values"
}
);
println!();
println!(
"machine: {} cores, {}",
sys::cpu_count(),
units::format_size(sys::total_memory())
);
println!(
"budget: {} cores, {}",
cfg.budget_cpu()?,
units::format_size(cfg.budget_mem()?)
);
println!(
"default job: {} core(s), {}, {}",
cfg.default_cpu(),
units::format_size(cfg.default_mem()?),
match cfg.default_timeout()? {
Some(d) => format!("timeout {}", units::format_duration(d)),
None => "no timeout".to_string(),
}
);
println!(
"keep free: {} of memory",
units::format_size(cfg.reserve_mem()?)
);
match enforce::startup_warning(cfg) {
Some(warning) => {
println!("enforcement: {:?} — NOT ACTIVE", cfg.enforce.mode);
println!(" {warning}");
}
None if cfg.enforce.mode.is_on() => {
println!("enforcement: {:?}, active", cfg.enforce.mode)
}
None => println!("enforcement: off; the claims control the queue only"),
}
println!("peers: {}", peers::describe(cfg));
println!("oversized: {:?}", cfg.queue.oversized);
println!("environment: capture {:?}", cfg.submit.env_capture);
Ok(())
}