use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::BTreeSet, fmt::Write as _};
use chrono::Utc;
use netsky_core::config::{Config as RuntimeConfig, EmailAccount, NetskyToml, TomlEmailAccount};
use netsky_core::consts::{
AGENT0_NAME, AGENTINFINITY_NAME, CLAUDE, DISK_MIN_MB_DEFAULT, ENV_DISK_MIN_MB,
ENV_TICKER_INTERVAL, LAUNCHD_LABEL, NETSKY_BIN, TICKER_INTERVAL_DEFAULT_S, TICKER_LOG_PATH,
TICKER_SESSION, TMUX_BIN,
};
use netsky_core::paths::{
agent0_hang_marker, agent0_inbox_dir, agentinfinity_ready_marker, agentinit_escalation_marker,
agentinit_failures_file, handoff_archive_dir, home, is_netsky_source_tree, resolve_netsky_dir,
restart_status_dir, ticker_missing_count_file, watchdog_event_log_path,
};
use netsky_sh::{tmux, which};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Verdict {
Green,
Yellow,
Red,
}
impl Verdict {
fn as_str(self) -> &'static str {
match self {
Self::Green => "green",
Self::Yellow => "yellow",
Self::Red => "red",
}
}
}
#[derive(Debug, Clone)]
struct CheckRec {
section: String,
name: String,
verdict: Verdict,
detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Text,
Brief,
Quiet,
Json,
}
struct Doc {
mode: Mode,
section: String,
red: u32,
yellow: u32,
checks: Vec<CheckRec>,
}
pub fn run(brief: bool, quiet: bool, json: bool) -> netsky_core::Result<()> {
let mode = match (brief, quiet, json) {
(_, _, true) => Mode::Json,
(_, true, _) => Mode::Quiet,
(true, _, _) => Mode::Brief,
_ => Mode::Text,
};
let mut d = Doc {
mode,
section: String::new(),
red: 0,
yellow: 0,
checks: Vec::new(),
};
d.section("tmux");
for sess in [AGENT0_NAME, AGENTINFINITY_NAME] {
if tmux::session_is_alive(sess) {
d.check(sess, Verdict::Green, "session alive");
} else if tmux::has_session(sess) {
d.check(sess, Verdict::Red, "session pane exited");
} else {
d.check(sess, Verdict::Red, "session MISSING");
}
}
check_ticker(&mut d);
d.section("channels");
check_inbox(&mut d);
check_clone_inboxes(&mut d);
d.section("markers");
check_markers(&mut d);
d.section("watchdog");
check_watchdog(&mut d);
d.section("disk");
check_disk(&mut d);
d.section("archives");
check_archives(&mut d);
d.section("binaries");
for bin in [CLAUDE, NETSKY_BIN, TMUX_BIN] {
match which(bin) {
Some(p) => d.check(bin, Verdict::Green, &p.display().to_string()),
None => d.check(
bin,
Verdict::Red,
"not on PATH — see ONBOARDING.md or run /onboard",
),
}
}
d.section("config");
check_config(&mut d);
d.section("hooks");
check_hooks(&mut d);
d.section("launchctl");
check_launchctl(&mut d);
d.section("drivers");
check_driver_quorum(&mut d);
let red = d.red;
let yellow = d.yellow;
if d.mode == Mode::Json {
print_json(&d);
} else {
d.summary(red, yellow);
}
if red > 0 {
netsky_core::bail!("doctor: red")
} else {
Ok(())
}
}
fn build_json(d: &Doc, generated_at: &str) -> serde_json::Value {
let status = if d.red > 0 {
"red"
} else if d.yellow > 0 {
"yellow"
} else {
"green"
};
let summary = if d.red > 0 {
format!("RED — {} fail, {} warn", d.red, d.yellow)
} else if d.yellow > 0 {
format!("YELLOW — {} warn", d.yellow)
} else {
"GREEN — all checks passed".to_string()
};
let green_count = d.checks.len() as u32 - d.red - d.yellow;
let checks: Vec<serde_json::Value> = d
.checks
.iter()
.map(|c| {
serde_json::json!({
"section": c.section,
"name": c.name,
"verdict": c.verdict.as_str(),
"detail": c.detail,
})
})
.collect();
serde_json::json!({
"command": "doctor",
"status": status,
"summary": summary,
"generated_at": generated_at,
"data": {
"checks": checks,
"counts": {
"red": d.red,
"yellow": d.yellow,
"green": green_count,
"total": d.checks.len() as u32,
},
},
})
}
fn print_json(d: &Doc) {
let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let envelope = build_json(d, &now);
println!(
"{}",
serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| "{}".to_string())
);
}
fn check_inbox(d: &mut Doc) {
let inbox = agent0_inbox_dir();
let name = "agent0 inbox";
match inbox_status(&inbox) {
InboxStatus::Present(n) => {
let (v, det) = inbox_verdict(n);
d.check(name, v, &det);
}
InboxStatus::Missing => d.check(
name,
Verdict::Red,
&format!("directory missing: {}", inbox.display()),
),
}
}
fn check_clone_inboxes(d: &mut Doc) {
let root = home().join(".claude/channels/agent");
let Ok(entries) = fs::read_dir(&root) else {
d.check(
"clone inboxes",
Verdict::Yellow,
&format!("channel root missing: {}", root.display()),
);
return;
};
let mut counts = Vec::new();
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if name == "agent0" || !is_clone_agent_name(name) {
continue;
}
if let InboxStatus::Present(n) = inbox_status(&path.join("inbox")) {
counts.push((name.to_string(), n));
}
}
counts.sort();
if counts.is_empty() {
d.check("clone inboxes", Verdict::Green, "none present");
return;
}
let total: usize = counts.iter().map(|(_, n)| *n).sum();
let max = counts.iter().map(|(_, n)| *n).max().unwrap_or(0);
let blocked: Vec<_> = counts.iter().filter(|(_, n)| *n > 0).collect();
let detail = if blocked.is_empty() {
format!("{} clone inbox(es) empty", counts.len())
} else {
let sample = blocked
.iter()
.take(4)
.map(|(name, n)| format!("{name}:{n}"))
.collect::<Vec<_>>()
.join(", ");
format!(
"{total} pending across {} clone(s): {sample}",
blocked.len()
)
};
let verdict = if max > 4 {
Verdict::Red
} else if total > 0 {
Verdict::Yellow
} else {
Verdict::Green
};
d.check("clone inboxes", verdict, &detail);
}
enum InboxStatus {
Present(usize),
Missing,
}
fn inbox_status(inbox: &Path) -> InboxStatus {
match fs::read_dir(inbox) {
Ok(entries) => InboxStatus::Present(pending_json_count(entries)),
Err(_) => InboxStatus::Missing,
}
}
fn pending_json_count(entries: fs::ReadDir) -> usize {
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("json"))
.count()
}
fn inbox_verdict(n: usize) -> (Verdict, String) {
match n {
0 => (Verdict::Green, "empty (drained)".to_string()),
1..=4 => (Verdict::Yellow, format!("{n} envelope(s) pending")),
_ => (
Verdict::Red,
format!("{n} envelopes backing up — MCP poll stalled?"),
),
}
}
fn is_clone_agent_name(name: &str) -> bool {
name.strip_prefix("agent")
.is_some_and(|n| !n.is_empty() && n.chars().all(|ch| ch.is_ascii_digit()))
}
fn check_markers(d: &mut Doc) {
let ready = agentinfinity_ready_marker();
if ready.exists() {
d.check("agentinfinity-ready", Verdict::Green, "present");
} else {
d.check(
"agentinfinity-ready",
Verdict::Red,
"marker missing; agentinit phase-2 never completed",
);
}
let esc = agentinit_escalation_marker();
if esc.exists() {
d.check(
"agentinit-escalation",
Verdict::Red,
&format!("ESCALATION FIRED — see {}", esc.display()),
);
} else {
d.check(
"agentinit-escalation",
Verdict::Green,
"absent (no escalation)",
);
}
let hang = agent0_hang_marker();
if hang.exists() {
d.check(
"agent0-hang-suspected",
Verdict::Red,
&format!("HANG SUSPECTED — see {}", hang.display()),
);
} else {
d.check(
"agent0-hang-suspected",
Verdict::Green,
"absent (pane is progressing)",
);
}
let fails = agentinit_failures_file();
match fs::read_to_string(&fails) {
Ok(s) => {
let n = s.lines().filter(|l| !l.trim().is_empty()).count();
let (v, det) = match n {
0 => (Verdict::Green, "empty".to_string()),
1..=2 => (Verdict::Yellow, format!("{n} recent failure(s) in window")),
_ => (
Verdict::Red,
format!("{n} failures in window (at/over threshold)"),
),
};
d.check("agentinit-failures", v, &det);
}
Err(_) => d.check("agentinit-failures", Verdict::Green, "no failures recorded"),
}
}
fn check_watchdog(d: &mut Doc) {
let log = Path::new(TICKER_LOG_PATH);
if !log.exists() {
d.check(
"watchdog.out.log",
Verdict::Red,
&format!("log not present at {}", log.display()),
);
} else {
let age = age_seconds(log).unwrap_or(u64::MAX);
let (v, det) = match age {
a if a <= 300 => (Verdict::Green, format!("{a}s ago")),
a if a <= 600 => (Verdict::Yellow, format!("{a}s ago (>5min)")),
a => (
Verdict::Red,
format!("{a}s ago (>10min — watchdog stalled?)"),
),
};
d.check("watchdog.out.log mtime", v, &det);
if let Some(last) = last_line(log) {
let truncated: String = last.chars().take(70).collect();
d.check("watchdog last entry", Verdict::Green, &truncated);
}
let interval = std::env::var(ENV_TICKER_INTERVAL)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(TICKER_INTERVAL_DEFAULT_S);
let expected = 600u64.saturating_div(interval).max(1);
let half = expected / 2;
if let Ok(content) = fs::read_to_string(log) {
let cutoff_s = unix_now().saturating_sub(600);
let count = content
.lines()
.filter(|l| tick_line_after(l, cutoff_s))
.count() as u64;
let detail = format!("{count} tick(s) in last 10min (expected ~{expected})");
let v = if count >= half {
Verdict::Green
} else if count > 0 {
Verdict::Yellow
} else {
Verdict::Red
};
d.check("watchdog tick-rate", v, &detail);
}
}
let events = watchdog_event_log_path();
let (verdict, detail) = watchdog_events_row(&events);
d.check("watchdog events", verdict, &detail);
let summary = crate::cmd::restart::latest_restart_status_summary(&restart_status_dir());
let (verdict, detail) = restart_status_row(&summary);
d.check("restart status", verdict, &detail);
}
fn restart_status_row(summary: &crate::cmd::restart::RestartStatusSummary) -> (Verdict, String) {
if summary.corrupt {
return match &summary.path {
Some(path) => (Verdict::Red, format!("corrupt at {}", path.display())),
None => (Verdict::Red, "corrupt at unknown path".to_string()),
};
}
if summary.path.is_none() {
return (Verdict::Green, "no prior restart".to_string());
}
let mut detail = format!(
"last={} phase={} exit={} target_agents={}",
summary.timestamp, summary.phase, summary.exit, summary.target_agents
);
if let Some(error) = &summary.error {
detail.push_str(&format!(" error={error}"));
}
(Verdict::Green, detail)
}
fn watchdog_events_row(path: &Path) -> (Verdict, String) {
let Ok(meta) = fs::metadata(path) else {
return (
Verdict::Yellow,
format!(
"path={} mtime=missing size=0B last-parse-status=missing",
path.display()
),
);
};
let age = age_seconds(path).unwrap_or(u64::MAX);
let size = meta.len();
match crate::cmd::watchdog_events::parse_status(path) {
Ok(status) => {
let parse = match status.last_ts {
Some(ts) => format!(
"parseable records={} last-ts={}",
status.records,
ts.format("%Y-%m-%dT%H:%M:%SZ")
),
None => "parseable records=0 last-ts=none".to_string(),
};
let verdict = if age <= 600 {
Verdict::Green
} else if age <= 3600 {
Verdict::Yellow
} else {
Verdict::Red
};
(
verdict,
format!(
"path={} mtime={}s ago size={}B last-parse-status={parse}",
path.display(),
age,
size
),
)
}
Err(e) => (
Verdict::Red,
format!(
"path={} mtime={}s ago size={}B last-parse-status=parse-error: {e}",
path.display(),
age,
size
),
),
}
}
fn check_ticker(d: &mut Doc) {
if tmux::has_session(TICKER_SESSION) {
d.check(TICKER_SESSION, Verdict::Green, "session alive");
return;
}
let missing = fs::read_to_string(ticker_missing_count_file())
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
let detail = match missing {
0 => "session missing; watchdog will self-heal on the next miss".to_string(),
1 => "missing for 1 tick; watchdog will respawn on the second miss".to_string(),
n => format!("missing for {n} ticks; watchdog self-heal pending"),
};
d.check(TICKER_SESSION, Verdict::Yellow, &detail);
}
fn check_disk(d: &mut Doc) {
let min_mb = std::env::var(ENV_DISK_MIN_MB)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DISK_MIN_MB_DEFAULT);
let avail_mb = match Command::new("df").args(["-Pk", "/tmp"]).output() {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.nth(1)
.and_then(|line| line.split_whitespace().nth(3))
.and_then(|s| s.parse::<u64>().ok())
.map(|kb| kb / 1024),
_ => None,
};
match avail_mb {
Some(mb) if mb >= min_mb * 2 => d.check(
"/tmp free",
Verdict::Green,
&format!("{mb}MB (threshold {min_mb}MB)"),
),
Some(mb) if mb >= min_mb => d.check(
"/tmp free",
Verdict::Yellow,
&format!("{mb}MB (close to {min_mb}MB threshold)"),
),
Some(mb) => d.check(
"/tmp free",
Verdict::Red,
&format!("{mb}MB < {min_mb}MB threshold"),
),
None => d.check("/tmp free", Verdict::Yellow, "df parse failed"),
}
}
fn check_archives(d: &mut Doc) {
let archive = handoff_archive_dir();
if archive.exists() {
if fs::metadata(&archive).is_ok_and(|m| !m.permissions().readonly()) {
let n = fs::read_dir(&archive)
.map(|es| {
es.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("json"))
.count()
})
.unwrap_or(0);
d.check(
"handoff archive",
Verdict::Green,
&format!("{n} envelope(s); writable"),
);
} else {
d.check("handoff archive", Verdict::Red, "exists but not writable");
}
} else if let Some(parent) = archive.parent()
&& fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly())
{
d.check(
"handoff archive",
Verdict::Green,
"not yet created (parent writable; created on next restart)",
);
} else {
d.check("handoff archive", Verdict::Red, "parent dir not writable");
}
}
fn check_config(d: &mut Doc) {
let dir = resolve_netsky_dir();
if is_netsky_source_tree(&dir) {
d.check("netsky dir", Verdict::Green, &dir.display().to_string());
} else {
d.check(
"netsky dir",
Verdict::Green,
&format!(
"binary mode at {} (prompts, addenda, and notes live under ~/.netsky)",
dir.display()
),
);
}
let toml_path = netsky_core::config::netsky_toml_path();
match NetskyToml::load_from(&toml_path) {
Ok(None) => d.check(
"netsky.toml",
Verdict::Green,
&format!("absent at {} (env + defaults active)", toml_path.display()),
),
Ok(Some(cfg)) => {
let owner = cfg
.owner
.as_ref()
.and_then(|o| o.name.as_deref())
.unwrap_or("(no [owner].name)");
let machine = cfg
.netsky
.as_ref()
.and_then(|n| n.machine_id.as_deref())
.unwrap_or("(no [netsky].machine_id)");
let addendum = cfg
.addendum
.as_ref()
.and_then(|a| a.agent0.as_deref())
.unwrap_or("(no [addendum].agent0; defaults to 0.md)");
d.check(
"netsky.toml",
Verdict::Green,
&format!(
"loaded from {} (owner={owner}, machine={machine}, addendum.agent0={addendum})",
toml_path.display()
),
);
check_email_config_drift(d, &cfg);
}
Err(e) => d.check(
"netsky.toml",
Verdict::Red,
&format!("parse failed at {}: {e}", toml_path.display()),
),
}
}
fn check_email_config_drift(d: &mut Doc, cfg: &NetskyToml) {
let modern_allowed = normalize_email_list(
cfg.channels
.as_ref()
.and_then(|channels| channels.email.as_ref())
.and_then(|email| email.allowed.clone())
.unwrap_or_default(),
);
let modern_accounts = normalize_modern_email_accounts(
cfg.channels
.as_ref()
.and_then(|channels| channels.email.as_ref())
.and_then(|email| email.accounts.clone())
.unwrap_or_default(),
);
let runtime = match RuntimeConfig::load() {
Ok(cfg) => cfg,
Err(err) => {
d.check(
"email config drift",
Verdict::Yellow,
&format!("could not read owner.toml for drift check: {err}"),
);
return;
}
};
let runtime_allowed = normalize_email_list(runtime.owner.email_addresses);
let runtime_accounts = normalize_runtime_email_accounts(runtime.owner.email_accounts);
let allowlist_match = modern_allowed == runtime_allowed;
let accounts_match = modern_accounts == runtime_accounts;
if allowlist_match && accounts_match {
d.check(
"email config drift",
Verdict::Green,
"netsky.toml + owner.toml email config aligned",
);
return;
}
let mut detail = String::new();
if !allowlist_match {
let _ = write!(
&mut detail,
"allowlist netsky.toml={} owner.toml={}",
render_email_list(&modern_allowed),
render_email_list(&runtime_allowed)
);
}
if !accounts_match {
if !detail.is_empty() {
detail.push_str("; ");
}
let _ = write!(
&mut detail,
"accounts netsky.toml={} owner.toml={}",
render_email_accounts(&modern_accounts),
render_email_accounts(&runtime_accounts)
);
}
d.check("email config drift", Verdict::Yellow, &detail);
}
fn normalize_email_list(addrs: Vec<String>) -> Vec<String> {
let mut set = BTreeSet::new();
for addr in addrs {
let addr = addr.trim().to_lowercase();
if !addr.is_empty() {
set.insert(addr);
}
}
set.into_iter().collect()
}
fn normalize_modern_email_accounts(accounts: Vec<TomlEmailAccount>) -> Vec<(String, Vec<String>)> {
let mut out = accounts
.into_iter()
.filter_map(|account| {
let primary = account.primary.unwrap_or_default().trim().to_lowercase();
if primary.is_empty() {
return None;
}
let mut send_as: Vec<String> = account
.send_as
.unwrap_or_default()
.into_iter()
.map(|alias| alias.trim().to_lowercase())
.filter(|alias| !alias.is_empty())
.collect();
send_as.sort();
send_as.dedup();
Some((primary, send_as))
})
.collect::<Vec<_>>();
out.sort();
out
}
fn normalize_runtime_email_accounts(accounts: Vec<EmailAccount>) -> Vec<(String, Vec<String>)> {
let mut out = accounts
.into_iter()
.filter_map(|account| {
let primary = account.primary.trim().to_lowercase();
if primary.is_empty() {
return None;
}
let mut send_as: Vec<String> = account
.send_as
.into_iter()
.map(|alias| alias.trim().to_lowercase())
.filter(|alias| !alias.is_empty())
.collect();
send_as.sort();
send_as.dedup();
Some((primary, send_as))
})
.collect::<Vec<_>>();
out.sort();
out
}
fn render_email_list(addrs: &[String]) -> String {
if addrs.is_empty() {
"(empty)".into()
} else {
addrs.join(",")
}
}
fn render_email_accounts(accounts: &[(String, Vec<String>)]) -> String {
if accounts.is_empty() {
return "(empty)".into();
}
accounts
.iter()
.map(|(primary, send_as)| {
if send_as.is_empty() {
primary.clone()
} else {
format!("{primary}[{}]", send_as.join(","))
}
})
.collect::<Vec<_>>()
.join(";")
}
fn check_hooks(d: &mut Doc) {
let root = match Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
{
Ok(o) if o.status.success() => {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() {
return;
}
Path::new(&s).to_path_buf()
}
_ => return,
};
let canonical = root.join(".githooks/pre-push");
if !canonical.exists() {
return;
}
let hook = root.join(".git/hooks/pre-push");
match crate::cmd::hooks::classify(&hook) {
crate::cmd::hooks::HookState::CanonicalLink => {
if !canonical.exists() {
d.check(
"pre-push hook",
Verdict::Yellow,
"symlink ok but canonical missing",
);
} else if !fs::metadata(&canonical)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
{
d.check(
"pre-push hook",
Verdict::Yellow,
"symlink ok but canonical not executable",
);
} else {
d.check("pre-push hook", Verdict::Green, "canonical symlink");
}
}
crate::cmd::hooks::HookState::Absent => {
d.check(
"pre-push hook",
Verdict::Red,
"absent — run `netsky hooks install`",
);
}
crate::cmd::hooks::HookState::Drift => {
d.check(
"pre-push hook",
Verdict::Red,
"drifted — run `netsky hooks install --force`",
);
}
}
if std::env::var("SKIP_PREPUSH_CHECK").is_ok_and(|v| v == "1") {
d.check(
"SKIP_PREPUSH_CHECK",
Verdict::Yellow,
"set in env; hook will be bypassed",
);
}
}
fn check_driver_quorum(d: &mut Doc) {
let ticker_alive = tmux::has_session(TICKER_SESSION);
let launchd_loaded = launchd_plist_loaded();
match (ticker_alive, launchd_loaded) {
(true, true) => d.check("quorum", Verdict::Green, "ticker + launchd both live"),
(true, false) => d.check("quorum", Verdict::Green, "ticker driving (launchd absent)"),
(false, true) => d.check(
"quorum",
Verdict::Green,
"launchd driving (ticker absent; fallback cadence 120s)",
),
(false, false) => d.check(
"quorum",
Verdict::Red,
"no tick driver — both ticker + launchd missing; watchdog will not fire",
),
}
}
fn launchd_plist_loaded() -> bool {
if which("launchctl").is_none() {
return false;
}
let target = format!("gui/{}/{LAUNCHD_LABEL}", unsafe { getuid() });
Command::new("launchctl")
.args(["print", &target])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn check_launchctl(d: &mut Doc) {
if which("launchctl").is_none() {
return;
}
if launchd_plist_loaded() {
d.check(LAUNCHD_LABEL, Verdict::Green, "plist loaded");
} else {
d.check(
LAUNCHD_LABEL,
Verdict::Yellow,
"plist not loaded (tmux-ticker is primary; this is fallback)",
);
}
}
impl Doc {
fn section(&mut self, label: &str) {
self.section = label.to_string();
if matches!(self.mode, Mode::Text) {
println!();
println!("{label}");
}
}
fn check(&mut self, name: &str, v: Verdict, detail: &str) {
match v {
Verdict::Green => {}
Verdict::Yellow => self.yellow += 1,
Verdict::Red => self.red += 1,
}
self.checks.push(CheckRec {
section: self.section.clone(),
name: name.to_string(),
verdict: v,
detail: detail.to_string(),
});
if !matches!(self.mode, Mode::Text) {
return;
}
let badge = match v {
Verdict::Green => "ok",
Verdict::Yellow => "warn",
Verdict::Red => "FAIL",
};
println!(" [{badge:>4}] {name:<34} {detail}");
}
fn summary(&self, red: u32, yellow: u32) {
if matches!(self.mode, Mode::Quiet | Mode::Json) {
return;
}
if matches!(self.mode, Mode::Text) {
println!();
print!("summary: ");
}
if red > 0 {
println!("RED — {red} fail, {yellow} warn");
} else if yellow > 0 {
println!("YELLOW — {yellow} warn");
} else {
println!("GREEN — all checks passed");
}
}
}
fn age_seconds(path: &Path) -> Option<u64> {
let mtime = fs::metadata(path).ok()?.modified().ok()?;
SystemTime::now()
.duration_since(mtime)
.ok()
.map(|d| d.as_secs())
}
fn last_line(path: &Path) -> Option<String> {
fs::read_to_string(path)
.ok()
.and_then(|s| s.lines().last().map(|s| s.to_string()))
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn tick_line_after(line: &str, cutoff_s: u64) -> bool {
let Some(rest) = line.strip_prefix("[watchdog-tick ") else {
return false;
};
let Some(end) = rest.find(']') else {
return false;
};
let ts = &rest[..end];
let cutoff_iso = chrono::DateTime::<chrono::Utc>::from_timestamp(cutoff_s as i64, 0)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string());
match cutoff_iso {
Some(c) => ts >= c.as_str(),
None => false,
}
}
#[allow(non_snake_case)]
unsafe fn getuid() -> u32 {
unsafe extern "C" {
fn getuid() -> u32;
}
unsafe { getuid() }
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn watchdog_events_row_reports_mtime_size_and_parse_status() {
let dir = tempdir().unwrap();
let path = dir.path().join("watchdog-events-2026-04-17.jsonl");
fs::write(
&path,
r#"{"ts":"2026-04-17T00:00:00Z","kind":"ticker stopped","detail":{"message":"gap"}}"#,
)
.unwrap();
let (verdict, detail) = watchdog_events_row(&path);
assert_eq!(verdict, Verdict::Green);
assert!(detail.contains("path="), "{detail}");
assert!(detail.contains("mtime="), "{detail}");
assert!(detail.contains("size="), "{detail}");
assert!(
detail.contains("last-parse-status=parseable records=1"),
"{detail}"
);
}
#[test]
fn json_envelope_shape_is_stable() {
let doc = Doc {
mode: Mode::Json,
section: String::new(),
red: 1,
yellow: 1,
checks: vec![
CheckRec {
section: "tmux".into(),
name: "agent0".into(),
verdict: Verdict::Green,
detail: "session alive".into(),
},
CheckRec {
section: "channels".into(),
name: "clone inboxes".into(),
verdict: Verdict::Yellow,
detail: "2 pending".into(),
},
CheckRec {
section: "markers".into(),
name: "agentinit-escalation".into(),
verdict: Verdict::Red,
detail: "ESCALATION FIRED".into(),
},
],
};
let v = build_json(&doc, "2026-04-18T00:00:00Z");
assert_eq!(v["command"], "doctor");
assert_eq!(v["status"], "red");
assert_eq!(v["generated_at"], "2026-04-18T00:00:00Z");
assert_eq!(v["data"]["counts"]["red"], 1);
assert_eq!(v["data"]["counts"]["yellow"], 1);
assert_eq!(v["data"]["counts"]["green"], 1);
assert_eq!(v["data"]["counts"]["total"], 3);
let checks = v["data"]["checks"].as_array().unwrap();
assert_eq!(checks.len(), 3);
assert_eq!(checks[0]["section"], "tmux");
assert_eq!(checks[0]["verdict"], "green");
assert_eq!(checks[2]["verdict"], "red");
}
#[test]
fn restart_status_row_reports_phase_and_exit_from_temp_state_dir() {
let dir = tempdir().unwrap();
let status_dir = dir.path().join("restart-status");
fs::create_dir_all(&status_dir).unwrap();
fs::write(
status_dir.join("20260417T000000Z-42.json"),
r#"{"started_at":"2026-04-17T00:00:00Z","updated_at":"2026-04-17T00:00:05Z","phase":"errored","exit_code":1,"target_agent_count":4,"error":"tmux failed\nmore"}"#,
)
.unwrap();
let summary = crate::cmd::restart::latest_restart_status_summary(&status_dir);
let (verdict, detail) = restart_status_row(&summary);
assert_eq!(verdict, Verdict::Green);
assert!(detail.contains("phase=failed"), "{detail}");
assert!(detail.contains("exit=1"), "{detail}");
assert!(detail.contains("target_agents=4"), "{detail}");
assert!(detail.contains("error=tmux failed"), "{detail}");
assert!(!detail.contains("more"), "{detail}");
}
#[test]
fn normalize_email_list_is_case_insensitive_and_deduped() {
let got = normalize_email_list(vec![
"Cody@dkdc.dev".into(),
" cody@dkdc.dev ".into(),
"cody@dkdc.io".into(),
]);
assert_eq!(got, vec!["cody@dkdc.dev", "cody@dkdc.io"]);
}
#[test]
fn render_email_accounts_is_stable() {
let rendered = render_email_accounts(&[
("cody@dkdc.dev".into(), vec!["cody@dkdc.io".into()]),
("cody@dkdc.llc".into(), vec![]),
]);
assert_eq!(rendered, "cody@dkdc.dev[cody@dkdc.io];cody@dkdc.llc");
}
}