// F2 (REQ-SETUP-2): mosaic project north-star, mosaic project interview and thread create --instance. The // north-star runs go through a real broker and a real records repository (the bus spec fixture); the // transport is the human's, as callFor gives it in tests. import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { run } from '../src/cli.mjs'; import { io } from './helpers.mjs'; import { fixture, git } from '../../bus/tests/spec-fixtures.mjs'; import { interviewMessage, loadInterviewTemplate, SPEC_PATH } from '../../project/src/onboarding.mjs'; const shipped = name => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8')); const DRAFT = loadInterviewTemplate().drafts.northStar; async function setup(t) { const f = await fixture(t, { roles: { assistant: { definition: 'assistant', authority: shipped('assistant').authority } } }); f.business.definitions = { assistant: { messaging: shipped('assistant').messaging } }; const project = f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' }).id; const thread = f.call(f.human, 'thread.create', { project, name: 'Onboarding interview', instance: 'assistant' }).id; f.assistant = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread }); f.broker.request(f.assistant, { verb: 'role.claim' }); f.calls = []; const transport = business => async (verb, args) => { f.calls.push(verb); assert.equal(business, 'demo'); return structuredClone(await f.call(f.human, verb, args)); }; f.cli = async args => { const x = io(); const code = await run(['project', 'north-star', ...args, '--business', 'demo'], x, { system: { dataRoot: f.root }, transport }); return { code, out: x.out.text, err: x.err.text }; }; f.propose = text => f.call(f.assistant, 'record.write', { project: 'stack', path: DRAFT, record: text }); return f; } test('north-star shows the template spec and says there is no draft yet', async t => { const f = await setup(t); const r = await f.cli(['stack']); assert.equal(r.code, 0, r.err); assert.match(r.out, new RegExp(`^${SPEC_PATH} commit ${f.commit} \\S+ still the template\\n`)); assert.ok(r.out.includes(`${DRAFT}: no draft yet; the onboarding interview on the project page asks the assistant for one`)); assert.deepEqual(f.calls, ['project.file', 'project.file']); }); test('north-star shows the draft and the exact approve command; --approve writes the spec as the human', async t => { const f = await setup(t); const text = '# North star\n\nOnboard new organizations.\n'; const draft = await f.propose(text); const shown = await f.cli(['stack']); assert.equal(shown.code, 0, shown.err); assert.ok(shown.out.includes(`${DRAFT} commit ${draft.commit}`)); assert.ok(shown.out.includes('by assistant.assistant')); assert.ok(shown.out.includes(text)); const command = `mosaic project north-star stack --business demo --approve ${draft.commit} --base ${f.commit}`; assert.ok(shown.out.trimEnd().endsWith(`approve this draft: ${command}`), shown.out); assert.equal(git(f.repo, 'rev-parse', 'HEAD'), draft.commit); const approved = await f.cli(['stack', '--approve', draft.commit, '--base', f.commit]); assert.equal(approved.code, 0, approved.err); const head = git(f.repo, 'rev-parse', 'HEAD'); assert.equal(approved.out, `approved ${SPEC_PATH} commit ${head} from ${DRAFT} ${draft.commit}\n`); assert.equal(git(f.repo, 'show', '-s', '--format=%an', head), 'jason'); assert.equal(git(f.repo, 'show', `HEAD:${SPEC_PATH}`), text.trimEnd()); const after = await f.cli(['stack']); assert.match(after.out, new RegExp(`^${SPEC_PATH} commit ${head} \\S+ by jason\\n`)); }); test('north-star approve refusals: a rewritten draft, a moved spec and bad arguments write nothing', async t => { const f = await setup(t); const first = await f.propose('# First\n'); const second = await f.propose('# Second\n'); const before = git(f.repo, 'rev-parse', 'HEAD'); const stale = await f.cli(['stack', '--approve', first.commit, '--base', f.commit]); assert.equal(stale.code, 2); assert.match(stale.err, /^mosaic: draft-stale: docs\/north-star-draft\.md changed since you reviewed it/); const base = await f.cli(['stack', '--approve', second.commit, '--base', second.commit]); assert.equal(base.code, 2); assert.match(base.err, /^mosaic: stale-base: the north star was not confirmed saved/); assert.equal(git(f.repo, 'rev-parse', 'HEAD'), before); for (const [args, message] of [ [['stack', '--approve', second.commit], /--approve and --base go together/], [['stack', '--base', f.commit], /--approve and --base go together/], [['stack', '--approve', 'abc', '--base', f.commit], /each take a full 40-character commit/], [[], /project north-star takes one records project/], [['a', 'b'], /project north-star takes one records project/], [['../x'], /is not a records project id/], [['stack', '--draft', 'x'], /unknown option --draft/], ]) { const r = await f.cli(args); assert.equal(r.code, 4, JSON.stringify(args)); assert.match(r.err, message); } assert.equal(git(f.repo, 'rev-parse', 'HEAD'), before); }); test('north-star as the human refuses inside an agent, and a bus refusal keeps its exit code', async t => { const f = await setup(t); const x = io({ ...io().env, MOSAIC_RUN_ID: 'r1' }); const refused = await run(['project', 'north-star', 'stack', '--business', 'demo'], x, { system: { dataRoot: f.root }, transport: () => async () => assert.fail('no bus call inside an agent') }); assert.equal(refused, 3); assert.match(x.err.text, /runs only from a human shell/); const denied = io(); const human = async () => { throw Object.assign(new Error('human-required'), { code: 'human-required' }); }; assert.equal(await run(['project', 'north-star', 'stack', '--business', 'demo'], denied, { system: { dataRoot: f.root }, transport: () => human }), 3); // run() adds its other-tree hint after a human-required refusal, as for every bus command. assert.ok(denied.err.text.startsWith('mosaic: human-required\n'), denied.err.text); const missing = io(); const none = async () => { throw Object.assign(new Error('project-not-declared'), { code: 'project-not-declared' }); }; assert.equal(await run(['project', 'north-star', 'other', '--business', 'demo'], missing, { system: { dataRoot: f.root }, transport: () => none }), 2); }); test('project interview prints the first message from the template and contacts no bus', async () => { const x = io(); assert.equal(await run(['project', 'interview', 'alpha'], x, { transport: () => assert.fail('no bus') }), 0); assert.equal(x.out.text, `${interviewMessage(loadInterviewTemplate(), { project: 'alpha' })}\n`); for (const args of [[], ['../x'], ['a', 'b'], ['alpha', '--business', 'demo']]) { const bad = io(); assert.equal(await run(['project', 'interview', ...args], bad, {}), 4, JSON.stringify(args)); } }); test('thread create passes --instance to the bus and leaves it out when not given', async () => { const sent = []; const transport = () => async (verb, args) => { if (verb === 'plugins') return { business: 'demo', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }; if (verb === 'project.list') return [{ id: '11111111-1111-4111-8111-111111111111', name: 'stack work' }]; sent.push([verb, args]); return { id: '22222222-2222-4222-8222-222222222222', instance: args.instance ?? 'assistant' }; }; for (const extra of [['--instance', 'assistant'], []]) { const x = io(); const code = await run(['thread', 'create', 'stack work', 'Onboarding interview', ...extra, '--business', 'demo'], x, { system: { dataRoot: '/nonexistent' }, transport }); assert.equal(code, 0, x.err.text); assert.match(x.out.text, /^created stack work\/Onboarding interview id 22222222-.* instance assistant\n$/); } assert.deepEqual(sent, [ ['thread.create', { project: '11111111-1111-4111-8111-111111111111', name: 'Onboarding interview', instance: 'assistant' }], ['thread.create', { project: '11111111-1111-4111-8111-111111111111', name: 'Onboarding interview' }], ]); });