[object Object]

← back to AbramsEgo

feat(cron-launchd): scheduled-jobs panel

ce2faceb53c4a50443c414e80038863a41383606 · 2026-07-01 11:21:53 -0700 · Steve

Files touched

Diff

commit ce2faceb53c4a50443c414e80038863a41383606
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 1 11:21:53 2026 -0700

    feat(cron-launchd): scheduled-jobs panel
---
 public/index.html | 36 ++++++++++++++++++++++++++++++++++++
 server.js         | 36 +++++++++++++++++++++++++++++++++++-
 2 files changed, 71 insertions(+), 1 deletion(-)

diff --git a/public/index.html b/public/index.html
index 0540bfb..56c276f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -161,6 +161,12 @@
     <div class="list" id="subruns" style="max-height:340px"></div>
   </section>
 
+  <!-- CRON / LAUNCHD (panel 4) — every scheduled com.steve.* job as an admin card -->
+  <section class="card span12">
+    <h2>⏱ Cron / launchd <span class="muted" id="crons-sub"></span></h2>
+    <div id="crongrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px"></div>
+  </section>
+
   <!-- AI CHAT -->
   <section class="card span12 chat">
     <h2>🤖 Ask AbramsEgo <span class="muted">· grounded in the live snapshot · cost shown per query</span></h2>
@@ -221,6 +227,9 @@ async function load(){
   // ---- model usage + sub-agent runs (panel 5) ----
   renderUsage(d);
 
+  // ---- cron / launchd (panel 4) ----
+  renderCrons(d);
+
   // ---- fleet ----
   const k=d.kamatera||{}, l=d.localFleet||{};
   $('kam-up').innerHTML=`<span class="${cls(100-(k.up/(k.total||1)*100),20,50)}">${k.up??'—'}</span><span class="dim" style="font-size:16px">/${k.total??'—'} up</span>`;
@@ -333,6 +342,33 @@ function renderUsage(d){
     }).join('')||'<span class="muted">no sub-agent runs</span>';
   }
 }
+// panel 4 — every com.steve.* launchd job as an ADMIN CARD → MUST show created
+// date+time (🕓 toLocaleString, full ISO in title=). Status pill: running=green
+// PID · idle=grey · nonzero-exit=red.
+function renderCrons(d){
+  const cr=d.crons||{};
+  const jobs=(cr.jobs||[]).slice().sort((a,b)=>String(a.label).localeCompare(String(b.label)));
+  $('crons-sub').textContent=`· ${cr.loadedCount??0} loaded / ${cr.count??0} total · com.steve.*`;
+  const el=$('crongrid'); if(!el) return;
+  const pill=(j)=>{
+    if(j.state==='running') return `<span class="pill live">▶ running · ${esc(j.pid)}</span>`;
+    if(j.state==='error') return `<span class="pill" style="color:var(--bad);border-color:#ff5d6c44;background:#ff5d6c0f">✕ exit ${esc(j.lastExit)}</span>`;
+    return `<span class="pill">idle</span>`;
+  };
+  el.innerHTML=jobs.map(j=>{
+    const iso=j.created||j.modified||'';
+    const dt=iso?new Date(iso):null;
+    const when=dt?dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
+    const name=String(j.label||'').replace(/^com\.steve\./,'');
+    const sched=j.schedule?`⏲ ${esc(j.schedule)}`:'<span class="dim">no schedule</span>';
+    const prog=j.program?` · <span class="dim">${esc(j.program)}</span>`:'';
+    return `<div class="subcard" title="created ${esc(iso)}">
+      <div class="subcard-top"><span class="subcard-proj">${esc(name)}</span>${pill(j)}</div>
+      <div class="subcard-when" style="margin-top:6px">${sched}${prog}</div>
+      <div class="subcard-when">🕓 ${esc(when)}</div>
+    </div>`;
+  }).join('')||'<span class="muted">no com.steve.* jobs</span>';
+}
 function statusDot(st){const c=st==='online'?'var(--good)':st?'var(--bad)':'var(--dim)';return `<span class="dot" style="background:${c}"></span>`;}
 function fmtDur(s){if(s==null)return null;if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m';return Math.floor(s/3600)+'h';}
 function esc(x){return (x==null?'':String(x)).replace(/[<>&]/g,m=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[m]));}
diff --git a/server.js b/server.js
index aad33c4..99c425c 100644
--- a/server.js
+++ b/server.js
@@ -169,6 +169,35 @@ async function collectCanaries() {
   return { canaries: out, count: out.length };
 }
 
+// parse a launchd plist for a human-readable schedule + the file's created/
+// modified timestamps (satisfies Steve's admin-card "created date+time" rule).
+// Cheap synchronous read; never throws — degrades to nulls.
+function readCronPlistMeta(filePath) {
+  const meta = { schedule: null, program: null, created: null, modified: 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';
+  } 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();
+  return meta;
+}
+
 async function collectCrons() {
   const laDir = path.join(HOME, 'Library', 'LaunchAgents');
   let files = [];
@@ -186,7 +215,12 @@ async function collectCrons() {
   const jobs = files.map((f) => {
     const label = f.replace(/\.plist$/, '');
     const l = loaded[label];
-    return { label, loaded: !!l, pid: l && l.pid !== '-' ? l.pid : null, lastExit: l ? l.exit : null };
+    const pid = l && l.pid !== '-' ? l.pid : null;
+    const lastExit = l ? l.exit : null;
+    const meta = readCronPlistMeta(path.join(laDir, f));
+    // running = live PID; error = loaded with a non-zero last exit; idle otherwise.
+    const state = pid ? 'running' : (lastExit && lastExit !== '0' && lastExit !== '-') ? 'error' : 'idle';
+    return { label, loaded: !!l, pid, lastExit, state, schedule: meta.schedule, program: meta.program, created: meta.created, modified: meta.modified };
   });
   return { count: jobs.length, loadedCount: jobs.filter((j) => j.loaded).length, jobs };
 }

← f5a8b07 chore: gitignore ephemeral data/activity.json snapshot cache  ·  back to AbramsEgo  ·  auto-save: 2026-07-01T11:42:20 (8 files) — build-queue/tasks 628b737 →