Files
jason.woltje bfd2cc5e79 fix(bus,cli,webui): a pack hire falls back to the org assistant's harness and model (#1873, lane pr2)
pack.state gave a pack hire only the chief of staff's resolved route, so an
org whose chief has no model refused every hire pack-hire-route-missing. The
route is now the chief's (launch.by), else the org assistant's (the instance
whose definition is assistant; the one named assistant when there are several,
else the first in key order), else null. Both resolve through resolveInstance,
need harness and model, and carry thinking when set. pack.state adds routeFrom,
and each hired or awaiting-owner receipt line carries it. Refusal next step,
CLI hint, WebUI hire reason and the bus, CLI and TOOLS docs say the refusal now
means neither has a route.
2026-11-14 18:32:01 -06:00

241 lines
23 KiB
JavaScript

import { readFile, writeFile } from 'node:fs/promises';
import { configDir } from '../../organization/src/organization-config.mjs';
import { basename } from 'node:path';
import { listOrgs, orgLock, resolveOrg } from '../../organization/src/org-registry.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, projectRepoSet, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
import { installedStarterPacks } from '../../plugins/src/starter-pack.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem, orgPaths, rolesDir } from './config.mjs';
import { CliError } from './errors.mjs';
import { busStart, busStopForDelete, copyRemoteFor } from './org-bus.mjs';
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
import { consoleBusRestart } from './org-console.mjs';
import { busExit, humanTransport, refuseInsideAgent } from './transport.mjs';
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs';
// Starter packs SP4 (design section 8): the owner's pack, board and workflow words.
export const PACKS_USAGE = 'mosaic org pack list | pack preview ID PACK [--project P] [--as KIND.NAME=NEW]... | pack apply ID PACK --digest D [--project P] [--as KIND.NAME=NEW]... | pack hire ID PACK [--project P] | board set ID FILE [--project P] | workflow set ID NAME FILE [--project P] | workflow remove ID NAME [--project P]';
// #1873 SP8: saving the organization, or one project, as a template; the owner's alone.
export const TEMPLATE_USAGE = 'mosaic org template save ID NAME [--project P] (--preview | --digest D) | template list ID';
export const ORG_USAGE = 'mosaic org lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | ' + PACKS_USAGE.slice('mosaic org '.length) + ' | ' + TEMPLATE_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } }
// agent-guard (#1873): archive and delete stop the organization's bus, so an agent run is refused
// as it is for `mosaic bus stop`: after the usage checks, before any config is read, and checking
// this process's own environment as well as io.env. Restore starts the organization's bus (E1, P1-4),
// so it is refused the same way unless --no-bus is given; `restore --no-bus` starts nothing.
function refuseAgentRun(io) { refuseInsideAgent(process.env); refuseInsideAgent(io.env); }
// E1 (P1-4): the bus a restore starts is the one `mosaic bus start ID --dev-local-owner --detach`
// starts, after that verb's checks. The checks run before the restore, so a refusal changes nothing.
function restoreBusCheck(system, env) {
try { assertBuildStart({ devOwner: true }); assertDevInstance(system, env); }
catch (e) { throw new CliError(`restore starts the bus as the local dev owner, and ${e.message}; restore with --no-bus, then start the bus with mosaic bus start`, 3); }
}
// Starter packs SP4: `mosaic org pack|board|workflow`. Every word is the owner's and goes through the
// organization's bus as its human, so it runs only from a human shell: the bus holds the credential
// store whose held-secret check each preview and write needs, and the open hire decisions a preview
// reads. A refusal arrives as its code; a FILE's text is never echoed.
async function packFile(file) {
try { return JSON.parse(await readFile(file, 'utf8')); }
catch (e) { throw new CliError(`cannot read ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); }
}
// deps.callFor or deps.transport stand in for the bus in tests; otherwise the call is callFor's in cli.mjs.
function packCall(id, io, deps) {
if (typeof deps.callFor === 'function') return deps.callFor({ positional: [id], flags: new Set(), values: { '--business': id } });
if (typeof deps.transport === 'function') return deps.transport(id);
return async (verb, args) => {
const dir = configDir(io.env);
const match = /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir));
if (!match) throw new CliError('organization commands require an instance config directory', 3);
const system = deps.system ?? loadSystem({ env: io.env, instance: match[1] });
const org = await resolveOrg({ configDir: dir, dataRoot: system.dataRoot, instance: match[1], env: io.env, system }, id);
if (org.state !== 'active') throw new CliError('organization is not active', 3);
return humanTransport({ socket: orgPaths(org.root).socket, business: id, env: io.env })(verb, args);
};
}
const PACK_REFUSALS = {
'pack-plan-stale': 'the organization or the pack changed since the preview; preview again and apply its digest',
'revision-conflict': 'the organization file changed while this ran; run it again',
'credential-leak': 'the request holds a credential the organization\'s bus holds; nothing was written',
'invalid-request': 'the bus refused the request: a bad argument, a project that is not a code project, or a pack that fails the composition check',
'human-required': 'only the organization\'s owner, from a human shell, may do this',
'starter-pack-not-found': 'no installed, shipped or saved pack has that name',
'starter-pack-invalid': 'the pack fails its own checks or the composition check; nothing was written',
'starter-pack-template-changed': 'a template the pack names changed since it was loaded; preview again',
'not-configured': 'this organization\'s bus has no organization writer configured',
// Ruling 2026-11-14: apply and pack hire hire the pack's proposals. These come back as a hire's code.
'pack-not-applied': 'that pack has no packs entry in this organization or project; preview and apply it first',
'pack-hire-route-missing': 'neither the chief of staff nor the assistant has a harness and model to give a hire; set both in either one\'s vars, then run pack hire',
'no-chief-of-staff': 'the organization has no chief of staff to raise its hires',
'right-required': 'hiring needs the org.hire right',
};
// E2E6 fix 3: humanTransport throws a CliError whose message and .code are the bus code alone. A code
// a table knows gains its hint, as template save's do; a next line it carried is kept. Any other
// CliError (bus-not-running with its own next line, a usage error) passes unchanged, and a bare coded
// error (a test's call) becomes a CliError named by its code.
function hinted(e, ...tables) {
const code = typeof e?.code === 'string' ? e.code : '';
const table = tables.find(t => Object.hasOwn(t, code));
if (!table && (code === '' || e instanceof CliError)) return e;
const error = new CliError(table ? `${code}: ${table[code]}` : code, busExit(code), { next: e.next });
error.code = code;
return error;
}
async function packsCommand(args, io, deps) {
const pos = [], values = {}, as = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (['--project', '--digest', '--as'].includes(a)) {
if (!args[i + 1] || args[i + 1].startsWith('--') || (a !== '--as' && values[a] !== undefined)) throw new CliError(`${a} needs one value`, 4);
if (a === '--as') as.push(args[++i]); else values[a] = args[++i];
} else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4);
else pos.push(a);
}
const allowed = (...keys) => { for (const k of [...Object.keys(values), ...(as.length ? ['--as'] : [])]) if (!keys.includes(k)) throw new CliError(`unexpected ${k}`, 4); };
const project = values['--project'] === undefined ? {} : { project: values['--project'] };
const [word, sub, id, name, file] = pos;
// Every word is the human's: an agent run refuses before a file is read, a pack is loaded or the bus is called.
refuseAgentRun(io);
let verb, request, revised = false;
if (word === 'pack' && sub === 'list' && pos.length === 2) { allowed(); verb = 'packs'; request = {}; }
else if (word === 'pack' && ['preview', 'apply'].includes(sub) && pos.length === 4) {
allowed('--project', '--as', ...(sub === 'apply' ? ['--digest'] : []));
if (sub === 'apply' && !values['--digest']) throw new CliError('--digest is required: apply the digest a preview printed', 4);
verb = `pack.${sub}`;
request = { pack: name, ...project, ...(as.length ? { as } : {}), ...(sub === 'apply' ? { digest: values['--digest'] } : {}) };
} else if (word === 'pack' && sub === 'hire' && pos.length === 4) {
// Ruling 2026-11-14: hire the proposals of a pack already applied, the same loop apply runs.
allowed('--project'); verb = 'pack.hire'; request = { pack: name, ...project };
} else if (word === 'board' && sub === 'set' && pos.length === 4) { allowed('--project'); verb = 'board.set'; revised = true; request = { board: await packFile(name), ...project }; }
else if (word === 'workflow' && sub === 'set' && pos.length === 5) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: await packFile(file), ...project }; }
else if (word === 'workflow' && sub === 'remove' && pos.length === 4) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: null, ...project }; }
else throw new CliError(PACKS_USAGE, 4);
// pack list names no organization, so it reads what the bus's `packs` verb reads (the shipped and
// installed packs, never a saved template) from this instance's data root.
if (verb === 'packs') {
const system = deps.system ?? loadSystem({ env: io.env, instance: devOwnerInstance(io.env) });
const { packs } = await installedStarterPacks({ dataRoot: system.dataRoot, rolesDir: rolesDir(io.env) });
io.stdout.write(JSON.stringify({ packs: packs.map(({ id, origin, pack }) => ({ id, origin, pack })) }, null, 2) + '\n');
return;
}
const call = packCall(id, io, deps);
if (revised) {
const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) });
request = { revision: writer.read(id).revision, ...request };
}
let result;
try { result = await call(verb, request); }
catch (e) { throw hinted(e, PACK_REFUSALS); }
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
// #1873 SP8: template save previews (--preview) or writes the previewed JSON (--digest); template
// list prints the owner's saved templates. Both call the bus as the owner's human; an agent run is
// refused before the bus is called.
const TEMPLATE_REFUSALS = {
'template-secret-suspected': 'a field the template would keep holds a credential the organization\'s bus holds; remove it and preview again (nothing was written)',
'pack-plan-stale': 'the organization or a template changed since the preview; preview again and save its digest',
'template-exists': 'a template of this name is already saved for your account; nothing was written',
'invalid-request': 'the bus refused the request: a bad name, an unknown project, or an organization the template cannot hold',
'human-required': 'only the organization\'s owner, from a human shell, may do this',
'not-configured': 'the templates directory is missing or not private to this instance\'s user',
'starter-pack-invalid': 'the template would not be a valid pack: more than 8 hires, or a definition wider than its template',
};
async function templateCommand(args, io, deps) {
const pos = [], values = {}, flags = new Set();
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (['--project', '--digest'].includes(a)) {
if (!args[i + 1] || args[i + 1].startsWith('--') || values[a] !== undefined) throw new CliError(`${a} needs one value`, 4);
values[a] = args[++i];
} else if (a === '--preview') { if (flags.has(a)) throw new CliError('--preview is given twice', 4); flags.add(a); }
else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4);
else pos.push(a);
}
const [, sub, id, name] = pos;
refuseAgentRun(io);
let verb, request;
if (sub === 'save' && pos.length === 4 && flags.has('--preview') !== (values['--digest'] !== undefined)) {
verb = 'template.save';
request = { name, ...(values['--project'] === undefined ? {} : { project: values['--project'] }), ...(flags.has('--preview') ? { preview: true } : { digest: values['--digest'] }) };
} else if (sub === 'list' && pos.length === 3 && !flags.size && !Object.keys(values).length) { verb = 'template.list'; request = {}; }
else throw new CliError(TEMPLATE_USAGE, 4);
let result;
try { result = await packCall(id, io, deps)(verb, request); }
catch (e) {
const error = hinted(e, TEMPLATE_REFUSALS, PACK_REFUSALS);
// E2E6 fix 8: a taken name says what to do next.
if (error.code === 'template-exists' && error.next === undefined) error.next = `preview and save under another name, or see the saved names with mosaic org template list ${id}`;
throw error;
}
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
const busStartVerb = id => `mosaic bus start ${id} --dev-local-owner --detach`;
export async function orgCommand(args, io, deps = {}) {
try {
// #1873 T1: the definitions verbs take their own options.
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
// E2 (#1873, P1-3): the hire form and the autonomy panel, through the running Console (org-owner.mjs).
if (ORG_OWNER_VERBS.includes(args[0])) return await orgOwnerCommand(args, io, deps);
if (['pack', 'board', 'workflow'].includes(args[0])) return await packsCommand(args, io, deps);
if (args[0] === 'template' && ['save', 'list'].includes(args[1])) return await templateCommand(args, io, deps);
const p = parse(args); const words = p.pos; let result; let accepted = [];
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
if (words[0] === 'template' && words[1] === 'render' && words.length === 3) { allowed([], ['--fixture', '--output']); result = await templateRender(words[2], { fixture: p.values['--fixture'] }); await writeFile(need('--output'), JSON.stringify(result, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); io.stdout.write(`rendered ${words[2]} to ${p.values['--output']}\n`); return; }
if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; }
// U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read.
if (words[0] === 'reload') refuseInsideAgent(io.env);
const revision = (r, required) => { if (r === undefined && !required) return undefined; if (!/^[1-9][0-9]*$/.test(r ?? '')) throw new CliError(r === undefined ? '--if-revision is required' : 'invalid revision', 4); return Number(r); };
let lifecycle = null;
if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json', ...(words[0] === 'restore' ? ['--no-bus'] : [])], ['--if-revision']); lifecycle = { ifRevision: revision(p.values['--if-revision'], true) }; if (words[0] === 'archive' || !p.flags.has('--no-bus')) refuseAgentRun(io); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const ifRevision = revision(p.values['--if-revision'], false); lifecycle = { confirm: need('--confirm'), ifRevision }; refuseAgentRun(io); }
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx) });
else if (words[0] === 'restore' && words.length === 2) {
const startBus = !p.flags.has('--no-bus');
if (startBus) restoreBusCheck(system, io.env);
result = await restoreOrg(ctx, words[1], lifecycle);
// The restore stands whatever the bus does. A bus that does not start is said so, with the verb
// that starts it, and the command exits 3 after printing the restore receipt.
if (startBus) {
try { const started = await (deps.busStart ?? busStart)({ ...ctx, env: io.env }, words[1], { detach: true, devLocalOwner: true, pm: false }); result = { ...result, bus: { outcome: 'started', pid: started.pid } }; }
catch (e) {
// A host that died left its lock behind; `bus start` refuses it until `bus recover` clears it.
const message = e instanceof CliError ? e.message : 'the bus did not start', dead = /^stale /.test(message);
result = { ...result, bus: { outcome: 'failed', message, next: dead ? `mosaic bus recover ${words[1]}; ${busStartVerb(words[1])}` : busStartVerb(words[1]) } };
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
throw new CliError(`${words[1]} was restored, but its bus did not start: ${message}; ${dead ? `clear the dead host with mosaic bus recover ${words[1]}, then start it with` : 'start it with'} ${busStartVerb(words[1])}`, 3);
}
}
}
else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx), ...lifecycle });
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
else if (words[0] === 'show' && words.length === 2) { allowed(['--json'], []); result = await showOrg(ctx, words[1]); }
else if (words[0] === 'list' && words.length === 1) { allowed(['--json'], []); for (const entry of await listOrgs(ctx)) if (entry.state === 'active' || entry.state === 'archived') accepted.push(await showOrg(ctx, entry.id, { includeArchived: true })); result = accepted; }
else if ((words[0] === 'queue' && words.length === 3) || (words[0] === 'project' && words[1] === 'repo' && words.length === 5)) {
const project = words[0] === 'project', op = words[project ? 2 : 1], id = words[project ? 3 : 2], name = project ? words[4] : null;
if (op === 'show') { allowed(['--json'], []); result = project ? await projectRepoShow(ctx, id, name) : await queueShow(ctx, id); }
else if (op === 'init') { allowed(['--local-only', '--accept-local-loss-risk'], ['--remote']); if (p.flags.has('--local-only') === Boolean(p.values['--remote'])) throw new CliError('select exactly one of --local-only or --remote', 4); const opts = { mode: p.flags.has('--local-only') ? 'local-only' : 'remote', remote: p.values['--remote'], acceptLocalLossRisk: p.flags.has('--accept-local-loss-risk') }; result = project ? await projectRepoInit(ctx, id, name, opts) : await queueInit(ctx, id, opts); }
// #1873 P1-7: change the project's repository (remote URL, or remote to local-only and back).
// The same URL rules and check as init; origin follows; nothing is pushed.
else if (op === 'set' && project) { allowed(['--json', '--local-only', '--accept-local-loss-risk'], ['--remote', '--if-revision']); if (p.flags.has('--local-only') === Boolean(p.values['--remote'])) throw new CliError('select exactly one of --local-only or --remote', 4); const ifRevision = revision(p.values['--if-revision'], true); result = await projectRepoSet(ctx, id, name, { mode: p.flags.has('--local-only') ? 'local-only' : 'remote', remote: p.values['--remote'], acceptLocalLossRisk: p.flags.has('--accept-local-loss-risk'), ifRevision }); }
else throw new CliError(ORG_USAGE, 4);
} else throw new CliError(ORG_USAGE, 4);
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
} catch (e) {
if (e instanceof CliError) throw e;
// A raw Node filesystem error ("CODE: description, syscall 'path'") is named by its operation
// and code, never by the host path in its message (PR #1916).
const message = typeof e?.syscall === 'string' && typeof e.code === 'string' && e.message.startsWith(`${e.code}:`) ? `cannot ${e.syscall} (${errorCode(e)})` : e.message;
throw new CliError(`${e.code ? e.code + ': ' : ''}${message}`, 2);
}
}