git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
184 lines
11 KiB
JavaScript
184 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, mkdirSync, 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 { Broker } from '../src/broker.mjs';
|
|
import { startBroker } from '../src/runtime.mjs';
|
|
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
|
|
|
// Row 213 (C2-08, #1714, Case 2 design 5.1): a creative instance talks with the user only, and it has
|
|
// no decision channel (M6). The messaging lists are read from the shipped role files.
|
|
const shipped = (name) =>
|
|
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
|
|
|
const within = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
|
const roles = {
|
|
chief: { definition: 'chief-of-staff', ...within },
|
|
assistant: { definition: 'assistant', ...within },
|
|
pm: { definition: 'pm', ...within },
|
|
bean: { definition: 'companion', ...within },
|
|
quill: { definition: 'proofreader', ...within },
|
|
};
|
|
const definitions = {
|
|
'chief-of-staff': {},
|
|
assistant: { messaging: shipped('assistant').messaging },
|
|
pm: {},
|
|
companion: { messaging: shipped('companion').messaging },
|
|
proofreader: { messaging: shipped('proofreader').messaging },
|
|
};
|
|
|
|
function setup(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-creative-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const b = new Broker({ store, businesses: { demo: {
|
|
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' }, roles, definitions,
|
|
projects: { stack: { root: '/nonexistent' } }, vars: { 'bus.tester': true },
|
|
} } });
|
|
const caps = {};
|
|
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
|
|
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
|
|
const threads = { assistant: thread };
|
|
for (const role of Object.keys(roles)) {
|
|
if (['bean', 'quill'].includes(role))
|
|
threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
|
|
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
|
|
b.request(caps[role], { verb: 'role.claim' });
|
|
}
|
|
caps.tester = b.bindTester({ business: 'demo' });
|
|
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
|
const count = (sql) => store.get(sql).n;
|
|
return { call, count, store, threads };
|
|
}
|
|
|
|
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
|
|
|
|
test('M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises', (t) => {
|
|
const { call, count } = setup(t);
|
|
for (const who of ['bean', 'quill']) {
|
|
assert.throws(() => call(who, 'decision.raise', raise), { code: 'decision-not-allowed' }, who);
|
|
// The refusal comes before the spec-cited path, so a cited raise never reaches the project.
|
|
assert.throws(() => call(who, 'decision.raise', { ...raise, project: 'stack', spec_path: 'spec/north-star.md', spec_commit: 'a'.repeat(40) }),
|
|
{ code: 'decision-not-allowed' }, `${who} cited`);
|
|
}
|
|
assert.equal(count('SELECT count(*) n FROM decisions'), 0);
|
|
const d = call('assistant', 'decision.raise', raise);
|
|
assert.match(String(d.id), /./);
|
|
assert.equal(count('SELECT count(*) n FROM decisions'), 1);
|
|
});
|
|
|
|
test('a creative instance sends only to the user and hears only from the user', (t) => {
|
|
const { call, count, threads } = setup(t);
|
|
for (const who of ['bean', 'quill']) {
|
|
call(who, 'message.send', { to: 'human', body: 'hello' });
|
|
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
|
|
assert.throws(() => call(who, 'message.send', { to, body: 'hello' }), { code: 'message-recipient-refused' }, `${who} to ${to}`);
|
|
}
|
|
for (const from of ['chief', 'pm']) {
|
|
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
|
|
}
|
|
call('human', 'thread.send', { thread: threads[who], body: 'hello' });
|
|
}
|
|
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
|
|
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
|
|
});
|
|
|
|
// M10 through the broker (webby-0040 B1): the broker names the actor from the session, so these cases
|
|
// pin that a chief session reaches the writer as chief-of-staff.chief and a tester session as tester.
|
|
// A real organization file and writer, as tester-writes.test.mjs.
|
|
async function runtime(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-creative-m10-'));
|
|
const rolesDir = rolesCopy(root);
|
|
const doc = businessDoc(root, 'demo');
|
|
doc.plugins = { ...doc.plugins, harness: ['pi'] };
|
|
doc.roles.bean = { definition: 'companion', credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
|
for (const r of Object.values(doc.roles)) r.vars = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high', ...r.vars };
|
|
// Only bean names the sonnet key, so the chief may still change every other key.
|
|
for (const [name, r] of Object.entries(doc.roles)) if (name !== 'bean') r.vars.model = 'claude-opus-5-5';
|
|
const file = join(root, 'businesses/demo.json');
|
|
writeJson(file, doc);
|
|
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
|
const bus = await startBroker({
|
|
dataRoot: join(root, 'data'),
|
|
businesses: {
|
|
demo: {
|
|
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
|
plugins: { harness: ['pi'] },
|
|
roles: { chief: { definition: 'chief-of-staff', authority: shipped('chief-of-staff').authority }, bean: { definition: 'companion', ...within } },
|
|
},
|
|
},
|
|
organization: { dir: root, rolesDir, system: systemFor(root) },
|
|
});
|
|
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const b = bus.broker;
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const tester = b.bindTester({ business: 'demo' });
|
|
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
|
b.request(chief, { verb: 'role.claim' });
|
|
const call = (cap, verb, args = {}) => b.request(cap, { verb, args });
|
|
const history = join(root, 'businesses/.history/demo/changes.jsonl');
|
|
const log = () => (existsSync(history) ? readFileSync(history, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []);
|
|
const bytes = () => readFileSync(file, 'utf8');
|
|
return { bus, human, tester, chief, call, log, bytes };
|
|
}
|
|
|
|
test('M10 through the broker: the chief and the tester are refused on bean, the human\'s limits.set of its models key works', async (t) => {
|
|
const { human, tester, chief, call, log, bytes } = await runtime(t);
|
|
const { revision, models } = call(human, 'limits');
|
|
const changed = { ...models, 'claude-sonnet-5-5': { ...models['claude-sonnet-5-5'], model: 'claude-sonnet-5-5-next' } };
|
|
const before = bytes();
|
|
// The chief of staff's organization.set reaches the writer as chief-of-staff.chief and is refused there.
|
|
assert.throws(() => call(chief, 'organization.set', { revision, models: changed }), { code: 'organization-creative-human-only' });
|
|
assert.throws(() => call(chief, 'organization.set', { revision, instances: { bean: 'claude-opus-5-5' } }), { code: 'organization-creative-instance' });
|
|
// The tester never reaches the writer through these two verbs (webby-0040 N5).
|
|
assert.throws(() => call(tester, 'organization.set', { revision, models: changed }), { code: 'tester-refused' });
|
|
assert.throws(() => call(tester, 'limits.set', { revision, models: changed }), { code: 'tester-refused' });
|
|
// Its instances.set and routes.set do reach the writer, as tester, and are refused there.
|
|
assert.throws(() => call(tester, 'instances.set', { revision, instance: 'bean', op: 'change', vars: { thinking: 'low' } }),
|
|
{ code: 'organization-creative-human-only' });
|
|
assert.throws(() => call(tester, 'instances.set', { revision, instance: 'muse', op: 'add', definition: 'ideas' }),
|
|
{ code: 'organization-creative-human-only' });
|
|
assert.throws(() => call(tester, 'routes.set', { revision, routes: { bean: { harness: 'pi', model: 'claude-opus-5-5', thinking: 'high', fallback: null } } }),
|
|
{ code: 'organization-creative-human-only' });
|
|
assert.equal(bytes(), before);
|
|
assert.deepEqual(log().filter((e) => e.state === 'committed'), []);
|
|
// The human's limits.set of the same change lands, logged with the human as actor.
|
|
call(human, 'limits.set', { revision, models: changed });
|
|
assert.equal(JSON.parse(bytes()).launch.models['claude-sonnet-5-5'].model, 'claude-sonnet-5-5-next');
|
|
assert.deepEqual(log().filter((e) => e.state === 'committed').map((e) => e.actor), ['jason']);
|
|
});
|
|
|
|
// Row 256 (#1761, REQ-COS-3, T6): the human deletes bean through the verb and the launcher's pass, on
|
|
// the real writer, so the actor reaches the delete check at both. The chief is refused human-required
|
|
// and the tester tester-refused at the verb, so neither reaches the writer. Each reader's instances
|
|
// view carries its own endProtected.
|
|
test('row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb', async (t) => {
|
|
const { bus, human, tester, chief, call, log, bytes } = await runtime(t);
|
|
assert.equal(call(human, 'instances').instances.bean.endProtected, false);
|
|
assert.equal(call(tester, 'instances').instances.bean.endProtected, true);
|
|
const { revision } = call(human, 'instances');
|
|
const before = bytes();
|
|
assert.throws(() => call(chief, 'instances.set', { revision, instance: 'bean', op: 'delete' }), { code: 'human-required' });
|
|
assert.throws(() => call(tester, 'instances.set', { revision, instance: 'bean', op: 'delete' }), { code: 'tester-refused' });
|
|
assert.equal(bytes(), before);
|
|
// The verb's check runs instances.check with the human as actor, and records the request.
|
|
const { request } = call(human, 'instances.set', { revision, instance: 'bean', op: 'delete' });
|
|
assert.equal(bytes(), before);
|
|
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
|
|
// The launcher's pass commits the delete with the request's by, the human.
|
|
const { transaction } = bus.lifecycleDelete({ business: 'demo', request });
|
|
const file = JSON.parse(bytes());
|
|
assert.equal(Object.hasOwn(file.roles, 'bean'), false);
|
|
assert.deepEqual(file.retired, ['bean']);
|
|
const committed = log().filter((e) => e.state === 'committed');
|
|
assert.deepEqual(committed.map((e) => e.actor), ['jason']);
|
|
assert.equal(committed[0].transaction, transaction);
|
|
});
|