packages/cli adds mosaic inbox, decide, tasks, agents and trail over the human-cli transport, and mosaic bus start, stop and status as the trusted host (unit mosaic-bus@<business>, scripts/bus-service.sh). The host boots packages/bus/src/process.mjs, passes config.trackers from the tracker.* variables (lead decision 70), and runs a notifier child. The notifier DMs each open blocking decision once and sends an 08:00 America/Chicago digest, journaled in notify/<business>/sent.jsonl at 0600 with no Discord ids. A torn journal tail is copied aside and truncated; a malformed line, a directory looser than 0700 or a symlinked journal refuses (lead decision 71). packages/discord gains dmRecipient, createDm and notify.mjs. Candidate agents/rocko/work/slice1-s4, baseb9b6cf00, build.patch b52f7d68, manifest e858504e (29 files). Darkwing approved round 2 on #1521 (comment 26855), Filbert approved round 2 (comment 26856). The packet's mutant table lists M28 as killed; it survived, and BUILD-LOG records the correction. Integration gate in a worktree on2557e29dwith the patch applied: bus 67, business 60, cli 49, control-board 124, discord 178, ledger 78, mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no failures. Conversation is 149/3, the same K1, K3 and K10 cases that fail on the base; S4 doesn't touch the package. Every scripts/test-*.sh is green, with test-release 14/14 and test-task 98/98 on the existing gate2 compose network. A scratch test, not in this commit, booted the real host with trackers against S3's fake Vikunja: the adapter went ready and a task.close on a missing task answered task-not-found after a Vikunja read. Co-Authored-By: Claude Opus 5.5 <[email protected]>
212 lines
10 KiB
JavaScript
212 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// mosaic inbox | decide | tasks | agents | trail, and mosaic bus start|stop|status.
|
|
// See packages/cli/README.md. Exit codes are in src/errors.mjs.
|
|
//
|
|
// The human commands read and resolve through the broker's human transport
|
|
// (src/transport.mjs), never the SQLite file. `bus start` is the trusted
|
|
// host (src/host.mjs) the systemd unit mosaic-bus@<business> runs.
|
|
|
|
import { lstatSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { createInterface } from "node:readline/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { BINDING_NAME } from "../../discord/src/binding.mjs";
|
|
import { CliError } from "./errors.mjs";
|
|
import { bootConfig, loadSystem, socketPath } from "./config.mjs";
|
|
import { humanTransport, refuseInsideAgent } from "./transport.mjs";
|
|
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
|
|
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
|
|
|
|
export const USAGE = `usage:
|
|
mosaic inbox [--business <id>] [--json]
|
|
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
|
mosaic tasks [--business <id>] [--json]
|
|
mosaic agents [--business <id>] [--json]
|
|
mosaic trail <task|decision> [--business <id>] [--json]
|
|
mosaic bus start <business>
|
|
mosaic bus stop
|
|
mosaic bus status [--json]`;
|
|
|
|
const usage = (why) => new CliError(why ? `${why}\n${USAGE}` : USAGE, 4);
|
|
|
|
function parse(args, { flags = [], values = [] }) {
|
|
const out = { positional: [], flags: new Set(), values: {} };
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (flags.includes(a)) out.flags.add(a);
|
|
else if (values.includes(a)) {
|
|
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw usage(`${a} needs one value`);
|
|
out.values[a] = args[++i];
|
|
} else if (a.startsWith("--")) throw usage(`unknown option ${a}`);
|
|
else out.positional.push(a);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The notifier's private config: `<dataRoot>/notify/<business>/notify.json`,
|
|
// 0600, `{"notifyVersion": 1, "binding": "<discord binding>" | null}`.
|
|
// Missing refuses, so a host never starts without someone deciding whether
|
|
// it DMs; null runs the host without a notifier.
|
|
export function readNotifyConfig(dataRoot, business) {
|
|
const file = join(dataRoot, "notify", business, "notify.json");
|
|
let st;
|
|
try {
|
|
st = lstatSync(file);
|
|
} catch {
|
|
throw new CliError(`no notifier config at ${file}; write {"notifyVersion": 1, "binding": "<discord binding>"} there, mode 0600, or "binding": null to run without DMs`, 3);
|
|
}
|
|
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
|
|
throw new CliError(`notifier config must be a regular file, mode 0600, owned by this user: ${file}`, 3);
|
|
}
|
|
let doc;
|
|
try {
|
|
doc = JSON.parse(readFileSync(file, "utf8"));
|
|
} catch {
|
|
throw new CliError(`notifier config is not JSON: ${file}`, 3);
|
|
}
|
|
const keys = doc && typeof doc === "object" && !Array.isArray(doc) ? Object.keys(doc).sort().join(",") : "";
|
|
if (keys !== "binding,notifyVersion" || doc.notifyVersion !== 1 || (doc.binding !== null && !(typeof doc.binding === "string" && BINDING_NAME.test(doc.binding)))) {
|
|
throw new CliError(`notifier config must be exactly {"notifyVersion": 1, "binding": <binding name> | null}: ${file}`, 3);
|
|
}
|
|
return doc.binding;
|
|
}
|
|
|
|
// --business, else the running host's business.
|
|
function businessFor(parsed, dataRoot) {
|
|
if (parsed.values["--business"]) return parsed.values["--business"];
|
|
const state = readHostState(dataRoot);
|
|
if (state?.live) return state.business;
|
|
throw usage("no bus host runs here, so name the business with --business");
|
|
}
|
|
|
|
function findDecision(list, ref) {
|
|
const exact = list.find((d) => d.id === ref);
|
|
if (exact) return exact;
|
|
if (ref.length < 8) throw new CliError(`decision reference ${JSON.stringify(ref)} is too short; use at least 8 characters of the id`, 2);
|
|
const hits = list.filter((d) => d.id.startsWith(ref));
|
|
if (hits.length === 1) return hits[0];
|
|
if (hits.length > 1) throw new CliError(`${ref} matches ${hits.length} open decisions; use more of the id`, 2);
|
|
throw new CliError(`no open decision for you matches ${ref}; see mosaic inbox`, 2);
|
|
}
|
|
|
|
async function confirm(io, question) {
|
|
const rl = createInterface({ input: io.stdin, output: io.stdout });
|
|
try {
|
|
return /^(y|yes)$/i.test((await rl.question(question)).trim());
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
async function decide(parsed, call, io) {
|
|
if (parsed.positional.length !== 2) throw usage("decide takes a decision and an option");
|
|
const [ref, choice] = parsed.positional;
|
|
const d = findDecision(await call("inbox"), ref);
|
|
const option = d.options.find((o) => o.key === choice);
|
|
if (!option) throw new CliError(`decision ${shortId(d.id)} has no option ${JSON.stringify(choice)}; its options are ${d.options.map((o) => o.key).join(", ")}`, 2);
|
|
io.stdout.write(formatDecision(d));
|
|
const effect = d.authorization ? (choice === d.authorization.approvalChoice ? "this authorizes the action" : "this declines the action") : null;
|
|
io.stdout.write(`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : ""}\n`);
|
|
if (!parsed.flags.has("--yes")) {
|
|
if (!io.stdin.isTTY) throw usage("stdin is not a terminal; pass --yes to resolve without the prompt");
|
|
if (!(await confirm(io, `resolve ${shortId(d.id)} with ${choice}? [y/N] `))) throw new CliError("not resolved", 1);
|
|
}
|
|
const args = { id: d.id, choice };
|
|
if (parsed.values["--note"] !== undefined) args.note = parsed.values["--note"];
|
|
try {
|
|
await call("decision.resolve", args);
|
|
} catch (e) {
|
|
if (e.code === "outcome-unknown") {
|
|
throw new CliError(`outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail ${d.id} before trying again`, 1);
|
|
}
|
|
if (e.code === "decision-closed") throw new CliError(`decision ${shortId(d.id)} was closed before your answer arrived; see mosaic trail ${d.id}`, 2);
|
|
throw e;
|
|
}
|
|
io.stdout.write(`resolved ${d.id}: ${choice}\n`);
|
|
}
|
|
|
|
async function human(verb, rest, io, deps) {
|
|
const spec = verb === "decide" ? { flags: ["--yes"], values: ["--business", "--note"] } : { flags: ["--json"], values: ["--business"] };
|
|
const parsed = parse(rest, spec);
|
|
refuseInsideAgent(io.env);
|
|
const system = deps.system ?? loadSystem({ env: io.env });
|
|
const business = businessFor(parsed, system.dataRoot);
|
|
const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
|
|
if (verb === "decide") return decide(parsed, call, io);
|
|
const json = parsed.flags.has("--json");
|
|
const print = (value, text) => io.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : text(value));
|
|
if (verb === "trail") {
|
|
if (parsed.positional.length !== 1) throw usage("trail takes one task or decision");
|
|
const subject = parsed.positional[0];
|
|
return print(await call("trail", { subject }), (rows) => formatTrail(subject, rows));
|
|
}
|
|
if (parsed.positional.length !== 0) throw usage(`${verb} takes no arguments`);
|
|
if (verb === "inbox") return print(await call("inbox"), formatInbox);
|
|
if (verb === "tasks") return print(await call("tasks"), formatTasks);
|
|
return print(await call("agents"), formatAgents);
|
|
}
|
|
|
|
async function bus(rest, io, deps) {
|
|
const [sub, ...args] = rest;
|
|
if (sub === "start") {
|
|
const parsed = parse(args, {});
|
|
if (parsed.positional.length !== 1) throw usage("bus start takes one business");
|
|
const businessId = parsed.positional[0];
|
|
const system = deps.system ?? loadSystem({ env: io.env });
|
|
const boot = bootConfig({ system, businessId, env: io.env, warn: (w) => io.stderr.write(`mosaic-bus: warning: ${w}\n`) });
|
|
const binding = readNotifyConfig(system.dataRoot, businessId);
|
|
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null });
|
|
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}\n`);
|
|
const stop = () => host.close(0);
|
|
process.on("SIGTERM", stop);
|
|
process.on("SIGINT", stop);
|
|
const code = await host.done;
|
|
process.off("SIGTERM", stop);
|
|
process.off("SIGINT", stop);
|
|
io.stdout.write(`bus host stopped (${code})\n`);
|
|
if (code !== 0) throw new CliError("bus host stopped after a child exited", code);
|
|
return;
|
|
}
|
|
if (sub === "stop") {
|
|
if (args.length !== 0) throw usage("bus stop takes no arguments");
|
|
const system = deps.system ?? loadSystem({ env: io.env });
|
|
const r = await stopHost(system.dataRoot);
|
|
io.stdout.write(r.stopped ? `stopped bus host for ${r.business} (pid ${r.pid})\n` : `${r.reason}\n`);
|
|
return;
|
|
}
|
|
if (sub === "status") {
|
|
const parsed = parse(args, { flags: ["--json"] });
|
|
if (parsed.positional.length !== 0) throw usage("bus status takes no arguments");
|
|
const system = deps.system ?? loadSystem({ env: io.env });
|
|
const s = hostStatus(system.dataRoot);
|
|
if (parsed.flags.has("--json")) return io.stdout.write(`${JSON.stringify(s, null, 2)}\n`);
|
|
const lines = [
|
|
s.host ? `host: ${s.host.business}, pid ${s.host.pid}, ${s.host.live ? "running" : "not running (stale state file)"}, since ${s.host.startedAt}, notifier ${s.host.notifier ?? "off"}` : "host: none",
|
|
`socket: ${s.socket ? "present" : "absent"}`,
|
|
s.writerLock
|
|
? `writer.lock: pid ${s.writerLock.pid ?? "?"}${s.writerLock.live ? "" : " (not running: a broker died hard; remove the lock by hand once you've checked, see packages/bus/README.md)"}`
|
|
: "writer.lock: absent",
|
|
];
|
|
return io.stdout.write(`${lines.join("\n")}\n`);
|
|
}
|
|
throw usage();
|
|
}
|
|
|
|
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
|
|
const [verb, ...rest] = argv;
|
|
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
|
if (verb === "bus") return bus(rest, io, deps);
|
|
if (verb === "-h" || verb === "--help") return io.stdout.write(`${USAGE}\n`);
|
|
throw usage();
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
try {
|
|
await main(process.argv.slice(2));
|
|
} catch (error) {
|
|
if (!(error instanceof CliError)) throw error;
|
|
process.stderr.write(`mosaic: ${error.message}\n`);
|
|
process.exitCode = error.exitCode;
|
|
}
|
|
}
|