← back to Doing Viewer

server.js

292 lines

#!/usr/bin/env node
// doing-viewer — a live 3-column board of every ticket that is DOING right now:
//   col 1  What's doing        (the task)
//   col 2  What it's doing      (the latest Claude Code activity on it)
//   col 3  In plain words       (a 12-year-old-friendly explanation, local LLM, $0)
//
// Zero-dependency: pure node http + the ticket system's own lib.js (single source
// of truth for status). The plain-words column is generated by local Ollama
// (qwen3:14b) and cached by activity fingerprint so it only re-writes when a card
// actually does something new.

const http = require('http');
const fs   = require('fs');
const path = require('path');
const os   = require('os');

const PORT       = process.env.PORT || 9790;
const TICKET_LIB = path.join(os.homedir(), 'Projects', 'ticket-system', 'lib.js');
const CACHE_FILE = path.join(__dirname, 'data', 'eli12-cache.json');
const OLLAMA     = 'http://127.0.0.1:11434/api/generate';
const MODEL      = process.env.ELI12_MODEL || 'qwen3:14b';

const { execFile } = require('child_process');
const { tickets, withLock, append } = require(TICKET_LIB);
const RUN_SH = path.join(os.homedir(), 'Projects', 'ticket-system', 'run-ticket.sh');

// ── plain-words cache ────────────────────────────────────────────────────────
let cache = {};
try { cache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch {}
let cacheDirty = false;
function saveCache() {
  if (!cacheDirty) return;
  try { fs.writeFileSync(CACHE_FILE, JSON.stringify(cache)); cacheDirty = false; } catch {}
}
setInterval(saveCache, 5000);

// ── action trust: only localhost / home-LAN, and NOT proxied via Cloudflare ──
// The followup tunnel forwards public traffic to 127.0.0.1, so remoteAddress
// alone is ambiguous. Cloudflare stamps cf-ray / cf-connecting-ip on tunneled
// requests; a genuinely local/LAN request has neither. Require both signals.
function isLocalTrusted(req) {
  if (req.headers['cf-ray'] || req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']) return false;
  let ip = (req.socket && req.socket.remoteAddress) || '';
  ip = ip.replace(/^::ffff:/, '');
  return ip === '127.0.0.1' || ip === '::1' ||
    /^10\./.test(ip) || /^192\.168\./.test(ip) ||
    /^172\.(1[6-9]|2[0-9]|3[01])\./.test(ip) || /^169\.254\./.test(ip);
}

// ── owner unlock: "keep it public but route actions to ME" ───────────────────
// A secret key (data/owner.key) mints a long-lived httpOnly cookie. Any device
// that visits /unlock?key=<KEY> once becomes an ACTION device — even over the
// public tunnel. Everyone else (password only, no cookie) stays view-only.
const crypto = require('crypto');
const KEYFILE = path.join(__dirname, 'data', 'owner.key');
let OWNER_KEY;
try { OWNER_KEY = fs.readFileSync(KEYFILE, 'utf8').trim(); } catch {}
if (!OWNER_KEY) { OWNER_KEY = crypto.randomBytes(18).toString('hex'); try { fs.writeFileSync(KEYFILE, OWNER_KEY); } catch {} }
const OWNER_COOKIE = crypto.createHash('sha256').update(OWNER_KEY).digest('hex').slice(0, 32); // browser never sees the raw key
function cookies(req) { const h = req.headers.cookie || ''; const o = {}; h.split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) o[p.slice(0, i).trim()] = p.slice(i + 1).trim(); }); return o; }
function isOwner(req) { return cookies(req)['doing_owner'] === OWNER_COOKIE; }
function trustedForActions(req) { return isLocalTrusted(req) || isOwner(req); }

// ── pm2 process liveness (best-effort, cached) ───────────────────────────────
const { execFileSync } = require('child_process');
let pm2Map = {}, pm2At = 0;
function pm2status() {
  if (Date.now() - pm2At < 15000) return pm2Map;
  pm2At = Date.now();
  try {
    const env = { ...process.env, PATH: (process.env.PATH || '') + ':' + os.homedir() + '/.npm-global/bin:/opt/homebrew/bin' };
    const arr = JSON.parse(execFileSync('pm2', ['jlist'], { env, timeout: 4000 }).toString());
    const m = {};
    for (const p of arr) m[p.name] = p.pm2_env && p.pm2_env.status;   // online|stopped|errored
    pm2Map = m;
  } catch { /* keep last */ }
  return pm2Map;
}
// does this ticket's agent map to a pm2 process? return its status or null
function agentProc(agent) {
  if (!agent) return null;
  const m = pm2status(); const a = agent.toLowerCase();
  for (const name of Object.keys(m)) {
    const n = name.toLowerCase();
    if (n === a || a.includes(n) || n.includes(a)) return { name, status: m[name] };
  }
  return null;
}

// ── why is it slow + do we restart it ────────────────────────────────────────
function fmtAge(h) { const m = Math.round(h * 60); return m < 60 ? m + ' min' : h < 48 ? h.toFixed(1) + ' h' : (h / 24).toFixed(1) + ' days'; }
function diagnose(item) {
  const t = (item.doingText || '').toLowerCase();
  const age = item.ageH;
  const proc = agentProc(item.agent);
  const gated  = /gated|pending-approval|await|waiting on steve|steve-gate|human gate|needs steve|blocked on|drafted .*approval/.test(t);
  const stop   = /auto-?stop|halted|paused at|stopped at the gated|cap reached|only-gated/.test(t);
  const crash  = /crash|error|failed|exception|traceback|enoent|econnrefused|\btimeout\b/.test(t);

  // dead background process always wins — that's a real restart
  if (proc && proc.status !== 'online')
    return { why: `Its background process "${proc.name}" is ${proc.status}. Nothing can move until it's back up.`,
             verdict: 'RESTART', label: '🔴 Restart it', color: 'stale' };

  if (gated)
    return { why: `Not actually stuck — it did its safe work and is HOLDING for your approval (last step ${fmtAge(age)} ago).`,
             verdict: 'WAITING', label: '⛔ Waiting on you', color: 'wait' };
  if (stop)
    return { why: `It hit a wall it isn't allowed to cross on its own and stopped itself (${fmtAge(age)} ago).`,
             verdict: 'WAITING', label: '⛔ Needs your go', color: 'wait' };
  if (crash)
    return { why: `Its last step errored out ${fmtAge(age)} ago, then nothing. Looks broken.`,
             verdict: 'RESTART', label: '🔴 Restart it', color: 'stale' };

  if (age < 6)
    return { why: `Working normally — last step ${fmtAge(age)} ago.`,
             verdict: 'RUNNING', label: '🟢 Let it run', color: 'fresh' };
  if (age < 48)
    return { why: `It went quiet ${fmtAge(age)} ago — the work session probably just ended, no error.`,
             verdict: 'NUDGE', label: '🟡 Nudge it', color: 'warn' };
  return { why: `Cold — no movement in ${fmtAge(age)}. The session is long gone; it needs a fresh kick to continue.`,
           verdict: 'RESTART', label: '🔴 Restart it', color: 'stale' };
}

// ── build the doing list from the shared event log ───────────────────────────
function doingList() {
  const map = tickets();
  const out = [];
  for (const t of map.values()) {
    if (t.status !== 'doing') continue;
    const lastAction = t.actions.length ? t.actions[t.actions.length - 1] : null;
    const lastComment = t.comments.length ? t.comments[t.comments.length - 1] : null;
    // "what it's doing" = newest of last action / last comment
    let doingText = '', doingTs = t.updated_at, doingAgent = t.assignee || t.agent || '';
    const cand = [lastAction, lastComment].filter(Boolean).sort((a, b) => (a.ts > b.ts ? -1 : 1))[0];
    if (cand) { doingText = cand.text || ''; doingTs = cand.ts; doingAgent = cand.agent || doingAgent; }
    const ageH = (Date.now() - new Date(t.updated_at).getTime()) / 3.6e6;
    const fp = t.id + ':' + doingTs;               // activity fingerprint
    const item = {
      id: t.id, title: t.title, project: t.project,
      agent: doingAgent, doingText, doingTs, ageH,
      fp, eli12: cache[fp] ? cache[fp].text : null,
    };
    item.diag = diagnose(item);
    out.push(item);
  }
  out.sort((a, b) => a.ageH - b.ageH);             // freshest activity first
  return out;
}

// ── local LLM: explain like I'm 12 ($0, cached) ──────────────────────────────
function stripThink(s) { return String(s).replace(/<think>[\s\S]*?<\/think>/gi, '').trim(); }

function ollamaEli12(item) {
  return new Promise((resolve) => {
    const prompt =
`You explain computer work to a smart 12-year-old. In 1-2 short, friendly sentences, plainly say what this task is trying to do and how it's going right now. No tech jargon, no code words, no lists. Use an everyday analogy only if it helps.

TASK: ${item.title}
LATEST ACTIVITY: ${item.doingText || '(just started, nothing logged yet)'}

Plain answer:`;
    const body = JSON.stringify({
      model: MODEL, prompt, stream: false, think: false,
      options: { temperature: 0.4, num_predict: 120 },
    });
    const req = http.request(OLLAMA, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
      timeout: 60000,
    }, (res) => {
      let d = '';
      res.on('data', c => d += c);
      res.on('end', () => {
        try { resolve(stripThink(JSON.parse(d).response) || null); }
        catch { resolve(null); }
      });
    });
    req.on('error', () => resolve(null));
    req.on('timeout', () => { req.destroy(); resolve(null); });
    req.end(body);
  });
}

// heuristic fallback so the column is never empty even if Ollama is down
function heuristicEli12(item) {
  const t = (item.title || '').replace(/[_\-]+/g, ' ');
  const a = item.doingText ? ` Right now it just: "${item.doingText.slice(0, 120)}".` : '';
  return `This task is about: ${t}.${a}`;
}

// background generator — one card per tick, only missing fingerprints
let busy = false;
async function tick() {
  if (busy) return; busy = true;
  try {
    const items = doingList().filter(i => !cache[i.fp]);
    if (items.length) {
      const item = items[0];
      let text = await ollamaEli12(item);
      let via = 'qwen3:14b (local, $0)';
      if (!text) { text = heuristicEli12(item); via = 'heuristic (ollama unavailable)'; }
      cache[item.fp] = { text, via, at: new Date().toISOString() };
      cacheDirty = true;
    }
  } catch {} finally { busy = false; }
}
setInterval(tick, 4000);
tick();

// ── http ─────────────────────────────────────────────────────────────────────
const INDEX = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');

// ── Basic Auth (house standard admin / DW2024!, env-overridable) ─────────────
const AUTH_USER = process.env.BASIC_USER || 'admin';
const AUTH_PASS = process.env.BASIC_PASS || 'DW2024!';
function authed(req) {
  const h = req.headers['authorization'] || '';
  if (!h.startsWith('Basic ')) return false;
  const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
  return u === AUTH_USER && p === AUTH_PASS;
}

http.createServer((req, res) => {
  if (!authed(req)) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="doing-viewer"' });
    res.end('auth required');
    return;
  }
  // ── owner unlock/lock (claim this device for actions) ──────────────────────
  if (req.url.startsWith('/unlock')) {
    const key = new URL(req.url, 'http://x').searchParams.get('key');
    if (key === OWNER_KEY) {
      res.writeHead(302, { 'Set-Cookie': `doing_owner=${OWNER_COOKIE}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000; Path=/`, 'Location': '/' });
      return res.end();
    }
    res.writeHead(403, { 'Content-Type': 'text/html' });
    return res.end('<body style="font-family:sans-serif;background:#0e1116;color:#e6edf3;padding:40px">Wrong or missing key. Append <code>?key=YOUR_KEY</code>.</body>');
  }
  if (req.url === '/lock') {
    res.writeHead(302, { 'Set-Cookie': 'doing_owner=; Max-Age=0; Path=/', 'Location': '/' });
    return res.end();
  }

  // ── action: restart/nudge a ticket via the hardened run-ticket.sh ──────────
  if (req.method === 'POST' && req.url === '/api/action') {
    if (!trustedForActions(req)) { res.writeHead(403, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ error: 'This device is view-only. Unlock it once via /unlock?key=YOUR_KEY, or open the board on Mac2 / your home network.' })); }
    let raw = ''; req.on('data', c => (raw += c)); req.on('end', () => {
      let b; try { b = JSON.parse(raw); } catch { res.writeHead(400); return res.end('bad json'); }
      const id = String(b.id || ''), verb = String(b.verb || '');
      // id MUST be a current doing ticket (prevents arbitrary launches) + shape-checked
      const cur = doingList().find(i => i.id === id);
      if (!cur || !/^TK-[0-9]+(-[a-z0-9-]+)?$/.test(id)) { res.writeHead(404); return res.end('unknown ticket'); }
      if (verb !== 'restart' && verb !== 'nudge') { res.writeHead(400); return res.end('bad verb'); }
      // cwd = the ticket's project dir if it exists (same rule as the ticket board)
      let cwd = os.homedir();
      if (cur.project && /^[a-z0-9._-]+$/i.test(cur.project)) {
        const p = path.join(os.homedir(), 'Projects', cur.project);
        if (fs.existsSync(p)) cwd = p;
      }
      execFile('bash', [RUN_SH, id, cwd], { timeout: 25000 }, (err) => {
        try { withLock(() => append({ ts: new Date().toISOString(), type: 'action', id, agent: 'doing-board',
          text: err ? ('⚠ ' + verb.toUpperCase() + ' from board failed to launch iTerm2 — ' + String(err.message || err).split('\n')[0])
                     : ('▶ ' + verb.toUpperCase() + ' pushed from doing-board — launched fresh Claude session on this ticket') })); } catch {}
      });
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ ok: true, id, verb, msg: 'launching a Claude session on ' + id + ' (an iTerm window will open on Mac2)' }));
    });
    return;
  }
  if (req.url === '/api/doing') {
    const list = doingList();
    const pending = list.filter(i => !i.eli12).length;
    res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
    res.end(JSON.stringify({ now: new Date().toISOString(), count: list.length, pending, canAct: trustedForActions(req), items: list }));
    return;
  }
  if (req.url === '/' || req.url === '/index.html') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(INDEX);
    return;
  }
  // Browsers implicitly request /favicon.ico; without a route it 404s and logs a
  // console error on every page load (caught by /5x, TK-11446 follow-on). Answer
  // 204 No Content so there's no favicon 404 for any client.
  if (req.url === '/favicon.ico') { res.writeHead(204); res.end(); return; }
  res.writeHead(404); res.end('not found');
}).listen(PORT, () => console.log(`doing-viewer on http://127.0.0.1:${PORT}`));

process.on('SIGTERM', () => { saveCache(); process.exit(0); });