Files
stack/packages/cli/src/instances.mjs
T
jason.woltje 13c75b699f cli, organization: org config and org instances commands, old business spelling kept (rename r2 stage B2)
mosaic org config validate|resolve runs runConfigCommand
(packages/organization/src/config-command.mjs), the callable service the
old mosaic business validate|resolve executable now calls too. The service
returns the exit code, never exits the process and does nothing on import.
The spelling sets only the stderr prefix and the usage text; status and
stdout are byte-identical, and the v1 output keeps the business key.

mosaic org instances|instance|veto takes the same arguments as
mosaic business instances|instance|veto and runs the same proof-bearing
instances command. It is dispatched before orgCommand, so it never builds
the dev-local-owner context. mosaic org validate FILE is unchanged.

Help, the UI labels for agent actions and instance reasons, and the
current docs now use the org spelling and the packages/organization paths.
Two webui test regexes follow the label text. packages/cli/tests/
rename-b-commands.test.mjs compares the two spellings golden for golden
on status, stdout, stderr and authorization outcomes, and covers a draft
file named like an installed ID.
2026-11-09 12:00:29 -06:00

292 lines
18 KiB
JavaScript

// `mosaic business instances` and `mosaic business instance add|set` (row 165, #1662, Q171,
// REQ-ROLE-3): the human reads, adds and changes role instances through the broker's instances
// 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`.
// Rename r2 stage B: `mosaic org instances|instance|veto` is the primary spelling of all of these,
// with the same arguments and this same code (orgInstancesCommand). Only the usage text differs;
// the refusal reasons below still name the old spelling, which keeps working.
// 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] [--bot BOT --bot-id N] --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";
// Rename r2 stage B: the primary spelling. `mosaic org instances|instance|veto` takes the same
// arguments and runs the same command through the same proof-bearing call; only the usage text it
// prints for bad arguments reads `org`. Output and refusals are byte for byte the old spelling's.
export const ORG_INSTANCES_USAGE = INSTANCES_USAGE.replace(/^mosaic business /, "mosaic org ")
.replaceAll(" | business ", " | org ").replaceAll("BUSINESS", "ORG");
// The bus answers a refusal with its code alone, so the reason a human reads is here.
export const REASONS = {
"organization-credentials-out-of-scope": "only add binds credentials, as store references; set refuses a definition that " +
"declares them and a definition change on an instance that holds them, and delete refuses a file or env reference; " +
"those stay a hand edit of the business file",
// Row 194 (brief item 7, sage-1610 a): add names an existing Vikunja bot; it makes no account.
"organization-tracker-required": "the definition uses vikunja; add needs --bot BOT --bot-id N, an existing Vikunja bot",
"organization-tracker-bot-taken": "another instance already uses that Vikunja bot name or id",
"organization-instance-exists": "the business already has that instance; change it with `mosaic business instance set`",
"organization-unknown-instance": "the business has no such instance; add it with `mosaic business instance add`",
"organization-definition-refused": "this command neither adds nor changes an assistant, and moves no instance to the assistant " +
"definition; an assistant changes only through `mosaic limits set` with an assistants patch",
"organization-launch-not-configured": "the business file has no launch block, so launch membership can't change",
"organization-no-change": "the business file already says that",
"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",
// Row 194 (brief item 8, deviation 11, sage-1620): a delete revokes before it writes, so a failure can
// leave tokens revoked with the instance still listed. It holds nothing it can use, and a retry is safe.
"credential-revoke-failed": "a credential couldn't be revoked, so the business file is unchanged; tokens already revoked " +
"stay revoked and the instance can't use them; clear what else the agent's store directory holds and run the same " +
"command again, which is safe",
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock; these follow one that ran.
"revision-conflict-revoked": "the business file changed after the agent's tokens were revoked and before the write, so " +
"the file is unchanged; the tokens may already be revoked and the instance can't use them; read it again with " +
"`mosaic business instances` and run the same command again, which is safe",
"outcome-unknown-revoked": "the agent's tokens may already be revoked and the write's outcome is unknown; read the " +
"business file again with `mosaic business instances` before trying again",
"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";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
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", "--wait", "--reason", "--bot", "--bot-id"].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);
else out.positional.push(arg);
}
return out;
}
// The args instances.set takes, from `instance add|set`. A usage error is exit 4 before any call.
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 bot = values["--bot"] !== undefined || values["--bot-id"] !== undefined;
if (bot && (op !== "add" || values["--bot"] === undefined || !/^[0-9]{1,15}$/.test(values["--bot-id"] ?? ""))) {
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)) ||
(op === "set" && !values["--definition"] && !values["--vars"] && launch === undefined)) {
throw new CliError(INSTANCES_USAGE, 4);
}
const args = { revision: values["--revision"], instance, op: op === "add" ? "add" : "change" };
if (values["--definition"]) args.definition = values["--definition"];
if (values["--vars"]) {
try {
args.vars = JSON.parse(readFileSync(values["--vars"], "utf8"));
} catch (error) {
throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4);
}
}
if (launch !== undefined) args.launch = launch;
if (bot) args.tracker = { bot: values["--bot"], botId: Number(values["--bot-id"]) };
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") {
const bot = r.done.bot ? `tracker bot ${r.done.bot}: remove the Vikunja bot user and any token it holds by hand in Vikunja\n` : "";
return `business ${business}: deleted ${instance}, now retired\ntransaction ${r.done.transaction}\n${bot}`;
}
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", 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}`);
}
const definitions = Object.entries(view.definitions);
const accepted = definitions.filter(([, d]) => d.accepted).map(([name]) => name);
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, added with store references and a restart: ${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`;
}
// args are the words after `mosaic org`; the same command as instancesCommand, with the org usage.
export async function orgInstancesCommand(args, io, callFor) {
try {
return await instancesCommand(args, io, callFor);
} catch (error) {
if (error instanceof CliError && error.message === INSTANCES_USAGE) throw new CliError(ORG_INSTANCES_USAGE, error.exitCode);
throw error;
}
}
// args are the words after `mosaic business`. callFor(parsed) returns the human's call(verb, args).
export async function instancesCommand(args, io, callFor) {
const parsed = parse(args);
const sub = parsed.positional[0];
const business = parsed.positional[sub === "instance" ? 2 : 1];
if (!["instances", "instance", "veto"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
let request;
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") {
// Row 194 b6 (webby-0094 B1): a veto says whether the reload took, as instances.set does.
return io.stdout.write(`business ${out.business}: veto ${lifecycle.args.op} ${lifecycle.args.name}\nrevision ${out.revision}\ntransaction ${out.transaction}\n${out.restart ? RESTART_NEEDED : NO_RESTART}\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));
}
const result = await call("instances.set", request);
const entry = result.instances[request.instance];
io.stdout.write([
`business ${result.business}: ${request.op === "add" ? "added" : "changed"} ${request.instance} (${entry.definition}${entry.launched ? ", launched" : ""})`,
`revision ${result.revision}`,
`transaction ${result.transaction}`,
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);
refused.code = code;
throw refused;
}
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
}
}