// #1873 Phase A slice A3: the broker half of root and project agent copies. The business carries the // boot's `copies` view (organization agent-copies.mjs withCopies): a bound copy is also in `roles`, // an unbound or refused one is not. The cli half, with real runners, is cli agent-copies.test.mjs. import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Store } from '../src/store.mjs'; import { BusError, Broker } from '../src/broker.mjs'; const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8')); const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; const SECRET = 'sk-test-NOT-A-SECRET-1234'; const copy = (name, definition, project, state, extra = {}) => ({ name, level: project === null ? 'root' : 'project', project, definition, state, ...extra }); const demo = () => ({ id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' }, definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, companion: { messaging: shipped('companion').messaging } }, projects: { stack: { root: '/nonexistent/stack' }, web: { root: '/nonexistent/web' }, docs: { root: '/nonexistent/docs' } }, roles: { chief: { definition: 'chief-of-staff', ...none }, pm: { definition: 'pm', ...none }, assistant: { definition: 'assistant', ...none }, gate: { definition: 'merge-gate', ...none }, 'companion.stack': { definition: 'companion', ...none }, buddy: { definition: 'companion', ...none }, }, copies: { gate: copy('gate', 'merge-gate', null, 'instance'), buddy: copy('buddy', 'companion', null, 'instance'), 'companion.stack': copy('companion', 'companion', 'stack', 'instance'), researcher: copy('researcher', 'research', null, 'unbound', { missing: ['gitea'] }), wide: copy('wide', 'merge-gate', null, 'refused', { reason: 'model is not in launch.models' }), 'lint.web': copy('lint', 'security-reviewer', 'web', 'refused', { reason: 'project web refuses its agents: ghost is excluded, but nothing defines it' }), }, // Each code project's union as withCopies computes it: web excludes buddy, docs refuses. unions: { stack: { ok: true, agents: ['assistant', 'buddy', 'chief', 'companion.stack', 'gate', 'pm'] }, web: { ok: true, agents: ['assistant', 'chief', 'gate', 'pm'] }, docs: { ok: false, reason: 'projects.docs.agents.ghost: names no root or organization agent to exclude' }, }, }); function setup(t) { const root = mkdtempSync(join(tmpdir(), 'bus-copies-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const b = new Broker({ store, businesses: { demo: demo() } }); const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const call = (verb, args = {}) => b.request(human, { verb, args }); const refusal = (fn) => { try { fn(); } catch (e) { assert.ok(e instanceof BusError, String(e)); return e; } assert.fail('no refusal'); }; const trail = () => store.all('SELECT kind, body FROM events').map((e) => `${e.kind} ${e.body}`).join('\n'); return { b, store, call, refusal, trail }; } test('A3 bus: a bound copy takes a launch request; an unbound or refused one refuses at the verb by name, with the code alone in the trail', (t) => { const f = setup(t); assert.match(f.call('instance.launch', { instance: 'gate' }).request, /\S/); const unbound = f.refusal(() => f.call('instance.launch', { instance: 'researcher' })); assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']); const refused = f.refusal(() => f.call('instance.launch', { instance: 'wide' })); assert.equal(refused.code, 'copy-refused'); assert.match(refused.reason, /^wide can't launch in demo: /); const project = f.refusal(() => f.call('instance.launch', { instance: 'lint.web' })); assert.equal(project.code, 'project-refused'); assert.match(project.reason, /^lint\.web can't launch in demo: project web refuses its agents: /); // Regenerate takes the same check. assert.equal(f.refusal(() => f.call('instance.regenerate', { instance: 'researcher' })).code, 'credential-unbound'); // A name that is no copy and no instance is still unknown. assert.equal(f.refusal(() => f.call('instance.launch', { instance: 'nobody' })).code, 'unknown-role'); // The trail holds each refusal's code, never its reason. const trail = f.trail(); assert.match(trail, /credential-unbound/); for (const text of ['has no binding', "can't launch", 'refuses its agents', SECRET]) assert.equal(trail.includes(text), false, text); }); test('A3 bus: a project agent takes a thread only in its own project, and only when named', (t) => { const f = setup(t); const stack = f.call('project.create', { name: 'stack work', repo: 'stack' }).id; const web = f.call('project.create', { name: 'web work', repo: 'web' }).id; const loose = f.call('project.create', { name: 'loose' }).id; // Unnamed, the organization file's own thread instance is still the one choice. const plain = f.call('thread.create', { project: stack, name: 'plain' }).id; assert.equal(f.store.get('SELECT instance FROM threads WHERE id=?', plain).instance, 'assistant'); const own = f.call('thread.create', { project: stack, name: 'own', instance: 'companion.stack' }); assert.match(own.id, /\S/); for (const project of [web, loose]) assert.equal(f.refusal(() => f.call('thread.create', { project, name: 'elsewhere', instance: 'companion.stack' })).code, 'project-agent-elsewhere'); // A named copy the boot refused says why, before the thread instance check. const unbound = f.refusal(() => f.call('thread.create', { project: stack, name: 'r', instance: 'researcher' })); assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']); }); test('A3 bus: a room holds only the thread instances that work in its project; an excluded agent takes no thread there; a refused union takes none at all, naming the project', (t) => { const f = setup(t); const project = (repo) => f.call('project.create', { name: `${repo ?? 'loose'} work`, ...(repo ? { repo } : {}) }).id; const [stack, web, docs, loose] = [project('stack'), project('web'), project('docs'), project(null)]; const members = (id) => f.call('room.open', { project: id }).threads.map((x) => x.instance).sort(); // Another project's agent never joins, and the project's own exclusion holds. assert.deepEqual(members(stack), ['assistant', 'buddy', 'companion.stack']); assert.deepEqual(members(web), ['assistant']); assert.deepEqual(members(loose), ['assistant', 'buddy']); assert.deepEqual(f.call('room.send', { project: web, body: 'hello' }).to, ['assistant']); const excluded = f.refusal(() => f.call('thread.create', { project: web, name: 'b', instance: 'buddy' })); assert.deepEqual([excluded.code, excluded.reason], ['project-refused', "buddy can't work in project web: the project excludes it"]); // docs's union refuses: no thread, named or not, and no room, each naming docs and why. for (const fn of [ () => f.call('thread.create', { project: docs, name: 'plain' }), () => f.call('thread.create', { project: docs, name: 'a', instance: 'assistant' }), () => f.call('room.open', { project: docs }), ]) { const e = f.refusal(fn); assert.equal(e.code, 'project-refused'); assert.match(e.reason, /^project docs refuses its agents: .*ghost/); } assert.equal(f.trail().includes('refuses its agents'), false, 'the trail holds the code only'); });