feat(webui): F3 self-service restarts and owner settings writes on an org (#1873) #1884
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -20,6 +20,8 @@ baseline CLI.
|
||||
| Recover a stopped bus with stale host state | `mosaic bus recover <id>` | CLI only |
|
||||
| Inspect or clear a dead lifecycle lock | `mosaic org lock show <id>` / `mosaic org lock clear <id>` | CLI only |
|
||||
| Start, stop or restart a bus | `mosaic bus start <id>` / `mosaic bus stop <id>` / `mosaic bus restart <id>` | S6 integration hook |
|
||||
| Restart (or first start) an organization's bus | `mosaic bus restart <id>`, or `mosaic bus start <id>` 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 <name> --repo <project> --org <id>` | Enabled |
|
||||
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
|
||||
| Open a thread | `mosaic thread open <thread> --org <id>` | 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 <id> <choice> --org <id>` | 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
|
||||
`<dataRoot>/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
|
||||
|
||||
@@ -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 <dataRoot>/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); }
|
||||
};
|
||||
}
|
||||
@@ -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') };
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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 = '<h2 id="agents-title" class="sr-only">Agents</h2>';
|
||||
if (!v) return `<section class="panel" id="agents-section">${title}<p class="small warn">The agents could not be read, so there is nothing to act on. Read again to retry.</p></section>`;
|
||||
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 `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>`;};
|
||||
const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>${busControl(text)}`;};
|
||||
const option=(name,selected,first)=>`<option value="">${first}</option>${accepted.map(d=>`<option value="${esc(d)}"${d===selected?' selected':''}>${txt(d)}</option>`).join('')}`;
|
||||
const button=(action,name,label)=>`<button type="button"${['keep','unkeep'].includes(action)?' role="menuitem"':''} data-agent-action="${esc(action)}" data-agent-name="${esc(name)}"${off}>${txt(label)}</button>`;
|
||||
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?`<section class="panel"><h3>Hiring</h3><p>Turn hiring off to stop new agents of a definition.</p><div class="hiring-grid">${defs.map(n=>`<div>${open?`<button type="button" role="switch" aria-label="Hiring allowed for ${esc(n)}" aria-checked="${!noHire.includes(n)}" data-agent-action="${noHire.includes(n)?'hire':'nohire'}" data-agent-name="${esc(n)}"${off}><span class="switch-track"></span></button>`:`<span>${noHire.includes(n)?'Off':'On'}</span>`}<span class="hiring-name">${txt(n)}</span></div>`).join('')}</div>${retired.length?`<p>Retired names, never reused: ${retired.map(txt).join(', ')}.</p>`:''}</section>`:'';
|
||||
const state=pending?`<p id="agents-pending">Request <code>${txt(pending.request)}</code> to ${txt(pending.action)} ${txt(pending.instance)}: ${txt(pending.state)}.</p>`:'';
|
||||
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
|
||||
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${busControl(receipt)}${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
|
||||
}
|
||||
|
||||
// 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 `<span class="agent-restart"><button type="button" class="btn" data-agent-restart="${esc(name)}" data-agent-thread="${thread ? 'yes' : 'no'}"${r?.busy ? ' disabled' : ''}>${r?.busy ? 'Restarting…' : `Restart ${esc(name)} now`}</button> <span role="status" data-agent-restart-status="${esc(name)}">${esc(r?.text ?? '')}</span></span>`;
|
||||
}
|
||||
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) });
|
||||
})();
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic inbox</code> in a terminal lists them.</p>';
|
||||
@@ -344,7 +344,7 @@
|
||||
<button class="btn primary" type="submit" data-send aria-disabled="${sending}">${sending ? 'Sending…' : 'Send'}</button></form>${said}</section>`;
|
||||
}
|
||||
async function composer(get) {
|
||||
if (!canSend) return '<p class="small muted">Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.</p>';
|
||||
if (!canMessage) return '<p class="small muted">Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.</p>';
|
||||
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 => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canSend ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canSend ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canMessage ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canMessage ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
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='<h2 id="instances-title">Role instances</h2>';
|
||||
@@ -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?`<ul class="routes-apply" aria-label="How the change reaches each agent">${routesApply.map(a=>`<li><b>${txt(a.name)}</b>: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}</li>`).join('')}</ul>`:'';
|
||||
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?`<button type="button" id="provider-switch" data-enabled="${v.catalogue?.status!=='disabled'}">${v.catalogue?.status!=='disabled'?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
|
||||
const harnessControl=canSend&&!isTester?`<fieldset class="harness-control"><legend>Harnesses</legend>${harnessSettings ? [...new Set([...harnessSettings.available.harness,...(harnessSettings.plugins.harness??[])])].map(name=>`<label><input type="checkbox" data-harness="${esc(name)}"${(harnessDraft??harnessSettings.plugins.harness??[]).includes(name)?' checked':''}${harnessBusy?' disabled':''}> ${txt(name)}</label>`).join('')+` <button type="button" id="harness-save"${harnessBusy?' disabled':''}>Save harnesses</button>` : '<p>Harnesses unavailable.</p><button type="button" id="harness-read">Read harnesses again</button>'}<p role="status" id="harness-status">${txt(harnessReceipt)}</p></fieldset>`:'';
|
||||
const off=v.harnesses.length?'':'<p class="small warn">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.</p>';
|
||||
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div></form></section>`;
|
||||
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div>${routesApplyMarkup()}</form></section>`;
|
||||
}
|
||||
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='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';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()??'<section class="panel"><p class="small warn">The Console controls did not load. Reload the page to try again.</p></section>','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 => {
|
||||
|
||||
@@ -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 = `<span>${esc(text)}</span>${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.', ' <button type="button" class="btn" id="console-reconnect-reload">Reload now</button>');
|
||||
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 '<section class="panel"><p class="small warn">The Console settings could not be read. Reload the page to try again.</p></section>';
|
||||
if (config.tester === true || config.user === true) return '<section class="panel"><p>Restarting the Console is the owner\'s.</p></section>';
|
||||
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'
|
||||
? '<div class="settings-actions" id="console-restart-confirm"><p>Restart the Console now?</p><button type="button" id="console-restart-go">Restart now</button> <button type="button" id="console-restart-cancel">Cancel</button></div>' : '';
|
||||
body = `<p>The Console stops, and <code>mosaic console start</code> 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}</p><p class="small muted">Each restart is recorded in the Console's action trail with your identity.</p><div class="settings-actions"><button type="button" id="console-restart"${busy}${restartState === 'confirm' ? ' hidden' : ''}>Restart Console</button></div>${confirm}`;
|
||||
} else if (config.orgSetup !== true && config.send !== true) {
|
||||
body = '<p>This Console is read-only, so it does not restart itself. Restart it where it was started.</p>';
|
||||
} else {
|
||||
body = '<p>This Console was not started in the foreground by <code>mosaic console start</code>, so nothing would start it again. Restart it where it was started: in its terminal, or with <code>systemctl --user restart</code> and its unit.</p>';
|
||||
}
|
||||
return `<section class="panel" aria-labelledby="console-restart-title"><h3 id="console-restart-title">Restart the Console</h3>${body}<p role="status" id="console-restart-receipt">${esc(restartReceipt)}</p></section>`;
|
||||
}
|
||||
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 `<span class="bus-restart"><button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${busBusy ? ' disabled' : ''}>${esc(busBusy ? 'Restarting…' : label)}</button> <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
|
||||
}
|
||||
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; } });
|
||||
})();
|
||||
@@ -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 = '<h2>Set up the model proxy</h2><p>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:</p><pre><code></code></pre><p>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.</p>';
|
||||
note.querySelector('code').textContent = `mosaic proxy fetch\nmosaic proxy init${org ? ` --org ${org}` : ''}`;
|
||||
$('proxy-message').after(note); } } });
|
||||
return cleanup;
|
||||
}
|
||||
window.MosaicProxy={markup,mount};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
|
||||
<script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
|
||||
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// F3: the owner restarts the Console from Settings > Console. The real supervisor in
|
||||
// `mosaic console start` relaunches the Console on the same address, and the open page shows the
|
||||
// restart and comes back by itself, with no manual reload. A page holding an unsaved draft keeps
|
||||
// it and offers the reload instead of taking it.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
|
||||
import { AGENT_MARKERS } from '../../cli/src/transport.mjs';
|
||||
import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// The page reloads on its own, so a check can land between two documents; it is retried.
|
||||
async function until(b, expression, ms = 30000) {
|
||||
const deadline = Date.now() + ms;
|
||||
for (;;) {
|
||||
try { if (await b.evaluate(`!!(${expression})`)) return; } catch { /* between documents */ }
|
||||
if (Date.now() > deadline) throw new Error(`timed out: ${expression}`);
|
||||
await sleep(50);
|
||||
}
|
||||
}
|
||||
// Every banner the page shows is kept in sessionStorage, which outlives the page's own reload.
|
||||
const WATCH = `(() => {
|
||||
const seen = JSON.parse(sessionStorage.getItem('f3-seen') || '[]');
|
||||
new MutationObserver(() => {
|
||||
const box = document.getElementById('console-reconnect');
|
||||
if (box && !box.hidden && seen.at(-1) !== box.textContent) { seen.push(box.textContent); sessionStorage.setItem('f3-seen', JSON.stringify(seen)); }
|
||||
}).observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
||||
return true;
|
||||
})()`;
|
||||
|
||||
test('Settings > Console restarts the Console under its supervisor and the open page reconnects by itself', { timeout: 120000 }, async t => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'f3-'));
|
||||
const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-f3test/config.json') };
|
||||
for (const k of AGENT_MARKERS) delete env[k];
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'f3test'], { env, encoding: 'utf8' });
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
const ctx = { configDir: join(home, '.config/mosaic-f3test'), dataRoot: join(home, '.mosaic-f3test'), instance: 'f3test', env };
|
||||
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
|
||||
const warnings = [];
|
||||
const run = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
|
||||
t.after(async () => { run.stop(); await run.done; try { await consoleStop(ctx, { warn: () => {} }); } catch {} rmSync(home, { recursive: true, force: true }); });
|
||||
const stateFile = join(ctx.dataRoot, 'console/console.json');
|
||||
const pidNow = () => JSON.parse(readFileSync(stateFile, 'utf8')).pid;
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`${run.url}#/settings/console`);
|
||||
await until(b, 'document.getElementById("console-restart")');
|
||||
const boot1 = await b.evaluate('window.MosaicConsole.config.boot');
|
||||
assert.equal(typeof boot1, 'string');
|
||||
assert.match(await b.evaluate('document.getElementById("console-restart-title").closest("section").textContent'), /Organization buses are separate processes and keep running/);
|
||||
await b.evaluate(`window.__f3 = 'first'`); await b.evaluate(WATCH);
|
||||
|
||||
// The button asks first; Cancel restarts nothing.
|
||||
await b.evaluate('document.getElementById("console-restart").click()');
|
||||
await until(b, 'document.getElementById("console-restart-cancel")');
|
||||
await b.evaluate('document.getElementById("console-restart-cancel").click()');
|
||||
await until(b, '!document.getElementById("console-restart-go") && !document.getElementById("console-restart").hidden');
|
||||
assert.equal(pidNow(), run.pid);
|
||||
|
||||
await b.evaluate('document.getElementById("console-restart").click()');
|
||||
await until(b, 'document.getElementById("console-restart-go")');
|
||||
await b.evaluate('document.getElementById("console-restart-go").click()');
|
||||
// The page reloads itself once the new Console answers: the marker is gone, the boot is new.
|
||||
await until(b, `window.__f3 === undefined && window.MosaicConsole?.config?.boot && window.MosaicConsole.config.boot !== ${JSON.stringify(boot1)}`);
|
||||
const seen1 = JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`));
|
||||
assert.equal(seen1[0], 'Console restarting. This page reconnects when it is back.');
|
||||
assert.equal(await b.evaluate('location.hash'), '#/settings/console');
|
||||
await until(b, 'document.getElementById("console-restart")');
|
||||
const pid2 = pidNow();
|
||||
assert.notEqual(pid2, run.pid);
|
||||
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).url, run.url);
|
||||
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
|
||||
const trail = readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
assert.deepEqual(trail.map(e => [e.action, e.outcome, e.by.provider, e.boot]), [['console.restart', 'restarting', 'local-dev', boot1]]);
|
||||
|
||||
// An unsaved draft (a reply being written) is not thrown away: the page says the Console is
|
||||
// back and offers the reload. The textarea stands in for the chat reply box.
|
||||
const boot2 = await b.evaluate('window.MosaicConsole.config.boot');
|
||||
await b.evaluate(`(() => { window.__f3 = 'second'; sessionStorage.removeItem('f3-seen'); const t = document.createElement('textarea'); t.id = 'reply'; t.value = 'half a reply'; document.body.append(t); return true; })()`);
|
||||
await b.evaluate(WATCH);
|
||||
await b.evaluate('document.getElementById("console-restart").click()');
|
||||
await until(b, 'document.getElementById("console-restart-go")');
|
||||
await b.evaluate('document.getElementById("console-restart-go").click()');
|
||||
await until(b, 'document.getElementById("console-reconnect-reload")');
|
||||
assert.equal(await b.evaluate('window.__f3'), 'second');
|
||||
assert.equal(await b.evaluate('document.getElementById("reply").value'), 'half a reply');
|
||||
assert.deepEqual(JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`)), ['Console restarting. This page reconnects when it is back.', 'The Console is back. Your unsaved draft is kept on this page. Reload now']);
|
||||
assert.notEqual(await b.evaluate('window.MosaicConsole.config.boot'), boot2);
|
||||
assert.notEqual(pidNow(), pid2);
|
||||
await b.evaluate('document.getElementById("console-reconnect-reload").click()');
|
||||
await until(b, `window.__f3 === undefined && document.getElementById("console-restart")`);
|
||||
assert.equal(readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).length, 2);
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
// F3: the owner's Console restart and the organization settings writes, through the real server.
|
||||
// Each control is refused without the human, refused from another origin, works for the dev local
|
||||
// owner and writes the action trail. No test reaches a live Console, a real config or a secret.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { consoleAccess } from '../src/access.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { createSsoSession } from './helpers/sso-session.mjs';
|
||||
|
||||
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
|
||||
const REVISION = 'a'.repeat(64);
|
||||
const REQUEST = '10000000-0000-4000-8000-000000000001';
|
||||
const ROUTES = { assistant: { harness: 'claude', model: 'opus', thinking: 'high' } };
|
||||
const ROUTES_ANSWER = { revision: 'b'.repeat(64), harnesses: [], models: [], levels: {}, instances: { assistant: ROUTES.assistant } };
|
||||
|
||||
function call(target, { method = 'GET', path, headers = {}, body }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const all = { ...headers };
|
||||
for (const k of Object.keys(all)) if (all[k] === null) delete all[k];
|
||||
if (body !== undefined) all['content-length'] = Buffer.byteLength(body);
|
||||
const req = request({ ...target, method, path, headers: all }, res => {
|
||||
let raw = '';
|
||||
res.on('data', c => { raw += c; });
|
||||
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* not JSON */ } resolve({ status: res.statusCode, body: json, raw }); });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const scratch = t => { const root = mkdtempSync(join(tmpdir(), 'f3-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
|
||||
const lines = dir => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
|
||||
const spy = () => { const calls = []; let hit; const once = new Promise(r => { hit = r; }); const fn = () => { calls.push(Date.now()); hit(); }; return { fn, calls, once }; };
|
||||
|
||||
// A loopback Console under the dev local owner with --send; `opts` overrides any option.
|
||||
async function devConsole(t, opts = {}) {
|
||||
const root = scratch(t), dir = join(root, 'console'), restart = spy();
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), log: () => {}, restart: restart.fn, trail: actionTrail(dir), ...opts });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`;
|
||||
const write = (path, body = '{}', headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path, body, headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } });
|
||||
const read = async path => (await call({ host: '127.0.0.1', port }, { path, headers: { host } })).body;
|
||||
return { port, host, origin, dir, root, restart, write, read };
|
||||
}
|
||||
const FENCE = port => [
|
||||
[{ origin: null }, 'cross-origin request refused'],
|
||||
[{ origin: 'http://evil.example' }, 'cross-origin request refused'],
|
||||
[{ origin: `http://localhost:${port + 1}` }, 'cross-origin request refused'],
|
||||
[{ 'sec-fetch-site': null }, 'cross-site request refused'],
|
||||
[{ 'sec-fetch-site': 'cross-site' }, 'cross-site request refused'],
|
||||
[{ 'sec-fetch-site': 'same-site' }, 'cross-site request refused'],
|
||||
[{ host: `evil.example:${port}` }, 'non-local Host refused'],
|
||||
[{ 'x-mosaic-tester': '1' }, 'tester listener required'],
|
||||
];
|
||||
|
||||
test('the dev owner restarts a supervised Console once, after the answer, and the trail names them', async t => {
|
||||
const c = await devConsole(t);
|
||||
const config = await c.read('/api/config');
|
||||
assert.equal(config.consoleRestart, true);
|
||||
assert.match(config.boot, /^[0-9a-f-]{36}$/);
|
||||
const r = await c.write('/api/console/restart');
|
||||
assert.equal(r.status, 202, r.raw);
|
||||
assert.deepEqual(r.body, { outcome: 'restarting', boot: config.boot });
|
||||
await c.restart.once;
|
||||
assert.equal(c.restart.calls.length, 1);
|
||||
const trail = lines(c.dir);
|
||||
assert.equal(trail.length, 1);
|
||||
const [{ at, ...entry }] = trail;
|
||||
assert.ok(Number.isFinite(Date.parse(at)));
|
||||
assert.deepEqual(entry, { by: DEV, action: 'console.restart', outcome: 'restarting', boot: config.boot });
|
||||
assert.equal(statSync(join(c.dir, 'actions.jsonl')).mode & 0o777, 0o600);
|
||||
assert.equal(statSync(c.dir).mode & 0o777, 0o700);
|
||||
});
|
||||
|
||||
test('a Console restart from another origin, site, Host or the tester header is refused and leaves no trail', async t => {
|
||||
const c = await devConsole(t);
|
||||
for (const [headers, error] of FENCE(c.port)) {
|
||||
const r = await c.write('/api/console/restart', '{}', headers);
|
||||
assert.equal(r.status, 403, JSON.stringify(headers));
|
||||
assert.equal(r.body.error, error, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal(c.restart.calls.length, 0);
|
||||
assert.deepEqual(lines(c.dir), []);
|
||||
});
|
||||
|
||||
test('without the signed-in owner there is no Console restart', async t => {
|
||||
const root = scratch(t), dir = join(root, 'console'), restart = spy();
|
||||
// A loopback Console with no sign-in has no owner at all.
|
||||
const anon = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
|
||||
t.after(() => close(anon));
|
||||
const ap = anon.address().port, ah = `127.0.0.1:${ap}`;
|
||||
const a = await call({ host: '127.0.0.1', port: ap }, { method: 'POST', path: '/api/console/restart', body: '{}', headers: { host: ah, origin: `http://${ah}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
|
||||
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
|
||||
assert.equal((await call({ host: '127.0.0.1', port: ap }, { path: '/api/config', headers: { host: ah } })).body.consoleRestart, false);
|
||||
|
||||
// Under --sso: signed out, a granted user, then the signed-in owner.
|
||||
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
|
||||
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
|
||||
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'demo' }) });
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
|
||||
t.after(() => close(web));
|
||||
const port = web.address().port, target = { host: '127.0.0.1', port };
|
||||
const post = cookie => call(target, { method: 'POST', path: '/api/console/restart', body: '{}', headers: s.writeHeaders(cookie) });
|
||||
const out = await post(null);
|
||||
assert.equal(out.status, 401); assert.equal(out.body.error, 'sign-in required');
|
||||
const alice = await s.signIn(port, { sub: 'alice-sub' });
|
||||
const granted = await post(alice);
|
||||
assert.equal(granted.status, 403); assert.equal(granted.body.error, 'owner-only');
|
||||
assert.equal(restart.calls.length, 0);
|
||||
assert.deepEqual(lines(dir), []);
|
||||
const owner = await s.signIn(port);
|
||||
assert.equal((await call(target, { path: '/api/config', headers: s.readHeaders(owner) })).body.consoleRestart, true);
|
||||
const ok = await post(owner);
|
||||
assert.equal(ok.status, 202, ok.raw);
|
||||
await restart.once;
|
||||
const [entry] = lines(dir);
|
||||
assert.equal(entry.by.provider, 'writer'); assert.equal(entry.by.sub, 'owner-sub'); assert.equal(entry.by.name, 'jason');
|
||||
assert.equal(entry.action, 'console.restart');
|
||||
});
|
||||
|
||||
test('the tester listener never restarts the Console', async t => {
|
||||
const root = scratch(t), dir = join(root, 'console'), restart = spy(), socketPath = join(root, 'tester.sock');
|
||||
const server = await startServer({ tester: true, testerSocket: socketPath, board: 'http://127.0.0.1:9', log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
|
||||
t.after(() => close(server));
|
||||
const headers = { host: 'mosaic-tester.invalid', origin: 'http://mosaic-tester.invalid', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
const r = await call({ socketPath }, { method: 'POST', path: '/api/console/restart', body: '{}', headers });
|
||||
assert.equal(r.status, 403); assert.equal(r.body.error, 'owner-only');
|
||||
assert.equal((await call({ socketPath }, { path: '/api/config', headers: { host: 'mosaic-tester.invalid' } })).body.consoleRestart, false);
|
||||
assert.equal(restart.calls.length, 0);
|
||||
assert.deepEqual(lines(dir), []);
|
||||
});
|
||||
|
||||
test('a Console without --send, its supervisor or a safe trail says so and stays up', async t => {
|
||||
const readOnly = await devConsole(t, { send: null });
|
||||
assert.equal((await readOnly.read('/api/config')).consoleRestart, false);
|
||||
const ro = await readOnly.write('/api/console/restart');
|
||||
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
|
||||
|
||||
const detached = await devConsole(t, { restart: null });
|
||||
assert.equal((await detached.read('/api/config')).consoleRestart, false);
|
||||
const d = await detached.write('/api/console/restart');
|
||||
assert.equal(d.status, 409); assert.equal(d.body.error, 'not-supervised');
|
||||
assert.deepEqual(lines(detached.dir), []);
|
||||
|
||||
const noTrail = await devConsole(t, { trail: null });
|
||||
assert.equal((await noTrail.read('/api/config')).consoleRestart, false);
|
||||
const n = await noTrail.write('/api/console/restart');
|
||||
assert.equal(n.status, 503); assert.equal(n.body.error, 'trail-unavailable');
|
||||
|
||||
const broken = await devConsole(t, { trail: () => { throw new Error('disk full'); } });
|
||||
const b = await broken.write('/api/console/restart');
|
||||
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
|
||||
|
||||
// A trail directory that is a symlink is unsafe; the restart is refused, nothing is written.
|
||||
const root = scratch(t), real = join(root, 'real');
|
||||
mkdirSync(real, { mode: 0o700 }); symlinkSync(real, join(root, 'console'));
|
||||
const linked = await devConsole(t, { trail: actionTrail(join(root, 'console')) });
|
||||
const l = await linked.write('/api/console/restart');
|
||||
assert.equal(l.status, 500); assert.equal(l.body.error, 'trail-unavailable');
|
||||
assert.equal(existsSync(join(real, 'actions.jsonl')), false);
|
||||
|
||||
for (const c of [readOnly, detached, noTrail, broken, linked]) assert.equal(c.restart.calls.length, 0);
|
||||
});
|
||||
|
||||
test('the Console restart body is exactly {} as JSON', async t => {
|
||||
const c = await devConsole(t);
|
||||
for (const [body, headers, status] of [
|
||||
['{}', { 'content-type': 'text/plain' }, 415],
|
||||
['{}', { 'content-type': null }, 415],
|
||||
['{"now":true}', {}, 400],
|
||||
['[]', {}, 400],
|
||||
['nope', {}, 400],
|
||||
['', {}, 400],
|
||||
]) {
|
||||
const r = await c.write('/api/console/restart', body, headers);
|
||||
assert.equal(r.status, status, `${body} ${JSON.stringify(headers)}`);
|
||||
}
|
||||
assert.equal(c.restart.calls.length, 0);
|
||||
assert.deepEqual(lines(c.dir), []);
|
||||
});
|
||||
|
||||
// The organization settings writes. `alpha` answers; `beta` is offline.
|
||||
function orgs() {
|
||||
const routed = [], launched = [], changed = [];
|
||||
const organizations = {
|
||||
list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }],
|
||||
resolve: async id => (['alpha', 'beta'].includes(id) ? {
|
||||
busHealthy: id === 'alpha', send: async () => ({}),
|
||||
routesWrite: async args => { routed.push([id, args]); return ROUTES_ANSWER; },
|
||||
agentWrite: async (verb, args) => { launched.push([id, verb, args]); return { request: REQUEST }; },
|
||||
instancesWrite: async args => { changed.push([id, args]); return { revision: REVISION, restart: false, definitions: {}, instances: { coder: {} } }; },
|
||||
} : null),
|
||||
};
|
||||
return { organizations, routed, launched, changed };
|
||||
}
|
||||
|
||||
test('the dev owner changes an organization agent route and restarts it, and the trail names the organization', async t => {
|
||||
const o = orgs();
|
||||
const c = await devConsole(t, { organizations: o.organizations });
|
||||
const alpha = await c.read('/api/orgs/alpha/config');
|
||||
assert.equal(alpha.send, false); assert.equal(alpha.settingsSend, true); assert.equal(alpha.orgSetup, true);
|
||||
assert.equal((await c.read('/api/orgs/beta/config')).settingsSend, false, 'an offline organization offers no settings writes');
|
||||
const r = await c.write('/api/orgs/alpha/routes', JSON.stringify({ revision: REVISION, routes: ROUTES }));
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual(o.routed, [['alpha', { revision: REVISION, routes: ROUTES }]]);
|
||||
const launch = await c.write('/api/orgs/alpha/agent/launch', JSON.stringify({ instance: 'assistant' }));
|
||||
assert.equal(launch.status, 200, launch.raw);
|
||||
assert.deepEqual(o.launched, [['alpha', 'instance.launch', { instance: 'assistant' }]]);
|
||||
const trail = lines(c.dir).map(({ at, ...e }) => e);
|
||||
assert.deepEqual(trail, [
|
||||
{ by: DEV, org: 'alpha', action: 'routes.set', instances: ['assistant'], outcome: 'done' },
|
||||
{ by: DEV, org: 'alpha', action: 'instance.launch', instance: 'assistant', outcome: `done request=${REQUEST}` },
|
||||
]);
|
||||
// The limits route passes the gate and is refused by its own check: alpha has no settings transport.
|
||||
const limits = await c.write('/api/orgs/alpha/settings', '{}');
|
||||
assert.equal(limits.status, 403); assert.equal(limits.body.error, 'read-only');
|
||||
assert.equal((await c.write('/api/orgs/missing/routes', JSON.stringify({ revision: REVISION, routes: ROUTES }))).status, 404);
|
||||
});
|
||||
|
||||
test('F3 review P3: an organization instance change appends its trail line, as the README promises', async t => {
|
||||
const o = orgs();
|
||||
const c = await devConsole(t, { organizations: o.organizations });
|
||||
const r = await c.write('/api/orgs/alpha/instances', JSON.stringify({ revision: REVISION, instance: 'coder', op: 'change', vars: {} }));
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual(o.changed, [['alpha', { revision: REVISION, instance: 'coder', op: 'change', vars: {} }]]);
|
||||
assert.deepEqual(lines(c.dir).map(({ at, ...e }) => e), [
|
||||
{ by: DEV, org: 'alpha', action: 'instances.set', op: 'change', instance: 'coder', outcome: 'done restart=false' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('organization settings writes from another origin or site are refused before the bus', async t => {
|
||||
const o = orgs();
|
||||
const c = await devConsole(t, { organizations: o.organizations });
|
||||
const body = JSON.stringify({ revision: REVISION, routes: ROUTES });
|
||||
for (const [headers] of FENCE(c.port)) {
|
||||
for (const path of ['/api/orgs/alpha/routes', '/api/orgs/alpha/agent/terminate']) {
|
||||
const r = await c.write(path, path.endsWith('routes') ? body : JSON.stringify({ instance: 'assistant' }), headers);
|
||||
assert.equal(r.status, 403, `${path} ${JSON.stringify(headers)}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(o.routed, []); assert.deepEqual(o.launched, []);
|
||||
});
|
||||
|
||||
test('organization settings writes need the signed-in owner: no sign-in, the tester listener and a granted user are refused', async t => {
|
||||
const o = orgs(), root = scratch(t), dir = join(root, 'console');
|
||||
const body = JSON.stringify({ revision: REVISION, routes: ROUTES });
|
||||
const anon = await startServer({ port: 0, board: 'http://127.0.0.1:9', organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
|
||||
t.after(() => close(anon));
|
||||
const ap = anon.address().port, ah = `127.0.0.1:${ap}`;
|
||||
const a = await call({ host: '127.0.0.1', port: ap }, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: { host: ah, origin: `http://${ah}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
|
||||
assert.equal(a.status, 404); assert.equal(a.body.error, 'org-unavailable');
|
||||
|
||||
const socketPath = join(root, 'tester.sock');
|
||||
const tester = await startServer({ tester: true, testerSocket: socketPath, board: 'http://127.0.0.1:9', organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
|
||||
t.after(() => close(tester));
|
||||
const tr = await call({ socketPath }, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: { host: 'mosaic-tester.invalid', origin: 'http://mosaic-tester.invalid', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
|
||||
assert.equal(tr.status, 404); assert.equal(tr.body.error, 'org-unavailable');
|
||||
|
||||
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
|
||||
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
|
||||
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'demo' }) });
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
|
||||
t.after(() => close(web));
|
||||
const port = web.address().port, target = { host: '127.0.0.1', port };
|
||||
const alice = await s.signIn(port, { sub: 'alice-sub' });
|
||||
const g = await call(target, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: s.writeHeaders(alice) });
|
||||
assert.equal(g.status, 403); assert.equal(g.body.error, 'owner-only');
|
||||
assert.deepEqual(o.routed, []);
|
||||
assert.deepEqual(lines(dir), []);
|
||||
|
||||
// The signed-in owner on a deployed (non-dev) Console reaches the organization the same way.
|
||||
const owner = await s.signIn(port);
|
||||
const ok = await call(target, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: s.writeHeaders(owner) });
|
||||
assert.equal(ok.status, 200, ok.raw);
|
||||
assert.equal(o.routed.length, 1);
|
||||
const [entry] = lines(dir);
|
||||
assert.equal(entry.by.sub, 'owner-sub'); assert.equal(entry.org, 'alpha'); assert.equal(entry.action, 'routes.set');
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { createOrgAdmin, orgAdminHTTP, browserProblems, pathFromMessage, toCanonical, forBrowser, plainMessage } from '../src/org-admin.mjs';
|
||||
import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
class OrgError extends Error { constructor(code, message = code) { super(message); this.code = code; } }
|
||||
@@ -37,6 +38,7 @@ function fakes() {
|
||||
busStatus: async (ctx, id) => { get(id); return { org: id, instance: 'test', root: `/data/org-runtime/${id}`, socket: '/run/s', healthy: running === id, pid: running === id ? 4242 : null }; },
|
||||
busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
|
||||
busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; },
|
||||
busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
|
||||
};
|
||||
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||
return { admin, orgs, calls, fail, service };
|
||||
@@ -479,3 +481,87 @@ test('archive requires the injected bus stop and both lifecycle routes require o
|
||||
for (const step of ['archive', 'restore']) assert.equal((await readonly.call('POST', `/api/orgs/acme/${step}`, { revision: 1 })).status, 403);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
// F3 (REQ-WEB-3): the owner restarts an organization's bus from the page, with a done receipt.
|
||||
const trailIn = t => { const root = mkdtempSync(join(tmpdir(), 'f3-trail-')); t.after(() => rmSync(root, { recursive: true, force: true })); const dir = join(root, 'console'); return { dir, trail: actionTrail(dir), lines: () => { try { return readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.ok(Number.isFinite(Date.parse(at))); return e; }); } catch (e) { if (e.code === 'ENOENT') return []; throw e; } } }; };
|
||||
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
|
||||
|
||||
test('the owner starts a stopped bus and restarts a running one, and the trail records each', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') })).status, 201);
|
||||
let r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual(r.body, { outcome: 'done', step: 'bus-restart', restarted: false, healthy: true, receipt: { org: 'acme', pid: 4242, socket: '<host path>', root: '<data root>/org-runtime/acme' }, verb: 'mosaic bus start acme --dev-local-owner --detach' });
|
||||
// A stopped bus starts as setup starts it, keeping the dev local owner.
|
||||
assert.deepEqual(c.calls.filter(x => x[0] === 'busStart').map(x => x.slice(1)), [['acme', { detach: true, devLocalOwner: true, pm: false }]]);
|
||||
r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.equal(r.body.restarted, true); assert.equal(r.body.healthy, true); assert.equal(r.body.verb, 'mosaic bus restart acme');
|
||||
assert.equal(r.body.receipt.pid, 4343);
|
||||
assert.deepEqual(c.calls.filter(x => x[0] === 'busRestart'), [['busRestart', 'acme']]);
|
||||
assert.deepEqual(tr.lines(), [
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false },
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: true },
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a bus restart from another origin or site, without the owner or --send, or for no organization is refused', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
for (const headers of [{ origin: null }, { origin: 'http://evil.example' }, { origin: `http://localhost:${c.port + 1}` }, { 'sec-fetch-site': null }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': 'same-site' }, { 'x-mosaic-tester': '1' }]) {
|
||||
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {}, headers);
|
||||
assert.equal(r.status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/restart/bus', {}, { 'content-type': 'text/plain' })).status, 415);
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/restart/bus', { now: true })).status, 400);
|
||||
assert.equal((await c.call('GET', '/api/orgs/acme/restart/bus')).status, 404);
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/restart/queue', {})).status, 404);
|
||||
const unknown = await c.call('POST', '/api/orgs/ghost/restart/bus', {});
|
||||
assert.equal(unknown.status, 404); assert.equal(unknown.body.error, 'org-unavailable');
|
||||
const readOnly = await console_(t, { trail: tr.trail, send: null });
|
||||
const ro = await readOnly.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
|
||||
const anon = await console_(t, { trail: tr.trail, sso: null, send: null });
|
||||
const a = await anon.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
|
||||
assert.equal(c.calls.filter(x => ['busStart', 'busRestart'].includes(x[0])).length, 0);
|
||||
assert.deepEqual(tr.lines(), []);
|
||||
});
|
||||
|
||||
test('a bus restart with no trail, or a trail that cannot be written, changes nothing', async t => {
|
||||
const c = await console_(t);
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(r.status, 503); assert.equal(r.body.error, 'trail-unavailable');
|
||||
const broken = await console_(t, { trail: () => { throw new Error('disk full'); } });
|
||||
await broken.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
const b = await broken.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
|
||||
for (const x of [c, broken]) assert.equal(x.calls.filter(y => ['busStart', 'busRestart'].includes(y[0])).length, 0);
|
||||
});
|
||||
|
||||
test('a bus that refuses to restart is a visible failure and the trail says refused', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
await c.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
const bus = await c.admin.bus();
|
||||
bus.busRestart = async () => { throw Object.assign(new Error('bus failed to start; see /data/org-runtime/acme/host/host.log'), { name: 'CliError', exitCode: 3 }); };
|
||||
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
|
||||
assert.equal(r.body.message, 'bus failed to start; see <data root>/org-runtime/acme/host/host.log');
|
||||
assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused']);
|
||||
// A host whose status cannot be read (a stale lock) is refused before anything changes; the
|
||||
// attempt is still in the trail, and nothing restarts or starts.
|
||||
bus.busStatus = async () => { throw Object.assign(new Error('stale bus host lock'), { name: 'CliError', exitCode: 3 }); };
|
||||
const before = c.calls.length;
|
||||
const s = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(s.status, 409); assert.equal(s.body.error, 'bus-refused');
|
||||
assert.equal(s.body.message, 'stale bus host lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.');
|
||||
assert.deepEqual(tr.lines().slice(2), [{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'refused', running: null }]);
|
||||
assert.equal(c.calls.slice(before).filter(y => ['busStart', 'busRestart'].includes(y[0])).length, 0);
|
||||
});
|
||||
@@ -54,7 +54,10 @@ test('organization routes bind reads and writes to the authorized organization',
|
||||
assert.deepEqual(calls, [['alpha', { thread, body: 'hello' }]]);
|
||||
assert.equal((await post(`/api/orgs/beta/chat/threads/${thread}/messages`, { body: 'hello' }, { origin: 'http://evil.example' }))[0], 403);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal((await post('/api/orgs/alpha/settings', {}))[1].error, 'unavailable');
|
||||
// F3: the owner's settings writes now reach the selected organization; this one offers no
|
||||
// settings writer, so the route itself answers read-only and nothing is written.
|
||||
assert.equal((await post('/api/orgs/alpha/settings', {}))[1].error, 'read-only');
|
||||
assert.equal((await post('/api/orgs/alpha/settings', {}, { origin: 'http://evil.example' }))[0], 403);
|
||||
assert.equal((await post('/api/settings', {}))[1].error, 'org-unavailable');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// F3: after a route save, Settings > Routing says how the change reaches each saved agent, read
|
||||
// from the instances: the launcher and a stopped agent take it at their next start, a running
|
||||
// session keeps its old route until it ends (a thread when it goes idle), and a running agent
|
||||
// offers "Restart now". Restart now terminates with the reason "route change", waits for the
|
||||
// session to end and launches it again; a thread is only terminated. Real server under the dev
|
||||
// local owner, a stub bus, every write gated by the browser's own Origin and recorded in the trail.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const REV = 'a'.repeat(64), REV2 = 'b'.repeat(64);
|
||||
const END = '10000000-0000-4000-8000-0000000000e1', LAUNCH = '10000000-0000-4000-8000-0000000000a1', END2 = '10000000-0000-4000-8000-0000000000e2';
|
||||
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
|
||||
const route = thinking => ({ harness: 'claude', model: 'opus', thinking, fallback: null });
|
||||
|
||||
test('F3 Settings > Routing: a save lists how the change reaches each agent, and Restart now moves a running one', { timeout: 120000 }, async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'f3r-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const entry = (definition, extra) => ({ definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [], alive: [], ...extra });
|
||||
const instances = {
|
||||
chief: entry('chief', { launcher: true }),
|
||||
coder: entry('coder', { running: ['run-1'], alive: ['run-1'] }),
|
||||
assistant: entry('assistant', { running: ['run-a'], alive: ['run-a'] }),
|
||||
reviewer: entry('reviewer', { launched: false }),
|
||||
};
|
||||
const view = () => structuredClone({ business: 'acme', revision: REV, launch: true, noHire: [], retired: [], instances,
|
||||
definitions: Object.fromEntries(Object.values(instances).map(e => [e.definition, { services: [], accepted: true }])) });
|
||||
let routes = { revision: REV, harnesses: ['claude'], models: ['opus'], levels: { claude: ['low', 'high'] },
|
||||
instances: Object.fromEntries(Object.keys(instances).map(n => [n, route('high')])) };
|
||||
const asked = [], requests = new Map();
|
||||
const bus = { messages: async () => ({ messages: [], unread: 0 }), usage: async () => { throw Error('no usage'); }, instances: async () => view(), routes: async () => structuredClone(routes) };
|
||||
const routesWrite = async args => {
|
||||
asked.push(['routes.set', args]);
|
||||
routes = { ...routes, revision: REV2, instances: { ...routes.instances, ...structuredClone(args.routes) } };
|
||||
return structuredClone(routes);
|
||||
};
|
||||
const agentWrite = async (verb, args) => {
|
||||
asked.push([verb, args]);
|
||||
if (verb === 'instance.end') {
|
||||
const id = args.instance === 'coder' ? END : END2;
|
||||
requests.set(id, { instance: args.instance, action: 'end' });
|
||||
// The session ends a moment after the request closes, so the page has to wait for it.
|
||||
setTimeout(() => { instances[args.instance].running = []; instances[args.instance].alive = []; }, 400);
|
||||
return { request: id, runs: [...instances[args.instance].running], cancelled: [], taken: [] };
|
||||
}
|
||||
if (verb === 'instance.launch') { requests.set(LAUNCH, { instance: args.instance, action: 'launch' }); instances[args.instance].running = ['run-2']; return { request: LAUNCH }; }
|
||||
if (verb === 'instance.request') {
|
||||
const r = requests.get(args.request);
|
||||
return { request: args.request, instance: r.instance, action: r.action, state: 'closed', done: { run: r.action === 'launch' ? 'run-2' : null, transaction: null, refusal: null }, cancelled: null };
|
||||
}
|
||||
throw Error(`unexpected ${verb}`);
|
||||
};
|
||||
const dir = join(root, 'console');
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), bus, routesWrite, agentWrite,
|
||||
reads: { settings: () => ({ business: 'acme', release: 'fixture', notifier: {} }) }, trail: actionTrail(dir), log: () => {} });
|
||||
t.after(() => close(web));
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
const wait = (expr, ms = 20000) => b.evaluate(`(async()=>{const end=Date.now()+${ms};for(;;){if(${expr})return true;if(Date.now()>end)throw Error('wait failed: '+${JSON.stringify(expr)});await new Promise(r=>setTimeout(r,30));}})()`);
|
||||
const cell = (name, key) => `[...document.querySelectorAll('[data-route]')].find(e=>e.dataset.route===${JSON.stringify(JSON.stringify([name, key]))})`;
|
||||
const items = () => b.evaluate(`[...document.querySelectorAll('.routes-apply li')].map(li=>{const c=li.cloneNode(true);c.querySelector('.agent-restart')?.remove();return c.textContent.trim();})`);
|
||||
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
|
||||
await wait(`${cell('coder', 'thinking')}&&!${cell('coder', 'thinking')}.disabled`);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('.routes-apply')`), false, 'nothing is listed before a save');
|
||||
for (const name of ['chief', 'coder', 'assistant', 'reviewer']) await b.evaluate(`(()=>{const s=${cell(name, 'thinking')};s.value='low';s.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
|
||||
await b.evaluate(`document.getElementById('routes-form').requestSubmit();true`);
|
||||
await wait(`document.querySelector('.routes-apply')`);
|
||||
assert.deepEqual(asked, [['routes.set', { revision: REV, routes: { chief: route('low'), coder: route('low'), assistant: route('low'), reviewer: route('low') } }]]);
|
||||
assert.equal(await b.evaluate(`document.getElementById('routes-receipt').textContent`), 'Routes saved for chief, coder, assistant, reviewer. The next launch of each uses the new route.');
|
||||
assert.deepEqual(await items(), [
|
||||
'chief: The launcher. It takes the new route at its next start.',
|
||||
'coder: Running. Its session keeps the old route until it ends. Restart it to move it now.',
|
||||
'assistant: Running. Its session keeps the old route until the thread goes idle; the next message after that starts it on the new route. Restart it to move it now.',
|
||||
'reviewer: Not running. Its next start uses the new route.',
|
||||
]);
|
||||
// Only the running agents offer the restart, and the thread is marked as one.
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('.routes-apply [data-agent-restart]')].map(e=>[e.dataset.agentRestart,e.dataset.agentThread,e.textContent])`),
|
||||
[['coder', 'no', 'Restart coder now'], ['assistant', 'yes', 'Restart assistant now']]);
|
||||
|
||||
// Restart now on a session: terminate with the reason, wait for it to end, then one launch.
|
||||
await b.evaluate(`document.querySelector('[data-agent-restart="coder"]').click();true`);
|
||||
await wait(`document.querySelector('[data-agent-restart-status="coder"]')?.textContent==='Restarted coder on the new route (run run-2).'`, 30000);
|
||||
assert.deepEqual(asked.slice(1).filter(([v]) => v !== 'instance.request'), [['instance.end', { instance: 'coder', reason: 'route change' }], ['instance.launch', { instance: 'coder' }]]);
|
||||
assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'coder').length, 1, 'one launch, after the session ended');
|
||||
// A thread is terminated, not launched: its next message starts it on the new route.
|
||||
await b.evaluate(`document.querySelector('[data-agent-restart="assistant"]').click();true`);
|
||||
await wait(`/^Terminated assistant at .+\\. Its next message starts it on the new route\\.$/.test(document.querySelector('[data-agent-restart-status="assistant"]')?.textContent??'')`, 30000);
|
||||
assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'assistant').length, 0);
|
||||
|
||||
// The trail names the dev owner for the save and each lifecycle write.
|
||||
const trail = readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.match(at, /^\d{4}-\d\d-\d\dT/); return e; });
|
||||
assert.deepEqual(trail.map(e => [e.action, e.by?.provider, e.outcome?.split(' ')[0]]), [
|
||||
['routes.set', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'], ['instance.launch', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'],
|
||||
]);
|
||||
assert.deepEqual(trail[0].by, DEV);
|
||||
await b.viewport(390, 844);
|
||||
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=390`), true, 'no sideways scroll at phone width');
|
||||
});
|
||||
@@ -122,5 +122,16 @@ test('an unconfigured proxy is one empty state without forms or a false read-onl
|
||||
await b.navigate(f.base+'/#/settings/model-proxy');await wait('document.querySelector("#proxy-message")?.textContent.includes("not configured")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-message").classList.contains("empty")'),true);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'),false);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll(".settings-model-proxy .panel:not(#proxy-dev-setup)")].every(e=>e.hidden)'),true);
|
||||
// F3: a dev Console adds one note naming the terminal setup. It holds no form and no input: the
|
||||
// management key is never typed into this page.
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-dev-setup code")?.textContent'),'mosaic proxy fetch\nmosaic proxy init');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#proxy-dev-setup form,#proxy-dev-setup input,#proxy-dev-setup textarea").length'),0);
|
||||
});
|
||||
|
||||
test('an unconfigured proxy without the dev owner shows no setup note', {timeout:90000}, async t => {
|
||||
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/config':{send:true},'GET /api/proxy/accounts':{status:503,body:{error:'not-configured'}},'GET /api/proxy/keys':{status:503,body:{error:'not-configured'}}}});
|
||||
await b.navigate(f.base+'/#/settings/model-proxy');await wait('document.querySelector("#proxy-message")?.textContent.includes("not configured")');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#proxy-dev-setup")'),false);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll(".settings-model-proxy .panel")].every(e=>e.hidden)'),true);
|
||||
});
|
||||
Reference in new issue
Block a user