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.
152 lines
9.8 KiB
JavaScript
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);
|
|
});
|