// Row 165 (#1662, Q171, REQ-ROLE-3): `mosaic business instances` and `mosaic business instance // add|set` reach the broker's human-only verbs, which write through the business package's // instance writer. The bus here is a real startBroker over a temporary organization directory. import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { BusError } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { systemFor, writeJson } from "../../business/tests/helpers.mjs"; import { main, run } from "../src/cli.mjs"; import { RESTART_NEEDED } from "../src/instances.mjs"; import { fixture, io, tmp } from "./helpers.mjs"; const BUSINESS_CLI = fileURLToPath(new URL("../../business/src/cli.mjs", import.meta.url)); const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url)); async function setup(t) { const root = tmp(t, "mosaic-cli-instances-"); const f = fixture(root, "acme"); mkdirSync(f.dataRoot, { mode: 0o700 }); const dir = join(root, "config"); const bus = await startBroker({ dataRoot: f.dataRoot, businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir, rolesDir: f.roles, system: systemFor(root) }, }); t.after(() => bus.close()); const human = bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }); const calls = []; const deps = { system: { dataRoot: f.dataRoot }, transport: (business) => async (verb, args = {}) => { calls.push({ business, verb, args }); try { return structuredClone(bus.broker.request(human, { verb, args })); } catch (e) { if (!(e instanceof BusError)) throw e; const error = new Error(e.code); error.code = e.code; throw error; } }, }; const file = join(dir, "businesses", "acme.json"); const log = () => readFileSync(join(dir, "businesses", ".history", "acme", "changes.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); const validate = () => spawnSync(process.execPath, [BUSINESS_CLI, "validate", "acme"], { env: { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: f.config, MOSAIC_ROLES_DIR: f.roles }, encoding: "utf8", }); const mosaic = async (args, env = f.env) => { const x = io(env); const code = await run(["business", ...args], x, deps); return { code, out: x.out.text, err: x.err.text }; }; const revision = async () => JSON.parse((await mosaic(["instances", "acme", "--json"])).out).revision; return { root, f, file, log, validate, mosaic, revision, calls }; } test("row 165 CLI: instance add puts an instance in the file, validate exits 0, and the log names the human", async (t) => { const s = await setup(t); const view = await s.mosaic(["instances", "acme"]); assert.equal(view.code, 0, view.err); assert.match(view.out, /^business acme, revision [a-f0-9]{64}$/m); // Row 65: the cli fixture gives every instance its route vars, so the view lists them. assert.match(view.out, /^ {2}chief: chief-of-staff \(launcher\) vars \{"harness":"pi","model":"glm-5\.3-flash","thinking":"high"\}$/m); assert.match(view.out, /^definitions add and set accept: .*\bmerge-gate\b.*\bsecurity-reviewer\b/m); assert.match(view.out, /^definitions that declare credentials, a hand edit: .*\breviewer \(gitea, vikunja\)/m); const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--launch", "--revision", await s.revision()]); assert.equal(added.code, 0, added.err); const entry = s.log().at(-1); const now = await s.revision(); assert.deepEqual(added.out.split("\n"), [ "business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`, RESTART_NEEDED, "", ]); assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it"); assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)"); assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it"); const doc = JSON.parse(readFileSync(s.file, "utf8")); assert.deepEqual(doc.roles["sec-1"], { definition: "security-reviewer" }); assert.ok(doc.launch.instances.includes("sec-1")); assert.deepEqual([entry.actor, entry.state], ["jason", "committed"]); const checked = s.validate(); assert.equal(checked.status, 0, checked.stderr); }); test("row 165 CLI: instance set passes the vars file and --no-launch, and a vars-only change needs no restart", async (t) => { const s = await setup(t); const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" }); const changed = await s.mosaic(["instance", "set", "acme", "coder", "--vars", vars, "--no-launch", "--revision", await s.revision()]); assert.equal(changed.code, 0, changed.err); assert.deepEqual(s.calls.at(-1).args, { revision: s.calls.at(-1).args.revision, instance: "coder", op: "change", vars: { harness: "pi", model: "claude-sonnet-5-5" }, launch: false }); assert.match(changed.out, /^business acme: changed coder \(coder\)$/m); assert.match(changed.out, /^no restart: the launcher reads the change at its next launch check$/m); const doc = JSON.parse(readFileSync(s.file, "utf8")); assert.equal(doc.launch.instances.includes("coder"), false); assert.ok(doc.roles.coder.credentials.gitea && doc.roles.coder.tracker, "a vars change keeps the binding"); assert.equal(s.validate().status, 0); }); test("row 165 CLI: a refusal names its code and why, exits 2 and keeps the bytes", async (t) => { const s = await setup(t); const rev = await s.revision(); const before = readFileSync(s.file, "utf8"); const cases = [ [["instance", "add", "acme", "reviewer-2", "--definition", "reviewer", "--revision", rev], /^mosaic: organization-credentials-out-of-scope: this command binds no credentials/], [["instance", "set", "acme", "coder", "--definition", "security-reviewer", "--revision", rev], /^mosaic: organization-credentials-out-of-scope: /], [["instance", "add", "acme", "coder", "--definition", "merge-gate", "--revision", rev], /^mosaic: organization-instance-exists: /], [["instance", "set", "acme", "ghost", "--launch", "--revision", rev], /^mosaic: organization-unknown-instance: /], [["instance", "add", "acme", "asst-2", "--definition", "assistant", "--revision", rev], /^mosaic: organization-definition-refused: this command neither adds nor changes an assistant, and moves no instance to the assistant definition;/], [["instance", "add", "acme", "gate-1", "--definition", "merge-gate", "--revision", "b".repeat(64)], /^mosaic: revision-conflict: the business file changed/], ]; for (const [args, message] of cases) { const r = await s.mosaic(args); assert.equal(r.code, 2, args.join(" ")); assert.match(r.err, message); } assert.equal(readFileSync(s.file, "utf8"), before); }); test("row 165 CLI: usage errors exit 4 before any bus call, and an agent session is refused", async (t) => { const s = await setup(t); const rev = "a".repeat(64); for (const args of [ ["instances"], ["instances", "acme", "extra"], ["instances", "acme", "--launch"], ["instance", "add", "acme", "sec-1", "--definition", "security-reviewer"], ["instance", "add", "acme", "sec-1", "--revision", rev], ["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--no-launch", "--revision", rev], ["instance", "set", "acme", "coder", "--revision", rev], ["instance", "set", "acme", "coder", "--launch", "--no-launch", "--revision", rev], ["instance", "set", "acme", "coder", "--vars", join(s.root, "missing.json"), "--revision", rev], ["instance", "remove", "acme", "coder", "--revision", rev], ["instance", "set", "acme", "coder", "--holder", "mos", "--revision", rev], ]) { const r = await s.mosaic(args); assert.equal(r.code, 4, args.join(" ")); } assert.equal(s.calls.length, 0); const agent = await s.mosaic(["instances", "acme"], { ...s.f.env, CODEX_THREAD_ID: "fixture-agent" }); assert.equal(agent.code, 3); assert.match(agent.err, /agent/i); assert.equal(s.calls.length, 0); await assert.rejects(main(["business", "validate", "acme"], io(s.f.env), {}), /mosaic business instances/); }); test("row 165: scripts/mosaic sends business instances and instance to packages/cli, and validate still to packages/business", (t) => { const root = tmp(t, "mosaic-cli-route-"); const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") }; const routed = spawnSync(MOSAIC, ["business", "instance"], { env, encoding: "utf8" }); assert.equal(routed.status, 4); assert.match(routed.stderr, /mosaic business instances BUSINESS/); const business = spawnSync(MOSAIC, ["business", "validate"], { env, encoding: "utf8" }); assert.doesNotMatch(business.stderr, /mosaic business instances BUSINESS/); });