// E2 (#1873, P1-3, org ruling 6): the WebUI's task board, hire form and autonomy panel from a // terminal. Each test sends the CLI verb and the page's own request for the same input, and checks // that both reach the same bus verb with the same arguments and get the same answer, a refusal // included. Hire and autonomy run the real scripts/mosaic against an in-process dev-owner Console // whose organization transports are fakes, so no bus host starts; tasks run against a real broker // bound to the person, as the human transport binds it. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { request } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs'; import { processState } from '../../seat/src/process-state.mjs'; import { startServer } from '../../webui/src/serve.mjs'; import { createDevOwner } from '../../webui/src/dev-owner.mjs'; import { BusReadError } from '../../webui/src/bus.mjs'; import { parseTaskWrite } from '../../webui/src/workspace-tasks.mjs'; import { close } from '../../webui/tests/fixture.mjs'; import { main } from '../src/cli.mjs'; import { CliError } from '../src/errors.mjs'; import { orgCommand } from '../src/org.mjs'; import { broker, humanEnv, io } from './helpers.mjs'; const REPO = new URL('../../../', import.meta.url).pathname; const MOSAIC = join(REPO, 'scripts/mosaic'); const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350]; const SUBJECT = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' }; const SHA = 'a'.repeat(64); async function instance(t) { const home = mkdtempSync(join(tmpdir(), 'e2-')); const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-e2test/config.json') }); const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'e2test'], { env, encoding: 'utf8' }); assert.equal(setup.status, 0, setup.stderr); const ctx = { configDir: join(home, '.config/mosaic-e2test'), dataRoot: join(home, '.mosaic-e2test'), instance: 'e2test', env }; await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha'); t.after(() => rmSync(home, { recursive: true, force: true })); return { ctx, env }; } function mosaic(env, args, timeout = 60000) { return new Promise((resolve, reject) => { const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = '', stderr = ''; child.stdout.setEncoding('utf8').on('data', c => { stdout += c; }); child.stderr.setEncoding('utf8').on('data', c => { stderr += c; }); const timer = setTimeout(() => child.kill('SIGKILL'), timeout); child.on('error', reject); child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); }); }); } function recordConsole(ctx, url) { const dir = join(ctx.dataRoot, 'console'); mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700); const file = join(dir, 'console.json'); writeFileSync(file, JSON.stringify({ pid: process.pid, startTime: processState(process.pid).startTime, url }), { mode: 0o600 }); chmodSync(file, 0o600); } // The page's own request: a same-origin fetch from the Console's page. function page(port, method, path, body) { return new Promise((resolve, reject) => { const payload = body === undefined ? undefined : JSON.stringify(body); const headers = { host: `127.0.0.1:${port}`, ...(payload === undefined ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }) }; request({ host: '127.0.0.1', port, method, path, headers }, res => { let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) })); }).on('error', reject).end(payload); }); } // The bus's revision is a 64-hex digest; REV(n) stands in for the nth. const REV = n => n.toString(16).padStart(64, '0'); const LOOP = { 'cross-role': 'in the loop', merge: 'in the loop', policy: 'in the loop', authority: 'in the loop' }; // One organization, alpha, whose bus transports record each call. `send` false is a Console // started without --send. function organizations({ send = true } = {}) { const calls = []; let state = { revision: REV(3), levels: { ...LOOP }, autonomy: { notices: false } }; const org = { busHealthy: true, send: send ? async () => ({}) : null, decisionWrite: async (verb, args, opts) => { calls.push({ verb, args, opts }); if (verb === 'hire.templates.console') return { templates: [{ name: 'worker', sha256: SHA }] }; if (args.name === 'blocked') throw new BusReadError('definition-not-hireable'); return { id: `hire-${calls.length}`, op: 'resolved', choice: 'yes' }; }, requestRead: async (verb, args, opts) => { calls.push({ verb, args, opts }); return structuredClone(state); }, autonomyWrite: async (verb, args, opts) => { calls.push({ verb, args, opts }); if (verb === 'autonomy.review.console') return { reviewed: args.decisionClass }; if (args.revision !== state.revision) throw new BusReadError('revision-conflict'); const { notices, reverseWindowHours, authorityResolver, ...levels } = args.autonomy; state = { revision: REV(parseInt(state.revision, 16) + 1), levels, autonomy: { notices, reverseWindowHours, ...(authorityResolver ? { authorityResolver } : {}) } }; return { revision: state.revision, transaction: `tx-${parseInt(state.revision, 16)}` }; }, }; return { calls, organizations: { list: async () => [{ id: 'alpha' }], resolve: async id => (id === 'alpha' ? org : null) } }; } async function consoleFor(t, ctx, options) { const f = organizations(options); const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), organizations: f.organizations, log: () => {} }); t.after(() => close(server)); const port = server.address().port; assert.ok(!FORBIDDEN.includes(port)); recordConsole(ctx, `http://127.0.0.1:${port}/`); return { ...f, port, server }; } const HIRE = ['--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'more hands']; const GUARDED = [ ['org', 'role-templates', 'alpha'], ['org', 'hire', 'alpha', ...HIRE], ['org', 'change', 'alpha', ...HIRE], ['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'done'], ['org', 'autonomy', 'alpha'], ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'near'], ['org', 'autonomy', 'alpha', 'review', 'merge'], ]; test('hire and autonomy verbs: usage errors first (exit 4), then refused inside an agent run before any config is read', async () => { // The config path is not an instance config, so a read would refuse with another message. const env = { ...humanEnv({ MOSAIC_CONFIG: '/nonexistent/e2/config.json' }), CLAUDECODE: '1' }; for (const args of GUARDED) { const r = await mosaic(env, args); assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, /outside any agent run \(found CLAUDECODE/, args.join(' ')); assert.equal(r.stdout, ''); } for (const args of [ ['org', 'hire', 'alpha', '--name', 'ada'], ['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'done', '--model', 'glm'], ['org', 'hire', 'alpha', ...HIRE, '--level', 'project'], ['org', 'autonomy', 'alpha', 'set', '--merge', 'near'], ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'sideways'], ['org', 'autonomy', 'alpha', 'set', '--revision', '3', '--merge', 'near'], ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--reverse-window-hours', '200'], ['org', 'autonomy', 'alpha', 'review', 'everything'], ['org', 'role-templates', 'Not An Id'], ]) { const r = await mosaic(env, args); assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`); assert.doesNotMatch(r.stderr, /agent run/); } // This process's own environment counts too, as for org archive|delete: the marker is only in // process.env here, never in io.env. const saved = process.env.CLAUDECODE; process.env.CLAUDECODE = '1'; try { for (const args of GUARDED) { const x = io(humanEnv({ MOSAIC_CONFIG: '/nonexistent/e2/config.json' })); delete x.env.CLAUDECODE; await assert.rejects(orgCommand(args.slice(1), x, {}), e => e instanceof CliError && e.exitCode === 3 && /outside any agent run/.test(e.message), args.join(' ')); } } finally { if (saved === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = saved; } // The default instance is a one-line refusal (exit 3), not an uncaught error. const d = io(humanEnv()); delete d.env.MOSAIC_CONFIG; await assert.rejects(orgCommand(['autonomy', 'alpha'], d, {}), e => e instanceof CliError && e.exitCode === 3 && /requires a named instance.*--dev-local-owner/.test(e.message)); }); test('role-templates, hire, change and fire send the hire form\'s request and get its answer, refusals included', { timeout: 120000 }, async t => { const { ctx, env } = await instance(t); const c = await consoleFor(t, ctx); const opts = { subject: SUBJECT, business: 'alpha' }; let r = await mosaic(env, ['org', 'role-templates', 'alpha', '--json']); assert.equal(r.status, 0, r.stderr); const pageTemplates = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/templates', { business: 'alpha' }); assert.deepEqual(JSON.parse(r.stdout), pageTemplates.body); assert.deepEqual(c.calls.splice(0), [{ verb: 'hire.templates.console', args: {}, opts }, { verb: 'hire.templates.console', args: {}, opts }]); r = await mosaic(env, ['org', 'role-templates', 'alpha']); assert.equal(r.stdout, `worker ${SHA.slice(0, 12)}\n`); c.calls.splice(0); // The form's body for this input (bus.js hireSubmit): the template it read, by name and sha256. const form = (op, name, extra = {}) => ({ op, level: 'org', name, reason: 'more hands', ...extra, ...(op === 'fire' ? {} : { role: { template: 'worker', sha256: SHA }, route: { harness: 'pi', model: 'glm' } }) }); for (const op of ['hire', 'change']) { r = await mosaic(env, ['org', op, 'alpha', ...HIRE, '--json']); assert.equal(r.status, 0, r.stderr); const cli = c.calls.splice(0); assert.deepEqual(cli.map(x => x.verb), ['hire.templates.console', 'hire.start.console']); const pageAnswer = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/start', { business: 'alpha', hire: form(op, 'ada') }); const pageCall = c.calls.splice(0); assert.deepEqual(cli[1], pageCall[0]); assert.deepEqual(cli[1].args, form(op, 'ada')); assert.equal(pageAnswer.status, 200); // Each decision gets its own id; the rest of the answer is the page's. assert.deepEqual({ ...JSON.parse(r.stdout), id: 'x' }, { ...pageAnswer.body, id: 'x' }); } r = await mosaic(env, ['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'more hands', '--level', 'project', '--project', 'site']); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /^recorded decision hire-1: fire ada \(resolved yes\)\n$/); assert.deepEqual(c.calls.splice(0), [{ verb: 'hire.start.console', args: { op: 'fire', level: 'project', name: 'ada', reason: 'more hands', project: 'site' }, opts }]); // A bus refusal reaches the terminal with the Console's code and message, as the form shows it. r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE.map(a => (a === 'ada' ? 'blocked' : a))]); const pageRefusal = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/start', { business: 'alpha', hire: form('hire', 'blocked') }); assert.equal(pageRefusal.status, 409); assert.equal(r.status, 3, r.stderr); assert.ok(r.stderr.includes(`hire refused (definition-not-hireable): ${pageRefusal.body.message}`), r.stderr); c.calls.splice(0); // A template the Console did not offer is refused before anything is raised. r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE.map(a => (a === 'worker' ? 'ghost' : a))]); assert.equal(r.status, 2, r.stderr); assert.match(r.stderr, /no role template "ghost"/); assert.deepEqual(c.calls.splice(0).map(x => x.verb), ['hire.templates.console']); // A Console without --send: the page's refusal, and nothing reaches the bus. await close(c.server); const ro = await consoleFor(t, ctx, { send: false }); r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE]); const pageRo = await page(ro.port, 'POST', '/api/orgs/alpha/decisions/hire/templates', { business: 'alpha' }); assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, new RegExp(`hire refused \\(${pageRo.body.error}\\)`)); assert.equal(pageRo.body.error, 'read-only'); assert.deepEqual(ro.calls, []); // An organization the Console does not know is refused as the page is. r = await mosaic(env, ['org', 'role-templates', 'beta']); assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, /role-templates refused \(org-unavailable\)/); }); test('autonomy reads, saves with the panel\'s revision check and records a review through the panel\'s route', { timeout: 120000 }, async t => { const { ctx, env } = await instance(t); const c = await consoleFor(t, ctx); const opts = { subject: SUBJECT }; let r = await mosaic(env, ['org', 'autonomy', 'alpha', '--json']); assert.equal(r.status, 0, r.stderr); assert.deepEqual(JSON.parse(r.stdout), (await page(c.port, 'GET', '/api/orgs/alpha/autonomy')).body); r = await mosaic(env, ['org', 'autonomy', 'alpha']); assert.equal(r.stdout, `revision ${REV(3)}\ncross-role: in the loop\nmerge: in the loop\npolicy: in the loop\nauthority: in the loop\nnotices: off\nreverse window: not set (the page offers 24 hours)\nauthority resolver: none\n`); c.calls.splice(0); // The panel's save for "merge near the loop" (autonomy.js): every class as shown, notices, the // window it offers (24) and no resolver. const panel = { ...LOOP, merge: 'near the loop', notices: false, reverseWindowHours: 24 }; // Imported here so that the other tests still run, and fail on their own, on a base without it. const { autonomyDocument } = await import('../src/org-owner.mjs'); assert.deepEqual(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false } }, { merge: 'near the loop' }), panel); // An emptied resolver field: the panel leaves it out of the save, and so does the terminal. assert.deepEqual(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false, authorityResolver: 'ops' } }, { authorityResolver: '' }), { ...LOOP, notices: false, reverseWindowHours: 24 }); assert.equal(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false, authorityResolver: 'ops' } }, {}).authorityResolver, 'ops'); r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'near', '--json']); assert.equal(r.status, 0, r.stderr); assert.deepEqual(JSON.parse(r.stdout), { revision: REV(4), transaction: 'tx-4', writable: true }); assert.deepEqual(c.calls.splice(0), [{ verb: 'autonomy.get.console', args: {}, opts }, { verb: 'autonomy.set.console', args: { revision: REV(3), autonomy: panel }, opts }]); // The page's save of the same document at revision 4 lands the same way. const pageSave = await page(c.port, 'POST', '/api/orgs/alpha/autonomy', { revision: REV(4), autonomy: panel }); assert.deepEqual(pageSave.body, { revision: REV(5), transaction: 'tx-5', writable: true }); assert.deepEqual(c.calls.splice(0), [{ verb: 'autonomy.set.console', args: { revision: REV(4), autonomy: panel }, opts }]); // A stale revision is the panel's revision-conflict, and nothing changes. r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--policy', 'near']); const pageStale = await page(c.port, 'POST', '/api/orgs/alpha/autonomy', { revision: REV(3), autonomy: panel }); assert.deepEqual(pageStale, { status: 409, body: { error: 'revision-conflict' } }); assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, /autonomy set refused \(revision-conflict\): the organization changed; read it again with mosaic org autonomy ID/); assert.equal((await page(c.port, 'GET', '/api/orgs/alpha/autonomy')).body.revision, REV(5)); c.calls.splice(0); r = await mosaic(env, ['org', 'autonomy', 'alpha', 'review', 'merge']); assert.equal(r.status, 0, r.stderr); assert.equal(r.stdout, 'recorded your trail review for merge on alpha\n'); await page(c.port, 'POST', '/api/orgs/alpha/autonomy/review', { decisionClass: 'merge' }); const [cli, pageReview] = c.calls.splice(0); assert.deepEqual(cli, pageReview); assert.deepEqual(cli, { verb: 'autonomy.review.console', args: { decisionClass: 'merge' }, opts }); // Without --send the read still answers and both writes are the page's read-only refusal. await close(c.server); const ro = await consoleFor(t, ctx, { send: false }); r = await mosaic(env, ['org', 'autonomy', 'alpha', '--json']); assert.equal(r.status, 0, r.stderr); assert.equal(JSON.parse(r.stdout).writable, false); for (const args of [['set', '--revision', REV(3), '--merge', 'near'], ['review', 'merge']]) { r = await mosaic(env, ['org', 'autonomy', 'alpha', ...args]); assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, /refused \(read-only\): start the Console with --send/); } assert.deepEqual((await page(ro.port, 'POST', '/api/orgs/alpha/autonomy/review', { decisionClass: 'merge' })).body, { error: 'read-only' }); assert.deepEqual(ro.calls.map(x => x.verb), ['autonomy.get.console', 'autonomy.get.console']); }); // mosaic tasks create|edit|archive with the person's transport over a real broker. async function run(argv, s) { const x = io(); try { await main(argv, x, { system: { dataRoot: s.dataRoot }, transport: s.transport }); return { code: 0, out: x.out.text, err: '' }; } catch (e) { if (!(e instanceof CliError)) throw e; return { code: e.exitCode, out: x.out.text, err: e.message }; } } test('tasks create, edit and archive send the board\'s writes to the bus as the person', async t => { const b = broker(t); const s = { ...b, dataRoot: mkdtempSync(join(tmpdir(), 'e2t-')) }; t.after(() => rmSync(s.dataRoot, { recursive: true, force: true })); // The board's request for the same input, through the board's own check. const board = body => { const w = parseTaskWrite({ org: 'demo', ...body }); return { verb: w.verb, args: w.args }; }; let r = await run(['tasks', 'create', '--title', 'Plan the store', '--requirement', 'REQ-TASK-1', '--assignees', 'coder', '--priority', '2', '--business', 'demo', '--json'], s); assert.equal(r.code, 0, r.err); const created = JSON.parse(r.out); assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'create', title: 'Plan the store', requirement: 'REQ-TASK-1', assignees: ['coder'], priority: 2 })]); assert.deepEqual({ ...created, digest: 'd' }, { ok: true, org: 'demo', op: 'create', task_ref: 'task:1', digest: 'd' }); const row = () => s.read('tasks').find(x => x.task_ref === 'task:1'); assert.equal(created.digest, row().digest); assert.equal(row().fields.title, 'Plan the store'); assert.deepEqual(row().fields.assignees, ['coder']); r = await run(['tasks', 'edit', 'task:1', '--expect', created.digest, '--state', 'in-progress', '--due', 'none', '--business', 'demo'], s); assert.equal(r.code, 0, r.err); assert.equal(r.out, `changed task:1, digest ${row().digest}\n`); assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'edit', task_ref: 'task:1', expect: created.digest, state: 'in-progress', due_date: null })]); assert.equal(row().fields.state, 'in-progress'); // A stale digest is the board's task-conflict, and nothing is written. const before = row(); r = await run(['tasks', 'archive', 'task:1', '--expect', created.digest, '--business', 'demo'], s); assert.equal(r.code, 2, r.err); assert.match(r.err, /^tasks archive refused \(task-conflict\): the task changed after you read it/); assert.deepEqual(row(), before); s.calls.splice(0); r = await run(['tasks', 'archive', 'task:1', '--expect', before.digest, '--business', 'demo'], s); assert.equal(r.code, 0, r.err); assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'archive', task_ref: 'task:1', expect: before.digest })]); assert.match(r.out, /^archived task:1, digest [0-9a-f]{64}\n$/); // What the board refuses with 400 the terminal refuses as usage (exit 4), with the board's words, // before anything is sent. for (const [argv, body] of [ [['tasks', 'create', '--title', 'x', '--business', 'demo'], { op: 'create', title: 'x' }], [['tasks', 'edit', 'task:1', '--expect', 'nope', '--title', 'y', '--business', 'demo'], { op: 'edit', task_ref: 'task:1', expect: 'nope', title: 'y' }], [['tasks', 'archive', 'task:1', '--business', 'demo'], { op: 'archive', task_ref: 'task:1' }], ]) { let boardError; try { parseTaskWrite({ org: 'demo', ...body }); } catch (e) { boardError = e; } assert.equal(boardError?.status, 400, JSON.stringify(body)); r = await run(argv, s); assert.equal(r.code, 4, argv.join(' ')); assert.ok(r.err.includes(boardError.message), `${r.err} / ${boardError.message}`); } assert.deepEqual(s.calls, []); // Terminal-only slips the board cannot make are usage too: an option-looking word is not a value, // and the organization is named (exit 4 even inside an agent run). for (const [argv, env] of [ [['tasks', 'create', '--title', '--json', '--requirement', 'REQ-TASK-1', '--business', 'demo'], humanEnv()], [['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1'], { ...humanEnv(), CLAUDECODE: '1' }], ]) { const y = io(env); await assert.rejects(main(argv, y, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 4, argv.join(' ')); } assert.deepEqual(s.calls, []); // A marker only in this process's own environment counts too. const savedMarker = process.env.CLAUDECODE; process.env.CLAUDECODE = '1'; try { const y = io(humanEnv()); delete y.env.CLAUDECODE; await assert.rejects(main(['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1', '--business', 'demo'], y, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 3 && /outside any agent run/.test(e.message)); } finally { if (savedMarker === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = savedMarker; } assert.deepEqual(s.calls, []); // Inside an agent run the write is refused before anything is sent. const x = io({ ...humanEnv(), CLAUDECODE: '1' }); await assert.rejects(main(['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1', '--business', 'demo'], x, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 3); assert.deepEqual(s.calls, []); });