Compare commits

...
Author SHA1 Message Date
jason.woltje b1fc96f4ae fix(routes): name Disable only when it makes the save apply (P0-4 review)
The catalogue refusals no longer tell every user to press Disable model
proxy catalogue. The server text names the proxy setup step. The Routing
page adds the Disable step only when cliproxy is listed and every moved
model comes from another listed provider, and sends a tester to the
owner. The switch mismatch message no longer claims a setting changed.

Tests: a fallback thinking-only change saves with no catalogue; a move
between model-proxy models refuses before and after cliproxy is removed.
2026-11-11 13:35:56 -06:00
jason.woltje 967025f749 fix(routes): fixture routes, thinking and limits save without a model catalogue (P0-4)
A fixture-only organization reads a ready catalogue with no model proxy
client: the catalogue asks the proxy only when a module calls it.
A thinking-only change, and a limits patch that keeps harness, model and
entry, no longer asks the catalogue. A change that truly needs it still
refuses, and the WebUI names the next step: keep the model and change
thinking, or press Disable model proxy catalogue. The Routing switch
follows whether cliproxy is listed and removes or adds only cliproxy.
2026-11-11 13:35:55 -06:00
jason.woltje 70504ddddb fix(webui): build the inbox hire form after the read; pass through only pre-insert hire codes (#1873)
D4 review fix round.

P2: the org inbox took the hire form's HTML before its workspace read, so typing or a
submit result that landed during a slow read was painted over with the older draft.
inboxPage now takes a function and calls it while building the page after the read.
Two browser cases hold the inbox read: typing during it, and a refused start that
resolves during it, both survive the repaint (both fail on the previous commit).

P3: a hire start passes a bus code through only when the broker raises it before it
records the decision (hire-decisions prepare: the model route codes,
definition-not-hireable, definition-shared, no-chief-of-staff). Every other code,
including revision-conflict-revoked, request-timeout, invalid-envelope,
incomplete-request and codes the resolve or apply steps can raise, stays
outcome-unknown. Messages are worded for hire, fire and change alike.
2026-11-11 13:01:54 -06:00
jason.woltje c3293ef4ef feat(webui): hire form on the org inbox; a definite hire refusal keeps its code (#1873)
D4, readiness P0-1 and P1-2.

On an org Console, #/inbox now carries the hire, fire and change form
below "Answered today". It posts to the current organization's bus
through the same decision endpoint and verb (hire.start.console); the
organization field is read-only. Inputs carry focus keys, so a poll
repaint keeps the draft, focus and caret.

A hire start the bus definitely refused now keeps its own code. The
model route codes (model-provider-unavailable and its kin) get a next
step. Any other definite code is named, and the message never claims
that nothing was recorded. A lost or unreadable reply is still
outcome-unknown. The templates, confirmation and resolve steps are
unchanged.
2026-11-11 12:45:12 -06:00
13 changed files with 594 additions and 28 deletions

No files matched your search

+5 -2
View File
@@ -22,9 +22,12 @@ export function createModelCatalogue({ client, settings, pluginDataRoot = null,
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
const result = await loader.load({ 'model-provider': names });
if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error();
const c = client(); if (!c) throw Error();
// A module that needs no model proxy (fixture) answers without one; a module
// that asks the proxy while none is configured gets a refusal and reads unavailable.
const c = client();
const proxy = method => async (...args) => { if (!c) throw Error('model proxy not configured'); return c[method](...args); };
const answers = await Promise.all(loader.snapshot('model-provider').map(async ({ name, module }) => {
const answer = await module.catalogue({ listAccounts: () => c.getCredentials(), listModels: name => c.getCredentialModels(name), listGroups: () => c.getUpstreamGroups(), now: () => new Date(now()).toISOString() });
const answer = await module.catalogue({ listAccounts: proxy('getCredentials'), listModels: proxy('getCredentialModels'), listGroups: proxy('getUpstreamGroups'), now: () => new Date(now()).toISOString() });
if (answer?.status === 'unavailable' && answer.reason === 'model-key-collision') throw Object.assign(Error(), { code: 'model-key-collision' });
if (answer?.status !== 'ready' || !Array.isArray(answer.models) || answer.models.length > 10000 || !Array.isArray(answer.notices) || answer.notices.length > 100) throw Error();
for (const m of answer.models) {
@@ -0,0 +1,143 @@
// D3 (P0-4, readiness-1111): an organization on the fixture model provider, with no model proxy
// configured, changes routes and thinking, takes limits.set and starts a hire. The real catalogue
// service runs here (no modelCatalogue stand-in), so a fixture-only organization reads ready
// without a proxy client. A thinking-only change saves while the catalogue is unavailable, a
// change that needs the proxy still refuses, and removing cliproxy (the Routing page's Disable)
// makes the next save apply.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { busOrganization } from '../src/organization-context.mjs';
import { startBroker } from '../src/runtime.mjs';
import { createModelCatalogue } from '../src/model-catalogue.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { FIXTURE_ENTRY, FIXTURE_KEY_NAME } from '../../plugins/model-provider/fixture/index.mjs';
import { approveTemplateHire } from './hire-fixtures.mjs';
function busForm(root, rolesDir, id = 'demo') {
const b = loadBusiness(id, { dir: root, rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
return busOrganization(b, resolved);
}
async function setup(t, providers = ['fixture'], edit = () => {}) {
const root = mkdtempSync(join(tmpdir(), 'd3-fixture-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi'], 'model-provider': providers };
doc.launch.models[FIXTURE_KEY_NAME] = { ...FIXTURE_ENTRY };
doc.roles.coder.vars = { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off', 'limits.network': 'none' };
// The audit's instance: the assistant on the fixture route.
doc.roles.assistant = { definition: 'assistant', vars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off' } };
edit(doc);
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
// No proxy option: the runtime has no model proxy client and no proxy lock.
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(() => bus.close());
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const ask = (verb, args) => Promise.resolve().then(() => bus.broker.request(human, { verb, args }));
const saved = () => JSON.parse(readFileSync(file, 'utf8'));
return { root, rolesDir, file, bus, ask, saved };
}
const route = (thinking, model = FIXTURE_KEY_NAME) => ({ harness: 'pi', model, thinking, fallback: null });
// readiness-1111 evidence patch-low.json: `mosaic limits set --file patch-low.json`.
const patch = (revision, thinking) => ({ revision, assistants: { assistant: { definition: 'assistant', vars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking }, tracker: null } } });
test('D3 a fixture-only organization with no model proxy reads a ready catalogue and saves a thinking change and a fixture route', async t => {
const f = await setup(t);
const view = await f.ask('routes');
assert.equal(view.catalogue.status, 'ready');
assert.deepEqual(view.catalogue.models.map(m => m.key), [FIXTURE_KEY_NAME]);
const thinking = await f.ask('routes.set', { revision: view.revision, routes: { coder: route('low') } });
assert.ok(thinking.transaction);
assert.equal(f.saved().roles.coder.vars.thinking, 'low');
// A fixture route change: the reviewer moves onto the fixture model.
const moved = await f.ask('routes.set', { revision: thinking.revision, routes: { reviewer: route('off') } });
assert.ok(moved.transaction);
assert.equal(f.saved().roles.reviewer.vars.model, FIXTURE_KEY_NAME);
});
test('D3 limits.set takes the audit thinking patch on a fixture-only organization with no model proxy', async t => {
const f = await setup(t);
const { revision } = await f.ask('limits');
const result = await f.ask('limits.set', patch(revision, 'low'));
assert.ok(result.transaction);
assert.equal(f.saved().roles.assistant.vars.thinking, 'low');
});
test('D3 hire start on a fixture-only organization with no model proxy writes the hire', async t => {
const f = await setup(t);
const hired = await approveTemplateHire(f.bus.broker, f.rolesDir, { name: 'sec-1', definition: 'chief-information-security-officer', model: FIXTURE_KEY_NAME });
assert.ok(hired.transaction);
assert.equal(f.saved().roles['sec-1'].vars.model, FIXTURE_KEY_NAME);
});
test('D3 with cliproxy enabled and no model proxy, a thinking change saves, a proxy model refuses, and removing cliproxy lets a route change save', async t => {
const f = await setup(t, ['fixture', 'cliproxy']);
let view = await f.ask('routes');
assert.equal(view.catalogue.status, 'unavailable');
// Thinking only: same harness, model key and entry, so no catalogue is asked.
const thinking = await f.ask('routes.set', { revision: view.revision, routes: { coder: route('low') } });
assert.equal(f.saved().roles.coder.vars.thinking, 'low');
const limits = await f.ask('limits');
await f.ask('limits.set', patch(limits.revision, 'medium'));
assert.equal(f.saved().roles.assistant.vars.thinking, 'medium');
// A route change truly needs the catalogue, which the absent proxy makes unavailable.
const before = readFileSync(f.file, 'utf8');
view = await f.ask('routes');
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off') } }), { code: 'model-catalogue-unavailable' });
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'zai-not-offered') } }), { code: 'model-catalogue-unavailable' });
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.ok(thinking.transaction);
// Disable model proxy catalogue: plugins.set without cliproxy.
const settings = await f.ask('plugins');
await f.ask('plugins.set', { revision: settings.revision, plugins: { 'model-provider': ['fixture'] } });
view = await f.ask('routes');
assert.equal(view.catalogue.status, 'ready');
await f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off') } });
assert.equal(f.saved().roles.reviewer.vars.model, FIXTURE_KEY_NAME);
});
test('D3 the catalogue asks a module that needs no proxy even with no proxy client, and a module that asks the proxy reads unavailable', async () => {
let names = ['fixture'];
const service = createModelCatalogue({ client: () => null, settings: () => ({ 'model-provider': names }) });
const ready = await service.read('demo', { fresh: true });
assert.equal(ready.status, 'ready');
assert.deepEqual(ready.models.map(m => [m.key, m.available]), [[FIXTURE_KEY_NAME, true]]);
names = ['cliproxy'];
assert.equal((await service.read('demo', { fresh: true })).status, 'unavailable');
names = ['fixture', 'cliproxy'];
const both = await service.read('demo', { fresh: true });
assert.equal(both.status, 'unavailable');
assert.ok(both.models.every(m => m.available === false));
});
// D3 review (finding 1): Disable can't make a move between two model-proxy models apply, which is
// why the Routing page names Disable only for a move onto a model from another listed module.
test('D3 a move between model-proxy models refuses before and after cliproxy is removed, and writes nothing', async t => {
const proxyEntry = model => ({ provider: 'proxy', model, module: 'cliproxy', upstream: 'claude' });
const f = await setup(t, ['fixture', 'cliproxy'], doc => {
doc.launch.models['claude-a'] = proxyEntry('claude-a');
doc.launch.models['claude-b'] = proxyEntry('claude-b');
doc.roles.reviewer.vars = { ...doc.roles.reviewer.vars, harness: 'pi', model: 'claude-a', thinking: 'off' };
});
const before = readFileSync(f.file, 'utf8');
let view = await f.ask('routes');
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'claude-b') } }), { code: 'model-catalogue-unavailable' });
assert.equal(readFileSync(f.file, 'utf8'), before);
const settings = await f.ask('plugins');
await f.ask('plugins.set', { revision: settings.revision, plugins: { 'model-provider': ['fixture'] } });
const disabled = readFileSync(f.file, 'utf8');
view = await f.ask('routes');
assert.equal(view.catalogue.status, 'ready');
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'claude-b') } }), { code: 'model-provider-unavailable' });
assert.equal(readFileSync(f.file, 'utf8'), disabled);
assert.notEqual(disabled, before);
});
+12 -5
View File
@@ -132,15 +132,18 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
}
const currentEntry = proposed.launch?.models?.[vars[model]];
const previousEntry = old?.launch?.models?.[before[model]];
const changed = [harness, model, thinking].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
if (changed && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
if (changed && currentEntry?.module) {
// D3 (P0-4): the catalogue answers which harness a model runs on, not its thinking level, so
// a change that keeps the harness, the model key and its entry needs no catalogue; the
// harness checks below still run on the new thinking level.
const routeChanged = [harness, model].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
if (routeChanged && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
if (routeChanged && currentEntry?.module) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const offered = catalogue.models?.find(m => m.key === vars[model]);
if (!offered || !offered.available || !proposed.plugins?.['model-provider']?.includes(currentEntry.module) || canonicalJson(currentEntry) !== canonicalJson({ provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream })) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
}
if (changed && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
if (routeChanged && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const mapping = catalogue.resolveLegacy ? catalogue.resolveLegacy(currentEntry) : catalogue.legacy?.[vars[model]];
const offered = mapping && catalogue.models?.find(m => m.module === mapping.module && m.upstream === mapping.upstream && m.model === currentEntry.model);
@@ -369,6 +372,10 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
for (const [choice, prefix] of [[route, ''], [fallback, 'fallback.']]) {
if (!choice) continue;
const unchanged = ROUTE_FIELDS.every(field => priorVars[prefix + field] === choice[field]);
// D3 (P0-4): a choice that keeps the harness and model key needs no catalogue, as in
// checkRoutes; only its thinking level may differ. A key the catalogue offers as a
// different model still refuses model-key-collision below on any change.
const sameRoute = ['harness', 'model'].every(field => priorVars[prefix + field] === choice[field]);
const offered = catalogue?.models?.find(m => m.key === choice.model);
const existing = next.launch.models[choice.model];
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
@@ -377,7 +384,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
if (!existing || existing.module) {
if (unchanged) continue;
if (sameRoute) continue;
if (catalogue?.status !== 'ready') throw new OrganizationError('model-catalogue-unavailable');
if (!offered || !offered.available || !Array.isArray(next.plugins?.['model-provider']) || !next.plugins['model-provider'].includes(offered.module)) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(choice.harness)) throw new OrganizationError('model-harness-incompatible');
@@ -267,3 +267,35 @@ test('a role can add a legacy model when the model-provider module is off', asyn
const saved=settings.set('acme',{revision:view.revision,models,instances:{coder:'legacy-new'}},'chief-of-staff.chief');
assert.equal(saved.instances.coder.modelKey,'legacy-new');
});
test('D3 P0-4: a thinking-only change on a provider model saves with no catalogue, and a model or harness change still refuses', async () => {
const f=fixture();const {settings}=await routeSettings(f);const doc=JSON.parse(readFileSync(f.file,'utf8'));
doc.plugins['model-provider']=['fixture','cliproxy'];doc.launch.models['fixture-deterministic']={provider:'proxy',model:'fixture-deterministic',module:'fixture',upstream:'fixture'};
doc.roles.coder.vars={...doc.roles.coder.vars,harness:'pi',model:'fixture-deterministic',thinking:'off'};writeJson(f.file,doc);
const pick=(thinking,extra={})=>({coder:{harness:'pi',model:'fixture-deterministic',thinking,fallback:null,...extra}});
for(const catalogue of [null,{status:'unavailable',models:[],notices:[]}]){
const view=settings.routes('acme'),next=view.instances.coder.thinking==='low'?'high':'low';
settings.setRoutes('acme',{revision:view.revision,routes:pick(next)},'jason',catalogue);
assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars.thinking,next);
}
// A level the harness doesn't apply still refuses on a thinking-only change.
assert.throws(()=>settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick('medium')},'jason',null),code('thinking-unsupported'));
// A harness or model change truly needs the catalogue.
const rev=settings.routes('acme').revision,before=readFileSync(f.file,'utf8');
assert.throws(()=>settings.setRoutes('acme',{revision:rev,routes:pick('low',{harness:'claude-code'})},'jason',{status:'unavailable',models:[],notices:[]}),code('model-catalogue-unavailable'));
assert.throws(()=>settings.set('acme',{revision:rev,instances:{reviewer:'fixture-deterministic'}},'jason',{status:'unavailable',models:[],notices:[]}),code('model-provider-unavailable'));
assert.equal(readFileSync(f.file,'utf8'),before);
});
test('D3 review: a fallback thinking-only change on a provider model saves with no catalogue, and a fallback harness change still refuses', async () => {
const f=fixture();const {settings}=await routeSettings(f);const doc=JSON.parse(readFileSync(f.file,'utf8'));
doc.plugins['model-provider']=['fixture','cliproxy'];doc.launch.models['fixture-deterministic']={provider:'proxy',model:'fixture-deterministic',module:'fixture',upstream:'fixture'};
doc.roles.coder.vars={...doc.roles.coder.vars,harness:'pi',model:'fixture-deterministic',thinking:'off','fallback.harness':'pi','fallback.model':'fixture-deterministic','fallback.thinking':'off'};writeJson(f.file,doc);
const unavailable={status:'unavailable',models:[],notices:[]};
const pick=fallback=>({coder:{harness:'pi',model:'fixture-deterministic',thinking:'off',fallback:{harness:'pi',model:'fixture-deterministic',...fallback}}});
settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick({thinking:'low'})},'jason',unavailable);
assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars['fallback.thinking'],'low');
const before=readFileSync(f.file,'utf8');
assert.throws(()=>settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick({harness:'claude-code',thinking:'low'})},'jason',unavailable),code('model-catalogue-unavailable'));
assert.equal(readFileSync(f.file,'utf8'),before);
});
+22 -1
View File
@@ -42,6 +42,21 @@ const BUS_CODES = {
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
'invalid-request': 'invalid-request', 'hire-owner-required':'hire-owner-required','right-required':'right-required','decision-expired':'decision-expired','hire-resolution-required':'hire-resolution-required',
};
// D4 (#1873, P1-2): a hire, fire or change start the bus definitely refused keeps its own code,
// but only for codes the broker raises before it records the decision: the proposal checks in
// hire-decisions.mjs prepare(), which #raiseHire runs before its INSERT. hire.start.console then
// resolves the decision and applies it in #hireConsole, and those steps can raise other codes (and
// some of the same generic ones) after the decision exists or is consumed, so every code not listed
// here stays outcome-unknown. Review P3: an allowlist, not a pass-through of any well-formed code.
const HIRE_START_REFUSALS = {
'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy. Nothing was applied.'],
'model-catalogue-unavailable': [503, 'The model catalogue could not be read, so this model could not be checked. Check Settings > Model proxy, then try again. Nothing was applied.'],
'model-harness-incompatible': [409, 'This model does not run on the chosen harness. Pick another model or harness. Nothing was applied.'],
'thinking-unsupported': [409, 'The chosen harness does not apply this thinking level. Clear Thinking or pick a level it applies. Nothing was applied.'],
'definition-not-hireable': [409, 'This organization does not hire agents from this role template. Pick another template. Nothing was applied.'],
'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it. Nothing was applied.'],
'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it. Nothing was applied.'],
};
const BODY_MAX = 4096;
const TOKEN = /^[a-f0-9]{64}$/;
const DIGEST = /^[a-f0-9]{64}$/;
@@ -136,7 +151,13 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf
if(step==='templates' && !Array.isArray(result?.templates))return refuse('confirmation-unavailable');
line(step==='start'?'hire=recorded':'templates=read');
return json(res,200,step==='start'?{id:result.id,op:result.op,choice:result.choice}:result);
} catch(err) {return refuse(busCode(err,step==='start'?'outcome-unknown':'confirmation-unavailable'));}
} catch(err) {
if(step!=='start' || !(err instanceof BusReadError) || !Object.hasOwn(HIRE_START_REFUSALS,err.code))
return refuse(busCode(err,step==='start'?'outcome-unknown':'confirmation-unavailable'));
const [status,message]=HIRE_START_REFUSALS[err.code];
line(`error=${err.code}`);
return json(res,status,{error:err.code,message});
}
}
if (step === 'confirmation') {
if (Object.keys(value).some(k=>!['choice','adjust'].includes(k)) || typeof value.choice !== 'string') return refuse('invalid-request');
+4
View File
@@ -60,6 +60,10 @@ table.s1-roles td,table.s1-roles th { overflow-wrap:normal; word-break:normal; }
table.s1-roles { min-width: 0; width: 100%; table-layout: fixed; }
table.s1-roles td, table.s1-roles th { overflow-wrap: anywhere; }
}
/* D4 (#1873): the hire form on the org-mode workspace inbox. */
html[data-organizations] #hire-start.ws-hire{margin-top:22px}
html[data-organizations] #hire-start.ws-hire h2{margin:0 0 4px;font-size:16px}
html[data-organizations] #hire-start.ws-hire select{min-height:40px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font:inherit;max-width:100%}
/* A4: use the Console's controls for authority proposals and keep grant labels beside their checkboxes. */
#hire-start form,#hire-adjust{max-width:680px}
#hire-start form>label,#hire-adjust>label{display:grid;gap:5px;margin:12px 0;color:var(--text)}
+44 -18
View File
@@ -427,20 +427,26 @@
route:{harness:$('hire-route-harness').value.trim(),model:$('hire-route-model').value.trim(),...(thinking?{thinking}:{})}};
}
const HIRE_START_LABEL='Record and approve';
let hireDraft={op:'hire',level:'org'},hireTemplates=[],hireBusy=false,hireStatus='';
let hireDraft={op:'hire',level:'org'},hireTemplates=[],hireBusy=false,hireStatus='',hireCode='';
// D4 (#1873, P0-1): an org Console shows this form on the workspace inbox. Its posts go to the
// current organization's bus (display.js), so the organization is that one and is not editable.
const hireOrg=()=>window.MosaicOrg?.id ?? null;
function hireStartHtml() {
if(!canDecide)return '';
const d=hireDraft,off=hireBusy?' disabled':'';
const input=(key,label,extra='')=>`<label>${label} <input data-hire-field="${key}" value="${esc(d[key]??'')}" maxlength="${key==='reason'?500:200}" ${extra}${off}></label>`;
return `<section class="panel" id="hire-start"><h2>Hire, fire or change an agent</h2><form id="hire-start-form">
${input('business','Organization','required')}<button type="button" class="btn" data-hire-load${off}>Read role templates</button>
<label>Operation <select data-hire-field="op"${off}>${['hire','fire','change'].map(v=>`<option${d.op===v?' selected':''}>${v}</option>`).join('')}</select></label>
<label>Level <select data-hire-field="level"${off}>${['org','project','root'].map(v=>`<option${d.level===v?' selected':''}>${v}</option>`).join('')}</select></label>
const org=hireOrg(),d=hireDraft,off=hireBusy?' disabled':'';
const input=(key,label,extra='')=>`<label>${label} <input data-hire-field="${key}" data-focus-key="hire:${key}" value="${esc(d[key]??'')}" maxlength="${key==='reason'?500:200}" ${extra}${off}></label>`;
const select=(key,label,options)=>`<label>${label} <select data-hire-field="${key}" data-focus-key="hire:${key}"${off}>${options}</select></label>`;
const business=org?`<label>Organization <input value="${esc(org)}" readonly aria-describedby="hire-start-org"></label><p class="small muted" id="hire-start-org">This hire goes to ${txt(org)}'s bus. Switch organization to hire elsewhere.</p>`:input('business','Organization','required');
const status=`${txt(hireStatus)}${hireCode?` (<code>${txt(hireCode)}</code>)`:''}`;
return `<section class="${org?'ws-panel ws-hire':'panel'}" id="hire-start" aria-labelledby="hire-start-h"><h2 id="hire-start-h">Hire, fire or change an agent</h2><form id="hire-start-form">
${business}<button type="button" class="btn" data-hire-load data-focus-key="hire:load"${off}>Read role templates</button>
${select('op','Operation',['hire','fire','change'].map(v=>`<option${d.op===v?' selected':''}>${v}</option>`).join(''))}
${select('level','Level',['org','project','root'].map(v=>`<option${d.level===v?' selected':''}>${v}</option>`).join(''))}
${input('project','Project (project level only)')}${input('name','Agent name','required')}
<label>Role template <select data-hire-field="template"${off}><option value="">Choose a template</option>${hireTemplates.map(t=>`<option value="${esc(t.name)}"${d.template===t.name?' selected':''}>${txt(t.name)}</option>`).join('')}</select></label>
${select('template','Role template',`<option value="">Choose a template</option>${hireTemplates.map(t=>`<option value="${esc(t.name)}"${d.template===t.name?' selected':''}>${txt(t.name)}</option>`).join('')}`)}
${input('harness','Harness')}${input('model','Model')}${input('thinking','Thinking (optional)')}${input('reason','Reason','required')}
<p>Submitting raises and approves one decision. Flagged grants and credentials require the owner; root agents require the instance owner.</p>
<button type="submit" class="btn primary"${off}>${hireBusy?'Working…':HIRE_START_LABEL}</button><p id="hire-start-status" role="status">${txt(hireStatus)}</p></form></section>`;
<button type="submit" class="btn primary" data-focus-key="hire:submit"${off}>${hireBusy?'Working…':HIRE_START_LABEL}</button><p id="hire-start-status" role="status">${status}</p></form></section>`;
}
function paintHireStart() {const e=$('hire-start');if(e)e.outerHTML=hireStartHtml();}
document.addEventListener('input',e=>{const f=e.target.closest?.('[data-hire-field]');if(f)hireDraft[f.dataset.hireField]=f.value;});
@@ -448,15 +454,15 @@
async function hireSubmit(templatesOnly=false) {
if(hireBusy || out() || !canDecide)return;
const d={...hireDraft},template=hireTemplates.find(t=>t.name===d.template);
if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';paintHireStart();return;}
if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';hireCode='';paintHireStart();return;}
const thinking=d.thinking?.trim(),hire={op:d.op,level:d.level,name:d.name,reason:d.reason,...(d.level==='project'?{project:d.project}:{}),...(d.op==='fire'?{}:{role:{template:template?.name,sha256:template?.sha256},route:{harness:d.harness,model:d.model,...(thinking?{thinking}:{})}})};
hireBusy=true;hireStatus='';paintHireStart();
const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:d.business,...(templatesOnly?{}:{hire})});
hireBusy=true;hireStatus='';hireCode='';paintHireStart();
const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:hireOrg() ?? d.business,...(templatesOnly?{}:{hire})});
hireBusy=false;
if(res?.type==='opaqueredirect'){signOut();return;}
if(res?.ok && templatesOnly && Array.isArray(json?.templates)){hireTemplates=json.templates;hireStatus=hireTemplates.length?'Templates read.':'No templates available.';}
else if(res?.ok && !templatesOnly && json?.op==='resolved'){hireStatus=`Recorded decision ${json.id}.`;}
else hireStatus=decideRefusal(res,json,templatesOnly?'confirmation-unavailable':'outcome-unknown').message;
else {const r=decideRefusal(res,json,templatesOnly?'confirmation-unavailable':'outcome-unknown');hireStatus=r.message;hireCode=r.code;}
paintHireStart();
}
document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);});
@@ -1184,8 +1190,13 @@
return select(name,key,[...new Set([...legacy,...offered.map(m=>m.key)])],c[key],(v.providerModels?.[c[key]]||enabled&&v.models.includes(c[key]))?'unavailable':'not in the model table',labels);};
const thinking=(key,h)=>select(name,key,levelsOf(c[h]),c[key],c[h]?`not applied by ${c[h]}`:'no harness');
return `<tr class="route-row${['fallback.harness','fallback.model','fallback.thinking'].some(k=>c[k])?' fallback-open':''}"><th scope="row">${txt(name)}${instanceInfo?.instances?.[name]?.definition?`<small class="route-definition">${txt(instanceInfo.instances[name].definition)}</small>`:''}</th><td data-label="Harness">${harness('harness')}</td><td data-label="Model">${model('model')}</td><td data-label="Thinking">${thinking('thinking','harness')}</td><td class="fallback-cell" data-label="Fallback harness"><button type="button" class="fallback-toggle">Set fallback</button>${harness('fallback.harness')}</td><td class="fallback-cell" data-label="Fallback model">${model('fallback.model')}</td><td class="fallback-cell" data-label="Fallback thinking">${thinking('fallback.thinking','fallback.harness')}</td></tr>`;}).join('');
const catalogueInfo=v.catalogue?.status==='ready'?`<p class="small muted">Model proxy catalogue, observed ${txt(window.MosaicLocalTime(v.catalogue.observedAt))}.</p>${(v.catalogue.notices??[]).map(n=>`<p class="small warn">${txt(n)}</p>`).join('')}`:v.catalogue?.status==='unavailable'?'<p class="small warn">Model catalogue unavailable.</p>':'<p class="small muted">Model catalogue not enabled.</p>';
const providerControl=canSend&&!isTester?`<button type="button" id="provider-switch" data-enabled="${v.catalogue?.status!=='disabled'}">${v.catalogue?.status!=='disabled'?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
// D3 (P0-4): the switch adds or removes the cliproxy module only, so its label follows that
// module in the organization's model-provider list, not the whole catalogue's status (a
// fixture-only catalogue is ready with no model proxy). Before the setting is read it falls
// back to the catalogue status, as before.
const proxyOn=harnessSettings?(Array.isArray(harnessSettings.plugins?.['model-provider'])?harnessSettings.plugins['model-provider']:[]).includes('cliproxy'):v.catalogue?.status!=='disabled';
const catalogueInfo=v.catalogue?.status==='ready'?`<p class="small muted">Model catalogue, observed ${txt(window.MosaicLocalTime(v.catalogue.observedAt))}.</p>${(v.catalogue.notices??[]).map(n=>`<p class="small warn">${txt(n)}</p>`).join('')}`:v.catalogue?.status==='unavailable'?`<p class="small warn">Model catalogue unavailable. A change to thinking alone still saves.${proxyOn&&canSend&&!isTester?' A new model needs the catalogue: set up the model proxy, or, when this organization has none, press Disable model proxy catalogue.':''}</p>`:'<p class="small muted">Model catalogue not enabled.</p>';
const providerControl=canSend&&!isTester?`<button type="button" id="provider-switch" data-enabled="${proxyOn}">${proxyOn?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
const harnessControl=canSend&&!isTester?`<fieldset class="harness-control"><legend>Harnesses</legend>${harnessSettings ? [...new Set([...harnessSettings.available.harness,...(harnessSettings.plugins.harness??[])])].map(name=>`<label><input type="checkbox" data-harness="${esc(name)}"${(harnessDraft??harnessSettings.plugins.harness??[]).includes(name)?' checked':''}${harnessBusy?' disabled':''}> ${txt(name)}</label>`).join('')+` <button type="button" id="harness-save"${harnessBusy?' disabled':''}>Save harnesses</button>` : '<p>Harnesses unavailable.</p><button type="button" id="harness-read">Read harnesses again</button>'}<p role="status" id="harness-status">${txt(harnessReceipt)}</p></fieldset>`:'';
const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. Switch one on under Harnesses.</p>';
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div>${routesApplyMarkup()}</form></section>`;
@@ -1202,6 +1213,19 @@
return {revision:routesView.revision,routes};
}
const unknownRoutes='Outcome unknown. Read the routes again before trying. Draft kept.';
// D3 (P0-4): after a catalogue refusal, Disable is named only when it can make this save apply:
// cliproxy is listed and every model the change moves to comes from another listed module.
// A viewer without the switch is sent to the owner.
function catalogueStep(code,routes){
if(!['model-catalogue-unavailable','model-provider-unavailable'].includes(code))return '';
if(!canSend||isTester)return ' Ask the organization\'s owner to set up the model proxy, or to disable the model proxy catalogue when the change needs no proxy.';
const names=Array.isArray(harnessSettings?.plugins?.['model-provider'])?harnessSettings.plugins['model-provider']:[];
if(!names.includes('cliproxy'))return '';
const moved=Object.entries(routes).flatMap(([name,r])=>{const was=routesView?.instances?.[name]??{};
return [[r.harness,r.model,was.harness,was.model],[r.fallback?.harness,r.fallback?.model,was.fallback?.harness,was.fallback?.model]].filter(([h,m,wh,wm])=>m&&(h!==wh||m!==wm)).map(([,m])=>m);});
const module=key=>routesView?.providerModels?.[key]?.module;
return moved.length&&moved.every(key=>module(key)&&module(key)!=='cliproxy'&&names.includes(module(key)))?' This change needs no model proxy: press Disable model proxy catalogue, then save again.':'';
}
const routesAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&Array.isArray(r.harnesses)&&Array.isArray(r.models)&&[r.levels,r.instances].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('#routes-form');if(!form||!routesView)return;routesDraft=routesFields(form);routesEditVersion++;markSettingsDraft('routing',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=$('routes-reload');if(discard){discard.hidden=false;discard.disabled=false;}
// A harness change redraws the row so its thinking cell offers that harness's levels.
@@ -1224,7 +1248,9 @@
const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;
try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.');
const names=Array.isArray(settings.plugins?.['model-provider'])?settings.plugins['model-provider']:[];
const next=button.dataset.enabled==='true'?names.filter(n=>n!=='cliproxy'):[...new Set([...names,'cliproxy'])];
// The fresh read decides: a stale label never turns the proxy module on when it meant off.
const on=names.includes('cliproxy');if(String(on)!==button.dataset.enabled){button.dataset.enabled=String(on);button.textContent=`${on?'Disable':'Enable'} model proxy catalogue`;button.disabled=false;status.textContent='The switch now shows the current setting. Press it again to change it.';return;}
const next=on?names.filter(n=>n!=='cliproxy'):[...names,'cliproxy'];
const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});
if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
}catch(error){status.textContent=error.message;button.disabled=false;}
@@ -1237,7 +1263,7 @@
routesBusy=true;routesReceipt='';routesApply=null;render(false);
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));}
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'}${catalogueStep(result?.error,payload.routes)} Draft kept.`;}}
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
});
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
@@ -1524,7 +1550,7 @@
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
if (a === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
if (a === 'inbox' && n === 1 && window.MosaicOrg) return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { hire: hireStartHtml }));
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
+6 -2
View File
@@ -663,7 +663,10 @@
}
// ---- inbox across organizations (sample 05) ----
async function inboxPage(q) {
// `opts.hire`: a function that returns the hire, fire and change form bus.js builds for the current
// organization (D4, P0-1). It is called after the workspace read so typing or a submit result that
// lands during the read is not painted over with an older draft.
async function inboxPage(q, opts = {}) {
const org = listParam(q, 'org', id => ORG_ID.test(id));
const want = String(q?.get?.('d') || '');
let body, all;
@@ -722,7 +725,8 @@
${unread}
<div class="ws-ibx-grid${insp ? ' with-insp' : ''}"><div class="ws-ibx-list">${table}
${secH('Answered today', 'kept for 24 hours, then in the trail')}
<p class="ws-panel tight note" data-ws-answered>Answered and expired items are not built yet.</p></div>
<p class="ws-panel tight note" data-ws-answered>Answered and expired items are not built yet.</p>
${typeof opts?.hire === 'function' ? opts.hire() : ''}</div>
${insp}
</div>
</section>`;
+7
View File
@@ -583,6 +583,13 @@ const ROUTE_REASONS = {
'organization-unknown-instance': ['400', 'the business has no such instance'],
'organization-no-change': ['400', 'the business file already says that'],
'revision-conflict': ['409', 'the business file changed since you read it. Read the routes again'],
// D3 (P0-4): the model refusals name the next step. Status stays the bus status these codes
// already had (502). The Routing page adds Disable model proxy catalogue only when that switch
// can make the save apply (bus.js catalogueStep).
'model-catalogue-unavailable': ['502', 'the model catalogue is unavailable, so a model it supplies can\'t be checked. A change to thinking alone still saves; a model from the model proxy needs the proxy set up first'],
'model-provider-unavailable': ['502', 'the model provider doesn\'t offer that model now. A change to thinking alone still saves; otherwise pick a model the catalogue lists, or set up the model proxy when the model comes from it'],
'model-harness-incompatible': ['502', 'that model doesn\'t run on that harness. Pick a harness the model lists, or another model'],
'model-key-collision': ['502', 'that model key already names a different model in the model table. Pick the table\'s entry or another key'],
};
const routeObject = o => o && typeof o === 'object' && !Array.isArray(o);
const routesAnswer = v => /^[a-f0-9]{64}$/.test(v?.revision ?? '') && Array.isArray(v.harnesses) && Array.isArray(v.models) && routeObject(v.levels) && routeObject(v.instances);
@@ -292,3 +292,64 @@ test('A4 confirmation holds the exact human adjustment, so resolve cannot substi
const r=await s.resolve({choice:'new/autonomous',confirmation:c.json().confirmation});
assert.equal(r.status,200,r.text);assert.deepEqual(s.calls[1].args.adjust,adjust);
});
// D4 (#1873, P1-2): a hire start the bus definitely refused before it recorded the decision keeps its
// own code and a next step; every other code, a lost or unreadable reply among them, is still
// outcome-unknown, and the other steps keep their mapping.
const START_HIRE={op:'hire',level:'org',name:'scout',reason:'Need help',role:{template:'coder',sha256:DIGEST},route:{harness:'pi',model:'gone-model'}};
const startWith=async err=>{
const s=setup({resolve:()=>{throw err;}});
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}}));
assert.equal(s.calls.length,1,'one bus call, never retried');
return {r,s};
};
test('D4 hire start: model-provider-unavailable keeps its code and names Settings > Model proxy',async()=>{
const {r,s}=await startWith(new BusReadError('model-provider-unavailable'));
assert.equal(r.status,409,r.text);
const body=r.json();
assert.equal(body.error,'model-provider-unavailable');
assert.match(body.message,/Settings > Model proxy/);
assert.match(body.message,/Nothing was applied/);
assert.equal(r.headers['cache-control'],'no-store');
assert.match(s.lines.at(-1),/step=start error=model-provider-unavailable$/);
});
test('D4 hire start: the other model route codes keep their code with a next step',async()=>{
for(const [code,status] of [['model-catalogue-unavailable',503],['model-harness-incompatible',409],['thinking-unsupported',409]]) {
const {r}=await startWith(new BusReadError(code));
assert.equal(r.status,status,`${code}: ${r.text}`);assert.equal(r.json().error,code);assert.match(r.json().message,/Nothing was applied/);
}
});
test('D4 hire start: the other proposal checks that run before the decision insert keep their code',async()=>{
for(const code of ['definition-not-hireable','definition-shared','no-chief-of-staff']) {
const {r}=await startWith(new BusReadError(code));
assert.equal(r.status,409,`${code}: ${r.text}`);assert.equal(r.json().error,code);assert.match(r.json().message,/Nothing was applied/);
assert.doesNotMatch(r.json().message,/\bhire was\b/,'worded for hire, fire and change alike');
}
});
// Review P3: a code the broker can raise after the decision exists or is consumed, or one the bus
// server raises about the request itself, is not a definite refusal of the start.
test('D4 hire start: a code that can follow the decision insert, or a transport code, stays outcome-unknown',async()=>{
for(const code of ['revision-conflict-revoked','request-timeout','invalid-envelope','incomplete-request','instance-kept','organization-instance-exists','organization-unknown-instance','root-fire-shared','fire-in-progress','some-future-code'])
refused((await startWith(new BusReadError(code))).r,'outcome-unknown');
});
test('D4 hire start: a lost or unreadable reply is still outcome-unknown',async()=>{
for(const err of [new BusReadError('outcome-unknown'),new BusReadError('invalid-response'),new BusReadError('response-too-large'),new BusReadError('outcome-unknown-revoked'),new BusReadError('Not A Code'),new BusReadError(''),new Error('socket closed'),Object.assign(new Error('x'),{code:'model-provider-unavailable'})])
refused((await startWith(err)).r,'outcome-unknown');
// A reply that does not say the hire resolved yes is not a refusal either.
const s=setup({resolve:()=>({id:'new-hire',op:'resolved',choice:'no'})});
refused(await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}})),'outcome-unknown');
});
test('D4 hire start: the bus codes the endpoint already names keep their refusal',async()=>{
refused((await startWith(new BusReadError('unauthenticated'))).r,'human-required');
refused((await startWith(new BusReadError('right-required'))).r,'right-required');
refused((await startWith(new BusReadError('hire-owner-required'))).r,'hire-owner-required');
});
test('D4 templates, confirmation and resolve keep their open mapping for a definite unnamed code',async()=>{
const t=setup({resolve:()=>{throw new BusReadError('model-provider-unavailable');}});
refused(await t.run('/api/decisions/hire/templates',request({body:{business:'demo'}})),'confirmation-unavailable');
const c=setup({answer:()=>{throw new BusReadError('model-provider-unavailable');}});
refused(await c.confirm('yes'),'confirmation-unavailable');
const s=setup({resolve:()=>{throw new BusReadError('model-provider-unavailable');}});
const ok=await s.confirm('yes');assert.equal(ok.status,200);
refused(await s.resolve({choice:'yes',confirmation:ok.json().confirmation}),'outcome-unknown');
});
@@ -0,0 +1,167 @@
// D4 (#1873, readiness P0-1 and P1-2): on an org Console the hire, fire and change form is on the
// workspace inbox (#/inbox), posts through the real decision endpoint to the current organization's
// bus, and shows a definite refusal by its own code and next step. A lost reply stays outcome-unknown.
// The bus is a fake decision transport behind the real server, page code and decision endpoint.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { BusReadError } from '../src/bus.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const SHA = 'b'.repeat(64);
const DECISION = 'dec-acme-0001';
// One running organization. `decide` gives it the send and decision transports a --send Console has.
// `gate.held`, when set, is a promise the workspace inbox read waits on: a slow read for the repaint race.
function organizations({ decide, calls, gate }) {
const LIST = [{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }];
const inbox = [{ id: DECISION, question: 'Ship the hero?', class: 'gated', blocking: false, raised_by_role: 'coder', at: new Date(Date.now() - 600000).toISOString(), options: [{ key: 'yes', text: 'Ship it' }, { key: 'no', text: 'Hold' }] }];
const reads = { inbox: async () => { gate.reads++; await gate.held; return inbox; }, agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], usage: async () => ({}), limits: async () => ({}), decisions: async () => [] };
// The model name picks the bus answer: a refusal before the decision exists, a lost reply, or a hire.
const decisionWrite = async (verb, args, ctx) => {
calls.push({ verb, args, ctx });
if (verb === 'hire.templates.console') return { templates: [{ name: 'coder', sha256: SHA }] };
if (verb !== 'hire.start.console') throw new BusReadError('decision-not-found');
if (args.route?.model === 'gone-model') throw new BusReadError('model-provider-unavailable');
if (args.route?.model === 'lost-model') throw new BusReadError('outcome-unknown');
return { id: 'dec-hire-1', op: 'resolved', choice: 'yes' };
};
return {
list: async () => LIST,
members: async () => LIST.map(org => ({ id: org.id, doc: { appearance: { theme: org.theme } }, status: async () => ({ healthy: true, pid: 4242, boot: null }), options: () => ({ bus: reads, chat: { threads: async () => [], projects: async () => [] } }) })),
resolve: async id => id !== 'acme' ? null : {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
chat: { projects: async () => [], threads: async () => [] },
...(decide ? { send: async () => ({}), decisionWrite } : {}),
},
};
}
async function setup(t, { decide = true, viewport = [1440, 900] } = {}) {
const calls = [], gate = { held: null, reads: 0 };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: organizations({ decide, calls, gate }), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/#/orgs/acme/inbox`);
// hold() makes the next workspace inbox reads wait until the returned release() is called.
const hold = () => { let release; gate.held = new Promise(r => { release = r; }); return () => { gate.held = null; release(); }; };
return { b, calls, gate, hold, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
// MOSAIC_UX_SHOTS=<dir> also saves light and dark shots; `at` is the element centred in view.
const shots = async (b, name, width, at) => {
const dir = process.env.MOSAIC_UX_SHOTS;
if (!dir) return;
await mkdir(dir, { recursive: true });
for (const mode of ['light', 'dark']) {
await b.evaluate(`document.documentElement.dataset.mode=${JSON.stringify(mode)};document.querySelector(${JSON.stringify(at)}).scrollIntoView({block:'center'})`);
await b.screenshot(join(dir, `inbox-hire-${name}-${width}-${mode}.png`));
}
};
const fill = (b, values) => b.evaluate(`for (const [k, v] of Object.entries(${JSON.stringify(values)})) { const e = document.querySelector('[data-hire-field="' + k + '"]'); e.value = v; e.dispatchEvent(new Event('input', { bubbles: true })); }`);
for (const [width, height] of [[1440, 900], [390, 844]]) test(`D4 the org-mode inbox at ${width}px starts a hire on the current organization's bus, and a definite refusal keeps its code`, { timeout: 120000 }, async t => {
const { b, calls, wait, text } = await setup(t, { viewport: [width, height] });
await wait('document.querySelector("[data-ws-inbox] [data-ws-item]")');
// P0-1: the form is on the workspace inbox, beside the cross-organization list, not a separate page.
await wait('document.querySelector("[data-ws-inbox] #hire-start #hire-start-form")');
assert.match(await text('#hire-start h2'), /Hire, fire or change an agent/);
// The organization is the current one and is not editable: the posts go to its bus.
assert.equal(await b.evaluate('document.querySelector("[data-hire-field=business]")'), null);
assert.deepEqual(await b.evaluate('(e => [e.value, e.readOnly])(document.querySelector("#hire-start input[readonly]"))'), ['acme', true]);
assert.match(await text('#hire-start-org'), /acme's bus/);
await b.evaluate('document.querySelector("[data-hire-load]").click()');
await wait('document.querySelector("[data-hire-field=template] option[value=coder]")');
assert.deepEqual(calls.map(c => [c.verb, c.ctx.business]), [['hire.templates.console', 'acme']]);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
await shots(b, 'form', width, '#hire-start h2');
// P1-2: the bus refuses before it records anything; the page shows that code and its next step.
await fill(b, { name: 'scout', template: 'coder', harness: 'pi', model: 'gone-model', reason: 'Need a coder' });
await b.evaluate('document.querySelector("#hire-start-form").requestSubmit()');
await wait('document.querySelector("#hire-start-status code")');
assert.equal(await text('#hire-start-status code'), 'model-provider-unavailable');
assert.match(await text('#hire-start-status'), /Settings > Model proxy/);
assert.doesNotMatch(await text('#hire-start-status'), /may have reached the bus/);
const start = calls.filter(c => c.verb === 'hire.start.console');
assert.equal(start.length, 1, 'one start, never retried');
assert.equal(start[0].ctx.business, 'acme');
assert.deepEqual(start[0].args, { op: 'hire', level: 'org', name: 'scout', reason: 'Need a coder', role: { template: 'coder', sha256: SHA }, route: { harness: 'pi', model: 'gone-model' } });
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
await shots(b, 'refused', width, '#hire-start-status');
// A lost reply is still outcome-unknown.
await fill(b, { model: 'lost-model' });
await b.evaluate('document.querySelector("#hire-start-form").requestSubmit()');
await wait('document.querySelector("#hire-start-status code")?.textContent === "outcome-unknown"');
assert.match(await text('#hire-start-status'), /may have reached the bus/);
// And a hire the bus records.
await fill(b, { model: 'fixture-model' });
await b.evaluate('document.querySelector("#hire-start-form").requestSubmit()');
await wait('document.querySelector("#hire-start-status")?.textContent.includes("Recorded decision dec-hire-1")');
assert.equal(await b.evaluate('document.querySelector("#hire-start-status code")'), null);
assert.equal(calls.filter(c => c.verb === 'hire.start.console').length, 3);
});
test('D4 a poll repaint of the inbox keeps the hire draft, focus and caret', { timeout: 120000 }, async t => {
const { b, wait } = await setup(t);
await wait('document.querySelector("#hire-start-form")');
await fill(b, { name: 'scout', reason: 'Need a coder' });
await b.evaluate('(e => { e.focus(); e.setSelectionRange(2, 2); })(document.querySelector("[data-hire-field=name]"))');
await b.evaluate('window.__marker = document.querySelector("[data-ws-inbox]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('document.querySelector("[data-ws-inbox]") && document.querySelector("[data-ws-inbox]") !== window.__marker');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'hire:name');
assert.deepEqual(await b.evaluate('[document.activeElement.value, document.activeElement.selectionStart]'), ['scout', 2]);
assert.equal(await b.evaluate('document.querySelector("[data-hire-field=reason]").value'), 'Need a coder');
});
// Review P2: the form is built after the workspace read, so what lands while the read is slow survives
// the repaint: typing into the draft, and the status of a submit that resolves during the read.
test('D4 a slow inbox read does not paint an older hire draft over typing done during the read', { timeout: 120000 }, async t => {
const { b, gate, hold, wait } = await setup(t);
await wait('document.querySelector("#hire-start-form")');
await fill(b, { name: 'sc' });
const release = hold(), before = gate.reads;
await b.evaluate('window.__marker = document.querySelector("[data-ws-inbox]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
for (let i = 0; i < 200 && gate.reads === before; i++) await new Promise(r => setTimeout(r, 30));
assert.ok(gate.reads > before, 'the repaint read reached the bus and is held');
await fill(b, { name: 'scout', reason: 'Typed during the read' });
release();
await wait('document.querySelector("[data-ws-inbox]") !== window.__marker && document.querySelector("#hire-start-form")');
assert.deepEqual(await b.evaluate('["name","reason"].map(k => document.querySelector("[data-hire-field=" + k + "]").value)'), ['scout', 'Typed during the read']);
});
test('D4 a hire answer that arrives during a slow inbox read keeps its status after the repaint', { timeout: 120000 }, async t => {
const { b, calls, gate, hold, wait } = await setup(t);
await wait('document.querySelector("#hire-start-form")');
await b.evaluate('document.querySelector("[data-hire-load]").click()');
await wait('document.querySelector("[data-hire-field=template] option[value=coder]")');
await fill(b, { name: 'scout', template: 'coder', harness: 'pi', model: 'gone-model', reason: 'Need a coder' });
const release = hold(), before = gate.reads;
await b.evaluate('window.__marker = document.querySelector("[data-ws-inbox]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
for (let i = 0; i < 200 && gate.reads === before; i++) await new Promise(r => setTimeout(r, 30));
assert.ok(gate.reads > before, 'the repaint read reached the bus and is held');
await b.evaluate('document.querySelector("#hire-start-form").requestSubmit()');
await wait('document.querySelector("#hire-start-status code")');
release();
await wait('document.querySelector("[data-ws-inbox]") !== window.__marker && document.querySelector("#hire-start-form")');
assert.equal(await b.evaluate('document.querySelector("#hire-start-status code")?.textContent ?? null'), 'model-provider-unavailable');
assert.match(await b.evaluate('document.querySelector("#hire-start-status").innerText'), /Settings > Model proxy/);
assert.equal(calls.filter(c => c.verb === 'hire.start.console').length, 1);
});
test('D4 without decision answers (no --send) the org-mode inbox shows no hire form', { timeout: 120000 }, async t => {
const { b, calls, wait } = await setup(t, { decide: false });
await wait('document.querySelector("[data-ws-inbox] [data-ws-item]")');
assert.equal(await b.evaluate('document.querySelector("#hire-start")'), null);
assert.equal(calls.length, 0);
});
@@ -264,3 +264,76 @@ test('quick fallback edits survive the harness redraw', {timeout:60000}, async t
await b.evaluate('new Promise(r=>setTimeout(r,500))');
assert.deepEqual(await b.evaluate(`[${cell('fallback.harness')}.value,${cell('fallback.model')}.value,${cell('fallback.thinking')}.value]`),['claude-code','second','high']);
});
// D3 (P0-4): an organization on the fixture provider with cliproxy listed and no model proxy reads
// an unavailable catalogue. The page says a thinking change still saves, the switch reads Disable
// from the cliproxy module itself, and pressing it removes cliproxy only. A fixture-only
// organization's catalogue is ready with no proxy, and its switch reads Enable.
test('D3 Routing: with cliproxy listed and no model proxy the switch reads Disable and removes cliproxy only; a fixture-only org reads Enable', {timeout:90000}, async t => {
for (const [providers, label, line, next] of [
[['fixture','cliproxy'], 'Disable', /Model catalogue unavailable\. A change to thinking alone still saves\. A new model needs the catalogue: set up the model proxy, or, when this organization has none, press Disable model proxy catalogue\./, ['fixture']],
[['fixture'], 'Enable', /^Model catalogue, observed /, ['fixture','cliproxy']],
]) {
const root=tmp(t,'d3-switch-'), f=fixture(root,'acme',doc=>{doc.plugins['model-provider']=providers;return doc;});
const dir=join(root,'config'),file=join(dir,'businesses','acme.json');
// No modelCatalogue stand-in and no proxy: the broker's own catalogue service answers.
const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)}});t.after(()=>host.close());
const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});
const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args}));
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web));
const b=await browser();t.after(()=>b.close());
const settings=await call('plugins');
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.posts=[];window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/proxy/plugins')return new Response(JSON.stringify(${JSON.stringify(settings)}));if(url==='/api/proxy/plugins/set'){posts.push(JSON.parse(options.body));return new Promise(r=>window.answer=(status,body)=>r(new Response(JSON.stringify(body),{status})));}return original(url,options);};`});
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
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');})()`);
await wait(`document.querySelector("#provider-switch")?.textContent.startsWith(${JSON.stringify(label)})&&!!document.querySelector('[data-harness]')`);
const info=await b.evaluate('document.querySelector(".routing-panel > p.small")?.textContent');
assert.match(info,line,providers.join(','));
await b.evaluate('document.querySelector("#provider-switch").click();true');
await wait('posts.length===1');const payload=await b.evaluate('posts[0]');
assert.deepEqual(payload.plugins,{'model-provider':next},providers.join(','));
const result=await call('plugins.set',payload);await b.evaluate(`window.answer(200,${JSON.stringify(result)});true`);
assert.deepEqual(JSON.parse(readFileSync(file,'utf8')).plugins['model-provider'],next);
await b.close();
}
});
// D3 review (finding 1, 3): after a catalogue refusal the receipt names Disable model proxy
// catalogue only when pressing it can make the save apply: cliproxy is listed and the model the
// change moves to comes from another listed module. A model from the proxy, or a list without
// cliproxy (Disable already pressed), gets no Disable step; a tester is sent to the owner.
test('D3 Routing: a catalogue refusal names Disable only when it can make the save apply, and sends a tester to the owner', {timeout:90000}, async t => {
const refusal=code=>({error:code,message:`${code}: the reason. Nothing changed.`,sent:false});
for (const [providers, tester, instance, code, expect] of [
[['fixture','cliproxy'], false, 'coder', 'model-catalogue-unavailable', /Nothing changed\. This change needs no model proxy: press Disable model proxy catalogue, then save again\. Draft kept\.$/],
[['fixture','cliproxy'], false, 'reviewer', 'model-catalogue-unavailable', /Nothing changed\. Draft kept\.$/],
[['fixture'], false, 'reviewer', 'model-provider-unavailable', /Nothing changed\. Draft kept\.$/],
[['fixture','cliproxy'], true, 'coder', 'model-catalogue-unavailable', /Nothing changed\. Ask the organization's owner to set up the model proxy, or to disable the model proxy catalogue when the change needs no proxy\. Draft kept\.$/],
]) {
const label=`${providers.join(',')} tester=${tester} ${instance}`;
const view={business:'acme',revision:'a'.repeat(64),harnesses:['pi','claude-code'],models:['fixture-deterministic','proxy-x'],
providerModels:{'fixture-deterministic':{module:'fixture',upstream:'fixture',model:'fixture-deterministic'},'proxy-x':{module:'cliproxy',upstream:'u',model:'x'}},
levels:{pi:['off','low'],'claude-code':['off','low']},
instances:{coder:{harness:'pi',model:'fixture-deterministic',thinking:'off',fallback:null},reviewer:{harness:'pi',model:'proxy-x',thinking:'off',fallback:null}},
catalogue:{status:'unavailable',models:[],notices:[]}};
const plugins={revision:'b'.repeat(64),plugins:{harness:['pi','claude-code'],'model-provider':providers},available:{harness:['pi','claude-code']}};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:{messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture')},routes:async()=>view},reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web));
const b=await browser();t.after(()=>b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.posts=[];window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:${!tester},tester:${tester}}));if(url==='/api/proxy/plugins')return new Response(JSON.stringify(${JSON.stringify(plugins)}));if(url==='/api/routes'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify(${JSON.stringify(refusal(code))}),{status:502});}return original(url,options);};`});
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
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)});})()`);
const cell=key=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify([instance,key])}']`)})`;
await wait(`${cell('harness')}&&!document.querySelector('#routes-form button[type=submit]').disabled${tester?'':"&&!!document.querySelector('[data-harness]')"}`);
// The harness change moves the instance's route, so the bus would ask the catalogue.
await b.evaluate(`{const e=${cell('harness')};e.value='claude-code';e.dispatchEvent(new Event('change',{bubbles:true}));}true`);
await wait(`${cell('harness')}.value==='claude-code'`);
await b.evaluate(`document.querySelector('#routes-form').requestSubmit();true`);
await wait('posts.length===1&&document.querySelector("#routes-receipt").textContent.includes("Draft kept")');
assert.deepEqual(await b.evaluate('posts[0].routes'),{[instance]:{harness:'claude-code',model:view.instances[instance].model,thinking:'off',fallback:null}},label);
const text=await b.evaluate('document.querySelector("#routes-receipt").textContent');
assert.match(text,new RegExp(`^${code}: the reason\\. `),label);
assert.match(text,expect,label);
await b.close();await close(web);
}
});
+18
View File
@@ -173,3 +173,21 @@ test('row 65 Console: the routes read takes no arguments, needs a bus and refuse
const refused = await setup(t, { bus: { routes: async () => { throw new BusReadError('human-required'); } } });
assert.equal((await get(refused, '/api/bus/routes')).status, 403);
});
// The Disable step is the Routing page's to add (bus.js catalogueStep), since only the page knows
// whether pressing it can help; the server text never names it.
test('D3 P0-4: a model refusal keeps its status and names the next step that makes the save apply', async t => {
for (const [code, step] of [
['model-catalogue-unavailable', /A change to thinking alone still saves; a model from the model proxy needs the proxy set up first\. Nothing changed\.$/],
['model-provider-unavailable', /A change to thinking alone still saves; otherwise pick a model the catalogue lists, or set up the model proxy when the model comes from it\. Nothing changed\.$/],
['model-harness-incompatible', /Pick a harness the model lists, or another model/],
['model-key-collision', /Pick the table's entry or another key/],
]) {
const s = await setup(t, { write: async () => { throw new BusReadError(code); } });
const r = await send(s, '/api/routes', { revision: REV, routes: { coder: ROUTE } });
assert.deepEqual([r.status, r.body.error, r.body.sent], [502, code, false], code);
assert.match(r.body.message, new RegExp(`^${code}: .*Nothing changed\\.$`), code);
assert.match(r.body.message, step, code);
assert.doesNotMatch(r.body.message, /refused by the bus|Disable/, code);
}
});