Files
stack/packages/webui/tests/console-restart-browser.test.mjs
jason.woltje 1b6cac2bdf feat(webui): restart the Console, the org bus and agents from the WebUI (F3)
Self-service operation from the WebUI, for a dev instance first.

- Settings > Console > Restart Console. A foreground `mosaic console
  start` now supervises the webui: the child exits 75 after answering a
  restart and the supervisor relaunches it with the same argv. Org buses
  keep running. /api/config carries a per-process boot id, so the open
  page polls until a new process answers and then reloads. A detached
  Console has no supervisor and answers 409.
- Restart bus on every "Restart needed" receipt and on the bus-down
  banner: POST /api/orgs/<id>/restart/bus restarts a running bus or
  starts a stopped one, then shows a done receipt with the time.
- Settings > Routing: after a save, the page lists how the change
  reaches each agent (next start, session end, thread idle) and offers
  "Restart <agent> now" for a running one. A session is terminated with
  the reason "route change" and launched again; a thread is terminated.
- Organization settings: the signed-in owner's settings, routing,
  agent, plug-in and Model proxy writes now reach the selected org's
  bus instead of answering 403. Org mode exists only under
  --dev-local-owner. Proxy setup stays a terminal step; a dev-only note
  names the two commands.

Every control needs the signed-in owner session, the page's exact
Origin and Sec-Fetch-Site: same-origin. The tester listener, granted
users, API tokens, agents and bus roles are refused before any work.
Each use is written to <dataRoot>/console/actions.jsonl (0600, no
symlinks) with who, when, what and the outcome. The Console and bus
restart trails fail closed. No change to credential handling, the
broker, the bus tenant boundary or v2 schemas.
2026-11-09 13:37:28 -06:00

102 lines
6.8 KiB
JavaScript

// F3: the owner restarts the Console from Settings > Console. The real supervisor in
// `mosaic console start` relaunches the Console on the same address, and the open page shows the
// restart and comes back by itself, with no manual reload. A page holding an unsaved draft keeps
// it and offers the reload instead of taking it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { AGENT_MARKERS } from '../../cli/src/transport.mjs';
import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs';
import { browser } from './browser.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const sleep = ms => new Promise(r => setTimeout(r, ms));
// The page reloads on its own, so a check can land between two documents; it is retried.
async function until(b, expression, ms = 30000) {
const deadline = Date.now() + ms;
for (;;) {
try { if (await b.evaluate(`!!(${expression})`)) return; } catch { /* between documents */ }
if (Date.now() > deadline) throw new Error(`timed out: ${expression}`);
await sleep(50);
}
}
// Every banner the page shows is kept in sessionStorage, which outlives the page's own reload.
const WATCH = `(() => {
const seen = JSON.parse(sessionStorage.getItem('f3-seen') || '[]');
new MutationObserver(() => {
const box = document.getElementById('console-reconnect');
if (box && !box.hidden && seen.at(-1) !== box.textContent) { seen.push(box.textContent); sessionStorage.setItem('f3-seen', JSON.stringify(seen)); }
}).observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
return true;
})()`;
test('Settings > Console restarts the Console under its supervisor and the open page reconnects by itself', { timeout: 120000 }, async t => {
const home = mkdtempSync(join(tmpdir(), 'f3-'));
const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-f3test/config.json') };
for (const k of AGENT_MARKERS) delete env[k];
delete env.NODE_TEST_CONTEXT;
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'f3test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-f3test'), dataRoot: join(home, '.mosaic-f3test'), instance: 'f3test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
const warnings = [];
const run = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
t.after(async () => { run.stop(); await run.done; try { await consoleStop(ctx, { warn: () => {} }); } catch {} rmSync(home, { recursive: true, force: true }); });
const stateFile = join(ctx.dataRoot, 'console/console.json');
const pidNow = () => JSON.parse(readFileSync(stateFile, 'utf8')).pid;
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`${run.url}#/settings/console`);
await until(b, 'document.getElementById("console-restart")');
const boot1 = await b.evaluate('window.MosaicConsole.config.boot');
assert.equal(typeof boot1, 'string');
assert.match(await b.evaluate('document.getElementById("console-restart-title").closest("section").textContent'), /Organization buses are separate processes and keep running/);
await b.evaluate(`window.__f3 = 'first'`); await b.evaluate(WATCH);
// The button asks first; Cancel restarts nothing.
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-cancel")');
await b.evaluate('document.getElementById("console-restart-cancel").click()');
await until(b, '!document.getElementById("console-restart-go") && !document.getElementById("console-restart").hidden');
assert.equal(pidNow(), run.pid);
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-go")');
await b.evaluate('document.getElementById("console-restart-go").click()');
// The page reloads itself once the new Console answers: the marker is gone, the boot is new.
await until(b, `window.__f3 === undefined && window.MosaicConsole?.config?.boot && window.MosaicConsole.config.boot !== ${JSON.stringify(boot1)}`);
const seen1 = JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`));
assert.equal(seen1[0], 'Console restarting. This page reconnects when it is back.');
assert.equal(await b.evaluate('location.hash'), '#/settings/console');
await until(b, 'document.getElementById("console-restart")');
const pid2 = pidNow();
assert.notEqual(pid2, run.pid);
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).url, run.url);
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
const trail = readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
assert.deepEqual(trail.map(e => [e.action, e.outcome, e.by.provider, e.boot]), [['console.restart', 'restarting', 'local-dev', boot1]]);
// An unsaved draft (a reply being written) is not thrown away: the page says the Console is
// back and offers the reload. The textarea stands in for the chat reply box.
const boot2 = await b.evaluate('window.MosaicConsole.config.boot');
await b.evaluate(`(() => { window.__f3 = 'second'; sessionStorage.removeItem('f3-seen'); const t = document.createElement('textarea'); t.id = 'reply'; t.value = 'half a reply'; document.body.append(t); return true; })()`);
await b.evaluate(WATCH);
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-go")');
await b.evaluate('document.getElementById("console-restart-go").click()');
await until(b, 'document.getElementById("console-reconnect-reload")');
assert.equal(await b.evaluate('window.__f3'), 'second');
assert.equal(await b.evaluate('document.getElementById("reply").value'), 'half a reply');
assert.deepEqual(JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`)), ['Console restarting. This page reconnects when it is back.', 'The Console is back. Your unsaved draft is kept on this page. Reload now']);
assert.notEqual(await b.evaluate('window.MosaicConsole.config.boot'), boot2);
assert.notEqual(pidNow(), pid2);
await b.evaluate('document.getElementById("console-reconnect-reload").click()');
await until(b, `window.__f3 === undefined && document.getElementById("console-restart")`);
assert.equal(readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).length, 2);
});