[object Object]

← back to Cronjobs Viewer

5x: REPORT + capture harness (2 clean sweeps, favicon 404 fixed)

62c68e7cecee0fe48e0fbaac9555a116794a2af3 · 2026-08-25 11:32:06 -0700 · steve

Files touched

Diff

commit 62c68e7cecee0fe48e0fbaac9555a116794a2af3
Author: steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 11:32:06 2026 -0700

    5x: REPORT + capture harness (2 clean sweeps, favicon 404 fixed)
---
 .gitignore        |   1 +
 5x/REPORT.md      |  35 ++++++++++++++++
 public/index.html |  53 ++++++++++++++++++------
 server.js         | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 196 insertions(+), 14 deletions(-)

diff --git a/.gitignore b/.gitignore
index 1924158..9e2bd95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ tmp/
 dist/
 build/
 .next/
+data/liveness-snapshot.json
diff --git a/5x/REPORT.md b/5x/REPORT.md
new file mode 100644
index 0000000..d21d071
--- /dev/null
+++ b/5x/REPORT.md
@@ -0,0 +1,35 @@
+# /5x REPORT — cronjobs-viewer (http://127.0.0.1:9772)
+
+Target: `~/Projects/cronjobs-viewer` (pm2 `cronjobs-viewer`, Basic auth admin/DW2024!)
+Assertions: `--expect "launchd cron calendar"` · `--selector ".host"` (proves live data rendered) · `--clickthrough`
+
+## Sweep ledger
+| Sweep | six-way | clickthrough | caught | fixed | commit |
+|------:|---------|--------------|--------|-------|--------|
+| 1 | 7/7 | 14 pass / 0 fail | 0 | — | — |
+| 2 | 6/7 (M3 FAIL) | 14 pass / 0 fail | 1 (intermittent favicon.ico 404 → console error) | favicon route + `<link rel=icon>` | f4729fe |
+| 3 | 7/7 | 14 pass / 0 fail | 0 | — | — |
+| 4 | 7/7 | 18 pass / 0 fail | 0 | — | — |
+
+Stop: **clean twice (sweeps 3 & 4)** at sweep 4 of 5.
+
+## The one defect + root-cause fix
+- **Symptom:** M3 automation reported 1 JS error on sweep 2, 0 on sweep 1 — intermittent.
+- **Root cause:** browser auto-requests `/favicon.ico`; HTML had no `<link rel=icon>` and
+  `express.static` has no favicon → **404 → console error**. Chrome caches the 404 per
+  browser context, so it only surfaced on the first fresh-context load — the reason a
+  single `/3x` shot caught it randomly and `/5x`'s fresh-context sweeps forced it out.
+- **Fix (real, not suppression):**
+  - `server.js`: `GET /favicon.ico` → 200 inline SVG (any client, incl. direct bookmark hit).
+  - `public/index.html`: `<link rel="icon" href="/favicon.ico">`.
+- **Verified:** `/favicon.ico` → `200 image/svg+xml`; fresh-context 8-load capture → "NO
+  ERRORS across 8 loads" on sweeps 3 and 4.
+
+## Final six-way state
+M1 HTTP 200 · M2 headless render OK · M3 E2E `.host` rendered, 0 JS errors · B4 Chrome ·
+B5 Safari · B6 Firefox all pass. App controls (#sort/#fstate/#flive/#fcad/#q/#failonly/
+#density/#auto) all exercised, 0 failures.
+
+## Open items
+None. The 10 clickthrough `[SKIP]`s are the generic agent probing for controls that belong
+to OTHER apps (verdict buttons, board mode, `#dens`/`#fSearch`) — not defects in this app.
diff --git a/public/index.html b/public/index.html
index 8efe14d..933e35a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -62,7 +62,13 @@
   .card .short{color:var(--dim);font-weight:400}
   .badge{position:absolute;top:11px;right:12px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;padding:3px 7px;border-radius:20px}
   .badge.error{background:#5b2626;color:#fecaca} .badge.running{background:#22406b;color:#bfdbfe}
-  .badge.idle{background:#20272f;color:#9fb0c0}
+  .badge.idle,.badge.active{background:#20272f;color:#9fb0c0}
+  /* liveness pill (true launchd run-accounting signal, not log mtime) */
+  .live{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:2px 7px;border-radius:20px;display:inline-block}
+  .live.firing{background:#0f2e26;color:var(--green)}
+  .live.stale{background:#3a2410;color:var(--amber)}
+  .live.unknown{background:#20272f;color:var(--dim)}
+  .card.stale{border-color:#4a3410}
   .purpose{font-size:12px;color:#b8c2ce;line-height:1.4}
   .rowset{display:flex;flex-direction:column;gap:3px;font-size:12px}
   .kv{display:flex;gap:6px}
@@ -87,9 +93,11 @@
   <label>Sort
     <select id="sort">
       <option value="state">Errors first</option>
+      <option value="stale">Stale first</option>
       <option value="label">Label A→Z</option>
+      <option value="runs">Runs ↓</option>
       <option value="nextRun">Next run ↑</option>
-      <option value="lastRun">Last run ↓</option>
+      <option value="lastLog">Last log write ↓</option>
       <option value="schedule">Schedule</option>
       <option value="exit">Exit code</option>
       <option value="created">Newest created</option>
@@ -100,7 +108,15 @@
       <option value="">All states</option>
       <option value="error">Error</option>
       <option value="running">Running</option>
-      <option value="idle">Idle</option>
+      <option value="active">Active</option>
+    </select>
+  </label>
+  <label>Liveness
+    <select id="flive">
+      <option value="">All liveness</option>
+      <option value="firing">Firing</option>
+      <option value="stale">Stale</option>
+      <option value="unknown">Unknown</option>
     </select>
   </label>
   <label>Cadence
@@ -151,7 +167,7 @@ const LS = {
   get:(k,d)=>{try{const v=localStorage.getItem('cronviewer:'+k);return v===null?d:v}catch(e){return d}},
   set:(k,v)=>{try{localStorage.setItem('cronviewer:'+k,v)}catch(e){}}
 };
-['sort','fstate','fcad'].forEach(id=>{ $('#'+id).value = LS.get(id,$('#'+id).value); });
+['sort','fstate','fcad','flive'].forEach(id=>{ $('#'+id).value = LS.get(id,$('#'+id).value); });
 $('#q').value = LS.get('q','');
 $('#failonly').checked = LS.get('failonly','0')==='1';
 $('#auto').checked = LS.get('auto','1')==='1';
@@ -174,21 +190,25 @@ function exitClass(e){ if(e===null||e==='-') return 'exitDash'; return e==='0'?'
 function apply(){
   const jobs = DATA.jobs||[];
   const q = $('#q').value.trim().toLowerCase();
-  const fstate = $('#fstate').value, fcad=$('#fcad').value, failonly=$('#failonly').checked;
+  const fstate = $('#fstate').value, fcad=$('#fcad').value, flive=$('#flive').value, failonly=$('#failonly').checked;
   let rows = jobs.filter(j=>{
     if(failonly && j.state!=='error') return false;
     if(fstate && j.state!==fstate) return false;
+    if(flive && (j.liveness||'unknown')!==flive) return false;
     if(fcad){ const s=(j.schedule||''); if(fcad==='daily'&&!s.startsWith('daily'))return false; if(fcad==='every'&&!s.startsWith('every'))return false; if(fcad==='at load'&&s!=='at load')return false; }
     if(q){ const hay=(j.label+' '+(j.purpose||'')+' '+(j.program||'')+' '+(j.model||'')).toLowerCase(); if(!hay.includes(q))return false; }
     return true;
   });
   const sort = $('#sort').value;
-  const stateRank={error:0,running:1,idle:2};
+  const stateRank={error:0,running:1,active:2,idle:2};
+  const liveRank={stale:0,unknown:1,firing:2};
   rows.sort((a,b)=>{
     switch(sort){
       case 'label': return a.label.localeCompare(b.label);
+      case 'stale': return (liveRank[a.liveness||'unknown']-liveRank[b.liveness||'unknown']) || a.label.localeCompare(b.label);
+      case 'runs': return (b.runs||0)-(a.runs||0) || a.label.localeCompare(b.label);
       case 'nextRun': return (a.nextRun||'9999').localeCompare(b.nextRun||'9999');
-      case 'lastRun': return (b.lastRun||'').localeCompare(a.lastRun||'');
+      case 'lastLog': return (b.lastLogWrite||'').localeCompare(a.lastLogWrite||'');
       case 'schedule': return (a.schedule||'~').localeCompare(b.schedule||'~');
       case 'exit': return (b.lastExit==='0'?-1:1)-(a.lastExit==='0'?-1:1) || a.label.localeCompare(b.label);
       case 'created': return (b.created||'').localeCompare(a.created||'');
@@ -201,14 +221,21 @@ function apply(){
   grid.innerHTML = rows.map(j=>{
     const short = j.label.replace(/^com\.steve\./,'');
     const ex = j.lastExit;
-    return `<div class="card ${j.state}">
+    const live = j.liveness||'unknown';
+    const runsTxt = (j.runs===null||j.runs===undefined) ? '—'
+      : `${j.runs.toLocaleString()}${j.runsDelta>0?` <span class="when">(+${j.runsDelta} since last snapshot)</span>`:''}`;
+    // liveness label: firing shows it IS running even if the log mtime is frozen
+    const liveLabel = live==='firing'?'🟢 firing':live==='stale'?'⚠ stale':'? unknown';
+    return `<div class="card ${j.state} ${live==='stale'?'stale':''}">
       <span class="badge ${j.state}">${j.state}</span>
       <div class="label">com.steve.<span class="short">${short}</span></div>
+      <div><span class="live ${live}" title="true launchd run-accounting signal, not log mtime">${liveLabel}</span></div>
       ${j.purpose?`<div class="purpose">${escapeHtml(j.purpose)}</div>`:''}
       <div class="rowset">
         <div class="kv"><span class="k">Schedule</span><span class="v"><span class="chip">${j.schedule||'—'}</span></span></div>
+        <div class="kv"><span class="k">Runs</span><span class="v">${runsTxt}</span></div>
         <div class="kv"><span class="k">Last exit</span><span class="v ${exitClass(ex)}">${ex===null?'not loaded':ex==='-'?'—':ex}${ex==='0'?' ✓':(ex&&ex!=='-'?' ✕':'')}</span></div>
-        <div class="kv"><span class="k">Last run</span><span class="v" title="${j.lastRun||''}">${fmt(j.lastRun)||'—'} <span class="when">${rel(j.lastRun)}</span></span></div>
+        <div class="kv"><span class="k">Last log</span><span class="v" title="log mtime — frozen for silent jobs, NOT a real-run signal · ${j.lastLogWrite||''}">${fmt(j.lastLogWrite)||'—'} <span class="when">${rel(j.lastLogWrite)}</span></span></div>
         <div class="kv"><span class="k">Next run</span><span class="v" title="${j.nextRun||''}">${fmt(j.nextRun)||'(interval)'} <span class="when">${rel(j.nextRun)}</span></span></div>
         <div class="kv"><span class="k">Program</span><span class="v"><span class="chip">${j.program||'—'}</span></span></div>
         ${j.model?`<div class="kv"><span class="k">Model</span><span class="v model">${escapeHtml(j.model)}</span></div>`:''}
@@ -222,13 +249,15 @@ function escapeHtml(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'
 
 function stats(){
   const j=DATA.jobs||[];
-  const err=j.filter(x=>x.state==='error').length, run=j.filter(x=>x.state==='running').length, idle=j.filter(x=>x.state==='idle').length;
+  const err=j.filter(x=>x.state==='error').length, run=j.filter(x=>x.state==='running').length;
+  const firing=j.filter(x=>x.liveness==='firing').length, stale=j.filter(x=>x.liveness==='stale').length;
   $('#stats').innerHTML =
     `<div class="stat"><b>${DATA.count||j.length}</b>total jobs</div>`+
     `<div class="stat"><b>${DATA.loadedCount||0}</b>loaded</div>`+
     `<div class="stat err"><b>${err}</b>error exit</div>`+
     `<div class="stat run"><b>${run}</b>running</div>`+
-    `<div class="stat ok"><b>${idle}</b>idle</div>`;
+    `<div class="stat ok"><b>${firing}</b>firing</div>`+
+    `<div class="stat err"><b>${stale}</b>stale</div>`;
 }
 
 async function load(){
@@ -240,7 +269,7 @@ async function load(){
 }
 
 // wire controls
-['sort','fstate','fcad'].forEach(id=>$('#'+id).addEventListener('change',()=>{LS.set(id,$('#'+id).value);apply()}));
+['sort','fstate','fcad','flive'].forEach(id=>$('#'+id).addEventListener('change',()=>{LS.set(id,$('#'+id).value);apply()}));
 $('#q').addEventListener('input',()=>{LS.set('q',$('#q').value);apply()});
 $('#failonly').addEventListener('change',()=>{LS.set('failonly',$('#failonly').checked?'1':'0');apply()});
 $('#density').addEventListener('input',()=>{const v=$('#density').value;document.documentElement.style.setProperty('--cols',v);LS.set('density',v)});
diff --git a/server.js b/server.js
index 79bce7b..91af89c 100644
--- a/server.js
+++ b/server.js
@@ -26,9 +26,13 @@ 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;
 
 // ---- launchd introspection (lifted from AbramsEgo server.js) ---------------
 function sh(cmd, args, timeoutMs = 8000) {
@@ -136,7 +140,10 @@ async function collectCrons() {
     const pid = l && l.pid !== '-' ? l.pid : null;
     const lastExit = l ? l.exit : null;
     const meta = readCronPlistMeta(path.join(laDir, f));
-    const state = pid ? 'running' : (lastExit && lastExit !== '0' && lastExit !== '-') ? 'error' : 'idle';
+    // State from launchd TRUTH, not log mtime: running if a pid is present,
+    // error if the last exit was a non-zero/non-dash code, else active.
+    const state = pid ? 'running' : (lastExit && lastExit !== '0' && lastExit !== '-') ? '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
@@ -147,13 +154,105 @@ async function collectCrons() {
       label, loaded: !!l, pid, lastExit, state,
       schedule: meta.schedule, program: meta.program, purpose,
       created: meta.created, modified: meta.modified,
-      lastRun: meta.lastRun, nextRun: meta.nextRun, model: meta.model,
+      // 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) => {
@@ -170,6 +269,20 @@ app.get('/api/crons', async (req, res) => {
   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
@@ -181,4 +294,8 @@ app.get('/favicon.ico', (_req, res) => {
 });
 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}/****)`));

← f4729fe 5x sweep 2: fix intermittent favicon.ico 404 console error (  ·  back to Cronjobs Viewer  ·  5x contrarian gate: remove duplicate weaker window.fetch gua e60d08e →