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.
168 lines
12 KiB
JavaScript
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);
|
|
});
|