use std::ffi::OsString;
use std::process::Command;
use anyhow::{bail, Context, Result};
use chrono::Local;
use crate::cron::{format_absolute, format_relative, CronExpr};
use crate::cron_daemon::{self, StatusDetail};
use crate::inspect::{collect_scripts, collect_systems, ScriptEntry};
use crate::RootSpec;
pub fn dispatch_systems(args: &[OsString], spec: &RootSpec) -> Result<i32> {
let mut args = args;
if args.first().is_some_and(|a| a == "--") {
args = &args[1..];
}
let Some(first) = args.first() else {
print_systems_help();
return Ok(0);
};
let cmd = first.to_string_lossy();
match cmd.as_ref() {
"--help" | "-h" | "help" => {
print_systems_help();
Ok(0)
}
"list" => run_list(spec, &args[1..]),
"show" => run_show(spec, &args[1..]),
"status" => run_status(spec, &args[1..]),
other => bail!("unknown systems subcommand `{other}`; try `jan systems --help`"),
}
}
fn print_systems_help() {
print!(
"\
systems — observability for named agent systems (`system:` in YAML)
USAGE:
jan systems list
jan systems show <NAME>
jan systems status <NAME> [--json]
Declare a system by setting `system: <name>` on a command group. Every
descendant script leaf inherits that tag. Jan then derives:
- which leaves belong to the system (cron vs event-only)
- filtered `jan cron` running / deferred / recent spawns
- mailbox pending depth per event-addressable leaf
- conventional unifier keys: <name>/status/*, <name>/board/url, <name>/digest/url
No per-agent instrumentation is required for this baseline view.
Opt out a utility leaf under a system group with `system: \"\"`.
Examples:
jan systems list
jan systems show gifts
jan systems status gifts
jan systems status jobs --json
"
);
}
fn owned_scripts_for_system(spec: &RootSpec, name: &str) -> Vec<ScriptEntry> {
collect_scripts(spec)
.into_iter()
.filter(|s| {
s.system
.as_deref()
.is_some_and(|sys| sys.eq_ignore_ascii_case(name))
})
.collect()
}
fn run_list(spec: &RootSpec, args: &[OsString]) -> Result<i32> {
for a in args {
let s = a.to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_systems_help();
return Ok(0);
}
other => bail!("unknown systems list flag `{other}`"),
}
}
let systems = collect_systems(spec);
if systems.is_empty() {
println!("(no agent systems — set `system: <name>` on a command group)");
return Ok(0);
}
let scripts = collect_scripts(spec);
println!("{:<16} {:>6} {:>6} {}", "system", "leaves", "cron", "sample");
for name in &systems {
let members: Vec<_> = scripts
.iter()
.filter(|s| s.system.as_deref() == Some(name.as_str()))
.collect();
let cron_n = members.iter().filter(|s| !s.cron.is_empty()).count();
let sample = members
.iter()
.map(|s| s.name.as_str())
.take(3)
.collect::<Vec<_>>()
.join(", ");
let sample = if members.len() > 3 {
format!("{sample}, …")
} else {
sample
};
println!("{name:<16} {:>6} {:>6} {sample}", members.len(), cron_n);
}
eprintln!("({} system(s))", systems.len());
Ok(0)
}
fn run_show(spec: &RootSpec, args: &[OsString]) -> Result<i32> {
let mut name: Option<String> = None;
for a in args {
let s = a.to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_systems_help();
return Ok(0);
}
other if other.starts_with('-') => bail!("unknown systems show flag `{other}`"),
other => {
if name.is_some() {
bail!("unexpected argument `{other}`");
}
name = Some(other.to_string());
}
}
}
let name = name.context("usage: jan systems show <NAME>")?;
let members = owned_scripts_for_system(spec, &name);
if members.is_empty() {
bail!("unknown system `{name}` (declare `system: {name}` on a command group)");
}
println!("system: {name}");
println!("leaves: {}", members.len());
println!();
println!("{:<22} {:<28} {}", "leaf", "trigger", "chain");
for s in &members {
let trigger = if s.cron.is_empty() {
"event-only".to_string()
} else {
format!("cron {}", s.cron.join("; "))
};
println!("{:<22} {:<28} {}", s.name, trigger, s.chain_str());
}
Ok(0)
}
fn unifier_get(key: &str) -> String {
let out = Command::new("unifier").args(["get", key]).output().ok();
match out {
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => String::new(),
}
}
fn mailbox_pending(leaf: &str) -> usize {
let out = Command::new("unifier")
.args(["list", &format!("mailbox/{leaf}")])
.output()
.ok();
match out {
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| l.trim().starts_with('{'))
.count(),
_ => 0,
}
}
fn next_cron_fire(exprs: &[String]) -> Option<String> {
let now = Local::now();
let mut best = None;
for raw in exprs {
let Ok(expr) = CronExpr::parse(raw) else {
continue;
};
if let Some(t) = expr.next_after(now) {
best = match best {
Some(b) if b <= t => Some(b),
_ => Some(t),
};
}
}
best.map(|t| {
format!(
"{} ({})",
format_absolute(t),
format_relative(now, t)
)
})
}
fn filter_detail(detail: StatusDetail, system: &str) -> StatusDetail {
let match_sys =
|s: &Option<String>| s.as_deref().is_some_and(|x| x.eq_ignore_ascii_case(system));
StatusDetail {
running: detail
.running
.into_iter()
.filter(|j| match_sys(&j.system))
.collect(),
deferred: detail
.deferred
.into_iter()
.filter(|j| match_sys(&j.system))
.collect(),
recent: detail
.recent
.into_iter()
.filter(|j| match_sys(&j.system))
.collect(),
wakeups: detail
.wakeups
.into_iter()
.filter(|j| match_sys(&j.system))
.collect(),
}
}
fn run_status(spec: &RootSpec, args: &[OsString]) -> Result<i32> {
let mut name: Option<String> = None;
let mut json = false;
for a in args {
let s = a.to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_systems_help();
return Ok(0);
}
"--json" => json = true,
other if other.starts_with('-') => bail!("unknown systems status flag `{other}`"),
other => {
if name.is_some() {
bail!("unexpected argument `{other}`");
}
name = Some(other.to_string());
}
}
}
let name = name.context("usage: jan systems status <NAME>")?;
let members = owned_scripts_for_system(spec, &name);
if members.is_empty() {
bail!("unknown system `{name}` (declare `system: {name}` on a command group)");
}
let detail = cron_daemon::fetch_status_detail().unwrap_or(StatusDetail {
running: Vec::new(),
deferred: Vec::new(),
recent: Vec::new(),
wakeups: Vec::new(),
});
let filtered = filter_detail(detail, &name);
let mut mail: Vec<(String, usize)> = Vec::new();
for s in &members {
if s.cron.is_empty() {
mail.push((s.name.clone(), mailbox_pending(&s.name)));
}
}
let mut schedule: Vec<(String, String)> = Vec::new();
for s in &members {
if s.cron.is_empty() {
schedule.push((s.name.clone(), "mailbox/event wakeup".into()));
} else {
let nxt = next_cron_fire(&s.cron).unwrap_or_else(|| s.cron.join("; "));
schedule.push((s.name.clone(), nxt));
}
}
let timeline = unifier_get(&format!("{name}/status/timeline"));
let summary = unifier_get(&format!("{name}/status/summary"));
let updated = unifier_get(&format!("{name}/status/updated_at"));
let board = unifier_get(&format!("{name}/board/url"));
let digest = unifier_get(&format!("{name}/digest/url"));
let sync_last = unifier_get(&format!("{name}/sync/last"));
if json {
let obj = serde_json::json!({
"system": name,
"leaves": members.iter().map(|s| {
serde_json::json!({
"name": s.name,
"chain": s.chain_str(),
"cron": s.cron,
})
}).collect::<Vec<_>>(),
"schedule": schedule.iter().map(|(k,v)| serde_json::json!({"leaf": k, "next": v})).collect::<Vec<_>>(),
"mail": mail.iter().map(|(k,v)| serde_json::json!({"leaf": k, "pending": v})).collect::<Vec<_>>(),
"running": filtered.running,
"deferred": filtered.deferred,
"recent": filtered.recent.iter().take(16).collect::<Vec<_>>(),
"wakeups": filtered.wakeups,
"unifier": {
"summary": summary,
"updated_at": updated,
"timeline": timeline.lines().collect::<Vec<_>>(),
"board_url": board,
"digest_url": digest,
"sync_last": sync_last,
}
});
println!("{}", serde_json::to_string_pretty(&obj)?);
return Ok(0);
}
println!("== system: {name} ==");
println!("leaves {}", members.len());
if !summary.is_empty() {
println!("summary {summary}");
}
if !updated.is_empty() {
println!("updated {updated}");
}
if !board.is_empty() {
println!("board {board}");
}
if !digest.is_empty() {
println!("digest {digest}");
}
if !sync_last.is_empty() {
println!("sync {sync_last}");
}
println!();
println!("== schedule ==");
for (leaf, nxt) in &schedule {
println!("{leaf:<22} {nxt}");
}
if !mail.is_empty() {
println!();
println!("== mailboxes ==");
for (leaf, n) in &mail {
println!("{leaf:<22} pending={n}");
}
}
println!();
println!("== execution ==");
println!("running: {}", filtered.running.len());
for j in &filtered.running {
println!(
" {} trigger={} {}",
j.leaf,
j.trigger.as_str(),
j.started_at
);
}
println!("deferred: {}", filtered.deferred.len());
for j in &filtered.deferred {
println!(" {} trigger={}", j.leaf, j.trigger.as_str());
}
println!("recent:");
if filtered.recent.is_empty() {
println!(" (none — start jan cron, or no spawns yet for this system)");
} else {
for r in filtered.recent.iter().take(12) {
let code = r
.exit_code
.map(|c| format!("exit={c}"))
.unwrap_or_else(|| {
if r.overlap_skip {
"overlap_skip".into()
} else {
"exit=?".into()
}
});
println!(
" {} trigger={} {} {code}",
r.leaf,
r.trigger.as_str(),
r.started_at
);
}
}
println!();
println!("== timeline ==");
if timeline.is_empty() {
println!("(empty — agents may append to {name}/status/timeline)");
} else {
for line in timeline
.lines()
.rev()
.take(15)
.collect::<Vec<_>>()
.into_iter()
.rev()
{
println!("{line}");
}
}
Ok(0)
}