REQ-SETUP-2 (PRD 1.20 draft), unified workspace direction ruling 14 and phase F2. - packages/project/templates/onboarding-interview.json: the interview's conversation name, four questions, draft paths and first message. loadInterviewTemplate(url) reads it and validateInterviewTemplate checks it, so an organization file or a plug-in can pass a replacement later. - approveNorthStarDraft: agents never write spec/, so the assistant proposes docs/north-star-draft.md with its existing record.write grant and the human approves. The approval copies the exact reviewed draft commit into spec/north-star.md through the human save verb, pinned to the reviewed spec commit. A rewritten draft or a moved spec writes nothing. No new verb, grant or role change. - mosaic project north-star <project> [--approve <draft> --base <spec>] shows the spec and the draft and approves as the human; mosaic project interview <project> prints the first message; mosaic thread create takes --instance.
138 lines
8.2 KiB
JavaScript
138 lines
8.2 KiB
JavaScript
// 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' }],
|
|
]);
|
|
});
|