Files
stack/packages/cli/tests/instances.test.mjs

393 lines
25 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.
// Row 194 (#1692, D3): it boots from the file through bootConfig, as the unit does, so a create goes
// live with no restart. A stub boot whose credential references differ from the file still answers
// restart needed.
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 { CliError } from "../src/errors.mjs";
import { busExit } from "../src/transport.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
import { main, run } from "../src/cli.mjs";
import { bootConfig, loadSystem } from "../src/config.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));
// hook(verb, args, bus), when given, runs before each call reaches the broker (row 194: a test moves a
// request on as the launcher's pass would).
async function setup(t, { stub = false, hook = null } = {}) {
const root = tmp(t, "mosaic-cli-instances-");
const f = fixture(root, "acme");
const dir = join(root, "config");
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const bus = await startBroker({
dataRoot: f.dataRoot,
businesses: stub
? { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }
: boot.businesses,
organization: stub ? { dir, rolesDir: f.roles, system: systemFor(root) } : boot.organization,
});
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 });
await hook?.(verb, args, bus);
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, bus };
}
test("row 165 CLI: instance add puts an instance in the file, validate exits 0, the log names the human, and the instance binds with no restart (row 194, T1)", 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, added with store references and a restart: .*\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}`,
"no restart: the launcher reads the change at its next launch check", "",
]);
assert.ok(s.bus.broker.bindLaunch({ business: "acme", role: "sec-1", run: "run-1", harness: "pi" }), "the running bus binds the new instance");
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 194 CLI: a bus whose boot credential references differ from the file answers restart needed, and doesn't bind the instance (D3 item 3)", async (t) => {
const s = await setup(t, { stub: true });
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);
assert.equal(added.out.split("\n").at(-2), 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");
assert.throws(() => s.bus.broker.bindLaunch({ business: "acme", role: "sec-1", run: "run-1", harness: "pi" }), { code: "unknown-role" });
// Row 194 b6 (webby-0094 B1): a veto on the same bus prints the restart line too.
const kept = await s.mosaic(["veto", "acme", "keep", "sec-1", "--revision", await s.revision()]);
assert.equal(kept.code, 0, kept.err);
assert.equal(kept.out.split("\n").at(-2), RESTART_NEEDED);
});
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 = [
// Row 194 (brief item 7, sage-1610 a): a vikunja definition needs --bot and --bot-id.
[["instance", "add", "acme", "reviewer-2", "--definition", "reviewer", "--revision", rev], /^mosaic: organization-tracker-required: the definition uses vikunja; add needs --bot BOT --bot-id N/],
[["instance", "add", "acme", "reviewer-2", "--definition", "reviewer", "--bot", "bot-acme-reviewer", "--bot-id", "40", "--revision", rev], /^mosaic: organization-tracker-bot-taken: /],
[["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, instance and (row 194) veto 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/);
// Row 194: veto goes to packages/cli too.
const veto = spawnSync(MOSAIC, ["business", "veto"], { env, encoding: "utf8" });
assert.equal(veto.status, 4);
assert.match(veto.stderr, /\| business veto BUSINESS keep\|unkeep INSTANCE/);
const business = spawnSync(MOSAIC, ["business", "validate"], { env, encoding: "utf8" });
assert.doesNotMatch(business.stderr, /mosaic business instances BUSINESS/);
});
test("row 194 CLI V6: veto keep, unkeep, nohire and hire write through veto.set, the read shows kept and noHire, and a no-op refuses", async (t) => {
const s = await setup(t);
const kept = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
assert.equal(kept.code, 0, kept.err);
assert.deepEqual(s.calls.at(-1), { business: "acme", verb: "veto.set", args: { revision: s.calls.at(-1).args.revision, op: "keep", name: "coder" } });
const entry = s.log().at(-1);
assert.deepEqual(kept.out.split("\n"), ["business acme: veto keep coder", `revision ${await s.revision()}`, `transaction ${entry.transaction}`,
"no restart: the launcher reads the change at its next launch check", ""]);
assert.equal(entry.actor, "jason");
const nohire = await s.mosaic(["veto", "acme", "nohire", "merge-gate", "--revision", await s.revision()]);
assert.equal(nohire.code, 0, nohire.err);
const view = await s.mosaic(["instances", "acme"]);
assert.match(view.out, /^ {2}coder: coder \(launched, kept\)/m);
assert.match(view.out, /^the chief may not hire: merge-gate$/m);
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
assert.deepEqual([json.instances.coder.kept, json.noHire, json.retired], [true, ["merge-gate"], []]);
// A kept instance refuses delete at the verb.
const del = await s.mosaic(["instance", "delete", "acme", "coder", "--revision", await s.revision()]);
assert.equal(del.code, 2);
assert.match(del.err, /^mosaic: organization-instance-kept: the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first$/m);
const again = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
assert.equal(again.code, 2);
assert.match(again.err, /^mosaic: organization-no-change: /);
for (const [op, name] of [["unkeep", "coder"], ["hire", "merge-gate"]]) {
const r = await s.mosaic(["veto", "acme", op, name, "--revision", await s.revision()]);
assert.equal(r.code, 0, r.err);
}
const after = JSON.parse(readFileSync(s.file, "utf8"));
assert.deepEqual([after.launch.keep, after.launch.noHire], [[], []]);
});
test("row 194 CLI: the read lists each instance's running run from the bus claims", async (t) => {
const s = await setup(t);
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
assert.deepEqual([json.instances.coder.running, json.instances.reviewer.running], [["run-c1"], []]);
assert.match((await s.mosaic(["instances", "acme"])).out, /^ {2}coder: coder \(launched, running run-c1\)/m);
});
// The launcher's pass, played by the test: on the first poll the request is taken, then closed with `done`.
const closing = (done) => (verb, args, bus) => {
if (verb !== "instance.request") return;
if (bus.lifecycleTake({ business: "acme", request: args.request }).go && done) bus.lifecycleDone({ business: "acme", request: args.request, ...done(bus, args.request) });
};
test("row 194 CLI D1.1 item 5: launch and regenerate poll instance.request and print the run; a done with a refusal exits 3 with its code and the request id", async (t) => {
let done = () => ({ run: "run-new" });
const s = await setup(t, { hook: closing((bus, request) => done(bus, request)) });
const launched = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(launched.code, 0, launched.err);
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
assert.deepEqual(s.calls.find((c) => c.verb === "instance.launch").args, { instance: "reviewer" });
assert.equal(launched.out, `business acme: launched reviewer, run run-new (request ${request})\n`);
assert.match(launched.err, new RegExp(`^mosaic: request ${request}, waiting up to 30 s$`, "m"));
const regen = await s.mosaic(["instance", "regenerate", "acme", "reviewer"]);
assert.equal(regen.code, 0, regen.err);
assert.match(regen.out, /^business acme: regenerated reviewer, run run-new \(request [^)]+\)$/m);
done = () => ({ refusal: "capacity-full" });
const full = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(full.code, 3);
const id = s.calls.filter((c) => c.verb === "instance.request").at(-1).args.request;
assert.match(full.err, new RegExp(`^mosaic: capacity-full: a launch limit is reached; .* \\(request ${id}\\)$`, "m"));
done = () => ({ refusal: "some-new-code" });
const other = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(other.code, 3);
assert.match(other.err, /^mosaic: some-new-code: the launcher refused the request \(request [^)]+\)$/m);
});
test("row 194 CLI D1.1 item 5: an open request at --wait exits 3 with its id, and a taken one with no done is outcome-unknown", async (t) => {
const s = await setup(t);
const open = await s.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "0"]);
assert.equal(open.code, 3);
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
assert.match(open.err, new RegExp(`^mosaic: request ${request} is still open after 0 s; the launcher takes it within its 60 s or closes it request-expired$`, "m"));
const t2 = await setup(t, { hook: closing(null) });
const taken = await t2.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "1"]);
assert.equal(taken.code, 3);
const id = t2.calls.find((c) => c.verb === "instance.request").args.request;
assert.match(taken.err, new RegExp(`^mosaic: outcome-unknown: request ${id} was taken and has not closed after 1 s; the launch log names any run it started$`, "m"));
assert.ok(t2.calls.filter((c) => c.verb === "instance.request").length > 1, "it polled until --wait ran out");
});
test("row 194 CLI: a request cancelled by a terminate exits 3 request-cancelled", async (t) => {
const s = await setup(t, { hook: (verb, args, bus) => {
if (verb === "instance.request") bus.broker.request(bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }), { verb: "instance.end", args: { instance: "reviewer" } });
} });
const r = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(r.code, 3);
assert.match(r.err, /^mosaic: request-cancelled: cancelled by jason \(request [^)]+\)$/m);
});
test("row 194 CLI V3: terminate prints the runs, the cancelled and the taken requests, and nothing held refuses not-held", async (t) => {
const s = await setup(t);
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
const human = s.bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
const open = s.bus.broker.request(human, { verb: "instance.launch", args: { instance: "coder" } }).request;
const taken = s.bus.broker.request(human, { verb: "instance.regenerate", args: { instance: "coder" } }).request;
s.bus.lifecycleTake({ business: "acme", request: taken });
const r = await s.mosaic(["instance", "terminate", "acme", "coder", "--reason", "stuck"]);
assert.equal(r.code, 0, r.err);
assert.deepEqual(s.calls.at(-1).args, { instance: "coder", reason: "stuck" });
const lines = r.out.split("\n");
assert.match(lines[0], /^business acme: ended coder \(request [^)]+\)$/);
assert.deepEqual(lines.slice(1), [
"runs run-c1: claims revoked, the launcher signals each", `cancelled ${open}`,
`taken ${taken}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`, "",
]);
const none = await s.mosaic(["instance", "terminate", "acme", "reviewer"]);
assert.equal(none.code, 2);
assert.match(none.err, /^mosaic: not-held: the instance has no run and no open request$/m);
});
test("row 194 CLI V5: delete polls to the transaction and the read lists the name retired; workspace-rename-failed names the transaction", async (t) => {
let rename = null;
const s = await setup(t, { hook: closing((bus, request) => ({ transaction: bus.lifecycleDelete({ business: "acme", request }).transaction, ...(rename ? { refusal: rename } : {}) })) });
for (const name of ["sec-1", "sec-2"]) {
const added = await s.mosaic(["instance", "add", "acme", name, "--definition", "security-reviewer", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
}
const del = await s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
assert.equal(del.code, 0, del.err);
const entry = s.log().at(-1);
assert.equal(del.out, `business acme: deleted sec-1, now retired\ntransaction ${entry.transaction}\n`);
assert.deepEqual(s.calls.find((c) => c.verb === "instances.set" && c.args.op === "delete").args, { revision: s.calls.find((c) => c.args.op === "delete").args.revision, instance: "sec-1", op: "delete" });
assert.match((await s.mosaic(["instances", "acme"])).out, /^retired: sec-1$/m);
rename = "workspace-rename-failed";
const failed = await s.mosaic(["instance", "delete", "acme", "sec-2", "--revision", await s.revision()]);
assert.equal(failed.code, 3);
assert.match(failed.err, new RegExp(`^mosaic: workspace-rename-failed: .*run the same delete again to retry the rename \\(request [^,]+, transaction ${s.log().at(-1).transaction}\\)$`, "m"));
});
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock. A delete closed after
// that revoke ran says the tokens may already be revoked; a plain revision-conflict keeps its text.
test("row 239 T5 CLI: a delete closed -revoked says the tokens may already be revoked; a plain revision-conflict doesn't", async (t) => {
let refusal = "revision-conflict-revoked";
const s = await setup(t, { hook: closing(() => ({ refusal })) });
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
const del = async () => s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
let r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: revision-conflict-revoked: the business file changed after the agent's tokens were revoked .*; the tokens may already be revoked .*which is safe \(request [^)]+\)$/m);
refusal = "outcome-unknown-revoked";
r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: outcome-unknown-revoked: the agent's tokens may already be revoked and the write's outcome is unknown; .* \(request [^)]+\)$/m);
refusal = "revision-conflict";
r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: revision-conflict: the business file changed since that revision; .* \(request [^)]+\)$/m);
assert.doesNotMatch(r.err, /may already be revoked/);
});
test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call", async (t) => {
const s = await setup(t);
const rev = "a".repeat(64);
for (const args of [
["instance", "launch", "acme"], ["instance", "launch", "acme", "coder", "extra"],
["instance", "launch", "acme", "coder", "--wait", "x"], ["instance", "launch", "acme", "coder", "--wait", "601"],
["instance", "launch", "acme", "coder", "--revision", rev], ["instance", "launch", "acme", "coder", "--reason", "x"],
["instance", "terminate", "acme", "coder", "--wait", "5"], ["instance", "terminate", "acme", "coder", "--revision", rev],
["instance", "delete", "acme", "coder"], ["instance", "delete", "acme", "coder", "--revision", rev, "--tester"],
["instance", "delete", "acme", "coder", "--revision", rev, "--reason", "x"],
["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", rev, "--wait", "5"],
["veto", "acme", "keep", "coder"], ["veto", "acme", "drop", "coder", "--revision", rev],
["veto", "acme", "keep", "--revision", rev], ["veto", "acme", "keep", "coder", "--revision", rev, "--tester"],
["veto", "acme", "keep", "coder", "--revision", rev, "--json"], ["veto"],
]) {
const r = await s.mosaic(args);
assert.equal(r.code, 4, args.join(" "));
}
assert.equal(s.calls.length, 0);
});
test('v1 business instances, instance and veto retain broker proof refusals and leave files untouched', async t => {
const s = await setup(t);
const before = readFileSync(s.file, 'utf8');
const reader = s.bus.broker.bindReader({ business: 'acme' });
const calls = [];
const deps = { system: { dataRoot: s.f.dataRoot }, transport: business => async (verb, args) => {
calls.push({ business, verb, args });
try { return structuredClone(s.bus.broker.request(reader, { verb, args })); }
catch (error) { const e = new CliError(error.code, busExit(error.code)); e.code = error.code; throw e; }
} };
const cases = [
[['instances', 'acme', '--json'], 'instances', {}],
[['instance', 'set', 'acme', 'coder', '--launch', '--revision', 'a'.repeat(64)], 'instances.set', { revision: 'a'.repeat(64), instance: 'coder', op: 'change', launch: true }],
[['veto', 'acme', 'keep', 'coder', '--revision', 'a'.repeat(64)], 'veto.set', { revision: 'a'.repeat(64), op: 'keep', name: 'coder' }],
];
for (const [args, verb, request] of cases) {
const x = io(s.f.env);
assert.equal(await run(['business', ...args], x, deps), 3, args.join(' '));
assert.equal(x.out.text, '');
assert.match(x.err.text, /human-required/);
assert.deepEqual(calls.at(-1), { business: 'acme', verb, args: request });
const count = calls.length;
const agent = io({ ...s.f.env, CODEX_THREAD_ID: 'fixture-agent' });
assert.equal(await run(['business', ...args], agent, deps), 3);
assert.match(agent.err.text, /human shell/);
assert.equal(calls.length, count);
}
assert.equal(readFileSync(s.file, 'utf8'), before);
});