app
Service Containers
Public Unreviewedby John Lauer
One dashboard for every Adom service container: what is healthy, what is down, why, and a restart button that runs the watchdog's own start command. Reads the watchdog's verdicts off disk, so it costs no extra probes and no vendor API quota.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
#!/usr/bin/env node
// svcwatch CLI.
//
// Output contract: every response starts with OK: or ERROR:, and every error
// carries at least one Hint: naming the exact next command.
//
// The dock bar runs `svcwatch serve --print-url` as a launch step and waits for
// that process to EXIT before reading its stdout. So serve must self-background:
// it spawns the server detached, waits for the server to answer, prints the
// direct URL, and returns. A serve that stays in the foreground blocks the exec
// for the whole timeout, gets killed, and the dock reports "started, but <url>
// never answered".
import { spawn, execFile } from 'child_process';
import { readFileSync, readdirSync, unlinkSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const DIR = dirname(fileURLToPath(import.meta.url));
const SLUG = 'svcwatch';
const VERSION = JSON.parse(readFileSync(join(DIR, 'package.json'), 'utf8')).version;
const INSTANCES = join(process.env.HOME, '.adom', 'instances', SLUG);
// Strip color when stdout is not a TTY.
const TTY = process.stdout.isTTY;
const dim = s => (TTY ? `\x1b[2m${s}\x1b[0m` : s);
const argv = process.argv.slice(2);
const cmd = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'help';
function flag(name) { return argv.includes(name); }
function val(name, fallback = null) {
const i = argv.indexOf(name);
return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback;
}
const AI_THREAD = val('--ai-thread', process.env.ADOM_AI_THREAD || 'dock');
function ok(msg) { console.log('OK: ' + msg); }
function hint(msg) { console.log('Hint: ' + msg); }
function fail(msg, ...hints) {
console.error('ERROR: ' + msg);
for (const h of hints) console.error('Hint: ' + h);
process.exit(1);
}
function alive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }
function instances() {
const out = [];
if (!existsSync(INSTANCES)) return out;
for (const f of readdirSync(INSTANCES)) {
if (!f.endsWith('.json')) continue;
const p = join(INSTANCES, f);
let e;
try { e = JSON.parse(readFileSync(p, 'utf8')); } catch { continue; }
if (!e.pid || !alive(e.pid)) { try { unlinkSync(p); } catch {} continue; }
out.push(e);
}
return out;
}
async function probe(port, ms = 1200) {
try {
const c = new AbortController();
const t = setTimeout(() => c.abort(), ms);
const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: c.signal });
clearTimeout(t);
return r.ok ? await r.json() : null;
} catch { return null; }
}
async function liveInstance() {
for (const e of instances()) {
if (await probe(e.port)) return e;
}
return null;
}
// The direct URL, never the :7380/proxy/<port>/ form. The workspace is local
// WSL2 with mirrored networking, so a distro port IS a Windows localhost port.
// 127.0.0.1 rather than the name localhost: IPv6-first resolution costs about
// 800ms per request on Windows.
const directUrl = port => `http://127.0.0.1:${port}/`;
// The proxy URL is what a webview OUTSIDE the container must load. A webview
// cannot reach 127.0.0.1 in here.
function proxyUrl(port) {
const t = process.env.VSCODE_PROXY_URI;
return t ? t.replace('{{port}}', String(port)) : directUrl(port);
}
async function waitForPort(port, budgetMs = 30000) {
const deadline = Date.now() + budgetMs;
while (Date.now() < deadline) {
const h = await probe(port);
if (h) return h;
await new Promise(r => setTimeout(r, 400));
}
return null;
}
async function startDetached() {
mkdirSync(INSTANCES, { recursive: true });
const port = val('--port', 'auto');
const child = spawn(process.execPath, [
join(DIR, 'server.js'), 'serve', '--ai-thread', AI_THREAD, '--port', port,
], { detached: true, stdio: ['ignore', 'ignore', 'ignore'] });
child.unref();
// The server picks its own port when asked for auto, so discover it through
// the registry rather than guessing.
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const e = (instances()).find(i => i.pid === child.pid);
if (e) {
const h = await waitForPort(e.port, 15000);
if (h) return e;
}
await new Promise(r => setTimeout(r, 300));
}
return null;
}
// Once serving, the app takes over the tab the dock pre-opened for it.
function claimDockWebview(port) {
const name = process.env.ADOM_DOCK_WEBVIEW;
if (!name) return;
execFile('adom-cli', [
'hydrogen', 'webview', 'open-or-refresh',
'--name', name, '--url', proxyUrl(port),
], () => {});
}
async function main() {
switch (cmd) {
case 'serve': {
// Reuse instead of respawn: a second instance would fight for the same
// registry entry and leave a zombie behind.
let inst = await liveInstance();
if (!inst) inst = await startDetached();
if (!inst) {
fail('svcwatch did not come up within 30 seconds.',
'Run it in the foreground to see why: node ' + join(DIR, 'server.js') + ' serve --port 8901',
'Check the watchdog state directory exists: ls /home/adom/.watchdog-state');
}
claimDockWebview(inst.port);
if (flag('--print-url')) {
// The dock scrapes the first https?://\S+ on stdout. Keep it on its
// own line and on stdout, never stderr.
console.log(directUrl(inst.port));
return;
}
ok(`svcwatch v${VERSION} serving on port ${inst.port}`);
console.log('URL: ' + directUrl(inst.port));
console.log('Webview URL: ' + proxyUrl(inst.port));
return;
}
case 'show': {
let inst = await liveInstance();
if (!inst) inst = await startDetached();
if (!inst) fail('Could not start svcwatch.', 'Try: svcwatch serve');
const name = process.env.ADOM_DOCK_WEBVIEW || 'Service Containers';
execFile('adom-cli', ['hydrogen', 'webview', 'open-or-refresh',
'--name', name, '--url', proxyUrl(inst.port),
'--panel-id', val('--panel-id', '') || 'f11c55d1-1007-4520-9769-d8f3b2c0a333',
], (err) => {
if (err) {
console.error('ERROR: could not open the webview tab.');
console.error('Hint: find a pane id with: adom-cli hydrogen workspace tabs');
process.exit(1);
}
ok(`Service Containers tab open on port ${inst.port}`);
});
return;
}
case 'status': {
const inst = await liveInstance();
if (!inst) {
fail('svcwatch is not running.', 'Start it with: svcwatch serve');
}
const h = await probe(inst.port, 4000);
const c = h.counts;
ok(`${c.ok}/${c.total} services healthy` +
(c.down ? `, ${c.down} down` : '') +
(c.pending ? `, ${c.pending} watching` : ''));
console.log(dim(`port ${inst.port} thread "${inst.thread}" v${inst.version}` +
(h.sweepAgeS != null ? ` watchdog wrote ${h.sweepAgeS}s ago` : '')));
if (c.down) hint('Open the dashboard to see which: svcwatch show');
return;
}
case 'health': {
const inst = await liveInstance();
if (!inst) fail('svcwatch is not running.', 'Start it with: svcwatch serve');
ok(`svcwatch v${inst.version} answering on port ${inst.port}`);
return;
}
case 'ls': {
const list = instances();
if (!list.length) { ok('no svcwatch instances running'); return; }
for (const e of list) {
const idleM = Math.round((Date.now() - new Date(e.lastActivity).getTime()) / 60000);
const drift = e.version !== VERSION
? ` WARN: runs ${e.version}, disk has ${VERSION}` : '';
console.log(`port ${e.port} thread "${e.thread}" idle ${idleM}m v${e.version}${drift}`);
}
ok(`${list.length} instance${list.length === 1 ? '' : 's'}`);
return;
}
case 'stop': {
const list = instances();
if (!list.length) { ok('nothing to stop'); return; }
for (const e of list) {
try {
await fetch(`http://127.0.0.1:${e.port}/shutdown`, { method: 'POST' });
} catch {}
}
ok(`stopped ${list.length} instance${list.length === 1 ? '' : 's'}`);
return;
}
// Drive the page the same way a person would, so the AI can walk every
// interaction and assert the result.
case 'ui': {
const inst = await liveInstance();
if (!inst) fail('svcwatch is not running.', 'Start it with: svcwatch serve');
const sub = argv[1];
const base = `http://127.0.0.1:${inst.port}`;
const post = async (path, body) => {
const r = await fetch(base + path, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
return { status: r.status, json: await r.json().catch(() => ({})) };
};
if (sub === 'toast') {
// Drop each --flag AND the value that follows it, or "--kind success"
// leaves the word "success" stuck on the end of the message.
const words = [];
for (let i = 2; i < argv.length; i++) {
if (argv[i].startsWith('--')) { i++; continue; }
words.push(argv[i]);
}
const message = words.join(' ');
if (!message) fail('No message given.', 'Try: svcwatch ui toast "sweep finished"');
await post('/api/toast', { kind: val('--kind', 'info'), message });
ok(`toast raised: ${message}`);
return;
}
if (sub === 'sweep') {
const r = await post('/api/sweep', {});
if (r.status !== 200) fail('Sweep failed.', 'Read the log: tail /tmp/service-watchdog.log');
ok(`sweep complete: ${r.json.counts.ok}/${r.json.counts.total} healthy`);
return;
}
if (sub === 'restart') {
const label = argv[2];
const reason = val('--reason');
if (!label) fail('No service given.', 'Try: svcwatch ui restart oauth --reason "recovering the gateway"');
if (!reason) {
fail('Restarting a shared service needs a written reason.',
`Try: svcwatch ui restart ${label} --reason "why you are restarting it"`);
}
const r = await post('/api/restart', { label, reason });
if (r.status !== 200) {
fail(r.json.error || 'Restart failed.',
r.json.hint || 'List valid names with: svcwatch status');
}
ok(`${r.json.service} restart dispatched`);
return;
}
fail(`Unknown ui command: ${sub || '(none)'}`,
'Try: svcwatch ui toast "..." | svcwatch ui sweep | svcwatch ui restart <service> --reason "..."');
return;
}
default: {
console.log(`svcwatch v${VERSION}: the Adom service container dashboard`);
console.log('');
console.log(' svcwatch serve [--print-url] start the server (self-backgrounds)');
console.log(' svcwatch show open or refresh the Services webview tab');
console.log(' svcwatch status one-line health of every service container');
console.log(' svcwatch health is svcwatch itself up');
console.log(' svcwatch ls running instances, with version drift');
console.log(' svcwatch stop shut every instance down');
console.log(' svcwatch ui toast "..." raise a toast on the page');
console.log(' svcwatch ui sweep run a watchdog sweep now');
console.log(' svcwatch ui restart <svc> --reason "..." restart one service');
return;
}
}
}
main().catch(e => fail(e.message, 'This is a bug in svcwatch. Run with: node cli.js ' + cmd));