← back to Answer Cockpit
lib/audit.js
101 lines
'use strict';
// audit.js — append-only audit trail + PASS/WARN/FAIL heartbeat.
//
// data/audit.jsonl one line per write-back attempt INCLUDING refusals
// {ts,action,tty,ticket,color,text,memoFile,ok,err}
// data/latest.json fleet-health-rollup heartbeat. verdict AND status are
// one of exactly PASS | WARN | FAIL (CLAUDE.md TK-10546).
// PASS scan ok + iTerm reachable (measured)
// WARN empty scan / terminal_api unavailable — an
// unmeasured input is never green (TK-11431 #1)
// FAIL the last write-back threw (iTerm unreachable)
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, '..', 'data');
const AUDIT = path.join(DATA_DIR, 'audit.jsonl');
const LATEST = path.join(DATA_DIR, 'latest.json');
function ensureDir() { try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch {} }
// Last write-back outcome, kept in memory so the heartbeat can reflect it.
const state = { lastWriteback: null, lastScan: null };
function audit(entry) {
ensureDir();
const line = {
ts: new Date().toISOString(),
action: entry.action || 'unknown',
tty: entry.tty || null,
ticket: entry.ticket || null,
color: entry.color || null,
text: entry.text == null ? null : String(entry.text).slice(0, 2000),
memoFile: entry.memoFile || null,
ok: !!entry.ok,
err: entry.err ? String(entry.err).slice(0, 500) : null,
};
if (entry.dry) line.dry = true;
if (entry.refused) line.refused = true;
// menu answers type a NUMBER; keep the human label so the audit reads "2 (Bravo)", not "2"
if (entry.optionLabel) line.optionLabel = String(entry.optionLabel).slice(0, 200);
try { fs.appendFileSync(AUDIT, JSON.stringify(line) + '\n'); } catch {}
// Only real osascript attempts (not guard refusals, not dry runs) move the
// FAIL needle — a 403 refusal is the cockpit working, not iTerm failing.
if (entry.osascriptAttempted) {
state.lastWriteback = { ts: line.ts, ok: line.ok, err: line.err, action: line.action };
}
return line;
}
function tailAudit(n = 20) {
try {
const txt = fs.readFileSync(AUDIT, 'utf8');
const lines = txt.split('\n').filter(Boolean);
return lines.slice(-n).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
} catch { return []; }
}
/**
* heartbeat(scan) — called after every scan. scan = { rows, stale, terminalApi, source, error }
* Three states, never two: MEASURED-GOOD / MEASURED-BAD / NOT-MEASURED (WARN).
*/
function heartbeat(scan) {
ensureDir();
state.lastScan = { ts: new Date().toISOString(), n: scan.rows ? scan.rows.length : 0, stale: !!scan.stale, terminalApi: scan.terminalApi || 'unknown', source: scan.source || null };
let verdict = 'PASS';
let reason = 'scan ok, iTerm reachable';
const population = scan.rows ? scan.rows.length : 0;
if (state.lastWriteback && state.lastWriteback.ok === false) {
verdict = 'FAIL';
reason = 'last write-back threw: ' + (state.lastWriteback.err || 'unknown');
} else if (scan.error) {
verdict = 'WARN';
reason = 'scan failed (NOT-MEASURED): ' + scan.error;
} else if (scan.stale || scan.terminalApi === 'unavailable') {
verdict = 'WARN';
reason = 'terminal_api unavailable / stale scan (NOT-MEASURED, never green)';
} else if (population === 0) {
verdict = 'WARN';
reason = 'scan returned 0 rows (0 of 0 is indistinguishable from broken — NOT-MEASURED)';
}
const doc = {
ts: state.lastScan.ts,
skill: 'answer-cockpit',
verdict, status: verdict,
reason,
population,
observed_needs_steve: scan.needsSteve == null ? null : scan.needsSteve,
terminal_api: state.lastScan.terminalApi,
scan_source: state.lastScan.source,
stale: state.lastScan.stale,
last_writeback: state.lastWriteback,
cost: '$0 (local)',
};
try { fs.writeFileSync(LATEST, JSON.stringify(doc, null, 2) + '\n'); } catch {}
return doc;
}
function latest() { try { return JSON.parse(fs.readFileSync(LATEST, 'utf8')); } catch { return null; } }
module.exports = { audit, tailAudit, heartbeat, latest, state, DATA_DIR, AUDIT, LATEST };