Review fix round for slice A3. - Every project's union is recomputed from the current organization file at each load. A refused union refuses the project's own copies and every inherited root or org agent there: no mount at launch (checkout-skipped, project-refused), no thread and no room, each naming the project and why. - A project exclude removes that project's mount from the excluded agent; a narrow reaches the instance's resolved limits, so its real bundle is narrowed. A narrow in any project narrows the instance for every launch, since one launch mounts every project it works in. - Two copies whose instance names collide both refuse, naming each claimant; the view lists the claimants. An organization instance of the same name replaces a root or project copy, at boot and after it, and the copy is gone rather than refused. - Rooms hold only the thread instances that work in the room's project. - The grid finds an agent's copy by name and project from the bus view, never by rebuilding the instance name. - Real-boot regressions: inherited agents in a refused union, exclude and narrow in the bundle, an org override after boot, two organizations each running a copy, a root file change while running, and a grid fed by a booted bus.
123 lines
7.7 KiB
JavaScript
123 lines
7.7 KiB
JavaScript
// #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 <value> 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');
|
|
});
|