[object Object]

← back to Cncp Failure Collector

tick 3: launchd plist-driven .err.log tailing (97 com.steve.* jobs watched, refresh every 10min, 200 cap, 100MB skip)

c57181c1c4e1cee4490b2b1368f94579dc8fdd6b · 2026-05-11 11:51:31 -0700 · SteveStudio2

Files touched

Diff

commit c57181c1c4e1cee4490b2b1368f94579dc8fdd6b
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 11 11:51:31 2026 -0700

    tick 3: launchd plist-driven .err.log tailing (97 com.steve.* jobs watched, refresh every 10min, 200 cap, 100MB skip)
---
 index.js | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++-------------
 1 file changed, 80 insertions(+), 21 deletions(-)

diff --git a/index.js b/index.js
index 8f40736..62f5493 100644
--- a/index.js
+++ b/index.js
@@ -28,7 +28,6 @@ const STATE_FILE = path.join(__dirname, 'state.json');
 const ONCE = process.argv.includes('--once');
 
 const PM2_LOG_DIR = path.join(os.homedir(), '.pm2', 'logs');
-const LAUNCHD_LOG_DIR = path.join(os.homedir(), 'Library', 'Logs');
 
 function log(...a) { console.log(new Date().toISOString(), ...a); }
 
@@ -132,44 +131,104 @@ async function scanPm2(state) {
   }
 }
 
+const LAUNCH_AGENTS_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
+const PLIST_SCAN_INTERVAL_MS = 10 * 60 * 1000;
+const MAX_WATCHLIST = 200;
+const MAX_LOG_SIZE = 100 * 1024 * 1024;
+let cachedWatchlist = null;
+
+function buildLaunchdWatchlist() {
+  // Scan ~/Library/LaunchAgents/com.steve.*.plist; extract Label + StandardErrorPath.
+  // Steve's logs live at per-project paths (e.g. ~/Projects/animals/logs/hawk.stderr.log),
+  // NOT in ~/Library/Logs/. plist is the only authoritative source.
+  let plists = [];
+  try {
+    plists = fs.readdirSync(LAUNCH_AGENTS_DIR)
+      .filter(f => f.startsWith('com.steve.') && f.endsWith('.plist'))
+      .map(f => path.join(LAUNCH_AGENTS_DIR, f));
+  } catch (e) {
+    log('buildLaunchdWatchlist: readdir failed', e.message);
+    return [];
+  }
+  const out = [];
+  for (const p of plists) {
+    let label = null, errPath = null, outPath = null;
+    try {
+      const json = execSync(`plutil -convert json -o - ${JSON.stringify(p)}`, { encoding: 'utf-8', timeout: 2000 });
+      const parsed = JSON.parse(json);
+      label = parsed.Label;
+      errPath = parsed.StandardErrorPath;
+      outPath = parsed.StandardOutPath;
+    } catch {
+      // Regex fallback for malformed plists.
+      try {
+        const raw = fs.readFileSync(p, 'utf-8');
+        const lm = raw.match(/<key>Label<\/key>\s*<string>([^<]+)<\/string>/);
+        const em = raw.match(/<key>StandardErrorPath<\/key>\s*<string>([^<]+)<\/string>/);
+        if (lm) label = lm[1];
+        if (em) errPath = em[1];
+      } catch {}
+    }
+    if (!label) continue;
+    // Prefer err path; fall back to stdout (some scripts only log to stdout).
+    const watchPath = errPath || outPath;
+    if (!watchPath) continue;
+    if (!fs.existsSync(watchPath)) continue;
+    out.push({ label, errPath: watchPath });
+  }
+  if (out.length > MAX_WATCHLIST) {
+    log(`launchd watchlist capped: ${out.length} -> ${MAX_WATCHLIST}`);
+    out.length = MAX_WATCHLIST;
+  }
+  return out;
+}
+
+function getWatchlist(state) {
+  const now = Date.now();
+  if (cachedWatchlist && state.lastPlistScan && (now - state.lastPlistScan) < PLIST_SCAN_INTERVAL_MS) {
+    return cachedWatchlist;
+  }
+  cachedWatchlist = buildLaunchdWatchlist();
+  state.lastPlistScan = now;
+  log(`building launchd watchlist: ${cachedWatchlist.length} entries`);
+  return cachedWatchlist;
+}
+
 async function scanLaunchdErrLogs(state) {
-  // Look at ~/Library/Logs/com.steve.*.err.log — only files matching the user's launchd convention.
-  let entries = [];
-  try { entries = fs.readdirSync(LAUNCHD_LOG_DIR); } catch { return; }
-  const errFiles = entries.filter(f => f.startsWith('com.steve.') && /\.err(\.log)?$/.test(f));
+  const watchlist = getWatchlist(state);
   const isBootstrap = Object.keys(state.logOffsets).length === 0;
-  for (const f of errFiles) {
-    const full = path.join(LAUNCHD_LOG_DIR, f);
+  for (const { label, errPath } of watchlist) {
     let stat;
-    try { stat = fs.statSync(full); } catch { continue; }
-    const prior = state.logOffsets[full];
+    try { stat = fs.statSync(errPath); } catch { continue; }
+    if (stat.size > MAX_LOG_SIZE) {
+      // Runaway log — skip read but seed offset at EOF so we resume cleanly if it shrinks.
+      state.logOffsets[errPath] = stat.size;
+      continue;
+    }
+    const prior = state.logOffsets[errPath];
     const cur = stat.size;
-    // On bootstrap, seed offset at current EOF — don't post pre-existing bytes.
     if (prior === undefined || isBootstrap) {
-      state.logOffsets[full] = cur;
+      state.logOffsets[errPath] = cur;
       continue;
     }
     if (cur <= prior) {
-      // Either no growth, or file was rotated (smaller now). Reset offset to current size.
-      state.logOffsets[full] = cur;
+      state.logOffsets[errPath] = cur;
       continue;
     }
-    // Read only the new bytes.
     let chunk = '';
     try {
-      const fd = fs.openSync(full, 'r');
-      const len = Math.min(cur - prior, 16_000); // cap chunk
+      const fd = fs.openSync(errPath, 'r');
+      const len = Math.min(cur - prior, 16_000);
       const buf = Buffer.alloc(len);
       fs.readSync(fd, buf, 0, len, prior);
       fs.closeSync(fd);
       chunk = buf.toString('utf-8');
-    } catch (e) { log('read err log failed', full, e.message); continue; }
+    } catch (e) { log('read err log failed', errPath, e.message); continue; }
 
-    // Suppress trivial noise: blank chunks, lone newlines.
     const meaningful = chunk.replace(/\s+/g, '').length > 5;
-    if (!meaningful) { state.logOffsets[full] = cur; continue; }
+    if (!meaningful) { state.logOffsets[errPath] = cur; continue; }
 
-    const processName = f.replace(/^com\.steve\./, '').replace(/\.err(\.log)?$/, '');
+    const processName = label.replace(/^com\.steve\./, '');
     const posted = await postTask({
       source: 'launchd',
       processName,
@@ -178,7 +237,7 @@ async function scanLaunchdErrLogs(state) {
     });
     if (posted) {
       log(`reported launchd error chunk: ${processName} (${chunk.length}B new)`);
-      state.logOffsets[full] = cur;
+      state.logOffsets[errPath] = cur;
     } else {
       log(`launchd post failed, will retry: ${processName} (offset stays at ${prior})`);
     }

← 495181c tick 2: heartbeat log every 10 quiet cycles, skip self in pm  ·  back to Cncp Failure Collector  ·  tick 4: fire-and-forget classify on new rows (5min per-proce 8a79514 →