← 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 →