Files
stack/packages/webui/src/cli.mjs
T
jason.woltje e6ab8bc1fd feat(bus,plugins,organization,cli,webui): SP9 remove system types outright, bus schema 3v-3w drops project_system_types (#1873)
Jason's 2026-11-12 ruling: the only deployed system is the dev environment on
dragon-lin, so the system-type plug-in kind goes with no retired kind, fence,
conversion or new refusal code.

- plugins: system-type leaves KINDS; system-types.mjs and the five modules are
  deleted. An org file naming the kind refuses unknown-kind.
- organization: the plugins.system-type check, systemTypesFence and the
  system-types.console right are removed.
- bus: system-types.console, project.system-types[.set], the system-type half
  of the tester check, the runtime reads and system-type-not-enabled are
  removed. Step 3v-3w drops project_system_types, its index, its four guards
  and its rows; SCHEMA_VERSION is 3w (32 tables, 126 triggers, 5 views).
- cli: mosaic org system-types and mosaic project system-types[-set] are
  removed; stack-release COUNTED and the budget supervisor's closed list move
  to 3w.
- channel: the project.system-types verbs are removed.
- webui: the System types page, route, dashboard row, chips and project
  selection are removed.
- docs: REQ-SYS-1/2 now describe starter packs; README system-type text and
  the requirements titles follow.
2026-11-12 21:02:46 -06:00

285 lines
23 KiB
JavaScript

#!/usr/bin/env node
import { assertBuildStart, assertDevInstance, assertDeployed } from '../../cli/src/deployment.mjs';
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
import { organizationConsole } from './organizations.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { packsCaller } from './packs-http.mjs';
import { templatesCaller } from './template-http.mjs';
import { autonomyCaller } from './autonomy-http.mjs';
import { consoleAccess } from './access.mjs';
import { accessContextFor } from '../../cli/src/access.mjs';
import { accessFilePath, loadAccess } from '../../organization/src/access.mjs';
import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
import { loadSystem, socketPath, orgPaths, REPO, rolesDir } from '../../cli/src/config.mjs';
import { busStatus as orgBusStatus, busRevive as orgBusRevive } from '../../cli/src/org-bus.mjs';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { showOrg } from '../../organization/src/org-service.mjs';
import { configDir } from '../../organization/src/organization-config.mjs';
import { loadRootAgents } from '../../organization/src/agent-levels.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { templateHashes } from '../../organization/src/definitions.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createDevOwner } from './dev-owner.mjs';
import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs';
import { loadAccounts } from '../../organization/src/accounts.mjs';
import { createSso, readSlot } from './sso.mjs';
import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isAbsolute, join } from 'node:path';
import { actionTrail } from './action-trail.mjs';
import { reviveBuses } from './bus-revive.mjs';
import { createProxyAdmin, orgCatalogue } from './proxy-admin.mjs';
import { busesStopped } from '../../cli/src/proxy-admin-host.mjs';
import { agentLevelsWriteRoutes } from './agent-levels-write.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
// relative shell argument even though Node's process.argv resolves it. Re-exec the same
// program with its absolute path before opening anything; preserve PID, ancestry and env so
// the existing human and Console proof checks remain unchanged.
if (process.platform === 'linux') {
const raw = readFileSync('/proc/self/cmdline', 'utf8').split('\0');
let at = 1 + process.execArgv.length;
if (raw[at] === '--') at += 1; // Node leaves its option delimiter out of execArgv.
const script = raw[at];
if (script && !isAbsolute(script)) {
if (typeof process.execve !== 'function') throw new Error('relative Console launch requires process.execve; use an absolute script path');
process.execve(process.execPath, [process.execPath, ...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], process.env);
}
}
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] [--supervised] [--proxy-unit-dir PATH] [--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, 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.
const selector = selectorScope({
valid: id => ID_PATTERN.test(id),
refuse: (kind, why) => { throw new Error(kind === 'invalid' || kind === 'flag' ? 'business must be a business id' : why); },
});
while (args.length) {
const flag = args.shift();
if (selector.owns(flag)) { selector.take(flag, args.shift()); continue; }
// Row 76 (#1570): the one flag without a value.
if (flag === '--send') { sendOn = true; continue; }
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
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}`);
if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
} else if (flag === '--board') options.board = value;
// Row 77 (#1571): the canonical checkout the queue read runs from, when
// serve runs from a pinned copy; everything else loads from the copy.
else if (flag === '--checkout') {
let dir = false;
try { dir = isAbsolute(value) && statSync(value).isDirectory(); } catch { /* refused below */ }
if (!dir) throw new Error('--checkout must be the absolute path of a directory');
options.checkout = value;
}
// Row 74 (#1568): the preview mode, all three or none, checked before any config is read.
else if (flag === '--public-host') preview.publicHost = value;
else if (flag === '--auth-header') preview.authHeader = value;
else if (flag === '--allow-user') preview.allowUser = value;
else if (flag === '--proof-header') proofFlags.proofHeader = value;
else if (flag === '--proof-file') proofFlags.proofFile = value;
// org-lan-bind: the dev owner on the LAN, no sign-in. Both flags or neither, and only with
// --dev-local-owner (devLanOptions below, before any config is read).
else if (flag === '--dev-insecure-bind') {
if (devBind !== null) throw new Error('--dev-insecure-bind takes one address');
devBind = value;
} else if (flag === '--dev-allow-host') devNames.push(value);
// #1873: where the Proxy page installs the proxy user unit; proxy-service.sh's default otherwise.
else if (flag === '--proxy-unit-dir') {
if (!isAbsolute(value)) throw new Error('--proxy-unit-dir must be an absolute path');
options.proxyUnitDir = value;
}
else throw new Error(`unknown option: ${flag}`);
}
const stamp = assertBuildStart({ serve: true, sso: ssoOn, devOwner, devBind, devNames });
if (selector.given) options.business = selector.value;
if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket');
const lan = devLanOptions({ devOwner, bind: devBind, names: devNames });
const instance = devOwner ? devOwnerInstance() : null;
// The local-development console command has no separate --send flag.
if (devOwner) sendOn = true;
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
options.preview = previewOptions(preview);
if (sendOn && !ssoOn && !devOwner) throw new Error('--send needs --sso; header preview is read-only');
const proof = proofOptions(proofFlags, options.preview);
// The bus views (slice 1 S5) need the system config. Without it the
// board still serves and the bus routes answer not-configured.
// The Queue, Business and Settings reads (row 54) need no bus; without
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
// --send without a bus refuses to start rather than serve a composer that can't send.
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, librarySave = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, decisionWrite = null, requestRead = null, requestWrite = null, busLine, system = null;
let processing = null, ownerCall = null;
try {
system = loadSystem({ instance });
if (devOwner) assertDevInstance(system);
if (stamp) {
assertDeployed(system);
loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console?.sso?.providers ?? [] });
}
const { dataRoot } = system;
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, librarySave, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall } = consoleBus(call, { send: sendOn }));
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
// --sso and --send.
if ((ssoOn || devOwner) && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
// #797 / #1839 Task 3: the request intake controls use the same console wrapper (the signed-in
// (provider, iss, sub) is the session's, never the client's). Reads need --sso, writes --sso and
// --send. Workspace reads and project remote operations use this owner-only, verb-fenced transport.
if (ssoOn || devOwner) requestRead = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
if ((ssoOn || devOwner) && sendOn) requestWrite = requestRead;
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
if (ssoOn || devOwner) throw new Error(`${devOwner ? "--dev-local-owner" : "--sso"} needs the system config (${why})`);
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
// The sign-in config is checked again here, then each slot file, before anything listens. A
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
let sso = devOwner ? createDevOwner() : null, userOptions = null;
if (ssoOn) {
if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config');
const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })]));
// FED R6 (#1800): access.json users sign in too. R1's loader reads the file on every check,
// with the same context as `mosaic access`; a missing or refused file signs nobody in.
const users = consoleAccess({
load: () => loadAccess(accessFilePath(configDir()), accessContextFor(system, process.env)),
business: busBusiness({ dataRoot: system.dataRoot, business: options.business ?? null }),
});
sso = await createSso(system.console.sso, { secrets, users, accounts: { load: () => loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console.sso.providers }) } });
// Each request of a granted user's session gets the bus options over that user's own
// user-cli transport. The human transport above serves only the chat's `plugins` read (C6).
userOptions = userOptionsFor({ owner: ownerCall, send: sendOn, transport: user => userCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, user }) });
}
const organizations = devOwner ? organizationConsole({
ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance },
listOrgs, showOrg, resolveOrg, orgPaths, busStatus: orgBusStatus,
// U1 (#1873): the cross-organization read passes `timeoutMs` so a slow bus's children are
// killed at its deadline. The org pages pass none and keep the transport default.
options: ({ id, root, paths, session, timeoutMs, signal, deadline }) => {
const bound = timeoutMs === undefined ? {} : { timeoutMs, signal, deadline };
const owner = busCaller({ dataRoot: root, socket: paths.socket, business: id, ...bound });
const call = session.user === undefined ? owner : userCaller({ dataRoot: root, socket: paths.socket, business: id, user: session.user, ...bound });
return {
...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }),
decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
// The owner reads request status on the selected organization's bus, as on the root Console. Request
// writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release.
requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
// D1 (#1873): the owner's autonomy writes (save, review, confirm, reverse) on this organization's bus.
// These options exist only on a --dev-local-owner Console (`organizations` above). The fence passes
// those four verbs and nothing else the console transport carries.
autonomyWrite: session.user === undefined && sendOn ? autonomyCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
// Starter packs SP4 (#1873): the owner's pack apply, board and workflow writes, the three write
// .console verbs only. Preview and the pack list are reads over requestRead.
packsWrite: session.user === undefined && sendOn ? packsCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
// Starter packs SP8 (#1873): the owner's template save, template.save.console with a digest only. The list
// and the preview are reads over requestRead.
templatesWrite: session.user === undefined && sendOn ? templatesCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
// Starter packs SP6 (#1873): the owner's workflow read, the organization file and its revision as the
// writer reads them (the read `mosaic org workflow set` takes its revision from), and the V2 template names.
workflowsRead: session.user === undefined ? () => ({ ...organizationWriter({ dir: configDir(), rolesDir: rolesDir() }).read(id), templates: templateHashes(rolesDir()).map(t => t.template) }) : null,
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
};
},
}) : null;
// #1873 A1: the root agent level, read fresh on each request; the Agents grid's only new read.
const agentLevels = organizations ? { loadRoot: () => loadRootAgents(configDir(), { rolesDir: rolesDir() }), rolesDir: rolesDir(), ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance } } : null;
// #1873: proxy setup for the instance owner (dev local owner, or the root owner under --sso).
// Instance-wide: one pinned release, one proxy.json, one unit. Each organization uses it (shared).
const proxyAdmin = system && (devOwner || ssoOn) ? createProxyAdmin({
dataRoot: system.dataRoot, configDir: configDir(), instance, unitDir: options.proxyUnitDir ?? null,
buses: () => busesStopped({ dataRoot: system.dataRoot, configDir: configDir() }),
orgs: async () => {
const catalogue = orgCatalogue;
if (!organizations) return [{ id: options.business ?? null, proxy: 'shared', catalogue: await catalogue(ownerCall ?? (async () => { throw new Error(); })) }];
const out = [];
for (const org of await listOrgs({ configDir: configDir(), dataRoot: system.dataRoot, instance })) {
if (org.state !== 'active') continue;
let paths; try { paths = orgPaths(org.root); } catch { out.push({ id: org.id, proxy: 'shared', catalogue: { status: 'bus-unavailable', models: null } }); continue; }
out.push({ id: org.id, proxy: 'shared', catalogue: await catalogue(busCaller({ dataRoot: org.root, socket: paths.socket, business: org.id, timeoutMs: 5000 })) });
}
return out;
},
}) : null;
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;
// #1873 A2: Settings > Agents writes for the root and project levels, beside the levels read.
const agentLevelRoutes = organizations ? [agentLevelsWriteRoutes({ configDir: configDir(), rolesDir: rolesDir(), trail })] : [];
try {
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, proxyAdmin, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, routes: agentLevelRoutes, preselect: options.business ?? null });
} catch (err) { sso?.close(); throw err; }
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') };
try {
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
processing: { read: () => call('processing.status') },
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
// Row 194 (#1692, D4.1): the tester launches, terminates and reads its own requests.
agentWrite: agentCaller(call, TESTER_AGENT_VERBS),
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
chat: createChat({ call }),
});
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
}
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, read-only` : '';
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
const health = sso?.health() ?? {};
const ssoLines = sso && !devOwner ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
const port = server.address().port;
if (devOwner) console.log(`DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner (named instance ${instance})`);
// 0.0.0.0 is reachable on loopback too; a specific address only at that address.
const urlHost = !lan || lan.bind === '0.0.0.0' ? '127.0.0.1' : lan.bind;
if (lan) {
console.error(`WARNING: dev owner Console listens on ${lan.bind}:${port} with no sign-in; anyone who can reach it acts as the owner.`);
console.log(`Listening: ${lan.bind}:${port}, allowed Host names ${lan.allowHosts.map(name => `${name}:${port}`).join(', ')}`);
}
console.log(`Mosaic Console: http://${urlHost}:${port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
// D2 (P0-3): the dev Console reattaches to the buses that still run and brings back the ones its
// unit stop killed. It runs after the Console answers, so a slow bus never delays the page.
if (organizations && trail) reviveBuses({ ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance }, listOrgs, busStatus: orgBusStatus, busRevive: orgBusRevive, trail, log: line => console.log(line) }).catch(() => console.log('bus revive: stopped on an unexpected error'));
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
process.exitCode = 2;
}