← back to Fix Live Board

server.js

109 lines

#!/usr/bin/env node
// fix-live-board — GENERALIZED live "broken -> fixed" board (generalized from flock-fix-viewer).
// One board per JOB. A job supplies a source-agnostic `probe` command that prints a JSON array
// of rows; the board derives per-field pass/fail chips + an overall fixed% progress bar, and
// polls live so cards turn green as reversible/ledgered fixers land. Basic auth admin/DW2024!.
//
// Run:  JOB=jobs/flock.json node server.js       (usually launched via run.mjs / watcher.mjs)
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');

const ROOT = __dirname;
const JOB_PATH = path.resolve(ROOT, process.env.JOB || 'jobs/demo.json');
const job = JSON.parse(fs.readFileSync(JOB_PATH, 'utf8'));
const JOB_ID = job.id || path.basename(JOB_PATH).replace(/\.json$/, '');
const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
const REALM = (job.name || JOB_ID).replace(/[^\w \-]/g, '');
const FIELDS = job.fields || [];
// A row is "fixed" when the probe says so, else when every non-warn field is truthy.
const REQUIRED = FIELDS.filter(f => !f.warn).map(f => f.key);
function isFixed(row) {
  if (typeof row.fixed === 'boolean') return row.fixed;
  const f = row.fields || {};
  return REQUIRED.length ? REQUIRED.every(k => !!f[k]) : false;
}

// --- probe: run the job's command, expect a JSON array on stdout ---
let cache = { at: 0, rows: [], err: null };
function runProbe() {
  return new Promise(resolve => {
    const cwd = job.cwd ? path.resolve(ROOT, job.cwd) : ROOT;
    const p = spawn('/bin/sh', ['-c', job.probe], { cwd, env: process.env });
    let out = '', err = '';
    const to = setTimeout(() => { try { p.kill('SIGKILL'); } catch (e) {} }, (job.probeTimeoutMs || 30000));
    p.stdout.on('data', c => out += c);
    p.stderr.on('data', c => err += c);
    p.on('close', () => {
      clearTimeout(to);
      try {
        const rows = JSON.parse(out);
        resolve({ rows: Array.isArray(rows) ? rows.map(r => ({ ...r, fixed: isFixed(r) })) : [], err: null });
      } catch (e) {
        resolve({ rows: [], err: 'probe parse error: ' + String(e).slice(0, 200) + (err ? ' | stderr: ' + err.slice(0, 200) : '') });
      }
    });
    p.on('error', e => { clearTimeout(to); resolve({ rows: [], err: 'probe spawn error: ' + String(e) }); });
  });
}
async function fetchStatus() {
  if (Date.now() - cache.at < (job.throttleMs || 4000) && (cache.rows.length || cache.err)) return cache;
  const r = await runProbe();
  cache = { at: Date.now(), rows: r.rows, err: r.err };
  return cache;
}

// --- gated fixer launch: ONLY commands the job explicitly declares with launch:true fire here ---
function ledger(entry) {
  try {
    const line = JSON.stringify({ ts: new Date().toISOString(), agent: 'fix-live-board', ...entry }) + '\n';
    fs.appendFileSync(path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl'), line);
  } catch (e) {}
}
function launchFixer(id) {
  return new Promise(resolve => {
    const fx = (job.fixers || []).find(f => f.id === id);
    if (!fx) return resolve({ ok: false, msg: 'unknown fixer' });
    if (!fx.launch) return resolve({ ok: false, msg: 'GATED — run this fixer manually (launch not enabled for this job)' });
    const cwd = fx.cwd ? path.resolve(ROOT, fx.cwd) : (job.cwd ? path.resolve(ROOT, job.cwd) : ROOT);
    const p = spawn('/bin/sh', ['-c', fx.cmd], { cwd, env: process.env });
    let out = '', err = '';
    p.stdout.on('data', c => out += c); p.stderr.on('data', c => err += c);
    p.on('close', code => {
      ledger({ ticket: job.ticket || '', action: 'fix-live-board launch fixer ' + id + ' (' + JOB_ID + ')', blast_radius: fx.blast || 'job-declared', undo_cmd: fx.undo || 'see job fixer', verify: 'board reflects on next poll', exit: code });
      resolve({ ok: code === 0, code, out: out.slice(-4000), err: err.slice(-2000) });
    });
    p.on('error', e => resolve({ ok: false, msg: String(e) }));
  });
}

const PAGE = fs.readFileSync(path.join(ROOT, 'public', 'board.html'), 'utf8');
const server = http.createServer(async (req, res) => {
  if ((req.headers.authorization || '') !== AUTH) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="' + REALM + '"' }); return res.end('auth required');
  }
  const u = req.url || '/';
  if (u.startsWith('/api/meta')) {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ id: JOB_ID, name: job.name || JOB_ID, blurb: job.blurb || '', fields: FIELDS, fixers: (job.fixers || []).map(f => ({ id: f.id, label: f.label, launch: !!f.launch })) }));
  }
  if (u.startsWith('/api/status')) {
    const s = await fetchStatus();
    res.writeHead(s.err ? 200 : 200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ rows: s.rows, err: s.err, at: s.at }));
  }
  if (u.startsWith('/api/fix') && req.method === 'POST') {
    const id = new URL(u, 'http://x').searchParams.get('id');
    const r = await launchFixer(id);
    res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(r));
  }
  res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(PAGE);
});
server.listen(0, '127.0.0.1', () => {
  const port = server.address().port;
  fs.writeFileSync(path.join(ROOT, '.runtime', JOB_ID + '.port'), String(port));
  fs.writeFileSync(path.join(ROOT, '.runtime', JOB_ID + '.pid'), String(process.pid));
  console.log('FIX-LIVE-BOARD [' + JOB_ID + '] http://127.0.0.1:' + port + '  (admin / DW2024!)');
});