← back to Factory Floor

server.js

218 lines

// factory-floor — single-page live coordinator. Lean variant: event-loop-safe,
// concurrency-capped, lazy first poll, no shelling out during request handling.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = +(process.env.PORT || 9936);
const POLL_MS = +(process.env.POLL_MS || 15_000);
const GIT_LIMIT = +(process.env.GIT_LIMIT || 40);   // cap repos polled per cycle
const CONCURRENCY = +(process.env.CONCURRENCY || 4);
const PROJECTS_DIR = path.join(os.homedir(), 'Projects');

const SERVICES = JSON.parse(fs.readFileSync(path.join(__dirname, 'services.json'), 'utf8')).services;

function run(cmd, args, opts = {}) {
  return new Promise((res) => {
    execFile(cmd, args, { timeout: 15000, maxBuffer: 8 * 1024 * 1024, ...opts }, (err, stdout) =>
      res({ err, stdout: stdout?.toString() || '' })
    );
  });
}

// concurrency-capped parallel map
async function pmap(items, fn, limit = CONCURRENCY) {
  const out = new Array(items.length);
  let i = 0;
  async function worker() {
    while (true) {
      const idx = i++;
      if (idx >= items.length) return;
      try { out[idx] = await fn(items[idx], idx); } catch (e) { out[idx] = { error: e.message }; }
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return out;
}

async function pollPm2() {
  const r = await run('pm2', ['jlist'], { timeout: 20000 });
  if (r.err) return { error: r.err.code || r.err.message, items: [] };
  try {
    const j = JSON.parse(r.stdout);
    return {
      items: j.map(p => ({
        name: p.name,
        status: p.pm2_env?.status,
        cpu: p.monit?.cpu ?? 0,
        mem_mb: Math.round((p.monit?.memory ?? 0) / 1024 / 1024),
        uptime_s: p.pm2_env?.pm_uptime ? Math.round((Date.now() - p.pm2_env.pm_uptime) / 1000) : 0,
        restarts: p.pm2_env?.restart_time ?? 0,
        port: p.pm2_env?.PORT ?? p.pm2_env?.env?.PORT ?? null,
      })).sort((a, b) => a.name.localeCompare(b.name)),
    };
  } catch (e) { return { error: e.message, items: [] }; }
}

async function pollOllama() {
  try {
    const ctrl = AbortSignal.timeout(4000);
    const [tagsR, psR] = await Promise.all([
      fetch('http://127.0.0.1:11434/api/tags', { signal: ctrl }).then(r => r.json()).catch(() => null),
      fetch('http://127.0.0.1:11434/api/ps', { signal: ctrl }).then(r => r.json()).catch(() => null),
    ]);
    return {
      installed: (tagsR?.models || []).map(m => ({
        name: m.name,
        size_gb: +(m.size / 1024 ** 3).toFixed(2),
        q: m.details?.quantization_level,
        params: m.details?.parameter_size,
      })),
      loaded: (psR?.models || []).map(m => ({ name: m.name })),
    };
  } catch (e) { return { error: e.message, installed: [], loaded: [] }; }
}

async function pollServices() {
  return pmap(SERVICES, async (s) => {
    const t0 = Date.now();
    try {
      const r = await fetch(s.url, { signal: AbortSignal.timeout(3000), redirect: 'manual' });
      return { ...s, ok: r.status < 500, status: r.status, ms: Date.now() - t0 };
    } catch (e) {
      return { ...s, ok: false, status: 0, ms: Date.now() - t0, error: e.code || (e.name === 'TimeoutError' ? 'timeout' : 'err') };
    }
  }, 6);
}

async function pollGit() {
  const dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })
    .filter(d => d.isDirectory() && !d.name.startsWith('.') && !d.name.startsWith('_'))
    .map(d => d.name);
  // cheap pre-filter: only dirs with a .git
  const repos = dirs.filter(name => {
    try { return fs.statSync(path.join(PROJECTS_DIR, name, '.git')).isDirectory(); } catch { return false; }
  });
  // sample most-recent-mtime first for relevance
  const dated = repos.map(name => {
    let mt = 0;
    try { mt = fs.statSync(path.join(PROJECTS_DIR, name, '.git')).mtimeMs; } catch {}
    return { name, mt };
  }).sort((a, b) => b.mt - a.mt).slice(0, GIT_LIMIT);

  const rows = await pmap(dated, async ({ name }) => {
    const repo = path.join(PROJECTS_DIR, name);
    const r = await run('git', ['-C', repo, 'log', '-1', '--format=%cI%x09%h%x09%s'], { timeout: 4000 });
    if (r.err || !r.stdout) return null;
    const [iso, sha, ...msg] = r.stdout.trim().split('\t');
    return { project: name, iso, sha, msg: msg.join('\t'), age_s: Math.round((Date.now() - new Date(iso).getTime()) / 1000) };
  }, 4);
  return rows.filter(Boolean).sort((a, b) => b.iso.localeCompare(a.iso)).slice(0, 15);
}

function pollHost() {
  const load = os.loadavg();
  const total = os.totalmem(), free = os.freemem();
  return {
    host: os.hostname(),
    cores: os.cpus().length,
    load: load.map(n => +n.toFixed(2)),
    mem_gb_total: +(total / 1024 ** 3).toFixed(1),
    mem_gb_free: +(free / 1024 ** 3).toFixed(1),
    uptime_h: +(os.uptime() / 3600).toFixed(1),
  };
}

// "Big Red is thinking" — detect active claude CLI child processes
async function pollBigRed() {
  const r = await run('pgrep', ['-fl', 'claude'], { timeout: 3000 });
  const lines = (r.stdout || '').split('\n').filter(Boolean);
  const claudeProcs = lines.filter(l => /\sclaude\s/.test(l) || /\/claude\b/.test(l));
  // Heuristic: big-red invokes claude with --print + sandbox cwd. Count those specifically.
  const bigRedActive = lines.filter(l => /big-red|--print/.test(l)).length;
  return {
    thinking: bigRedActive > 0,
    claude_procs: claudeProcs.length,
    big_red_calls: bigRedActive,
  };
}

let STATE = { generated_at: null, booting: true };
let inFlight = false;

async function refresh() {
  if (inFlight) return;
  inFlight = true;
  const t0 = Date.now();
  try {
    const [pm2, ollama, services, host, bigRed] = await Promise.all([
      pollPm2(), pollOllama(), pollServices(), pollHost(), pollBigRed(),
    ]);
    // git last; it's the heaviest
    const git = await pollGit().catch(() => []);
    STATE = { generated_at: new Date().toISOString(), poll_ms: Date.now() - t0, pm2, ollama, services, git, host, bigRed };
  } finally {
    inFlight = false;
  }
}

// Schedule first refresh AFTER server starts listening, so the server is reachable immediately
const server = http.createServer((req, res) => {
  try {
    if (req.url === '/api/state') {
      res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
      return res.end(JSON.stringify(STATE));
    }
    if (req.url === '/api/refresh' && req.method === 'POST') {
      refresh().catch(() => {});
      res.writeHead(202, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ scheduled: true }));
    }
    if (req.url === '/api/bigred/model' && req.method === 'POST') {
      let body = '';
      req.on('data', c => body += c);
      req.on('end', () => {
        try {
          const { model } = JSON.parse(body || '{}');
          if (!model || typeof model !== 'string') { res.writeHead(400); return res.end('model required'); }
          const target = path.join(os.homedir(), 'Projects', 'big-red', '.selected-model');
          fs.writeFileSync(target, model + '\n');
          res.writeHead(200, { 'Content-Type': 'application/json' });
          res.end(JSON.stringify({ ok: true, model, written_to: target }));
        } catch (e) { res.writeHead(500); res.end(e.message); }
      });
      return;
    }
    if (req.url === '/api/bigred/model' && req.method === 'GET') {
      const target = path.join(os.homedir(), 'Projects', 'big-red', '.selected-model');
      let model = null;
      try { model = fs.readFileSync(target, 'utf8').trim(); } catch {}
      res.writeHead(200, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ model }));
    }
    let urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
    const filePath = path.join(__dirname, 'public', urlPath);
    if (!filePath.startsWith(path.join(__dirname, 'public'))) { res.writeHead(403); return res.end(); }
    if (!fs.existsSync(filePath)) { res.writeHead(404); return res.end('not found'); }
    const ext = path.extname(filePath).slice(1);
    const types = { html: 'text/html; charset=utf-8', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml', json: 'application/json' };
    res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream', 'Cache-Control': 'no-store' });
    fs.createReadStream(filePath).pipe(res);
  } catch (e) {
    res.writeHead(500); res.end(e.message);
  }
});

server.listen(PORT, () => {
  console.log(`factory-floor listening on http://127.0.0.1:${PORT}  (poll every ${POLL_MS}ms, git_limit=${GIT_LIMIT})`);
  // Defer first poll so the server is responsive even while the first poll is in flight
  setTimeout(() => {
    refresh().catch(e => console.error('initial refresh err', e.message));
    setInterval(() => refresh().catch(() => {}), POLL_MS);
  }, 250);
});