Author and gate owner: Sage (mos-0855). Role files take an optional skills list. The bundle reads each named skill from <dataRoot>/skills-enabled, copies it into the bundle and renders every file into prompt.md between skill-file markers, so the prompt text matches the bundle copy byte for byte. The role's skills together are capped at SKILL_BYTES (64 KiB): the claude adapter passes prompt.md as one argument, and Linux caps one argument at 128 KiB. Symlinks are refused, not followed. The launcher checks the list before a run exists: skill-not-enabled names the activate command, anything else refuses skill-refused. Pi keeps --no-skills; Claude Code keeps --disable-slash-commands and --restricted. The two harness modules and MOSAIC_SKILLS are unchanged. First lists: coder, reviewer, cto and pm, ms-unslop on each.
155 lines
9.1 KiB
JavaScript
155 lines
9.1 KiB
JavaScript
// 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 { 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");
|
|
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/);
|
|
});
|