business: agent limits as settings with one organization write path (row 122, #1616)
launch.max becomes launch.models plus launch.limits (total, providers, exact provider/model). The launcher re-reads limits on every attempt. Settings and `mosaic limits` write through one locked, journaled organization writer. `mosaic limits migrate` is print-only. Candidate 00b84e311475f97ced0d9a9599fba23a28442971ed15d8d489d9422d84a8812e, Rocko packet4b6e50636, Filbert round 2 approve (comment 28542). Landing gate at0c56cc2d4: 23 suites green. Decision 123 go from Mos. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c0c1e8646e
commit
7b5b682ae8
44 files changed
+1066
-117
No files matched your search
@@ -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.
|
||||
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/<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.
|
||||
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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
// The business file, <configDir>/businesses/<id>.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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -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}$/;
|
||||
@@ -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/,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
|
||||
@@ -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}}};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 } } },
|
||||
|
||||
+30
-2
@@ -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).
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// REQ-LIMIT-1: human settings; decision 145 migration is a separate print-only read.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadSystem } from "./config.mjs";
|
||||
import { systemVars } from "../../business/src/index.mjs";
|
||||
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 arg = args[i];
|
||||
if (arg === "--json") out.flags.add(arg);
|
||||
else if (["--file", "--models", "--roles-dir", "--business"].includes(arg)) {
|
||||
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(LIMITS_USAGE, 4);
|
||||
out.values[arg] = args[++i];
|
||||
} else if (arg.startsWith("--")) throw new CliError(LIMITS_USAGE, 4);
|
||||
else out.positional.push(arg);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function limitsCommand(args, io, callFor) {
|
||||
const parsed = parseLimits(args);
|
||||
const verb = parsed.positional[0] ?? "view";
|
||||
const values = parsed.values;
|
||||
if (parsed.positional.length > 1) throw new CliError(LIMITS_USAGE, 4);
|
||||
try {
|
||||
if (verb === "migrate") {
|
||||
if (!values["--file"] || !values["--models"] || values["--business"]) {
|
||||
throw new CliError(LIMITS_USAGE, 4);
|
||||
}
|
||||
const file = values["--file"];
|
||||
const raw = JSON.parse(readFileSync(file, "utf8"));
|
||||
const mapping = JSON.parse(readFileSync(values["--models"], "utf8"));
|
||||
const rolesDir = values["--roles-dir"] ?? fileURLToPath(new URL("../../../roles", import.meta.url));
|
||||
const inherits = raw.launch?.max && Object.values(raw.roles ?? {}).some(role => role.vars?.model === undefined);
|
||||
const system = inherits ? systemVars(loadSystem({ env: io.env })) : null;
|
||||
const converted = migrateLaunch(raw, mapping, file, { rolesDir, system });
|
||||
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
if (!["view", "set"].includes(verb) || values["--models"] || values["--roles-dir"] ||
|
||||
(verb === "view" && values["--file"]) || (verb === "set" && !values["--file"])) {
|
||||
throw new CliError(LIMITS_USAGE, 4);
|
||||
}
|
||||
const call = callFor(parsed);
|
||||
const result = verb === "set"
|
||||
? await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")))
|
||||
: await call("limits", {});
|
||||
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||
} catch (error) {
|
||||
if (error instanceof CliError) throw error;
|
||||
throw new CliError(error.message, 2);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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 chief of staff launches a coder through its `launch` tool (row 119: the
|
||||
@@ -33,7 +34,8 @@ function edit(doc) {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" };
|
||||
doc.roles.coder.vars.model = "claude-sonnet-5-5";
|
||||
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5" };
|
||||
doc.launch = { by: "chief", instances: ["coder", "reviewer", "cto"], max: { opus: 1, sonnet: 1 } };
|
||||
doc.launch = launchDoc("chief",["coder","reviewer","cto"],{opus:1,sonnet:1});
|
||||
doc.launch.limits.models.zai["glm-5.3-flash"]=0;
|
||||
return doc;
|
||||
}
|
||||
|
||||
@@ -130,7 +132,14 @@ function ask(path, line) {
|
||||
const log = (f) => readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
||||
|
||||
test("bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest", { skip, timeout: 30000 }, async (t) => {
|
||||
const { f, launcher, host, cto, close } = await setup(t);
|
||||
// Row 122: uncertain write receipts cannot prevent the window's first chief launch.
|
||||
const prepared = prepare(t);
|
||||
const history = join(prepared.root, "config", "businesses", ".history", "acme");
|
||||
mkdirSync(history, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(history, "changes.jsonl"), JSON.stringify({
|
||||
state: "prepared", transaction: "fixture-uncertain", before: "a".repeat(64), after: "b".repeat(64),
|
||||
}) + "\n", { mode: 0o600 });
|
||||
const { f, launcher, host, cto, close } = await setup(t, doc => doc, prepared);
|
||||
const sock = launchSocketPath(f.dataRoot);
|
||||
assert.equal(statSync(sock).mode & 0o777, 0o600);
|
||||
|
||||
@@ -141,7 +150,7 @@ test("bus start --pm: the launcher instance (the chief of staff) runs under its
|
||||
const [s] = readSessions(f.dataRoot);
|
||||
assert.equal(s.run, chief.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);
|
||||
@@ -252,7 +261,7 @@ test("the chief of staff launches a coder through its launch tool; the coder ans
|
||||
["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);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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.roles.chief.vars={model:'claude-opus-5-5'};old.launch={by:'chief',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',chief:'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);
|
||||
});
|
||||
|
||||
test('post-119 example and Jason fixture migrate with checked inherited models; wrong or absent config refuses',async t=>{
|
||||
const {execFileSync}=await import('node:child_process');const {validateBusinessDocument}=await import('../../business/src/business.mjs');
|
||||
const root=tmp(t),f=fixture(root,'acme'),doc=JSON.parse(execFileSync('git',['show','94e41ae29:packages/business/examples/mosaic-stack.example.json'],{encoding:'utf8'}));
|
||||
doc.id='acme';let bot=10;for(const r of Object.values(doc.roles))if(r.tracker)r.tracker.botId=bot++;
|
||||
doc.tracker.sync.botId=bot;for(const r of [...Object.values(doc.roles),{credentials:doc.tracker.sync.credentials}])for(const ref of Object.values(r.credentials??{})){if(ref.rotateBy)ref.rotateBy='2099-01-01';if(ref.expires)ref.expires='2099-01-01';}
|
||||
const models={opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},instances=Object.fromEntries(Object.keys(doc.roles).map(k=>[k,'opus']));
|
||||
const mapping={models,families:{opus:'opus',sonnet:'sonnet'},instances},map=writeJson(join(root,'mapping.json'),mapping),file=writeJson(join(root,'config/businesses/acme.json'),doc);
|
||||
const config=JSON.parse(readFileSync(f.config,'utf8'));config.execution.provider='anthropic';config.execution.model='claude-opus-5-5';writeJson(f.config,config);
|
||||
const args=['limits','migrate','--file',file,'--models',map,'--roles-dir',f.roles],run=async()=>{const x=io(f.env);await main(args,x,{});return JSON.parse(x.out.text);};
|
||||
const before=readFileSync(file,'utf8'),mt=statSync(file).mtimeMs,configBytes=readFileSync(f.config,'utf8'),configMt=statSync(f.config).mtimeMs;
|
||||
let converted=await run();assert.equal(converted.launch.by,'chief');assert.equal(converted.roles.assistant.holder,'mos');assert.equal(converted.roles.chief.vars.model,'opus');assert.equal(converted.roles.assistant.vars.model,'opus');validateBusinessDocument(converted,file,{rolesDir:f.roles});assert.equal(readFileSync(file,'utf8'),before);assert.equal(statSync(file).mtimeMs,mt);assert.equal(readFileSync(f.config,'utf8'),configBytes);assert.equal(statSync(f.config).mtimeMs,configMt);
|
||||
// Jason's known four explicit selections plus 119's inherited chief/assistant.
|
||||
for(const [name,key]of Object.entries({pm:'opus',cto:'opus',coder:'sonnet',reviewer:'opus'})){doc.roles[name].vars={...doc.roles[name].vars,model:models[key].model};mapping.instances[name]=key;}
|
||||
writeJson(file,doc);writeJson(map,mapping);const jasonBytes=readFileSync(file,'utf8'),jasonMt=statSync(file).mtimeMs;converted=await run();validateBusinessDocument(converted,file,{rolesDir:f.roles});assert.equal(converted.roles.coder.vars.model,'sonnet');assert.equal(converted.roles.chief.vars.model,'opus');assert.equal(readFileSync(f.config,'utf8'),configBytes);assert.equal(statSync(f.config).mtimeMs,configMt);
|
||||
for(const bad of [{...mapping,instances:{...mapping.instances,assistant:'sonnet'}},{...mapping,models:{...models,opus:{...models.opus,provider:'wrong-provider'}}}]){writeJson(map,bad);await assert.rejects(run(),/inherited provider\/model/);assert.equal(readFileSync(file,'utf8'),jasonBytes);assert.equal(statSync(file).mtimeMs,jasonMt);}
|
||||
writeJson(map,mapping);await assert.rejects(main(args,io({...f.env,MOSAIC_CONFIG:join(root,'missing.json')}),{}),/system config/);assert.equal(readFileSync(file,'utf8'),jasonBytes);assert.equal(statSync(file).mtimeMs,jasonMt);writeJson(f.config,'invalid JSON');await assert.rejects(run(),/system config/);assert.equal(readFileSync(file,'utf8'),jasonBytes);assert.equal(statSync(file).mtimeMs,jasonMt);
|
||||
writeJson(f.config,configBytes);assert.equal(readFileSync(f.config,'utf8'),configBytes);assert.equal(readFileSync(file,'utf8'),jasonBytes);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -24,7 +24,10 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
|
||||
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, instance, (d) => (d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi", model: "probe-model" }));
|
||||
const resolved = resolvedFor(t, instance, (d) => {
|
||||
d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi", 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.
|
||||
|
||||
@@ -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: [] } },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -152,10 +152,49 @@ 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');
|
||||
}
|
||||
const writable = ['limits', 'models', 'instances'];
|
||||
if (Object.keys(value).some(key => !['revision', ...writable].includes(key)) ||
|
||||
typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
|
||||
!writable.some(key => Object.hasOwn(value, key))) {
|
||||
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 +230,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 });
|
||||
|
||||
@@ -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'), {
|
||||
|
||||
@@ -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,7 +120,7 @@ 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], ['chief', 'chief-of-staff', 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)));
|
||||
@@ -127,7 +128,9 @@ test('business: names, vars on the allowlist, authority, credential metadata onl
|
||||
// Row 119 (#1613): the chief of staff launches; the PM does not.
|
||||
assert.equal(b.instances.find(i => i.instance === 'chief').authority['role.launch'], 'within');
|
||||
assert.equal(b.instances.find(i => i.instance === 'pm').authority['role.launch'], 'gated');
|
||||
assert.deepEqual(b.launch, { by: 'chief', instances: ['coder', 'reviewer'], max: { opus: 2, sonnet: 4 } });
|
||||
assert.equal(b.launch.by, 'chief');
|
||||
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' });
|
||||
@@ -179,7 +182,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 });
|
||||
|
||||
@@ -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);}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
+1
-1
@@ -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" "$@"
|
||||
Reference in new issue
Block a user