pack.state gave a pack hire only the chief of staff's resolved route, so an org whose chief has no model refused every hire pack-hire-route-missing. The route is now the chief's (launch.by), else the org assistant's (the instance whose definition is assistant; the one named assistant when there are several, else the first in key order), else null. Both resolve through resolveInstance, need harness and model, and carry thinking when set. pack.state adds routeFrom, and each hired or awaiting-owner receipt line carries it. Refusal next step, CLI hint, WebUI hire reason and the bus, CLI and TOOLS docs say the refusal now means neither has a route.
187 lines
16 KiB
JavaScript
187 lines
16 KiB
JavaScript
// Ruling 2026-11-14: "If I choose a starter pack and apply it, that is a decision!" Driven in Chromium
|
|
// against stand-in pack, instance and decision verbs: the owner's apply hires the pack's proposals, the
|
|
// pack card says how many agents it hired and lists the open proposals with their reasons, and "Hire the
|
|
// N proposed agents" runs the hire again. The Agents page shows the new cards, each naming its pack, in
|
|
// the same response cycle as the write, and Fire removes a card at once with a receipt. Each wait is
|
|
// at most three seconds, well inside the page's ten-second poll, so the write's own redraw shows them.
|
|
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 { browser } from './browser.mjs';
|
|
import { close } from './fixture.mjs';
|
|
|
|
const REV = n => String(n).repeat(64);
|
|
const waitFor = (b, expression, what, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,2000))})()`);
|
|
const PACKS = [{ id: 'software-team', origin: 'shipped', pack: { label: 'Software team', description: 'Roles, a board and a review workflow.', version: '1.0.0', scope: 'organization' } }];
|
|
const FROM = { id: 'software-team', origin: 'shipped', project: null }; // the bus's instances read shape (pack-hire.test.mjs)
|
|
const PREVIEW = {
|
|
pack: { id: 'software-team', version: '1.0.0', label: 'Software team', origin: 'shipped', scope: 'organization' },
|
|
project: null, revision: REV('c'),
|
|
items: [{ item: 'board', name: 'board', target: 'board', path: 'board', mark: 'add' }],
|
|
hires: [
|
|
{ name: 'coder-1', definition: 'coder', mark: 'propose', pin: null, flagged: [{ field: 'tools', value: 'bash', reasons: ['above-chief'] }], credentialLines: [{ service: 'gitea', scope: 'repo:write' }], noHire: false },
|
|
{ name: 'reviewer-1', definition: 'reviewer', mark: 'propose', pin: null, flagged: [], credentialLines: [], noHire: false },
|
|
{ name: 'scout-1', definition: 'scout', mark: 'propose', pin: null, flagged: [], credentialLines: [], noHire: false },
|
|
],
|
|
skills: { notEnabled: [] }, chief: true, plan: {}, digest: REV('d'),
|
|
};
|
|
|
|
// busy: the bus's apply committed but its hire loop did not run (hireError), and the packs read lists one line a
|
|
// member's pack hire left in the owner's inbox (fromPack), one raised some other way and one refused.
|
|
async function fixture(t, { busy = false } = {}) {
|
|
const verbs = [], decisions = [];
|
|
const hired = new Map(); // name -> its pack provenance, as the bus's instances read reports it
|
|
let applied = null, route = false, instanceReads = 0;
|
|
const instances = () => ({ chief: { definition: 'chief-of-staff', running: [] }, assistant: { definition: 'assistant', running: [], protected: true, endProtected: true }, ...Object.fromEntries([...hired].map(([n, pack]) => [n, { definition: n.split('-')[0], running: [], pack }])) });
|
|
// The bus's hire loop, stood in: with a route every open proposal is hired; without one scout-1 is refused.
|
|
const hireLoop = () => PREVIEW.hires.map(h => {
|
|
if (hired.has(h.name)) return { name: h.name, state: 'exists' };
|
|
if (!route && h.name === 'scout-1') return { name: h.name, state: 'refused', code: 'pack-hire-route-missing' };
|
|
hired.set(h.name, FROM); const decision = `d-${hired.size}`; decisions.push(decision);
|
|
return { name: h.name, state: 'hired', decision };
|
|
});
|
|
const listed = () => applied ? [{ id: 'software-team', origin: 'shipped', project: null, hired: [...hired.keys()],
|
|
open: PREVIEW.hires.filter(h => !hired.has(h.name)).map(h => ({ name: h.name, reason: `Proposed by starter pack software-team for ${h.definition} work.`,
|
|
...(!busy || h.name === 'scout-1' ? { state: 'refused', code: 'pack-hire-route-missing' } : { state: 'awaiting-owner', decision: `inbox-${h.name}`, ...(h.name === 'coder-1' ? { fromPack: true } : {}) }) })) }] : [];
|
|
const read = async (verb, args) => {
|
|
verbs.push([verb, args]);
|
|
if (verb === 'packs.console') return { packs: PACKS, applied: listed() };
|
|
if (verb === 'pack.preview.console') return PREVIEW;
|
|
if (verb === 'autonomy.get.console') return { revision: REV('e'), autonomy: {}, levels: {} };
|
|
throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
|
|
};
|
|
const write = async (verb, args) => {
|
|
verbs.push([verb, args]);
|
|
if (verb === 'pack.apply.console') { applied = true; return { revision: REV('f'), added: ['board'], pack: { id: args.pack, version: '1.0.0' }, ...(busy ? { hires: [], hireError: 'organization-busy' } : { hires: hireLoop() }) }; }
|
|
if (verb === 'pack.hire.console') return { pack: args.pack, hires: hireLoop() };
|
|
throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
|
|
};
|
|
const decisionWrite = async (verb, args) => {
|
|
verbs.push([verb, args]);
|
|
if (verb === 'hire.start.console' && args.op === 'fire' && hired.has(args.name)) { hired.delete(args.name); return { id: 'fire-1', op: 'resolved', choice: 'yes' }; }
|
|
throw Object.assign(new Error('invalid-request'), { code: 'invalid-request' });
|
|
};
|
|
const server = await startServer({
|
|
port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {},
|
|
organizations: {
|
|
list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }],
|
|
resolve: async () => ({
|
|
busHealthy: true, decisionWrite, requestRead: read, requestWrite: read, packsWrite: write,
|
|
workflowsRead: async () => ({ revision: REV('a'), document: { workflows: {}, projects: {}, definitions: {} }, templates: [] }), send: async () => ({}),
|
|
reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
|
|
bus: { instances: async () => { instanceReads++; return { revision: REV('b'), instances: instances(), definitions: {} }; }, agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
|
|
chat: { projects: async () => [], threads: async () => [] },
|
|
}),
|
|
},
|
|
});
|
|
t.after(() => close(server));
|
|
const b = await browser({ machine: false, args: ['--disable-extensions'] });
|
|
t.after(() => b.close());
|
|
await b.viewport(1440, 900);
|
|
return { b, verbs, decisions, port: server.address().port, reads: () => instanceReads, setRoute: v => { route = v; } };
|
|
}
|
|
|
|
const shot = async (b, name, selector) => {
|
|
if (!process.env.MOSAIC_UX_SHOTS) return;
|
|
await mkdir(process.env.MOSAIC_UX_SHOTS, { recursive: true });
|
|
await b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block:'start'});true`);
|
|
await b.screenshot(join(process.env.MOSAIC_UX_SHOTS, `${name}.png`));
|
|
};
|
|
|
|
test('PD apply hires the proposals, the pack card counts them, Hire the N proposed agents hires the rest, Agents shows and fires them without a poll', { timeout: 90000 }, async t => {
|
|
const f = await fixture(t);
|
|
const { b, verbs } = f;
|
|
await b.navigate(`http://127.0.0.1:${f.port}/#/orgs/acme/agents`);
|
|
await waitFor(b, `document.querySelector('[data-org-agents] [data-agent-card="chief"]')`, 'agents before');
|
|
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-agents] [data-agent-card]').length`), 2);
|
|
// Fire is offered on neither the chief nor the assistant: the bus would refuse both.
|
|
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-agents] [data-agent-fire]').length`), 0);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-agent-card="chief"] [data-agent-fire]')`), null, 'the chief has no Fire');
|
|
await shot(b, 'agents-before-1440', '[data-org-agents]');
|
|
|
|
await b.navigate(`http://127.0.0.1:${f.port}/#/orgs/acme/organization`);
|
|
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-preview-button]')`, 'pack list');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-hired]')`), null, 'nothing applied yet');
|
|
await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-preview-button]').click();true`);
|
|
await waitFor(b, `document.querySelector('[data-pack-preview]')`, 'preview');
|
|
// The preview says the apply is the hire decision and that the owner's apply approves the flagged and credential lines.
|
|
assert.match(await b.evaluate(`document.querySelector('[data-pack-hire="coder-1"]').textContent`), /Hired when you apply: applying is the hire decision, and approves the flagged and credential lines below\. A credential line is approved, not bound/);
|
|
// A hire with no credential line says nothing about binding one.
|
|
assert.match(await b.evaluate(`document.querySelector('[data-pack-hire="reviewer-1"] p').textContent`), /^Hired when you apply: applying is the hire decision\.$/);
|
|
assert.doesNotMatch(await b.evaluate(`document.querySelector('[data-pack-hire="reviewer-1"]').textContent`), /flagged and credential lines/);
|
|
await shot(b, 'pack-card-before-1440', '[data-pack="software-team"]');
|
|
|
|
await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
|
|
await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply status');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Applied. Added board. Hired coder-1, reviewer-1. Not hired: scout-1, neither the chief of staff nor the assistant has a harness and model to give it; set them under Settings, Routing.');
|
|
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-hired]')`, 'hired count');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-pack-hired]').textContent`), '2 agents hired: coder-1, reviewer-1');
|
|
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-pack-open-hire]')].map(l=>[l.dataset.packOpenHire,l.dataset.packOpenState,l.textContent])`),
|
|
[['scout-1', 'refused', 'scout-1: Proposed by starter pack software-team for scout work. not hired: neither the chief of staff nor the assistant has a harness and model to give it; set them under Settings, Routing']]);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-pack-hire-button]').textContent`), 'Hire the 1 proposed agent');
|
|
await shot(b, 'pack-card-after-1440', '[data-pack="software-team"]');
|
|
|
|
// The route is set; the card's action hires the remaining proposal, the same loop, and nothing twice.
|
|
f.setRoute(true);
|
|
await b.evaluate(`document.querySelector('[data-pack-hire-button]').click();true`);
|
|
await waitFor(b, `document.querySelector('[data-packs-status]').textContent.startsWith('Hired scout-1.')`, 'hire status');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Hired scout-1. Already hired: coder-1, reviewer-1.');
|
|
assert.deepEqual(verbs.filter(([v]) => v === 'pack.hire.console').map(([, a]) => a), [{ pack: 'software-team' }]);
|
|
await waitFor(b, `document.querySelector('[data-pack-hired]').textContent.startsWith('3 agents hired')`, 'hired count 3');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-pack-hire-button]')`), null, 'nothing left to hire');
|
|
assert.equal(f.decisions.length, 3);
|
|
|
|
// The Agents page: three new cards, each naming its pack.
|
|
await b.navigate(`http://127.0.0.1:${f.port}/#/orgs/acme/agents`);
|
|
await waitFor(b, `document.querySelectorAll('[data-org-agents] [data-agent-card]').length===5`, 'agents after');
|
|
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-org-agents] [data-agent-card] [data-agent-pack]')].map(s=>[s.closest('[data-agent-card]').dataset.agentCard,s.textContent])`),
|
|
[['coder-1', 'from pack software-team'], ['reviewer-1', 'from pack software-team'], ['scout-1', 'from pack software-team']]);
|
|
assert.match(await b.evaluate(`document.querySelector('[data-agents-available] tr[data-agent="coder-1"]')?.textContent ?? ''`), /from pack software-team/);
|
|
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true);
|
|
await shot(b, 'agents-after-1440', '[data-org-agents]');
|
|
|
|
// Fire asks first; Cancel fires nothing.
|
|
const card = `[data-org-agents] [data-agent-card="reviewer-1"]`;
|
|
await b.evaluate(`document.querySelector('${card} [data-agent-fire]').click();true`);
|
|
await waitFor(b, `document.querySelector('${card} [data-agent-fire-no]')`, 'fire confirm');
|
|
await b.evaluate(`document.querySelector('${card} [data-agent-fire-no]').click();true`);
|
|
await waitFor(b, `document.querySelector('${card} [data-agent-fire]')`, 'kept');
|
|
assert.equal(verbs.filter(([v]) => v === 'hire.start.console').length, 0);
|
|
// Confirm: the card goes at once, the receipt names the decision, and the page reads the bus again.
|
|
await b.evaluate(`document.querySelector('${card} [data-agent-fire]').click();true`);
|
|
await waitFor(b, `document.querySelector('${card} [data-agent-fire-yes]')`, 'fire confirm again');
|
|
const before = f.reads();
|
|
await b.evaluate(`document.querySelector('${card} [data-agent-fire-yes]').click();true`);
|
|
await waitFor(b, `document.querySelector('[data-agents-receipt]')?.textContent.includes('Fired reviewer-1')`, 'fire receipt');
|
|
assert.equal(await b.evaluate(`document.querySelector('${card}')`), null);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-agents-available] tr[data-agent="reviewer-1"]')`), null);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-agents-receipt]').textContent`), 'Fired reviewer-1. Recorded decision fire-1.');
|
|
assert.deepEqual(verbs.filter(([v]) => v === 'hire.start.console').map(([, a]) => a), [{ op: 'fire', level: 'org', name: 'reviewer-1', reason: 'Fired from the Agents page.' }]);
|
|
// The redraw the fire asked for reads the instances again and keeps the receipt.
|
|
for (let i = 0; i < 100 && f.reads() <= before; i++) await new Promise(r => setTimeout(r, 30));
|
|
assert.ok(f.reads() > before, 'the fire redraws the page from a fresh read');
|
|
await waitFor(b, `document.querySelector('[data-agents-receipt]')?.textContent.includes('Fired reviewer-1')&&document.querySelectorAll('[data-org-agents] [data-agent-card]').length===4`, 'redrawn');
|
|
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-agents] [data-agent-card]').length`), 4);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-agents-receipt]').textContent`), 'Fired reviewer-1. Recorded decision fire-1.');
|
|
});
|
|
|
|
test('PD an apply whose hire loop did not run says so; the owner card counts a pack-raised inbox line as hireable and leaves the inbox its own', { timeout: 90000 }, async t => {
|
|
const f = await fixture(t, { busy: true });
|
|
const { b } = f;
|
|
await b.navigate(`http://127.0.0.1:${f.port}/#/orgs/acme/organization`);
|
|
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-preview-button]')`, 'pack list');
|
|
await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-preview-button]').click();true`);
|
|
await waitFor(b, `document.querySelector('[data-pack-apply]')`, 'preview');
|
|
await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
|
|
await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply status');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Applied. Added board. The hires did not run: another pack hire on this organization was running. Use Hire on the pack card to run them.');
|
|
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-open]')`, 'open lines');
|
|
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-pack-open-hire]')].map(l=>[l.dataset.packOpenHire,l.dataset.packOpenState])`),
|
|
[['coder-1', 'awaiting-owner'], ['reviewer-1', 'awaiting-owner'], ['scout-1', 'refused']]);
|
|
// coder-1 was left in the inbox by a pack hire, so the owner's pack hire approves it; reviewer-1 is the inbox's.
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-pack-hire-button]').textContent`), 'Hire the 2 proposed agents');
|
|
});
|