Files
stack/packages/webui/tests/hire-inbox-browser.test.mjs
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

168 lines
12 KiB
JavaScript

// 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);
});