From ce066123b06d00731c84763667e8b6ebc7028f96 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 15:59:32 -0600 Subject: [PATCH 1/2] feat(console): Stop bus stops one organization's bus cleanly (#1873) POST /api/orgs/ID/stop/bus runs busStop the way mosaic bus stop does: a graceful SIGTERM close that leaves no host.lock or writer.lock, so an offline schema migration can open the store straight after. It follows restartBus for authority, same-origin checks, trail and receipt. A bus already stopped answers done with state stopped and changes nothing; a host identity the Console cannot prove refuses with every file unchanged. The organization header gains a Stop bus button beside Restart bus, one press, sharing its receipt and busy state. The restart icon now lives inside its button, so a wide receipt no longer pulls it off the button. --- packages/webui/src/org-admin.mjs | 61 +++++- packages/webui/src/public/console-restart.js | 40 +++- packages/webui/src/public/shell.css | 17 +- packages/webui/src/public/workspace.js | 5 +- packages/webui/src/serve.mjs | 2 +- packages/webui/tests/bus-stop.test.mjs | 127 ++++++++++++ packages/webui/tests/org-admin.test.mjs | 185 ++++++++++++++++++ .../tests/unified-workspace-browser.test.mjs | 57 +++++- 8 files changed, 470 insertions(+), 24 deletions(-) create mode 100644 packages/webui/tests/bus-stop.test.mjs diff --git a/packages/webui/src/org-admin.mjs b/packages/webui/src/org-admin.mjs index a3e46739a..72c612f20 100644 --- a/packages/webui/src/org-admin.mjs +++ b/packages/webui/src/org-admin.mjs @@ -18,6 +18,7 @@ // POST /api/orgs/ID/setup/bus C3 busStart mosaic bus start --detach // POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped; // busRevive for a host that died, dev-local owner only, D2) +// POST /api/orgs/ID/stop/bus C3 busStop mosaic bus stop (a stopped bus answers state stopped) // // GET /api/orgs (the list) and other /api/orgs/ID/... routes are S5's; this // handler returns false for them. @@ -35,7 +36,7 @@ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/; const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/; const MAX_BODY = 64 * 1024; -const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/; +const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart|stop)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/; // The org-template placeholder (packages/organization/src/org-service.mjs templateRender). export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`; // Project fields the server owns. The browser never sees or sends them. `agents` (#1873 A1) is @@ -54,6 +55,7 @@ export function verbFor(step, id, opts = {}) { if (step === 'records') return `mosaic project init ${id} ${opts.project}`; if (step === 'bus') return `mosaic bus start ${id} --dev-local-owner --detach`; if (step === 'bus-restart') return `mosaic bus restart ${id}`; + if (step === 'bus-stop') return `mosaic bus stop ${id}`; if (step === 'bus-revive') return `mosaic bus recover ${id}; mosaic bus start ${id} --dev-local-owner --detach`; throw new Error(`unknown step ${step}`); } @@ -522,6 +524,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); } } if (kind === 'restart') return restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }); + if (kind === 'stop') return stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }); if (kind !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' }); // Every setup write reads the step back first and never overwrites. @@ -613,6 +616,62 @@ async function restartBus(req, res, { sub, write, id, ctx, service, admin, log, return json(res, 200, { outcome: 'done', step, restarted: running, healthy, receipt: forReceipt(ctx, receipt), verb: running ? verbFor('bus-restart', id) : verbFor('bus', id) }); } +// Stop bus: the same owner, origin, --send and trail rules as Restart bus. C3 busStop sends SIGTERM +// to the proven host and waits for it to exit; the host's close() removes host.json and host.lock, +// and the broker's store removes writer.lock, so a clean stop leaves no lock behind (an offline +// migrate-schema run can follow). The bus state is read back after the stop: a host or lock still +// there, or state that does not check out, is a failure, not a done receipt. A host whose state +// cannot be proven refuses before anything changes; there is no recovery path here, unlike Restart +// bus on the dev Console. A bus that is already stopped answers done with state stopped. +async function stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) { + const step = 'bus-stop'; + if (sub !== 'bus' || !write) return json(res, 404, { error: 'not found' }); + const body = await readBody(req); strictKeys(body, []); + try { await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step, id }); } + const bus = await need(admin, 'bus', res, step); if (!bus) return; + if (typeof bus.busStop !== 'function') return json(res, 503, { error: 'not-configured', step, message: 'bus control cannot stop a bus on this Console yet' }); + if (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not stop buses' }); + // The dev Console's start-up revive may be bringing this bus back; the press acts on the result. + await revivePending(id); + let status; + try { status = await bus.busStatus(ctx, id); } + catch (e) { + if (['org-unknown', 'org-mismatch', 'org-unavailable', 'org-archived'].includes(e?.code)) return refusal(res, e, { step, id }); + if (isCliError(e)) { + // Refused before anything changed, but the owner asked: the trail records the attempt. + try { trail({ by, action: 'bus.stop', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus stop: trail write failed after refused`); } + return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` }); + } + return refusal(res, e, { step, id }); + } + const running = Number.isSafeInteger(status?.pid); + try { trail({ by, action: 'bus.stop', org: id, outcome: 'requested', running }); } + catch { return json(res, 500, { error: 'trail-unavailable', step, message: 'the action trail could not be written, so nothing was stopped' }); } + const note = outcome => { try { trail({ by, action: 'bus.stop', org: id, outcome }); } catch { log(`org-admin: ${id} bus stop: trail write failed after ${outcome}`); } }; + if (!running) { + note('done'); + return json(res, 200, { outcome: 'done', step, stopped: false, state: 'stopped', receipt: { org: id, stopped: false }, verb: verbFor('bus-stop', id) }); + } + let receipt; + try { receipt = await bus.busStop(ctx, id); } + catch (e) { + note('refused'); + if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true }); + return json(res, 409, { error: e.exitCode === 3 ? 'bus-refused' : 'bus-failed', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}.` }); + } + // Read the state back: the stop is done only when no host and no lock is left. + let after = null, left = 'a bus host still runs'; + try { after = await bus.busStatus(ctx, id); } + catch (e) { if (!isCliError(e)) { note('failed'); return refusal(res, e, { step, id, unknownOk: true }); } left = plainMessage(ctx, e.message); } + if (!after || Number.isSafeInteger(after.pid)) { + note('failed'); + return json(res, 409, { error: 'bus-failed', step, message: `the bus host was stopped, but ${left}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` }); + } + note('done'); + log(`org-admin: ${id} bus stopped`); + return json(res, 200, { outcome: 'done', step, stopped: true, state: 'stopped', receipt: forReceipt(ctx, { org: id, ...receipt }), verb: verbFor('bus-stop', id) }); +} + // The dev-local owner exactly (packages/cli/src/dev-local-owner.mjs). A signed-in session carries // its provider's issuer, so no SSO user matches. const isDevOwner = by => by?.provider === DEV_OWNER.provider && by.iss === DEV_OWNER.iss && by.sub === DEV_OWNER.sub; diff --git a/packages/webui/src/public/console-restart.js b/packages/webui/src/public/console-restart.js index 8f23def96..74cb1f9a9 100644 --- a/packages/webui/src/public/console-restart.js +++ b/packages/webui/src/public/console-restart.js @@ -15,7 +15,7 @@ const sleep = ms => new Promise(r => setTimeout(r, ms)); const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); let config = null, boot = null, reconnecting = false, planned = false; - let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false; + let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false, busAction = 'restart'; const readConfig = () => nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' }) .then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null)); @@ -119,11 +119,17 @@ else if (e.target.id === 'console-restart-go' && restartState === 'confirm') restartConsole(); }); - // The org bus restart control, offered on the organization's own pages to its owner. + // The org bus restart control, offered on the organization's own pages to its owner. With + // { stop: true } (the organization header) a Stop bus button sits beside it: one press, like + // Restart bus, sharing its receipt and busy state. glyph is the caller's own icon markup, kept + // inside the restart button so it stays on the button whatever width the receipt takes. const busAllowed = () => !!window.MosaicOrg && config?.orgSetup === true; - function busButton(label = 'Restart bus') { + function busButton(label = 'Restart bus', { stop = false, glyph = '' } = {}) { if (!busAllowed()) return ''; - return ` ${esc(busReceipt)}`; + const off = busBusy ? ' disabled' : ''; + const restart = ``; + const buttons = stop ? `${restart} ` : restart; + return `${buttons} ${esc(busReceipt)}`; } async function attachBus(box, label) { await ready; @@ -133,14 +139,16 @@ box.insertAdjacentHTML('beforeend', ` ${busButton(label)}`); } const paintBus = () => document.querySelectorAll('.bus-restart').forEach(el => { - const label = el.querySelector('[data-bus-restart]')?.dataset.label; - el.outerHTML = busButton(label); + const button = el.querySelector('[data-bus-restart]'); + el.outerHTML = busButton(button?.dataset.label, { stop: button?.hasAttribute('data-stop'), glyph: button?.querySelector('svg')?.outerHTML || '' }); }); document.addEventListener('click', async e => { - const button = e.target.closest?.('[data-bus-restart]'); + const button = e.target.closest?.('[data-bus-restart],[data-bus-stop]'); if (!button || button.disabled || busBusy || !busAllowed()) return; const id = window.MosaicOrg.id; + busAction = button.hasAttribute('data-bus-stop') ? 'stop' : 'restart'; busBusy = true; busReceipt = ''; paintBus(); + if (busAction === 'stop') return stopBus(id); try { const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/restart/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' }); if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.'); @@ -170,5 +178,23 @@ } finally { busBusy = false; paintBus(); } }); + // Stop bus: the bus host stops cleanly and leaves no lock behind. An already stopped bus answers + // done with state stopped. The page reads the list and its send flags again, as after a restart. + async function stopBus(id) { + try { + const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/stop/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' }); + if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.'); + const body = await response.json(); + if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-stop' || body.state !== 'stopped') throw new Error(body.message || 'The stop was refused. Nothing was retried.'); + busReceipt = body.stopped ? `Bus stopped at ${clock()}. Restart bus starts it again.` : `The bus was already stopped at ${clock()}. Nothing changed.`; + $('bus-restart-done')?.remove(); + await Promise.resolve(window.MosaicOrg.refreshList?.()).catch(() => {}); + const fresh = await window.fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).catch(() => null); + if (fresh) { config = fresh; document.dispatchEvent(new CustomEvent('mosaic-config', { detail: fresh })); } + } catch (error) { + busReceipt = error instanceof SyntaxError || error?.name === 'TypeError' ? 'Outcome unknown. Check the bus with mosaic bus status before retrying.' : error.message; + } finally { busBusy = false; paintBus(); } + } + window.MosaicConsole = Object.freeze({ ready, settingsMarkup, busButton, attachBus, get busRestart() { return busAllowed(); }, get config() { return config; } }); })(); diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index 773f6de0a..0a4a9b8cd 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -431,16 +431,16 @@ html[data-organizations] .ws-org-row .ws-dot{font-size:12.5px;font-weight:500} html[data-organizations] .ws-org-row .ws-dot.s-offline,html[data-organizations] .ws-org-row .ws-dot.s-error{color:var(--text)} html[data-organizations] .ws-org-row .ws-dot.s-offline b{color:var(--muted)} html[data-organizations] .ws-org-row .ws-dot.s-error b{color:var(--danger)} -html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none} -html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled){background:var(--raised)} -html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled{color:var(--muted);cursor:not-allowed;opacity:1} +html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart],html[data-organizations] .ws-busctl [data-bus-stop]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none} +html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-stop]:hover:not(:disabled){background:var(--raised)} +html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled,html[data-organizations] .ws-busctl [data-bus-stop]:disabled{color:var(--muted);cursor:not-allowed;opacity:1} html[data-organizations] .ws-btn svg.i{width:15px;height:15px} -/* The live bus restart is console-restart.js's own button; its icon sits beside it, outside the - part that file repaints. */ -html[data-organizations] .ws-busctl{position:relative;display:inline-flex;flex-direction:column;align-items:flex-end} +/* The live bus restart is console-restart.js's own button; it carries its icon through repaints, + so the icon stays on the button however wide the receipt below it is. */ +html[data-organizations] .ws-busctl{display:inline-flex;flex-direction:column;align-items:flex-end} html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px} -html[data-organizations] .ws-busctl [data-bus-restart]{padding-left:31px} -html[data-organizations] .ws-busctl .ws-busicon{position:absolute;left:11px;top:7px;width:15px;height:15px;pointer-events:none;color:var(--strong);z-index:1} +html[data-organizations] .ws-busctl .bus-buttons{display:inline-flex;flex-wrap:wrap;gap:6px;justify-content:flex-end} +html[data-organizations] .ws-busctl [data-bus-restart] svg.i{width:15px;height:15px} html[data-organizations] .ws-busctl [data-bus-restart-receipt]{font-size:12px;color:var(--muted);max-width:28em;text-align:right} html[data-organizations] .ws-busctl [data-bus-restart-receipt]:empty{display:none} html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:center;gap:6px} @@ -689,6 +689,7 @@ html[data-organizations] #copy-status:empty{display:none} html[data-organizations] .ws-orghead .ws-right{margin-left:0} html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start} html[data-organizations] .ws-busctl [data-bus-restart-receipt]{text-align:left} + html[data-organizations] .ws-busctl .bus-buttons{justify-content:flex-start} html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:0} html[data-organizations] .ws-levels{grid-template-columns:minmax(0,1fr)!important} html[data-organizations] .ws-levels>div{border-right:0} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 68e8291c5..c55633e34 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -219,9 +219,10 @@ // Header: the bus restart is console-restart.js's button where the Console allows it, otherwise // the same control, present and disabled, with the reason. - const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus') || ''; + const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus', { stop: true, glyph: icon('restart') }) || ''; const offTitle = pane ? "Restart the bus from this organization's full view" : "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail"; - const bus = restart ? `${icon('restart', 'ws-busicon')}${restart}` : ``; + const stopTitle = pane ? "Stop the bus from this organization's full view" : "Stopping the bus is the owner's trailed action, on a Console started with send and the action trail"; + const bus = restart ? `${restart}` : ` `; const reload = ``; const [bcls, bglyph] = state(org); const meta = `

Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}Restart is a trailed human action; reload arrives with the cross-org update.

`; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index f5fe60d27..cf8577898 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -828,7 +828,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF for (const registerRoutes of routes) registerRoutes(registered); registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true }); const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log, trail }); - for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) { + for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart|stop)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) { for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute); } registered.add('POST', '/api/orgs', adminRoute); diff --git a/packages/webui/tests/bus-stop.test.mjs b/packages/webui/tests/bus-stop.test.mjs new file mode 100644 index 000000000..480d08267 --- /dev/null +++ b/packages/webui/tests/bus-stop.test.mjs @@ -0,0 +1,127 @@ +// Stop bus (#1873, bus-stop): the Console's POST /api/orgs/ID/stop/bus stops one organization's bus +// host the way `mosaic bus stop` does, with SIGTERM and a clean close, so no host.lock or writer.lock +// is left and an offline schema migration can open the store straight after. A real scratch instance +// (bootstrap.sh), a real bus host and a real dev-owner Console on port 0. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { request } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { humanEnv } from '../../cli/tests/helpers.mjs'; +import { busStatus, busStop } from '../../cli/src/org-bus.mjs'; +import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs'; +import { createOrg, templateRender } from '../../organization/src/org-service.mjs'; + +const REPO = new URL('../../../', import.meta.url).pathname; +const MOSAIC = join(REPO, 'scripts/mosaic'); +const MIGRATE = join(REPO, 'packages/bus/src/migrate-schema.mjs'); +const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' }; +const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350]; + +function mosaic(env, args, timeout = 60000) { + return new Promise((resolve, reject) => { + const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + child.stdout.setEncoding('utf8').on('data', c => { stdout += c; }); + child.stderr.setEncoding('utf8').on('data', c => { stderr += c; }); + const timer = setTimeout(() => child.kill('SIGKILL'), timeout); + child.on('error', reject); + child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); }); + }); +} +// A request the way the page sends it to its own Console. +function post(port, path) { + return new Promise((resolve, reject) => { + const host = `127.0.0.1:${port}`; + const req = request({ host: '127.0.0.1', port, path, method: 'POST', headers: { host, origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } }, res => { + let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; }); + res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body, text }); }); + }); + req.on('error', reject); req.end('{}'); + }); +} +// Every file under the bus state directories, with its content hash. +function snapshot(root) { + const out = {}; + for (const dir of ['bus-host', 'bus']) { + const base = join(root, dir); + if (!existsSync(base)) continue; + for (const name of readdirSync(base).sort()) { + const p = join(base, name), st = statSync(p); + out[`${dir}/${name}`] = st.isFile() ? createHash('sha256').update(readFileSync(p)).digest('hex') : st.isDirectory() ? 'dir' : 'other'; + } + } + return out; +} + +test('Stop bus stops a live bus cleanly: no locks, an offline migration runs, a second press answers stopped, setup starts it again', { timeout: 180000 }, async t => { + const home = mkdtempSync(join(tmpdir(), 'bs-')); + const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-bstop/config.json') }); + const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'bstop'], { env, encoding: 'utf8' }); + assert.equal(setup.status, 0, setup.stderr); + const ctx = { configDir: join(home, '.config/mosaic-bstop'), dataRoot: join(home, '.mosaic-bstop'), instance: 'bstop', env }; + t.after(async () => { + try { await busStop(ctx, 'alpha'); } catch {} + try { await consoleStop(ctx, { warn: () => {} }); } catch {} + rmSync(home, { recursive: true, force: true }); + }); + await createOrg({ ...ctx, principal: DEV }, 'alpha', await templateRender('alpha', { fixture: 'local' })); + const started = await mosaic(env, ['bus', 'start', 'alpha', '--no-launch', '--dev-local-owner', '--detach']); + assert.equal(started.status, 0, started.stderr); + const first = await busStatus(ctx, 'alpha'); + assert.equal(first.healthy, true); + const root = first.root; + const hostLock = join(root, 'bus-host', 'host.lock'), writerLock = join(root, 'bus', 'writer.lock'); + // A live bus holds both locks, so their absence afterwards is the stop's doing. + assert.ok(existsSync(hostLock), 'a live bus holds host.lock'); assert.ok(existsSync(writerLock), 'a live bus holds writer.lock'); + const run = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }); + const port = Number(new URL(run.url).port); + assert.ok(port > 0 && !FORBIDDEN.includes(port), String(port)); + + const stop = await post(port, '/api/orgs/alpha/stop/bus'); + assert.equal(stop.status, 200, stop.text); + assert.equal(stop.body.step, 'bus-stop'); assert.equal(stop.body.state, 'stopped'); assert.equal(stop.body.stopped, true); + assert.equal(stop.body.receipt.pid, first.pid); + assert.ok(!stop.text.includes(home), 'the receipt names no host path'); + assert.ok(!existsSync(`/proc/${first.pid}`) || !readFileSync(`/proc/${first.pid}/cmdline`, 'utf8').includes('--internal-host'), 'the host process is gone'); + assert.ok(!existsSync(hostLock), 'no host.lock left'); assert.ok(!existsSync(writerLock), 'no writer.lock left'); + const status = await busStatus(ctx, 'alpha'); + assert.equal(status.healthy, false); assert.equal(status.pid ?? null, null); + + // The offline schema migration opens the store straight after (it refuses writer-locked otherwise). + const migrate = spawnSync(process.execPath, [MIGRATE, root], { env: { PATH: process.env.PATH }, encoding: 'utf8' }); + assert.equal(migrate.status, 0, migrate.stderr); + assert.match(migrate.stdout, /bus schema \S+ ready/); + assert.ok(!existsSync(writerLock), 'the migration released the store'); + + // A second press finds it stopped and changes nothing. + const quiet = snapshot(root); + const again = await post(port, '/api/orgs/alpha/stop/bus'); + assert.equal(again.status, 200, again.text); + assert.deepEqual([again.body.step, again.body.state, again.body.stopped], ['bus-stop', 'stopped', false]); + assert.deepEqual(snapshot(root), quiet); + + // A host lock this Console cannot prove (a live pid that is not the recorded process) refuses, + // and every bus file stays as it was. + writeFileSync(hostLock, JSON.stringify({ pid: process.pid, startTime: '1', business: 'alpha', startedAt: new Date().toISOString() })); + const unproven = snapshot(root); + const refused = await post(port, '/api/orgs/alpha/stop/bus'); + assert.equal(refused.status, 409, refused.text); assert.equal(refused.body.error, 'bus-refused'); + assert.match(refused.body.message, /^stale bus host lock\. Inspect it with mosaic bus status alpha/); + assert.deepEqual(snapshot(root), unproven); + rmSync(hostLock); + + // The setup step starts it again. + const up = await post(port, '/api/orgs/alpha/setup/bus'); + assert.equal(up.status, 201, up.text); assert.equal(up.body.step, 'bus'); + const back = await busStatus(ctx, 'alpha'); + assert.equal(back.healthy, true); assert.notEqual(back.pid, first.pid); + assert.ok(existsSync(hostLock)); assert.ok(existsSync(writerLock)); + // And stops again from the page. + const last = await post(port, '/api/orgs/alpha/stop/bus'); + assert.equal(last.status, 200, last.text); assert.equal(last.body.stopped, true); + assert.ok(!existsSync(hostLock)); assert.ok(!existsSync(writerLock)); +}); diff --git a/packages/webui/tests/org-admin.test.mjs b/packages/webui/tests/org-admin.test.mjs index 4ae16ad85..f7fc584c9 100644 --- a/packages/webui/tests/org-admin.test.mjs +++ b/packages/webui/tests/org-admin.test.mjs @@ -39,6 +39,7 @@ function fakes() { busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; }, busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; }, busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; }, + busStop: async (ctx, id) => { calls.push(['busStop', id]); const was = running === id; running = null; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; }, }; const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) }); return { admin, orgs, calls, fail, service }; @@ -686,3 +687,187 @@ test('D2: a press during the Console start-up revive waits for it and acts on th assert.equal(r.status, 200, r.raw); assert.equal(r.body.restarted, true); assert.deepEqual(c.calls.filter(x => x[0] === 'busRestart'), [['busRestart', 'acme']]); }); + +// Stop bus: the owner stops one organization's bus from its page. Same owner, origin, --send and +// trail rules as Restart bus; a stopped bus answers state stopped and nothing changes. +const STOP = '/api/orgs/acme/stop/bus'; +const STOP_DONE = { outcome: 'done', step: 'bus-stop', stopped: true, state: 'stopped', receipt: { org: 'acme', stopped: true, business: 'acme', pid: 4242 }, verb: 'mosaic bus stop acme' }; + +test('stop: the owner stops a running bus, a second press answers stopped, and setup starts it again', async t => { + const tr = trailIn(t); + const c = await console_(t, { trail: tr.trail }); + assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') })).status, 201); + assert.equal((await c.call('POST', '/api/orgs/acme/setup/bus', {})).status, 201); + let r = await c.call('POST', STOP, {}); + assert.equal(r.status, 200, r.raw); + assert.deepEqual(r.body, STOP_DONE); + r = await c.call('POST', STOP, {}); + assert.equal(r.status, 200, r.raw); + assert.deepEqual(r.body, { outcome: 'done', step: 'bus-stop', stopped: false, state: 'stopped', receipt: { org: 'acme', stopped: false }, verb: 'mosaic bus stop acme' }); + // The second press changed nothing: busStop ran once. + assert.deepEqual(c.calls.filter(x => x[0] === 'busStop'), [['busStop', 'acme']]); + const up = await c.call('POST', '/api/orgs/acme/setup/bus', {}); + assert.equal(up.status, 201, up.raw); assert.equal(up.body.step, 'bus'); + assert.equal(c.calls.filter(x => x[0] === 'busStart').length, 2); + assert.deepEqual(tr.lines(), [ + { by: DEV, action: 'bus.stop', org: 'acme', outcome: 'requested', running: true }, + { by: DEV, action: 'bus.stop', org: 'acme', outcome: 'done' }, + { by: DEV, action: 'bus.stop', org: 'acme', outcome: 'requested', running: false }, + { by: DEV, action: 'bus.stop', org: 'acme', outcome: 'done' }, + ]); +}); + +test('stop: another origin or site, no owner or --send, a body, GET or no organization is refused like Restart bus', async t => { + const tr = trailIn(t); + const c = await console_(t, { trail: tr.trail }); + await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') }); + await c.call('POST', '/api/orgs/acme/setup/bus', {}); + for (const headers of [{ origin: null }, { origin: 'http://evil.example' }, { origin: `http://localhost:${c.port + 1}` }, { 'sec-fetch-site': null }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': 'same-site' }, { 'x-mosaic-tester': '1' }]) { + const [stop, restart] = [await c.call('POST', STOP, {}, headers), await c.call('POST', '/api/orgs/acme/restart/bus', {}, headers)]; + assert.equal(stop.status, 403, JSON.stringify(headers)); + assert.deepEqual([stop.status, stop.body?.error], [restart.status, restart.body?.error], JSON.stringify(headers)); + } + assert.equal((await c.call('POST', STOP, {}, { 'content-type': 'text/plain' })).status, 415); + assert.equal((await c.call('POST', STOP, { now: true })).status, 400); + assert.equal((await c.call('GET', STOP)).status, 404); + assert.equal((await c.call('POST', '/api/orgs/acme/stop/queue', {})).status, 404); + assert.equal((await c.call('POST', '/api/orgs/acme/stop', {})).status, 404); + const unknown = await c.call('POST', '/api/orgs/ghost/stop/bus', {}); + assert.equal(unknown.status, 404); assert.equal(unknown.body.error, 'org-unavailable'); + const readOnly = await console_(t, { trail: tr.trail, send: null }); + const ro = await readOnly.call('POST', STOP, {}); + assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only'); + const anon = await console_(t, { trail: tr.trail, sso: null, send: null }); + const a = await anon.call('POST', STOP, {}); + assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only'); + assert.equal(c.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0); + assert.deepEqual(tr.lines(), []); +}); + +test('stop: on a deployed (signed-in) Console a granted user is refused like Restart bus, and the owner stops the bus', async t => { + const { createSsoSession } = await import('./helpers/sso-session.mjs'); + const { consoleAccess } = await import('../src/access.mjs'); + const tr = trailIn(t); + const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }], + grants: [{ id: 'g-1', user: 'alice', business: 'acme', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] }; + const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'acme' }) }); + const f = fakes(); + await f.service.createOrg({}, 'acme', template('acme')); + const bus = await f.admin.bus(); + await bus.busStart({}, 'acme', {}); + const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), orgAdmin: f.admin, log: () => {}, trail: tr.trail }); + t.after(() => close(server)); + const port = server.address().port; + const post = (path, cookie, headers = {}) => new Promise((resolve, reject) => { + const all = { ...s.writeHeaders(cookie), 'content-length': 2, ...headers }; + const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers: all }, res => { let raw = ''; res.on('data', x => raw += x); res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch {} resolve({ status: res.statusCode, body: json, raw }); }); }); + req.on('error', reject); req.end('{}'); + }); + const alice = await s.signIn(port, { sub: 'alice-sub' }); + for (const cookie of [null, alice]) { + const [stop, restart] = [await post(STOP, cookie), await post('/api/orgs/acme/restart/bus', cookie)]; + assert.ok(stop.status === 401 || stop.status === 403 || stop.status === 404, stop.raw); + assert.deepEqual([stop.status, stop.body?.error], [restart.status, restart.body?.error]); + } + assert.equal(f.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0); + assert.deepEqual(tr.lines(), []); + // The signed-in owner from another origin is refused too; from the Console's own origin the bus stops. + const owner = await s.signIn(port); + const cross = await post(STOP, owner, { origin: 'https://evil.example' }); + assert.equal(cross.status, 403); + const ok = await post(STOP, owner); + assert.equal(ok.status, 200, ok.raw); assert.equal(ok.body.state, 'stopped'); assert.equal(ok.body.stopped, true); + assert.deepEqual(tr.lines().map(e => [e.by.provider, e.by.sub, e.action, e.outcome]), [['writer', 'owner-sub', 'bus.stop', 'requested'], ['writer', 'owner-sub', 'bus.stop', 'done']]); +}); + +test('stop: a signed-in operator who is not the owner is refused before any bus call', async t => { + const { Readable } = await import('node:stream'); + const tr = trailIn(t); + const f = fakes(); + await f.service.createOrg({}, 'acme', template('acme')); + for (const path of [STOP, '/api/orgs/acme/restart/bus']) { + const req = Object.assign(Readable.from([Buffer.from('{}')]), { method: 'POST', headersDistinct: { origin: ['http://console.example'], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'] } }); + const res = { writeHead(status) { this.status = status; }, end(body) { this.body = JSON.parse(body); } }; + await orgAdminHTTP(req, res, { path, admin: f.admin, session: { provider: 'fixture', iss: 'https://issuer.example', sub: 'op', rootOwner: false, instanceRole: 'operator' }, owner: false, send: true, origin: 'http://console.example', trail: tr.trail }); + assert.equal(res.status, 403, path); assert.equal(res.body.error, 'owner-only', path); + } + assert.equal(f.calls.filter(x => x[0].startsWith('bus')).length, 0); + assert.deepEqual(tr.lines(), []); +}); + +test('stop: no trail, or a trail that cannot be written, stops nothing', async t => { + const c = await console_(t); + await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') }); + await c.call('POST', '/api/orgs/acme/setup/bus', {}); + const r = await c.call('POST', STOP, {}); + assert.equal(r.status, 503); assert.equal(r.body.error, 'trail-unavailable'); + const broken = await console_(t, { trail: () => { throw new Error('disk full'); } }); + await broken.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') }); + await broken.call('POST', '/api/orgs/acme/setup/bus', {}); + const b = await broken.call('POST', STOP, {}); + assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable'); + for (const x of [c, broken]) assert.equal(x.calls.filter(y => y[0] === 'busStop').length, 0); +}); + +test('stop: a host that cannot be proven refuses with nothing changed, on the dev Console too', async t => { + const tr = trailIn(t); + const c = await console_(t, { trail: tr.trail }); + await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') }); + const bus = await c.admin.bus(); + bus.busRevive = async () => assert.fail('Stop bus reached busRevive'); + for (const reason of ['stale bus host lock', 'bus host identity mismatch', 'bus host lock does not match host state', 'stale bus writer lock']) { + bus.busStatus = async () => { throw cliError(reason); }; + const r = await c.call('POST', STOP, {}); + assert.equal(r.status, 409, reason); assert.equal(r.body.error, 'bus-refused'); + assert.equal(r.body.message, `${reason}. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.`); + } + assert.equal(c.calls.filter(x => x[0] === 'busStop').length, 0); + assert.deepEqual(tr.lines(), Array(4).fill({ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'refused', running: null })); +}); + +test('stop: a stop that is refused, times out or leaves state behind is a visible failure', async t => { + const tr = trailIn(t); + const c = await console_(t, { trail: tr.trail }); + await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') }); + await c.call('POST', '/api/orgs/acme/setup/bus', {}); + const bus = await c.admin.bus(); + const live = async () => ({ org: 'acme', healthy: true, pid: 4242, root: '/data/org-runtime/acme' }); + bus.busStatus = live; + bus.busStop = async () => { throw cliError('pid 4242 is not a bus host; refusing to signal it'); }; + let r = await c.call('POST', STOP, {}); + assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-refused'); + assert.equal(r.body.message, 'pid 4242 is not a bus host; refusing to signal it. Inspect it with mosaic bus status acme.'); + bus.busStop = async () => { throw Object.assign(new Error('bus host pid 4242 did not stop within 60 s'), { name: 'CliError', exitCode: 1 }); }; + r = await c.call('POST', STOP, {}); + assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed'); + // The host was signalled and exited, but a lock is left, or the host is still recorded. + let after; + bus.busStop = async () => { bus.busStatus = after; return { stopped: true, business: 'acme', pid: 4242 }; }; + after = async () => { throw cliError('stale bus writer lock'); }; + r = await c.call('POST', STOP, {}); + assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed'); + assert.equal(r.body.message, 'the bus host was stopped, but stale bus writer lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.'); + bus.busStatus = live; after = live; + r = await c.call('POST', STOP, {}); + assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed'); + assert.match(r.body.message, /^the bus host was stopped, but a bus host still runs\./); + assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused', 'requested', 'refused', 'requested', 'failed', 'requested', 'failed']); +}); + +test('stop: an archived organization, or a Console whose bus control has no stop, refuses', async t => { + const tr = trailIn(t); + const c = await console_(t, { trail: tr.trail }); + await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') }); + const bus = await c.admin.bus(); + const busStop = bus.busStop; delete bus.busStop; + const n = await c.call('POST', STOP, {}); + assert.equal(n.status, 503); assert.equal(n.body.error, 'not-configured'); + bus.busStop = busStop; + bus.busStatus = async () => { throw new OrgError('org-archived', 'organization acme is archived'); }; + const a = await c.call('POST', STOP, {}); + const ra = await c.call('POST', '/api/orgs/acme/restart/bus', {}); + assert.deepEqual([a.status, a.body.error], [422, 'org-archived'], a.raw); + assert.deepEqual([ra.status, ra.body.error], [422, 'org-archived'], ra.raw); + assert.equal(c.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0); + assert.deepEqual(tr.lines(), []); +}); diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 5fce19184..3ad71c2ef 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -3,6 +3,10 @@ import assert from 'node:assert/strict'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { createOrgAdmin } from '../src/org-admin.mjs'; +import { actionTrail } from '../src/action-trail.mjs'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; import { fileURLToPath } from 'node:url'; @@ -202,7 +206,7 @@ test('organization dashboard presents sections and honest unavailable controls', assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true); }); -test('each header control is present, reload is disabled, and restart is refused without the owner', async t => { +test('each header control is present, reload is disabled, and restart and stop are refused without the owner', async t => { const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(owner)); const readOnly = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(readOnly)); const signedOut = await startServer({ port: 0, organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(signedOut)); @@ -210,27 +214,69 @@ test('each header control is present, reload is disabled, and restart is refused const wait = waitFor(b); const header = server => b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`).then(() => wait('document.querySelector("[data-org-dashboard] [data-org-settings]")')).then(() => b.evaluate(`(() => { const h = document.querySelector('[data-org-dashboard] .ws-orghead'), live = h.querySelector('[data-bus-restart]'), off = h.querySelector('[data-bus-restart-off]'), reload = h.querySelector('[data-org-reload]'); + const stop = h.querySelector('[data-bus-stop]'), stopOff = h.querySelector('[data-bus-stop-off]'); return { live: live ? [live.textContent, live.disabled] : null, off: off ? [off.textContent, off.disabled, !!off.title] : null, + stop: stop ? [stop.textContent, stop.disabled, stop.closest('.bus-restart') === live.closest('.bus-restart')] : null, stopOff: stopOff ? [stopOff.textContent, stopOff.disabled, !!stopOff.title] : null, reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] }; })()`)); const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.']; // The owner on a Console started with --send gets console-restart.js's restart button. - assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, reload: RELOAD }); + // Stop bus sits beside it, in the same control, and is one press like Restart bus. + assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, stop: ['Stop bus', false, true], stopOff: null, reload: RELOAD }); // Without --send, or with nobody signed in, the control is present and disabled, and the route refuses. const post = 'fetch("/api/orgs/ddigital/restart/bus", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async r => [r.status, (await r.json()).error])'; - assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], reload: RELOAD }); + const stopPost = post.replace('/restart/bus', '/stop/bus'); + assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], stop: null, stopOff: ['Stop bus', true, true], reload: RELOAD }); assert.deepEqual(await b.evaluate(post), [403, 'read-only']); + assert.deepEqual(await b.evaluate(stopPost), [403, 'read-only']); // With nobody signed in the organization list is not readable, so the dashboard reads nothing and // shows no controls at all. await b.navigate(`http://127.0.0.1:${signedOut.address().port}/#/orgs/ddigital/organization`); await wait('document.querySelector("[data-org-unavailable]")'); - assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-org-reload]")'), null); + assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-bus-stop], [data-bus-stop-off], [data-org-reload]")'), null); // With nobody signed in, the server refuses every per-org route before the admin hooks run. assert.deepEqual(await b.evaluate(post), [404, 'org-unavailable']); + assert.deepEqual(await b.evaluate(stopPost), [404, 'org-unavailable']); // There is no reload route. assert.equal(await b.evaluate('fetch("/api/orgs/ddigital/reload", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(r => r.ok)'), false); }); +test('the owner presses Stop bus once: the bus stops, a second press says it was already stopped, and the restart icon stays on its button', async t => { + // A fake bus behind the real route: busStop is the only thing a press reaches. + let running = true; const stops = []; + const bus = { + busStatus: async (ctx, id) => ({ org: id, healthy: running, pid: running ? 4242 : null }), + busStop: async (ctx, id) => { stops.push(id); const was = running; running = false; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; }, + }; + const orgAdmin = createOrgAdmin({ service: { showOrg: async (ctx, id) => ({ id, revision: 1, doc: {} }) }, registry: { listOrgs: async () => [] }, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) }); + const root = mkdtempSync(join(tmpdir(), 'bus-stop-trail-')); t.after(() => rmSync(root, { recursive: true, force: true })); + const trail = actionTrail(join(root, 'console')); + const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs({ send: true }), orgAdmin, trail, log: () => {} }); t.after(() => close(server)); + const b = await browser({ machine: false }); t.after(() => b.close()); + const wait = waitFor(b); + await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`); + await wait('document.querySelector("[data-org-dashboard] [data-bus-stop]")'); + // The icon is part of the restart button, so it moves with it whatever width the receipt takes. + const icon = () => b.evaluate(`(() => { const r = document.querySelector('[data-org-dashboard] [data-bus-restart]'), i = r.querySelector('svg'); if (!i) return null; const a = r.getBoundingClientRect(), c = i.getBoundingClientRect(); return c.left >= a.left && c.right <= a.right && c.top >= a.top && c.bottom <= a.bottom; })()`); + assert.equal(await icon(), true); + await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-stop]").click()'); + await wait('/^Bus stopped at \\d/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")'); + assert.deepEqual(stops, ['ddigital']); + assert.equal(await icon(), true, 'the repaint keeps the icon on the restart button'); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-org-dashboard] .bus-buttons button")].map(x => [x.textContent, x.disabled])'), [['Restart bus', false], ['Stop bus', false]]); + await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-stop]").click()'); + await wait('/^The bus was already stopped at \\d/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")'); + // The second press reached the route, which found no host and stopped nothing. + assert.deepEqual(stops, ['ddigital']); + assert.equal(await icon(), true); + // Each press is the owner's trailed action. + const lines = readFileSync(join(root, 'console', 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, by, ...e } = JSON.parse(l); return e; }); + assert.deepEqual(lines, [ + { action: 'bus.stop', org: 'ddigital', outcome: 'requested', running: true }, { action: 'bus.stop', org: 'ddigital', outcome: 'done' }, + { action: 'bus.stop', org: 'ddigital', outcome: 'requested', running: false }, { action: 'bus.stop', org: 'ddigital', outcome: 'done' }, + ]); +}); + // Tab from the first sidebar link through the dashboard and return each stop's region and box. async function tabStops(b, start) { await b.evaluate(`(${start}).focus()`); @@ -268,8 +314,9 @@ test('keyboard focus follows visual order and survives a data refresh', async t assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440'); const keys = wide.map(s => s.key); for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`); - // The disabled reload is not a tab stop; the restart button is. + // The disabled reload is not a tab stop; the restart button is, and Stop bus follows it. assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus')); + assert.equal(keys.indexOf('Stop bus'), keys.indexOf('Restart bus') + 1); await b.viewport(390, 844); await wait('document.querySelector("[data-org-dashboard]")'); const narrow = (await tabStops(b, 'document.querySelector("[data-org-dashboard] .ws-crumb a")')).filter(s => s.region !== 'other'); -- 2.54.0 From a5f227e9fc37cbfdef8d8cc9c1b9189439d1bdff Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 16:10:48 -0600 Subject: [PATCH 2/2] fix(console): Stop bus review round: trail and stopped flag (#1873) A stop that may already have signalled the host (a timeout or an unexpected error) is trailed as failed, not refused; only exit 3 comes before the signal. The response's stopped flag follows what busStop reports, so a host that exited on its own between the read and the stop is not reported as stopped by this press. --- packages/webui/src/org-admin.mjs | 6 ++++-- packages/webui/tests/org-admin.test.mjs | 12 +++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/webui/src/org-admin.mjs b/packages/webui/src/org-admin.mjs index 72c612f20..c9c3acf4c 100644 --- a/packages/webui/src/org-admin.mjs +++ b/packages/webui/src/org-admin.mjs @@ -655,7 +655,8 @@ async function stopBus(req, res, { sub, write, id, ctx, service, admin, log, tra let receipt; try { receipt = await bus.busStop(ctx, id); } catch (e) { - note('refused'); + // Only exit 3 comes before the signal; a timeout or an unexpected error may follow SIGTERM. + note(isCliError(e) && e.exitCode === 3 ? 'refused' : 'failed'); if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true }); return json(res, 409, { error: e.exitCode === 3 ? 'bus-refused' : 'bus-failed', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}.` }); } @@ -669,7 +670,8 @@ async function stopBus(req, res, { sub, write, id, ctx, service, admin, log, tra } note('done'); log(`org-admin: ${id} bus stopped`); - return json(res, 200, { outcome: 'done', step, stopped: true, state: 'stopped', receipt: forReceipt(ctx, { org: id, ...receipt }), verb: verbFor('bus-stop', id) }); + // A host that exited on its own between the read and the stop was not stopped by this press. + return json(res, 200, { outcome: 'done', step, stopped: receipt?.stopped === true, state: 'stopped', receipt: forReceipt(ctx, { org: id, ...receipt }), verb: verbFor('bus-stop', id) }); } // The dev-local owner exactly (packages/cli/src/dev-local-owner.mjs). A signed-in session carries diff --git a/packages/webui/tests/org-admin.test.mjs b/packages/webui/tests/org-admin.test.mjs index f7fc584c9..5bdf60471 100644 --- a/packages/webui/tests/org-admin.test.mjs +++ b/packages/webui/tests/org-admin.test.mjs @@ -840,6 +840,10 @@ test('stop: a stop that is refused, times out or leaves state behind is a visibl bus.busStop = async () => { throw Object.assign(new Error('bus host pid 4242 did not stop within 60 s'), { name: 'CliError', exitCode: 1 }); }; r = await c.call('POST', STOP, {}); assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed'); + // An unexpected error may come after the signal: the outcome is unknown and the trail says failed. + bus.busStop = async () => { throw new Error('EPERM'); }; + r = await c.call('POST', STOP, {}); + assert.equal(r.status, 502, r.raw); assert.equal(r.body.error, 'outcome-unknown'); // The host was signalled and exited, but a lock is left, or the host is still recorded. let after; bus.busStop = async () => { bus.busStatus = after; return { stopped: true, business: 'acme', pid: 4242 }; }; @@ -851,7 +855,13 @@ test('stop: a stop that is refused, times out or leaves state behind is a visibl r = await c.call('POST', STOP, {}); assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed'); assert.match(r.body.message, /^the bus host was stopped, but a bus host still runs\./); - assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused', 'requested', 'refused', 'requested', 'failed', 'requested', 'failed']); + assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed']); + // The host exited on its own between the read and the stop: done, but this press stopped nothing. + bus.busStatus = live; + bus.busStop = async () => { bus.busStatus = async () => ({ org: 'acme', healthy: false, pid: null }); return { stopped: false, reason: 'no host runs' }; }; + r = await c.call('POST', STOP, {}); + assert.equal(r.status, 200, r.raw); + assert.deepEqual([r.body.outcome, r.body.state, r.body.stopped, r.body.receipt.stopped], ['done', 'stopped', false, false]); }); test('stop: an archived organization, or a Console whose bus control has no stop, refuses', async t => { -- 2.54.0