feat(packs): the chief applies a pack's non-definition half and raises hires from its proposals (#1873 SP5)
pack.preview, pack.apply, board.set and workflow.set accept an agent session whose role
lists organization.change (Jason 11-12 Q2): the check runs before the read, and the grant
is spent once after a write commits. A role without it gets decision-required. The packs
read lists packs for a within-role holder and spends nothing.
An agent apply never writes a definition. A definition a human apply would add is marked
not-written and its hire is pinned to the template or the inline entry. A workflow step
naming such a definition is left out and listed, and a workflow left with no steps is not
written. A definitions rename or a saved template refuses human-required, with the writer's
own guard behind both.
hire.raise takes {proposal: {pack, project?, hire}, route, reason?} from the chief. The broker
reads the recorded proposal, checks an inline pin's hash (starter-pack-proposal-stale) and
launch.noHire, and hands the pin to the hire service, which refuses a moved template or
definition as decision-stale or definition-rev-conflict.
This commit is contained in:
1 parent
991eb8c332
commit
5ffe38e3b2
5 files changed
+432
-16
No files matched your search
@@ -12,6 +12,8 @@ import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
import { compositionOf } from '../../organization/src/definitions.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -6287,6 +6289,9 @@ export class Broker {
|
||||
if (request.verb === 'hire.raise' || request.verb === 'hire.start.console' ||
|
||||
(request.verb === 'decision.raise' && ['role.hire','role.fire','role.change'].includes(args.action))) {
|
||||
const input=request.verb==='decision.raise' ? (()=>{ keys(args,['action','hire'],['action','hire']); if(args.action!==`role.${args.hire?.op}`)fail('invalid-request'); return args.hire; })() : args;
|
||||
// #1873 SP5: a raise from a pack's recorded proposal; the pin, not the caller, names the role.
|
||||
if(request.verb==='hire.raise' && object(args) && Object.hasOwn(args,'proposal'))
|
||||
return Promise.resolve().then(()=>this.#raiseHire(s,this.#proposalHire(s,args),false)).catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);});
|
||||
return this.#raiseHire(s,input,request.verb==='hire.start.console').catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);});
|
||||
}
|
||||
if(request.verb==='decision.preview.console' && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action))return this.#hireConsole(s,'hire.preview.console',args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
|
||||
@@ -6301,20 +6306,43 @@ export class Broker {
|
||||
if (Object.hasOwn(PACK_BUS_KEYS, request.verb)) {
|
||||
if (s.tester) fail('tester-refused');
|
||||
const owner = this.#business(s.business).human;
|
||||
if (request.verb.endsWith('.console')) { if (!s.console || s.human !== owner) fail('human-required'); }
|
||||
else { this.#human(s); if (s.human !== owner) fail('human-required'); }
|
||||
keys(args, ...PACK_BUS_KEYS[request.verb]);
|
||||
// #1873 SP5: the unsuffixed verbs also serve an agent whose role file lists
|
||||
// organization.change, the test organization.set uses; any other role gets decision-required.
|
||||
// The packs read checks the grant and never spends it, as organization.revision; an agent
|
||||
// without it gets human-required. A write checks before the writer, again inside it, and
|
||||
// spends the grant once the writer has committed. The actor is the definition and instance.
|
||||
const agent = !request.verb.endsWith('.console') && !s.human && !s.console;
|
||||
let actor = s.human, authorityError = null;
|
||||
if (agent) {
|
||||
keys(args, ...PACK_BUS_KEYS[request.verb]);
|
||||
if (request.verb === 'packs') {
|
||||
this.#processingWork(); this.#agent(s);
|
||||
if (this.#classification(s, 'organization.change') !== 'within-role') fail('human-required');
|
||||
} else this.#checkAuthority(s, 'organization.change');
|
||||
actor = this.#business(s.business).roles[s.role].definition + '.' + s.role;
|
||||
}
|
||||
else {
|
||||
if (request.verb.endsWith('.console')) { if (!s.console || s.human !== owner) fail('human-required'); }
|
||||
else { this.#human(s); if (s.human !== owner) fail('human-required'); }
|
||||
keys(args, ...PACK_BUS_KEYS[request.verb]);
|
||||
}
|
||||
if (!this.#organization) fail('not-configured');
|
||||
const verb = request.verb.replace(/\.console$/, '');
|
||||
const spends = agent && verb !== 'packs' && verb !== 'pack.preview';
|
||||
const agentContext = agent ? { agent: true, beforeWrite: () => { try { this.#checkAuthority(s, 'organization.change'); } catch (e) { authorityError = e; throw e; } } } : {};
|
||||
const packFail = e => { if (authorityError && e === authorityError) throw e; this.#packFail(e); };
|
||||
const openHires = verb === 'pack.preview' || verb === 'pack.apply'
|
||||
? this.#store.all(`SELECT d.id FROM decisions d WHERE d.business=? AND d.action='role.hire' AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY d.at, d.id`, s.business)
|
||||
.map(({ id }) => this.#raised(s.business, id)?.hire).filter(object).map(h => ({ name: h.name, project: h.project ?? null }))
|
||||
: [];
|
||||
let result;
|
||||
try { result = this.#organization(s.business, verb, structuredClone(args), s.human, { openHires }); }
|
||||
catch (e) { this.#packFail(e); }
|
||||
const done = (value) => { this.#secretCheck(value); return value; };
|
||||
return result?.then ? result.then(done, e => this.#packFail(e)) : done(result);
|
||||
try { result = this.#organization(s.business, verb, structuredClone(args), actor, { openHires, ...agentContext }); }
|
||||
catch (e) { packFail(e); }
|
||||
const done = (value) => {
|
||||
if (spends) this.#store.transaction(() => this.#consumeAuthority(s, 'organization.change'));
|
||||
this.#secretCheck(value); return value;
|
||||
};
|
||||
return result?.then ? result.then(done, packFail) : done(result);
|
||||
}
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
@@ -6664,6 +6692,44 @@ export class Broker {
|
||||
if(s.human!==this.#business(s.business).human)fail('human-required');
|
||||
return {actor:s.human,agent:false,rights:['org.hire'],owner:true,instanceOwner:true,subject:s.console};
|
||||
}
|
||||
// #1873 SP5 (design section 3, "The chief applying a pack"): hire.raise {proposal: {pack, project?,
|
||||
// hire}, route, reason?}. Only the chief gets this far. The proposal is the latest packs entry of
|
||||
// that pack id and project, in the organization as loaded, whose proposedHires names the hire. Its
|
||||
// pin fills op, name, level and role; a caller's own role, name, level, op, credentials,
|
||||
// personality or binding refuses. A template pin and a definition pin go to prepare as recorded,
|
||||
// which refuses decision-stale and definition-rev-conflict. An inline pin is checked here: the
|
||||
// stored entry must still hash to the pin (starter-pack-proposal-stale), and its from template
|
||||
// must not be in launch.noHire (definition-not-hireable), since prepare's own noHire check
|
||||
// reads only a template or definition ref. The returned input carries no proposal key, so
|
||||
// prepare's request keys are unchanged. reason defaults to the one the pack recorded.
|
||||
#proposalHire(s, args) {
|
||||
this.#agent(s);
|
||||
const b = this.#business(s.business);
|
||||
if (s.role !== b.launch?.by) fail('role-not-chief');
|
||||
keys(args, ['proposal', 'route', 'reason'], ['proposal', 'route']);
|
||||
const proposal = args.proposal;
|
||||
keys(proposal, ['pack', 'project', 'hire'], ['pack', 'hire']);
|
||||
for (const value of [proposal.pack, proposal.hire, ...(Object.hasOwn(proposal, 'project') ? [proposal.project] : [])])
|
||||
if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value)) fail('invalid-request');
|
||||
const project = proposal.project ?? null;
|
||||
const entry = (Array.isArray(b.packs) ? b.packs : [])
|
||||
.filter(e => e?.id === proposal.pack && (e.project ?? null) === project && Array.isArray(e.proposedHires) && e.proposedHires.some(h => h?.name === proposal.hire))
|
||||
.at(-1);
|
||||
if (!entry) fail('invalid-request');
|
||||
const hire = entry.proposedHires.find(h => h.name === proposal.hire);
|
||||
const pin = hire.pin;
|
||||
if (!object(pin)) fail('invalid-request');
|
||||
let role;
|
||||
if (Object.hasOwn(pin, 'inline')) {
|
||||
if (!object(hire.entry) || typeof pin.sha256 !== 'string') fail('invalid-request');
|
||||
if (sha256(canonicalJson(hire.entry)) !== pin.sha256) fail('starter-pack-proposal-stale');
|
||||
const from = hire.entry.from?.template;
|
||||
if (from !== undefined && (b.launch?.noHire ?? []).includes(from)) fail('definition-not-hireable');
|
||||
role = { inline: compositionOf(hire.entry) };
|
||||
} else role = structuredClone(pin);
|
||||
const reason = Object.hasOwn(args, 'reason') ? args.reason : hire.reason;
|
||||
return { op: 'hire', level: project === null ? 'org' : 'project', ...(project === null ? {} : { project }), name: hire.name, role, route: structuredClone(args.route), reason };
|
||||
}
|
||||
async #raiseHire(s, input, immediate) {
|
||||
const b=this.#business(s.business);
|
||||
// Change is a replacement composition and uses the existing role.hire grant.
|
||||
|
||||
@@ -211,6 +211,13 @@ group(
|
||||
"R8: apply writes only the plan its digest names",
|
||||
"The organization file or the pack changed since the preview. Preview again and apply the new digest."
|
||||
);
|
||||
// #1873 SP5: a hire raised from a pack proposal is the role the proposal pinned. An inline pin is the
|
||||
// canonical hash of the entry stored beside it; a stored entry that no longer hashes to it refuses.
|
||||
group(
|
||||
"starter-pack-proposal-stale",
|
||||
"R8: a hire raised from a pack proposal matches its recorded pin",
|
||||
"The role entry stored with this proposal no longer matches the hash it was recorded with. Preview and apply the pack again for a fresh proposal, then raise the hire from that one."
|
||||
);
|
||||
group(
|
||||
"not-resolver",
|
||||
"Role resolve right and decision route",
|
||||
|
||||
@@ -351,7 +351,18 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
|
||||
// A saved template may reuse a shipped id, so the origin is part of the key.
|
||||
const sameApply = (e, id, origin, project) => e.id === id && e.origin === origin && (e.project ?? null) === project;
|
||||
const tombstones = (doc, id, origin, project) => new Set((doc.packs ?? []).filter(e => sameApply(e, id, origin, project)).flatMap(e => e.added ?? []));
|
||||
// #1873 SP5 (design section 3, "The chief applying a pack"): context.agent is an
|
||||
// organization.change holder's call. It names no saved template and no definitions rename
|
||||
// (human-required), and its plan writes no definition: a definition a human apply would add is
|
||||
// marked not-written, and its hire proposal pins the template or the inline entry, which the chief
|
||||
// raises as a hire decision. A workflow step naming a pack definition the organization does not
|
||||
// hold and that is not a V2 template is left out and listed; a workflow left with no step is
|
||||
// marked left-out and not written. applyPack refuses an agent's definitions or saved origin
|
||||
// whatever this builds.
|
||||
async function build(id, args, actor, context) {
|
||||
const agent = context?.agent === true;
|
||||
if (agent && typeof args.pack === 'string' && args.pack.startsWith(SAVED_PREFIX)) throw new BusError('human-required');
|
||||
if (agent && Array.isArray(args.as) && args.as.some(v => typeof v === 'string' && v.startsWith('definitions.'))) throw new BusError('human-required');
|
||||
const { document: doc, revision } = writer.read(id);
|
||||
const found = await loadPack(args.pack, actor);
|
||||
const pack = found.pack;
|
||||
@@ -379,6 +390,16 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
|
||||
const chief = chiefOf(doc, `${id}.json`);
|
||||
const registry = registryFor(doc);
|
||||
const noHire = doc.launch?.noHire ?? [];
|
||||
const templates = new Map();
|
||||
const isTemplate = name => {
|
||||
if (!templates.has(name)) {
|
||||
try { readTemplate(rolesDir, name); templates.set(name, true); } catch (error) {
|
||||
if (error?.name !== 'BusinessError') throw error;
|
||||
templates.set(name, false);
|
||||
}
|
||||
}
|
||||
return templates.get(name);
|
||||
};
|
||||
const items = [];
|
||||
const plan = {};
|
||||
// Definitions are organization-wide in either scope: a project's agents name org definitions.
|
||||
@@ -400,6 +421,7 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
|
||||
const primitives = PRIMITIVES.filter(k => canonicalJson(before[k] ?? null) !== canonicalJson(after[k] ?? null));
|
||||
item.diff = { primitives, instructions: primitives.includes('instructions') ? lineDiff(before.instructions, after.instructions) : [] };
|
||||
}
|
||||
if (agent && mark === 'add') item.mark = mark = 'not-written';
|
||||
if (mark === 'add') definitions[target] = entry;
|
||||
checked[name].added = mark === 'add';
|
||||
items.push(item);
|
||||
@@ -423,11 +445,21 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
|
||||
const workflow = { description: value.description, steps: value.steps.map(s => ({ title: s.title, state: s.state, roles: s.roles.map(role) })) };
|
||||
if (workflow.steps.some(s => new Set(s.roles).size !== s.roles.length)) bad();
|
||||
const path = `${prefix}workflows.${target}`;
|
||||
const leftOut = [];
|
||||
if (agent) {
|
||||
const unwritable = r => r !== 'human' && Object.hasOwn(pack.definitions, r) && !Object.hasOwn(doc.definitions ?? {}, role(r)) && !isTemplate(role(r));
|
||||
workflow.steps = workflow.steps.filter((s, i) => {
|
||||
const missing = value.steps[i].roles.filter(unwritable).map(role);
|
||||
if (missing.length) leftOut.push({ step: i + 1, title: s.title, roles: missing });
|
||||
return missing.length === 0;
|
||||
});
|
||||
}
|
||||
const current = scope.workflows?.[target];
|
||||
let mark;
|
||||
if (current === undefined) mark = gone.has(path) ? 'removed' : 'add';
|
||||
if (agent && workflow.steps.length === 0) mark = 'left-out';
|
||||
else if (current === undefined) mark = gone.has(path) ? 'removed' : 'add';
|
||||
else mark = canonicalJson(current) === canonicalJson(workflow) ? 'same' : 'kept';
|
||||
items.push({ item: 'workflow', name, target, path, mark });
|
||||
items.push({ item: 'workflow', name, target, path, mark, ...(leftOut.length ? { leftOut } : {}) });
|
||||
if (mark === 'add') workflows[target] = workflow;
|
||||
}
|
||||
if (Object.keys(workflows).length) plan[`${prefix}workflows`] = workflows;
|
||||
@@ -453,7 +485,10 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
|
||||
items, hires, skills: { notEnabled }, chief: chief !== null, plan, digest,
|
||||
};
|
||||
}
|
||||
const write = (id, plan, revision, actor, detail) => {
|
||||
// context.beforeWrite (SP5) is the broker's recheck of an agent's organization.change grant,
|
||||
// run once more before the writer commits.
|
||||
const write = (id, plan, revision, actor, detail, context) => {
|
||||
context?.beforeWrite?.();
|
||||
const out = writer.applyPack(id, plan, { revision, actor, assertClean, detail });
|
||||
return { revision: out.revision, added: out.added };
|
||||
};
|
||||
@@ -479,15 +514,15 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
|
||||
if (Object.keys(plan).every(k => k === 'packs') && last && last.sha256 === plan.packs.sha256
|
||||
&& canonicalJson(last.proposedHires ?? []) === canonicalJson(plan.packs.proposedHires))
|
||||
return { revision: preview.revision, added: [], pack: preview.pack.id };
|
||||
return { ...write(id, plan, preview.revision, actor, {}), pack: preview.pack.id };
|
||||
return { ...write(id, plan, preview.revision, actor, {}, context), pack: preview.pack.id };
|
||||
},
|
||||
'board.set': (id, args, actor) => {
|
||||
'board.set': (id, args, actor, context) => {
|
||||
if (args.board !== null && !isObject(args.board)) bad();
|
||||
return write(id, { [scoped(args, 'board')]: args.board }, revisionOf(args), actor, {});
|
||||
return write(id, { [scoped(args, 'board')]: args.board }, revisionOf(args), actor, {}, context);
|
||||
},
|
||||
'workflow.set': (id, args, actor) => {
|
||||
'workflow.set': (id, args, actor, context) => {
|
||||
if (typeof args.name !== 'string' || (args.workflow !== null && !isObject(args.workflow))) bad();
|
||||
return write(id, { [scoped(args, 'workflows')]: { [args.name]: args.workflow } }, revisionOf(args), actor, {});
|
||||
return write(id, { [scoped(args, 'workflows')]: { [args.name]: args.workflow } }, revisionOf(args), actor, {}, context);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1140,6 +1175,7 @@ export async function startBroker({
|
||||
}
|
||||
// #1873 SP4: the human's pack, board and workflow ops. The broker passes only the business's human;
|
||||
// the reads return here, and each write reloads the organization and refreshes its limit checks.
|
||||
// SP5: or an organization.change holder's actor, with context.agent and context.beforeWrite.
|
||||
if (Object.hasOwn(PACK_OPS, verb)) {
|
||||
const ops = packOps({ writer: organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}), rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot,
|
||||
system: organization.system, registryFor: doc => organizationPlugins(instanceRoot ?? dataRoot, doc.plugins).registry, assertClean: value => credentials.assertClean(value), configDir: organization.dir });
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
// #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.
|
||||
async function harness(t, { mutate = () => {} } = {}) {
|
||||
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 businesses = { demo: { 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 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 }), /organization-|invalid-request|stale|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 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));
|
||||
});
|
||||
@@ -318,7 +318,10 @@ test('SP4 who may call: the owner only; the tester, a member, an agent and a con
|
||||
['pack.apply', { pack: 'coding', digest: 'a'.repeat(64) }], ['board.set', { revision: revision(), board: null }]])
|
||||
assert.throws(() => b.request(member, { verb, args }), /human-required/);
|
||||
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
||||
assert.throws(() => b.request(agent, { verb: 'pack.apply', args: { pack: 'coding', digest: 'a'.repeat(64) } }), /human-required/);
|
||||
// SP5: an agent reaches the unsuffixed names through its role's organization.change; the coder
|
||||
// lists none, so its write needs a decision (packs-agent.test.mjs covers the chief).
|
||||
b.request(agent, { verb: 'role.claim' });
|
||||
assert.throws(() => b.request(agent, { verb: 'pack.apply', args: { pack: 'coding', digest: 'a'.repeat(64) } }), /decision-required/);
|
||||
assert.throws(() => b.request(agent, { verb: 'pack.preview.console', args: { pack: 'coding' } }), /human-required/);
|
||||
// The owner's console cap calls the .console names; the cli cap the unsuffixed ones.
|
||||
await assert.rejects(call('pack.preview', { pack: 'coding' }), /human-required/);
|
||||
|
||||
Reference in new issue
Block a user