Files
stack/packages/cli/tests/org-packs.test.mjs
jason.woltje 359b3299b4 feat(bus,cli,organization,webui): applying a starter pack is the hire decision (#1873, lane pd)
Ruling 2026-11-14: a person's pack apply records one hire decision per
proposed hire and applies it in the same act, through the hire-decisions
path, with the evidence an inbox approval leaves.

- bus: pack.apply.console runs the pack hire loop; pack.hire.console hires
  an applied record's open proposals. The owner's apply approves flagged and
  credential lines; a member with org.hire gets the unflagged hires and the
  rest open as awaiting-owner decisions; an agent apply still refuses
  human-required. Project packs hire into projects.<p>.agents. Nothing is
  hired twice. Credential lines are approved, not bound. New refusal codes
  pack-not-applied and pack-hire-route-missing. The instances read names the
  pack each hire came from; the packs read lists hired agents and open
  proposals with reasons.
- organization: pack.hire.console maps to org.hire; instances carry
  hireDecision.
- cli: mosaic org pack hire ID PACK [--project P]; apply prints the hires.
- webui: pack card shows N agents hired, open proposals with reasons and
  "Hire the N proposed agents"; Agents and organization pages redraw on
  mosaic-org-changed; agent cards say "from pack X"; Fire (not on the chief
  or assistant) removes the card at once with a receipt.
- docs: design doc section 3 dated note, TOOLS.md, package READMEs.
2026-11-14 15:40:10 -06:00

152 lines
9.8 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));
});
// Ruling 2026-11-14: "If I choose a starter pack and apply it, that is a decision!" The owner's apply
// hires the proposals in the same act; pack hire runs the same loop on the packs entry and hires none twice.
test('PD mosaic org pack apply hires the proposals; pack hire on the same record hires nothing twice', async (t) => {
const s = await setup(t);
assert.ok(PACKS_USAGE.includes('pack hire ID PACK [--project P]'));
const preview = await s.run(['pack', 'preview', 'acme', 'coding']);
const applied = await s.run(['pack', 'apply', 'acme', 'coding', '--digest', preview.digest]);
assert.deepEqual(applied.hires.map((x) => [x.name, x.state]), [['pm-1', 'hired'], ['coder-1', 'hired'], ['reviewer-1', 'hired']]);
for (const x of applied.hires) assert.match(String(x.decision), /^[0-9a-f-]{36}$/);
const doc = s.doc();
for (const x of applied.hires) { assert.equal(doc.roles[x.name].hireDecision, x.decision); assert.ok(doc.launch.instances.includes(x.name), x.name); }
const again = await s.run(['pack', 'hire', 'acme', 'coding']);
assert.deepEqual(again.hires.map((x) => [x.name, x.state]), [['pm-1', 'exists'], ['coder-1', 'exists'], ['reviewer-1', 'exists']]);
assert.deepEqual(s.calls.at(-1), ['pack.hire', ['pack']]);
await assert.rejects(s.run(['pack', 'hire', 'acme', 'ghost-pack']), (e) => e.code === 'pack-not-applied' && /apply it first/.test(e.message));
await assert.rejects(s.run(['pack', 'hire', 'acme']), (e) => e.exitCode === 4 && e.message === PACKS_USAGE);
});