← back to Cronjobs Viewer

server.js

317 lines

#!/usr/bin/env node
/**
 * cronjobs-viewer — a standalone, live launchd cron-calendar for macstudio3.
 *
 * Introspects EVERY com.steve.* LaunchAgent live (launchctl list + the plist
 * files in ~/Library/LaunchAgents) and serves a single full-page viewer that
 * shows, per job: label + inferred purpose, schedule, last run, next run, last
 * exit code, state (active/running/alert/error), program, and model.
 *
 * The launchd-introspection core (readCronPlistMeta / collectCrons / sh) is
 * lifted verbatim from the AbramsEgo command center (:9773) /api/crons handler
 * so there is ONE parser, not two divergent ones.
 *
 * Local surface behind Basic Auth (admin / DW2024!). Public exposure is via a
 * Cloudflare tunnel + Cloudflare Access Zero-Trust gate — that step is GATED
 * (see the go-live runbook in ~/.claude/yolo-queue/pending-approval/); this
 * server itself only binds localhost-friendly and never opens a public port.
 */
'use strict';

const express = require('express');
const basicAuth = require('basic-auth');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execFile } = require('child_process');

const HOME = os.homedir();
const UID = process.getuid();
const PORT = process.env.PORT || 9796; // free port picked at build time
const AUTH_USER = process.env.CRONJOBS_USER || 'admin';
const AUTH_PASS = process.env.CRONJOBS_PASS || 'DW2024!';
const SNAPSHOT_PATH = path.join(__dirname, 'data', 'liveness-snapshot.json');
const RUNS_REFRESH_MS = 5 * 60 * 1000;
const RUNS_CONCURRENCY = 8;
// These jobs intentionally encode a detected condition as a non-zero exit.
// Explicit membership avoids hiding genuine crashes behind a name heuristic.
const ALERT_EXIT_LABELS = new Set([
  'com.steve.dw-backup-canary',
  'com.steve.gmc-feed-guard',
  'com.steve.pm2-fracture-canary',
  'com.steve.chargeandexplore-release-canary',
  'com.steve.enrichment-health',
]);

// ---- launchd introspection (lifted from AbramsEgo server.js) ---------------
function sh(cmd, args, timeoutMs = 8000) {
  return new Promise((resolve) => {
    execFile(cmd, args, { timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 * 16 }, (err, stdout) => {
      resolve({ ok: !err, out: (stdout || '').toString(), err: err && err.message });
    });
  });
}

// next occurrence of HH:MM local time from now (today, or tomorrow if passed)
function nextDailyRun(hour, minute) {
  const now = new Date();
  const next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0);
  if (next <= now) next.setDate(next.getDate() + 1);
  return next.toISOString();
}

function readCronPlistMeta(filePath) {
  const meta = { schedule: null, program: null, created: null, modified: null, lastRun: null, nextRun: null, model: null };
  try {
    const st = fs.statSync(filePath);
    meta.created = new Date(st.birthtimeMs || st.ctimeMs).toISOString();
    meta.modified = new Date(st.mtimeMs).toISOString();
  } catch (e) {}
  let xml = '';
  try { xml = fs.readFileSync(filePath, 'utf8'); } catch (e) { return meta; }
  const iv = xml.match(/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/i);
  if (iv) {
    const s = parseInt(iv[1], 10);
    meta.schedule = s % 3600 === 0 ? `every ${s / 3600}h` : s % 60 === 0 ? `every ${s / 60}m` : `every ${s}s`;
  } else if (/<key>StartCalendarInterval<\/key>/i.test(xml)) {
    const h = xml.match(/<key>Hour<\/key>\s*<integer>(\d+)<\/integer>/i);
    const m = xml.match(/<key>Minute<\/key>\s*<integer>(\d+)<\/integer>/i);
    meta.schedule = h ? `daily ${String(h[1]).padStart(2, '0')}:${m ? String(m[1]).padStart(2, '0') : '00'}` : 'calendar';
    if (h) meta.nextRun = nextDailyRun(parseInt(h[1], 10), m ? parseInt(m[1], 10) : 0);
  } else if (/<key>RunAtLoad<\/key>\s*<true/i.test(xml)) {
    meta.schedule = 'at load';
  }
  const prog = xml.match(/<key>Program<\/key>\s*<string>([^<]+)<\/string>/i)
    || xml.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/i);
  if (prog) meta.program = prog[1].split('/').pop();
  let lastMs = 0;
  for (const re of [/<key>StandardOutPath<\/key>\s*<string>([^<]+)<\/string>/i, /<key>StandardErrorPath<\/key>\s*<string>([^<]+)<\/string>/i]) {
    const p = xml.match(re);
    if (!p) continue;
    try { const st = fs.statSync(p[1]); if (st.mtimeMs > lastMs) lastMs = st.mtimeMs; } catch (e) {}
  }
  if (lastMs) meta.lastRun = new Date(lastMs).toISOString();
  const mArg = xml.match(/--model\s*<\/string>\s*<string>([^<]+)</i);
  const mTok = xml.match(/\bclaude-(?:opus|sonnet|haiku|fable)[\w.-]*/i);
  if (mArg) meta.model = mArg[1].trim();
  else if (mTok) meta.model = mTok[0];
  if (!meta.model) {
    try {
      const argsBlock = xml.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/i);
      const args = argsBlock ? [...argsBlock[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]) : [];
      let script = args.find((a) => a.startsWith('/') && /\.(sh|js|cjs|mjs|py)$/.test(a));
      if (!script) {
        const wd = (xml.match(/<key>WorkingDirectory<\/key>\s*<string>([^<]+)<\/string>/i) || [])[1];
        const rel = args.find((a) => !a.startsWith('-') && !a.startsWith('/') && /\.(sh|js|cjs|mjs|py)$/.test(a));
        if (wd && rel) script = path.join(wd, rel);
      }
      if (!script && args.length) {
        const last = args[args.length - 1];
        if (last.startsWith('/') && fs.existsSync(last) && fs.statSync(last).isFile()) script = last;
      }
      if (script && fs.existsSync(script)) {
        const st = fs.statSync(script);
        if (st.isFile() && st.size < 512 * 1024) {
          const src = fs.readFileSync(script, 'utf8');
          const sArg = src.match(/--model[= ]["']?([\w.-]+)/);
          const sTok = src.match(/\bclaude-(?:opus|sonnet|haiku|fable)[\w.-]*/i);
          if (sArg) meta.model = sArg[1];
          else if (sTok) meta.model = sTok[0];
        }
      }
    } catch (e) {}
  }
  // inferred purpose: first non-boilerplate XML comment in the plist, else the
  // program name. Cheap heuristic — good enough for a "what does it do" column.
  let purpose = null;
  const cmt = xml.match(/<!--\s*([\s\S]*?)\s*-->/);
  if (cmt) purpose = cmt[1].replace(/\s+/g, ' ').trim().slice(0, 220);
  meta.purpose = purpose;
  return meta;
}

async function collectCrons() {
  const laDir = path.join(HOME, 'Library', 'LaunchAgents');
  let files = [];
  try { files = fs.readdirSync(laDir).filter((f) => /^com\.steve\..*\.plist$/.test(f)); } catch (e) {}
  const ll = await sh('launchctl', ['list'], 5000);
  const loaded = {};
  if (ll.ok) {
    ll.out.trim().split('\n').forEach((line) => {
      const cols = line.split(/\s+/);
      const label = cols[cols.length - 1];
      if (label && label.startsWith('com.steve.')) loaded[label] = { pid: cols[0], exit: cols[1] };
    });
  }
  const jobs = files.map((f) => {
    const label = f.replace(/\.plist$/, '');
    const l = loaded[label];
    const pid = l && l.pid !== '-' ? l.pid : null;
    const lastExit = l ? l.exit : null;
    const meta = readCronPlistMeta(path.join(laDir, f));
    // Some canaries deliberately use a non-zero exit to mean "condition found".
    // Keep that operational alert distinct from a job that crashed. This list is
    // explicit: broad canary/guard name matching would hide genuine script errors.
    const nonzeroExit = lastExit && lastExit !== '0' && lastExit !== '-';
    // State from launchd TRUTH, not log mtime: running if a pid is present,
    // alert for a known signal-style nonzero exit, error for other nonzero exits.
    const state = pid ? 'running' : nonzeroExit
      ? (ALERT_EXIT_LABELS.has(label) ? 'alert' : 'error')
      : 'active';
    const live = livenessFor(RUNS_SNAPSHOT[label]);
    // Purpose: prefer the plist's own comment; else de-slug the label into a
    // readable phrase (com.steve.dw-shopify-storage-canary -> "dw shopify
    // storage canary") so EVERY card answers "what does it do", not just the
    // ~17% whose author left a comment.
    const purpose = meta.purpose
      || label.replace(/^com\.steve\./, '').replace(/[-_.]/g, ' ').trim();
    return {
      label, loaded: !!l, pid, lastExit, state,
      schedule: meta.schedule, program: meta.program, purpose,
      created: meta.created, modified: meta.modified,
      // lastLogWrite (was `lastRun`): mtime of the job's stdout/stderr log — a
      // FROZEN value for silently-running jobs, so it is NOT a real-run signal.
      // Kept because it's still informative (last time the job wrote output).
      lastLogWrite: meta.lastRun, nextRun: meta.nextRun, model: meta.model,
      // true liveness from launchd run accounting (see livenessFor)
      runs: live.runs, runsDelta: live.runsDelta,
      lastExitCode: live.lastExitCode, liveness: live.liveness,
    };
  });
  jobs.sort((a, b) => a.label.localeCompare(b.label));
  return { count: jobs.length, loadedCount: jobs.filter((j) => j.loaded).length, jobs, generatedAt: new Date().toISOString() };
}

// ---- true liveness from launchd run accounting -----------------------------
// The log-mtime lastRun above is a LIE for silently-running jobs (they write
// nothing to stdout/stderr, so their log mtime freezes while they fire fine).
// The authoritative signal is `runs = N` from `launchctl print`, which counts
// every dispatch and does NOT reset on reload. We snapshot it on a timed
// interval (never per-request — printing ~281 jobs is too heavy for the API
// hit), persist it, and compute runsDelta across snapshots.

// parse the single top-level `runs =` / `last exit code =` / `state =` out of
// one `launchctl print` block (the indented `state = active` lines are service
// subtrees, so we take the FIRST match of each at the job's own indent level).
function parsePrint(out) {
  const runs = out.match(/^\s*runs = (\d+)/m);
  const exit = out.match(/^\s*last exit code = (\S.*?)\s*$/m);
  const st = out.match(/^\tstate = (\S.*?)\s*$/m);
  return {
    runs: runs ? parseInt(runs[1], 10) : null,
    exit: exit ? exit[1] : null,
    running: st ? /running/.test(st[1]) && !/not running/.test(st[1]) : null,
  };
}

let RUNS_SNAPSHOT = {}; // label -> { runs, exit, ts, runsDelta }
function loadSnapshot() {
  try { RUNS_SNAPSHOT = JSON.parse(fs.readFileSync(SNAPSHOT_PATH, 'utf8')); } catch (e) { RUNS_SNAPSHOT = {}; }
}
function saveSnapshot() {
  try {
    fs.mkdirSync(path.dirname(SNAPSHOT_PATH), { recursive: true });
    fs.writeFileSync(SNAPSHOT_PATH, JSON.stringify(RUNS_SNAPSHOT, null, 2));
  } catch (e) {}
}

let refreshing = false;
async function refreshRunsSnapshot() {
  if (refreshing) return;
  refreshing = true;
  try {
    const laDir = path.join(HOME, 'Library', 'LaunchAgents');
    let labels = [];
    try {
      labels = fs.readdirSync(laDir)
        .filter((f) => /^com\.steve\..*\.plist$/.test(f))
        .map((f) => f.replace(/\.plist$/, ''));
    } catch (e) {}
    const prev = RUNS_SNAPSHOT;
    const next = {};
    const now = new Date().toISOString();
    let i = 0;
    async function worker() {
      while (i < labels.length) {
        const label = labels[i++];
        const r = await sh('launchctl', ['print', `gui/${UID}/${label}`], 6000);
        if (!r.ok) continue; // not loaded / print failed -> liveness "unknown"
        const p = parsePrint(r.out);
        if (p.runs === null) continue;
        const was = prev[label];
        const delta = was && typeof was.runs === 'number' ? p.runs - was.runs : (p.runs > 0 ? p.runs : 0);
        next[label] = { runs: p.runs, exit: p.exit, running: p.running, runsDelta: delta, ts: now };
      }
    }
    await Promise.all(Array.from({ length: RUNS_CONCURRENCY }, worker));
    RUNS_SNAPSHOT = next;
    saveSnapshot();
  } finally {
    refreshing = false;
  }
}

// classify true liveness from the cached snapshot. firing = the runs counter
// advanced since the last snapshot (or first-seen with runs>0) → the job IS
// running regardless of log mtime. stale = counter did NOT advance (or the job
// has literally never run) → genuinely-not-firing. unknown = no snapshot yet /
// launchctl print failed (job not loaded).
function livenessFor(snap) {
  if (!snap || typeof snap.runs !== 'number') return { runs: null, runsDelta: null, lastExitCode: null, liveness: 'unknown' };
  const firstSeen = snap.runsDelta === snap.runs; // no prior snapshot for this label
  const advancing = snap.runsDelta > 0 || (firstSeen && snap.runs > 0);
  return {
    runs: snap.runs,
    runsDelta: snap.runsDelta,
    lastExitCode: snap.exit,
    liveness: advancing ? 'firing' : 'stale',
  };
}

// ---- express app -----------------------------------------------------------
const app = express();
app.use((req, res, next) => {
  const cred = basicAuth(req);
  if (!cred || cred.name !== AUTH_USER || cred.pass !== AUTH_PASS) {
    res.set('WWW-Authenticate', 'Basic realm="cronjobs-viewer"');
    return res.status(401).send('Auth required');
  }
  next();
});

app.get('/api/crons', async (req, res) => {
  try { res.json(await collectCrons()); }
  catch (e) { res.status(500).json({ error: e.message }); }
});

// Lazy per-job liveness: prints ONE job live (bypassing the 5-min cache) so a
// card can refresh its own runs counter on demand without a full sweep.
app.get('/api/liveness/:label', async (req, res) => {
  const label = req.params.label;
  if (!/^com\.steve\.[\w.-]+$/.test(label)) return res.status(400).json({ error: 'bad label' });
  const r = await sh('launchctl', ['print', `gui/${UID}/${label}`], 6000);
  if (!r.ok) return res.json({ label, liveness: 'unknown' });
  const p = parsePrint(r.out);
  if (p.runs === null) return res.json({ label, liveness: 'unknown' });
  const was = RUNS_SNAPSHOT[label];
  const delta = was && typeof was.runs === 'number' ? p.runs - was.runs : (p.runs > 0 ? p.runs : 0);
  res.json({ label, runs: p.runs, runsDelta: delta, lastExitCode: p.exit, running: p.running });
});

app.get('/healthz', (req, res) => res.json({ ok: true }));
// Favicon: browsers auto-request /favicon.ico with no HTML reference; without this
// route express.static 404s it → a (cached, intermittent) console error. Serve a tiny
// inline SVG clock so any client — including a direct bookmark hit — gets a 200.
app.get('/favicon.ico', (_req, res) => {
  res.type('image/svg+xml').set('Cache-Control', 'public, max-age=86400').send(
    "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🕓</text></svg>"
  );
});
app.use(express.static(path.join(__dirname, 'public')));

loadSnapshot();
refreshRunsSnapshot(); // prime on boot (async; API serves cached/prior values meanwhile)
setInterval(refreshRunsSnapshot, RUNS_REFRESH_MS).unref();

app.listen(PORT, () => console.log(`cronjobs-viewer on http://127.0.0.1:${PORT} (Basic ${AUTH_USER}/****)`));