← 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
M .gitignoreA 5x/REPORT.mdM public/index.htmlM server.js
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=>({'&':'&','<':'
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 →