From 06226807567ec1d4147d1487d2cddd7daa2f772c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 20 Oct 2026 16:56:26 -0500 Subject: [PATCH] honker: row 194 S3b cli: business instance launch, regenerate, terminate and delete with --wait polling instance.request, business veto, the instances read gains running, every lifecycle outcome but done exits 3 with the request id, the CLI end to end through the pass, T2a, T4 unbound and mismatched runs, T6 evidence bytes, T11 two roots and two businesses on one bus --- agents/honker/work/queue-194/BUILD.md | 49 +++++- packages/bus/src/broker.mjs | 4 + packages/bus/tests/chief-of-staff.test.mjs | 29 ++++ packages/cli/src/cli.mjs | 2 +- packages/cli/src/instances.mjs | 139 ++++++++++++++++- packages/cli/tests/instances.test.mjs | 164 +++++++++++++++++++- packages/cli/tests/lifecycle.test.mjs | 172 ++++++++++++++++++++- scripts/mosaic | 4 +- 8 files changed, 547 insertions(+), 16 deletions(-) diff --git a/agents/honker/work/queue-194/BUILD.md b/agents/honker/work/queue-194/BUILD.md index 5b6d09101..3c3574fa1 100644 --- a/agents/honker/work/queue-194/BUILD.md +++ b/agents/honker/work/queue-194/BUILD.md @@ -23,7 +23,7 @@ brief commit). Slices that don't need row 191 build first. S6 waits for row 191' |---|---|---|---| | S1 | business: vocabulary (role.hire, role.fire hard waits, HIRE_ALLOWED), role.mjs `hireable`, business.mjs keep/noHire/retired, instance-settings delete and veto ops, chief-of-staff.json `hireable: []` | T6 (business), T7, T8c, T17, D2.2 writer | done, see below | | S2 | bus: reloadBusiness and runtime callback, instance.launch/end/regenerate requests, instances.set delete, veto.set, instance.hire/fire, keep checks, D4 hook, tester checks | T8-T10, T9c, T12-T14, T18 | S2a (D3), S2b (veto, hook, keep, T13), S2c (requests, terminate, delete) and S2d (hire, fire, W-N1) and S2e (PRD 1.7 routing, sage-1471) done | -| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | S3a (launcher pass) done, S3b (verbs) next | +| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | S3a (launcher pass) and S3b (verbs, veto, end to end, T2a, T4, T6, T11) done | | S4 | harness: launch tool schema, hire and fire tools | T8 (D3 item 5) | | | S5 | webui: Agents section and routes | T16 | | | S6 | row 191 chain: halted checks, fire SIGKILL through row 191's stop | T9a, T9b, T15 | waits on row 191 | @@ -330,3 +330,50 @@ Listed here as the build changes them. - Singles at S3a: seat sandbox 10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, bus lifecycle-verbs 12/12, cli instances 6/6, cli lifecycle 9/9, bus process 8/8. broker.mjs imports did not change, and the four must-run files ran anyway. + +## S3b notes (the CLI verbs) + +- `packages/cli/src/instances.mjs`: `business instance launch|regenerate BUSINESS I [--wait S] + [--tester]`, `terminate BUSINESS I [--reason TEXT] [--tester]`, `delete BUSINESS I --revision REV + [--wait S]` and `business veto BUSINESS keep|unkeep INSTANCE|nohire|hire DEFINITION --revision + REV`. `--wait` takes 0 to 600 s and defaults to 30. Every usage error exits 4 before any bus call. + add and set refuse `--wait` and `--reason`. +- Launch, regenerate and delete print the request id on stderr, then poll `instance.request` every + 200 ms. Closed with a run or a transaction prints it and exits 0. A refusal prints its reason, the + request id and any transaction. A cancel prints who cancelled it. Still open at `--wait` says so and + names the 60 s expiry. Taken with no done is `outcome-unknown` and points at the launch log. + Terminate doesn't wait: it prints the revoked runs, the cancelled requests and any taken request + past cancelling. +- Exit codes: every lifecycle outcome that isn't done (refusal, cancel, still open, outcome unknown) + exits 3, as the design's `--wait` timeout does. Tradeoff: errors.mjs has 1 for an unknown outcome + elsewhere. One code lets a script treat "the request did not do it" as one case, and the message + says which. A refusal at the verb keeps the transport's code (2), as add and set do. +- The bus `instances` read gains `running` per instance (`#running`, from the claims), so the CLI + and S5 can show it without a second verb. An answer with no instances map passes through as + before. `bus instances-verb.test.mjs`'s stub writer answers `{ok: true}` and caught the first + version, which threw. The CLI view marks `kept` and `running r1, r2` and adds `retired:` and + `the chief may not hire:` lines. +- `scripts/mosaic` sends `business veto` to packages/cli with instances and instance. +- Tests: cli `instances.test.mjs` +8: the V6 veto ops and kept-delete refusal, the `running` read, + launch and regenerate printing the run, a closed refusal with its id (exit 3), `--wait 0` still + open and taken `outcome-unknown`, request-cancelled, terminate output and `not-held`, delete to + its transaction and `workspace-rename-failed` naming it, usage errors (exit 4, no call), and the + scripts/mosaic veto route. The existing route test gains the veto assertions. cli + `lifecycle.test.mjs` +5: the CLI end to end through the pass (launch, regenerate, `capacity-full` + with the request id, terminate, delete), T2a (`launch-revoked` at the take with no process, a + done that fails once leaves the request taken and the next pass starts nothing), T4 (a spawned + run the bus never bound is signalled, a mismatched start time is not), T6 (run evidence bytes + unchanged by a delete, the launch log only grows, a create and a launch of the retired name + refuse), T11 (two roots with business `acme`: a launch, a terminate and a delete in one leave the + other's files byte-identical). bus `chief-of-staff.test.mjs` +1: T11's bus half, a chief on a bus + with two businesses hires and fires only in its own. +- Not covered here: T6's `instance-starting` and the V5 refusal order are bus tests from S2c. + `check()` refusing a retired name is shown through the verb and the take, since `check()` reads a + file that no longer lists the name. +- Singles at S3b: cli instances 14/14, cli lifecycle 14/14, bus lifecycle-verbs 12/12, bus + instances-verb 3/3 (after the fix), bus chief-of-staff 40/40, webui instances 5/5, seat sandbox + 10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, usage 2/2, project 4/4, app + 30/30, host 42/42, tester-threads 5/5, tester-reads 6/6, stack-copy 12/12, queue dispatch 4/4, + runs task-cli 9/9, webui pinned-copy 10/10, service 31/31, views 3/3. The broker change adds no + import. The four must-run files ran while the `instances` guard was being written, and the guard + touches only the read. No test pins a hash of scripts/mosaic. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index d6b303ec2..14866cb9b 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1538,6 +1538,10 @@ export class Broker { } if (role && !revisionRead) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change")); this.#secretCheck(result); + // Row 194 (#1692, D1): the read gains each instance's running runs, from the bus claims. An + // answer with no instances map passes through as it did before. + if (request.verb === "instances" && result?.instances && typeof result.instances === "object") + for (const [name, entry] of Object.entries(result.instances)) entry.running = this.#running(s.business, name); return result; } return this.#store.transaction(() => { diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs index 719969caa..798d95a97 100644 --- a/packages/bus/tests/chief-of-staff.test.mjs +++ b/packages/bus/tests/chief-of-staff.test.mjs @@ -752,6 +752,35 @@ test('row 194 T8: the chief hires with a reason and no decision, the writer gets assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } }); }); +// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own +// business's writer, and no argument names another. +test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => { + const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-')); + const store = new Store(root); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const organization = []; + const one = demo(LISTED).demo; + const b = new Broker({ + store, + businesses: { demo: one, other: { ...one, id: 'other' } }, + roleFile: roleFile(ROLE_FILES), + organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }), + }); + const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }); + b.request(chief, { verb: 'role.claim' }); + for (const verb of ['instance.hire', 'instance.fire']) + for (const extra of [{ business: 'other' }, { target: 'other' }]) + assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`); + assert.equal(organization.length, 0); + b.request(chief, { verb: 'instance.hire', args: HIRE }); + b.request(chief, { verb: 'instance.fire', args: FIRE }); + assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fire']]); + assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0); +}); + test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => { const { call, organization } = hireSetup(t); for (const reason of [undefined, '', 'x'.repeat(501), 7]) { diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 10ba53e95..f2cd211e2 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -576,7 +576,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin, if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps)); if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps)); if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps)); - // Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here. + // Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here. if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps)); if (verb === "talk") return talk(rest, io, deps); if (verb === "follow") return talk(rest, io, deps, true); diff --git a/packages/cli/src/instances.mjs b/packages/cli/src/instances.mjs index db7d7f052..bd5470e5b 100644 --- a/packages/cli/src/instances.mjs +++ b/packages/cli/src/instances.mjs @@ -3,12 +3,19 @@ // and instances.set verbs, and with --tester the tester does (row 175, #1673, Q173). The broker // refuses an agent and a reader; the business package's instance writer refuses a stale revision // and every document `mosaic business validate` would refuse, and logs the change with its actor. +// Row 194 (#1692, D1): `business instance launch|terminate|regenerate|delete` and `business veto`. +// Launch, regenerate and delete are requests the launcher's pass carries out. The command polls +// instance.request until the request closes or --wait runs out (D1.1 item 5). import { readFileSync } from "node:fs"; import { CliError } from "./errors.mjs"; export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " + "business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] | " + - "business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]"; + "business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester] | " + + "business instance launch|regenerate BUSINESS INSTANCE [--wait SECONDS] [--tester] | " + + "business instance terminate BUSINESS INSTANCE [--reason TEXT] [--tester] | " + + "business instance delete BUSINESS INSTANCE --revision REV [--wait SECONDS] | " + + "business veto BUSINESS keep|unkeep INSTANCE --revision REV | business veto BUSINESS nohire|hire DEFINITION --revision REV"; // The bus answers a refusal with its code alone, so the reason a human reads is here. export const REASONS = { @@ -23,8 +30,32 @@ export const REASONS = { "organization-project-mismatch": "a project file's id differs from its key in the business file; `mosaic business validate` shows it", "organization-unresolvable-instance": "an instance wouldn't resolve against a project file; `mosaic business validate` shows why", "revision-conflict": "the business file changed since that revision; read it again with `mosaic business instances`", + // Row 194 (#1692): the lifecycle and veto refusals. + "organization-instance-referenced": "the instance is launch.by or an arbiter; change that reference first", + "organization-instance-kept": "the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first", + "organization-instance-retired": "that name was deleted and stays retired; pick another name", + "instance-running": "the instance has a run; end it with `mosaic business instance terminate` first", + "instance-starting": "a request for the instance has not closed yet; wait for it and try again", + "instance-retired": "that name was deleted and launches no more", + "not-thread-launch": "a thread instance starts on a thread message, not by launch", + "regenerate-thread": "a thread instance isn't regenerated; its context is the thread's", + "not-held": "the instance has no run and no open request", + "launch-revoked": "launches are revoked for the business; `mosaic launches on --business BUSINESS` lifts it", + "launches-disabled": "the bus host was started with --no-launch", + "request-expired": "the launcher didn't take the request within its 60 s", + "regenerate-timeout": "the old run didn't exit within the request's 60 s, so no new run started; it still runs", + "workspace-rename-failed": "the business file is changed and the name retired, but the workspace stayed where it was; " + + "run the same delete again to retry the rename", + "capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them", + "limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them", + "unknown-role": "the business has no such instance", }; +const LIFECYCLE_OPS = ["launch", "terminate", "regenerate", "delete"]; +const VETO_OPS = ["keep", "unkeep", "nohire", "hire"]; +const WAIT_SECONDS = 30; +const POLL_MS = 200; + export const RESTART_NEEDED = "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it"; const NO_RESTART = "no restart: the launcher reads the change at its next launch check"; @@ -35,7 +66,7 @@ function parse(args) { if (["--json", "--launch", "--no-launch", "--tester"].includes(arg)) { if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4); out.flags.add(arg); - } else if (["--definition", "--vars", "--revision"].includes(arg)) { + } else if (["--definition", "--vars", "--revision", "--wait", "--reason"].includes(arg)) { if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(INSTANCES_USAGE, 4); out.values[arg] = args[++i]; } else if (arg.startsWith("--")) throw new CliError(INSTANCES_USAGE, 4); @@ -48,6 +79,7 @@ function parse(args) { function setArgs(parsed) { const [, op, , instance] = parsed.positional; const { values, flags } = parsed; + if (values["--wait"] !== undefined || values["--reason"] !== undefined) throw new CliError(INSTANCES_USAGE, 4); const launch = flags.has("--launch") ? true : flags.has("--no-launch") ? false : undefined; if (parsed.positional.length !== 4 || !["add", "set"].includes(op) || !values["--revision"] || flags.has("--json") || (flags.has("--launch") && flags.has("--no-launch")) || (op === "add" && (!values["--definition"] || launch === false)) || @@ -67,10 +99,89 @@ function setArgs(parsed) { return args; } +// Row 194: the lifecycle and veto args, or null when `instance OP` is add or set. Usage errors exit 4. +function lifecycleArgs(parsed) { + const { positional, values, flags } = parsed; + const [sub, op, , name] = positional; + const only = (allowed) => Object.keys(values).every((k) => allowed.includes(k)) && [...flags].every((f) => allowed.includes(f)); + let wait = WAIT_SECONDS; + if (values["--wait"] !== undefined) { + if (!/^[0-9]{1,3}$/.test(values["--wait"]) || Number(values["--wait"]) > 600) throw new CliError(INSTANCES_USAGE, 4); + wait = Number(values["--wait"]); + } + if (sub === "veto") { + const [, , vop, target] = positional; + if (positional.length !== 4 || !VETO_OPS.includes(vop) || !values["--revision"] || !only(["--revision"])) throw new CliError(INSTANCES_USAGE, 4); + return { verb: "veto.set", args: { revision: values["--revision"], op: vop, name: target } }; + } + if (!LIFECYCLE_OPS.includes(op)) return null; + if (positional.length !== 4) throw new CliError(INSTANCES_USAGE, 4); + if (op === "terminate") { + if (!only(["--reason", "--tester"])) throw new CliError(INSTANCES_USAGE, 4); + return { verb: "instance.end", args: { instance: name, ...(values["--reason"] !== undefined ? { reason: values["--reason"] } : {}) } }; + } + if (op === "delete") { + if (!values["--revision"] || !only(["--revision", "--wait"])) throw new CliError(INSTANCES_USAGE, 4); + return { verb: "instances.set", args: { revision: values["--revision"], instance: name, op: "delete" }, wait }; + } + if (!only(["--wait", "--tester"])) throw new CliError(INSTANCES_USAGE, 4); + return { verb: `instance.${op}`, args: { instance: name }, wait }; +} + +// D1.1 item 5: polls instance.request until the request closes or `wait` seconds pass. A closed +// request with a refusal or a cancel is exit 3 (refused) with its code. Still open or taken at the +// end is exit 3 with the request id, as the design's timeout is: a taken request with no done is +// outcome-unknown. +async function follow(call, request, wait) { + const end = Date.now() + wait * 1000; + for (;;) { + const r = await call("instance.request", { request }); + if (r.state === "closed") { + if (r.cancelled) throw refusal("request-cancelled", `cancelled by ${r.cancelled.by} (request ${request})`); + if (r.done?.refusal) { + const kept = r.done.transaction ? `, transaction ${r.done.transaction}` : ""; + throw refusal(r.done.refusal, `${REASONS[r.done.refusal] ?? "the launcher refused the request"} (request ${request}${kept})`); + } + return r; + } + if (Date.now() >= end) { + const error = r.state === "taken" + ? new CliError(`outcome-unknown: request ${request} was taken and has not closed after ${wait} s; the launch log names any run it started`, 3) + : new CliError(`request ${request} is still open after ${wait} s; the launcher takes it within its 60 s or closes it request-expired`, 3); + error.code = r.state === "taken" ? "outcome-unknown" : "request-open"; + error.final = true; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_MS, Math.max(0, end - Date.now())))); + } +} + +// `final`: the catch below passes it on as it is, with its request id. +function refusal(code, why) { + const error = new CliError(`${code}: ${why}`, 3); + error.code = code; + error.final = true; + return error; +} + +function lifecycleLine(business, op, instance, r) { + if (op === "delete") return `business ${business}: deleted ${instance}, now retired\ntransaction ${r.done.transaction}\n`; + return `business ${business}: ${op === "launch" ? "launched" : "regenerated"} ${instance}, run ${r.done.run} (request ${r.request})\n`; +} + +function terminateLines(business, instance, out) { + const lines = [`business ${business}: ended ${instance} (request ${out.request})`]; + lines.push(out.runs.length ? `runs ${out.runs.join(", ")}: claims revoked, the launcher signals each` : "no run held a claim"); + if (out.cancelled.length) lines.push(`cancelled ${out.cancelled.join(", ")}`); + if (out.taken.length) lines.push(`taken ${out.taken.join(", ")}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`); + return `${lines.join("\n")}\n`; +} + export function formatInstances(view) { const lines = [`business ${view.business}, revision ${view.revision}`]; for (const [name, entry] of Object.entries(view.instances)) { - const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected"].filter(Boolean); + const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected", entry.kept && "kept", + entry.running?.length && `running ${entry.running.join(", ")}`].filter(Boolean); const vars = Object.keys(entry.vars).length ? ` vars ${JSON.stringify(entry.vars)}` : ""; lines.push(` ${name}: ${entry.definition}${marks.length ? ` (${marks.join(", ")})` : ""}${vars}`); } @@ -79,6 +190,8 @@ export function formatInstances(view) { const bound = definitions.filter(([, d]) => d.services.length).map(([name, d]) => `${name} (${d.services.join(", ")})`); lines.push(`definitions add and set accept: ${accepted.join(", ") || "none"}`); if (bound.length) lines.push(`definitions that declare credentials, a hand edit: ${bound.join(", ")}`); + if (view.retired?.length) lines.push(`retired: ${view.retired.join(", ")}`); + if (view.noHire?.length) lines.push(`the chief may not hire: ${view.noHire.join(", ")}`); return `${lines.join("\n")}\n`; } @@ -86,16 +199,29 @@ export function formatInstances(view) { export async function instancesCommand(args, io, callFor) { const parsed = parse(args); const sub = parsed.positional[0]; - const business = parsed.positional[sub === "instances" ? 1 : 2]; - if (!["instances", "instance"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4); + const business = parsed.positional[sub === "instance" ? 2 : 1]; + if (!["instances", "instance", "veto"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4); let request; - if (sub === "instances") { + const lifecycle = sub === "instances" ? null : lifecycleArgs(parsed); + if (lifecycle) { + // veto and the lifecycle verbs, below. + } else if (sub === "instances") { if (parsed.positional.length !== 2 || Object.keys(parsed.values).length || parsed.flags.has("--launch") || parsed.flags.has("--no-launch")) { throw new CliError(INSTANCES_USAGE, 4); } } else request = setArgs(parsed); try { const call = callFor({ positional: parsed.positional, flags: parsed.flags, values: { "--business": business } }); + if (lifecycle) { + const out = await call(lifecycle.verb, lifecycle.args); + if (sub === "veto") { + return io.stdout.write(`business ${out.business}: veto ${lifecycle.args.op} ${lifecycle.args.name}\nrevision ${out.revision}\ntransaction ${out.transaction}\n`); + } + const op = parsed.positional[1]; + if (op === "terminate") return io.stdout.write(terminateLines(business, lifecycle.args.instance, out)); + io.stderr.write(`mosaic: request ${out.request}, waiting up to ${lifecycle.wait} s\n`); + return io.stdout.write(lifecycleLine(business, op, lifecycle.args.instance, await follow(call, out.request, lifecycle.wait))); + } if (!request) { const view = await call("instances", {}); return io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(view, null, 2)}\n` : formatInstances(view)); @@ -109,6 +235,7 @@ export async function instancesCommand(args, io, callFor) { result.restart ? RESTART_NEEDED : NO_RESTART, ].join("\n") + "\n"); } catch (error) { + if (error.final) throw error; const code = error.code; if (Object.hasOwn(REASONS, code)) { const refused = new CliError(`${code}: ${REASONS[code]}`, error.exitCode ?? 2); diff --git a/packages/cli/tests/instances.test.mjs b/packages/cli/tests/instances.test.mjs index 3dd4697b1..a866e41cd 100644 --- a/packages/cli/tests/instances.test.mjs +++ b/packages/cli/tests/instances.test.mjs @@ -21,7 +21,9 @@ 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, { stub = false } = {}) { +// 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"); @@ -40,6 +42,7 @@ async function setup(t, { stub = false } = {}) { 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) { @@ -160,12 +163,169 @@ test("row 165 CLI: usage errors exit 4 before any bus call, and an agent session 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) => { +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}`, ""]); + 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")); +}); + +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); +}); diff --git a/packages/cli/tests/lifecycle.test.mjs b/packages/cli/tests/lifecycle.test.mjs index d30f6be3d..149d66c2c 100644 --- a/packages/cli/tests/lifecycle.test.mjs +++ b/packages/cli/tests/lifecycle.test.mjs @@ -9,8 +9,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { launchDoc } from "../../business/tests/helpers.mjs"; @@ -19,10 +20,11 @@ import { startBroker } from "../../bus/src/runtime.mjs"; import { gitEnv } from "../../seat/src/checkout.mjs"; import { sandboxProblem } from "../../seat/src/sandbox.mjs"; import { launchLogFile, readSessions, SESSION_CREDENTIALS, workspaceDir } from "../../seat/src/session.mjs"; +import { run } from "../src/cli.mjs"; import { bootConfig, loadSystem } from "../src/config.mjs"; import { startTimeOf } from "../src/host.mjs"; import { createLauncher } from "../src/launcher.mjs"; -import { fakeHarnesses, fixture, tmp } from "./helpers.mjs"; +import { fakeHarnesses, fixture, io, tmp } from "./helpers.mjs"; const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url)); const skip = sandboxProblem() ?? false; @@ -101,12 +103,15 @@ async function start(t, { f, adapter }, options = {}) { }; const hooks = []; let closing = null; + // A host op named here throws once, as a bus that refused it would. + const faults = new Set(); const host = { business: "acme", path: runtime.path, dataRoot: f.dataRoot, op: refusing("launch-op", (m) => { if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request"); + if (faults.delete(m.op)) throw new BusError("storage-refused"); return ops[m.op](m); }), bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)), @@ -146,7 +151,26 @@ async function start(t, { f, adapter }, options = {}) { const file = join(f.dataRoot, "launches", "acme", run, "runner.log"); return existsSync(file) && readFileSync(file, "utf8").includes("claimed by run"); }); - return { runtime, launcher, call, state, carry, running, claimed, logs, close }; + // The CLI through the in-process human. Each poll of instance.request runs one pass first, so a + // --wait sees the request closed as the launcher's ticker would close it. + const cli = async (args) => { + const x = io(f.env); + const deps = { + system: { dataRoot: f.dataRoot }, + transport: () => async (verb, a = {}) => { + if (verb === "instance.request") await launcher.tick(); + try { + return structuredClone(call(verb, a)); + } catch (e) { + if (!(e instanceof BusError)) throw e; + throw Object.assign(new Error(e.code), { code: e.code }); + } + }, + }; + const code = await run(["business", ...args], x, deps); + return { code, out: x.out.text, err: x.err.text }; + }; + return { runtime, launcher, call, state, carry, running, claimed, logs, close, faults, cli }; } const launchLog = (f) => @@ -340,3 +364,143 @@ test("row 194 V5: a delete with no workspace commits and closes with the transac assert.ok(r.done.transaction); assert.equal(existsSync(join(workspaceDir(p.f.dataRoot, "acme", ".retired"))), false); }); + +// Every regular file under root, by path, as a sha256. Sockets and other special files are left out. +function snapshot(root) { + const out = {}; + const walk = (dir) => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + const st = lstatSync(path); + if (st.isDirectory()) walk(path); + else if (st.isFile()) out[path.slice(root.length)] = createHash("sha256").update(readFileSync(path)).digest("hex"); + } + }; + walk(root); + return out; +} + +test("row 194 D1.1 item 5: the CLI's launch, regenerate, terminate and delete end to end through the pass, and a closed refusal exits 3 with the request id", { skip, timeout: 90000 }, async (t) => { + const p = prepare(t); + const s = await start(t, p); + const launched = await s.cli(["instance", "launch", "acme", "reviewer"]); + assert.equal(launched.code, 0, launched.err); + const [r] = s.running("reviewer"); + assert.match(launched.out, new RegExp(`^business acme: launched reviewer, run ${r.run} \\(request [^)]+\\)\n$`)); + await s.claimed(r.run); + const regen = await s.cli(["instance", "regenerate", "acme", "reviewer"]); + assert.equal(regen.code, 0, regen.err); + const [r2] = s.running("reviewer"); + assert.notEqual(r2.run, r.run); + assert.match(regen.out, new RegExp(`^business acme: regenerated reviewer, run ${r2.run} \\(request [^)]+\\)\n$`)); + await s.claimed(r2.run); + // One sonnet run at a time: the launcher closes coder's request capacity-full. + const full = await s.cli(["instance", "launch", "acme", "coder"]); + assert.equal(full.code, 3); + const refused = launchLog(p.f).find((e) => e.event === "refused" && e.instance === "coder"); + assert.match(full.err, new RegExp(`^mosaic: capacity-full: .* \\(request ${refused.request}\\)$`, "m")); + assert.equal(s.running("coder").length, 0); + const ended = await s.cli(["instance", "terminate", "acme", "reviewer", "--reason", "done for today"]); + assert.equal(ended.code, 0, ended.err); + assert.match(ended.out, new RegExp(`^runs ${r2.run}: claims revoked, the launcher signals each$`, "m")); + await s.launcher.tick(); + await until(() => s.running("reviewer").length === 0); + const revision = JSON.parse((await s.cli(["instances", "acme", "--json"])).out).revision; + const del = await s.cli(["instance", "delete", "acme", "sec-1", "--revision", revision]); + assert.equal(del.code, 0, del.err); + assert.match(del.out, /^business acme: deleted sec-1, now retired\ntransaction [^\n]+\n$/); + assert.match((await s.cli(["instances", "acme"])).out, /^retired: sec-1$/m); +}); + +test("row 194 T2a: a launch.revoke between the request and the pass closes it launch-revoked with no process; a done that fails once leaves the request taken and the next pass starts nothing", { skip, timeout: 60000 }, async (t) => { + const p = prepare(t); + const s = await start(t, p); + const request = s.call("instance.launch", { instance: "coder" }).request; + s.call("launch.revoke"); + const revoked = await s.carry(request); + assert.deepEqual([revoked.state, revoked.done.refusal, revoked.done.run], ["closed", "launch-revoked", null]); + assert.equal(s.launcher.registry.sessions.length, 0); + assert.equal(existsSync(join(p.f.dataRoot, "launches", "acme")), false); + s.call("launch.restore"); + const held = s.call("instance.launch", { instance: "coder" }).request; + s.faults.add("lifecycleDone"); + const taken = await s.carry(held); + assert.deepEqual([taken.state, taken.done], ["taken", null]); + const [r] = s.running("coder"); + assert.ok(s.logs.some((l) => l === `request ${held} (coder): lifecycleDone refused: storage-refused; run ${r.run} started`), s.logs.join("\n")); + await s.launcher.tick(); + assert.equal(s.state(held).state, "taken"); + assert.equal(s.running("coder").length, 1); + assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 1); +}); + +test("row 194 T4: terminate signals a spawned run in the registry that the bus never bound, and not a run whose start time differs", { timeout: 30000 }, async (t) => { + const p = prepare(t); + const s = await start(t, p); + const spawned = (instance) => { + const child = spawn("sleep", ["60"], { stdio: "ignore" }); + t.after(() => child.kill("SIGKILL")); + const exited = new Promise((resolve) => child.on("exit", (code, signal) => resolve(signal))); + return { child, exited, instance }; + }; + const a = spawned("coder"); + const b = spawned("reviewer"); + await until(() => startTimeOf(a.child.pid) !== null && startTimeOf(b.child.pid) !== null); + // Neither holds a claim. An open request each lets the end through, and the end cancels it. + for (const [x, start] of [[a, startTimeOf(a.child.pid)], [b, startTimeOf(b.child.pid) + 1]]) + s.launcher.registry.add({ business: "acme", instance: x.instance, run: `run-${x.instance}`, pid: x.child.pid, startTime: start, runnerPid: x.child.pid, runnerStartTime: start }); + t.after(() => ["coder", "reviewer"].forEach((i) => s.launcher.registry.remove("acme", `run-${i}`))); + for (const x of [a, b]) { + s.call("instance.launch", { instance: x.instance }); + const out = s.call("instance.end", { instance: x.instance }); + assert.deepEqual([out.runs, out.cancelled.length], [[], 1]); + await s.carry(out.request); + } + assert.equal(await a.exited, "SIGTERM"); + await new Promise((r) => setTimeout(r, 300)); + assert.equal(b.child.exitCode, null); + assert.equal(b.child.signalCode, null); + const ends = launchLog(p.f).filter((e) => e.event === "lifecycle-end").map((e) => e.run); + assert.deepEqual(ends, ["run-coder", "run-reviewer"]); +}); + +test("row 194 T6: a delete leaves the run evidence bytes unchanged, and the retired name refuses a create and a launch", { skip, timeout: 60000 }, async (t) => { + const p = prepare(t); + const s = await start(t, p); + const run = (await s.carry(s.call("instance.launch", { instance: "sec-1" }).request)).done.run; + await s.claimed(run); + await s.carry(s.call("instance.end", { instance: "sec-1" }).request); + await until(() => s.running("sec-1").length === 0 && launchLog(p.f).some((e) => e.event === "end" && e.run === run)); + const runDir = join(p.f.dataRoot, "launches", "acme", run); + const before = snapshot(runDir); + const log = readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8"); + assert.ok(Object.keys(before).length > 0); + const del = await s.carry(s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request); + assert.equal(del.done.refusal, null); + assert.deepEqual(snapshot(runDir), before); + assert.ok(readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8").startsWith(log), "the launch log only grows"); + assert.throws( + () => s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "add", definition: "security-reviewer" }), + (e) => e.code === "organization-instance-retired", + ); + assert.throws(() => s.call("instance.launch", { instance: "sec-1" }), (e) => e.code === "instance-retired"); +}); + +test("row 194 T11: two stack roots with the same business id; a launch, a terminate and a delete in one leave the other byte-identical", { skip, timeout: 90000 }, async (t) => { + const pa = prepare(t); + const pb = prepare(t); + const a = await start(t, pa); + const b = await start(t, pb); + b.call("instances"); + const before = snapshot(pb.root); + const run = (await a.carry(a.call("instance.launch", { instance: "coder" }).request)).done.run; + await a.claimed(run); + await a.carry(a.call("instance.end", { instance: "coder" }).request); + await until(() => a.running("coder").length === 0); + const del = await a.carry(a.call("instances.set", { revision: a.call("instances").revision, instance: "sec-1", op: "delete" }).request); + assert.equal(del.done.refusal, null); + await b.launcher.tick(); + assert.deepEqual(snapshot(pb.root), before); + assert.equal(b.launcher.registry.sessions.length, 0); + assert.equal(Object.hasOwn(b.call("instances").instances, "sec-1"), true); +}); diff --git a/scripts/mosaic b/scripts/mosaic index 34944880c..8c4339f8c 100755 --- a/scripts/mosaic +++ b/scripts/mosaic @@ -27,9 +27,9 @@ if [ "${1:-}" = queue ]; then fi if [ "${1:-}" = business ]; then # Row 165 (#1662): the human's role instance commands need the human proof, - # so they run in packages/cli. validate and resolve stay in packages/business. + # so they run in packages/cli. validate and resolve stay in packages/business. Row 194 adds veto. case "${2:-}" in - instances|instance) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;; + instances|instance|veto) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;; esac shift exec node "$REPO/packages/business/src/cli.mjs" "$@"