use crate::LeaseAction;
use smix_lease::store::{CheckoutLedgers, LeaseDir, LedgerDivergence};
use smix_lease::{Admission, StaleReason, store};
use std::path::{Path, PathBuf};
pub fn describe(device_id: &str, admission: &Admission) -> String {
match admission {
Admission::Granted => format!("{device_id}: free"),
Admission::Denied(c) if c.holder_alive => format!(
"{device_id}: held by pid {} ({}) since {}",
c.holder.pid, c.holder.cmd, c.acquired_at
),
Admission::Denied(c) => format!(
"{device_id}: in use — the command that took it (pid {}) has exited, \
but what it started is still running",
c.holder.pid
),
Admission::Adoptable => format!(
"{device_id}: a finished session left its runner serving — the next \
command takes the lease over as-is"
),
Admission::Reclaimable { cleanup, reason } => {
let why = match reason {
StaleReason::HolderExited => "holder exited",
StaleReason::PidRecycled => "holder gone, its pid was reused",
StaleReason::HeartbeatExpired => "holder stopped responding",
};
format!(
"{device_id}: abandoned ({why}) — {} close(s) owed",
cleanup.len()
)
}
}
}
pub fn held_by_json(admission: &Admission) -> serde_json::Value {
match admission {
Admission::Denied(c) => serde_json::json!({
"pid": c.holder.pid,
"cmd": c.holder.cmd,
"alive": c.holder_alive,
}),
Admission::Granted | Admission::Adoptable | Admission::Reclaimable { .. } => {
serde_json::Value::Null
}
}
}
pub fn divergence_note(
d: &LedgerDivergence,
checkout: &str,
tree: &Path,
leases: &str,
last_written: Option<&str>,
) -> String {
match d {
LedgerDivergence::OnlyInCheckout { device_id } => format!(
"note: {device_id} has a ledger in {checkout} and none here — no other \
checkout can see it. `smix lease migrate --from {}` brings it over.",
tree.display()
),
LedgerDivergence::Disagrees { device_id, detail } => {
let when = match last_written {
Some(t) => format!("That copy was last written {t}"),
None => "When it was last written could not be read".to_string(),
};
format!(
"note: {device_id} is recorded differently in {checkout} — {detail}. \
{when}; this smix only reads it. This command answers from \
{leases} alone."
)
}
}
}
pub async fn run(leases: &LeaseDir, action: LeaseAction) -> Result<u8, crate::CliError> {
let checkout = std::env::current_dir()
.ok()
.and_then(|cwd| CheckoutLedgers::discover(&cwd));
let divergences: Vec<LedgerDivergence> = match &checkout {
Some(c) => store::survey(leases, c),
None => Vec::new(),
};
let say_divergences = |only: Option<&str>| {
let Some(c) = &checkout else { return };
let rows: Vec<&LedgerDivergence> = divergences
.iter()
.filter(|d| only.is_none_or(|id| d.device_id() == id))
.collect();
if rows.is_empty() {
return;
}
let tree = c
.path()
.parent()
.and_then(std::path::Path::parent)
.unwrap_or(c.path());
eprintln!();
for d in rows {
let written = c
.ledger_path(d.device_id())
.ok()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok())
.map(|t| {
chrono::DateTime::<chrono::Local>::from(t)
.format("%Y-%m-%d %H:%M:%S %:z")
.to_string()
});
eprintln!(
"{}",
divergence_note(
d,
&c.to_string(),
tree,
&leases.to_string(),
written.as_deref()
)
);
}
};
match action {
LeaseAction::List => {
let ids = leases.device_ids();
if ids.is_empty() {
println!("no device ledgers under {leases}");
say_divergences(None);
return Ok(0);
}
for id in ids {
let facts = store::collect_facts(leases, &id).map_err(to_cli_error)?;
println!("{}", describe(&id, &smix_lease::assess(&facts)));
}
say_divergences(None);
}
LeaseAction::Status { device, json: true } => {
let udid = crate::resolve_device(&device)?;
let lease = store::read(leases, &udid).map_err(to_cli_error)?;
let path = store::lease_path(leases, &udid).map_err(to_cli_error)?;
let facts = store::collect_facts(leases, &udid).map_err(to_cli_error)?;
let held_by = held_by_json(&smix_lease::assess(&facts));
println!(
"{}",
serde_json::json!({ "device": udid, "path": path, "lease": lease, "heldBy": held_by })
);
}
LeaseAction::Status {
device,
json: false,
} => {
let udid = crate::resolve_device(&device)?;
let facts = store::collect_facts(leases, &udid).map_err(to_cli_error)?;
let admission = smix_lease::assess(&facts);
println!("{}", describe(&udid, &admission));
if let Some(held) = &facts.existing {
for r in &held.lease.resources {
println!(" open: {r:?}");
}
}
if let Admission::Reclaimable { cleanup, .. } = &admission {
for a in cleanup {
println!(" owed: {a:?}");
}
println!("run `smix lease reconcile {device}` to close them");
}
say_divergences(Some(&udid));
}
LeaseAction::Reconcile { device } => {
let udid = crate::resolve_device(&device)?;
if let Some(d) = divergences.iter().find(|d| d.device_id() == udid) {
let c = checkout
.as_ref()
.expect("a divergence implies a checkout book");
let tree = c
.path()
.parent()
.and_then(std::path::Path::parent)
.unwrap_or(c.path());
eprintln!("{udid}: not settling — {c} has a different record for it.");
if let LedgerDivergence::Disagrees { detail, .. } = d {
eprintln!(" {detail}");
}
eprintln!(
" Settling from one book while the other says otherwise is how a \
live session gets torn down. Reconcile them first:"
);
eprintln!(" smix lease migrate --from {}", tree.display());
return Ok(1);
}
let facts = store::collect_facts(leases, &udid).map_err(to_cli_error)?;
match smix_lease::assess(&facts) {
Admission::Granted => println!("{udid}: nothing to settle"),
a @ Admission::Adoptable => {
println!("{}", describe(&udid, &a));
}
a @ Admission::Denied(_) => {
println!("{}", describe(&udid, &a));
println!("not touching it — a live session is not this command's to end");
}
Admission::Reclaimable { cleanup, reason } => {
println!(
"{}",
describe(
&udid,
&Admission::Reclaimable {
cleanup: cleanup.clone(),
reason,
}
)
);
let outcomes = smix_capsule::reconcile::execute(&cleanup);
let mut all_clean = true;
for o in &outcomes {
println!(" {}", o.line());
all_clean &= o.is_clean();
}
if all_clean {
store::remove(leases, &udid).map_err(to_cli_error)?;
println!("{udid}: settled, ledger cleared");
} else {
println!("{udid}: some closes failed — ledger kept so they stay visible");
}
}
}
}
LeaseAction::Claim { device } => return claim(leases, &device),
LeaseAction::Release { device } => release(leases, &device)?,
LeaseAction::Owner { device } => return owner(leases, &device),
LeaseAction::Migrate { from, dry_run } => migrate(leases, &from, dry_run)?,
LeaseAction::Prune { dry_run, device } => prune(leases, dry_run, device.as_deref()).await?,
LeaseAction::History { json } => crate::departures::print_history(leases, json)?,
}
Ok(0)
}
fn owner(leases: &LeaseDir, device: &str) -> Result<u8, crate::CliError> {
let udid = crate::resolve_device(device)?;
let facts = store::collect_facts(leases, &udid).map_err(to_cli_error)?;
let Some(held) = &facts.existing else {
println!("{udid}: no ledger — nothing here booted it");
return Ok(3);
};
let booted_by_us = held
.lease
.known_resources()
.any(|r| matches!(r, smix_lease::Resource::Booted { by_us: true }));
let claimed_at = held.lease.known_resources().find_map(|r| match r {
smix_lease::Resource::Claimed { at } => Some(at.clone()),
_ => None,
});
if !booted_by_us && claimed_at.is_none() {
println!(
"{udid}: a ledger exists (holder pid {}) but no row says smix booted \
or claimed it",
held.lease.holder.pid
);
return Ok(3);
}
let alive = held.holder.pid_exists && held.holder.identity_matches;
if booted_by_us {
println!(
"{udid}: booted by smix — holder pid {} ({}), {}",
held.lease.holder.pid,
held.lease.holder.cmd,
if alive { "still running" } else { "exited" }
);
} else {
println!(
"{udid}: claimed at {} — holder pid {} ({}), {}. Nothing here booted \
it, so it is this machine's to drive and not to shut down.",
claimed_at.unwrap_or_default(),
held.lease.holder.pid,
held.lease.holder.cmd,
if alive { "still running" } else { "exited" }
);
}
Ok(0)
}
fn claim(leases: &LeaseDir, device: &str) -> Result<u8, crate::CliError> {
let udid = crate::resolve_device(device)?;
let facts = store::collect_facts(leases, &udid).map_err(to_cli_error)?;
if let Admission::Denied(c) = smix_lease::assess(&facts) {
println!(
"{udid}: held by pid {} ({}) since {} — not a device to claim",
c.holder.pid, c.holder.cmd, c.acquired_at
);
return Ok(3);
}
if facts.existing.as_ref().is_some_and(|h| {
h.lease
.known_resources()
.any(|r| matches!(r, smix_lease::Resource::Booted { by_us: true }))
}) {
println!("{udid}: smix booted this one — it is already answered for");
return Ok(0);
}
store::record_claim(leases, &udid).map_err(to_cli_error)?;
println!(
"{udid}: claimed. Yours to drive; not yours to shut down, because \
nothing here booted it. `smix lease release {udid}` ends it, and so \
does the device going off."
);
Ok(0)
}
fn release(leases: &LeaseDir, device: &str) -> Result<(), crate::CliError> {
let udid = crate::resolve_device(device)?;
let had = store::read(leases, &udid)
.map_err(to_cli_error)?
.is_some_and(|l| {
l.known_resources()
.any(|r| matches!(r, smix_lease::Resource::Claimed { .. }))
});
if !had {
println!("{udid}: no claim here to release");
return Ok(());
}
store::drop_resource_kind(
leases,
&udid,
&smix_lease::Resource::Claimed { at: String::new() },
)
.map_err(to_cli_error)?;
println!("{udid}: claim released");
Ok(())
}
fn migrate(leases: &LeaseDir, from: &[PathBuf], dry_run: bool) -> Result<(), crate::CliError> {
let sources: Vec<PathBuf> = if from.is_empty() {
std::env::current_dir()
.ok()
.and_then(|cwd| checkout_lease_dir(&cwd))
.into_iter()
.collect()
} else {
from.iter()
.map(|p| {
if p.ends_with("leases") {
p.clone()
} else {
p.join(".smix").join("leases")
}
})
.collect()
};
if sources.is_empty() {
println!("nothing to migrate — no checkout ledgers found. Name them with --from <dir>.");
return Ok(());
}
let mut moved = 0usize;
let mut refused = 0usize;
for src in &sources {
let src_dir = CheckoutLedgers::at(src.clone());
let ids = src_dir.device_ids();
if ids.is_empty() {
println!(" {} held no ledgers", src.display());
continue;
}
for id in ids {
let Ok(Some(incoming)) = src_dir.read(&id) else {
eprintln!(" {}/{id}: unreadable, left where it is", src.display());
continue;
};
match store::read(leases, &id) {
Ok(None) => {
if !dry_run {
store::write(leases, &incoming).map_err(to_cli_error)?;
}
println!(" + {id} from {}", src.display());
moved += 1;
}
Ok(Some(existing)) if existing.holder.pid == incoming.holder.pid => {
println!(" = {id} already here");
}
Ok(Some(existing)) => {
eprintln!(
" ! {id}: this machine already has a ledger held by pid {}, \
and {} has one held by pid {}. Settle one of them \
(`smix lease status {id}`) and run this again.",
existing.holder.pid,
src.display(),
incoming.holder.pid
);
refused += 1;
}
Err(e) => return Err(to_cli_error(e)),
}
}
}
if dry_run {
println!("{moved} ledger(s) would move into {leases}; nothing was written");
} else {
println!("{moved} ledger(s) now in {leases}");
}
if refused > 0 {
return Err(crate::CliError::Other(format!(
"{refused} device(s) had a ledger in two places and were left alone"
)));
}
if !dry_run {
println!("the source ledgers are untouched");
}
Ok(())
}
fn checkout_lease_dir(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
let candidate = d.join(".smix").join("leases");
if candidate.is_dir() {
return Some(candidate);
}
dir = d.parent();
}
None
}
async fn prune(
leases: &LeaseDir,
dry_run: bool,
only: Option<&str>,
) -> Result<(), crate::CliError> {
let ids: Vec<String> = match only {
Some(device) => {
let id = crate::resolve_device(device)?;
if !leases.device_ids().contains(&id) {
println!("{id}: no ledger — nothing to prune");
return Ok(());
}
vec![id]
}
None => leases.device_ids(),
};
if ids.is_empty() {
println!("no device ledgers under {leases}");
return Ok(());
}
let live = crate::departures::ask_what_is_here(&ids).await;
let mut gone = 0usize;
for id in ids {
let facts = store::collect_facts(leases, &id).map_err(to_cli_error)?;
let Some(held) = &facts.existing else {
println!(" {id}: empty ledger — removing");
if !dry_run {
store::remove(leases, &id).map_err(to_cli_error)?;
}
gone += 1;
continue;
};
let on = is_on(&smix_lease::vanish::presence(&held.lease, &live));
if let smix_lease::PruneVerdict::Keep(why) = smix_lease::prune_verdict(held, on) {
println!(" {id}: {why} — kept");
continue;
}
println!(
" {id}: holder pid {} is gone and nothing it opened is still running — removing",
held.lease.holder.pid
);
if !dry_run {
store::remove(leases, &id).map_err(to_cli_error)?;
}
gone += 1;
}
if dry_run {
println!("{gone} ledger(s) would be removed");
} else {
println!("{gone} ledger(s) removed");
}
Ok(())
}
fn is_on(p: &smix_lease::vanish::Presence) -> Option<bool> {
use smix_lease::vanish::Presence;
match p {
Presence::Present => Some(true),
Presence::Gone { .. } => Some(false),
Presence::CannotTell => None,
}
}
fn to_cli_error(e: store::LeaseError) -> crate::CliError {
crate::CliError::Other(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use smix_lease::{CleanupAction, Contention, ProcIdentity};
fn ident(pid: u32) -> ProcIdentity {
ProcIdentity {
pid,
started_at: "Thu Aug 6 10:00:00 2026".into(),
cmd: "smix run hello.yaml".into(),
}
}
fn disagreement() -> LedgerDivergence {
LedgerDivergence::Disagrees {
device_id: "5D087114".into(),
detail: "holder: machine says pid 70842, the tree says pid 47495".into(),
}
}
#[test]
fn a_disagreement_says_when_the_tree_was_written_and_not_that_it_still_is() {
let msg = divergence_note(
&disagreement(),
"/home/u/repo/.smix/leases",
Path::new("/home/u/repo/.smix/leases")
.ancestors()
.nth(2)
.expect("a book two levels under its tree"),
"/home/u/.local/share/smix/leases",
Some("2026-08-11 22:21:54 +09:00"),
);
assert!(!msg.contains("still writes"), "{msg}");
assert!(
msg.contains("last written 2026-08-11 22:21:54 +09:00"),
"{msg}"
);
assert!(
msg.contains("answers from /home/u/.local/share/smix/leases alone"),
"{msg}"
);
}
#[test]
fn a_disagreement_whose_file_has_no_time_says_so() {
let msg = divergence_note(
&disagreement(),
"/home/u/repo/.smix/leases",
Path::new("/home/u/repo/.smix/leases")
.ancestors()
.nth(2)
.expect("a book two levels under its tree"),
"/home/u/.local/share/smix/leases",
None,
);
assert!(
msg.contains("When it was last written could not be read"),
"{msg}"
);
}
#[test]
fn a_live_holder_is_held_by_for_a_script() {
let v = held_by_json(&Admission::Denied(Contention {
holder: ident(4242),
acquired_at: "2026-08-06T10:00:00Z".into(),
holder_alive: true,
}));
assert_eq!(v["pid"], 4242);
assert_eq!(v["alive"], true);
assert!(
v["cmd"]
.as_str()
.is_some_and(|c| c.contains("smix run hello.yaml"))
);
}
#[test]
fn a_device_whose_launcher_exited_is_still_held_for_a_script() {
let v = held_by_json(&Admission::Denied(Contention {
holder: ident(4242),
acquired_at: "2026-08-06T10:00:00Z".into(),
holder_alive: false,
}));
assert_eq!(v["pid"], 4242);
assert_eq!(v["alive"], false);
}
#[test]
fn a_free_device_is_held_by_nobody() {
assert!(held_by_json(&Admission::Granted).is_null());
assert!(held_by_json(&Admission::Adoptable).is_null());
}
#[test]
fn a_live_holder_is_named_not_just_reported_busy() {
let msg = describe(
"UDID-1",
&Admission::Denied(Contention {
holder: ident(4242),
acquired_at: "2026-08-06T10:00:00Z".into(),
holder_alive: true,
}),
);
assert!(msg.contains("4242"), "the pid is what makes it actionable");
assert!(msg.contains("smix run hello.yaml"));
}
#[test]
fn an_exited_launcher_says_what_is_still_running() {
let msg = describe(
"UDID-1",
&Admission::Denied(Contention {
holder: ident(4242),
acquired_at: "2026-08-06T10:00:00Z".into(),
holder_alive: false,
}),
);
assert!(msg.contains("has exited"));
assert!(msg.contains("still running"));
}
#[test]
fn an_abandoned_device_says_why_and_how_much_is_owed() {
let msg = describe(
"UDID-1",
&Admission::Reclaimable {
cleanup: vec![CleanupAction::ShutdownSim {
udid: "UDID-1".into(),
}],
reason: StaleReason::PidRecycled,
},
);
assert!(msg.contains("pid was reused"));
assert!(msg.contains("1 close"));
}
#[test]
fn a_free_device_says_so_plainly() {
assert_eq!(describe("UDID-1", &Admission::Granted), "UDID-1: free");
}
}