Files
jason.woltje 23e504f331 fix(cli,bus,organization): E2E6 lane f6c, refusal lines name the next step (#1873)
1. Stale bus host lock refusals from bus status, start, stop and restart
   name mosaic bus recover ORG.
2. org definitions: unknown org, unknown or malformed name and bad id give
   one refusal or usage line, no stack trace, no host path.
3. Pack, board and workflow refusals print their hint as template save does;
   tested with the real CliError transport shape.
4. console start and stop clear a console/start.lock whose named supervisor
   (pid and start time) is gone; a live or unnamed holder refuses.
5. Template save keeps free text verbatim and writes no placeholders; the
   preview lists each field that mentions an org-specific name.
6. A request the shim never sent (no socket, connect refused) exits 5 and
   the CLI reports bus-not-running with the start and recover line.
7. autonomy-precondition carries the check that failed, including on the
   hire paths.
8. Saving under an existing template name refuses template-exists with a
   next step; refusal table entry added, channel-proof pins moved.
2026-11-13 20:14:21 -06:00

133 lines
8.3 KiB
JavaScript

// Starter packs SP4: `mosaic org pack|board|workflow`. Each word goes through the organization's bus as
// its human; pack apply writes the packs entry, and workflow remove takes out only that workflow.
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readFileSync, writeFileSync } from 'node:fs';
import { main } from '../src/cli.mjs';
import { ORG_USAGE, PACKS_USAGE } from '../src/org.mjs';
import { bootConfig, loadSystem } from '../src/config.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { fixture, io, tmp } from './helpers.mjs';
const FLOW = (title) => ({ description: `${title}.`, steps: [{ title, state: 'todo', roles: ['human'] }] });
async function setup(t) {
const root = tmp(t);
const f = fixture(root, 'acme');
const file = join(root, 'config/businesses/acme.json');
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
t.after(() => b.close());
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
const calls = [];
// The human transport's shape: a bus refusal arrives as its code.
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
calls.push([verb, Object.keys(args).sort()]);
try { return structuredClone(await b.broker.request(human, { verb, args })); }
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new Error(e.code), { code: e.code }); }
} };
const run = async (args, env = f.env) => { const x = io(env); await main(['org', ...args], x, deps); return JSON.parse(x.out.text); };
const doc = () => JSON.parse(readFileSync(file, 'utf8'));
return { root, f, file, calls, run, doc };
}
test('SP4 mosaic org pack: list reads the shipped packs, preview writes nothing, apply with its digest writes the packs entry', async (t) => {
const s = await setup(t);
assert.ok(ORG_USAGE.includes(PACKS_USAGE.slice('mosaic org '.length)));
const listed = await s.run(['pack', 'list']);
assert.ok(listed.packs.some((p) => p.id === 'coding' && p.origin === 'shipped'));
assert.deepEqual(s.calls, [], 'pack list reads the data root; it asks no bus');
const before = readFileSync(s.file);
const preview = await s.run(['pack', 'preview', 'acme', 'coding']);
assert.match(preview.digest, /^[0-9a-f]{64}$/);
assert.deepEqual(readFileSync(s.file), before, 'preview writes nothing');
await assert.rejects(s.run(['pack', 'apply', 'acme', 'coding']), (e) => e.exitCode === 4 && /--digest is required/.test(e.message));
await assert.rejects(s.run(['pack', 'apply', 'acme', 'coding', '--digest', '0'.repeat(64)]), (e) => e.code === 'pack-plan-stale' && /preview again/.test(e.message));
assert.deepEqual(readFileSync(s.file), before);
const applied = await s.run(['pack', 'apply', 'acme', 'coding', '--digest', preview.digest]);
assert.equal(applied.pack, 'coding');
const packs = s.doc().packs;
assert.equal(packs.length, 1);
assert.equal(packs[0].id, 'coding');
assert.equal(packs[0].origin, 'shipped');
assert.deepEqual(s.calls.map(([verb]) => verb), ['pack.preview', 'pack.apply', 'pack.apply']);
assert.deepEqual(s.calls[2][1], ['digest', 'pack']);
});
test('SP4 mosaic org workflow remove takes out only that workflow; board set changes only the board', async (t) => {
const s = await setup(t);
const spec = join(s.root, 'flow.json');
for (const name of ['one', 'two']) {
writeFileSync(spec, JSON.stringify(FLOW(name)));
await s.run(['workflow', 'set', 'acme', name, spec]);
}
writeFileSync(spec, JSON.stringify({ columns: [{ state: 'todo', label: 'Backlog' }, { state: 'in-progress', label: 'Building' }, { state: 'in-review', label: 'Review' }, { state: 'blocked', label: 'Blocked' }, { state: 'done', label: 'Merged' }], layout: 'kanban' }));
await s.run(['board', 'set', 'acme', spec]);
const before = s.doc();
assert.deepEqual(Object.keys(before.workflows).sort(), ['one', 'two']);
await s.run(['workflow', 'remove', 'acme', 'one']);
const after = s.doc();
assert.deepEqual(after.workflows, { two: before.workflows.two });
const { workflows: _a, ...restBefore } = before;
const { workflows: _b, ...restAfter } = after;
assert.deepEqual(restAfter, restBefore, 'nothing but the one workflow changed');
assert.deepEqual(s.calls.at(-1), ['workflow.set', ['name', 'revision', 'workflow']]);
// A file that is not JSON is named without its text; an unknown word prints the usage.
writeFileSync(spec, '{"title": fixture-not-json');
await assert.rejects(s.run(['workflow', 'set', 'acme', 'three', spec]), (e) => e.exitCode === 2 && !e.message.includes('fixture-not-json'));
await assert.rejects(s.run(['workflow', 'rename', 'acme', 'two']), (e) => e.exitCode === 4 && e.message === PACKS_USAGE);
});
test('SP4 an agent shell refuses every pack, board and workflow word, pack list too, before it reads a file or calls the bus', async (t) => {
const s = await setup(t);
const agent = { ...s.f.env, MOSAIC_RUN_ID: 'run-1' };
const spec = join(s.root, 'flow.json');
writeFileSync(spec, JSON.stringify(FLOW('one')));
const before = readFileSync(s.file);
for (const args of [['pack', 'list'], ['pack', 'preview', 'acme', 'coding'], ['pack', 'apply', 'acme', 'coding', '--digest', 'a'.repeat(64)], ['board', 'set', 'acme', spec], ['workflow', 'set', 'acme', 'one', spec], ['workflow', 'remove', 'acme', 'one']]) {
await assert.rejects(s.run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message), args.join(' '));
}
assert.deepEqual(s.calls, []);
assert.deepEqual(readFileSync(s.file), before);
});
// E2E6 fix 3: the real transport shape. humanTransport throws a CliError whose message and .code are
// the bus code alone (packages/cli/src/transport.mjs); pack, board and workflow refusals must still
// gain their hint, as template save's do, and a CliError with its own next line passes unchanged.
test('E2E6 pack, board and workflow refusals from a CliError-shaped transport print the hint', async (t) => {
const { CliError } = await import('../src/errors.mjs');
const { busExit, busNotRunning } = await import('../src/transport.mjs');
const root = tmp(t);
const f = fixture(root, 'acme');
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
t.after(() => b.close());
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
let down = false;
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
if (down) throw busNotRunning('acme');
try { return structuredClone(await b.broker.request(human, { verb, args })); }
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new CliError(e.code, busExit(e.code)), { code: e.code }); }
} };
const run = async (args) => { const x = io(f.env); await main(['org', ...args], x, deps); return JSON.parse(x.out.text); };
const hinted = (e) => typeof e.code === 'string' && e instanceof CliError && e.message.startsWith(`${e.code}: `) && e.message.length > e.code.length + 10;
await assert.rejects(run(['pack', 'preview', 'acme', 'no-such-pack']), (e) => e.code === 'starter-pack-not-found' && e.message === 'starter-pack-not-found: no installed, shipped or saved pack has that name');
const spec = join(root, 'board.json');
writeFileSync(spec, JSON.stringify({ columns: [{ state: 'todo', label: 'Backlog' }], layout: 'kanban' }));
await assert.rejects(run(['board', 'set', 'acme', spec]), (e) => hinted(e) && e.exitCode === busExit(e.code));
await assert.rejects(run(['workflow', 'remove', 'acme', 'ghost']), (e) => hinted(e) && e.exitCode === busExit(e.code));
// A stopped bus keeps its own message and next line.
down = true;
await assert.rejects(run(['pack', 'preview', 'acme', 'no-such-pack']), (e) => e.code === 'bus-not-running' && e.exitCode === 3
&& e.message === 'bus-not-running: the bus for acme is not running, so the request was not sent' && /mosaic bus start acme/.test(e.next) && /mosaic bus recover acme/.test(e.next));
});