← back to Fleet Lifecycle Steward
health-scan.mjs
85 lines
#!/usr/bin/env node
// health-scan.mjs — READ-ONLY health report for EVERY process in the fleet manifest.
// Reports per-process: name, kind, expected_up, live_status, run-health (last_exit /
// log_health), and a verdict. Takes NO heal action — pure visibility. This is the
// "debug every process" view Steve asked for: the same manifest the steward consumes,
// rendered as a health table that surfaces the loaded-but-crash-looping class
// (the com.steve.claude-mail failure mode) that liveness/online checks miss.
//
// Usage: node health-scan.mjs # all mac2 + kamatera, grouped by verdict
// node health-scan.mjs --bad # only non-HEALTHY rows
// node health-scan.mjs --json # machine-readable
// Never restarts/stops/deletes anything. Exit 0 always.
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const MANIFEST = process.env.FLEET_MANIFEST || join(homedir(), "Projects/_shared/fleet-registry/manifest.json");
const ONLY_BAD = process.argv.includes("--bad");
const AS_JSON = process.argv.includes("--json");
const manifest = JSON.parse(readFileSync(MANIFEST, "utf8"));
const REAL = new Set(["mac2", "kamatera", "mac1"]);
const entries = (manifest.entries || []).filter(e => e && REAL.has(e.machine) && e.kind && !String(e.id || "").startsWith("_"));
const HEALTHY_SCHED = new Set(["loaded", "running", "online"]);
const isScheduled = e => !!e.schedule || e.kind === "launchd" || e.kind === "plist-only";
const looksLive = e => isScheduled(e) ? HEALTHY_SCHED.has(e.live_status) : e.live_status === "online";
// classify each entry into a verdict (mirrors fleet-act.mjs but report-only)
function verdict(e) {
if (e.lifecycle_status === "retired") {
return e.live_status === "online"
? { v: "ZOMBIE", why: "retired but still online" }
: { v: "RETIRED", why: "retired, not running (steady state)" };
}
if (e.lifecycle_status === "deprecated") return { v: "DEPRECATED", why: "winding down" };
if (e.expected_up !== true) return { v: "OFF-OK", why: "not expected up, not running" };
// expected_up === true && active:
if (!looksLive(e)) return { v: "DARK", why: `expected up but live_status=${e.live_status}` };
// looks-live — check run-health (launchd crash-loop class)
if (e.kind === "launchd" || e.kind === "plist-only") {
const exit = e.last_exit, raw = e.last_exit_raw;
const running = e.live_status === "running", loaded = e.live_status === "loaded";
const signalled = raw !== null && raw !== undefined && raw < 0;
if (loaded) {
if (exit !== null && exit !== undefined && exit !== 0)
return { v: "UNHEALTHY", why: `loaded but last_exit=${exit} (crash-loop)` };
if ((exit === null || exit === undefined) && e.log_health === "fatal")
return { v: "UNHEALTHY", why: "loaded, FATAL log tail (no exit to clear it)" };
} else if (running && !signalled && exit > 0 && e.log_health === "fatal") {
return { v: "UNHEALTHY", why: `running but flapping (last_exit=${exit} + FATAL log)` };
}
}
return { v: "HEALTHY", why: e.health_url ? `live + health_url ${e.health_url}` : "live" };
}
const rows = entries.map(e => ({ ...verdict(e), id: e.id, name: e.name, machine: e.machine, kind: e.kind,
expected_up: e.expected_up, live_status: e.live_status, last_exit: e.last_exit, log_health: e.log_health, owner: e.owner }));
const ORDER = ["UNHEALTHY", "ZOMBIE", "DARK", "DEPRECATED", "HEALTHY", "OFF-OK", "RETIRED"];
const ICON = { UNHEALTHY: "🟠", ZOMBIE: "🧟", DARK: "🔴", DEPRECATED: "🟡", HEALTHY: "🟢", "OFF-OK": "⚪", RETIRED: "⚫" };
const counts = rows.reduce((a, r) => (a[r.v] = (a[r.v] || 0) + 1, a), {});
if (AS_JSON) {
console.log(JSON.stringify({ generated_at: manifest.generated_at, total: rows.length, counts, rows }, null, 2));
} else {
console.log(`fleet health-scan (READ-ONLY) — ${rows.length} processes · manifest @ ${manifest.generated_at}`);
console.log(ORDER.map(v => `${ICON[v]} ${v}:${counts[v] || 0}`).join(" "));
console.log("");
for (const v of ORDER) {
if (ONLY_BAD && ["HEALTHY", "OFF-OK", "RETIRED"].includes(v)) continue;
const grp = rows.filter(r => r.v === v).sort((a, b) => a.id.localeCompare(b.id));
if (!grp.length) continue;
console.log(`${ICON[v]} ${v} (${grp.length})`);
const show = (v === "HEALTHY" || v === "OFF-OK" || v === "RETIRED") ? grp.slice(0, 6) : grp;
for (const r of show) console.log(` ${r.name.padEnd(38)} ${r.machine}/${r.kind.padEnd(11)} ${r.why}`);
if (show.length < grp.length) console.log(` … and ${grp.length - show.length} more`);
console.log("");
}
console.log("cost: $0 (local) — read-only, no heal action taken");
}