← back to Fix Live Board

boardctl.mjs

47 lines

// Shared board control: spawn a detached board for a job, and report whether one is live.
import fs from 'fs';
import path from 'path';
import { spawn, spawnSync } from 'child_process';
import { fileURLToPath } from 'url';

export const ROOT = path.dirname(fileURLToPath(import.meta.url));
const RT = path.join(ROOT, '.runtime');

export function alive(pid) { if (!pid) return false; try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }

export function boardStatus(jobId) {
  const pidF = path.join(RT, jobId + '.pid'), portF = path.join(RT, jobId + '.port');
  const pid = fs.existsSync(pidF) ? parseInt(fs.readFileSync(pidF, 'utf8'), 10) : 0;
  const port = fs.existsSync(portF) ? fs.readFileSync(portF, 'utf8').trim() : '';
  return { pid, port, up: alive(pid) && !!port };
}

export function spawnBoard(jobId) {
  const s = boardStatus(jobId);
  if (s.up) return { ...s, started: false, url: 'http://127.0.0.1:' + s.port + '/' };
  const log = fs.openSync(path.join(RT, jobId + '.log'), 'a');
  const child = spawn('node', ['server.js'], { cwd: ROOT, env: { ...process.env, JOB: 'jobs/' + jobId + '.json' }, detached: true, stdio: ['ignore', log, log] });
  child.unref();
  // wait up to ~5s for the server to write its port file
  const portF = path.join(RT, jobId + '.port');
  const before = fs.existsSync(portF) ? fs.statSync(portF).mtimeMs : 0;
  for (let i = 0; i < 50; i++) {
    if (fs.existsSync(portF) && fs.statSync(portF).mtimeMs >= before) { break; }
    spawnSync('sleep', ['0.1']);
  }
  const port = fs.existsSync(portF) ? fs.readFileSync(portF, 'utf8').trim() : '';
  return { pid: child.pid, port, up: !!port, started: true, url: port ? 'http://127.0.0.1:' + port + '/' : null };
}

// Count how many rows are "still to follow" (not fixed) for a job, by running its probe once.
export function brokenCount(jobId) {
  const jobPath = path.join(ROOT, 'jobs', jobId + '.json');
  const job = JSON.parse(fs.readFileSync(jobPath, 'utf8'));
  const required = (job.fields || []).filter(f => !f.warn).map(f => f.key);
  const cwd = job.cwd ? path.resolve(ROOT, job.cwd) : ROOT;
  const r = spawnSync('/bin/sh', ['-c', job.probe], { cwd, encoding: 'utf8', timeout: job.probeTimeoutMs || 30000, env: process.env });
  let rows = []; try { rows = JSON.parse(r.stdout); } catch (e) { return { ok: false, err: 'probe parse: ' + String(e).slice(0, 120) + (r.stderr ? ' | ' + r.stderr.slice(0, 120) : ''), total: 0, broken: 0 }; }
  const fixed = rows.filter(row => typeof row.fixed === 'boolean' ? row.fixed : (required.length ? required.every(k => !!(row.fields || {})[k]) : false)).length;
  return { ok: true, total: rows.length, broken: rows.length - fixed };
}