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]>
188 lines
9.5 KiB
JavaScript
188 lines
9.5 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { main, readNotifyConfig } from "../src/cli.mjs";
|
|
import { CliError } from "../src/errors.mjs";
|
|
import { hostDir, hostFile, startTimeOf } from "../src/host.mjs";
|
|
import { broker, io, notifyConfig, tmp } from "./helpers.mjs";
|
|
import { writeJson } from "../../business/tests/helpers.mjs";
|
|
import { join } from "node:path";
|
|
|
|
const exitOf = async (p) => {
|
|
try {
|
|
await p;
|
|
return 0;
|
|
} catch (e) {
|
|
if (!(e instanceof CliError)) throw e;
|
|
return e.exitCode;
|
|
}
|
|
};
|
|
|
|
function setup(t) {
|
|
const bus = broker(t);
|
|
const dataRoot = tmp(t);
|
|
const run = async (argv, x = io()) => ({ code: await exitOf(main(argv, x, { system: { dataRoot }, transport: bus.transport })), io: x });
|
|
return { ...bus, dataRoot, run };
|
|
}
|
|
|
|
test("inbox lists only decisions routed to the human, with what approving authorizes and how to decide", async (t) => {
|
|
const s = setup(t);
|
|
s.raise("task.scope.change", { domain: "technical", target: "task-1" });
|
|
const gated = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
|
|
const { code, io: x } = await s.run(["inbox", "--business", "demo"]);
|
|
assert.equal(code, 0);
|
|
const text = x.out.text;
|
|
assert.match(text, /^inbox: 1 open decision\(s\)/);
|
|
assert.match(text, new RegExp(`${gated.id.slice(0, 8)} git\\.push\\.protected \\(gated, blocking\\)`));
|
|
assert.match(text, /action: git\.push\.protected on refactor/);
|
|
assert.match(text, /choosing "yes" authorizes it; any other choice declines/);
|
|
assert.match(text, /task: vikunja:1\/7/);
|
|
assert.match(text, new RegExp(`decide: mosaic decide ${gated.id.slice(0, 8)} <option>`));
|
|
const json = await s.run(["inbox", "--business", "demo", "--json"]);
|
|
assert.equal(JSON.parse(json.io.out.text)[0].id, gated.id);
|
|
assert.deepEqual(s.calls.map((c) => c.verb), ["inbox", "inbox"]);
|
|
});
|
|
|
|
test("decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow", async (t) => {
|
|
const s = setup(t);
|
|
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
|
|
const { code, io: x } = await s.run(["decide", d.id.slice(0, 8), "yes", "--yes", "--note", "ship it", "--business", "demo"]);
|
|
assert.equal(code, 0, x.err.text);
|
|
assert.match(x.out.text, /your choice: yes \(Allow\); this authorizes the action/);
|
|
assert.match(x.out.text, new RegExp(`resolved ${d.id}: yes`));
|
|
assert.deepEqual(s.calls.at(-1), { business: "demo", verb: "decision.resolve", args: { id: d.id, choice: "yes", note: "ship it" } });
|
|
assert.deepEqual(s.read("inbox"), []);
|
|
|
|
const trail = await s.run(["trail", d.id, "--business", "demo"]);
|
|
const raw = s.read("trail", { subject: d.id });
|
|
const lines = trail.io.out.text.trimEnd().split("\n");
|
|
assert.equal(lines[0], `trail ${d.id}: ${raw.length} row(s)`);
|
|
assert.deepEqual(lines.slice(1, 1 + raw.length).map((l) => l.split(" ")[1]), raw.map((r) => `${r.table}#${r.seq}`));
|
|
assert.deepEqual(lines.slice(-2), ["task: vikunja:1/7", "follow with: mosaic trail vikunja:1/7"]);
|
|
// A decision's trail names its task; it does not pull in the task's rows.
|
|
assert.ok(raw.every((r) => r.table !== "task_snapshots"));
|
|
});
|
|
|
|
test("decide refuses without a terminal or --yes, on an unknown option and on a short reference", async (t) => {
|
|
const s = setup(t);
|
|
const d = s.raise("git.push.protected", { target: "refactor" });
|
|
assert.equal((await s.run(["decide", d.id, "yes", "--business", "demo"])).code, 4);
|
|
assert.equal((await s.run(["decide", d.id, "maybe", "--yes", "--business", "demo"])).code, 2);
|
|
assert.equal((await s.run(["decide", d.id.slice(0, 7), "yes", "--yes", "--business", "demo"])).code, 2);
|
|
assert.equal((await s.run(["decide", "ffffffff", "yes", "--yes", "--business", "demo"])).code, 2);
|
|
assert.equal(s.read("inbox").length, 1);
|
|
assert.ok(s.calls.every((c) => c.verb === "inbox"));
|
|
});
|
|
|
|
test("decide prints a declining choice as declining", async (t) => {
|
|
const s = setup(t);
|
|
const d = s.raise("git.push.protected", { target: "refactor" });
|
|
const { code, io: x } = await s.run(["decide", d.id, "no", "--yes", "--business", "demo"]);
|
|
assert.equal(code, 0);
|
|
assert.match(x.out.text, /your choice: no \(Decline\); this declines the action/);
|
|
});
|
|
|
|
test("an unknown outcome is reported once and never resent", async (t) => {
|
|
const s = setup(t);
|
|
const d = s.raise("git.push.protected", { target: "refactor" });
|
|
const calls = [];
|
|
const transport = () => async (verb) => {
|
|
calls.push(verb);
|
|
if (verb === "inbox") return s.read("inbox");
|
|
const e = new Error("outcome-unknown");
|
|
e.code = "outcome-unknown";
|
|
throw e;
|
|
};
|
|
const x = io();
|
|
const err = await main(["decide", d.id, "yes", "--yes", "--business", "demo"], x, { system: { dataRoot: s.dataRoot }, transport }).catch((e) => e);
|
|
assert.equal(err.exitCode, 1);
|
|
assert.match(err.message, new RegExp(`Check mosaic inbox or mosaic trail ${d.id} before trying again`));
|
|
assert.deepEqual(calls, ["inbox", "decision.resolve"]);
|
|
});
|
|
|
|
test("a decision closed before the answer arrives exits 2 and points at its trail", async (t) => {
|
|
const s = setup(t);
|
|
const d = s.raise("git.push.protected", { target: "refactor" });
|
|
const transport = () => async (verb) => {
|
|
if (verb === "inbox") return s.read("inbox");
|
|
const e = new Error("decision-closed");
|
|
e.code = "decision-closed";
|
|
throw e;
|
|
};
|
|
const err = await main(["decide", d.id, "yes", "--yes", "--business", "demo"], io(), { system: { dataRoot: s.dataRoot }, transport }).catch((e) => e);
|
|
assert.ok(err instanceof CliError);
|
|
assert.equal(err.exitCode, 2);
|
|
assert.match(err.message, new RegExp(`was closed before your answer arrived; see mosaic trail ${d.id}`));
|
|
});
|
|
|
|
test("a prefix that matches two open decisions exits 2 and resolves neither", async (t) => {
|
|
const s = setup(t);
|
|
const d = s.raise("git.push.protected", { target: "refactor" });
|
|
const twin = { ...structuredClone(s.read("inbox")[0]), id: `${d.id.slice(0, 8)}-ffff-4fff-8fff-ffffffffffff` };
|
|
const calls = [];
|
|
const transport = () => async (verb) => {
|
|
calls.push(verb);
|
|
return verb === "inbox" ? [...s.read("inbox"), twin] : null;
|
|
};
|
|
const err = await main(["decide", d.id.slice(0, 8), "yes", "--yes", "--business", "demo"], io(), { system: { dataRoot: s.dataRoot }, transport }).catch((e) => e);
|
|
assert.equal(err.exitCode, 2);
|
|
assert.match(err.message, /matches 2 open decisions; use more of the id/);
|
|
assert.deepEqual(calls, ["inbox"]);
|
|
});
|
|
|
|
test("without --business a command uses the live host's business, and a stale host.json is not a host", async (t) => {
|
|
const s = setup(t);
|
|
mkdirSync(hostDir(s.dataRoot), { recursive: true, mode: 0o700 });
|
|
const write = (startTime) => writeFileSync(hostFile(s.dataRoot), JSON.stringify({ pid: process.pid, startTime, business: "demo" }), { mode: 0o600 });
|
|
write("not-this-process");
|
|
assert.equal((await s.run(["inbox"])).code, 4);
|
|
assert.equal(s.calls.length, 0);
|
|
write(startTimeOf(process.pid));
|
|
assert.equal((await s.run(["inbox"])).code, 0);
|
|
assert.deepEqual(s.calls.map((c) => c.business), ["demo"]);
|
|
});
|
|
|
|
test("every human command refuses inside an agent run before it touches the bus", async (t) => {
|
|
const s = setup(t);
|
|
for (const argv of [["inbox"], ["tasks"], ["agents"], ["trail", "vikunja:1/7"], ["decide", "abcdefgh", "yes", "--yes"]]) {
|
|
const x = io({ ...io().env, CLAUDECODE: "1" });
|
|
const { code } = await s.run([...argv, "--business", "demo"], x);
|
|
assert.equal(code, 3, argv.join(" "));
|
|
}
|
|
assert.equal(s.calls.length, 0);
|
|
});
|
|
|
|
test("usage errors exit 4; no business and no host is a usage error", async (t) => {
|
|
const s = setup(t);
|
|
for (const argv of [[], ["nope"], ["inbox", "extra", "--business", "demo"], ["inbox", "--bogus"], ["trail", "--business", "demo"], ["decide", "x", "--business", "demo"], ["bus"], ["bus", "stop", "x"], ["inbox"]]) {
|
|
assert.equal((await s.run(argv)).code, 4, argv.join(" ") || "(none)");
|
|
}
|
|
assert.equal(s.calls.length, 0);
|
|
});
|
|
|
|
test("agents and tasks print through the broker", async (t) => {
|
|
const s = setup(t);
|
|
const agents = await s.run(["agents", "--business", "demo"]);
|
|
assert.match(agents.io.out.text, /^coder coder-run pi since /);
|
|
const tasks = await s.run(["tasks", "--business", "demo"]);
|
|
assert.equal(tasks.io.out.text, "tasks: none\n");
|
|
});
|
|
|
|
test("notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes", (t) => {
|
|
const dataRoot = tmp(t);
|
|
assert.throws(() => readNotifyConfig(dataRoot, "acme"), (e) => e.exitCode === 3 && /no notifier config/.test(e.message));
|
|
const file = notifyConfig(dataRoot, "acme", "sage-seat");
|
|
assert.equal(readNotifyConfig(dataRoot, "acme"), "sage-seat");
|
|
chmodSync(file, 0o644);
|
|
assert.throws(() => readNotifyConfig(dataRoot, "acme"), (e) => e.exitCode === 3 && /mode 0600/.test(e.message));
|
|
writeJson(file, { notifyVersion: 1, binding: null, channel: "x" });
|
|
assert.throws(() => readNotifyConfig(dataRoot, "acme"), (e) => e.exitCode === 3);
|
|
writeJson(file, { notifyVersion: 1, binding: "../escape" });
|
|
assert.throws(() => readNotifyConfig(dataRoot, "acme"), (e) => e.exitCode === 3);
|
|
writeJson(file, "not json");
|
|
assert.throws(() => readNotifyConfig(dataRoot, "acme"), (e) => e.exitCode === 3);
|
|
writeJson(file, { notifyVersion: 1, binding: null });
|
|
assert.equal(readNotifyConfig(dataRoot, "acme"), null);
|
|
assert.equal(readFileSync(join(dataRoot, "notify", "acme", "notify.json"), "utf8").includes("null"), true);
|
|
});
|