diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 13366b63a..bba405c3f 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -566,3 +566,22 @@ 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 or system config and has no write flag. Inherited models +require a valid system config and an exact matching provider/model mapping. +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..64689c573 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,25 @@ 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..c28716de4 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -4,10 +4,12 @@ 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 { 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 +18,7 @@ export async function startBroker({ readers = [], repoRoots = [], tasks = null, + organization = null, }) { let store, credentials, server, adapter; try { @@ -32,9 +35,11 @@ export async function startBroker({ ]; credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot }); store = new Store(dataRoot); + const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot)}):null; 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..eaa8e7059 --- /dev/null +++ b/packages/bus/tests/limits.test.mjs @@ -0,0 +1,33 @@ +// 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'); +}); +test('uncertain organization receipts leave bus startup and reads alone but refuse writes until reconciled',async t=>{ + const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');const {organizationWriter}=await import('../../business/src/organization.mjs');const {readFileSync}=await import('node:fs'); + const root=mkdtempSync(join(tmpdir(),'mosaic-limits-recovery-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700}); + const writer=organizationWriter({dir:root,rolesDir}),original=readFileSync(file,'utf8'),revision=writer.read('demo').revision,next=structuredClone(doc);next.launch.limits.total=7; + const interrupted=organizationWriter({dir:root,rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>interrupted.write('demo',next,{revision,actor:'jason'}));const external=structuredClone(doc);external.launch.limits.total=12;writeJson(file,external);const bytes=readFileSync(file,'utf8'); + 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'});assert.equal(view.limits.total,12);assert.throws(()=>bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:1}}}),/organization-uncertain/);assert.equal(readFileSync(file,'utf8'),bytes); + // The operator reconciles the fixture to the recorded old whole document. + writeJson(file,original);const reconciled=bus.broker.request(human,{verb:'limits'});const result=bus.broker.request(human,{verb:'limits.set',args:{revision:reconciled.revision,limits:{total:1}}});assert.equal(result.limits.total,1); +}); diff --git a/packages/business/README.md b/packages/business/README.md index 29683efa5..9a6baad3c 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. @@ -116,15 +122,19 @@ it is the only role `launch.by` can name. The PM holds no `role.launch` (REQ-LAUNCH-1); it asks the chief of staff for a session. The owner adds both instances under `roles` in the business file, next to -the others, and points `launch.by` at the chief of staff. The stack never -writes that file. The instance names are the owner's choice; these are the +the others, and points `launch.by` at the chief of staff. Initial role entry +and schema migration installation are human steps; later launch settings edits +use the shared, broker-backed organization writer. The instance names are the owner's choice; these are the example file's: ```json -"chief": { "definition": "chief-of-staff" }, -"assistant": { "definition": "assistant", "holder": "mos" } +"chief": { "definition": "chief-of-staff", "vars": { "model": "opus" } }, +"assistant": { "definition": "assistant", "holder": "mos", "vars": { "model": "opus" } } ... -"launch": { "by": "chief", "instances": ["coder", "reviewer"], "max": { "opus": 4, "sonnet": 4 } } +"launch": { "by": "chief", "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 } } } } ``` Neither entry has a `tracker` or `credentials`, because neither role holds @@ -212,3 +222,25 @@ file missing). The CLI adds 3 for a system config problem and 4 for usage. The code that opens it at run time repeats these checks (rows S2 and S3). - Slice 1 knows two services and one tracker kind. A new one is a reviewed change to `src/vocabulary.mjs` and `src/vars.mjs`. + +## Organization writes + +`src/organization.mjs` is the trusted host write module. The caller supplies +a verified actor and the SHA-256 revision read earlier. It validates the whole +document, refuses stale revisions and concurrent writers, keeps exact previous +bytes under `businesses/.history///previous.json`, then publishes +a fsynced temporary file by atomic rename. `changes.jsonl` records prepared and +committed transactions with actor, hashes and old/new values per changed field. +Write-time recovery compares prepared hashes with current bytes; uncertain or +torn evidence refuses organization writes. Bus startup, Settings reads and +launches keep using the whole validated file on disk, without consulting receipts. 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). + +For inherited selections, print-only migration reads the existing system config +through its normal validator and requires an exact provider/model match. Missing +or invalid config refuses; it never changes the system config. Explicit role +models retain their external model and use the human-supplied provider table. diff --git a/packages/business/examples/mosaic-stack.example.json b/packages/business/examples/mosaic-stack.example.json index fd955602a..3a81ca0fa 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,14 +106,23 @@ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/reviewer-vikunja.token", "expires": "YYYY-MM-DD" } + }, + "vars": { + "model": "opus" } }, "chief": { - "definition": "chief-of-staff" + "definition": "chief-of-staff", + "vars": { + "model": "opus" + } }, "assistant": { "definition": "assistant", - "holder": "mos" + "holder": "mos", + "vars": { + "model": "opus" + } } }, "launch": { @@ -113,9 +131,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..cbe04a59b 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -1,12 +1,11 @@ // The business file, /businesses/.json (REQ-ROLE-3, note -// section 2, addendum A sections 7 and 8). Jason writes it; the stack reads -// it and never writes it. A missing or invalid file refuses (lead decision -// 46, 6.1). +// section 2, addendum A sections 7 and 8). Human edits go through the trusted +// organization writer. A missing or invalid file refuses (lead decision 46, 6.1). 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 +113,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 +126,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..fb5887291 --- /dev/null +++ b/packages/business/src/launch-settings.mjs @@ -0,0 +1,68 @@ +// 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 }); + + function view(id) { + const { document, revision } = writer.read(id); + const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir }); + if (!business.launch) throw new OrganizationError("launch-not-configured"); + const instances = Object.fromEntries(Object.keys(business.roles).map(instance => { + const vars = resolveInstance({ business, system, instance }).vars; + const key = vars.model ?? vars["execution.model"]; + const entry = Object.hasOwn(business.launch.models, key) ? business.launch.models[key] : null; + if (!entry) throw new OrganizationError("unknown-model"); + return [instance, { + harness: vars.harness ?? vars["execution.adapter"], + ...entry, modelKey: key, + }]; + })); + // model is the external id; modelKey is the table selection. + return { + business: id, revision, models: business.launch.models, + limits: business.launch.limits, instances, usage: usageCounts(sessions(), id), + }; + } + + function 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(key => Object.hasOwn(args, key))) { + throw new OrganizationError("invalid-request"); + } + const old = writer.read(id); + const 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 [instance, key] of Object.entries(args.instances)) { + if (!Object.hasOwn(next.roles, instance) || typeof key !== "string") { + throw new OrganizationError("invalid-request"); + } + next.roles[instance].vars = { ...next.roles[instance].vars, model: key }; + } + } + // Check effective inherited selections before publishing the complete document. + const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir }); + for (const instance of Object.keys(proposed.roles)) { + const vars = resolveInstance({ business: proposed, system, instance }).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 }; + } + + return { writer, read: view, set }; +} diff --git a/packages/business/src/launch.mjs b/packages/business/src/launch.mjs new file mode 100644 index 000000000..313d8ecd3 --- /dev/null +++ b/packages/business/src/launch.mjs @@ -0,0 +1,89 @@ +// 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 = (value, at) => requireString(value, at, { max: 200 }); +const count = (value, at) => { + if (!Number.isSafeInteger(value) || value < 0) refuse(`${at} must be a whole number of 0 or more`); + return value; +}; +const own = (object, key) => Object.hasOwn(object ?? {}, key) ? object[key] : undefined; + +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, entry]) => { + requireId(key, where); + requireObject(entry, `${where}.${key}`); + rejectUnknownKeys(entry, ["provider", "model"], `${where}.${key}`); + return [key, { + provider: name(entry.provider, `${where}.${key}.provider`), + model: name(entry.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(([provider, limit]) => { + return [name(provider, where), count(limit, `${where}.providers.${provider}`)]; + })); + } + if (Object.hasOwn(value, "models")) { + requireObject(value.models, `${where}.models`); + out.models = Object.fromEntries(Object.entries(value.models).map(([provider, models]) => { + name(provider, where); + requireObject(models, where); + return [provider, Object.fromEntries(Object.entries(models).map(([model, limit]) => { + return [name(model, where), count(limit, `${where}.models.${provider}.${model}`)]; + }))]; + })); + } + return out; +} + +export function usageCounts(sessions, business) { + const running = sessions.filter(session => session.business === business); + const providerCounts = new Map(); + const modelCounts = new Map(); + for (const session of running) { + const { provider, model } = session; + if (typeof provider !== "string" || typeof model !== "string") { + refuse("running session has no exact provider/model; reconcile before launching"); + } + providerCounts.set(provider, (providerCounts.get(provider) ?? 0) + 1); + if (!modelCounts.has(provider)) modelCounts.set(provider, new Map()); + const models = modelCounts.get(provider); + models.set(model, (models.get(model) ?? 0) + 1); + } + const providers = Object.fromEntries(providerCounts); + const models = Object.fromEntries([...modelCounts].map(([provider, counts]) => { + return [provider, Object.fromEntries(counts)]; + })); + return { total: running.length, providers, models }; +} + +export function limitHit(sessions, business, limits, selected) { + const usage = usageCounts(sessions, business); + const { provider, model } = selected; + const checks = [ + ["total", limits.total, usage.total], + ["provider", own(limits.providers, provider), 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..654d95f62 --- /dev/null +++ b/packages/business/src/migrate-launch.mjs @@ -0,0 +1,94 @@ +// Decision 145: pure print-only conversion. System values are supplied by the +// existing read-only config loader; this module never reads credentials or writes. +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, system = null }) { + 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]; + const 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); + const previous = new Map(); + for (const [instance, role] of Object.entries(out.roles)) { + const inherited = role.vars?.model === undefined; + let old = role.vars?.model; + let provider = null; + if (inherited) { + old = system?.["execution.model"]; + provider = system?.["execution.provider"]; + if (typeof old !== "string" || !old || typeof provider !== "string" || !provider) { + refuse(`inherited model for ${instance} requires valid system config`); + } + } + previous.set(instance, old); + let selected = mapping.instances?.[instance] ?? mapping.selections?.[old]; + if (selected === undefined) { + const matches = Object.entries(models).filter(([, entry]) => { + return entry.model === old && (!inherited || entry.provider === provider); + }); + if (matches.length !== 1) refuse(`unknown or ambiguous model mapping for ${instance}`); + selected = matches[0][0]; + } + if (typeof selected !== "string" || !Object.hasOwn(models, selected)) { + refuse(`unknown or ambiguous model mapping for ${instance}`); + } + const entry = models[selected]; + if (entry.model !== old || (inherited && entry.provider !== provider)) { + refuse(`migration mapping changes ${instance}'s ${inherited ? "inherited provider/model" : "external model"}`); + } + role.vars = { ...role.vars, model: selected }; + } + for (const instance of [out.launch.by, ...out.launch.instances]) { + const selected = models[out.roles[instance].vars.model]; + const old = previous.get(instance); + 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 entry = models[mapping.families[family]]; + for (const [instance, old] of previous) { + if (!old.toLowerCase().includes(family.toLowerCase())) continue; + const actual = models[out.roles[instance].vars.model]; + if (old !== 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..03ddc6714 --- /dev/null +++ b/packages/business/src/organization.mjs @@ -0,0 +1,219 @@ +// 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, +} 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 flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW; + const fd = openSync(file, flags, 0o600); + try { + writeFileSync(fd, bytes); + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function changes(old, next, path = "") { + if (canonicalJson(old) === canonicalJson(next)) return []; + const objects = old && next && typeof old === "object" && typeof next === "object"; + if (objects && !Array.isArray(old) && !Array.isArray(next)) { + const keys = [...new Set([...Object.keys(old), ...Object.keys(next)])]; + return keys.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"); + + // Reads and launches depend on the whole document, not transaction evidence. + function read(id) { + const file = businessFilePath(id, dir); + const bytes = regular(file); + const 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 st = lstatSync(lock); + if (!st.isDirectory() || st.uid !== process.getuid() || (st.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: confirm the old writer is gone before removing it"); + } + try { + return fn({ home, parent }); + } finally { + rmSync(lock, { recursive: true }); + syncDir(home); + } + } + + function log(id, event) { + let fd; + try { + const flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW; + fd = openSync(logFile(id), flags, 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); + } + } + + // Only writes reconcile receipts. Uncertain evidence cannot stop a bus start or launch. + function recover(id) { + let text; + try { + text = regular(logFile(id)); + } 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(), + }); + } + } + + function 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); + const oldBytes = regular(file); + const before = sha256(oldBytes); + if (revision !== before) fail("revision-conflict", "organization revision changed; read again"); + validateBusinessDocument(document, file, { rolesDir }); + const old = JSON.parse(oldBytes); + const bytes = JSON.stringify(document, null, 2) + "\n"; + const after = sha256(bytes); + const transaction = randomUUID(); + const 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 }; + }); + } + + return { logFile, read, write, recover: id => withLock(id, () => recover(id)) }; +} diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs index 4b151af04..592a476aa 100644 --- a/packages/business/src/vocabulary.mjs +++ b/packages/business/src/vocabulary.mjs @@ -81,8 +81,4 @@ export const VIKUNJA_GRANTABLE = Object.freeze({ labels: Object.freeze(["read_all"]), }); -// 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/assistant.test.mjs b/packages/business/tests/assistant.test.mjs index aaa809c44..ce2648fab 100644 --- a/packages/business/tests/assistant.test.mjs +++ b/packages/business/tests/assistant.test.mjs @@ -86,7 +86,7 @@ test("a business grant through limits does not widen the role", () => { test("a launch block whose by is the assistant 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 assistant, whose role assistant doesn't hold role\.launch within-role/, ); diff --git a/packages/business/tests/business.test.mjs b/packages/business/tests/business.test.mjs index 96ba7fda8..48e181f26 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: "chief", 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 = ["chief"]; }), /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 d32ec7bfe..b993cf5c6 100644 --- a/packages/business/tests/chief-of-staff.test.mjs +++ b/packages/business/tests/chief-of-staff.test.mjs @@ -9,7 +9,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs"; -import { businessDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs"; +import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs"; // Decision 104's list plus role.launch (decision 139). A role file that // differs from it fails. @@ -58,7 +58,7 @@ test("the role file loads and resolves through loadBusiness", () => { assert.equal(record.definition, "chief-of-staff"); assert.equal(record.contract, join(REPO_ROLES, "chief-of-staff.md")); assert.equal(record.tracker, null); - assert.deepEqual(record.launch, { by: INSTANCE, instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } }); + assert.deepEqual(record.launch, launchDoc(INSTANCE)); }); test("the shipped file is exactly the approved list", () => { @@ -123,7 +123,7 @@ test("a limit that drops role.launch leaves the chief of staff with no launch bl test("a launch block whose by is the pm instance is refused", () => { refuses( () => loadFixture((doc) => { - doc.launch = { by: "pm", instances: ["coder"], max: { sonnet: 1 } }; + doc.launch = {...doc.launch, by: "pm", instances: ["coder"]}; }), /launch\.by names pm, whose role pm doesn't hold role\.launch within-role/, ); diff --git a/packages/business/tests/helpers.mjs b/packages/business/tests/helpers.mjs index e105b4263..cca29ef94 100644 --- a/packages/business/tests/helpers.mjs +++ b/packages/business/tests/helpers.mjs @@ -72,7 +72,7 @@ export function businessDoc(root, id = "acme") { // credential and no tracker bot. chief: { definition: "chief-of-staff" }, }, - launch: { by: "chief", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } }, + launch: launchDoc(), }; } @@ -99,3 +99,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='chief',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..a62d090f8 --- /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:'chief',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:'chief',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.equal(w.read('acme').revision,revision);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:'chief',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 d442c5883..21aeece63 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..90836150c 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,31 @@ 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. If a role inherits its model, it reads and validates the system +config and requires the mapping to preserve both provider and exact model. +Missing or invalid config refuses. It prints the whole converted document and never writes, including +on refusal. There is no `--write`. Mapping JSON supplies `models` (keys to +provider/model pairs), `families` (old family names to keys), and optional +`instances` or `selections` (old model strings to keys). Unknown or ambiguous +mappings refuse. Every held role must map too. A migrated file prints unchanged. +Jason installs the output himself in Mos's window with the PM run idle, and +the re-pin follows in the same window (decision 145). diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index a95eabaab..0a96dcd50 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -13,6 +13,7 @@ import { join } from "node:path"; import { createInterface } from "node:readline/promises"; import { fileURLToPath } from "node:url"; import { BINDING_NAME } from "../../discord/src/binding.mjs"; +import { limitsCommand, LIMITS_USAGE } from "./limits.mjs"; import { CliError } from "./errors.mjs"; import { bootConfig, loadSystem, socketPath } from "./config.mjs"; import { humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs"; @@ -24,6 +25,7 @@ import { readSessions, stopSession } from "../../seat/src/session.mjs"; import { SeatError } from "../../seat/src/seat.mjs"; export const USAGE = `usage: + ${LIMITS_USAGE} mosaic inbox [--business ] [--json] [--tester] mosaic decide