diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 13366b63a..26f76df97 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -566,3 +566,20 @@ input; error on stderr, never a clean verdict). `3` configuration missing for a read operation · `4` usage/file/environment problem. Scripts print diagnostics on stderr; model responses (and only model responses) on stdout. + +## Agent limits (`scripts/mosaic limits`) + +View: `scripts/mosaic limits --business ID --json`. Set: `scripts/mosaic limits +set --business ID --file PATCH`, with the current revision and replacement +`limits` and/or `models`, or `instances` mapping role names to model-table keys. +Both use the human bus proof. Settings offers the same edits in signed-in send +mode. Unset means unlimited, zero blocks new launches, and running sessions +remain alive when a limit is lowered. Total, provider and exact model limits +are checked for each launch; counts have no hard-coded policy ceiling. + +Convert an old organization file with `scripts/mosaic limits migrate --file +ORGANIZATION --models MAPPING --roles-dir roles > CONVERTED`. It needs no bus, +never edits the input and has no write flag. Mapping JSON contains `models`, +`families` and optional per-role `instances` selections. Jason checks and installs +the output in Mos's window with the PM run idle; re-pin in that same window. +Never migrate the live file from an agent session. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 4ab951135..6b0c625a4 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -107,14 +107,16 @@ export class Broker { #secretCheck; #credentialStatus; #requirements; + #organization; #clock = 0; // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). - constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [] }) { + constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null }) { if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); this.#businesses = structuredClone(businesses); this.#store = store; this.#secretCheck = secretCheck; this.#credentialStatus = credentialStatus; + this.#organization = organization; // Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here. this.#requirements = loadRequirements(); for (const table of [ @@ -666,6 +668,12 @@ export class Broker { const args = request.args ?? {}; if (!object(args)) fail('invalid-request'); this.#secretCheck(args); + if(['limits','limits.set'].includes(request.verb)){ + if(!s.human)fail('human-required');if(!this.#organization)fail('not-configured'); + if(request.verb==='limits')keys(args,[]);else keys(args,['revision','limits','models','instances'],['revision']); + let result;try{result=this.#organization(s.business,request.verb,args,s.human);}catch(e){if(e.code==='revision-conflict')fail('revision-conflict');if(e.code?.startsWith('organization-'))fail(e.code);if(e.name==='BusinessError'||e.code==='invalid-request'||e.code==='unknown-model')fail('invalid-request');throw new BusError('outcome-unknown');} + this.#secretCheck(result);return result; + } return this.#store.transaction(() => { if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb)) fail('read-only'); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index dbb09e9b6..50cc43fda 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -4,10 +4,13 @@ import { Store } from './store.mjs'; import { Broker, BusError } from './broker.mjs'; import { Credentials } from './credentials.mjs'; import { serve } from './server.mjs'; +import { launchSettings } from '../../business/src/launch-settings.mjs'; +import { readSessions } from '../../seat/src/session.mjs'; +import { existsSync } from 'node:fs'; import { verifyHuman } from './human.mjs'; import { verifyTester } from './tester.mjs'; // Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them. -// Neither a business-file writer nor a socket-accessible configuration endpoint. +// Human limits writes use the shared organization writer behind the verified transport. // `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}. export async function startBroker({ dataRoot, @@ -16,6 +19,7 @@ export async function startBroker({ readers = [], repoRoots = [], tasks = null, + organization = null, }) { let store, credentials, server, adapter; try { @@ -32,9 +36,12 @@ export async function startBroker({ ]; credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot }); store = new Store(dataRoot); + const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot)}):null; + if(settings)for(const id of Object.keys(businesses))if(existsSync(settings.writer.logFile(id)))settings.writer.recover(id); const broker = new Broker({ store, businesses, + organization:settings?(id,verb,args,actor)=>verb==='limits'?settings.read(id):settings.set(id,args,actor):null, secretCheck: (value) => credentials.assertClean(value), credentialStatus: () => credentials.status(), }); diff --git a/packages/bus/tests/limits.test.mjs b/packages/bus/tests/limits.test.mjs new file mode 100644 index 000000000..6dfb55d57 --- /dev/null +++ b/packages/bus/tests/limits.test.mjs @@ -0,0 +1,23 @@ +// REQ-LIMIT-1: only the existing verified human channel can read/write settings. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync,mkdirSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {Broker} from '../src/broker.mjs'; +import {Store} from '../src/store.mjs'; +test('limits binds actor from human proof and rejects role, reader, tester and forged actor',t=>{ + const root=mkdtempSync(join(tmpdir(),'mosaic-limits-bus-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});const calls=[]; + const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}}); + const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),role=b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi',address:'coder-run'}),reader=b.bindReader({business:'demo'}),tester=b.bindTester({business:'demo'}),patch={revision:'a'.repeat(64),limits:{total:0}}; + for(const cap of [role,reader,tester])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/); + assert.equal(calls.length,0);assert.throws(()=>b.request(human,{verb:'limits.set',args:{...patch,actor:'forged'}}),/invalid-request/); + b.request(human,{verb:'limits'});b.request(human,{verb:'limits.set',args:patch});assert.deepEqual(calls,[['demo','limits',{},'jason'],['demo','limits.set',patch,'jason']]); +}); +test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{ + const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');const {readFileSync}=await import('node:fs'); + const root=mkdtempSync(join(tmpdir(),'mosaic-limits-runtime-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700}); + const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}}); + t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); + const view=bus.broker.request(human,{verb:'limits'});const done=bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:0}}});assert.equal(done.limits.total,0);assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).launch.limits.total,0);const receipt=JSON.parse(readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8').trim().split('\n').at(-1));assert.equal(receipt.actor,'jason');assert.equal(receipt.state,'committed'); +}); diff --git a/packages/business/README.md b/packages/business/README.md index f36ded9eb..50a779901 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -88,8 +88,14 @@ repository, `dataRoot` and every project root. A Vikunja token past `expires` refuses. One within seven days of it, or a Gitea token past `rotateBy`, prints a warning. -`launch` lets one instance (`by`) start the listed instances, up to `max` -sessions per model family (Opus 4, Sonnet 4 at most). `by` must name an +`launch` lets one instance (`by`) start the listed instances. `models` maps +selection keys to exact `{provider, model}` pairs; each role +`vars.model` selects a key. `limits` accepts optional `total`, +`providers: {provider: count}` and `models: {provider: {model: count}}`. +Counts are whole numbers of zero or more with no policy ceiling; absent +means unlimited and zero stops new launches. The example starts with four +Opus and four Sonnet sessions. The old `max` shape requires print-only +`mosaic limits migrate` before the new copy can load it. `by` must name an instance whose definition holds `role.launch` within-role; otherwise the file refuses. Only that instance keeps `role.launch`. Every other instance loses it, so for them it's gated. @@ -189,3 +195,19 @@ file missing). The CLI adds 3 for a system config problem and 4 for usage. The code that opens it at run time repeats these checks (rows S2 and S3). - Slice 1 knows two services and one tracker kind. A new one is a reviewed change to `src/vocabulary.mjs` and `src/vars.mjs`. + +## Organization writes + +`src/organization.mjs` is the trusted host write module. The caller supplies +a verified actor and the SHA-256 revision read earlier. It validates the whole +document, refuses stale revisions and concurrent writers, keeps exact previous +bytes under `businesses/.history///previous.json`, then publishes +a fsynced temporary file by atomic rename. `changes.jsonl` records prepared and +committed transactions with actor, hashes and old/new values per changed field. +Host-start recovery compares prepared hashes with current bytes; uncertain or +torn evidence refuses. A stale writer lock also refuses: an operator must +confirm that its old writer is gone before clearing it; the host does not +race another recovering process by deleting locks automatically. Settings reads never repair evidence themselves. +`launch-settings.mjs` restricts human edits to limits, model table and instance +model selections and validates effective selections before publication. Migration +uses none of this: it only prints a converted document (decision 145). diff --git a/packages/business/examples/mosaic-stack.example.json b/packages/business/examples/mosaic-stack.example.json index 702e8eb14..cc7cbd4db 100644 --- a/packages/business/examples/mosaic-stack.example.json +++ b/packages/business/examples/mosaic-stack.example.json @@ -45,6 +45,9 @@ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/pm-vikunja.token", "expires": "YYYY-MM-DD" } + }, + "vars": { + "model": "opus" } }, "cto": { @@ -63,6 +66,9 @@ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/cto-vikunja.token", "expires": "YYYY-MM-DD" } + }, + "vars": { + "model": "opus" } }, "coder": { @@ -80,6 +86,9 @@ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/coder-vikunja.token", "expires": "YYYY-MM-DD" } + }, + "vars": { + "model": "sonnet" } }, "reviewer": { @@ -97,6 +106,9 @@ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/reviewer-vikunja.token", "expires": "YYYY-MM-DD" } + }, + "vars": { + "model": "opus" } } }, @@ -106,9 +118,23 @@ "coder", "reviewer" ], - "max": { - "opus": 4, - "sonnet": 4 + "models": { + "opus": { + "provider": "your-provider", + "model": "claude-opus-5-5" + }, + "sonnet": { + "provider": "your-provider", + "model": "claude-sonnet-5-5" + } + }, + "limits": { + "models": { + "your-provider": { + "claude-opus-5-5": 4, + "claude-sonnet-5-5": 4 + } + } } } } diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs index 9e12cea18..e2aab7676 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -6,7 +6,7 @@ import { dirname, isAbsolute, join, normalize } from "node:path"; import { homedir } from "node:os"; import { refuse } from "./errors.mjs"; -import { LAUNCH_CEILING } from "./vocabulary.mjs"; +import { checkModels, checkLimits } from "./launch.mjs"; import { checkVars } from "./vars.mjs"; import { parseCredentialRef } from "./credentials.mjs"; import { loadRole } from "./role.mjs"; @@ -114,7 +114,8 @@ function checkRoles(roles, file, rolesDir) { function checkLaunch(launch, roles, definitions, file) { const where = `${file} launch`; requireObject(launch, where); - rejectUnknownKeys(launch, ["by", "instances", "max"], where); + if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`); + rejectUnknownKeys(launch, ["by", "instances", "models", "limits"], where); requireId(launch.by, `${where}.by`); if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`); const launcher = definitions[roles[launch.by].definition]; @@ -126,16 +127,10 @@ function checkLaunch(launch, roles, definitions, file) { if (!Object.hasOwn(roles, name)) refuse(`${where}.instances names ${name}, which the business file doesn't declare`); if (name === launch.by) refuse(`${where}.instances can't include the launcher itself (${name})`); }, { nonEmpty: true }); - requireObject(launch.max, `${where}.max`); - if (Object.keys(launch.max).length === 0) refuse(`${where}.max must name at least one model family`); - const max = {}; - for (const [family, count] of Object.entries(launch.max)) { - const ceiling = Object.hasOwn(LAUNCH_CEILING, family) ? LAUNCH_CEILING[family] : undefined; - if (ceiling === undefined) refuse(`${where}.max names unknown model family ${JSON.stringify(family)} (known: ${Object.keys(LAUNCH_CEILING).join(", ")})`); - if (!Number.isSafeInteger(count) || count < 0 || count > ceiling) refuse(`${where}.max.${family} must be an integer from 0 to ${ceiling}`); - max[family] = count; - } - return { by: launch.by, instances, max }; + const models=checkModels(launch.models,`${where}.models`); + const limits=checkLimits(launch.limits===undefined?{}:launch.limits,`${where}.limits`); + for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`); + return { by: launch.by, instances, models, limits }; } // Validate a parsed business document read from `file`. `rolesDir` is where diff --git a/packages/business/src/index.mjs b/packages/business/src/index.mjs index b935fff5b..95fd05c3d 100644 --- a/packages/business/src/index.mjs +++ b/packages/business/src/index.mjs @@ -4,7 +4,7 @@ export { BusinessError } from "./errors.mjs"; export { - ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, LAUNCH_CEILING, + ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, } from "./vocabulary.mjs"; export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs"; export { LAYERS, REGISTRY, checkVars, mergeVars } from "./vars.mjs"; diff --git a/packages/business/src/launch-settings.mjs b/packages/business/src/launch-settings.mjs new file mode 100644 index 000000000..f5a56e692 --- /dev/null +++ b/packages/business/src/launch-settings.mjs @@ -0,0 +1,21 @@ +// Shared human settings projection/update (REQ-LIMIT-1), used by the trusted broker. +import {organizationWriter,OrganizationError} from './organization.mjs'; +import {validateBusinessDocument,businessFilePath} from './business.mjs'; +import {resolveInstance} from './resolve.mjs'; +import {usageCounts} from './launch.mjs'; +import {requireObject,rejectUnknownKeys} from './util.mjs'; +export function launchSettings({dir,rolesDir,system,sessions=()=>[]}){ + const writer=organizationWriter({dir,rolesDir}); + const view=id=>{const {document,revision}=writer.read(id),b=validateBusinessDocument(document,businessFilePath(id,dir),{rolesDir});if(!b.launch)throw new OrganizationError('launch-not-configured'); + const instances=Object.fromEntries(Object.entries(b.roles).map(([name,r])=>{const vars=resolveInstance({business:b,system,instance:name}).vars,key=vars.model??vars['execution.model'],entry=Object.hasOwn(b.launch.models,key)?b.launch.models[key]:null;if(!entry)throw new OrganizationError('unknown-model');return [name,{model:key,harness:vars.harness??vars['execution.adapter'],...entry,modelKey:key}];})); + // model is the external id; modelKey is the table selection. + return {business:id,revision,models:b.launch.models,limits:b.launch.limits,instances,usage:usageCounts(sessions(),id)}; + }; + return {writer,read:view,set(id,args,actor){requireObject(args,'limits update');rejectUnknownKeys(args,['revision','limits','models','instances'],'limits update');if(typeof args.revision!=='string'||!/^[a-f0-9]{64}$/.test(args.revision))throw new OrganizationError('invalid-request');if(!['limits','models','instances'].some(k=>Object.hasOwn(args,k)))throw new OrganizationError('invalid-request');const old=writer.read(id),next=structuredClone(old.document);if(!next.launch)throw new OrganizationError('launch-not-configured'); + if(Object.hasOwn(args,'limits'))next.launch.limits=args.limits;if(Object.hasOwn(args,'models'))next.launch.models=args.models; + if(Object.hasOwn(args,'instances')){requireObject(args.instances,'instance model selections');for(const [name,key] of Object.entries(args.instances)){if(!Object.hasOwn(next.roles,name)||typeof key!=='string')throw new OrganizationError('invalid-request');next.roles[name].vars={...next.roles[name].vars,model:key};}} + // Validate effective selections too, before publication, including inherited defaults. + const proposed=validateBusinessDocument(next,businessFilePath(id,dir),{rolesDir});for(const name of Object.keys(proposed.roles)){const vars=resolveInstance({business:proposed,system,instance:name}).vars;if(!Object.hasOwn(proposed.launch.models,vars.model??vars['execution.model']))throw new OrganizationError('unknown-model');} + const receipt=writer.write(id,next,{revision:args.revision,actor});return {...view(id),transaction:receipt.transaction}; + }}; +} diff --git a/packages/business/src/launch.mjs b/packages/business/src/launch.mjs new file mode 100644 index 000000000..afb4520a1 --- /dev/null +++ b/packages/business/src/launch.mjs @@ -0,0 +1,29 @@ +// REQ-LIMIT-1: editable concurrency policy, never a compiled-in capacity ceiling. +import {requireObject,requireId,requireString,rejectUnknownKeys} from './util.mjs'; +import {refuse} from './errors.mjs'; +const name=(v,at)=>requireString(v,at,{max:200}); +const count=(v,at)=>{if(!Number.isSafeInteger(v)||v<0)refuse(`${at} must be a whole number of 0 or more`);return v;}; +export function checkModels(value,where='launch.models'){ + requireObject(value,where);if(!Object.keys(value).length)refuse(`${where} must name at least one model`); + return Object.fromEntries(Object.entries(value).map(([key,v])=>{requireId(key,where);requireObject(v,`${where}.${key}`);rejectUnknownKeys(v,['provider','model'],`${where}.${key}`);return [key,{provider:name(v.provider,`${where}.${key}.provider`),model:name(v.model,`${where}.${key}.model`)}];})); +} +export function checkLimits(value={},where='launch.limits'){ + requireObject(value,where);rejectUnknownKeys(value,['total','providers','models'],where);const out={}; + if(Object.hasOwn(value,'total'))out.total=count(value.total,`${where}.total`); + if(Object.hasOwn(value,'providers')){requireObject(value.providers,`${where}.providers`);out.providers=Object.fromEntries(Object.entries(value.providers).map(([k,v])=>[name(k,where),count(v,`${where}.providers.${k}`)]));} + if(Object.hasOwn(value,'models')){requireObject(value.models,`${where}.models`);out.models=Object.fromEntries(Object.entries(value.models).map(([p,ms])=>{name(p,where);requireObject(ms,where);return [p,Object.fromEntries(Object.entries(ms).map(([m,v])=>[name(m,where),count(v,`${where}.models.${p}.${m}`)]))];}));} + return out; +} +export function usageCounts(sessions,business){ + const running=sessions.filter(s=>s.business===business);const pc=new Map(),mc=new Map(); + for(const s of running){if(typeof s.provider!=='string'||typeof s.model!=='string')refuse('running session has no exact provider/model; reconcile before launching');pc.set(s.provider,(pc.get(s.provider)??0)+1);if(!mc.has(s.provider))mc.set(s.provider,new Map());const mm=mc.get(s.provider);mm.set(s.model,(mm.get(s.model)??0)+1);} + const providers=Object.fromEntries(pc),models=Object.fromEntries([...mc].map(([p,ms])=>[p,Object.fromEntries(ms)])); + return {total:running.length,providers,models}; +} +export function limitHit(sessions,business,limits,selected){ + const usage=usageCounts(sessions,business),{provider,model}=selected; + const own=(o,k)=>Object.hasOwn(o??{},k)?o[k]:undefined; + const checks=[['total',limits.total,usage.total],['provider',Object.hasOwn(limits.providers??{},provider)?limits.providers[provider]:undefined,own(usage.providers,provider)??0],['model',own(own(limits.models,provider),model),own(own(usage.models,provider),model)??0]]; + for(const [scope,limit,running] of checks)if(limit!==undefined&&running>=limit)return {scope,limit,running,...(scope!=='total'?{provider}:{}),...(scope==='model'?{model}:{})}; + return null; +} diff --git a/packages/business/src/migrate-launch.mjs b/packages/business/src/migrate-launch.mjs new file mode 100644 index 000000000..fc9769998 --- /dev/null +++ b/packages/business/src/migrate-launch.mjs @@ -0,0 +1,36 @@ +// Decision145: pure, print-only conversion. Never writes or authenticates. +import {checkModels,checkLimits} from './launch.mjs'; +import {requireObject,rejectUnknownKeys} from './util.mjs'; +import {refuse} from './errors.mjs'; +import {validateBusinessDocument} from './business.mjs'; +export function migrateLaunch(document,mapping,file,{rolesDir}){ + const out=structuredClone(document);if(!out.launch?.max){validateBusinessDocument(out,file,{rolesDir});return out;} + requireObject(mapping,'migration mapping');rejectUnknownKeys(mapping,['models','families','selections','instances'],'migration mapping');const models=checkModels(mapping.models);requireObject(mapping.families,'migration families');requireObject(out.launch.max,'old launch.max'); + for(const field of ['instances','selections'])if(Object.hasOwn(mapping,field))requireObject(mapping[field],`migration ${field}`); + for(const key of Object.keys(mapping.instances??{}))if(!Object.hasOwn(out.roles,key))refuse(`unknown migration instance ${key}`); + const limits={models:Object.create(null)};const used=new Set();for(const [family,count] of Object.entries(out.launch.max)){ + const key=mapping.families[family];if(typeof key!=='string'||!Object.hasOwn(models,key))refuse(`unknown migration mapping for family ${family}`);const entry=models[key],tuple=JSON.stringify([entry.provider,entry.model]);if(used.has(tuple))refuse('ambiguous migration: two family limits map to the same provider/model');used.add(tuple);limits.models[entry.provider]??=Object.create(null);limits.models[entry.provider][entry.model]=count; + } + const checkedLimits=checkLimits(limits); + for(const [instance,r] of Object.entries(out.roles)){ + const old=r.vars?.model,key=mapping.instances?.[instance]??mapping.selections?.[old]; + let selected=key; + if(selected===undefined&&old!==undefined){const matches=Object.entries(models).filter(([,m])=>m.model===old);if(matches.length!==1)refuse(`unknown or ambiguous model mapping for ${instance}`);selected=matches[0][0];} + if(selected===undefined)refuse(`unknown inherited model mapping for ${instance}; name it in instances`); + if(typeof selected!=='string'||!Object.hasOwn(models,selected))refuse(`unknown or ambiguous model mapping for ${instance}`); + if(old!==undefined&&models[selected].model!==old)refuse(`migration mapping changes ${instance}'s external model`); + r.vars={...r.vars,model:selected}; + } + for(const instance of [out.launch.by,...out.launch.instances]){ + const selected=models[out.roles[instance].vars.model],old=document.roles[instance].vars?.model??selected.model; + const families=Object.keys(out.launch.max).filter(f=>old.toLowerCase().includes(f.toLowerCase())); + if(families.length!==1)refuse(`unknown or ambiguous family mapping for launchable ${instance}`); + const constrained=models[mapping.families[families[0]]];if(selected.provider!==constrained.provider||selected.model!==constrained.model)refuse(`ambiguous migration mapping for launchable ${instance}`); + } + for(const family of Object.keys(out.launch.max)){ + const key=mapping.families[family],entry=models[key]; + // A family spanning multiple tuples cannot be split without widening it. + for(const [instance,r] of Object.entries(document.roles))if(typeof r.vars?.model==='string'&&r.vars.model.toLowerCase().includes(family.toLowerCase())){const actual=models[out.roles[instance].vars.model];if(r.vars.model!==entry.model||actual.provider!==entry.provider)refuse(`ambiguous migration: family ${family} spans multiple provider/model pairs`);} + } + const {max,...launch}=out.launch;out.launch={...launch,models,limits:checkedLimits};validateBusinessDocument(out,file,{rolesDir});return out; +} diff --git a/packages/business/src/organization.mjs b/packages/business/src/organization.mjs new file mode 100644 index 000000000..4f8122c27 --- /dev/null +++ b/packages/business/src/organization.mjs @@ -0,0 +1,37 @@ +// Trusted host write path (REQ-ROLE-3). Callers supply a verified actor; +// neither this module nor migration authenticates a new channel. +import {constants,openSync,closeSync,fstatSync,readFileSync,writeFileSync,fsyncSync,lstatSync,mkdirSync,renameSync,rmSync,appendFileSync,readdirSync} from 'node:fs'; +import {join,dirname} from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {businessFilePath,validateBusinessDocument} from './business.mjs'; +import {requireId,sha256,canonicalJson} from './util.mjs'; +export class OrganizationError extends Error{constructor(code,message=code){super(message);this.code=code;}} +const fail=(code,message)=>{throw new OrganizationError(code,message);}; +function regular(file){let fd;try{fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);const st=fstatSync(fd);if(!st.isFile()||st.uid!==process.getuid()||(st.mode&0o022))fail('organization-refused','organization file must be regular, owned and not group/world writable');return readFileSync(fd,'utf8');}finally{if(fd!==undefined)closeSync(fd);}} +function directory(path){try{mkdirSync(path,{mode:0o700});syncDir(dirname(path));}catch(e){if(e.code!=='EEXIST')throw e;}const st=lstatSync(path);if(!st.isDirectory()||st.uid!==process.getuid()||(st.mode&0o077))fail('organization-refused','organization evidence directory is unsafe');} +function syncDir(dir){const fd=openSync(dir,constants.O_RDONLY|constants.O_DIRECTORY);try{fsyncSync(fd);}finally{closeSync(fd);}} +function durable(file,bytes){const fd=openSync(file,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600);try{writeFileSync(fd,bytes);fsyncSync(fd);}finally{closeSync(fd);}} +function changes(old,next,path=''){if(canonicalJson(old)===canonicalJson(next))return [];if(old&&next&&typeof old==='object'&&typeof next==='object'&&!Array.isArray(old)&&!Array.isArray(next))return [...new Set([...Object.keys(old),...Object.keys(next)])].flatMap(k=>changes(old[k],next[k],path?path+'.'+k:k));return [{path,old:old??null,new:next??null,oldPresent:old!==undefined,newPresent:next!==undefined}];} +export function organizationWriter({dir,rolesDir,barrier=()=>{}}){ + const evidence=join(dir,'businesses','.history'); + const logFile=id=>join(evidence,id,'changes.jsonl'); + const read=id=>{const file=businessFilePath(id,dir),bytes=regular(file),document=JSON.parse(bytes);validateBusinessDocument(document,file,{rolesDir});return {document,revision:sha256(bytes)};}; + function withLock(id,fn){const parent=join(dir,'businesses');directory(evidence);const home=join(evidence,id);directory(home);const lock=join(home,'write.lock'); + try{mkdirSync(lock,{mode:0o700});durable(join(lock,'owner.json'),JSON.stringify({pid:process.pid}));syncDir(home);}catch(e){if(e.code!=='EEXIST')throw e;const lockStat=lstatSync(lock);if(!lockStat.isDirectory()||lockStat.uid!==process.getuid()||(lockStat.mode&0o077))fail('organization-refused','unsafe writer lock');let owner;try{owner=JSON.parse(regular(join(lock,'owner.json')));}catch{fail('organization-uncertain','writer lock has no verifiable owner');}if(!Number.isSafeInteger(owner.pid)||owner.pid<1)fail('organization-uncertain');try{process.kill(owner.pid,0);fail('organization-busy');}catch(x){if(x.code!=='ESRCH')throw x;}fail('organization-uncertain','stale writer lock: operator must confirm the old writer is gone before removing the lock');} + try{return fn({home,parent});}finally{rmSync(lock,{recursive:true});syncDir(home);} + } + function log(id,event){const file=logFile(id);let fd;try{fd=openSync(file,constants.O_WRONLY|constants.O_APPEND|constants.O_CREAT|constants.O_NOFOLLOW,0o600);const st=fstatSync(fd);if(!st.isFile()||st.uid!==process.getuid()||(st.mode&0o077))fail('organization-refused');writeFileSync(fd,JSON.stringify(event)+'\n');fsyncSync(fd);}finally{if(fd!==undefined)closeSync(fd);}} + function recover(id){const file=logFile(id);let text;try{text=regular(file);}catch(e){if(e.code==='ENOENT')return;throw e;}if(text&&!text.endsWith('\n'))fail('organization-uncertain','torn organization receipt');const entries=text.trim()?text.trim().split('\n').map(JSON.parse):[];const pending=new Map();for(const e of entries){if(e.state==='prepared')pending.set(e.transaction,e);else pending.delete(e.transaction);}for(const e of pending.values()){const hash=sha256(regular(businessFilePath(id,dir)));if(hash!==e.before&&hash!==e.after)fail('organization-uncertain','organization bytes do not match prepared receipt');log(id,{...e,state:hash===e.after?'committed':'aborted',recovered:true,at:new Date().toISOString()});}} + return {logFile,recover(id){return withLock(id,()=>recover(id));},read(id){ + const result=read(id);let text;try{text=regular(logFile(id));}catch(e){if(e.code==='ENOENT')return result;throw e;} + if(text&&!text.endsWith('\n'))fail('organization-uncertain','torn organization receipt');const pending=new Set();for(const e of text.trim()?text.trim().split('\n').map(JSON.parse):[]){if(e.state==='prepared')pending.add(e.transaction);else pending.delete(e.transaction);}if(pending.size)fail('organization-uncertain','organization receipt needs host recovery');return result; + },write(id,document,{revision,actor}){ + requireId(actor,'authenticated actor');requireId(id,'organization id');return withLock(id,({home,parent})=>{ + recover(id);const file=businessFilePath(id,dir),oldBytes=regular(file),before=sha256(oldBytes);if(revision!==before)fail('revision-conflict','organization revision changed; read again');validateBusinessDocument(document,file,{rolesDir}); + const old=JSON.parse(oldBytes),bytes=JSON.stringify(document,null,2)+'\n',after=sha256(bytes),transaction=randomUUID(),tx=join(home,transaction);directory(tx);const previous=join(tx,'previous.json');durable(previous,oldBytes);const candidate=join(parent,'.'+id+'.'+transaction+'.tmp');durable(candidate,bytes);syncDir(tx); + const event={transaction,actor,business:id,before,after,changes:changes(old,document),at:new Date().toISOString()};log(id,{...event,state:'prepared'});syncDir(home);barrier('prepared'); + if(sha256(regular(file))!==before){log(id,{...event,state:'aborted'});rmSync(candidate);fail('revision-conflict','organization revision changed before publication');} + renameSync(candidate,file);syncDir(parent);barrier('renamed');log(id,{...event,state:'committed'});return {revision:after,transaction,previous}; + }); + }}; +} diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs index 4b151af04..067e88473 100644 --- a/packages/business/src/vocabulary.mjs +++ b/packages/business/src/vocabulary.mjs @@ -83,6 +83,5 @@ export const VIKUNJA_GRANTABLE = Object.freeze({ // Model families the launch block may cap, and the PRD's ceiling for each // (REQ-LAUNCH-1: at most 4 Opus and 4 Sonnet sessions at once). -export const LAUNCH_CEILING = Object.freeze({ opus: 4, sonnet: 4 }); export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; diff --git a/packages/business/tests/business.test.mjs b/packages/business/tests/business.test.mjs index ec3ccc651..59e6810c1 100644 --- a/packages/business/tests/business.test.mjs +++ b/packages/business/tests/business.test.mjs @@ -5,7 +5,7 @@ import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, symlinkSync import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs"; -import { businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs"; +import { launchDoc, businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs"; function refuses(fn, pattern, exitCode = 2) { assert.throws(fn, (error) => { @@ -39,7 +39,7 @@ test("the fixture business validates and comes back frozen", () => { assert.equal(business.roles.reviewer.holder, null); assert.equal(business.roles.coder.vars["limits.network"], "none"); assert.equal(business.definitions.pm.roleVersion, 2); - assert.deepEqual(business.launch, { by: "pm", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } }); + assert.deepEqual(business.launch, launchDoc()); assert.ok(Object.isFrozen(business.roles.pm.credentials.gitea)); assert.throws(() => { business.roles.pm.holder = "x"; }, TypeError); }); @@ -125,13 +125,13 @@ test("launch", () => { refuses(() => check((d) => { d.launch.instances = ["coder", "coder"]; }), /duplicate/); refuses(() => check((d) => { d.launch.instances = ["ghost"]; }), /instances names ghost/); refuses(() => check((d) => { d.launch.instances = ["pm"]; }), /can't include the launcher itself/); - refuses(() => check((d) => { d.launch.max = {}; }), /at least one model family/); - refuses(() => check((d) => { d.launch.max = { glm: 1 }; }), /unknown model family "glm"/); - refuses(() => check((d) => { d.launch.max = { opus: 5 }; }), /from 0 to 4/); - refuses(() => check((d) => { d.launch.max = { sonnet: -1 }; }), /from 0 to 4/); - refuses(() => check((d) => { d.launch.max = { opus: 1.5 }; }), /from 0 to 4/); + refuses(() => check((d) => { d.launch.models = {}; }), /at least one model/); + refuses(() => check((d) => { d.launch.models = { glm: {} }; }), /provider/); + assert.equal(check((d)=>{d.launch.limits.total=50;}).launch.limits.total,50); + refuses(() => check((d) => { d.launch.limits.total = -1; }), /whole number/); + refuses(() => check((d) => { d.launch.limits.total = 1.5; }), /whole number/); refuses(() => check((d) => { d.launch.cap = 1; }), /unsupported .* launch key/); - assert.deepEqual(check((d) => { d.launch.max = { opus: 0 }; }).launch.max, { opus: 0 }); + assert.deepEqual(check((d) => { d.launch.limits = {total:0}; }).launch.limits, {total:0}); }); test("loadBusiness: file checks", () => { @@ -196,7 +196,7 @@ test("names that are Object.prototype properties don't count as declared", () => refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/); refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/); refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/); - refuses(() => check((d) => { d.launch.max = { constructor: 1 }; }), /unknown model family "constructor"/); + refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /unknown model constructor/); refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4); const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels; assert.equal(Object.getPrototypeOf(labels), Object.prototype); diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/business/tests/chief-of-staff.test.mjs index 08374d122..920173892 100644 --- a/packages/business/tests/chief-of-staff.test.mjs +++ b/packages/business/tests/chief-of-staff.test.mjs @@ -111,7 +111,7 @@ test("a business grant of a gated action through limits does not widen the role" test("a launch block whose by is the chief-of-staff instance is refused", () => { refuses( () => loadFixture((doc) => { - doc.launch = { by: INSTANCE, instances: ["coder"], max: { sonnet: 1 } }; + doc.launch = {...doc.launch, by: INSTANCE, instances: ["coder"]}; }), /launch\.by names chief, whose role chief-of-staff doesn't hold role\.launch within-role/, ); diff --git a/packages/business/tests/helpers.mjs b/packages/business/tests/helpers.mjs index 334fbe262..409606992 100644 --- a/packages/business/tests/helpers.mjs +++ b/packages/business/tests/helpers.mjs @@ -69,7 +69,7 @@ export function businessDoc(root, id = "acme") { coder: role("coder", 5, { vars: { harness: "pi", "limits.network": "none" } }), reviewer: role("reviewer", 6), }, - launch: { by: "pm", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } }, + launch: launchDoc(), }; } @@ -96,3 +96,10 @@ export function systemFor(root) { "execution.adapter": "mock", }; } + +// REQ-LIMIT-1 fixture policy: exact entries; these values are data, not ceilings. +export function launchDoc(by='pm',instances=['coder','reviewer'],counts={opus:2,sonnet:4}){ + const models=Object.fromEntries(['claude-opus-5-5','claude-sonnet-5-5','glm-5.3-flash'].map(model=>[model,{provider:'zai',model}])); + const values={};if(counts.opus!==undefined)values['claude-opus-5-5']=counts.opus;if(counts.sonnet!==undefined)values['claude-sonnet-5-5']=counts.sonnet; + return {by,instances,models,limits:{models:{zai:values}}}; +} diff --git a/packages/business/tests/launch-settings.test.mjs b/packages/business/tests/launch-settings.test.mjs new file mode 100644 index 000000000..cca3680ff --- /dev/null +++ b/packages/business/tests/launch-settings.test.mjs @@ -0,0 +1,57 @@ +// REQ-LIMIT-1 / REQ-ROLE-3: tunable settings, exact routing, audited writes. +import test,{after} from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync,statSync,writeFileSync,readdirSync,mkdirSync,symlinkSync,rmSync} from 'node:fs'; +import {join} from 'node:path'; +import {businessDoc,rolesCopy,tmp as makeRoot,writeJson} from './helpers.mjs'; +import {validateBusinessDocument} from '../src/business.mjs'; +const roots=[];after(()=>roots.forEach(root=>rmSync(root,{recursive:true,force:true}))); +function fixture(){const root=makeRoot();roots.push(root);const rolesDir=rolesCopy(root),doc=businessDoc(root,'acme');for(const r of Object.values(doc.roles))r.vars={...r.vars,model:'sonnet'};doc.roles.pm.vars.model='opus';doc.launch={by:'pm',instances:['coder','reviewer'],models:{opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},limits:{models:{anthropic:{'claude-opus-5-5':4,'claude-sonnet-5-5':4}}}};const file=join(root,'businesses/acme.json');writeJson(file,doc);return {root,rolesDir,doc,file};} +test('REQ-LIMIT-1 schema: every scope unset or zero, adjustable above four, bad values and unknown models refuse',()=>{ + const f=fixture(),check=d=>validateBusinessDocument(d,f.file,{rolesDir:f.rolesDir}); + for(const limits of [{},{total:0},{providers:{anthropic:0}},{models:{anthropic:{'claude-opus-5-5':0}}},{total:100,providers:{anthropic:25},models:{anthropic:{'claude-opus-5-5':12}}}]){const d=structuredClone(f.doc);d.launch.limits=limits;assert.deepEqual(check(d).launch.limits,limits);} + for(const bad of [-1,1.2,null,'4']){const d=structuredClone(f.doc);d.launch.limits={total:bad};assert.throws(()=>check(d));} + const old=structuredClone(f.doc);old.launch={by:'pm',instances:['coder'],max:{opus:4,sonnet:4}};assert.throws(()=>check(old),/migrat/); + const missing=structuredClone(f.doc);missing.roles.coder.vars.model='not-listed';assert.throws(()=>check(missing),/model/); +}); +test('REQ-LIMIT-1 counts each exact scope at/over limit, unlimited and old tuple after model reassignment',async()=>{ + const {limitHit}=await import('../src/launch.mjs');const sessions=[{business:'acme',provider:'anthropic',model:'opus-exact'},{business:'other',provider:'anthropic',model:'opus-exact'}]; + for(const limits of [{total:1},{providers:{anthropic:1}},{models:{anthropic:{'opus-exact':1}}}]){assert.ok(limitHit(sessions,'acme',limits,{provider:'anthropic',model:'opus-exact'}));assert.equal(limitHit(sessions,'acme',JSON.parse(JSON.stringify(limits).replaceAll(':1',':2')),{provider:'anthropic',model:'opus-exact'}),null);assert.ok(limitHit(sessions,'acme',JSON.parse(JSON.stringify(limits).replaceAll(':1',':0')),{provider:'anthropic',model:'opus-exact'}));} + for(const limits of [{total:0},{providers:{anthropic:0}},{models:{anthropic:{'opus-exact':0}}}])assert.ok(limitHit([],'acme',limits,{provider:'anthropic',model:'opus-exact'})); + assert.equal(limitHit(sessions,'acme',{}, {provider:'anthropic',model:'opus-exact'}),null); + assert.equal(limitHit(sessions,'acme',{models:{anthropic:{'sonnet-exact':1}}},{provider:'anthropic',model:'sonnet-exact'}),null); +}); +test('REQ-ROLE-3 writer: atomic validated revision, immutable previous bytes and authenticated actor/old/new receipt',async()=>{ + const {organizationWriter}=await import('../src/organization.mjs');const f=fixture(),w=organizationWriter({dir:f.root,rolesDir:f.rolesDir});const before=readFileSync(f.file,'utf8'),view=w.read('acme'); + const next=structuredClone(f.doc);next.launch.limits.total=99;const done=w.write('acme',next,{revision:view.revision,actor:'jason'}); + assert.equal(JSON.parse(readFileSync(f.file,'utf8')).launch.limits.total,99);assert.notEqual(done.revision,view.revision);assert.equal(readFileSync(done.previous,'utf8'),before); + const events=readFileSync(w.logFile('acme'),'utf8').trim().split('\n').map(JSON.parse);const e=events.at(-1);assert.equal(e.actor,'jason');assert.equal(e.state,'committed');assert.ok(e.changes.some(c=>c.path==='launch.limits.total'&&c.new===99)); + const bytes=readFileSync(f.file,'utf8');assert.throws(()=>w.write('acme',next,{revision:view.revision,actor:'jason'}),/revision/);next.launch.limits.total=-1;assert.throws(()=>w.write('acme',next,{revision:done.revision,actor:'jason'}));assert.equal(readFileSync(f.file,'utf8'),bytes);next.launch.limits.total=100;w.write('acme',next,{revision:done.revision,actor:'jason'});const last=JSON.parse(readFileSync(w.logFile('acme'),'utf8').trim().split('\n').at(-1));assert.ok(last.changes.some(c=>c.path==='launch.limits.total'&&c.old===99&&c.new===100)); +}); +test('REQ-ROLE-3 writer: pre-rename fault leaves old file, post-rename recovery records commit, conflicting recovery refuses',async()=>{ + const {organizationWriter}=await import('../src/organization.mjs');const f=fixture(),w=organizationWriter({dir:f.root,rolesDir:f.rolesDir});const before=readFileSync(f.file,'utf8'),revision=w.read('acme').revision,next=structuredClone(f.doc);next.launch.limits.total=7; + const crash=organizationWriter({dir:f.root,rolesDir:f.rolesDir,barrier:stage=>{if(stage==='prepared')throw Error('fixture crash');}});assert.throws(()=>crash.write('acme',next,{revision,actor:'jason'}));assert.equal(readFileSync(f.file,'utf8'),before); + assert.throws(()=>w.read('acme'),/recovery/);w.recover('acme');assert.equal(w.read('acme').revision,revision); + const committed=organizationWriter({dir:f.root,rolesDir:f.rolesDir,barrier:stage=>{if(stage==='renamed')throw Error('fixture after rename');}});assert.throws(()=>committed.write('acme',next,{revision,actor:'jason'}));w.recover('acme');assert.equal(w.read('acme').document.launch.limits.total,7);assert.equal(JSON.parse(readFileSync(w.logFile('acme'),'utf8').trim().split('\n').at(-1)).state,'committed'); +}); + +test('REQ-LIMIT-1 / decision145 migration prints only, preserves values, rejects ambiguity, and is idempotent',async()=>{ + const {migrateLaunch}=await import('../src/migrate-launch.mjs');const f=fixture(),old=structuredClone(f.doc);old.roles.pm.vars.model='claude-opus-5-5';for(const [name,r] of Object.entries(old.roles))if(name!=='pm')r.vars.model='claude-sonnet-5-5';old.launch={by:'pm',instances:['coder','reviewer'],max:{opus:4,sonnet:4}};writeJson(f.file,old);const bytes=readFileSync(f.file,'utf8'),mtime=statSync(f.file).mtimeMs; + const mapping={models:f.doc.launch.models,families:{opus:'opus',sonnet:'sonnet'}};const next=migrateLaunch(old,mapping,f.file,{rolesDir:f.rolesDir});assert.deepEqual(next.launch.limits, f.doc.launch.limits);assert.equal(next.roles.pm.vars.model,'opus');assert.equal(next.roles.coder.vars.model,'sonnet');assert.deepEqual(migrateLaunch(next,mapping,f.file,{rolesDir:f.rolesDir}),next); + assert.throws(()=>migrateLaunch(old,{...mapping,families:{opus:'missing',sonnet:'sonnet'}},f.file,{rolesDir:f.rolesDir}));assert.throws(()=>migrateLaunch(old,{...mapping,families:{opus:'opus',sonnet:'opus'}},f.file,{rolesDir:f.rolesDir}),/ambiguous/);assert.throws(()=>migrateLaunch(old,{...mapping,models:{...mapping.models,proxy:{provider:'proxy',model:'claude-opus-5-5'}},instances:{pm:'proxy'}},f.file,{rolesDir:f.rolesDir}),/ambiguous/);const onlyOpus=structuredClone(old);onlyOpus.launch.max={opus:4};assert.throws(()=>migrateLaunch(onlyOpus,mapping,f.file,{rolesDir:f.rolesDir}),/unknown or ambiguous/);const allOpus=structuredClone(old);for(const r of Object.values(allOpus.roles))r.vars.model='claude-opus-5-5';assert.throws(()=>migrateLaunch(allOpus,{...mapping,families:{opus:'opus',sonnet:'opus'}},f.file,{rolesDir:f.rolesDir}),/ambiguous/);assert.equal(readFileSync(f.file,'utf8'),bytes);assert.equal(statSync(f.file).mtimeMs,mtime); +}); +test('REQ-LIMIT-1 shared settings validates the complete proposed selection and logs exact actor',async()=>{ + const {launchSettings}=await import('../src/launch-settings.mjs');const f=fixture(),system={environment:'development',dataRoot:join(f.root,'data'),'execution.backend':'docker','execution.provider':'anthropic','execution.model':'sonnet','execution.adapter':'pi'};const settings=launchSettings({dir:f.root,rolesDir:f.rolesDir,system,sessions:()=>[{business:'acme',provider:'anthropic',model:'claude-opus-5-5'}]});const view=settings.read('acme');assert.equal(view.usage.total,1);assert.equal(view.instances.pm.modelKey,'opus'); + const result=settings.set('acme',{revision:view.revision,limits:{total:0},instances:{coder:'opus'}},'jason');assert.equal(result.limits.total,0);assert.equal(result.instances.coder.modelKey,'opus');const models=structuredClone(result.models);models.opus.provider='proxy';const updated=settings.set('acme',{revision:result.revision,models},'jason');assert.equal(updated.instances.pm.provider,'proxy');assert.throws(()=>settings.set('acme',{revision:result.revision,instances:{coder:'ghost'}},'jason'));assert.throws(()=>settings.set('acme',{revision:result.revision,roles:{}},'jason')); +}); +test('REQ-LIMIT-1 provider is table data: another provider routes separately and aliases share exact counts',async()=>{ + const {limitHit}=await import('../src/launch.mjs');const {sessionModel}=await import('../../harness/src/bundle.mjs'); + const models={alias:{provider:'proxy-next',model:'claude-opus-5-5'}},selected=sessionModel({vars:{harness:'claude-code',model:'alias'}},models);assert.equal(selected.provider,'proxy-next');assert.equal(selected.model,'claude-opus-5-5'); + const sessions=[{business:'acme',provider:'anthropic',model:selected.model}];assert.equal(limitHit(sessions,'acme',{providers:{'proxy-next':1},models:{'proxy-next':{[selected.model]:1}}},selected),null);sessions.push({business:'acme',provider:'proxy-next',model:selected.model});assert.equal(limitHit(sessions,'acme',{providers:{'proxy-next':1}},selected).scope,'provider');assert.equal(limitHit(sessions,'acme',{models:{'proxy-next':{[selected.model]:1}}},selected).scope,'model'); + assert.equal(limitHit([],'acme',{providers:{constructor:0}},{provider:'constructor',model:'toString'}).running,0); +}); +test('REQ-ROLE-3 refuses live writer locks, symlink targets and uncertain recovery without changing bytes',async()=>{ + const {organizationWriter}=await import('../src/organization.mjs');const f=fixture(),w=organizationWriter({dir:f.root,rolesDir:f.rolesDir}),revision=w.read('acme').revision,next=structuredClone(f.doc);next.launch.limits.total=8; + const crash=organizationWriter({dir:f.root,rolesDir:f.rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>crash.write('acme',next,{revision,actor:'jason'}));const external=structuredClone(f.doc);external.launch.limits.total=12;writeJson(f.file,external);const bytes=readFileSync(f.file,'utf8');assert.throws(()=>w.recover('acme'),/match prepared/);assert.equal(readFileSync(f.file,'utf8'),bytes); + const g=fixture(),gw=organizationWriter({dir:g.root,rolesDir:g.rolesDir}),gr=gw.read('acme').revision,lock=join(g.root,'businesses/.history/acme/write.lock');mkdirSync(lock,{recursive:true,mode:0o700});writeJson(join(lock,'owner.json'),{pid:process.pid});assert.throws(()=>gw.write('acme',g.doc,{revision:gr,actor:'jason'}),/busy/);rmSync(lock,{recursive:true});const target=join(g.root,'target.json');writeJson(target,g.doc);rmSync(g.file);symlinkSync(target,g.file);assert.throws(()=>gw.write('acme',g.doc,{revision:gr,actor:'jason'}));assert.equal(JSON.parse(readFileSync(target,'utf8')).id,'acme'); +}); diff --git a/packages/business/tests/resolve.test.mjs b/packages/business/tests/resolve.test.mjs index 75ac2925d..bba39fa9d 100644 --- a/packages/business/tests/resolve.test.mjs +++ b/packages/business/tests/resolve.test.mjs @@ -39,7 +39,7 @@ test("systemVars flattens the validated config", () => { test("precedence: system, business, project, project role, agent", () => { const s = setup((d) => { - d.roles.coder.vars.model = "agent-model"; + d.roles.coder.vars.model = "agent-model";d.launch.models["agent-model"]={provider:"zai",model:"agent-model"}; }, { vars: { "tracker.pollSeconds": 20, "tracker.project": 3 }, roles: { coder: { vars: { "tracker.pollSeconds": 15 } } }, diff --git a/packages/cli/README.md b/packages/cli/README.md index 1757ef1b6..7586cb792 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -263,8 +263,8 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or | its role has a contract file | `no-role-contract` | | a Claude Code session has `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL` to log in with | `no-session-credential` | | bwrap runs here | `sandbox-unavailable` | -| its model names exactly one `launch.max` family | `unknown-model-family` | -| that family has room; every running session counts, the PM's too | `capacity-full` | +| its model selects an exact entry in `launch.models` | `unsupported-harness` | +| total, provider and exact provider/model limits have room; the PM counts too | `capacity-full` | | the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) | | the workspace has a clone of each project, or the host can make one | `checkout-failed`, with the project id | | the bundle builds and the runner starts | `launch-failed` | @@ -628,3 +628,29 @@ exit 3, so a host never starts until someone decides whether it DMs. ## Not in this piece The WebUI (S5). + +## Agent limits and models + +`mosaic limits [--business ID] [--json]` reads the model table, each role's +selection, limits and running counts. `mosaic limits set --business ID +--file PATCH` submits JSON with the read's `revision` and any of `limits`, +`models`, `instances`. `limits` and `models` replace those entire objects; +`instances` maps role instance names to model-table keys. Both commands use +the existing human proof and broker; neither is available to a managed role +or tester. The Console Settings form uses the same write path and requires +signed-in send mode, named Host, exact Origin and same-origin Fetch Metadata. + +Unset limits mean unlimited; zero blocks new launches. Limits are checked +afresh at each launch in total, per provider and per exact provider/model. +Lowering one leaves running sessions alive and counted against their recorded +provider/model, including the PM. Capacity refusals log the scope, configured +limit and running count; launches log harness, provider and external model. + +`mosaic limits migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]` +needs no bus. It prints the whole converted document and never writes, including +on refusal. There is no `--write`. Mapping JSON supplies `models` (keys to +provider/model pairs), `families` (old family names to keys), and optional +`instances` or `selections` (old model strings to keys). Unknown or ambiguous +mappings refuse. Every held role must map too. A migrated file prints unchanged. +Jason installs the output himself in Mos's window with the PM run idle, and +the re-pin follows in the same window (decision 145). diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index a95eabaab..0a96dcd50 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -13,6 +13,7 @@ import { join } from "node:path"; import { createInterface } from "node:readline/promises"; import { fileURLToPath } from "node:url"; import { BINDING_NAME } from "../../discord/src/binding.mjs"; +import { limitsCommand, LIMITS_USAGE } from "./limits.mjs"; import { CliError } from "./errors.mjs"; import { bootConfig, loadSystem, socketPath } from "./config.mjs"; import { humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs"; @@ -24,6 +25,7 @@ import { readSessions, stopSession } from "../../seat/src/session.mjs"; import { SeatError } from "../../seat/src/seat.mjs"; export const USAGE = `usage: + ${LIMITS_USAGE} mosaic inbox [--business ] [--json] [--tester] mosaic decide