#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; const ROOT_DIR = path.resolve(import.meta.dirname, '..'); const DEFAULT_STATE_PATH = path.join(ROOT_DIR, 'memory', 'watchdog-state.json'); const DEFAULT_EVIDENCE_DIR = path.join(ROOT_DIR, 'state', 'long-task-watchdog'); function parseArgs(argv) { const args = { compact: false, state: DEFAULT_STATE_PATH, now: null, evidenceDir: DEFAULT_EVIDENCE_DIR, writeState: false, help: false, }; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (token === '--compact') { args.compact = true; continue; } if (token === '--write-state') { args.writeState = true; continue; } if (token === '--help' || token === '-h') { args.help = true; continue; } if (token === '--state') { args.state = argv[i + 1] ?? args.state; i += 1; continue; } if (token.startsWith('--state=')) { args.state = token.slice('--state='.length) || args.state; continue; } if (token === '--now') { args.now = argv[i + 1] ?? null; i += 1; continue; } if (token.startsWith('--now=')) { args.now = token.slice('--now='.length) || null; continue; } if (token === '--evidence-dir') { args.evidenceDir = argv[i + 1] ?? args.evidenceDir; i += 1; continue; } if (token.startsWith('--evidence-dir=')) { args.evidenceDir = token.slice('--evidence-dir='.length) || args.evidenceDir; continue; } } return args; } function printHelp() { process.stdout.write([ 'Usage: node scripts/long_task_watchdog.mjs [--compact] [--write-state] [--state ] [--now ] [--evidence-dir ]', '', 'Minimal file-backed long-task watchdog runner.', ].join('\n') + '\n'); } function parseJsonFile(filePath) { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } function parseTime(value) { if (typeof value !== 'string' || value.length === 0) return null; const timestamp = Date.parse(value); return Number.isNaN(timestamp) ? null : timestamp; } function toIso(value) { return new Date(value).toISOString(); } function toSafeName(value) { return String(value || 'watchdog') .replace(/[^a-zA-Z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80) || 'watchdog'; } function evaluateWatchdog(watchdog, nowMs) { const intervalMinutes = Number.isFinite(watchdog?.intervalMinutes) ? watchdog.intervalMinutes : Number.parseInt(String(watchdog?.intervalMinutes ?? '0'), 10); const intervalMs = intervalMinutes > 0 ? intervalMinutes * 60 * 1000 : 0; const milestoneMs = parseTime(watchdog?.lastMilestoneAt); const lastAlertMs = parseTime(watchdog?.lastAlertAt); const active = watchdog?.status === 'active'; if (!active) { return { id: watchdog?.id ?? null, active: false, overdue: false, action: 'skip_inactive', reason: 'watchdog is not active', }; } if (!intervalMs || milestoneMs === null) { return { id: watchdog?.id ?? null, active: true, overdue: false, action: 'invalid_contract', reason: 'intervalMinutes or lastMilestoneAt is missing/invalid', }; } const dueAtMs = milestoneMs + intervalMs; const overdue = nowMs >= dueAtMs; if (!overdue) { return { id: watchdog?.id ?? null, active: true, overdue: false, action: 'within_interval', reason: 'last milestone is still within interval', dueAt: toIso(dueAtMs), minutesOverdue: 0, }; } const lastAlertStillFresh = lastAlertMs !== null && lastAlertMs >= dueAtMs; if (lastAlertStillFresh) { return { id: watchdog?.id ?? null, active: true, overdue: true, action: 'already_alerted_this_interval', reason: 'lastAlertAt already covers current overdue interval', dueAt: toIso(dueAtMs), minutesOverdue: Math.floor((nowMs - dueAtMs) / 60000), }; } return { id: watchdog?.id ?? null, active: true, overdue: true, action: 'emit_external_evidence', reason: 'active watchdog is overdue and has not been externally evidenced for this interval', dueAt: toIso(dueAtMs), minutesOverdue: Math.floor((nowMs - dueAtMs) / 60000), }; } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function writeEvidence(evidenceDir, watchdog, evaluation, nowIso) { ensureDir(evidenceDir); const fileName = `${nowIso.replace(/[:]/g, '').replace(/\.\d{3}Z$/, 'Z')}-${toSafeName(watchdog.id)}.json`; const filePath = path.join(evidenceDir, fileName); const payload = { generatedAt: nowIso, tool: 'long_task_watchdog', watchdog: { id: watchdog.id, task: watchdog.task, ownerSession: watchdog.ownerSession ?? null, ownerSessionKey: watchdog.ownerSessionKey ?? null, reportChannel: watchdog.reportChannel ?? watchdog.channel ?? null, reportTarget: watchdog.reportTarget ?? watchdog.target ?? null, intervalMinutes: watchdog.intervalMinutes, lastMilestoneAt: watchdog.lastMilestoneAt ?? null, lastAlertAt: watchdog.lastAlertAt ?? null, }, evaluation, nextExpectedExternalAction: [ 'nudge owner session', 'report owner-visible checkpoint', 'or respawn / inspect locally if owner appears stalled', ], }; fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); return filePath; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { printHelp(); process.exit(0); } const nowMs = args.now ? parseTime(args.now) : Date.now(); if (nowMs === null) { process.stderr.write('Invalid --now value\n'); process.exit(1); } const nowIso = toIso(nowMs); const state = parseJsonFile(args.state); const watchdogs = Array.isArray(state.watchdogs) ? state.watchdogs : []; const evaluations = watchdogs.map((watchdog) => ({ watchdogId: watchdog?.id ?? null, ...evaluateWatchdog(watchdog, nowMs), })); const evidenceWrites = []; const nextWatchdogs = watchdogs.map((watchdog, index) => { const evaluation = evaluations[index]; if (evaluation.action !== 'emit_external_evidence') { return watchdog; } const evidencePath = writeEvidence(args.evidenceDir, watchdog, evaluation, nowIso); evidenceWrites.push({ watchdogId: watchdog.id, path: evidencePath }); return { ...watchdog, lastAlertAt: nowIso, lastObservedActivityAt: watchdog.lastObservedActivityAt ?? watchdog.lastMilestoneAt ?? null, lastNudgeAt: watchdog.lastNudgeAt ?? null, }; }); if (args.writeState) { const nextState = { ...state, watchdogs: nextWatchdogs, }; fs.writeFileSync(args.state, `${JSON.stringify(nextState, null, 2)}\n`, 'utf8'); } const response = { ok: true, tool: 'long_task_watchdog', version: 'mvp-v1', statePath: path.resolve(args.state), evidenceDir: path.resolve(args.evidenceDir), now: nowIso, writeState: args.writeState, result: { activeCount: watchdogs.filter((item) => item?.status === 'active').length, overdueCount: evaluations.filter((item) => item.overdue === true).length, emittedCount: evidenceWrites.length, evaluations, evidenceWrites, }, }; process.stdout.write(`${JSON.stringify(response, null, args.compact ? 0 : 2)}\n`); } main();