diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 64481d160..9f8b9cb41 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -583,7 +583,13 @@ async function consoleCommand(rest, io, deps) { const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) }); io.stdout.write(`${JSON.stringify(receipt)}\n`); if (!parsed.flags.has("--detach")) { - const code = await receipt.done; + // F3: this process supervises the Console. A stop signal (the unit's SIGTERM, Ctrl-C) goes to + // the Console and ends supervision cleanly; a restart the Console asks for is relaunched here. + const stop = () => receipt.stop(); + for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, stop); + let code; + try { code = await receipt.done; } + finally { for (const signal of ["SIGINT", "SIGTERM"]) process.off(signal, stop); } if (code !== 0) throw new CliError("console stopped unexpectedly", code); } } diff --git a/packages/cli/src/org-console.mjs b/packages/cli/src/org-console.mjs index 27dbf379a..d0ae4c27a 100644 --- a/packages/cli/src/org-console.mjs +++ b/packages/cli/src/org-console.mjs @@ -64,29 +64,22 @@ function takeLock(ctx) { let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); } return () => { closeSync(fd); rmSync(path, { force: true }); }; } -// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev -// owner Console on the LAN with no sign-in. Neither is set for a loopback Console. -export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) { - const value = port === 'auto' ? 0 : Number(port); - if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port'); - let lan; - try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); } - const release = takeLock(ctx); - let child = null, done = null; +// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's +// own restart route (POST /api/console/restart, a human action) exits with it. +export const RESTART_EXIT = 75; +const sleep = ms => new Promise(r => setTimeout(r, ms)); +let lastStamp = 0; +// One Console process: spawn it, wait for the URL it prints, check the address and port. +async function launch(ctx, args, lan, detach) { + lastStamp = Math.max(Date.now(), lastStamp + 1); + const log = join(directory(ctx), `console-${process.pid}-${lastStamp}.log`); + const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + let child; + try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); } + finally { closeSync(fd); } + const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); }); + let exited = false; done.then(() => { exited = true; }); try { - const prior = state(ctx); - if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first'); - const org = (await listOrgs(ctx)).find(x => x.state === 'active'); - if (org) await resolveOrg(ctx, org.id); - // The pre-S5 Console still accepts a business preselection. - const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []), - ...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : [])]; - const log = join(directory(ctx), `console-${process.pid}-${Date.now()}.log`); - const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); - try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); } - finally { closeSync(fd); } - done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); }); - let exited = false; done.then(() => { exited = true; }); const deadline = Date.now() + 30000; while (Date.now() < deadline && !exited) { const text = readFileSync(log, 'utf8'); @@ -97,22 +90,99 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false, if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port'); const startTime = startTimeOf(child.pid); if (!startTime) fail('console exited during startup'); - writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1], ...(lan ?? {}) }) + '\n', { mode: 0o600, flag: 'wx' }); - if (detach) child.unref(); - // The child's stderr goes to the log; the person who started it sees the warning here. - if (lan) warn(lanWarning(lan.bind, match[3])); - const result = { url: match[1], pid: child.pid, ...(lan ?? {}) }; - Object.defineProperty(result, 'done', { value: done }); - return result; + return { child, done, url: match[1], port: match[3], startTime }; } - await new Promise(r => setTimeout(r, 50)); + await sleep(50); } fail(`console failed to start; see ${log}`); } catch (e) { - if (child && child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; } + if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; } throw e; + } +} +const record = (run, lan) => JSON.stringify({ pid: run.child.pid, startTime: run.startTime, url: run.url, ...(lan ?? {}) }) + '\n'; +// F3: a relaunch keeps the arguments and the address. The state file must still name the process +// that asked for the restart; a stop that ran in between wins, and nothing is relaunched. +async function relaunch(ctx, old, args, lan) { + const next = args.map((a, i) => (args[i - 1] === '--port' ? old.port : a)); + let release; + for (let n = 0; !release; n++) { + try { release = takeLock(ctx); } catch (e) { if (n >= 50) throw e; await sleep(100); } + } + try { + const s = state(ctx); + if (!s || s.pid !== old.child.pid || s.startTime !== old.startTime || s.url !== old.url) fail('console state changed during restart'); + let fresh; + // The old listener has closed; a refused bind is retried briefly. + for (let n = 0; !fresh; n++) { + try { fresh = await launch(ctx, next, lan, false); } catch (e) { if (n >= 4) throw e; await sleep(500); } + } + try { + if (fresh.url !== old.url) fail('console restarted on another address'); + const tmp = `${stateFile(ctx)}.tmp-${randomBytes(4).toString('hex')}`; + writeFileSync(tmp, record(fresh, lan), { mode: 0o600, flag: 'wx' }); + renameSync(tmp, stateFile(ctx)); + } catch (e) { fresh.child.kill('SIGTERM'); await fresh.done; throw e; } + return fresh; } finally { release(); } } +// Foreground supervision: relaunch on RESTART_EXIT, end on any other exit. `stop` forwards a +// stop signal (the unit's SIGTERM, a terminal's Ctrl-C) to the Console; a stop is a clean end. +function supervise(ctx, first, args, lan, warn) { + let current = first, stopping = false; + const stop = () => { + if (stopping) return; + stopping = true; + const child = current.child; + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill('SIGTERM'); + setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 15000).unref(); + }; + const done = (async () => { + for (;;) { + const code = await current.done; + if (stopping) return 0; + if (code !== RESTART_EXIT) return code; + warn('mosaic: console asked for a restart; relaunching on the same arguments\n'); + try { current = await relaunch(ctx, current, args, lan); } + catch (e) { warn(`mosaic: console restart failed: ${e.message}\n`); return 1; } + if (stopping) { stopping = false; stop(); await current.done; return 0; } + } + })(); + return { done, stop }; +} +// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev +// owner Console on the LAN with no sign-in. Neither is set for a loopback Console. +// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and +// this process relaunches it in place (F3). A detached Console has no supervisor. +export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) { + const value = port === 'auto' ? 0 : Number(port); + if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port'); + let lan; + try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); } + const release = takeLock(ctx); + let run, args; + try { + const prior = state(ctx); + if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first'); + const org = (await listOrgs(ctx)).find(x => x.state === 'active'); + if (org) await resolveOrg(ctx, org.id); + // The pre-S5 Console still accepts a business preselection. + args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []), + ...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : []), ...(detach ? [] : ['--supervised'])]; + run = await launch(ctx, args, lan, detach); + try { writeFileSync(stateFile(ctx), record(run, lan), { mode: 0o600, flag: 'wx' }); } + catch (e) { run.child.kill('SIGTERM'); await run.done; throw e; } + } finally { release(); } + if (detach) run.child.unref(); + // The child's stderr goes to the log; the person who started it sees the warning here. + if (lan) warn(lanWarning(lan.bind, run.port)); + const result = { url: run.url, pid: run.child.pid, ...(lan ?? {}) }; + const supervision = detach ? { done: run.done, stop: () => run.child.kill('SIGTERM') } : supervise(ctx, run, args, lan, warn); + Object.defineProperty(result, 'done', { value: supervision.done }); + Object.defineProperty(result, 'stop', { value: supervision.stop }); + return result; +} export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = {}) { const release = takeLock(ctx); try { diff --git a/packages/cli/tests/org-console.test.mjs b/packages/cli/tests/org-console.test.mjs index 83b23230c..645f7d6b7 100644 --- a/packages/cli/tests/org-console.test.mjs +++ b/packages/cli/tests/org-console.test.mjs @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs'; import { get, request } from 'node:http'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -133,3 +133,80 @@ test('mosaic console start refuses the LAN flags without the dev owner and allow assert.deepEqual(JSON.parse(none.stdout), { running: false }); assert.equal(mosaic(['console', 'status', '--json']).status, 4); }); + +// F3: the owner restarts the Console from Settings. A foreground `mosaic console start` runs the +// Console with --supervised and relaunches it on the same arguments and address when it exits 75. +const WEBUI = join(REPO, 'packages/webui/src/cli.mjs'); +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const argvOf = pid => readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').slice(0, -1); +function call(port, path, { method = 'GET', body = null, headers = {} } = {}) { + const h = { host: `127.0.0.1:${port}`, ...(method === 'GET' ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }), ...headers }; + for (const k of Object.keys(h)) if (h[k] === null) delete h[k]; + return new Promise((resolve, reject) => { + const r = request({ host: '127.0.0.1', port, path, method, headers: h }, res => { + let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; }); + res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch {} resolve({ status: res.statusCode, body: json }); }); + }); + r.on('error', reject); r.end(body ?? undefined); + }); +} +const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' }; + +test('a foreground console relaunches on the same address when the owner restarts it, and a stop ends it', { timeout: 90000 }, async t => { + const { ctx } = await instance(t); + const warnings = []; + const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) }); + t.after(async () => { result.stop(); await result.done; }); + const port = Number(new URL(result.url).port); + assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port)); + assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']); + const before = (await call(port, '/api/config')).body; + assert.equal(before.consoleRestart, true); assert.equal(typeof before.boot, 'string'); + const stateFile = join(ctx.dataRoot, 'console/console.json'); + const trailFile = join(ctx.dataRoot, 'console/actions.jsonl'); + // Another origin or site restarts nothing. + for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) { + assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers)); + } + assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).pid, result.pid); + assert.equal(existsSync(trailFile), false); + const answer = await call(port, '/api/console/restart', { method: 'POST', body: '{}' }); + assert.equal(answer.status, 202); + assert.deepEqual(answer.body, { outcome: 'restarting', boot: before.boot }); + let saved; + for (const deadline = Date.now() + 30000; ; await sleep(100)) { + saved = JSON.parse(readFileSync(stateFile, 'utf8')); + if (saved.pid !== result.pid) break; + if (Date.now() > deadline) assert.fail('the Console was not relaunched'); + } + // Same address, same arguments; the auto port is now the port it had. + assert.equal(saved.url, result.url); + assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']); + assert.equal(statSync(stateFile).mode & 0o777, 0o600); + const after = (await call(port, '/api/config')).body; + assert.notEqual(after.boot, before.boot); + assert.equal(after.consoleRestart, true); + const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); + assert.deepEqual(more, []); + assert.ok(Number.isFinite(Date.parse(line.at))); + assert.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }); + assert.equal(statSync(trailFile).mode & 0o777, 0o600); + assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']); + assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' }); + result.stop(); + assert.equal(await result.done, 0); + assert.equal(consoleStatus(ctx).running, false); +}); + +test('a detached console has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => { + const { ctx } = await instance(t); + const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }); + const port = Number(new URL(result.url).port); + assert.ok(!argvOf(result.pid).includes('--supervised')); + assert.equal((await call(port, '/api/config')).body.consoleRestart, false); + const r = await call(port, '/api/console/restart', { method: 'POST', body: '{}' }); + assert.equal(r.status, 409); assert.equal(r.body.error, 'not-supervised'); + assert.equal((await call(port, '/api/config')).status, 200); + assert.equal(existsSync(join(ctx.dataRoot, 'console/actions.jsonl')), false); + assert.equal((await consoleStop(ctx)).stopped, true); +}); diff --git a/packages/webui/README.md b/packages/webui/README.md index a75023cc0..5055d2e80 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -20,6 +20,8 @@ baseline CLI. | Recover a stopped bus with stale host state | `mosaic bus recover ` | CLI only | | Inspect or clear a dead lifecycle lock | `mosaic org lock show ` / `mosaic org lock clear ` | CLI only | | Start, stop or restart a bus | `mosaic bus start ` / `mosaic bus stop ` / `mosaic bus restart ` | S6 integration hook | +| Restart (or first start) an organization's bus | `mosaic bus restart `, or `mosaic bus start ` for a bus never started | Enabled (F3): the owner's **Restart bus** control, offered where a page says the bus is down or a change needs a restart; trailed | +| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground: Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` | | Create chat project linked to a configured repository | `mosaic project create --repo --org ` | Enabled | | Create a thread | `mosaic thread create --org ` | Planned enabled | | Open a thread | `mosaic thread open --org ` | Planned enabled | @@ -34,19 +36,36 @@ baseline CLI. | Mark a board session seen/unseen; reply to a board seat | No CLI equivalent identified | Unavailable | | Approve or deny a decision | `mosaic decide --org ` | Owner-only confirmation and resolution on the selected organization bus | | Answer, attach, change mode, cancel or revoke a request | No equivalent CLI identified | Unavailable | -| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Unavailable pending parity | -| Rename an instance | `mosaic name` | Unavailable pending parity | -| Stop an assistant | No equivalent CLI identified | Unavailable | -| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Unavailable pending parity | -| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Unavailable pending parity | +| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Enabled (F3) for the owner on a selected organization's bus; unavailable on other Consoles pending parity | +| Rename an instance | `mosaic name` | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere pending parity | +| Stop an assistant | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere | +| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Enabled (F3) for the owner on a selected organization's bus, with **Restart NAME now** (terminate, then launch) after a route change; unavailable elsewhere pending parity | +| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Enabled (F3) for the owner on a selected organization's bus; the page lists how the change reaches each saved agent; unavailable elsewhere pending parity | | Halt or resume processing | `mosaic halt` / `mosaic resume` | Unavailable pending parity | | Save a project file or revert library content | No equivalent CLI identified | Unavailable | | Change a project remote | No equivalent CLI identified | Unavailable | -| Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Unavailable | +| Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus once its proxy is set up; setup itself stays `mosaic proxy init` in a terminal | Browser navigation, local appearance preferences and unsaved drafts are local state, not server writes. Signing out is a session action. +F3 writes (Console restart, bus restart, and the owner's routing, agent and +instance changes on an organization) append one line each to +`/console/actions.jsonl` (0600): who (provider, issuer, subject, +name), when, what and the outcome. A restart that cannot write its line is +refused. On a Console with sign-in, no tester, granted user or API token +reaches these controls: each needs the signed-in owner, the page's exact Origin +and `Sec-Fetch-Site: same-origin`. Those headers stop another site's page, not +a local program, which can send any header it likes. + +A dev local owner Console (`--dev-local-owner`) has no sign-in. Every request +that passes the Host and Origin checks acts as the owner, and this includes the +F3 controls. An agent launched on the same machine runs as the same OS user, so +it can restart the Console or a bus and change routing, agents and settings just +as the owner's page can. The startup warning says so. Use a dev instance for UI +testing only. The real boundary is sign-in (`--sso`); a refusal for local agent +peers in dev mode is a recorded follow-up. + ### Route registration A module may export `registerRoutes(table)` and be included with one entry in diff --git a/packages/webui/src/action-trail.mjs b/packages/webui/src/action-trail.mjs new file mode 100644 index 000000000..ebd91fe5e --- /dev/null +++ b/packages/webui/src/action-trail.mjs @@ -0,0 +1,24 @@ +// F3: the Console's trail of human operations (Console restart, org bus restart, route and +// instance changes made from the page). One JSON line per use: who, when, what, outcome. +// The file is /console/actions.jsonl, owner-only. A write that cannot be made safely +// throws, and the caller refuses the operation (fail closed). +import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, writeSync } from 'node:fs'; +import { join } from 'node:path'; + +// The person, never a token: provider, issuer, subject and display name of the session. +export const actor = session => ({ provider: session.provider, iss: session.iss, sub: session.sub, ...(typeof session.name === 'string' ? { name: session.name } : {}) }); + +export function actionTrail(dir) { + const file = join(dir, 'actions.jsonl'); + return entry => { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const st = lstatSync(dir); + if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) throw new Error('unsafe trail directory'); + const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600); + try { + const f = fstatSync(fd); + if (!f.isFile() || f.uid !== process.getuid() || (f.mode & 0o777) !== 0o600 || f.nlink !== 1) throw new Error('unsafe trail file'); + writeSync(fd, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`); + } finally { closeSync(fd); } + }; +} diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 48782bca2..63646cb4d 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -22,7 +22,9 @@ import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.m import { createSso, readSlot } from './sso.mjs'; import { readFileSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { isAbsolute } from 'node:path'; +import { isAbsolute, join } from 'node:path'; +import { actionTrail } from './action-trail.mjs'; +import { RESTART_EXIT } from '../../cli/src/org-console.mjs'; import { ID_PATTERN } from '../../organization/src/vocabulary.mjs'; import { selectorScope } from '../../organization/src/selector.mjs'; // The bus verifies the Console's exact same-tree script in /proc, whose argv preserves a @@ -40,12 +42,12 @@ if (process.platform === 'linux') { } } const args = process.argv.slice(2); -const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]'; +const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]'; try { if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); } if (args.shift() !== 'serve') throw new Error(usage); const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {}; - let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null; + let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null, supervised = false; const devNames = []; // --org and --business are one selector in this parser's scope (rename design r2). A flag-shaped // or invalid value keeps the old wording. @@ -62,6 +64,8 @@ try { if (flag === '--dev-local-owner') { devOwner = true; continue; } if (flag === '--sso') { ssoOn = true; continue; } if (flag === '--tester-socket') { testerSocket = true; continue; } + // F3: `mosaic console start` supervises this process and relaunches it on exit 75. + if (flag === '--supervised') { supervised = true; continue; } if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket'); const value = args.shift(); if (!value) throw new Error(`missing value for ${flag}`); @@ -165,11 +169,19 @@ try { }; }, }) : null; - let server; + let server, testerServer = null; + // F3: the restart route closes every listener and connection, then exits RESTART_EXIT for the + // supervisor to relaunch. Without --supervised the route answers not-supervised. + const restart = supervised ? () => { + sso?.close(); testerServer?.close(); + setTimeout(() => process.exit(RESTART_EXIT), 2000).unref(); + server.close(() => process.exit(RESTART_EXIT)); + server.closeAllConnections(); + } : null; + const trail = system ? actionTrail(join(system.dataRoot, 'console')) : null; try { - server = await startServer({ ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null }); + server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null }); } catch (err) { sso?.close(); throw err; } - let testerServer = null; if (testerSocket) { const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI }); const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') }; diff --git a/packages/webui/src/console-restart-http.mjs b/packages/webui/src/console-restart-http.mjs new file mode 100644 index 000000000..7c1a719f2 --- /dev/null +++ b/packages/webui/src/console-restart-http.mjs @@ -0,0 +1,39 @@ +// F3: POST /api/console/restart. The owner restarts the Console from Settings. +// +// The route table's hook fence has already required a signed-in session on the page's own name, +// the exact Origin and Sec-Fetch-Site: same-origin. This handler adds the owner, --send, a JSON +// `{}` body and the trail. The Console then closes and exits RESTART_EXIT, and the supervisor in +// `mosaic console start` relaunches it on the same arguments and address. Org buses are separate +// processes and keep running. A Console with no supervisor answers not-supervised and stays up. +import { actor } from './action-trail.mjs'; + +const json = (res, status, body) => { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + res.end(JSON.stringify(body)); +}; +const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null); + +async function emptyBody(req) { + if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(one(req, 'content-type') ?? '')) return 'JSON body required'; + let text = ''; + for await (const chunk of req) { text += chunk; if (text.length > 64) return 'body too large'; } + let value; + try { value = JSON.parse(text); } catch { return 'invalid JSON'; } + return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0 ? null : 'the body is {}'; +} + +export async function consoleRestartHTTP(req, res, { session = null, owner = false, send = false, restart = null, trail = null, boot, log = () => {} }) { + if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); + if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console is the owner\'s' }); + if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' }); + const invalid = await emptyBody(req); + if (invalid) return json(res, invalid === 'JSON body required' ? 415 : 400, { error: 'invalid-request', message: invalid }); + if (!restart) return json(res, 409, { error: 'not-supervised', message: 'This Console was not started in the foreground by mosaic console start, so nothing would start it again. Restart it where it was started.' }); + if (!trail) return json(res, 503, { error: 'trail-unavailable', message: 'this Console has no action trail, so it does not restart itself' }); + try { trail({ by: actor(session), action: 'console.restart', outcome: 'restarting', boot }); } + catch { return json(res, 500, { error: 'trail-unavailable', message: 'the action trail could not be written, so the Console did not restart' }); } + log('console: restart requested by the owner'); + // The answer reaches the page first; then every listener and connection closes. + res.once('finish', () => restart()); + return json(res, 202, { outcome: 'restarting', boot }); +} diff --git a/packages/webui/src/org-admin.mjs b/packages/webui/src/org-admin.mjs index 8400219fa..d92bedd02 100644 --- a/packages/webui/src/org-admin.mjs +++ b/packages/webui/src/org-admin.mjs @@ -16,6 +16,7 @@ // POST /api/orgs/ID/setup/queue C2 queueInit mosaic org queue init // POST /api/orgs/ID/setup/project/P C2 projectRepoInit mosaic org project repo init // 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) // // GET /api/orgs (the list) and other /api/orgs/ID/... routes are S5's; this // handler returns false for them. @@ -25,12 +26,13 @@ import { lstatSync } from 'node:fs'; import { checkProject, initProject, projectRepo, recordContext } from '../../project/src/index.mjs'; import { refuseInsideAgent } from '../../cli/src/transport.mjs'; import { join } from 'node:path'; +import { actor } from './action-trail.mjs'; 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)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/; +const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart)(?:\/(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. @@ -47,6 +49,7 @@ export function verbFor(step, id, opts = {}) { if (step === 'delete') return `mosaic org delete ${id} --confirm ${id} --if-revision ${opts.revision}`; 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}`; throw new Error(`unknown step ${step}`); } @@ -295,7 +298,8 @@ export function createOrgAdmin({ service, registry = null, bus = null, context } // The handler. Returns true when it answered. `owner` is the signed-in owner // session (provider, iss, sub) or null; `send` says --send is on; `origin` is // the page's own origin for the write fence. -export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {} }) { +// `trail` (F3) records each bus restart: who, when, which organization and the outcome. +export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {}, trail = null }) { const org = path.match(ROUTE); const top = ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST'); if (!org && !top) return false; @@ -311,7 +315,7 @@ export async function orgAdminHTTP(req, res, { path, search = '', admin, session const principal = { provider: session.provider, iss: session.iss, sub: session.sub }; if (![principal.provider, principal.iss, principal.sub].every(v => typeof v === 'string' && v)) { json(res, 403, { error: 'owner-only' }); return true; } try { - await route(req, res, { path, search, org, admin, principal, write, log }); + await route(req, res, { path, search, org, admin, principal, write, log, trail, by: actor(session) }); } catch (error) { if (error?.status) json(res, error.status, { error: error.message, ...(error.detail ? { message: error.detail } : {}), ...(error.errors ? { errors: error.errors } : {}) }); else { log(`org-admin: ${path}: ${error?.code ?? error?.name ?? 'error'}`); json(res, 500, { error: 'internal' }); } @@ -385,7 +389,7 @@ const gitOptions = (body, res, step) => { return null; }; -async function route(req, res, { path, search, org, admin, principal, write, log }) { +async function route(req, res, { path, search, org, admin, principal, write, log, trail = null, by = null }) { if (path === '/api/org-presets' && !write) { if (new URLSearchParams(search).size) return json(res, 422, { error: 'invalid', message: 'unexpected query parameter' }); const service = await need(admin, 'service', res, 'presets'); if (!service) return; @@ -508,6 +512,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log if (kind === 'setup' && !sub && !write) { 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 !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' }); // Every setup write reads the step back first and never overwrites. @@ -553,3 +558,44 @@ async function route(req, res, { path, search, org, admin, principal, write, log } return json(res, 404, { error: 'not found' }); } + +// F3 (REQ-WEB-3): one control restarts an organization's bus and answers with a done receipt. A +// running host restarts on the options it was started with (C3 busRestart keeps --dev-local-owner); +// a stopped one starts as setup starts it. The trail line is written before anything changes, and +// a trail that cannot be written refuses the restart. Stale host state is never cleared here. +async function restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) { + const step = 'bus-restart'; + 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 (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not restart buses' }); + 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.restart', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus restart: 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.restart', 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 restarted' }); } + const note = outcome => { try { trail({ by, action: 'bus.restart', org: id, outcome }); } catch { log(`org-admin: ${id} bus restart: trail write failed after ${outcome}`); } }; + let receipt; + try { + receipt = running ? await bus.busRestart(ctx, id) : await bus.busStart(ctx, id, { detach: true, devLocalOwner: by?.provider === 'local-dev', pm: false }); + } catch (e) { + note('refused'); + if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true }); + return json(res, 409, { error: /^bus failed to start\b/.test(e.message) ? 'bus-failed' : 'bus-refused', step, message: plainMessage(ctx, e.message) }); + } + note('done'); + log(`org-admin: ${id} bus ${running ? 'restarted' : 'started'}`); + let healthy = null; + try { healthy = (await bus.busStatus(ctx, id)).healthy; } catch { /* the receipt still stands */ } + return json(res, 200, { outcome: 'done', step, restarted: running, healthy, receipt: forReceipt(ctx, receipt), verb: running ? verbFor('bus-restart', id) : verbFor('bus', id) }); +} diff --git a/packages/webui/src/public/agents.js b/packages/webui/src/public/agents.js index 7e6bea241..d6e00cfc8 100644 --- a/packages/webui/src/public/agents.js +++ b/packages/webui/src/public/agents.js @@ -10,8 +10,12 @@ const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const POLL_MS = 2000, POLL_LIMIT = 90; const UNKNOWN = 'Outcome unknown. Read the agents again before trying; nothing was retried.'; - // The same text bus.js shows for an instances.set answer (bus.js RESTART_TEXT, NO_RESTART_TEXT). - const RESTART_TEXT = 'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.'; + // The same text bus.js shows for an instances.set answer (bus.js restartText(), NO_RESTART_TEXT). + // F3: on an organization Console its owner restarts the bus from the receipt (console-restart.js). + const restartText = () => window.MosaicConsole?.busRestart + ? 'Restart needed: the running bus keeps the roles it loaded at start. Restart the bus to load the change.' + : 'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.'; + const busControl = text => (String(text).includes('Restart needed:') ? window.MosaicConsole?.busButton('Restart bus') ?? '' : ''); const NO_RESTART_TEXT = 'No restart: the launcher reads the change at its next launch check.'; const DONE = { launch: 'Launched', regenerate: 'Regenerated', end: 'Terminated', delete: 'Deleted' }; let ctx = null, view = null, busy = false, receipt = '', confirming = null, pending = null, generation = 0; @@ -30,7 +34,7 @@ const { txt, esc } = c, title = '

Agents

'; if (!v) return `
${title}

The agents could not be read, so there is nothing to act on. Read again to retry.

`; const open=usable(), off=!open||busy||pending?' disabled':'', addOff=!open||busy||pending||c.instanceBusyOps.includes('add')?' disabled':'', changeOff=!open||busy||pending||c.instanceBusyOps.includes('change')?' disabled':'', defs=Object.keys(v.definitions||{}), accepted=defs.filter(d=>v.definitions[d]?.accepted); - const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `

${txt(text)}

`;}; + const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `

${txt(text)}

${busControl(text)}`;}; const option=(name,selected,first)=>`${accepted.map(d=>``).join('')}`; const button=(action,name,label)=>``; const rows=Object.entries(v.instances).map(([name,e])=>{ @@ -45,7 +49,7 @@ const noHire=Array.isArray(v.noHire)?v.noHire:[], retired=Array.isArray(v.retired)?v.retired:[]; const hiring=!c.isTester?`

Hiring

Turn hiring off to stop new agents of a definition.

${defs.map(n=>`
${open?``:`${noHire.includes(n)?'Off':'On'}`}${txt(n)}
`).join('')}
${retired.length?`

Retired names, never reused: ${retired.map(txt).join(', ')}.

`:''}
`:''; const state=pending?`

Request ${txt(pending.request)} to ${txt(pending.action)} ${txt(pending.instance)}: ${txt(pending.state)}.

`:''; - return `
${title}${state}
${rows}
AgentDefinitionLaunchKeptRunningActions

${txt(receipt)}

${!open||c.isTester?instanceStatus('add'):''}
${add}${hiring}

`; + return `
${title}${state}
${rows}
AgentDefinitionLaunchKeptRunningActions

${txt(receipt)}

${busControl(receipt)}${!open||c.isTester?instanceStatus('add'):''}
${add}${hiring}

`; } // One POST. `null` means the page signed out; otherwise the status and the parsed answer. @@ -79,7 +83,7 @@ const { ok, result } = answer; if (!ok) { receipt = refused(result); redraw(); return; } // Row 194 b6 (webby-0094 B1): a veto says whether the reload took, as a create does. - if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}. ${result.restart ? RESTART_TEXT : NO_RESTART_TEXT}`; reread(); return; } + if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}. ${result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); return; } if (!REQUEST_ID.test(result?.request ?? '')) { receipt = UNKNOWN; redraw(); return; } if (action === 'end') delete reasons[name]; pending = { request: result.request, action, instance: name, state: 'open', tries: 0 }; @@ -122,7 +126,7 @@ try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; } busy = false; if (answer === null) return; - if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? RESTART_TEXT : NO_RESTART_TEXT}`; reread(); } + if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); } else { receipt = answer.ok ? UNKNOWN : `${refused(answer.result)} Draft kept.`; redraw(); } } @@ -151,5 +155,78 @@ // A sign-out stops any poll; nothing that was asked is retried. document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; }); - window.MosaicAgents = Object.freeze({ section }); + // F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One + // terminate; then reads of the instances until the run has left (up to the 60 s regenerate + // allows); then one launch. A thread definition is not launched: its next message starts it. + // A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried. + const restarts = Object.create(null); + const RESTART_WAIT = 30; + function restartMarkup(name, thread = false) { + const r = restarts[name]; + return ` ${esc(r?.text ?? '')}`; + } + const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + const paintRestart = name => document.querySelectorAll('.agent-restart').forEach(el => { + const b = el.querySelector('[data-agent-restart]'); + if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes'); + }); + async function send(url, payload) { + let res, result = null; + try { res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), cache: 'no-store', redirect: 'manual' }); } + catch { return { unknown: true }; } + if (res.type === 'opaqueredirect') return { signedOut: true }; + try { result = await res.json(); } catch { return { unknown: true }; } + return { ok: res.ok, result }; + } + // The request's closing read, or a reason the chain stops. + async function closing(request) { + for (let tries = 0; tries < POLL_LIMIT; tries++) { + await new Promise(r => setTimeout(r, POLL_MS)); + let res, r = null; + try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; } + if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' }; + try { r = await res.json(); } catch { continue; } + if (res.ok && r?.request === request && r.state === 'closed') return { done: r }; + } + return { stop: `Request ${request} is still open. Read the agents again later.` }; + } + async function runs(name) { + try { + const res = await fetch('/api/bus/instances', { cache: 'no-store', redirect: 'manual' }); + if (!res.ok || res.type === 'opaqueredirect') return null; + const e = (await res.json())?.rows?.[0]?.instances?.[name]; + return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0; + } catch { return null; } + } + async function restartAgent(name, thread) { + const r = restarts[name] = { busy: true, text: `Terminating ${name}…` }; + const stop = text => { r.busy = false; r.text = text; paintRestart(name); }; + paintRestart(name); + const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' }); + if (asked.signedOut) return stop('Sign in again, then retry.'); + if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result)); + const ended = await closing(asked.result.request); + if (ended.stop) return stop(ended.stop); + if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`); + r.text = `Terminated ${name}. Waiting for its session to end…`; paintRestart(name); + let left = false; + for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name)) === 0; } + if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`); + if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`); + r.text = `Launching ${name}…`; paintRestart(name); + const launch = await send('/api/agent/launch', { instance: name }); + if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.'); + if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`); + const started = await closing(launch.result.request); + if (started.stop) return stop(started.stop); + if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`); + return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`); + } + document.addEventListener('click', e => { + const b = e.target.closest?.('[data-agent-restart]'); + if (!b || b.disabled || restarts[b.dataset.agentRestart]?.busy) return; + restartAgent(b.dataset.agentRestart, b.dataset.agentThread === 'yes'); + }); + + window.MosaicAgents = Object.freeze({ section, restartMarkup, isThread: definition => threadDefinitions.has(definition) }); })(); diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index f495564c8..16ab4cb64 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -490,7 +490,7 @@ // (likely when the bus is down) still refreshes the list, its banner and the Settings Start control. if (window.MosaicOrg) { const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id); - if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; return; } + if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; window.MosaicConsole?.attachBus(box, 'Restart the bus'); return; } } try { const s = await api('/api/bus-status'); @@ -498,6 +498,7 @@ const b = s.boot; box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`)); box.hidden = false; + window.MosaicConsole?.attachBus(box, 'Restart the bus'); } catch { /* no status to show: leave the banner as it was */ } } async function refresh() { diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 05f328ce2..5d7e16edc 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -319,7 +319,7 @@ }); let configRead = Promise.resolve(); - let canSend = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = []; + let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = []; const REPLIES = 50; // the broker's cap function repliesHtml() { if (replies === null) return '

The replies to it could not be read. Read again to retry; mosaic inbox in a terminal lists them.

'; @@ -344,7 +344,7 @@ ${said}`; } async function composer(get) { - if (!canSend) return '

Messaging a role needs a Console started with --send behind the sign-in proxy; this one only reads.

'; + if (!canMessage) return '

Messaging a role needs a Console started with --send behind the sign-in proxy; this one only reads.

'; try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; } if (receipt?.kind === 'sent') { const r = receipt; @@ -612,8 +612,8 @@ let heldOrganizations = { empty: false, invalid: [], error: null }; function heldTable(claims) { const cols = ['Role', 'State', 'Attention', 'Address', 'Holder', 'Held since']; - const rows = claims.map(c => `${txt(c.role)}${stateMark(c.state)}${c.working ? `${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}` : ''}${attention(c)}${c.address ? txt(c.address) : 'no address'}${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `${txt(c.harness)}` : ''}${when(c.at)}${canSend ? `${typeof c.role === 'string' && SAFE.test(c.role) ? `` : ''}` : ''}`).join(''); - return `
${cols.map(h => ``).join('')}${canSend ? '' : ''}${rows}
${h}Message
`; + const rows = claims.map(c => `${txt(c.role)}${stateMark(c.state)}${c.working ? `${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}` : ''}${attention(c)}${c.address ? txt(c.address) : 'no address'}${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `${txt(c.harness)}` : ''}${when(c.at)}${canMessage ? `${typeof c.role === 'string' && SAFE.test(c.role) ? `` : ''}` : ''}`).join(''); + return `
${cols.map(h => ``).join('')}${canMessage ? '' : ''}${rows}
${h}Message
`; } function paintHeld(err, noOrganizations, invalidOrganizations, organizationError) { heldErr = err; @@ -982,7 +982,8 @@ // offered: binding credentials stays a hand edit. When a change needs a bus restart the receipt // says so; the Console never restarts the bus. Drafts survive polling and refusals; nothing retries. let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusyOps=new Set(), instanceReceipt='', instanceReceiptOp=null, instanceReceipts=Object.create(null); - const RESTART_TEXT='Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.'; + // F3: an organization Console's owner restarts the bus from the receipt (console-restart.js). + const restartText=()=>window.MosaicConsole?.busRestart?'Restart needed: the running bus keeps the roles it loaded at start. Restart the bus to load the change.':'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.'; const NO_RESTART_TEXT='No restart: the launcher reads the change at its next launch check.'; function instancesSection(view){ const title='

Role instances

'; @@ -1024,7 +1025,7 @@ try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;} instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false); try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json(); - if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?RESTART_TEXT:NO_RESTART_TEXT}`;} + if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;} else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}} catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);} }); @@ -1035,6 +1036,21 @@ // choosing none for all three. Save sends the changed rows through routes.set. Drafts survive // polling and refusals; nothing retries. let routesView=null, routesDraft=null, routesBusy=false, routesReceipt='', routesEditVersion=0; + // F3: after a save, how the change reaches each saved agent: a session keeps the route it started + // with, so a running one moves when it ends (a thread when it goes idle) or when the owner + // restarts it here (agents.js restartMarkup). Read from the instances after the save. + let routesApply=null; + async function routesApplyRead(names){ + let view=null; + try{const res=await fetch('/api/bus/instances',{cache:'no-store',redirect:'manual'});if(res.ok&&res.type!=='opaqueredirect')view=(await res.json())?.rows?.[0]??null;}catch{/* said below */} + return names.map(name=>{const e=view?.instances?.[name]; + if(!view||!e)return {name,text:'Could not read whether it is running. A running session keeps its old route until it ends.'}; + const running=Array.isArray(e.running)?e.running.length:0, thread=!!window.MosaicAgents?.isThread(e.definition); + if(e.launcher)return {name,text:'The launcher. It takes the new route at its next start.'}; + if(!running)return {name,text:'Not running. Its next start uses the new route.'}; + return {name,thread,restart:true,text:thread?'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.':'Running. Its session keeps the old route until it ends. Restart it to move it now.'};}); + } + const routesApplyMarkup=()=>routesApply?.length?`
    ${routesApply.map(a=>`
  • ${txt(a.name)}: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}
  • `).join('')}
`:''; let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false; function readHarnesses(){ if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true; @@ -1070,7 +1086,7 @@ const providerControl=canSend&&!isTester?`

`:''; const harnessControl=canSend&&!isTester?`
Harnesses${harnessSettings ? [...new Set([...harnessSettings.available.harness,...(harnessSettings.plugins.harness??[])])].map(name=>``).join('')+` ` : '

Harnesses unavailable.

'}

${txt(harnessReceipt)}

`:''; const off=v.harnesses.length?'':'

No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. Switch one on under Harnesses.

'; - return `
${title}${catalogueInfo}${providerControl}${harnessControl}${off}
${ROUTE_CELLS.map(([,l])=>``).join('')}${rows}
AgentPrimaryFallback
${l.replace('Fallback ','')}

${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}

${txt(routesReceipt)}

`; + return `
${title}${catalogueInfo}${providerControl}${harnessControl}${off}
${ROUTE_CELLS.map(([,l])=>``).join('')}${rows}
AgentPrimaryFallback
${l.replace('Fallback ','')}

${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}

${txt(routesReceipt)}

${routesApplyMarkup()}
`; } function routesFields(form){const draft=Object.fromEntries(Object.keys(routesView.instances).map(name=>[name,routeCells(routesView.instances[name])]));for(const el of form.querySelectorAll('[data-route]')){const [name,key]=JSON.parse(el.dataset.route);if(draft[name])draft[name][key]=el.value;}return draft;} // The changed rows only. A fallback with all three cells empty is cleared; one with some set goes @@ -1112,13 +1128,13 @@ }catch(error){status.textContent=error.message;button.disabled=false;} }); document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');}); - document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';last.delete('routes');render(true);}); + document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);}); document.addEventListener('submit',async e=>{ if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return; routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;} - routesBusy=true;routesReceipt='';render(false); + routesBusy=true;routesReceipt='';routesApply=null;render(false); try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json(); - if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;} + if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));} else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}} catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);} }); @@ -1148,8 +1164,8 @@ }); }); // Organizations is writable when the server allows org setup, which it does in organization mode where send is off. - const readonlyPage = page => !['appearance','business'].includes(page) && !(page === 'organizations' && orgSetup) && !canSend && !isTester; - const settingsPages = [['appearance','Appearance'],['business',window.MosaicOrg ? 'Organization' : 'Business'],['limits','Launch limits'],['organizations','Organizations'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['about','About']]; + const readonlyPage = page => !['appearance','business','console'].includes(page) && !(page === 'organizations' && orgSetup) && !canSend && !isTester; + const settingsPages = [['appearance','Appearance'],['business',window.MosaicOrg ? 'Organization' : 'Business'],['limits','Launch limits'],['organizations','Organizations'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['console','Console'],['about','About']]; function markSettingsDraft(page,dirty){const link=document.querySelector(`#settings-nav a[href="#/settings/${page}"]`), dot='unsaved changes';if(link && dirty && !link.querySelector('.settings-dot'))link.insertAdjacentHTML('beforeend',dot);const button=$('settings-disclosure');if(dirty && link?.getAttribute('aria-current')==='page' && button && !button.querySelector('.settings-dot'))button.querySelector('strong').insertAdjacentHTML('beforeend',dot);} function settingsShell(page, body, helper='') { const pages=isTester ? [['appearance','Appearance'],['business','Business'],['routing','Routing'],['agents','Agents'],['names','Seat names']] : settingsPages; @@ -1164,6 +1180,8 @@ if (page==='appearance') return settingsShell(page,appearance(),'These choices apply in this browser only.'); if (page==='organizations') { await configRead; return settingsShell(page,window.MosaicOrgCreate.markup,'Create an organization from the template, edit it, and finish its setup. Each step names the command it equals.'); } if (page==='model-proxy') { await configRead; return settingsShell(page,window.MosaicProxy.markup,'Accounts used by this instance, their priority, and the client keys assigned to agents.'); } + // F3: Settings > Console (console-restart.js). The server checks the owner, Origin and trail. + if (page==='console') { await configRead; await window.MosaicConsole?.ready; return settingsShell(page,window.MosaicConsole?.settingsMarkup()??'

The Console controls did not load. Reload the page to try again.

','Restart this Console on the same address and configuration.'); } const s = await get.api('settings'), n = s.notifier || {}; let usage, names = null, instances = null, routes = null, limitRows = [], limitsRefused = false; if (page==='tokens') { try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } } @@ -1715,10 +1733,14 @@ }); // Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's // Message buttons are drawn from the roles already read. - configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => { + // F3: `settingsSend` lets the owner of an organization Console save Settings on the selected + // organization's bus; messaging a role still follows `send` (canMessage) and `chatSend`. + // A bus restart reads the config again and sends it here as a mosaic-config event. + const applyConfig = c => { isTester = c?.tester === true; canEdit = !isTester && c?.edit === true; - canSend = !isTester && c?.send === true; + canSend = !isTester && (c?.send === true || c?.settingsSend === true); + canMessage = !isTester && c?.send === true; chatSend = !isTester && c?.chatSend === true; orgSetup = !isTester && c?.orgSetup === true; window.MosaicOrgBusStopped = !!window.MosaicOrg && c?.devOwner === true && orgSetup && c?.chatSend === false; @@ -1726,7 +1748,7 @@ if (orgSetup && !$('org-new-link')) { const a = document.createElement('a'); a.id = 'org-new-link'; a.href = NEW_ORG; a.setAttribute('role', 'menuitem'); a.textContent = 'New organization'; document.querySelector('#avatar-menu > a[href="#/settings"]')?.after(a); } ssoOn = c?.sso === true; canDecide = !isTester && c?.decide === true; - if (isTester) { + if (isTester && !$('tester-banner')) { const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref'; banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.'; $('main').prepend(banner); @@ -1734,7 +1756,9 @@ if (!canSend && !isTester && !ssoOn) return; const path = routeOf().split('?')[0]; if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr); - }).catch(() => {}); + }; + configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {}); + document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') applyConfig(e.detail); }); // This context comes from the conversations renderer after its authorized read. let projectContext = null; document.addEventListener('mosaic-project-context', event => { diff --git a/packages/webui/src/public/console-restart.js b/packages/webui/src/public/console-restart.js new file mode 100644 index 000000000..76584539f --- /dev/null +++ b/packages/webui/src/public/console-restart.js @@ -0,0 +1,172 @@ +/* F3: restart the Console from Settings, restart an organization's bus, and bring an open page + back after any Console restart. The controls are the owner's: the server checks the human, the + exact Origin and Fetch Metadata, and writes the action trail. This file only asks and reports. + + Reconnect: /api/config carries `boot`, an id the Console picks at each start. When a same-origin + request fails at the network level, or the owner asks for a restart, the page polls /api/config. + The same boot means the Console never went away, and the banner goes. A new boot means a new + Console process: the page reloads itself, so its scripts, icons and session match the new one. + A page with an unsaved draft does not reload; it says the Console is back and keeps the draft. */ +(() => { + 'use strict'; + const nativeFetch = window.fetch.bind(window); + const $ = id => document.getElementById(id); + const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + 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; + + const readConfig = () => nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' }) + .then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null)); + const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null); + + const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() + || document.querySelector('.settings-dot') + || [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim())); + + function banner(text, action = '') { + let box = $('console-reconnect'); + if (!box) { + box = document.createElement('div'); box.id = 'console-reconnect'; box.className = 'banner ref console-reconnect'; + box.setAttribute('role', 'status'); box.setAttribute('aria-live', 'polite'); + ($('main') ?? document.body).prepend(box); + } + box.innerHTML = `${esc(text)}${action}`; + box.hidden = false; + } + const hideBanner = () => { const box = $('console-reconnect'); if (box) box.hidden = true; }; + + // One poll loop at a time. It ends when the Console answers; it never sends anything but a read. + async function reconnect(reason) { + if (reconnecting) return; + reconnecting = true; + const started = Date.now(); + banner(reason === 'restart' ? 'Console restarting. This page reconnects when it is back.' : 'The Console is not answering. Reconnecting…'); + for (;;) { + const waited = Date.now() - started; + await sleep(waited < 120000 ? 1000 : 5000); + if (waited >= 120000) banner('The Console has not come back after two minutes. Check the terminal or service that started it. This page keeps trying.'); + let c; + try { + const r = await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' }); + // A sign-in redirect: the new Console wants a sign-in, which the reload shows. + if (r.type === 'opaqueredirect') { location.reload(); return; } + if (!r.ok) continue; + c = await r.json(); + } catch { continue; } + // The process that took the restart can still answer before it closes. + if (boot !== null && c?.boot === boot) { if (planned) continue; reconnecting = false; hideBanner(); return; } + reconnecting = false; planned = false; + if (dirty()) { + boot = typeof c?.boot === 'string' ? c.boot : boot; config = c; + banner('The Console is back. Your unsaved draft is kept on this page.', ' '); + return; + } + location.reload(); + return; + } + } + document.addEventListener('click', e => { if (e.target.id === 'console-reconnect-reload') location.reload(); }); + + // A same-origin request that fails at the network level (the Console went away) starts the poll. + // An aborted request is an AbortError, not a TypeError, and starts nothing. + const sameOrigin = input => { try { return new URL(typeof input === 'string' ? input : input.url, location.href).origin === location.origin; } catch { return false; } }; + window.fetch = (input, init) => nativeFetch(input, init).catch(err => { + if (err?.name === 'TypeError' && sameOrigin(input) && boot !== null) reconnect('lost'); + throw err; + }); + + // Settings > Console. + function settingsMarkup() { + if (!config) return '

The Console settings could not be read. Reload the page to try again.

'; + if (config.tester === true || config.user === true) return '

Restarting the Console is the owner\'s.

'; + const signOut = config.sso === true && config.devOwner !== true ? ' Sign-in sessions are held in memory, so everyone signs in again after it.' : ''; + let body; + if (config.consoleRestart === true) { + const busy = restartState !== 'idle' ? ' disabled' : ''; + const confirm = restartState === 'confirm' + ? '

Restart the Console now?

' : ''; + body = `

The Console stops, and mosaic console start starts it again on the same address, arguments and configuration. Organization buses are separate processes and keep running. Open pages show the restart and reconnect when it is back.${signOut}

Each restart is recorded in the Console's action trail with your identity.

${confirm}`; + } else if (config.orgSetup !== true && config.send !== true) { + body = '

This Console is read-only, so it does not restart itself. Restart it where it was started.

'; + } else { + body = '

This Console was not started in the foreground by mosaic console start, so nothing would start it again. Restart it where it was started: in its terminal, or with systemctl --user restart and its unit.

'; + } + return `

Restart the Console

${body}

${esc(restartReceipt)}

`; + } + const repaint = () => { const panel = $('console-restart-title')?.closest('section'); if (panel) panel.outerHTML = settingsMarkup(); }; + + async function restartConsole() { + restartState = 'sending'; restartReceipt = 'Asking the Console to restart…'; repaint(); + let response, answer = null; + try { response = await nativeFetch('/api/console/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' }); } + catch { restartState = 'idle'; restartReceipt = 'Outcome unknown. If the Console restarted, this page reconnects by itself.'; repaint(); reconnect('lost'); return; } + try { answer = await response.json(); } catch { /* an unreadable answer is refused below */ } + if (response.status === 202 && answer?.outcome === 'restarting') { + planned = true; if (typeof answer.boot === 'string') boot = answer.boot; + restartState = 'idle'; restartReceipt = `Restart requested at ${clock()}.`; repaint(); + reconnect('restart'); + return; + } + restartState = 'idle'; + restartReceipt = response.type === 'opaqueredirect' ? 'Sign in again, then retry.' : (answer?.message || 'The restart was refused. Nothing was retried.'); + repaint(); + } + document.addEventListener('click', e => { + if (e.target.id === 'console-restart' && config?.consoleRestart === true) { restartState = 'confirm'; restartReceipt = ''; repaint(); $('console-restart-go')?.focus(); } + else if (e.target.id === 'console-restart-cancel') { restartState = 'idle'; repaint(); $('console-restart')?.focus(); } + 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. + const busAllowed = () => !!window.MosaicOrg && config?.orgSetup === true; + function busButton(label = 'Restart bus') { + if (!busAllowed()) return ''; + return ` ${esc(busReceipt)}`; + } + async function attachBus(box, label) { + await ready; + // The bus is unavailable again: an earlier receipt no longer describes it. + if (box && !box.hidden) $('bus-restart-done')?.remove(); + if (!box || box.querySelector('[data-bus-restart]') || !busAllowed()) return; + 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); + }); + document.addEventListener('click', async e => { + const button = e.target.closest?.('[data-bus-restart]'); + if (!button || button.disabled || busBusy || !busAllowed()) return; + const id = window.MosaicOrg.id; + busBusy = true; busReceipt = ''; paintBus(); + 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.'); + const body = await response.json(); + if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-restart') throw new Error(body.message || 'The restart was refused. Nothing was retried.'); + busReceipt = `${body.restarted ? 'Bus restarted' : 'Bus started'} at ${clock()}. ${body.healthy ? 'It answers.' : `It has not answered yet. Check it with ${body.verb.replace('restart', 'status')}.`}`; + // The page's send flags were read while the bus was in its old state; read them again. This + // read goes through the page's fetch, which display.js scopes to the selected organization. + // The organization list goes first, so the view the config event redraws shows the bus as it is now. + 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 })); } + // A healthy bus ends the "Bus unavailable" banner. Its receipt stays on the page in its place. + const down = $('bus-down'); + if (down && body.healthy) { + if (!down.hidden) { + $('bus-restart-done')?.remove(); + const done = document.createElement('p'); done.id = 'bus-restart-done'; done.className = 'banner'; done.setAttribute('role', 'status'); + done.textContent = busReceipt; down.after(done); + } + down.hidden = true; + } + } 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/display.js b/packages/webui/src/public/display.js index fa5858ca9..ea642cbaa 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -65,7 +65,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( const org = rows.find(row => row.id === decodeURIComponent(id) && !row.invalid); const mark = document.querySelector('.wordmark'); if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); } - if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; } } + if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } } return rows; }; let list = readList().then(paintHeader); @@ -240,13 +240,23 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo }); const cleanup = () => { clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; }; window.addEventListener('pagehide', cleanup, {once:true}); + let devOwner = false; fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => { - canWrite = config.send === true && config.tester !== true && config.user !== true; + // F3: an organization's settings writes go with settingsSend (the org Console has no global send). + canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true; + devOwner = config.devOwner === true; document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; }); $('proxy-readonly').hidden = canWrite; if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el=>el.hidden=true); return refresh(); - }).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true); } }); + }).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true); + // F3: on a dev instance, say plainly why this page cannot set the proxy up and what does. + // Setup takes a management key typed twice at a terminal and writes the proxy's credentials; + // this page never handles that key. Account sign-in (a provider's own OAuth) then runs here. + if (devOwner) { const org = window.MosaicOrg?.id, note = document.createElement('div'); note.id = 'proxy-dev-setup'; note.className = 'panel'; + note.innerHTML = '

Set up the model proxy

Nothing else on this dev Console asks for a sign-in. The proxy is the exception: its setup asks for a management key, typed twice, and writes the proxy\'s credentials. This page never handles that key, so setup runs once in a terminal on this machine:

Then refresh this page. Signing in to a Claude or Codex account is the provider\'s own sign-in, which cannot be skipped. It runs here, under Sign in to an account, with one button.

'; + note.querySelector('code').textContent = `mosaic proxy fetch\nmosaic proxy init${org ? ` --org ${org}` : ''}`; + $('proxy-message').after(note); } } }); return cleanup; } window.MosaicProxy={markup,mount}; diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index f8f7286d9..8c522cd6a 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -7,7 +7,7 @@ - + diff --git a/packages/webui/src/public/proxy.js b/packages/webui/src/public/proxy.js index 2507bef4f..e08a2e38d 100644 --- a/packages/webui/src/public/proxy.js +++ b/packages/webui/src/public/proxy.js @@ -100,7 +100,7 @@ }); window.addEventListener('pagehide', () => { endLogin(); $('proxy-apikey').elements.key.value = ''; }); fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => { - canWrite = config.send === true && config.tester !== true && config.user !== true; + canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true; document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; }); $('proxy-readonly').hidden = canWrite; return refresh(); diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index 192c07332..2377a26f3 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -93,6 +93,16 @@ table.s1-table td code{font-size:12.5px;white-space:nowrap} .banner.ref{border-color:color-mix(in srgb,var(--warning) 50%,var(--line));background:color-mix(in srgb,var(--warning) 9%,var(--surface))} .banner b,.banner strong{color:var(--strong)} .banner code{overflow-wrap:anywhere} +/* F3: the Console restart banner stays in view while the page waits for the Console, and the bus restart control sits inline in the receipt or banner that asks for it. */ +.console-reconnect{position:sticky;top:0;z-index:30;align-items:center} +.console-reconnect>span{flex:1 1 220px;min-width:0;overflow-wrap:anywhere} +.bus-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0} +.bus-restart [role=status]:empty{display:none} +.routes-apply{margin:12px 0 0;padding-left:18px;display:grid;gap:8px} +.routes-apply li{overflow-wrap:anywhere} +.agent-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0} +.agent-restart [role=status]:empty{display:none} +#proxy-dev-setup pre{overflow-x:auto;max-width:100%} /* Stream S4: which organization this is, in words. Colour (the theme) is never the only cue; the strip appears whenever the theme is not the built-in one. */ .org-cue{margin:0 0 12px;padding:6px 10px;border:1px solid var(--line);border-left:4px solid var(--action);border-radius:var(--r-table);background:var(--raised);color:var(--text);font-size:13px;overflow-wrap:anywhere}.skel-rows{display:grid;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)} .skel{display:block;height:12px;border-radius:4px;background:var(--raised);animation:skel 1.4s ease-in-out infinite} diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index d8713a3d9..a61e44ff8 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -2,7 +2,7 @@ import { proxyHTTP } from './proxy-http.mjs'; import { createServer } from 'node:http'; import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs'; import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs'; -import { timingSafeEqual } from 'node:crypto'; +import { timingSafeEqual, randomUUID } from 'node:crypto'; import { resolve } from 'node:path'; import { isIP } from 'node:net'; import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs'; @@ -19,6 +19,8 @@ import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } fro import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs'; import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs'; import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs'; +import { consoleRestartHTTP } from './console-restart-http.mjs'; +import { actor } from './action-trail.mjs'; import { devHostName } from '../../cli/src/dev-local-owner.mjs'; export const DEFAULT_BOARD = 'http://127.0.0.1:7331'; @@ -55,7 +57,7 @@ const files = new Map([ ['/', ['index.html', 'text/html; charset=utf-8']], ['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']], ['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']], - ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), + ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), ['/files.js', ['files.js', 'text/javascript; charset=utf-8']], ['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']], ['/projects.css', ['projects.css', 'text/css; charset=utf-8']], @@ -443,10 +445,11 @@ const reasonFor = (code, done) => { return `A skill this role needs isn't turned on: scripts/skill.sh activate ${done.skill}`; return Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : Object.hasOwn(AGENT_REASONS, code) ? AGENT_REASONS[code] : typeof code === 'string' && code.startsWith('proxy-') ? 'Check the model proxy configuration and account status' : String(code ?? 'unknown'); }; -async function instanceSend(req, res, { instancesWrite, preview, log }) { - let instance = null; +async function instanceSend(req, res, { instancesWrite, preview, log, note = null }) { + let instance = null, op = null; // The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged. - const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`); + // F3 review P3: the owner's instance changes also go to the action trail, like routes and agents. + const line = outcome => { log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`); note?.({ action: 'instances.set', op, instance: typeof instance === 'string' ? instance.slice(0, 200) : null, outcome }); }; const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); }; if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false }); if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin'); @@ -462,7 +465,7 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) { (has('vars') && (!value.vars || typeof value.vars !== 'object' || Array.isArray(value.vars))) || (has('launch') && typeof value.launch !== 'boolean')) { return refuse(400, 'invalid-request', 'this takes a revision, an instance, op add or change and a definition, vars or launch, or op delete alone, nothing else'); } - ({ instance } = value); + ({ instance, op } = value); // D4.1: the tester never deletes. The bus refuses it too (broker tester-refused); this makes no call. if (deleting && preview.tester) return refuse(403, 'tester-refused', 'the tester listener doesn\'t delete. Nothing changed.'); let result; @@ -499,10 +502,13 @@ const AGENT_ROUTES = { '/api/veto': { kind: 'veto', verb: 'veto.set', keys: ['revision', 'op', 'name'], tester: false, done: r => /^[a-f0-9]{64}$/.test(r?.revision ?? '') && typeof r.restart === 'boolean' && r.instances && typeof r.instances === 'object' && !Array.isArray(r.instances) }, }; -async function agentSend(req, res, { route, agentWrite, preview, log }) { +// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console). +const ORG_OWNER_WRITE = path => path === '/api/settings' || path === '/api/instances' || path === '/api/routes' + || Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/'); +async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) { let target = null; // The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged. - const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`); + const line = outcome => { log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`); note?.({ action: route.verb, instance: typeof target === 'string' ? target.slice(0, 200) : null, outcome }); }; const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); }; if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false }); if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin'); @@ -562,10 +568,11 @@ const ROUTE_REASONS = { }; const routeObject = o => o && typeof o === 'object' && !Array.isArray(o); const routesAnswer = v => /^[a-f0-9]{64}$/.test(v?.revision ?? '') && Array.isArray(v.harnesses) && Array.isArray(v.models) && routeObject(v.levels) && routeObject(v.instances); -async function routesSend(req, res, { routesWrite, preview, log }) { +// `note` (F3) adds the owner's change to the Console's action trail; best effort, after the log line. +async function routesSend(req, res, { routesWrite, preview, log, note = null }) { // Instance names only; routes are never logged. let names = []; - const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`); + const line = outcome => { log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`); note?.({ action: 'routes.set', instances: names, outcome }); }; const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); }; if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false }); if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin'); @@ -763,7 +770,7 @@ async function libraryStatusRead(res, read, search) { // `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for // tests; the field table is otherwise the committed packages/tasks/fields.json. // `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request. -export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined } = {}) { +export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID() } = {}) { // org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host // names (without a port) a remote browser may use; only the dev owner takes them, and only with // them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback. @@ -790,11 +797,14 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF const registered = routeTable(); 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 }); - for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) { + 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 method of ['GET', 'POST']) registered.add(method, path, adminRoute); } registered.add('POST', '/api/orgs', adminRoute); + // F3: the owner restarts the Console (console-restart-http.mjs). `restart` is set only under the + // supervisor; `boot` names this process so an open page can tell when the new one answers. + registered.add('POST', '/api/console/restart', (req, res, context) => consoleRestartHTTP(req, res, { ...context, restart, trail, boot, log })); const upstream = boardURL(board); // `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session // user's own transport (console-bus.mjs). Only a signed-in listener has user sessions. @@ -942,7 +952,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, upstream = null } = selected); } - if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path))) + // F3: the owner's settings writes reach the selected organization's bus too (limits, routing, + // agents, assistant, instances, model proxy). The routes below keep their own checks. + if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path)) + && !(orgId !== null && !tester && !granted && named && ORG_OWNER_WRITE(path))) return json(res, 403, { error: 'unavailable', sent: false }); } if (hook) { @@ -953,6 +966,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF return hook.handler(req, res, { path: originalPath, search, session: ssoSession, orgId, origin, owner: !tester && !granted && named && sso !== null, send: owner.send !== null }); } const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer; + // F3: the owner's routing and agent changes go to the action trail too (who, when, what). + const ownerNote = !tester && !granted && ssoSession && trail ? entry => { + try { trail({ by: actor(ssoSession), ...(orgId !== null ? { org: orgId } : {}), ...entry }); } + catch { log(`${new Date().toISOString()} trail write failed for ${entry.action}`); } + } : null; // P4 page and verbs are owner-only, including reads and static page assets. // This runs after owner sign-in and the granted-user fence, before generic // tester writes or static-file serving. No localhost preview grants access. @@ -1008,13 +1026,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (path === '/api/instances') { if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); if ((!tester && (!send || !named)) || !instancesWrite) return json(res, 403, { error: 'read-only', sent: false }); - return instanceSend(req, res, { instancesWrite, preview: writer, log }); + return instanceSend(req, res, { instancesWrite, preview: writer, log, note: ownerNote }); } // Row 194 (#1692, D7): the Agents section. No route gets a --send exception. if (Object.hasOwn(AGENT_ROUTES, path)) { if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); if ((!tester && (!send || !named)) || (tester && !AGENT_ROUTES[path].tester) || !agentWrite) return json(res, 403, { error: 'read-only', sent: false }); - return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log }); + return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log, note: ownerNote }); } if (path === '/api/agent/request') { if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' }); @@ -1024,7 +1042,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (path === '/api/routes') { if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false }); - return routesSend(req, res, { routesWrite, preview: writer, log }); + return routesSend(req, res, { routesWrite, preview: writer, log, note: ownerNote }); } if (path === '/api/library/revert') { if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); @@ -1138,7 +1156,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // `send`: the page shows the composer only where a send can pass (the public name, --send on). // `edit`: the editor shows Save only where a save can pass, the same test as the route's. // `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged. - if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy } : {}), decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named }); + if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && !granted && named && sso !== null && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named }); if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) }); if (path === '/favicon.ico') { res.writeHead(204); return res.end(); } const file = files.get(path); diff --git a/packages/webui/tests/agents.test.mjs b/packages/webui/tests/agents.test.mjs index aa82b0fd7..d5e9ceed8 100644 --- a/packages/webui/tests/agents.test.mjs +++ b/packages/webui/tests/agents.test.mjs @@ -218,7 +218,8 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once const s = await setup(t); const js = await get(s, '/agents.js'); assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/); - assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/); + // F3 adds the agent restart receipt and the thread test that Settings > Routing uses. + assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section, restartMarkup, isThread: definition => threadDefinitions\.has\(definition\) \}\)/); assert.match(js.text, /Agent actions are unavailable here\. Use mosaic org instance from the instance CLI\./); const html = await get(s, '/'); assert.equal(html.text.match(/