← 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 };