The broker no longer imports organization util or definitions; the proposal pin digest is digestPrd and the inline composition is built in place, so the sandbox client import closure project-runtime.test.mjs pins is unchanged. An agent apply the runtime skips (a repeat that adds nothing) never reaches beforeWrite and spends no grant. A proposal raise checks role.hire before it reads the packs list. An agent apply keeps a workflow step only for a pack role the organization defines or one the pack pins to the template of that name; an inline definition sharing a template's name is left out. Tests: a repeat apply spends nothing, apply then raise end to end with the broker booted from the file, a project pack, the template-name shadow, and a chief without role.hire; the revision refusal is matched exactly.
385 lines
27 KiB
JavaScript
385 lines
27 KiB
JavaScript
// #1873 SP5: the pack, board and workflow verbs for an agent whose role file lists
|
|
// organization.change, and hire.raise from a pack's recorded proposal (design section 3, "The
|
|
// chief applying a pack", and section 8, SP5). The chief's authority is read from the shipped
|
|
// roles/chief-of-staff.json, so the test pins the grant the stack runs.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { Broker } from '../src/broker.mjs';
|
|
import { Store } from '../src/store.mjs';
|
|
import { REFUSALS } from '../src/refusals.mjs';
|
|
import { pluginHash } from '../../plugins/src/pinned.mjs';
|
|
import { installPlugin } from '../../plugins/src/install.mjs';
|
|
import { starterPack as codingPack } from '../../plugins/starter-pack/coding/index.mjs';
|
|
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
|
import { organizationWriter } from '../../organization/src/organization.mjs';
|
|
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
|
import { compositionOf, entryFromTemplate, readTemplate, templateHash } from '../../organization/src/definitions.mjs';
|
|
import { canonicalJson } from '../../organization/src/util.mjs';
|
|
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
|
|
|
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
|
const chiefAuthority = shipped('chief-of-staff').authority;
|
|
const sha = (text) => createHash('sha256').update(text).digest('hex');
|
|
const ROUTE = { harness: 'pi', model: 'claude-opus-5-5' };
|
|
|
|
// The SP4 harness (packs.test.mjs) with the shipped chief grant and a coder without it. With
|
|
// fromFile, the broker boots from the loaded organization file, as autonomy-authority.test.mjs does,
|
|
// so the reload after a write reaches the broker and a later verb reads what was written.
|
|
async function harness(t, { mutate = () => {}, fromFile = false } = {}) {
|
|
const { startBroker } = await import('../src/runtime.mjs');
|
|
const root = mkdtempSync(join(tmpdir(), 'packs5-'));
|
|
const rolesDir = rolesCopy(root);
|
|
const doc = businessDoc(root, 'demo');
|
|
doc.plugins = { harness: ['pi'] };
|
|
mutate(doc);
|
|
writeJson(join(root, 'businesses/demo.json'), doc);
|
|
const data = join(root, 'data');
|
|
mkdirSync(data, { mode: 0o700 });
|
|
const roles = {
|
|
chief: { definition: 'chief-of-staff', authority: chiefAuthority },
|
|
coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
};
|
|
const loaded = () => { const l = loadBusiness('demo', { dir: root, rolesDir }); return { ...l, roles: Object.fromEntries(Object.entries(l.roles).map(([n, e]) => [n, { ...l.definitions[e.definition], ...e }])) }; };
|
|
const businesses = { demo: fromFile ? loaded() : { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles } };
|
|
const bus = await startBroker({ dataRoot: data, businesses, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
|
const thaw = (p) => { const st = lstatSync(p); if (st.isSymbolicLink()) return; if (st.isDirectory()) { chmodSync(p, 0o700); for (const e of readdirSync(p)) thaw(join(p, e)); } };
|
|
t.after(async () => { await bus.close(); thaw(root); rmSync(root, { recursive: true, force: true }); });
|
|
const b = bus.broker;
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief-run' });
|
|
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
|
b.request(chief, { verb: 'role.claim' });
|
|
b.request(coder, { verb: 'role.claim' });
|
|
const call = async (cap, verb, args = {}) => b.request(cap, { verb, args });
|
|
const path = join(root, 'businesses/demo.json');
|
|
const file = () => JSON.parse(readFileSync(path, 'utf8'));
|
|
const revision = () => b.request(human, { verb: 'limits' }).revision;
|
|
let n = 0;
|
|
const install = (pack) => {
|
|
const src = join(root, `src-${n++}`);
|
|
mkdirSync(join(src, 'starter-pack'), { recursive: true });
|
|
writeFileSync(join(src, 'starter-pack', 'index.mjs'), `export const interfaceVersion = 1;\nexport function starterPack() { return ${JSON.stringify(pack)}; }\n`);
|
|
const manifest = { name: pack.id, version: '1.0.0', kinds: ['starter-pack'], interfaceVersions: { 'starter-pack': 1 }, sha256: '0'.repeat(64),
|
|
source: { path: 'fixture', version: '1.0.0' }, signature: null, tools: [], actions: [], services: [], roleTemplates: [] };
|
|
writeFileSync(join(src, 'plugin.json'), JSON.stringify(manifest));
|
|
manifest.sha256 = pluginHash(src);
|
|
writeFileSync(join(src, 'plugin.json'), JSON.stringify(manifest));
|
|
installPlugin({ dataRoot: data, source: src, expectedHash: manifest.sha256 });
|
|
};
|
|
// The broker's own database, read-only: grants spent and decisions raised.
|
|
const query = (sql) => { const db = new DatabaseSync(join(data, 'bus/bus.sqlite'), { readOnly: true }); try { return db.prepare(sql).all(); } finally { db.close(); } };
|
|
const spent = () => query("SELECT body FROM events WHERE kind='action.allowed' ORDER BY seq").map(e => JSON.parse(e.body)).filter(e => e.action === 'organization.change').length;
|
|
const decisions = () => query('SELECT count(*) n FROM decisions')[0].n;
|
|
return { b, root, rolesDir, path, human, chief, coder, call, file, revision, install, spent, decisions };
|
|
}
|
|
const variant = (id, patch = () => {}) => { const p = structuredClone(codingPack()); p.id = id; patch(p); return p; };
|
|
const marks = (preview) => Object.fromEntries(preview.items.map(i => [i.path, i.mark]));
|
|
|
|
test('SP5 the chief applies the coding pack: board and workflows written, no definitions, template pins, one grant spent', async t => {
|
|
const { call, chief, file, path, rolesDir, spent, decisions } = await harness(t);
|
|
const before = file();
|
|
const preview = await call(chief, 'pack.preview', { pack: 'coding' });
|
|
assert.equal(spent(), 0);
|
|
assert.deepEqual(readFileSync(path, 'utf8'), JSON.stringify(before, null, 2) + '\n');
|
|
// A definition a human apply would add is not written for an agent; its hire pins the template.
|
|
for (const name of ['coder', 'cto', 'merge-gate', 'pm', 'reviewer', 'security-reviewer']) assert.equal(marks(preview)[`definitions.${name}`], 'not-written');
|
|
assert.equal(marks(preview).board, 'add');
|
|
assert.equal(marks(preview)['workflows.change'], 'add');
|
|
assert.equal(preview.plan.definitions, undefined);
|
|
for (const h of preview.hires) {
|
|
assert.equal(h.mark, 'propose');
|
|
assert.deepEqual(h.pin, { template: h.definition, sha256: templateHash(rolesDir, h.definition) });
|
|
}
|
|
await call(chief, 'pack.apply', { pack: 'coding', digest: preview.digest });
|
|
const after = file();
|
|
assert.equal(after.board.layout, 'kanban');
|
|
assert.deepEqual(after.board, codingPack().board);
|
|
assert.deepEqual(after.workflows.change.steps.map(s => s.roles), [['pm'], ['coder'], ['reviewer'], ['human', 'merge-gate']]);
|
|
assert.deepEqual(after.definitions ?? null, before.definitions ?? null);
|
|
assert.equal(after.packs.length, 1);
|
|
assert.equal(after.packs[0].by, 'chief-of-staff.chief');
|
|
assert.deepEqual(after.packs[0].added.sort(), ['board', 'workflows.change']);
|
|
assert.deepEqual(after.packs[0].proposedHires.map(h => [h.name, h.pin.template]), [['pm-1', 'pm'], ['coder-1', 'coder'], ['reviewer-1', 'reviewer']]);
|
|
assert.equal(spent(), 1);
|
|
assert.equal(decisions(), 0);
|
|
});
|
|
|
|
test('SP5 a repeat apply that writes nothing spends no grant', async t => {
|
|
const { call, chief, file, spent } = await harness(t);
|
|
const first = await call(chief, 'pack.preview', { pack: 'coding' });
|
|
await call(chief, 'pack.apply', { pack: 'coding', digest: first.digest });
|
|
assert.equal(spent(), 1);
|
|
const before = file();
|
|
// The same pack again: the board and workflow are the same and the proposals unchanged, so the
|
|
// runtime skips the write and the broker spends nothing.
|
|
const again = await call(chief, 'pack.preview', { pack: 'coding' });
|
|
const out = await call(chief, 'pack.apply', { pack: 'coding', digest: again.digest });
|
|
assert.deepEqual(out.added, []);
|
|
assert.deepEqual(file(), before);
|
|
assert.equal(spent(), 1);
|
|
});
|
|
|
|
test('SP5 end to end: the chief applies a pack, the organization reloads, and the chief raises a hire from the recorded proposal', async t => {
|
|
const { call, chief, file, rolesDir, decisions } = await harness(t, { fromFile: true });
|
|
const preview = await call(chief, 'pack.preview', { pack: 'coding' });
|
|
await call(chief, 'pack.apply', { pack: 'coding', digest: preview.digest });
|
|
assert.equal(decisions(), 0);
|
|
// The raise reads the packs list the broker reloaded after the write, not a fixture's.
|
|
const d = await call(chief, 'hire.raise', { proposal: { pack: 'coding', hire: 'coder-1' }, route: ROUTE });
|
|
assert.equal(d.hire.name, 'coder-1');
|
|
assert.equal(d.hire.level, 'org');
|
|
assert.equal(decisions(), 1);
|
|
const direct = await call(chief, 'hire.raise', { op: 'hire', level: 'org', name: 'coder-b', role: { template: 'coder', sha256: templateHash(rolesDir, 'coder') }, route: ROUTE, reason: 'Code.' });
|
|
assert.deepEqual(d.hire.role, direct.hire.role);
|
|
assert.deepEqual(d.hire.flagged, direct.hire.flagged);
|
|
// A second preview leaves out the hire the open decision names; the file is unchanged by the raise.
|
|
assert.equal((await call(chief, 'pack.preview', { pack: 'coding' })).hires.find(h => h.name === 'coder-1').mark, 'dropped');
|
|
assert.equal(file().packs.length, 1);
|
|
});
|
|
|
|
test('SP5 a project pack: the chief applies it to a code project and raises the project hire from it', async t => {
|
|
const { call, chief, file, install, decisions } = await harness(t, { fromFile: true });
|
|
install(variant('projkit', p => { p.scope = 'project'; }));
|
|
const preview = await call(chief, 'pack.preview', { pack: 'projkit', project: 'stack' });
|
|
assert.equal(marks(preview)['projects.stack.board'], 'add');
|
|
assert.equal(marks(preview)['definitions.coder'], 'not-written');
|
|
await call(chief, 'pack.apply', { pack: 'projkit', project: 'stack', digest: preview.digest });
|
|
assert.deepEqual(file().projects.stack.board, codingPack().board);
|
|
assert.equal(file().packs.at(-1).project, 'stack');
|
|
// The proposal names its project; without it, no org-level entry matches.
|
|
await assert.rejects(call(chief, 'hire.raise', { proposal: { pack: 'projkit', hire: 'coder-1' }, route: ROUTE }), { code: 'invalid-request' });
|
|
const d = await call(chief, 'hire.raise', { proposal: { pack: 'projkit', project: 'stack', hire: 'coder-1' }, route: ROUTE });
|
|
assert.equal(d.hire.level, 'project');
|
|
assert.equal(d.hire.project, 'stack');
|
|
assert.equal(decisions(), 1);
|
|
});
|
|
|
|
test('SP5 an inline pack definition that shares a template name does not keep its workflow step', async t => {
|
|
const { call, chief, install, rolesDir } = await harness(t);
|
|
const { rev: _rev, ...entry } = entryFromTemplate(readTemplate(rolesDir, 'coder'));
|
|
install(variant('shadowkit', p => {
|
|
p.definitions = { reviewer: { ...entry, instructions: entry.instructions + '\nShadow.' } };
|
|
p.hires = [];
|
|
p.workflows = { check: { description: 'Check.', steps: [{ id: 'look', title: 'Look', state: 'todo', roles: ['human'] }, { id: 'review', title: 'Review it', state: 'in-review', roles: ['reviewer'] }] } };
|
|
}));
|
|
const preview = await call(chief, 'pack.preview', { pack: 'shadowkit' });
|
|
const item = preview.items.find(i => i.path === 'workflows.check');
|
|
assert.equal(item.mark, 'add');
|
|
assert.deepEqual(item.leftOut, [{ step: 2, title: 'Review it', roles: ['reviewer'] }]);
|
|
assert.deepEqual(preview.plan.workflows.check.steps.map(s => s.roles), [['human']]);
|
|
});
|
|
|
|
test('SP5 a role without organization.change gets decision-required for the writes and human-required for the packs read', async t => {
|
|
const { call, coder, chief, path, revision, install, spent } = await harness(t);
|
|
const before = readFileSync(path);
|
|
for (const [verb, args] of [['pack.apply', { pack: 'coding', digest: 'a'.repeat(64) }], ['board.set', { revision: revision(), board: null }],
|
|
['workflow.set', { revision: revision(), name: 'x', workflow: null }], ['pack.preview', { pack: 'coding' }]])
|
|
await assert.rejects(call(coder, verb, args), /decision-required/, verb);
|
|
await assert.rejects(call(coder, 'packs'), /human-required/);
|
|
// The console names stay the owner's.
|
|
await assert.rejects(call(chief, 'pack.preview.console', { pack: 'coding' }), /human-required/);
|
|
await assert.rejects(call(chief, 'packs.console'), /human-required/);
|
|
assert.deepEqual(readFileSync(path), before);
|
|
// The chief's packs read lists shipped and installed packs and spends nothing.
|
|
install(variant('teamkit'));
|
|
const listed = (await call(chief, 'packs')).packs;
|
|
assert.ok(listed.some(p => p.id === 'coding' && p.origin === 'shipped'));
|
|
assert.ok(listed.some(p => p.id === 'teamkit' && p.origin === 'installed'));
|
|
assert.equal(spent(), 0);
|
|
// The agent request carries no origin, actor or unknown key.
|
|
await assert.rejects(call(chief, 'pack.preview', { pack: 'coding', actor: 'jason' }), /invalid-request/);
|
|
await assert.rejects(call(chief, 'packs', { all: true }), /invalid-request/);
|
|
});
|
|
|
|
test('SP5 the chief writes a board and a workflow, each spending the grant once, with its own actor', async t => {
|
|
const { call, chief, file, revision, spent } = await harness(t);
|
|
await call(chief, 'board.set', { revision: revision(), board: codingPack().board });
|
|
assert.deepEqual(file().board, codingPack().board);
|
|
assert.equal(spent(), 1);
|
|
const workflow = { description: 'Intake.', steps: [{ title: 'Take it', state: 'todo', roles: ['human'] }, { title: 'Build it', state: 'in-progress', roles: ['coder'] }] };
|
|
await call(chief, 'workflow.set', { revision: revision(), name: 'intake', workflow });
|
|
assert.deepEqual(file().workflows.intake, workflow);
|
|
assert.equal(spent(), 2);
|
|
await call(chief, 'workflow.set', { revision: revision(), name: 'intake', workflow: null });
|
|
assert.equal(file().workflows, undefined);
|
|
assert.equal(spent(), 3);
|
|
// A refused write spends nothing.
|
|
await assert.rejects(call(chief, 'board.set', { revision: 'b'.repeat(64), board: null }), { code: 'revision-conflict' });
|
|
await assert.rejects(call(chief, 'workflow.set', { revision: revision(), name: 'x', workflow: { description: 'x', steps: [{ title: 'Do', state: 'todo', roles: ['nobody-at-all'] }] } }), /invalid-request/);
|
|
assert.equal(spent(), 3);
|
|
});
|
|
|
|
test('SP5 an agent apply never writes a definition or a saved template: human-required', async t => {
|
|
const { call, chief, path, root, rolesDir } = await harness(t);
|
|
const before = readFileSync(path);
|
|
// A definitions rename names a definition to write.
|
|
await assert.rejects(call(chief, 'pack.preview', { pack: 'coding', as: ['definitions.coder=dev'] }), /human-required/);
|
|
await assert.rejects(call(chief, 'pack.apply', { pack: 'coding', as: ['definitions.coder=dev'], digest: 'a'.repeat(64) }), /human-required/);
|
|
// A saved template refuses before anything is read; the name need not exist.
|
|
await assert.rejects(call(chief, 'pack.preview', { pack: 'saved:x' }), /human-required/);
|
|
await assert.rejects(call(chief, 'pack.apply', { pack: 'saved:x', digest: 'a'.repeat(64) }), /human-required/);
|
|
// A workflows rename is allowed.
|
|
const renamed = await call(chief, 'pack.preview', { pack: 'coding', as: ['workflows.change=ship'] });
|
|
assert.equal(marks(renamed)['workflows.ship'], 'add');
|
|
assert.deepEqual(readFileSync(path), before);
|
|
// The writer's own fence: the chief's actor with a definition in the plan, or a saved origin.
|
|
const writer = organizationWriter({ dir: root, rolesDir });
|
|
const { rev: _rev, ...entry } = entryFromTemplate(readTemplate(rolesDir, 'coder'));
|
|
const pack = { id: 'kit', version: '1.0.0', sha256: 'a'.repeat(64), origin: 'installed', scope: 'org', project: null, proposedHires: [] };
|
|
const opts = () => ({ revision: writer.read('demo').revision, actor: 'chief-of-staff.chief', assertClean: () => {}, detail: {} });
|
|
assert.throws(() => writer.applyPack('demo', { definitions: { dev: entry }, packs: pack }, opts()), /human-required/);
|
|
assert.throws(() => writer.applyPack('demo', { board: codingPack().board, packs: { ...pack, origin: 'saved' } }, opts()), /human-required/);
|
|
assert.deepEqual(readFileSync(path), before);
|
|
});
|
|
|
|
test('SP5 a workflow step naming a definition the agent apply cannot write is left out and marked', async t => {
|
|
const { call, chief, file, install, rolesDir } = await harness(t);
|
|
const { rev: _rev, ...scribe } = entryFromTemplate(readTemplate(rolesDir, 'coder'));
|
|
install(variant('scribekit', p => {
|
|
p.definitions = { scribe, pm: p.definitions.pm };
|
|
p.hires = [{ name: 'scribe-1', definition: 'scribe', reason: 'Take notes.' }];
|
|
p.workflows = {
|
|
notes: { description: 'Notes.', steps: [{ id: 'plan', title: 'Plan it', state: 'todo', roles: ['pm'] }, { id: 'write', title: 'Write it', state: 'in-progress', roles: ['scribe', 'human'] }] },
|
|
solo: { description: 'Solo.', steps: [{ id: 'write', title: 'Write it', state: 'todo', roles: ['scribe'] }] },
|
|
};
|
|
}));
|
|
const preview = await call(chief, 'pack.preview', { pack: 'scribekit' });
|
|
assert.equal(marks(preview)['definitions.scribe'], 'not-written');
|
|
const items = Object.fromEntries(preview.items.map(i => [i.path, i]));
|
|
assert.equal(items['workflows.notes'].mark, 'add');
|
|
assert.deepEqual(items['workflows.notes'].leftOut, [{ step: 2, title: 'Write it', roles: ['scribe'] }]);
|
|
assert.equal(items['workflows.solo'].mark, 'left-out');
|
|
assert.deepEqual(items['workflows.solo'].leftOut, [{ step: 1, title: 'Write it', roles: ['scribe'] }]);
|
|
assert.deepEqual(Object.keys(preview.plan.workflows), ['notes']);
|
|
// The full-entry definition's hire pins the inline entry.
|
|
const hire = preview.hires.find(h => h.name === 'scribe-1');
|
|
assert.deepEqual(hire.pin, { inline: 'scribe', sha256: sha(canonicalJson(scribe)) });
|
|
await call(chief, 'pack.apply', { pack: 'scribekit', digest: preview.digest });
|
|
assert.deepEqual(file().workflows, { notes: { description: 'Notes.', steps: [{ title: 'Plan it', state: 'todo', roles: ['pm'] }] } });
|
|
assert.equal(file().definitions?.scribe, undefined);
|
|
assert.deepEqual(file().packs.at(-1).proposedHires[0].entry, scribe);
|
|
});
|
|
|
|
// The hire-decisions.test.mjs fixture: a Broker on the loaded organization and the hire service.
|
|
function fixture(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'sp5-bus-')), rolesDir = rolesCopy(root);
|
|
const file = writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
|
const storeRoot = join(root, 'data'); mkdirSync(storeRoot, { mode: 0o700 });
|
|
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(storeRoot);
|
|
const service = hireDecisions({ writer, rolesDir, dataRoot: storeRoot, configDir: root, assertClean: () => {} });
|
|
const loaded = () => { const doc = loadBusiness('demo', { dir: root, rolesDir }); return { ...doc, roles: Object.fromEntries(Object.entries(doc.roles).map(([n, e]) => [n, { ...doc.definitions[e.definition], ...e }])) }; };
|
|
const b = new Broker({ store, businesses: { demo: loaded() }, hires: service });
|
|
const caps = { chief: b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }), coder: b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' }) };
|
|
b.request(caps.chief, { verb: 'role.claim' }); b.request(caps.coder, { verb: 'role.claim' });
|
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const call = (cap, verb, args = {}) => b.request(cap, { verb, args });
|
|
// A packs entry as an owner's apply records it, then the broker's organization reloads.
|
|
const record = (plan) => {
|
|
writer.applyPack('demo', plan, { revision: writer.read('demo').revision, actor: 'jason', assertClean: () => {}, detail: {} });
|
|
b.reloadBusiness('demo', loaded());
|
|
};
|
|
const pack = (proposedHires, extra = {}) => ({ id: 'kit', version: '1.0.0', sha256: 'a'.repeat(64), origin: 'installed', scope: 'org', project: null, proposedHires, ...extra });
|
|
// Edit the organization file directly, as a later change would, then reload.
|
|
const edit = (change) => { const doc = JSON.parse(readFileSync(file, 'utf8')); change(doc); writeJson(file, doc); b.reloadBusiness('demo', loaded()); };
|
|
const raise = (proposal, extra = {}) => call(caps.chief, 'hire.raise', { proposal, route: ROUTE, ...extra });
|
|
const count = () => store.get('SELECT count(*) n FROM decisions').n;
|
|
return { root, rolesDir, b, store, file, writer, caps, call, record, pack, edit, raise, count, loaded };
|
|
}
|
|
|
|
test('SP5 a hire raised from a template proposal carries the broker-computed role and lines, as a direct raise does', async t => {
|
|
const { rolesDir, record, pack, raise, call, caps, count } = fixture(t);
|
|
const pin = { template: 'reviewer', sha256: templateHash(rolesDir, 'reviewer') };
|
|
record({ packs: pack([{ name: 'scout', reason: 'Review the pack work.', pin }]) });
|
|
const d = await raise({ pack: 'kit', hire: 'scout' });
|
|
assert.equal(d.hire.name, 'scout');
|
|
assert.equal(d.hire.level, 'org');
|
|
// The lines are the broker's, the same as a direct raise of the same role computes.
|
|
const direct = await call(caps.chief, 'hire.raise', { op: 'hire', level: 'org', name: 'scout-b', role: pin, route: ROUTE, reason: 'Review.' });
|
|
assert.deepEqual(d.hire.role, direct.hire.role);
|
|
assert.deepEqual(d.hire.flagged, direct.hire.flagged);
|
|
assert.deepEqual(d.hire.credentialLines, direct.hire.credentialLines);
|
|
assert.ok(Array.isArray(d.hire.credentialLines));
|
|
assert.equal(count(), 2);
|
|
// hire.raise requires a reason and stores none. The raise above sent none, so the recorded one
|
|
// filled it; a caller's own reason replaces it, and an empty one refuses as a direct raise does.
|
|
await assert.rejects(raise({ pack: 'kit', hire: 'scout' }, { reason: '' }), { code: 'invalid-request' });
|
|
assert.equal((await raise({ pack: 'kit', hire: 'scout' }, { reason: 'Mine.' })).hire.name, 'scout');
|
|
assert.equal(count(), 3);
|
|
});
|
|
|
|
test('SP5 a proposal raise: role-not-chief, decision-stale, definition-rev-conflict, starter-pack-proposal-stale, definition-not-hireable', async t => {
|
|
const { rolesDir, record, pack, raise, call, caps, edit, count } = fixture(t);
|
|
const { rev: _rev, ...scribe } = entryFromTemplate(readTemplate(rolesDir, 'coder'));
|
|
const inline = { name: 'inl', reason: 'Inline.', pin: { inline: 'scribe', sha256: sha(canonicalJson(scribe)) }, entry: scribe };
|
|
record({
|
|
definitions: { helper: scribe },
|
|
packs: pack([
|
|
{ name: 'moved', reason: 'Moved.', pin: { template: 'reviewer', sha256: 'b'.repeat(64) } },
|
|
{ name: 'def', reason: 'Def.', pin: { definition: 'helper', rev: 1 } },
|
|
inline,
|
|
]),
|
|
});
|
|
// Only the chief raises, with or without a proposal.
|
|
await assert.rejects(call(caps.coder, 'hire.raise', { proposal: { pack: 'kit', hire: 'def' }, route: ROUTE }), { code: 'role-not-chief' });
|
|
await assert.rejects(raise({ pack: 'kit', hire: 'moved' }), { code: 'decision-stale' });
|
|
edit(doc => { doc.definitions.helper = { ...doc.definitions.helper, rev: 2, instructions: doc.definitions.helper.instructions + '\nAlso this.' }; });
|
|
await assert.rejects(raise({ pack: 'kit', hire: 'def' }), { code: 'definition-rev-conflict' });
|
|
// The inline entry stored with the proposal still hashes to its pin, so it raises.
|
|
const ok = await raise({ pack: 'kit', hire: 'inl' });
|
|
assert.equal(ok.hire.name, 'inl');
|
|
const direct = await call(caps.chief, 'hire.raise', { op: 'hire', level: 'org', name: 'inl-b', role: { inline: compositionOf(scribe) }, route: ROUTE, reason: 'Inline.' });
|
|
assert.deepEqual(ok.hire.role, direct.hire.role);
|
|
assert.equal(count(), 2);
|
|
// A later edit to the stored entry is caught at the raise.
|
|
edit(doc => { doc.packs[0].proposedHires[2].entry.instructions += '\nAnd more.'; });
|
|
await assert.rejects(raise({ pack: 'kit', hire: 'inl' }), { code: 'starter-pack-proposal-stale' });
|
|
// An inline entry from a template launch.noHire names refuses, though prepare reads no ref for it.
|
|
edit(doc => { doc.packs[0].proposedHires[2].entry = scribe; doc.launch.noHire = ['coder']; });
|
|
await assert.rejects(raise({ pack: 'kit', hire: 'inl' }), { code: 'definition-not-hireable' });
|
|
assert.equal(count(), 2);
|
|
});
|
|
|
|
test('SP5 a chief without role.hire is refused a proposal raise before the packs list is read', async t => {
|
|
const { record, pack, raise, b, loaded, rolesDir, count } = fixture(t);
|
|
record({ packs: pack([{ name: 'scout', reason: 'Review.', pin: { template: 'reviewer', sha256: templateHash(rolesDir, 'reviewer') } }]) });
|
|
const doc = loaded();
|
|
const authority = doc.roles.chief.authority;
|
|
b.reloadBusiness('demo', { ...doc, roles: { ...doc.roles, chief: { ...doc.roles.chief, authority: { ...authority, withinRole: authority.withinRole.filter(a => a !== 'role.hire') } } } });
|
|
await assert.rejects(raise({ pack: 'kit', hire: 'scout' }), { code: 'not-authorized' });
|
|
// A proposal that does not exist gets the same refusal, so the refusal leaks nothing.
|
|
await assert.rejects(raise({ pack: 'nosuch', hire: 'nobody' }), { code: 'not-authorized' });
|
|
assert.equal(count(), 0);
|
|
});
|
|
|
|
test('SP5 a proposal raise names no role, name, level or other hire key; unknown proposals refuse; a plain raise is unchanged', async t => {
|
|
const { rolesDir, record, pack, raise, call, caps, count } = fixture(t);
|
|
const pin = { template: 'reviewer', sha256: templateHash(rolesDir, 'reviewer') };
|
|
record({ packs: pack([{ name: 'scout', reason: 'Review.', pin }]) });
|
|
for (const extra of [{ role: pin }, { name: 'other' }, { level: 'org' }, { op: 'hire' }, { credentials: {} }, { personality: 'x' }, { binding: {} }])
|
|
await assert.rejects(raise({ pack: 'kit', hire: 'scout' }, extra), { code: 'invalid-request' }, JSON.stringify(extra));
|
|
for (const proposal of [{ pack: 'kit' }, { pack: 'kit', hire: 'nobody' }, { pack: 'other', hire: 'scout' }, { pack: 'kit', hire: 'scout', project: 'stack' },
|
|
{ pack: 'kit', hire: 'scout', role: 'x' }, { pack: 'kit', hire: 7 }, 'kit'])
|
|
await assert.rejects(raise(proposal), { code: 'invalid-request' }, JSON.stringify(proposal));
|
|
await assert.rejects(call(caps.chief, 'hire.raise', { proposal: { pack: 'kit', hire: 'scout' } }), { code: 'invalid-request' });
|
|
assert.equal(count(), 0);
|
|
// Without proposal, hire.raise is what it was.
|
|
const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read and report.' };
|
|
const d = await call(caps.chief, 'hire.raise', { op: 'hire', level: 'org', name: 'plain', role: { inline: INLINE }, route: ROUTE, reason: 'Need help' });
|
|
assert.equal(d.hire.name, 'plain');
|
|
assert.equal(count(), 1);
|
|
});
|
|
|
|
test('SP5 starter-pack-proposal-stale is in the refusal table with a rail and a next step that names no path', () => {
|
|
const c = 'starter-pack-proposal-stale';
|
|
assert.ok(Object.hasOwn(REFUSALS, c));
|
|
assert.ok(REFUSALS[c].rail?.length > 5);
|
|
assert.ok(REFUSALS[c].nextStep?.length > 20);
|
|
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep));
|
|
});
|