Files

1420 lines
116 KiB
Diff

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/<id>/<transaction>/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 <id>] [--json] [--tester]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json] [--tester]
@@ -379,6 +381,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (argv.length === 0) return app(io, deps);
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
+ if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "talk") return talk(rest, io, deps);
if (verb === "follow") return talk(rest, io, deps, true);
if (verb === "stop") return stop(rest, io, deps);
diff --git a/packages/cli/src/config.mjs b/packages/cli/src/config.mjs
index e6373532c..856320d34 100644
--- a/packages/cli/src/config.mjs
+++ b/packages/cli/src/config.mjs
@@ -94,6 +94,7 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
const config = {
dataRoot: system.dataRoot,
businesses: { [b.id]: busBusiness(b, resolved) },
+ organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars},
launches: [],
readers: [b.id],
};
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index 574614a64..40f85ad42 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -16,8 +16,8 @@
// 5. a Claude Code session has a credential to log in with
// (no-session-credential)
// 6. bwrap runs here (sandbox-unavailable)
-// 7. its model is in exactly one launch.max family (unknown-model-family)
-// 8. that family has room; every running session counts, the PM's too
+// 7. its model names an exact provider/model table entry
+// 8. total, provider and provider/model have room, including the launcher
// (capacity-full)
// 9. the broker authorizes role.launch for the caller (its own codes:
// launch-revoked, gated, ...)
@@ -45,12 +45,12 @@ import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
+import { limitHit } from "../../business/src/launch.mjs";
import { buildBundle, sessionModel } from "../../harness/src/bundle.mjs";
import {
ADAPTERS,
appendLaunchLog,
endReason,
- family,
hasSessionCredential,
gitSocketPath,
launchSocketPath,
@@ -120,7 +120,7 @@ export function createLauncher({
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
}) {
const dataRoot = system.dataRoot;
- const b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
+ let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
const vars = systemVars(system);
// Sessions an earlier host left in sessions.json: it died without ending
@@ -157,13 +157,14 @@ export function createLauncher({
// Steps 2 to 8. Returns what the launch needs. Only `--pm` may name
// launch.by itself.
function check(instance, { pm = false } = {}) {
+ try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
if (registry.running(b.id, instance)) throw new Refusal("instance-running");
let resolved, model;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
- model = sessionModel(resolved);
+ model = sessionModel(resolved, b.launch.models);
} catch (e) {
throw new Refusal("unsupported-harness", e.message);
}
@@ -174,14 +175,13 @@ export function createLauncher({
}
const unavailable = sandboxProblem({ bwrap });
if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);
- const fam = family(model.model, b.launch.max);
- if (!fam) throw new Refusal("unknown-model-family", `model ${model.model} is in none, or more than one, of ${Object.keys(b.launch.max).join(", ")}`);
- if (registry.count(b.id, fam) >= b.launch.max[fam]) throw new Refusal("capacity-full", `${fam} sessions: ${registry.count(b.id, fam)} of ${b.launch.max[fam]}`);
- return { resolved, model, fam };
+ const hit=limitHit(registry.sessions,b.id,b.launch.limits,model);
+ if(hit){const error=new Refusal("capacity-full",JSON.stringify(hit));error.limit=hit;throw error;}
+ return { resolved, model };
}
// Bundle, spawn, bind, register, hand over the capability.
- async function start({ instance, launchedBy, resolved, model, fam }) {
+ async function start({ instance, launchedBy, resolved, model }) {
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
// The clones come before the run, so one that can't be made leaves no
// run behind. The host's git treats each clone as hostile, and its
@@ -201,6 +201,7 @@ export function createLauncher({
if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
+ ({resolved,model}=check(instance,{pm:instance===b.launch.by}));
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = join(runDir, "session");
mkdirSync(sessionDir, { mode: 0o700 });
@@ -210,6 +211,7 @@ export function createLauncher({
const bundle = buildBundle({
dir: join(runDir, "bundle"),
resolved,
+ models: b.launch.models,
contract: readFileSync(resolved.contract, "utf8"),
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
@@ -266,7 +268,7 @@ export function createLauncher({
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, pid: child.pid, startTime: outerStart });
bound = true;
entry = {
- business: b.id, instance, run, launchedBy, harness: model.harness, model: model.model, family: fam,
+ business: b.id, instance, run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
};
registry.add(entry);
@@ -291,7 +293,7 @@ export function createLauncher({
children.delete(run);
}
});
- record({ event: "launch", instance, run, launchedBy, harness: model.harness, model: model.model, family: fam, pid: child.pid, runnerPid: entry.runnerPid, manifest });
+ record({ event: "launch", instance, run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, pid: child.pid, runnerPid: entry.runnerPid, manifest });
return { run, instance, harness: model.harness, model: model.model };
}
@@ -324,7 +326,7 @@ export function createLauncher({
} catch (e) {
if (!(e instanceof Refusal)) throw e;
await host.op({ op: "refuse", cap, code: e.code }).catch(() => {});
- record({ event: "refused", by: who.role, instance, code: e.code, why: e.message });
+ record({ event: "refused", by: who.role, instance, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
return { ok: false, error: e.code };
}
try {
@@ -339,7 +341,7 @@ export function createLauncher({
const code = e instanceof Refusal ? e.code : "launch-failed";
log(`launch of ${instance} for ${who.role} failed: ${e.message}`);
await host.op({ op: "refuse", cap, code }).catch(() => {});
- record({ event: "refused", by: who.role, instance, code, why: e.message });
+ record({ event: "refused", by: who.role, instance, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
return { ok: false, error: code };
}
});
@@ -353,7 +355,7 @@ export function createLauncher({
return await start({ instance: b.launch.by, launchedBy: b.human, ...check(b.launch.by, { pm: true }) });
} catch (e) {
if (!(e instanceof Refusal)) throw e;
- record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message });
+ record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
throw new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
}
});
diff --git a/packages/cli/src/limits.mjs b/packages/cli/src/limits.mjs
new file mode 100644
index 000000000..59ff5ca6c
--- /dev/null
+++ b/packages/cli/src/limits.mjs
@@ -0,0 +1,21 @@
+// REQ-LIMIT-1: human settings; decision145 migration is a separate print-only read.
+import {readFileSync} from 'node:fs';
+import {fileURLToPath} from 'node:url';
+import {join} from 'node:path';
+import {CliError} from './errors.mjs';
+import {migrateLaunch} from '../../business/src/migrate-launch.mjs';
+export const LIMITS_USAGE='mosaic limits [--business ID] [--json] | set --file PATCH [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]';
+export function parseLimits(args){const out={positional:[],flags:new Set(),values:{}};for(let i=0;i<args.length;i++){const a=args[i];if(a==='--json')out.flags.add(a);else if(['--file','--models','--roles-dir','--business'].includes(a)){if(!args[i+1]||Object.hasOwn(out.values,a))throw new CliError(LIMITS_USAGE,4);out.values[a]=args[++i];}else if(a.startsWith('--'))throw new CliError(LIMITS_USAGE,4);else out.positional.push(a);}return out;}
+export async function limitsCommand(args,io,callFor){
+ const p=parseLimits(args),verb=p.positional[0]??'view';if(p.positional.length>1)throw new CliError(LIMITS_USAGE,4);
+ try{
+ if(verb==='migrate'){
+ if(!p.values['--file']||!p.values['--models']||p.values['--business'])throw new CliError(LIMITS_USAGE,4);
+ const file=p.values['--file'],raw=JSON.parse(readFileSync(file,'utf8')),mapping=JSON.parse(readFileSync(p.values['--models'],'utf8'));const rolesDir=p.values['--roles-dir']??fileURLToPath(new URL('../../../roles',import.meta.url));
+ io.stdout.write(JSON.stringify(migrateLaunch(raw,mapping,file,{rolesDir}),null,2)+'\n');return;
+ }
+ if(!['view','set'].includes(verb)||p.values['--models']||p.values['--roles-dir']||(verb==='view'&&p.values['--file'])||(verb==='set'&&!p.values['--file']))throw new CliError(LIMITS_USAGE,4);
+ const call=callFor(p);const result=verb==='set'?await call('limits.set',JSON.parse(readFileSync(p.values['--file'],'utf8'))):await call('limits',{});
+ io.stdout.write(JSON.stringify(result,null,2)+'\n');
+ }catch(e){if(e instanceof CliError)throw e;throw new CliError(e.message,2);}
+}
diff --git a/packages/cli/tests/launch-limits.test.mjs b/packages/cli/tests/launch-limits.test.mjs
new file mode 100644
index 000000000..4d46ba416
--- /dev/null
+++ b/packages/cli/tests/launch-limits.test.mjs
@@ -0,0 +1,20 @@
+// REQ-LIMIT-1: pure launch-policy checks; no sandbox, Docker or session process.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {join} from 'node:path';
+import {readFileSync,writeFileSync} from 'node:fs';
+import {createLauncher} from '../src/launcher.mjs';
+import {fixture,tmp} from './helpers.mjs';
+import {systemFor,writeJson} from '../../business/tests/helpers.mjs';
+import {launchLogFile} from '../../seat/src/session.mjs';
+test('each launch rereads limits and model selections; lowered limits keep sessions and log exact hit',async t=>{
+ const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),file=join(root,'config/businesses/acme.json'),ops=[];
+ const host={business:'acme',beforeClose:()=>{},op:async x=>{ops.push(x);if(x.op==='identity')return {business:'acme',role:'pm'};if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});return {};}};
+ const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}});
+ const session={business:'acme',instance:'reviewer',run:'fixture-existing',harness:'pi',provider:'zai',model:'claude-sonnet-5-5'};launcher.registry.add(session);
+ const update=limits=>{f.doc.launch.limits=limits;writeJson(file,f.doc);};
+ for(const [limits,scope] of [[{total:0},'total'],[{providers:{zai:1}},'provider'],[{models:{zai:{'claude-sonnet-5-5':1}}},'model']]){update(limits);assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'capacity-full'});const event=JSON.parse(readFileSync(launchLogFile(f.dataRoot,'acme'),'utf8').trim().split('\n').at(-1));assert.equal(event.limit.scope,scope);assert.equal(event.limit.running,1);assert.deepEqual(launcher.registry.sessions,[session]);}
+ update({total:2});assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
+ f.doc.launch.models['claude-sonnet-5-5'].provider='another-provider';update({models:{zai:{'claude-sonnet-5-5':1}}});assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');assert.equal(launcher.registry.sessions[0].provider,'zai');
+ writeFileSync(file,'invalid JSON');assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'organization-refused');assert.deepEqual(launcher.registry.sessions,[session]);
+});
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index b7fef78ad..5d951d4c0 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -1,3 +1,4 @@
+import { launchDoc } from "../../business/tests/helpers.mjs";
// The S6 launcher inside a real bus host: real broker child, real runners
// in the bwrap sandbox, the fake adapter from packages/harness standing in for the
// harness. The PM launches a coder through its `launch` tool; every host
@@ -31,7 +32,7 @@ const skip = sandboxProblem() ?? false;
function edit(doc) {
doc.roles.coder.vars.model = "claude-sonnet-5-5";
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5" };
- doc.launch = { by: "pm", instances: ["coder", "reviewer", "cto"], max: { opus: 1, sonnet: 1 } };
+ doc.launch = launchDoc("pm",["coder","reviewer","cto"],{opus:1,sonnet:1});doc.launch.limits.models.zai["glm-5.3-flash"]=0;
return doc;
}
@@ -139,7 +140,7 @@ test("bus start --pm: the PM runs under its own PID namespace, registered, with
const [s] = readSessions(f.dataRoot);
assert.equal(s.run, pm.run);
assert.equal(s.live, true);
- assert.equal(s.family, "opus");
+ assert.equal(s.provider, "zai");
assert.equal(s.launchedBy, "jason");
assert.notEqual(s.pid, s.runnerPid, "the runner is the bwrap process's child");
assert.equal(statSync(sessionsFile(f.dataRoot)).mode & 0o777, 0o600);
@@ -250,7 +251,7 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
["nobody", "instance-not-listed"],
["coder", "instance-running"],
["reviewer", "capacity-full"],
- ["cto", "unknown-model-family"],
+ ["cto", "capacity-full"],
];
for (const [instance, code] of refusals) {
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: cto.cap, instance })}\n`), { ok: false, error: code }, instance);
diff --git a/packages/cli/tests/limits.test.mjs b/packages/cli/tests/limits.test.mjs
new file mode 100644
index 000000000..dfe38021b
--- /dev/null
+++ b/packages/cli/tests/limits.test.mjs
@@ -0,0 +1,28 @@
+// REQ-LIMIT-1 and decision145: human transport and print-only migration.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync,statSync} from 'node:fs';
+import {join} from 'node:path';
+import {main} from '../src/cli.mjs';
+import {limitsCommand} from '../src/limits.mjs';
+import {fixture,io,tmp} from './helpers.mjs';
+import {writeJson} from '../../business/tests/helpers.mjs';
+
+test('migration needs no bus/config, prints whole converted document, is idempotent and never writes',async t=>{
+ const root=tmp(t),f=fixture(root,'acme'),file=join(root,'config/businesses/acme.json');
+ const old=f.doc;old.roles.pm.vars.model='claude-opus-5-5';old.roles.cto.vars.model='claude-opus-5-5';old.roles.coder.vars.model='claude-sonnet-5-5';old.roles.reviewer.vars={model:'claude-opus-5-5'};old.launch={by:'pm',instances:['coder','reviewer'],max:{opus:4,sonnet:4}};writeJson(file,old);
+ const mapping={models:{opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},families:{opus:'opus',sonnet:'sonnet'},instances:{pm:'opus',cto:'opus',coder:'sonnet',reviewer:'opus'}};
+ const map=writeJson(join(root,'mapping.json'),mapping),args=['limits','migrate','--file',file,'--models',map,'--roles-dir',f.roles],bytes=readFileSync(file,'utf8'),mtime=statSync(file).mtimeNs??statSync(file).mtimeMs;
+ const x=io({MOSAIC_CONFIG:'/nonexistent'});await main(args,x,{transport:()=>assert.fail('migration contacted bus')});const migrated=JSON.parse(x.out.text);
+ assert.equal(migrated.roles.cto.vars.model,'opus');assert.deepEqual(migrated.launch.limits,{models:{anthropic:{'claude-opus-5-5':4,'claude-sonnet-5-5':4}}});assert.equal(migrated.roles.pm.vars.harness,'pi');assert.equal(readFileSync(file,'utf8'),bytes);assert.equal(statSync(file).mtimeNs??statSync(file).mtimeMs,mtime);
+ for(const bad of [{...mapping,families:{opus:'missing',sonnet:'sonnet'}},{...mapping,families:{opus:'opus',sonnet:'opus'}}]){writeJson(map,bad);await assert.rejects(main(args,io(),{}));assert.equal(readFileSync(file,'utf8'),bytes);assert.equal(statSync(file).mtimeMs,mtime);}
+ writeJson(map,mapping);const migratedFile=writeJson(join(root,'acme.json'),migrated),before=readFileSync(migratedFile,'utf8'),mt=statSync(migratedFile).mtimeMs,y=io();await main(['limits','migrate','--file',migratedFile,'--models',map,'--roles-dir',f.roles],y,{});assert.deepEqual(JSON.parse(y.out.text),migrated);assert.equal(readFileSync(migratedFile,'utf8'),before);assert.equal(statSync(migratedFile).mtimeMs,mt);
+ await assert.rejects(main([...args,'--write'],io(),{}),/mosaic limits/);
+});
+test('limits view/set use the human bus transport and pass only the explicit patch',async t=>{
+ const root=tmp(t),patch={revision:'a'.repeat(64),limits:{total:9},instances:{coder:'opus'}},file=writeJson(join(root,'patch.json'),patch),calls=[];
+ const deps={system:{dataRoot:root},transport:business=>(verb,args)=>{calls.push({business,verb,args});return {revision:'b'.repeat(64),limits:args.limits??{}};}};
+ const x=io();await main(['limits','--business','acme','--json'],x,deps);await main(['limits','set','--business','acme','--file',file],io(),deps);
+ assert.deepEqual(calls,[{business:'acme',verb:'limits',args:{}},{business:'acme',verb:'limits.set',args:patch}]);assert.equal(JSON.parse(x.out.text).revision,'b'.repeat(64));
+ await assert.rejects(main(['limits','--business','acme'],io({CODEX_THREAD_ID:'fixture-agent'}),deps),/agent/i);assert.equal(calls.length,2);
+});
diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs
index 0e4d63148..95dbb1ebe 100644
--- a/packages/harness/src/bundle.mjs
+++ b/packages/harness/src/bundle.mjs
@@ -46,12 +46,13 @@ const quote = (s) => {
// The harness, provider and model one instance runs with. The agent-layer
// `harness` and `model` variables win; otherwise the system's execution
// adapter and model. Anything outside the two harnesses refuses.
-export function sessionModel(resolved) {
+export function sessionModel(resolved, models = null) {
const v = resolved.vars;
const harness = v.harness ?? v["execution.adapter"];
if (!HARNESSES.includes(harness)) throw new BundleError(`instance ${resolved.instance}: harness ${JSON.stringify(harness)} isn't one of ${HARNESSES.join(", ")}`);
const model = v.model ?? v["execution.model"];
if (typeof model !== "string" || !model) throw new BundleError(`instance ${resolved.instance}: no model`);
+ if(models!==null){const entry=Object.hasOwn(models,model)?models[model]:null;if(!entry)throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`);return {harness,provider:entry.provider,model:entry.model,modelKey:model,thinking:v.thinking??null};}
return { harness, provider: v["execution.provider"] ?? null, model, thinking: v.thinking ?? null };
}
@@ -113,8 +114,8 @@ function prompt({ contract, resolved, session, tools, untyped }) {
// piVersion the pinned pi version, for the manifest
// claudeVersion the host's `claude --version`, for the manifest
// Returns { manifest, files } with absolute paths.
-export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null }) {
- const model = sessionModel(resolved);
+export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null, models = null }) {
+ const model = sessionModel(resolved, models);
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project) });
const untyped = untypedActions(resolved, tools);
mkdirSync(dir, { recursive: true, mode: 0o700 });
diff --git a/packages/harness/tests/pi-session.test.mjs b/packages/harness/tests/pi-session.test.mjs
index 95954b9d3..4098d0694 100644
--- a/packages/harness/tests/pi-session.test.mjs
+++ b/packages/harness/tests/pi-session.test.mjs
@@ -23,7 +23,7 @@ async function session(t, script, place = (dir) => join(dir, "ws")) {
writeFileSync(join(dir, "secret.txt"), "outside\n");
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}` });
writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { probe: { baseUrl: api.url, api: "anthropic-messages", apiKey: "probe-dummy", models: [{ id: "probe-model" }] } } }));
- const resolved = resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, model: "probe-model" }));
+ const resolved = resolvedFor(t, "pm", (d) => { d.roles.pm.vars = { ...d.roles.pm.vars, model: "probe-model" }; d.launch.models["probe-model"]={provider:"probe",model:"probe-model"}; });
const sock = await fakeToolSocket(t, dir, (tool, args) => {
if (tool === "launch") throw new Error("capacity-full");
// trail answers a string `size` characters long once JSON-encoded, for the reply cap.
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
index 29d17af4b..784bd674e 100644
--- a/packages/harness/tests/runner.test.mjs
+++ b/packages/harness/tests/runner.test.mjs
@@ -23,7 +23,7 @@ const businesses = {
id: "demo",
human: "jason",
arbiters: { technical: "pm", delivery: "pm" },
- launch: { by: "pm", instances: ["coder"], max: { opus: 2, sonnet: 2 } },
+ launch: { by: "pm", instances: ["coder"], models:{}, limits:{} },
roles: {
pm: { authority: { withinRole: ["message.send", "role.launch"], crossRole: [] } },
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
diff --git a/packages/seat/src/session.mjs b/packages/seat/src/session.mjs
index f04386ac3..19c4fc53d 100644
--- a/packages/seat/src/session.mjs
+++ b/packages/seat/src/session.mjs
@@ -48,14 +48,6 @@ export const runsDir = (dataRoot, business) => join(dataRoot, "launches", busine
export const launchLogFile = (dataRoot, business) => join(dataRoot, "launches", `${business}.jsonl`);
export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "workspaces", business, instance);
-// The capacity family of a model: the one key of the business file's
-// launch.max that the model name contains (`claude-opus-5-5` is `opus`).
-// None, or more than one, is null, and the launch refuses.
-export function family(model, max) {
- const hits = Object.keys(max).filter((f) => model.toLowerCase().includes(f.toLowerCase()));
- return hits.length === 1 ? hits[0] : null;
-}
-
// The session's environment is an allowlist: nothing else from the host's
// environment passes, so a founder credential variable (GITEA_TOKEN,
// GH_TOKEN, SSH_AUTH_SOCK, ...) never reaches a session (REQ-CRED-2).
@@ -186,8 +178,8 @@ export class Registry {
this.#sessions = this.#sessions.filter((s) => !(s.business === business && s.run === run));
this.#write();
}
- count(business, fam) {
- return this.#sessions.filter((s) => s.business === business && s.family === fam).length;
+ count(business, provider, model = undefined) {
+ return this.#sessions.filter((s) => s.business === business && s.provider === provider && (model === undefined || s.model === model)).length;
}
running(business, instance) {
return this.#sessions.find((s) => s.business === business && s.instance === instance) ?? null;
diff --git a/packages/seat/tests/session.test.mjs b/packages/seat/tests/session.test.mjs
index e072709cb..c9babcd6a 100644
--- a/packages/seat/tests/session.test.mjs
+++ b/packages/seat/tests/session.test.mjs
@@ -17,7 +17,6 @@ import {
appendLaunchLog,
endReason,
ENV_ALLOW,
- family,
hasSessionCredential,
SESSION_CREDENTIALS,
isRunner,
@@ -46,13 +45,7 @@ function scratch(t) {
const mode = (p) => statSync(p).mode & 0o777;
-test("family: exactly one launch.max key in the model name, else null", () => {
- const max = { opus: 2, sonnet: 4 };
- assert.equal(family("claude-opus-5-5", max), "opus");
- assert.equal(family("Claude-Sonnet-5-5", max), "sonnet");
- assert.equal(family("glm-5.3-flash", max), null);
- assert.equal(family("opus-sonnet-mix", max), null);
-});
+test("REQ-LIMIT-1: family substring matching is no longer exported",async()=>{const m=await import('../src/session.mjs');assert.equal(Object.hasOwn(m,'family'),false);});
test("sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id", () => {
const env = sessionEnv({ HOME: "/h", ANTHROPIC_API_KEY: "k", GITEA_TOKEN: "t", GH_TOKEN: "t", SSH_AUTH_SOCK: "/s", MOSAIC_CONFIG: "/c", PATH: "/usr/bin" }, "r0123456789ab");
@@ -122,11 +115,11 @@ test("Registry mirrors to sessions.json; readSessions marks live entries; bad fi
const r = new Registry(root);
assert.equal(mode(sessionsFile(root)), 0o600);
assert.deepEqual(readSessions(root), []);
- const me = { business: "acme", instance: "coder", run: "r1", family: "sonnet", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) };
+ const me = { business: "acme", instance: "coder", run: "r1", provider:"zai",model:"claude-sonnet-5-5", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) };
r.add(me);
- r.add({ ...me, instance: "pm", run: "r2", family: "opus", runnerStartTime: "1" });
- assert.equal(r.count("acme", "sonnet"), 1);
- assert.equal(r.count("other", "sonnet"), 0);
+ r.add({ ...me, instance: "pm", run: "r2", provider:"zai",model:"claude-opus-5-5", runnerStartTime: "1" });
+ assert.equal(r.count("acme", "zai","claude-sonnet-5-5"), 1);
+ assert.equal(r.count("other", "zai","claude-sonnet-5-5"), 0);
assert.equal(r.running("acme", "coder").run, "r1");
assert.deepEqual(readSessions(root).map((s) => [s.run, s.live]), [["r1", true], ["r2", false]]);
r.remove("acme", "r1");
diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs
index 78f7d647a..ed0581498 100644
--- a/packages/webui/src/bus.mjs
+++ b/packages/webui/src/bus.mjs
@@ -85,6 +85,7 @@ export function sendOutcome(err) {
const STATUS = {
'human-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
+ 'revision-conflict':409,'organization-busy':409,'organization-uncertain':503,
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
};
export const busStatus = code => STATUS[code] ?? 502;
diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs
index de03a5ee4..fcf353f80 100644
--- a/packages/webui/src/cli.mjs
+++ b/packages/webui/src/cli.mjs
@@ -51,13 +51,13 @@ try {
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
// --send without a bus refuses to start rather than serve a composer that can't send.
- let bus = null, send = null, busLine, system = null;
+ let bus = null, send = null, settingsWrite = null, busLine, system = null;
try {
system = loadSystem();
const { dataRoot } = system;
const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
bus = views({ call });
- if (sendOn) send = args => call('message.send', args);
+ if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);}
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
@@ -65,7 +65,7 @@ try {
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
- const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send });
+ const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite });
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}\nCtrl-C stops this server.`);
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 0cc841bbc..15fcdeb21 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -539,7 +539,7 @@
const refused = instances.filter(i => i.refused).map(i => `<li>${txt(i.instance)}: ${txt(i.refused)}</li>`).join('');
const credRows = creds.map(c => `<tr><td>${txt(c.service)}</td><td>${txt(c.account) || '<span class="muted">not named in the business file</span>'}</td><td>${txt(c.role)}</td><td>${txt(c.dateKind === 'expires' ? 'expires' : 'rotate by')} <time datetime="${esc(c.date)}">${txt(c.date)}</time></td><td>${badge(c.state, CRED[c.state] || 'muted')}</td></tr>`).join('');
return `${head(`Business ${txt(b.id)}`, `${readLine()} The business file is edited where it lives; Console only reads it.`)}
- <section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${arbiterRefs(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
+ <section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${arbiterRefs(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.limits?.models || {}).flatMap(([p, ms]) => Object.entries(ms).map(([m, c]) => `${txt(c)} ${txt(p)} / ${txt(m)}`)).join(', ') || none}</dd></dl>
<h3>Vars</h3>${kvRows(b.vars, 'No vars Console shows are set.')}</section>
<section aria-labelledby="bz-roles"><h2 id="bz-roles">Role instances <span class="count">${instances.length}</span></h2>${instances.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table class="s1-table s1-roles"><thead><tr>${['Instance', 'Definition', 'Holder', 'Harness'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${roleRows}</tbody></table></div>` : empty('This business has no role instances.', 'An instance shows here when the business file names one under roles.')}</section>
${instances.length ? `<section aria-labelledby="bz-auth"><h2 id="bz-auth">Authority</h2><p class="small muted">Within the role, cross-role (needs the other role), or gated (needs the human), as the business package classifies each action.</p>${matrix}${refused ? `<ul class="s1-plain">${refused}</ul>` : ''}</section>` : ''}
@@ -552,12 +552,42 @@
const pick = (id, label, src) => `<label>${label}<select data-mirror="${id}">${[...src.options].map(o => `<option value="${esc(o.value)}"${o.value === src.value ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('')}</select></label>`;
return `<section aria-labelledby="st-look" class="panel"><h2 id="st-look">Appearance</h2><p class="small muted">Stored in this browser only. The same control is in the command bar.</p><div class="filters">${pick('palette', 'Palette', p)}${pick('mode', 'Appearance', m)}</div></section>`;
}
+ // REQ-WEB-1: drafts survive polling/refusals; only an explicit submit writes.
+ let settingsSnapshot=null, settingsInputs=null, settingsSaving=false, settingsReceipt='';
+ const inputKey=parts=>JSON.stringify(parts);
+ function launchForm(value){
+ if(!value||value.refused)return `<section class="panel"><h2>Agent limits</h2><p>${txt(value?.message||'Launch settings are not configured.')}</p></section>`;
+ if(!settingsInputs)settingsSnapshot=value;
+ const v=settingsSnapshot, modelTable=v.models||{}, limits=v.limits||{}, usage=v.usage||{};
+ const val=(parts,fallback)=>settingsInputs&&Object.hasOwn(settingsInputs,inputKey(parts))?settingsInputs[inputKey(parts)]:fallback;
+ const disabled=!canSend||out()||settingsSaving?' disabled':'';
+ const field=(parts,label,count,current)=>`<label>${txt(label)} <input data-setting="${esc(inputKey(parts))}" type="number" min="0" step="1" value="${esc(val(parts,count??''))}"${disabled}> <span class="small muted">${txt(current??0)} running</span></label>`;
+ const providers=new Set([...Object.keys(limits.providers||{}),...Object.values(modelTable).map(x=>x.provider)]);
+ const pairs=new Map();for(const [provider,models]of Object.entries(limits.models||{}))for(const model of Object.keys(models))pairs.set(inputKey([provider,model]),[provider,model]);for(const m of Object.values(modelTable))pairs.set(inputKey([m.provider,m.model]),[m.provider,m.model]);
+ const providerRows=[...providers].map(p=>field(['providers',p],p,limits.providers?.[p],usage.providers?.[p])).join('');
+ const modelRows=[...pairs.values()].map(([p,m])=>field(['models',p,m],`${p} / ${m}`,limits.models?.[p]?.[m],usage.models?.[p]?.[m])).join('');
+ const table=Object.entries(modelTable).map(([key,m])=>`<div class="filters"><strong>${txt(key)}</strong><label>Provider <input data-setting="${esc(inputKey(['table',key,'provider']))}" value="${esc(val(['table',key,'provider'],m.provider))}" maxlength="200"${disabled}></label><label>Model <input data-setting="${esc(inputKey(['table',key,'model']))}" value="${esc(val(['table',key,'model'],m.model))}" maxlength="200"${disabled}></label></div>`).join('');
+ const selections=Object.entries(v.instances||{}).map(([instance,entry])=>`<label>${txt(instance)} <select data-setting="${esc(inputKey(['instances',instance]))}"${disabled}>${Object.keys(modelTable).map(key=>`<option value="${esc(key)}"${val(['instances',instance],entry.modelKey)===key?' selected':''}>${txt(key)}</option>`).join('')}</select></label>`).join('');
+ return `<section class="panel" aria-labelledby="launch-settings-title"><h2 id="launch-settings-title">Agent limits</h2><p>Leave a limit blank for no limit. Zero stops new launches. Running sessions keep running when limits or models change.</p><form id="launch-settings-form"><fieldset><legend>All sessions</legend>${field(['total'],'Total',limits.total,usage.total)}</fieldset><fieldset><legend>By provider</legend><div class="filters">${providerRows}</div></fieldset><fieldset><legend>By provider and model</legend><div class="filters">${modelRows}</div></fieldset><fieldset><legend>Model table</legend>${table}</fieldset><fieldset><legend>Role models</legend><div class="filters">${selections}</div></fieldset><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${settingsSaving?'Saving…':'Save launch settings'}</button> <button type="button" id="launch-settings-reload">Read current settings and discard draft</button><p role="status" id="launch-settings-receipt">${txt(settingsReceipt)}</p></form></section>`;
+ }
+ function settingDraft(form){const values={};for(const el of form.querySelectorAll('[data-setting]'))values[el.dataset.setting]=el.value;return values;}
+ function settingPayload(){const v=settingsSnapshot, limits={providers:Object.create(null),models:Object.create(null)},models=structuredClone(v.models),instances={};for(const [encoded,text]of Object.entries(settingsInputs)){const [kind,p,m]=JSON.parse(encoded);if(kind==='table'){models[p][m]=text;continue;}if(kind==='instances'){instances[p]=text;continue;}if(text==='')continue;if(!/^\d+$/.test(text)||!Number.isSafeInteger(Number(text)))throw Error('Limits need whole numbers of zero or more.');const n=Number(text);if(kind==='total')limits.total=n;else if(kind==='providers')limits.providers[p]=n;else {limits.models[p]??=Object.create(null);limits.models[p][m]=n;}}
+ return {revision:v.revision,limits,models,instances};
+ }
+ document.addEventListener('input',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);});
+ document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);});
+ document.addEventListener('click',e=>{if(e.target.id!=='launch-settings-reload')return;settingsInputs=null;settingsSnapshot=null;settingsReceipt='';last.delete('api:settings');render(true);});
+ document.addEventListener('submit',async e=>{
+ if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message;render(false);return;}settingsSaving=true;settingsReceipt='';render(false);
+ try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}}
+ catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);}
+ });
async function settingsView(get) {
const s = await get.api('settings'), n = s.notifier || {};
const notify = typeof n.binding === 'string'
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
- return `${head('Settings', `${readLine()} Only appearance changes here.`)}${appearance()}
+ return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
}
@@ -888,7 +918,7 @@
if (c?.send !== true) return;
canSend = true;
const path = routeOf().split('?')[0];
- if (path.startsWith('/inbox')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
+ if (path.startsWith('/inbox')||path==='/settings') render(false); else if (path === '/' || path === '') paintHeld(heldErr);
}).catch(() => {});
window.addEventListener('hashchange', () => render(true));
$('refresh').addEventListener('click', () => render(false));
diff --git a/packages/webui/src/reads.mjs b/packages/webui/src/reads.mjs
index 588db4997..797afbd31 100644
--- a/packages/webui/src/reads.mjs
+++ b/packages/webui/src/reads.mjs
@@ -20,6 +20,8 @@ import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { BusinessError, loadBusiness, resolveInstance, classify, systemVars } from '../../business/src/index.mjs';
+import { launchSettings } from '../../business/src/launch-settings.mjs';
+import { readSessions } from '../../seat/src/session.mjs';
import { ACTIONS } from '../../business/src/vocabulary.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { readNotifyConfig } from '../../cli/src/cli.mjs';
@@ -216,7 +218,9 @@ export function consoleReads({ root, queueRoot = root, system = null, configDir
} catch (err) {
notifier = { refused: err instanceof ReadError ? err.code : 'not-configured', message: err.message };
}
- return scrub({ release, business: id, notifier, at: new Date(now()).toISOString() }, redact);
+ let launch;
+ try{if(!configDir||!system||!id)throw new Error('limits are not configured');launch=launchSettings({dir:configDir,rolesDir,system:systemVars(system),sessions:()=>readSessions(dataRoot)}).read(id);}catch{launch={refused:'not-configured',message:'Launch settings could not be read.'};}
+ return scrub({ release, business: id, notifier, launch, at: new Date(now()).toISOString() }, redact);
},
};
}
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 4b3859325..b354a8df3 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -152,10 +152,20 @@ export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
if (typeof allowUser !== 'string' || !USER.test(allowUser)) throw new Error('allowed user must be 1..256 visible ASCII characters without a comma');
return { host, header, user: allowUser };
}
+// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
+async function settingsSend(req,res,{settingsWrite,preview,log}){
+ const refuse=(status,error,message)=>json(res,status,{error,message,sent:false});
+ if(one(req,preview.header)!==preview.user)return refuse(401,'sign-in required','sign in again');
+ if(one(req,'origin')!==`https://${preview.host}`)return refuse(403,'cross-origin request refused','settings need the Console origin');
+ if(one(req,'sec-fetch-site')!=='same-origin')return refuse(403,'cross-site request refused','settings need same-origin Fetch Metadata');
+ let value;try{value=JSON.parse((await body(req,16384)).toString('utf8'));}catch{return refuse(400,'invalid-request','settings need a JSON object within 16 KiB');}
+ if(Object.keys(value).some(k=>!['revision','limits','models','instances'].includes(k))||typeof value.revision!=='string'||!/^[a-f0-9]{64}$/.test(value.revision)||!['limits','models','instances'].some(k=>Object.hasOwn(value,k)))return refuse(400,'invalid-request','settings need a revision and limits, models or instance selections');
+ try{const result=await settingsWrite(value);log(`${new Date().toISOString()} settings user=${JSON.stringify(preview.user)} changed`);return json(res,200,result);}catch(err){const {code,sent}=sendOutcome(err);log(`${new Date().toISOString()} settings user=${JSON.stringify(preview.user)} error=${code}`);if(sent==='unknown')return json(res,503,{error:'outcome-unknown',message:'Outcome unknown. Read settings before trying again.',sent:'unknown'});return refuse(busStatus(code),code,'Settings refused; read the current values before trying again.');}
+}
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
-export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
+export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
const upstream = boardURL(board);
@@ -191,7 +201,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const values = req.headersDistinct[preview.header];
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
- // With --send, POST /api/bus/message is the one exception; it checks the header again itself.
+ if(path==='/api/settings'&&req.method==='POST'){
+ if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
+ return settingsSend(req,res,{settingsWrite,preview,log});
+ }
+ // Message sending has its own bounded payload and repeats the identity check.
if (send && path === '/api/bus/message') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview, log });
diff --git a/packages/webui/tests/reads-fixture.mjs b/packages/webui/tests/reads-fixture.mjs
index 274717d84..539a1ba6c 100644
--- a/packages/webui/tests/reads-fixture.mjs
+++ b/packages/webui/tests/reads-fixture.mjs
@@ -42,7 +42,7 @@ export function seeded(t, { business = true, notify = true } = {}) {
doc.roles.coder.credentials.gitea = { env: 'CODER_GITEA_SECRET_ENV', rotateBy: day(-1) };
doc.roles.coder.credentials.vikunja.expires = day(3);
doc.roles.reviewer.credentials.vikunja.expires = day(-2);
- doc.roles.reviewer.vars = { model: '/srv/secret/models/local.gguf' };
+ doc.roles.reviewer.vars = { model: 'local-model' };doc.launch.models['local-model']={provider:'fixture',model:'/srv/secret/models/local.gguf'};
if (business) writeJson(join(configDir, 'businesses', 'acme.json'), doc);
if (notify) writeJson(join(dataRoot, 'notify', 'acme', 'notify.json'), { notifyVersion: 1, binding: 'jason-dm' });
writeJson(join(dataRoot, 'discord', 'jason-dm.json'), {
diff --git a/packages/webui/tests/reads.test.mjs b/packages/webui/tests/reads.test.mjs
index 13de88b21..77375fdd0 100644
--- a/packages/webui/tests/reads.test.mjs
+++ b/packages/webui/tests/reads.test.mjs
@@ -1,3 +1,4 @@
+import { launchDoc } from "../../business/tests/helpers.mjs";
// Row 54 (#1543): the Queue, Business and Settings reads over the seeded
// fixture in reads-fixture.mjs. No response may carry a secret, an id or
// the temporary directory.
@@ -69,7 +70,7 @@ test('queue: rows, one row, ids and methods, notes; no path or id from a row', a
assert.equal((await get(base, '/api/queue/7')).status, 404);
for (const bad of ['abc', '0', '01', '1234567', '9/x', '-1']) assert.equal((await get(base, `/api/queue/${bad}`)).status, 400, bad);
for (const path of ['/api/queue', '/api/queue/9', '/api/business', '/api/settings']) {
- assert.equal((await get(base, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 405, path);
+ assert.equal((await get(base, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, path === '/api/settings' ? 403 : 405, path);
}
assert.equal((await get(base, '/api/queue', { headers: { origin: 'https://evil.example' } })).status, 403);
});
@@ -119,13 +120,13 @@ test('business: names, vars on the allowlist, authority, credential metadata onl
assert.deepEqual(b.instances.map(i => [i.instance, i.definition, i.holder]), [['pm', 'pm', 'sage'], ['cto', 'cto', 'darkwing'], ['coder', 'coder', null], ['reviewer', 'reviewer', null]]);
assert.deepEqual(b.instances.find(i => i.instance === 'coder').vars, { harness: 'pi', 'limits.network': 'none' });
// An allowlisted var is scrubbed too (Filbert T1).
- assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: '<path>' });
+ assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: 'local-model' });
for (const i of b.instances) {
assert.deepEqual(Object.keys(i.authority), b.actions);
assert.ok(Object.values(i.authority).every(v => ['within', 'cross', 'gated'].includes(v)));
}
assert.equal(b.instances.find(i => i.instance === 'pm').authority['role.launch'], 'within');
- assert.deepEqual(b.launch, { by: 'pm', instances: ['coder', 'reviewer'], max: { opus: 2, sonnet: 4 } });
+ assert.deepEqual(b.launch.limits,launchDoc().limits);assert.equal(b.launch.models['local-model'].model,'<path>');
const cred = (role, service) => b.credentials.find(c => c.role === role && c.service === service);
assert.deepEqual(Object.keys(cred('pm', 'gitea')).sort(), ['account', 'date', 'dateKind', 'role', 'service', 'state']);
assert.deepEqual(cred('coder', 'gitea'), { service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: day(-1), state: 'rotation-due' });
@@ -177,7 +178,10 @@ test('settings: release, board, loopback address, notifier binding name only', a
let r = await get(base, '/api/settings');
assert.equal(r.status, 200);
const body = JSON.parse(r.text);
- assert.deepEqual({ ...body, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, at: undefined });
+ assert.equal(body.launch.instances.pm.provider, 'zai');
+ assert.equal(body.launch.limits.models.zai['claude-opus-5-5'], 2);
+ assert.match(body.launch.revision, /^[a-f0-9]{64}$/);
+ assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, launch: undefined, at: undefined });
clean(r.text, s.base, repo.base);
// No notifier config: still 200, so appearance works; the reason is redacted.
const bare = seeded(t, { notify: false });
diff --git a/packages/webui/tests/settings-form.test.mjs b/packages/webui/tests/settings-form.test.mjs
new file mode 100644
index 000000000..efd95d237
--- /dev/null
+++ b/packages/webui/tests/settings-form.test.mjs
@@ -0,0 +1,23 @@
+// REQ-WEB-1: actual Settings form over fixture reads and intercepted sends.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {browser} from './browser.mjs';
+import {startServer} from '../src/serve.mjs';
+import {close} from './fixture.mjs';
+const launch={business:'acme',revision:'a'.repeat(64),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}}},instances:{pm:{modelKey:'opus'},cto:{modelKey:'opus'},coder:{modelKey:'sonnet'},reviewer:{modelKey:'opus'}},usage:{total:1,providers:{anthropic:1},models:{anthropic:{'claude-opus-5-5':1}}}};
+test('Settings form shows every scope and held role; gated save, exact payload, retained draft and no automatic retry', {timeout:90000},async()=>{
+ const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0})};const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch})}}),b=await browser();
+ try{
+ await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.mode='success';window.fixture=${JSON.stringify(launch)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));if(mode==='unknown')throw Error('fixture lost answer');if(mode==='refused')return new Response(JSON.stringify({message:'fixture conflict',sent:false}),{status:409});return new Response(JSON.stringify({...fixture,...posts.at(-1),revision:'b'.repeat(64)}));}return original(url,options);};`});
+ await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings`);
+ const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
+ await wait('!!document.querySelector("#launch-settings-form")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-form button[type=submit]").disabled'),true);
+ assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form select").length'),4);assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form input[type=number]").length'),4);
+ await b.navigate(`http://127.0.0.1:${web.address().port}/?send=1#/settings`);await wait('document.querySelector("#launch-settings-form button[type=submit]")?.disabled===false');
+ const change=async(parts,value)=>b.evaluate(`(()=>{const e=[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(parts))});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
+ const submit=()=>b.evaluate('document.querySelector("#launch-settings-form").requestSubmit();true');
+ await change(['total'],'0');await change(['providers','anthropic'],'12');await change(['instances','coder'],'opus');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("saved")');const [payload]=await b.evaluate('posts');assert.equal(payload.limits.total,0);assert.equal(payload.limits.providers.anthropic,12);assert.equal(payload.instances.coder,'opus');assert.equal(payload.revision,launch.revision);assert.deepEqual(payload.models,launch.models);
+ await change(['total'],'9');await b.evaluate('mode="unknown"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("Outcome unknown")');assert.equal(await b.evaluate('posts.length'),2);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');await b.evaluate('new Promise(r=>setTimeout(r,300))');assert.equal(await b.evaluate('posts.length'),2);
+ await b.evaluate('mode="refused"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("fixture conflict")');assert.equal(await b.evaluate('posts.length'),3);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');
+ }finally{await b.close();await close(web);}
+});
diff --git a/packages/webui/tests/settings-write.test.mjs b/packages/webui/tests/settings-write.test.mjs
new file mode 100644
index 000000000..d7f58672b
--- /dev/null
+++ b/packages/webui/tests/settings-write.test.mjs
@@ -0,0 +1,18 @@
+// REQ-WEB-1 / REQ-LIMIT-1: Settings uses the Console's human send boundary.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {request} from 'node:http';
+import {startServer} from '../src/serve.mjs';
+const preview={host:'console.example',header:'x-user',user:'jason'};
+const headers={host:'console.example','x-user':'jason',origin:'https://console.example','sec-fetch-site':'same-origin','content-type':'application/json'};
+const payload={revision:'a'.repeat(64),limits:{total:0}};
+async function setup(t,enabled=true){const calls=[];const server=await startServer({port:0,preview,send:enabled?async()=>({}):null,settingsWrite:async args=>{calls.push(args);return {...args,business:'acme'};}});t.after(()=>new Promise(r=>server.close(r)));return {calls,port:server.address().port};}
+function post(s,hs=headers,data=payload){return new Promise((resolve,reject)=>{const req=request({hostname:'127.0.0.1',port:s.port,path:'/api/settings',method:'POST',headers:hs},r=>{let text='';r.on('data',c=>text+=c);r.on('end',()=>resolve({status:r.statusCode,text}));});req.on('error',reject);req.end(typeof data==='string'?data:JSON.stringify(data));});}
+test('REQ-WEB-1 signed-in same-origin Settings write reaches exactly one human operation',async t=>{const s=await setup(t);const r=await post(s);assert.equal(r.status,200,r.text);assert.deepEqual(s.calls,[payload]);});
+test('REQ-WEB-1 Settings writes require enabled send, named Host, identity, exact Origin and same-origin Fetch Metadata',async t=>{
+ const off=await setup(t,false);assert.equal((await post(off)).status,403);assert.equal(off.calls.length,0);
+ const s=await setup(t);for(const [key,value,status] of [['host','wrong.example',403],['x-user','other',401],['origin','https://else.example',403],['sec-fetch-site','cross-site',403],['origin',undefined,403],['sec-fetch-site',undefined,403],['x-user',['jason','jason'],401]]){const hs={...headers};if(value===undefined)delete hs[key];else hs[key]=value;assert.equal((await post(s,hs)).status,status,key);}assert.equal(s.calls.length,0);
+});
+test('REQ-WEB-1 Settings validates JSON, size, revision and strict writable keys before broker call',async t=>{
+ const s=await setup(t);for(const value of [{...payload,actor:'jason'},{limits:{}},[],null,'bad','x'.repeat(17000)])assert.equal((await post(s,headers,value)).status,400,String(value));assert.equal((await post(s,{...headers,'content-type':'text/plain'})).status,400);assert.equal(s.calls.length,0);
+});
diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs
index 3aa02734d..8b943638f 100644
--- a/packages/webui/tests/shell.test.mjs
+++ b/packages/webui/tests/shell.test.mjs
@@ -50,12 +50,12 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
- // No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has one write,
- // row 76's message to a role, and it sends only to and body.
+ // No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has two explicit writes:
+ // row 76's message to a role and row 122's human Settings update.
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
- assert.deepEqual(writes, ["fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
- assert.equal(read('bus.js').toString().split(/method:/).length - 1, 1);
+ assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
+ assert.equal(read('bus.js').toString().split(/method:/).length - 1, 2);
assert.equal(read('icons.svg').toString().includes('<script'), false);
});
diff --git a/packages/webui/tests/views.test.mjs b/packages/webui/tests/views.test.mjs
index 80ffceac9..a6e5cd7bc 100644
--- a/packages/webui/tests/views.test.mjs
+++ b/packages/webui/tests/views.test.mjs
@@ -29,7 +29,7 @@ function stubReads() {
business: {
id: 'acme', human: 'jason', arbiters: { delivery: 'pm' }, projects: ['stack'], vars: { 'tracker.pollSeconds': 60 }, actions: ['role.launch'],
instances: [{ instance: 'pm', definition: 'pm', holder: 'sage', vars: { harness: 'pi' }, authority: { 'role.launch': 'within' } }],
- launch: { by: 'pm', instances: ['pm'], max: { opus: 1 } },
+ launch: { by: 'pm', instances: ['pm'], models: {opus:{provider:'fixture',model:'opus'}}, limits:{models:{fixture:{opus:1}}} },
credentials: [{ service: 'gitea', account: 'bot-acme', role: 'pm', dateKind: 'expires', date: '2099-01-01', state: 'valid' }],
},
settings: { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' } },
diff --git a/scripts/mosaic b/scripts/mosaic
index 1f43dcb12..010e1a7e6 100755
--- a/scripts/mosaic
+++ b/scripts/mosaic
@@ -24,6 +24,6 @@ if [ "${1:-}" = business ]; then
exec node "$REPO/packages/business/src/cli.mjs" "$@"
fi
case "${1:-}" in
- inbox|decide|tasks|agents|trail|talk|stop|launches|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
+ limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"