Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
156 lines
9.1 KiB
JavaScript
156 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 { 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/);
|
|
});
|