Files
stack/packages/webui/tests/routes-apply-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

106 lines
8.6 KiB
JavaScript

// F3: after a route save, Settings > Routing says how the change reaches each saved agent, read
// from the instances: the launcher and a stopped agent take it at their next start, a running
// session keeps its old route until it ends (a thread when it goes idle), and a running agent
// offers "Restart now". Restart now terminates with the reason "route change", waits for the
// session to end and launches it again; a thread is only terminated. Real server under the dev
// local owner, a stub bus, every write gated by the browser's own Origin and recorded in the trail.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { close } from './fixture.mjs';
const REV = 'a'.repeat(64), REV2 = 'b'.repeat(64);
const END = '10000000-0000-4000-8000-0000000000e1', LAUNCH = '10000000-0000-4000-8000-0000000000a1', END2 = '10000000-0000-4000-8000-0000000000e2';
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
const route = thinking => ({ harness: 'claude', model: 'opus', thinking, fallback: null });
test('F3 Settings > Routing: a save lists how the change reaches each agent, and Restart now moves a running one', { timeout: 120000 }, async t => {
const root = mkdtempSync(join(tmpdir(), 'f3r-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const entry = (definition, extra) => ({ definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [], alive: [], ...extra });
const instances = {
chief: entry('chief', { launcher: true }),
coder: entry('coder', { running: ['run-1'], alive: ['run-1'] }),
assistant: entry('assistant', { running: ['run-a'], alive: ['run-a'] }),
reviewer: entry('reviewer', { launched: false }),
};
const view = () => structuredClone({ business: 'acme', revision: REV, launch: true, noHire: [], retired: [], instances,
definitions: Object.fromEntries(Object.values(instances).map(e => [e.definition, { services: [], accepted: true }])) });
let routes = { revision: REV, harnesses: ['claude'], models: ['opus'], levels: { claude: ['low', 'high'] },
instances: Object.fromEntries(Object.keys(instances).map(n => [n, route('high')])) };
const asked = [], requests = new Map();
const bus = { messages: async () => ({ messages: [], unread: 0 }), usage: async () => { throw Error('no usage'); }, instances: async () => view(), routes: async () => structuredClone(routes) };
const routesWrite = async args => {
asked.push(['routes.set', args]);
routes = { ...routes, revision: REV2, instances: { ...routes.instances, ...structuredClone(args.routes) } };
return structuredClone(routes);
};
const agentWrite = async (verb, args) => {
asked.push([verb, args]);
if (verb === 'instance.end') {
const id = args.instance === 'coder' ? END : END2;
requests.set(id, { instance: args.instance, action: 'end' });
// The session ends a moment after the request closes, so the page has to wait for it.
setTimeout(() => { instances[args.instance].running = []; instances[args.instance].alive = []; }, 400);
return { request: id, runs: [...instances[args.instance].running], cancelled: [], taken: [] };
}
if (verb === 'instance.launch') { requests.set(LAUNCH, { instance: args.instance, action: 'launch' }); instances[args.instance].running = ['run-2']; return { request: LAUNCH }; }
if (verb === 'instance.request') {
const r = requests.get(args.request);
return { request: args.request, instance: r.instance, action: r.action, state: 'closed', done: { run: r.action === 'launch' ? 'run-2' : null, transaction: null, refusal: null }, cancelled: null };
}
throw Error(`unexpected ${verb}`);
};
const dir = join(root, 'console');
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), bus, routesWrite, agentWrite,
reads: { settings: () => ({ business: 'acme', release: 'fixture', notifier: {} }) }, trail: actionTrail(dir), log: () => {} });
t.after(() => close(web));
const b = await browser(); t.after(() => b.close());
const wait = (expr, ms = 20000) => b.evaluate(`(async()=>{const end=Date.now()+${ms};for(;;){if(${expr})return true;if(Date.now()>end)throw Error('wait failed: '+${JSON.stringify(expr)});await new Promise(r=>setTimeout(r,30));}})()`);
const cell = (name, key) => `[...document.querySelectorAll('[data-route]')].find(e=>e.dataset.route===${JSON.stringify(JSON.stringify([name, key]))})`;
const items = () => b.evaluate(`[...document.querySelectorAll('.routes-apply li')].map(li=>{const c=li.cloneNode(true);c.querySelector('.agent-restart')?.remove();return c.textContent.trim();})`);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
await wait(`${cell('coder', 'thinking')}&&!${cell('coder', 'thinking')}.disabled`);
assert.equal(await b.evaluate(`!!document.querySelector('.routes-apply')`), false, 'nothing is listed before a save');
for (const name of ['chief', 'coder', 'assistant', 'reviewer']) await b.evaluate(`(()=>{const s=${cell(name, 'thinking')};s.value='low';s.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
await b.evaluate(`document.getElementById('routes-form').requestSubmit();true`);
await wait(`document.querySelector('.routes-apply')`);
assert.deepEqual(asked, [['routes.set', { revision: REV, routes: { chief: route('low'), coder: route('low'), assistant: route('low'), reviewer: route('low') } }]]);
assert.equal(await b.evaluate(`document.getElementById('routes-receipt').textContent`), 'Routes saved for chief, coder, assistant, reviewer. The next launch of each uses the new route.');
assert.deepEqual(await items(), [
'chief: The launcher. It takes the new route at its next start.',
'coder: Running. Its session keeps the old route until it ends. Restart it to move it now.',
'assistant: Running. Its session keeps the old route until the thread goes idle; the next message after that starts it on the new route. Restart it to move it now.',
'reviewer: Not running. Its next start uses the new route.',
]);
// Only the running agents offer the restart, and the thread is marked as one.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('.routes-apply [data-agent-restart]')].map(e=>[e.dataset.agentRestart,e.dataset.agentThread,e.textContent])`),
[['coder', 'no', 'Restart coder now'], ['assistant', 'yes', 'Restart assistant now']]);
// Restart now on a session: terminate with the reason, wait for it to end, then one launch.
await b.evaluate(`document.querySelector('[data-agent-restart="coder"]').click();true`);
await wait(`document.querySelector('[data-agent-restart-status="coder"]')?.textContent==='Restarted coder on the new route (run run-2).'`, 30000);
assert.deepEqual(asked.slice(1).filter(([v]) => v !== 'instance.request'), [['instance.end', { instance: 'coder', reason: 'route change' }], ['instance.launch', { instance: 'coder' }]]);
assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'coder').length, 1, 'one launch, after the session ended');
// A thread is terminated, not launched: its next message starts it on the new route.
await b.evaluate(`document.querySelector('[data-agent-restart="assistant"]').click();true`);
await wait(`/^Terminated assistant at .+\\. Its next message starts it on the new route\\.$/.test(document.querySelector('[data-agent-restart-status="assistant"]')?.textContent??'')`, 30000);
assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'assistant').length, 0);
// The trail names the dev owner for the save and each lifecycle write.
const trail = readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.match(at, /^\d{4}-\d\d-\d\dT/); return e; });
assert.deepEqual(trail.map(e => [e.action, e.by?.provider, e.outcome?.split(' ')[0]]), [
['routes.set', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'], ['instance.launch', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'],
]);
assert.deepEqual(trail[0].by, DEV);
await b.viewport(390, 844);
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=390`), true, 'no sideways scroll at phone width');
});