feat(cli): the mosaic CLI, broker host and decision notifier (row 39, S4, rocko)

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, base b9b6cf00, 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 on 2557e29d with 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]>
This commit is contained in:
2026-10-09 07:49:38 -05:00
co-authored by Claude Opus 5.5
parent 2557e29dc7
commit 2f5303c1c7
29 changed files with 2610 additions and 6 deletions
+22
View File
@@ -190,6 +190,28 @@ Exit codes: `0` ok · `2` invalid file or credential reference · `3` system
config problem · `4` usage or a required file missing. Details:
`packages/business/README.md`.
## Inbox, decisions and the bus host (`scripts/mosaic`)
```bash
scripts/mosaic inbox | tasks | agents [--business <id>] [--json]
scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> | stop | status [--json]
scripts/bus-service.sh render | install [--dir DIR] [--no-reload] | uninstall | status <business>
```
The human commands read and resolve through the broker's human transport
(`packages/bus/src/human-cli.mjs`), never the SQLite file. They refuse inside
an agent run. `bus start` is the host the systemd user unit
`mosaic-bus@<business>` runs. It boots the broker, starts the Discord
notifier, and needs `<dataRoot>/notify/<business>/notify.json`. That
directory must be 0700: it also holds the notifier's journal `sent.jsonl`
(0600, never a symlink). On open the notifier copies a torn final line to
`torn-<UTC stamp>.bin` and truncates the journal to its last newline; a
malformed complete line refuses with exit 3.
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
## Discord connector (`scripts/discord.sh`)
```bash
+225
View File
@@ -0,0 +1,225 @@
# CLI
Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
`decide`, `tasks`, `agents` and `trail`, and the bus host `mosaic bus
start|stop|status` with its Discord notifier. Brief:
`docs/plans/2026-10-04_slice-1.md`, row S4. Rulings: lead decision 70 in
`docs/plans/2026-09-26_lead-decisions.md`.
Every command reads through the broker. None opens the SQLite file.
```sh
scripts/mosaic inbox
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
scripts/mosaic bus status
node --test 'packages/cli/tests/*.test.mjs'
```
## Human commands
```
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]
```
- The business is `--business`, or else the business of the bus host running
on this data root. With neither, the command exits 4.
- The transport is `packages/bus/src/human-cli.mjs <socket>`, run as a child.
The command writes `{business, verb, args}` on its stdin and reads the JSON
reply on its stdout. The child proves a human shell: it re-executes itself
with a nonce, and the broker checks its process and ancestry for agent
markers. The command carries no capability and the bus is unchanged.
- Each command refuses with exit 3 before it touches the bus when an agent
marker is in its environment (REQ-DEC-3). The broker would refuse anyway;
this check only gives a clear message first. **Agent seats never run
these commands.** The tests use a stand-in transport.
- `inbox` lists the open decisions routed to the human, gated first, in
broker order. For a gated decision it prints what approving authorizes:
the action, its target, and the one choice that authorizes it.
- `decide` takes the full id, or a unique prefix of at least 8 characters,
from the inbox. It prints the decision and whether the choice authorizes
or declines, then asks `[y/N]` on a terminal. Without a terminal it needs
`--yes`, else it exits 4. On `outcome-unknown` it does not resend: it
exits 1 and asks you to check `mosaic inbox` or `mosaic trail <id>`
first.
- `trail` prints rows in the broker's order (at, table, seq). A decision's
trail ends with its `task_ref` and `follow with: mosaic trail <task>`. It
does not pull in the task's rows.
## Bus host
```
mosaic bus start <business> the host; run by the unit, not by hand
mosaic bus stop SIGTERM to the recorded host, then wait
mosaic bus status [--json] host state file, socket, writer.lock
```
`bus start` does the following:
1. It reads the system config through `scripts/mosaic-config.mjs validate`,
the business file and the role files, and builds the broker's
`{op: 'boot'}` message (`src/config.mjs`). The message has `launches: []`
and `readers: [<business>]`. It gets `config.trackers` (below) when a
tracker is set.
2. It reads the notifier config (below).
3. It forks `packages/bus/src/process.mjs` and waits up to 30 s for the boot
reply. If the broker answers `{ok: false, error}`, the host exits 3 and
prints `broker refused to start: <code>`. A timeout or an early exit
gives exit 1.
4. It forks `src/notifier-process.mjs` and hands it the reader capability
over IPC in `{op: 'start'}`. A notifier refusal closes the broker, and
the host exits 3.
5. It writes `<dataRoot>/bus-host/host.json` (0600): the pid, the process
start time, the business, the start time as text and the notifier
binding. The file holds no capability.
No capability appears in argv, stdout, the environment or a file.
`startHost()` in `src/host.mjs` is also the in-process API S6 uses:
`bindLaunch(record)` binds a launched run's process identity
(`pid`, `startTime`) and returns `{business, run, cap}`. Requests to the
broker process go one at a time, because its replies carry no request id.
SIGTERM or SIGINT stops the notifier after its poll in flight, then closes
the broker and removes `host.json`. If either child dies on its own, the
host goes down with exit 1. The unit then restarts both. The host watches
the children once both have started, and it checks each child's exit state
at that point too, so a broker that dies while the notifier starts still
takes the host down.
`bus stop` signals only a pid that still runs with the recorded start time
and whose command line is `…/packages/cli/src/cli.mjs bus start`.
`bus status` never removes a lock. A `writer.lock` whose pid is gone means
a broker was killed hard. Check, then remove the lock by hand
(`packages/bus/README.md`).
### Trackers
`config.trackers[<business>]` is `{baseUrl, project, pollSeconds,
reconcileMinutes}`, taken from the `tracker.*` variables. `tracker.project` is
a project-layer variable, so the entry comes from the one declared project
whose `.mosaic/project.json` sets it. The resulting entry depends on the
variables:
| Variables | Result |
|---|---|
| No `tracker.baseUrl` | No entry and no task verbs |
| `tracker.baseUrl` set, no project setting `tracker.project` | A warning on stderr and no entry |
| Two projects setting `tracker.project` | Refusal with exit 3, because the boot shape holds one tracker project per business |
### Unit
`scripts/bus-service.sh render|install|uninstall|status` installs the
template `systemd/mosaic-bus.service.in` as the systemd user unit
`[email protected]`, one instance per business (`mosaic-bus@<business>`).
The template's file name has no `@` because queue candidate manifests refuse
it. It is modelled on `scripts/discord-service.sh`:
- `install [--dir DIR] [--no-reload]` writes the unit through a temp file and
a rename, then runs `systemctl --user daemon-reload`.
- `uninstall` refuses while an instance is active.
The unit settings:
- `ExecStart=@REPO@/scripts/mosaic bus start %i`
- `Restart=on-failure`
- `RestartPreventExitStatus=2 3 4`, so refusals are never retried
- `KillSignal=SIGTERM`
- `TimeoutStopSec=60`
The unit has no `network-online.target` ordering, since a user unit cannot
order on that system target. The notifier needs no network at start: a
send that fails is journaled and retried.
## Notifier
The notifier is a child of the host. Each poll (every 30 s) it reads the
inbox through the reader capability and does two things:
- **DMs.** It DMs each open blocking decision once. The DM holds the
question, the options, the recommendation, what approving authorizes, the
task, and `mosaic decide <short id> <option>`.
- **Digest.** It sends one digest a day at 08:00 America/Chicago. The hour
comes from the IANA zone, so daylight saving time is handled. If the host
starts after 08:00 and the day has no digest yet, the digest goes at once.
The digest lists the inbox and marks each blocking decision as DM sent or
DM pending. An empty inbox gets one line. Each message stays within
Discord's 2000 characters.
The notifier only reads the bus. Its memory is the journal
`<dataRoot>/notify/<business>/sent.jsonl` (directory 0700, file 0600), with
one line per send attempt:
```json
{"at": "…", "kind": "dm", "decision": "<id>", "outcome": "confirmed", "messageId": "…"}
{"at": "…", "kind": "digest", "decision": null, "day": "2026-10-08", "outcome": "unknown", "messageId": null}
```
- A decision, or a day's digest, counts as sent once it has a `confirmed`
line.
- A `refused` or `unknown` send is retried. The wait starts at 30 s and
doubles up to 30 min. A duplicate costs less than a miss. Every retry
carries the same Discord nonce, so a retry inside Discord's dedupe window
returns the first message. A DM's nonce comes from the decision id. A
digest's comes from the business and the day.
- On open, a final line without its newline is a torn write. The notifier
copies those bytes to `torn-<UTC stamp>.bin` in the same directory (0600,
a new file, synced), then truncates `sent.jsonl` to its last newline and
syncs it. It logs both steps. A crash between the two leaves the torn
tail in place, and the next open repairs it with a second copy. The next
append therefore starts on a line of its own.
- A malformed complete line refuses with exit 3 and changes nothing.
- The journal refuses with exit 3 when its directory is looser than 0700 or
not yours, when `sent.jsonl` is a symlink, or when the file is not a
regular 0600 file you own.
- No Discord channel or user id goes in the journal, a log line or an
error.
The Discord side is `packages/discord/src/notify.mjs`. It uses the
connector's REST client and the binding's `tokenFile`, and sends to the
binding's fixed `dmRecipient`, who must be one of the binding's `users`.
### Notifier config
`<dataRoot>/notify/<business>/notify.json`, mode 0600, owned by you. The
journal shares the directory, so create it 0700 first (`mkdir -m 0700 -p
<dataRoot>/notify/<business>`). A host with a binding refuses with exit 3 on
a looser directory.
```json
{"notifyVersion": 1, "binding": "sage-seat"}
```
`"binding": null` runs the host without DMs. A missing file refuses with
exit 3, so a host never starts until someone decides whether it DMs.
## Exit codes
| Code | Meaning |
|---|---|
| 0 | ok |
| 1 | failed, or outcome unknown: check before retrying |
| 2 | invalid input: unknown option, no such open decision, decision already closed |
| 3 | refused or config problem: inside an agent run, human proof failed, unknown business, bad config, broker or notifier refused to start |
| 4 | usage |
## Limits
- **One broker per data root.** The bus store takes
`<dataRoot>/bus/writer.lock`. The unit is a template per business, but
only one instance can run on one data root.
- **`digest.sent` is unused.** The bus schema has this event kind, but
decision 70 puts the notifier's memory in the journal, and a reader
capability cannot write events.
- **The real human transport is untested here.** No test runs the real
`human-cli.mjs`, because its proof needs a human shell. The live run
covers it.
- **Tracker boot is tested only without trackers.** The `trackers` boot case
is tested once S3's broker change lands.
## Not in this piece
`mosaic talk` (S6) and the WebUI (S5).
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@mosaic/cli",
"version": "0.1.0",
"private": true,
"description": "Jason's front door: inbox, decide, tasks, agents, trail, and the bus host with its Discord notifier (slice 1 row S4).",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=24" },
"exports": { ".": "./src/index.mjs" },
"scripts": { "test": "node --test tests/*.test.mjs" }
}
+211
View File
@@ -0,0 +1,211 @@
#!/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;
}
}
+103
View File
@@ -0,0 +1,103 @@
// What the host and the human commands read before they touch the bus:
// the system config (through scripts/mosaic-config.mjs, as `mosaic business`
// does), the business file, and the boot message for the broker process.
// Everything here reads and refuses; nothing writes.
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BusinessError, configDir, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { busBusiness } from "../../bus/src/business.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { CliError } from "./errors.mjs";
export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
// The broker's socket. One broker per data root: the bus store takes
// `<dataRoot>/bus/writer.lock` (packages/bus/src/store.mjs).
export const socketPath = (dataRoot) => join(dataRoot, "bus", "broker.sock");
export function loadSystem({ env = process.env } = {}) {
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], {
encoding: "utf8",
maxBuffer: 1024 * 1024,
env,
});
if (proc.status !== 0) throw new CliError(`system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`, 3);
try {
return JSON.parse(proc.stdout);
} catch {
throw new CliError("system config: mosaic-config printed something that isn't JSON", 3);
}
}
export function rolesDir(env = process.env) {
return resolve(env.MOSAIC_ROLES_DIR || join(REPO, "roles"));
}
// A business-package or adapter refusal is a config problem: exit 3.
function business(fn) {
try {
return fn();
} catch (error) {
if (error instanceof BusinessError) throw new CliError(error.message, 3);
if (error instanceof BusError) throw new CliError(`business adapter refused: ${error.code}`, 3);
throw error;
}
}
// config.trackers[<business>] (lead decision 70): plain data from the
// tracker.* variables. tracker.project is a project-layer variable, so the
// entry comes from the one declared project whose file sets it. No
// tracker.baseUrl, or no project naming a tracker project, means no entry
// and no task verbs; two projects naming one each refuse, since the boot
// shape holds one tracker project per business.
function trackerFor(system, b, warn) {
const first = Object.keys(b.roles)[0];
const base = resolveInstance({ system, business: b, instance: first }).vars;
if (base["tracker.baseUrl"] === undefined) return null;
const named = [];
for (const [id, entry] of Object.entries(b.projects)) {
if (!existsSync(projectFilePath(entry.root))) continue;
const project = loadProject(entry.root);
if (project.id !== id) throw new CliError(`project file ${project.file} has id ${project.id}, but business ${b.id} declares it as ${id}`, 3);
const vars = resolveInstance({ system, business: b, project, instance: first }).vars;
if (vars["tracker.project"] !== undefined) named.push({ id, vars });
}
if (named.length > 1) {
throw new CliError(`business ${b.id}: projects ${named.map((n) => n.id).join(", ")} each set tracker.project; the broker takes one tracker project per business`, 3);
}
if (named.length === 0) {
warn(`business ${b.id} sets tracker.baseUrl, but no project file sets tracker.project; the broker gets no task verbs`);
return null;
}
const v = named[0].vars;
return {
baseUrl: v["tracker.baseUrl"],
project: v["tracker.project"],
pollSeconds: v["tracker.pollSeconds"],
reconcileMinutes: v["tracker.reconcileMinutes"],
};
}
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
// business. The host holds a reader capability for the notifier and binds
// launches later through bindLaunch, so `launches` starts empty.
export function bootConfig({ system, businessId, env = process.env, warn = () => {} }) {
return business(() => {
const vars = systemVars(system);
const b = loadBusiness(businessId, { dir: configDir(env), rolesDir: rolesDir(env) });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
const config = {
dataRoot: system.dataRoot,
businesses: { [b.id]: busBusiness(b, resolved) },
launches: [],
readers: [b.id],
};
const tracker = trackerFor(vars, b, warn);
if (tracker) config.trackers = { [b.id]: tracker };
return config;
});
}
+12
View File
@@ -0,0 +1,12 @@
// Exit codes for every `mosaic` command in this package: 0 ok; 1 failed
// (a child died, a boot timed out, an outcome is unknown); 2 invalid (bad
// input, a decision or option that doesn't exist); 3 refused (system or
// business config problem, the broker or notifier refused, the human proof
// refused, a host already running); 4 usage.
export class CliError extends Error {
constructor(message, exitCode = 2) {
super(message);
this.name = "CliError";
this.exitCode = exitCode;
}
}
+97
View File
@@ -0,0 +1,97 @@
// Plain-text views of broker results. Every function takes what the broker
// returned and keeps its order: the broker sorts, this file never re-sorts.
const one = (s, max = 160) => {
const flat = String(s ?? "").replace(/\s+/g, " ").trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
};
export const shortId = (id) => String(id).slice(0, 8);
export function optionsLine(d) {
return d.options.map((o) => `${o.key} = ${one(o.text, 80)}`).join("; ");
}
// What approving means, when the decision carries authorization context.
export function authorizationLines(d) {
const a = d.authorization;
if (!a) return [];
const lines = [`action: ${a.action}${a.target ? ` on ${a.target}` : ""}`];
lines.push(`choosing "${a.approvalChoice}" authorizes it; any other choice declines`);
return lines;
}
export function formatInbox(list) {
if (list.length === 0) return "inbox: empty\n";
const out = [`inbox: ${list.length} open decision(s)`];
for (const d of list) {
const flags = [d.class, d.blocking ? "blocking" : null].filter(Boolean).join(", ");
out.push("", `${shortId(d.id)} ${d.action} (${flags}) raised ${d.at} by ${d.raised_by_role}`);
out.push(` ${one(d.question, 400)}`);
out.push(` options: ${optionsLine(d)} (recommended: ${d.recommendation})`);
for (const l of authorizationLines(d)) out.push(` ${l}`);
if (d.task_ref) out.push(` task: ${d.task_ref}`);
out.push(` decide: mosaic decide ${shortId(d.id)} <option>`);
}
return `${out.join("\n")}\n`;
}
export function formatDecision(d) {
const out = [
`decision ${d.id}`,
` ${d.class}${d.blocking ? ", blocking" : ""}, raised ${d.at} by ${d.raised_by_role} (${d.raised_by_run})`,
` question: ${d.question}`,
` options: ${optionsLine(d)}`,
` recommended: ${d.recommendation}`,
];
for (const l of authorizationLines(d)) out.push(` ${l}`);
if (d.task_ref) out.push(` task: ${d.task_ref}`);
return `${out.join("\n")}\n`;
}
export function formatAgents(list) {
if (list.length === 0) return "agents: no role is claimed\n";
return `${list.map((c) => `${c.role} ${c.holder_run} ${c.harness ?? "-"} since ${c.at}`).join("\n")}\n`;
}
export function formatTasks(list) {
if (list.length === 0) return "tasks: none\n";
return `${list
.map((t) => {
const f = t.fields ?? {};
const title = f.title ?? f.name ?? "";
const state = f.status ?? f.state ?? (f.done === true ? "done" : f.done === false ? "open" : "");
return [t.task_ref, state, one(title, 100)].filter(Boolean).join(" ");
})
.join("\n")}\n`;
}
function trailSummary(r) {
switch (r.table) {
case "events":
return [r.kind, r.actor_role ?? null, r.body?.operation ?? null, r.body?.decision ? `decision ${shortId(r.body.decision)}` : null].filter(Boolean).join(" ");
case "decisions":
return `${r.class} ${r.action} → ${r.route_to}${r.blocking ? " (blocking)" : ""}: ${one(r.question, 120)}`;
case "decision_events":
return [r.op, r.choice ? `choice ${r.choice}` : null, `by ${r.by}`, r.via ? `via ${r.via}` : null].filter(Boolean).join(" ");
case "messages":
return `${r.from_role} → ${r.to_role}: ${one(r.body, 120)}`;
case "deliveries":
return [r.op, r.transport, r.holder_run].filter(Boolean).join(" ");
case "role_claims":
return `${r.op} ${r.role} by ${r.holder_run}`;
case "task_snapshots":
return `snapshot ${r.task_ref ?? ""}`.trim();
default:
return "";
}
}
// Rows print in the order the broker returned them (at, table, seq).
export function formatTrail(subject, rows) {
const out = [`trail ${subject}: ${rows.length} row(s)`];
for (const r of rows) out.push(`${r.at} ${r.table}#${r.seq} ${trailSummary(r)}`.trimEnd());
const decision = rows.find((r) => r.table === "decisions" && r.id === subject);
if (decision?.task_ref) out.push("", `task: ${decision.task_ref}`, `follow with: mosaic trail ${decision.task_ref}`);
return `${out.join("\n")}\n`;
}
+247
View File
@@ -0,0 +1,247 @@
// The trusted bus host (lead decision 70). It forks
// packages/bus/src/process.mjs, boots it over IPC, keeps the reader
// capability it gets back, and hands it to the notifier child over IPC.
// Capabilities never appear in argv, stdout or the environment. While the
// host runs, `<dataRoot>/bus-host/host.json` (0600) names it for `mosaic bus
// stop|status` and for the human commands' default business.
//
// startHost() is also the in-process API S6 uses: bindLaunch(record) binds
// a launched run's process identity and returns its capability.
import { fork } from "node:child_process";
import { once } from "node:events";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { CliError } from "./errors.mjs";
const BROKER = fileURLToPath(new URL("../../bus/src/process.mjs", import.meta.url));
const NOTIFIER = fileURLToPath(new URL("./notifier-process.mjs", import.meta.url));
export const BOOT_TIMEOUT_MS = 30000;
const START_TIMEOUT_MS = 15000;
const CLOSE_TIMEOUT_MS = 20000;
export const hostDir = (dataRoot) => join(dataRoot, "bus-host");
export const hostFile = (dataRoot) => join(hostDir(dataRoot), "host.json");
// `/proc/<pid>/stat` field 22, the start time in clock ticks. Null when the
// process is gone or has exited and waits to be reaped (state Z).
export function startTimeOf(pid) {
try {
const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
const fields = raw.slice(raw.lastIndexOf(")") + 2).split(" ");
return fields[0] === "Z" ? null : fields[19];
} catch {
return null;
}
}
export function cmdlineOf(pid) {
try {
return readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
} catch {
return null;
}
}
// The state file, or null. `live` is true only when the pid still runs with
// the recorded start time, so a recycled pid never counts as the host.
export function readHostState(dataRoot) {
const file = hostFile(dataRoot);
if (!existsSync(file)) return null;
let state;
try {
state = JSON.parse(readFileSync(file, "utf8"));
} catch {
throw new CliError(`bus host state is unreadable: ${file}`, 3);
}
if (!state || !Number.isSafeInteger(state.pid) || typeof state.startTime !== "string" || typeof state.business !== "string") {
throw new CliError(`bus host state is malformed: ${file}`, 3);
}
return { ...state, live: startTimeOf(state.pid) === state.startTime };
}
function writeHostState(dataRoot, state) {
mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
const file = hostFile(dataRoot);
const tmp = `${file}.${process.pid}.tmp`;
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: "wx" });
renameSync(tmp, file);
}
// The first IPC message from child, or a refusal when it exits or the wait runs out.
function firstReply(child, timeoutMs, what) {
return new Promise((resolve, reject) => {
const done = (fn, v) => {
clearTimeout(timer);
child.off("message", onMessage);
child.off("exit", onExit);
fn(v);
};
const onMessage = (m) => done(resolve, m);
const onExit = (code) => done(reject, new CliError(`${what} exited (${code}) before it replied`, 1));
const timer = setTimeout(() => done(reject, new CliError(`${what} did not reply within ${Math.round(timeoutMs / 1000)} s`, 1)), timeoutMs);
child.on("message", onMessage);
child.on("exit", onExit);
});
}
async function ended(child, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) return child.exitCode;
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
const [code] = await once(child, "exit");
clearTimeout(timer);
return code;
}
// Calls onDeath(name, code, signal) once for each child that exits. A child
// that exited before this runs (the broker while the notifier starts, say)
// has already emitted its exit event, so its exit state is checked here.
export function watchChildren(children, onDeath) {
for (const [name, child] of Object.entries(children)) {
if (!child) continue;
if (child.exitCode !== null || child.signalCode !== null) onDeath(name, child.exitCode, child.signalCode);
else child.once("exit", (code, signal) => onDeath(name, code, signal));
}
}
// boot: the {op:'boot'} config from bootConfig(). notifier: null, or
// {binding, base?, pollMs?}; base and pollMs exist for the tests, and the
// command line never sets them. Resolves once both children are up.
export async function startHost({ boot, business, notifier = null, bootTimeoutMs = BOOT_TIMEOUT_MS, log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`) }) {
const dataRoot = boot.dataRoot;
const prior = readHostState(dataRoot);
if (prior?.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
const broker = fork(BROKER, [], { stdio: ["ignore", "inherit", "inherit", "ipc"] });
const reply = firstReply(broker, bootTimeoutMs, "broker");
broker.send({ op: "boot", config: boot });
let ready;
try {
ready = await reply;
} catch (e) {
broker.kill("SIGTERM");
await ended(broker, 5000);
throw e;
}
if (ready?.ok !== true) {
await ended(broker, 5000);
throw new CliError(`broker refused to start: ${typeof ready?.error === "string" ? ready.error : "startup-refused"}`, 3);
}
const reader = ready.readers.find((r) => r.business === business);
let notify = null;
if (notifier) {
notify = fork(NOTIFIER, [], { stdio: ["ignore", "inherit", "inherit", "ipc"] });
const started = firstReply(notify, START_TIMEOUT_MS, "notifier");
notify.send({ op: "start", path: ready.path, cap: reader.cap, business, dataRoot, binding: notifier.binding, base: notifier.base, pollMs: notifier.pollMs });
let ok;
try {
ok = await started;
} catch (e) {
notify.kill("SIGTERM");
await ended(notify, 5000);
broker.send({ op: "close" });
await ended(broker, CLOSE_TIMEOUT_MS);
throw e;
}
if (ok?.ok !== true) {
await ended(notify, 5000);
broker.send({ op: "close" });
await ended(broker, CLOSE_TIMEOUT_MS);
throw new CliError(`notifier refused to start: ${typeof ok?.error === "string" ? ok.error : "notifier-refused"}`, 3);
}
}
const state = { hostVersion: 1, pid: process.pid, startTime: startTimeOf(process.pid), business, startedAt: new Date().toISOString(), notifier: notifier ? notifier.binding : null };
rmSync(hostFile(dataRoot), { force: true });
writeHostState(dataRoot, state);
let closing = false;
let finish;
const done = new Promise((r) => (finish = r));
// Replies from process.mjs carry no request id, so requests go one at a time.
let queue = Promise.resolve();
function bindLaunch(record) {
const run = queue.then(async () => {
if (closing) throw new CliError("bus host is closing", 1);
const r = firstReply(broker, 10000, "broker");
broker.send({ op: "bindLaunch", record });
const m = await r;
if (m?.ok !== true) throw new CliError(`launch bind refused: ${m?.error ?? "bind-refused"}`, 3);
return m.launch;
});
queue = run.catch(() => {});
return run;
}
async function close(code = 0) {
if (closing) return done;
closing = true;
let result = code;
if (notify) {
if (notify.connected) notify.send({ op: "stop" });
if ((await ended(notify, CLOSE_TIMEOUT_MS)) !== 0 && result === 0) result = 1;
}
await queue;
if (broker.connected) broker.send({ op: "close" });
if ((await ended(broker, CLOSE_TIMEOUT_MS)) !== 0 && result === 0) result = 1;
const now = readHostState(dataRoot);
if (now && now.pid === state.pid && now.startTime === state.startTime) rmSync(hostFile(dataRoot), { force: true });
finish(result);
return done;
}
// A child that dies on its own takes the host down with exit 1: the unit
// restarts the pair rather than run a broker without its notifier.
// Runs after `queue` exists, since close() awaits it.
watchChildren({ broker, notifier: notify }, (name, code, signal) => {
if (closing) return;
log(`${name} exited (${signal ?? code}); stopping the host`);
close(1);
});
return Object.freeze({ path: ready.path, business, bindLaunch, close, done, pids: Object.freeze({ broker: broker.pid, notifier: notify?.pid ?? null }) });
}
// `mosaic bus stop`: SIGTERM to the recorded host, after checking that the
// pid still runs with the recorded start time and is a `bus start` process.
export async function stopHost(dataRoot, { timeoutMs = 60000 } = {}) {
const state = readHostState(dataRoot);
if (!state || !state.live) return { stopped: false, reason: state ? "stale state file; no host runs" : "no host runs" };
const argv = cmdlineOf(state.pid) ?? [];
const i = argv.findIndex((a) => a.endsWith("/packages/cli/src/cli.mjs"));
if (i < 0 || argv[i + 1] !== "bus" || argv[i + 2] !== "start") {
throw new CliError(`pid ${state.pid} is not a bus host; refusing to signal it`, 3);
}
process.kill(state.pid, "SIGTERM");
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (startTimeOf(state.pid) !== state.startTime) return { stopped: true, business: state.business, pid: state.pid };
await new Promise((r) => setTimeout(r, 200));
}
throw new CliError(`bus host pid ${state.pid} did not stop within ${Math.round(timeoutMs / 1000)} s`, 1);
}
// `mosaic bus status`: what the files and /proc say. Never removes a lock.
export function hostStatus(dataRoot) {
const state = readHostState(dataRoot);
const lock = join(dataRoot, "bus", "writer.lock");
// The bus store writes {pid, at}. A dead pid means a broker was killed
// hard; the bus README leaves removing the lock to the operator.
let owner = null;
if (existsSync(lock)) {
try {
owner = JSON.parse(readFileSync(lock, "utf8"));
} catch {
owner = {};
}
}
const lockPid = Number.isSafeInteger(owner?.pid) ? owner.pid : null;
return {
host: state ? { business: state.business, pid: state.pid, startedAt: state.startedAt, notifier: state.notifier ?? null, live: state.live } : null,
socket: existsSync(join(dataRoot, "bus", "broker.sock")),
writerLock: owner ? { pid: lockPid, at: typeof owner.at === "string" ? owner.at : null, live: lockPid !== null && startTimeOf(lockPid) !== null } : null,
};
}
+8
View File
@@ -0,0 +1,8 @@
// @mosaic/cli: the human commands and the bus host. See README.md.
export { CliError } from "./errors.mjs";
export { bootConfig, loadSystem, rolesDir, socketPath } from "./config.mjs";
export { AGENT_MARKERS, HUMAN_CLI, busExit, humanTransport, refuseInsideAgent } from "./transport.mjs";
export { authorizationLines, formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, optionsLine, shortId } from "./format.mjs";
export { DIGEST_HOUR, POLL_MS, ZONE, createNotifier, digestContent, digestNonce, dmContent, dmNonce, journalPath, openJournal, runLoop, zoned } from "./notifier.mjs";
export { BOOT_TIMEOUT_MS, hostFile, hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
export { USAGE, main, readNotifyConfig } from "./cli.mjs";
+52
View File
@@ -0,0 +1,52 @@
// The notifier as a child of the bus host. Started with fork(); the reader
// capability arrives over IPC in `{op: 'start'}` and never touches argv,
// stdout or the environment. Replies `{ok: true}` once the binding, the
// token file and the journal check out, or `{ok: false, error}` and exits
// 3. `{op: 'stop'}`, SIGTERM or the host going away stop it after the poll
// in flight.
import { Client } from "../../bus/src/client.mjs";
import { openDirect } from "../../discord/src/notify.mjs";
import { createNotifier, runLoop } from "./notifier.mjs";
const log = (line) => process.stderr.write(`mosaic-notify: ${line}\n`);
let loop = null;
let started = false;
let stopping = false;
async function stop(code) {
if (stopping) return;
stopping = true;
try {
await loop?.stop();
} catch {
code = 1;
}
process.exitCode = code;
if (process.connected) process.disconnect();
}
if (!process.send) {
process.stderr.write("trusted-host-required\n");
process.exitCode = 2;
} else {
const timer = setTimeout(() => stop(2), 10000);
process.on("message", (m) => {
if (m?.op === "stop") return stop(0);
if (m?.op !== "start" || started) return;
started = true;
clearTimeout(timer);
try {
const client = new Client({ path: m.path, cap: m.cap });
const direct = openDirect({ dataRoot: m.dataRoot, name: m.binding, ...(m.base ? { base: m.base } : {}), log });
const notifier = createNotifier({ business: m.business, dataRoot: m.dataRoot, inbox: () => client.call("inbox"), direct, log });
loop = runLoop(notifier, { pollMs: m.pollMs ?? undefined, log });
process.send({ ok: true });
} catch (e) {
// DiscordError and CliError messages name files, never a token or id.
process.send({ ok: false, error: e?.message ?? "notifier-refused" }, () => stop(3));
}
});
process.on("disconnect", () => stop(stopping ? process.exitCode : 2));
process.on("SIGTERM", () => stop(0));
process.on("SIGINT", () => {});
}
+276
View File
@@ -0,0 +1,276 @@
// The notifier (lead decision 70): every poll it reads the human inbox
// through a reader capability, DMs each open blocking decision once, and
// sends one digest a day at 08:00 America/Chicago. It never writes to the
// bus; its memory is the journal `<dataRoot>/notify/<business>/sent.jsonl`
// (0600, in a 0700 directory), one line per send attempt:
//
// {at, kind: "dm"|"digest", decision, day?, outcome: "confirmed"|"refused"|"unknown", messageId, status?}
//
// A decision counts as sent once it has a confirmed line; a day's digest
// likewise. A refused or unknown send is retried with backoff (30 s
// doubling to 30 min): a duplicate costs less than a miss, and Discord's
// nonce folds a retry inside its dedupe window into the first message.
// No Discord channel or user id goes in the journal, a log line or an
// error; the Discord side (packages/discord/src/notify.mjs) keeps them.
import { createHash } from "node:crypto";
import { closeSync, constants, fstatSync, fsyncSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
import { dirname, join } from "node:path";
import { CliError } from "./errors.mjs";
import { authorizationLines, optionsLine, shortId } from "./format.mjs";
export const ZONE = "America/Chicago";
export const DIGEST_HOUR = 8;
export const POLL_MS = 30000;
const BACKOFF_MS = 30000;
const BACKOFF_MAX_MS = 30 * 60 * 1000;
const LIMIT = 2000;
export const journalPath = (dataRoot, business) => join(dataRoot, "notify", business, "sent.jsonl");
// Local date and hour in the IANA zone. An unknown zone throws RangeError,
// so a broken time-zone database refuses rather than guessing.
export function zoned(date, zone = ZONE) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", hourCycle: "h23" })
.formatToParts(date)
.map((p) => [p.type, p.value]),
);
return { day: `${parts.year}-${parts.month}-${parts.day}`, hour: Number(parts.hour) };
}
// Discord nonces are at most 25 characters. The digest nonce carries the
// business, so two businesses sharing a bot and a recipient never send the
// same nonce on the same day.
const hash23 = (text) => createHash("sha256").update(text).digest("hex").slice(0, 23);
export const dmNonce = (id) => `dm${hash23(String(id))}`;
export const digestNonce = (business, day) => `dg${hash23(`${business}\n${day}`)}`;
function clip(text, max) {
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}
export function dmContent(business, d) {
const lines = [
`Mosaic (${business}): a blocking decision needs you.`,
clip(d.question, 1000),
`options: ${optionsLine(d)} (recommended: ${d.recommendation})`,
...authorizationLines(d),
`raised by ${d.raised_by_role}${d.task_ref ? `, task ${d.task_ref}` : ""}`,
`decide: mosaic decide ${shortId(d.id)} <option>`,
];
return clip(lines.join("\n"), LIMIT);
}
export function digestContent(business, day, inbox, dmSent) {
if (inbox.length === 0) return `Mosaic digest (${business}, ${day}): your inbox is empty.`;
const head = `Mosaic digest (${business}, ${day}): ${inbox.length} open decision(s).`;
const tail = "Run mosaic inbox for the full list.";
const lines = [head];
let shown = 0;
for (const d of inbox) {
const mark = d.blocking ? (dmSent(d.id) ? "[blocking, DM sent] " : "[blocking, DM pending] ") : "";
const line = `- ${mark}${shortId(d.id)} ${d.action}: ${clip(d.question.replace(/\s+/g, " "), 160)}`;
const more = inbox.length - shown - 1;
const reserve = more > 0 ? `\n… and ${more} more.`.length : 0;
if ([...lines, line].join("\n").length + reserve + tail.length + 1 > LIMIT) break;
lines.push(line);
shown++;
}
if (shown < inbox.length) lines.push(`… and ${inbox.length - shown} more.`);
lines.push(tail);
return lines.join("\n");
}
const { O_APPEND, O_CREAT, O_NOFOLLOW, O_RDWR, O_WRONLY } = constants;
// UTC stamp for a torn-tail copy, e.g. 20261008T235212345Z.
const stamp = (date) => date.toISOString().replace(/[-:.]/g, "");
// Step 1 of a torn-tail repair (lead decision 71): the torn bytes go to a
// new file, torn-<UTC stamp>.bin (0600), fsynced with its directory. A name
// that exists already gets a counter, so a repeat never overwrites a copy.
function copyTorn(dir, bytes, date) {
for (let n = 0; ; n++) {
const name = `torn-${stamp(date)}${n ? `-${n}` : ""}.bin`;
let fd;
try {
fd = openSync(join(dir, name), O_WRONLY | O_CREAT | constants.O_EXCL | O_NOFOLLOW, 0o600);
} catch (e) {
if (e.code === "EEXIST") continue;
throw e;
}
try {
writeSync(fd, bytes);
fsyncSync(fd);
} finally {
closeSync(fd);
}
const dfd = openSync(dir, constants.O_RDONLY);
try {
fsyncSync(dfd);
} finally {
closeSync(dfd);
}
return name;
}
}
// Opens (creating if needed) the journal. The directory must be 0700 or
// tighter and the file 0600, both owned by this user; a symlinked journal
// refuses. Every complete line must parse, or the open refuses with exit 3.
// A final line without its newline is a write that never finished (lead
// decision 71): its bytes are copied to torn-<stamp>.bin, then the journal
// is truncated to its last newline and fsynced, and both steps are logged.
// A crash between the two leaves the tail torn, and the next open repeats
// both; the second copy is harmless.
export function openJournal(file, { log = () => {}, now = () => new Date() } = {}) {
const dir = dirname(file);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const ds = lstatSync(dir);
if (!ds.isDirectory() || ds.uid !== process.getuid() || (ds.mode & 0o077) !== 0) {
throw new CliError(`notify journal directory must be mode 0700 and owned by this user: ${dir}`, 3);
}
let fd;
try {
fd = openSync(file, O_RDWR | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600);
} catch (e) {
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink: ${file}`, 3);
throw e;
}
const sent = new Set();
const days = new Set();
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
}
const bytes = readFileSync(fd);
const end = bytes.lastIndexOf(0x0a) + 1;
const lines = bytes.subarray(0, end).toString("utf8").split("\n");
lines.pop();
lines.forEach((line, i) => {
let r;
try {
r = JSON.parse(line);
} catch {
r = null;
}
if (!r || typeof r !== "object" || !["dm", "digest"].includes(r.kind)) {
throw new CliError(`notify journal line ${i + 1} is malformed: ${file}`, 3);
}
if (r.outcome !== "confirmed") return;
if (r.kind === "dm") sent.add(r.decision);
else days.add(r.day);
});
if (end < bytes.length) {
const name = copyTorn(dir, bytes.subarray(end), now());
log(`notify journal: copied a torn final line (${bytes.length - end} bytes) to ${name}`);
ftruncateSync(fd, end);
fsyncSync(fd);
log(`notify journal: truncated ${file} to its last newline (${end} bytes)`);
}
} finally {
closeSync(fd);
}
return {
sent,
days,
append(record) {
const afd = openSync(file, O_WRONLY | O_APPEND | O_NOFOLLOW);
try {
writeSync(afd, `${JSON.stringify(record)}\n`);
} finally {
closeSync(afd);
}
if (record.outcome !== "confirmed") return;
if (record.kind === "dm") sent.add(record.decision);
else days.add(record.day);
},
};
}
// inbox(): the broker's inbox through the reader capability.
// direct.send({content, nonce}) resolves {messageId} or throws a RestOutcome.
export function createNotifier({ business, dataRoot, inbox, direct, now = () => new Date(), zone = ZONE, log = () => {} }) {
zoned(now(), zone);
const journal = openJournal(journalPath(dataRoot, business), { log, now });
const backoff = new Map();
function waiting(key, t) {
const b = backoff.get(key);
return b !== undefined && t < b.next;
}
async function attempt(key, record, message) {
const t = now().getTime();
try {
const { messageId } = await direct.send(message);
backoff.delete(key);
journal.append({ at: new Date(t).toISOString(), ...record, outcome: "confirmed", messageId });
return true;
} catch (err) {
const kind = err?.kind === "refused" ? "refused" : "unknown";
const status = Number.isInteger(err?.details?.status) ? err.details.status : null;
const n = (backoff.get(key)?.n ?? -1) + 1;
backoff.set(key, { n, next: t + Math.min(BACKOFF_MS * 2 ** n, BACKOFF_MAX_MS) });
journal.append({ at: new Date(t).toISOString(), ...record, outcome: kind, messageId: null, ...(status !== null ? { status } : {}) });
log(`notify: ${record.kind} ${kind}${status !== null ? ` (HTTP ${status})` : ""}; retry in ${Math.round(Math.min(BACKOFF_MS * 2 ** n, BACKOFF_MAX_MS) / 1000)} s`);
return false;
}
}
// One poll. Returns what it did, for the tests and the log.
async function tick() {
const done = { dms: 0, digest: false, failed: 0 };
let list;
try {
list = await inbox();
} catch (err) {
log(`notify: inbox read failed (${err?.code ?? err?.message ?? "error"}); next poll retries`);
return { ...done, inboxError: true };
}
const t = now();
for (const d of list) {
if (!d.blocking || journal.sent.has(d.id)) continue;
const key = `dm:${d.id}`;
if (waiting(key, t.getTime())) continue;
if (await attempt(key, { kind: "dm", decision: d.id }, { content: dmContent(business, d), nonce: dmNonce(d.id) })) done.dms++;
else done.failed++;
}
const { day, hour } = zoned(t, zone);
const key = `digest:${day}`;
if (hour >= DIGEST_HOUR && !journal.days.has(day) && !waiting(key, t.getTime())) {
const content = digestContent(business, day, list, (id) => journal.sent.has(id));
if (await attempt(key, { kind: "digest", decision: null, day }, { content, nonce: digestNonce(business, day) })) done.digest = true;
else done.failed++;
}
return done;
}
return Object.freeze({ tick, journal: journalPath(dataRoot, business) });
}
// Runs tick() every pollMs until stop(). Ticks never overlap.
export function runLoop(notifier, { pollMs = POLL_MS, log = () => {} } = {}) {
let timer = null;
let stopped = false;
let running = Promise.resolve();
const loop = () => {
if (stopped) return;
running = notifier
.tick()
.catch((err) => log(`notify: poll failed: ${err?.message ?? err}`))
.finally(() => {
if (!stopped) timer = setTimeout(loop, pollMs);
});
};
loop();
return {
async stop() {
stopped = true;
clearTimeout(timer);
await running;
},
};
}
+75
View File
@@ -0,0 +1,75 @@
// The human transport (lead decision 70): run
// `packages/bus/src/human-cli.mjs <socket>` as a child, write
// `{business, verb, args}` on its stdin, read the JSON reply on its stdout,
// or one bus error code on its stderr. The bus does the proof: the child
// re-executes itself with a nonce and the broker checks the process and its
// ancestry for agent markers. Nothing here carries a capability.
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { CliError } from "./errors.mjs";
export const HUMAN_CLI = fileURLToPath(new URL("../../bus/src/human-cli.mjs", import.meta.url));
// The broker's markers (packages/bus/src/human.mjs). The broker checks the
// whole ancestry; this check is only the clear early message for the case
// it would refuse anyway.
export const AGENT_MARKERS = Object.freeze([
"MOSAIC_BUS_CAP",
"MOSAIC_RUN_ID",
"MOSAIC_AGENT_RUN",
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CODEX_THREAD_ID",
"PI_AGENT_DIR",
]);
export function refuseInsideAgent(env = process.env) {
const found = AGENT_MARKERS.filter((k) => env[k]);
if (found.length > 0) {
throw new CliError(`refused: this runs only from a human shell, outside any agent run (found ${found.join(", ")} in the environment)`, 3);
}
}
// Exit codes by bus error code; anything else is invalid input (2).
const EXIT = {
"human-required": 3,
unauthenticated: 3,
"unknown-business": 3,
"read-only": 3,
"outcome-unknown": 1,
"response-too-large": 1,
"invalid-response": 1,
};
export function busExit(code) {
return EXIT[code] ?? 2;
}
// Returns a call(verb, args) for one business. `cli` and `spawn` exist for
// the tests; the command line never sets them.
export function humanTransport({ socket, business, env = process.env, cli = HUMAN_CLI, spawn = spawnSync, timeoutMs = 30000 }) {
return async function call(verb, args = {}) {
const proc = spawn(process.execPath, [cli, socket], {
input: `${JSON.stringify({ business, verb, args })}\n`,
encoding: "utf8",
env,
timeout: timeoutMs,
maxBuffer: 8 * 1024 * 1024,
});
if (proc.error || proc.status === null) throw new CliError("outcome-unknown: the bus transport did not finish", 1);
if (proc.status !== 0) {
const code = String(proc.stderr ?? "").trim().split("\n").reverse().find((l) => /^[a-z-]{1,64}$/.test(l)) ?? "invalid-response";
const error = new CliError(code, busExit(code));
error.code = code;
throw error;
}
try {
return JSON.parse(proc.stdout);
} catch {
const error = new CliError("invalid-response", 1);
error.code = "invalid-response";
throw error;
}
};
}
@@ -0,0 +1,33 @@
# Mosaic bus host, one instance per business: mosaic-bus@<business>.
# Rendered by scripts/bus-service.sh from packages/cli/systemd/; @REPO@ and
# @PATH@ are filled in at install time. Edit the template and reinstall; do
# not edit the installed copy. The bus store allows one broker per data
# root, so run one instance per data root (packages/cli/README.md).
[Unit]
Description=Mosaic bus host (%i)
Documentation=file://@REPO@/packages/cli/README.md
StartLimitIntervalSec=600
StartLimitBurst=5
[Service]
Type=simple
WorkingDirectory=@REPO@
Environment=PATH=@PATH@
# The host reads the system config and the business file, boots the broker
# (packages/bus/src/process.mjs) and starts the notifier. Config problems and
# refusals exit 3, usage 4, invalid input 2: none of those is retried. A
# child that dies takes the host down with exit 1, and the unit restarts
# the pair.
ExecStart=@REPO@/scripts/mosaic bus start %i
Restart=on-failure
RestartSec=15
RestartPreventExitStatus=2 3 4
# `systemctl --user stop` sends SIGTERM to the host, which stops the
# notifier after its poll in flight, then closes the broker.
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec=60
NoNewPrivileges=yes
[Install]
WantedBy=default.target
+187
View File
@@ -0,0 +1,187 @@
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);
});
+67
View File
@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { writeJson } from "../../business/tests/helpers.mjs";
import { fixture, tmp } from "./helpers.mjs";
test("bootConfig builds the broker's boot message for one business, with no trackers key when no project names one", (t) => {
const f = fixture(tmp(t));
const system = loadSystem({ env: f.env });
const warnings = [];
const boot = bootConfig({ system, businessId: "acme", env: f.env, warn: (w) => warnings.push(w) });
assert.equal(boot.dataRoot, f.dataRoot);
assert.deepEqual(Object.keys(boot.businesses), ["acme"]);
assert.deepEqual(boot.launches, []);
assert.deepEqual(boot.readers, ["acme"]);
assert.equal("trackers" in boot, false);
assert.match(warnings[0], /no project file sets tracker\.project/);
});
test("trackers come from the tracker.* variables of the one project that names a tracker project", (t) => {
const root = tmp(t);
const f = fixture(root);
writeJson(join(root, "project", ".mosaic", "project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": 12 } });
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
assert.deepEqual(boot.trackers, { acme: { baseUrl: "http://127.0.0.1:3456", project: 12, pollSeconds: 60, reconcileMinutes: 60 } });
});
test("with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict", (t) => {
const root = tmp(t);
const f = fixture(root, "acme", (doc) => {
doc.projects.web = { root: join(root, "web") };
return doc;
});
writeJson(join(root, "project", ".mosaic", "project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": 12 } });
writeJson(join(root, "web", ".mosaic", "project.json"), { projectVersion: 1, id: "web", vars: {} });
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
assert.deepEqual(boot.trackers, { acme: { baseUrl: "http://127.0.0.1:3456", project: 12, pollSeconds: 60, reconcileMinutes: 60 } });
});
test("two projects that each name a tracker project refuse, since the boot shape holds one", (t) => {
const root = tmp(t);
const f = fixture(root, "acme", (doc) => {
doc.projects.web = { root: join(root, "web") };
return doc;
});
writeJson(join(root, "project", ".mosaic", "project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": 12 } });
writeJson(join(root, "web", ".mosaic", "project.json"), { projectVersion: 1, id: "web", vars: { "tracker.project": 13 } });
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env }), (e) => e.exitCode === 3 && /stack, web each set tracker\.project/.test(e.message));
});
test("a business without tracker.baseUrl gets no trackers entry", (t) => {
const f = fixture(tmp(t), "acme", (doc) => {
delete doc.vars["tracker.baseUrl"];
return doc;
});
const warnings = [];
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env, warn: (w) => warnings.push(w) });
assert.equal("trackers" in boot, false);
assert.deepEqual(warnings, []);
});
test("an unknown business and a broken system config refuse with exit 3", (t) => {
const f = fixture(tmp(t));
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: "nope", env: f.env }), (e) => e.exitCode === 3);
assert.throws(() => loadSystem({ env: { ...f.env, MOSAIC_CONFIG: join(f.dataRoot, "missing.json") } }), (e) => e.exitCode === 3);
});
+27
View File
@@ -0,0 +1,27 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { formatAgents, formatInbox, formatTasks, formatTrail } from "../src/format.mjs";
test("empty views say so", () => {
assert.equal(formatInbox([]), "inbox: empty\n");
assert.equal(formatAgents([]), "agents: no role is claimed\n");
assert.equal(formatTasks([]), "tasks: none\n");
});
test("the trail keeps the broker's order and names a decision's task without its rows", () => {
const rows = [
{ at: "2026-10-08T10:00:00Z", table: "decisions", seq: 4, id: "d-1", class: "gated", action: "deploy", route_to: "human", blocking: 1, question: "Ship?", task_ref: "vikunja:1/7" },
{ at: "2026-10-08T09:00:00Z", table: "events", seq: 2, kind: "decision.raised", actor_role: "coder" },
{ at: "2026-10-08T11:00:00Z", table: "decision_events", seq: 1, op: "resolved", choice: "yes", by: "jason", via: "cli" },
];
const lines = formatTrail("d-1", rows).trimEnd().split("\n");
assert.deepEqual(lines.slice(1, 4).map((l) => l.split(" ")[1]), ["decisions#4", "events#2", "decision_events#1"]);
assert.match(lines[1], /gated deploy → human \(blocking\): Ship\?/);
assert.match(lines[3], /resolved choice yes by jason via cli/);
assert.deepEqual(lines.slice(-2), ["task: vikunja:1/7", "follow with: mosaic trail vikunja:1/7"]);
assert.doesNotMatch(formatTrail("vikunja:1/7", rows), /follow with/);
});
test("tasks print the tracker fields the snapshot carries", () => {
assert.equal(formatTasks([{ task_ref: "vikunja:1/7", fields: { title: "Build S4", done: false } }]), "vikunja:1/7 open Build S4\n");
});
+113
View File
@@ -0,0 +1,113 @@
// Shared fixtures. Every test works in its own temporary directory and
// points MOSAIC_CONFIG there; nothing reads the real ~/.config, a real
// token, a real binding, or the real human transport.
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { BusError, Broker } from "../../bus/src/broker.mjs";
import { Store } from "../../bus/src/store.mjs";
import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
import { AGENT_MARKERS } from "../src/transport.mjs";
export function tmp(t, prefix = "mosaic-cli-") {
const root = mkdtempSync(join(tmpdir(), prefix));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
// The environment with every agent marker removed: the test runner itself
// may run under an agent, and the commands refuse there.
export function humanEnv(extra = {}) {
const env = { ...process.env, ...extra };
for (const k of AGENT_MARKERS) delete env[k];
delete env.NODE_TEST_CONTEXT;
return env;
}
// A config directory with the system config, the roles and business `id`.
export function fixture(root, id = "acme", edit = (doc) => doc) {
const config = systemConfig(root);
const roles = rolesCopy(root);
const doc = edit(businessDoc(root, id));
writeJson(join(root, "config", "businesses", `${id}.json`), doc);
return { config, roles, dataRoot: join(root, "data"), doc, env: humanEnv({ MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: roles }) };
}
export function notifyConfig(dataRoot, business, binding) {
// The journal sits beside notify.json and refuses a directory looser than 0700.
mkdirSync(join(dataRoot, "notify", business), { recursive: true, mode: 0o700 });
return writeJson(join(dataRoot, "notify", business, "notify.json"), { notifyVersion: 1, binding });
}
// An io for main(): captured stdout and stderr, a stdin that is not a TTY.
export function io(env = humanEnv()) {
const out = { text: "" };
const err = { text: "" };
const stdin = new PassThrough();
stdin.isTTY = false;
return {
env,
stdin,
stdout: { write: (s) => ((out.text += s), true) },
stderr: { write: (s) => ((err.text += s), true) },
out,
err,
};
}
export const OPTIONS = [
{ key: "yes", text: "Allow" },
{ key: "no", text: "Decline" },
];
export const BUSINESSES = {
demo: {
id: "demo",
human: "jason",
arbiters: { technical: "cto", delivery: "pm" },
roles: {
pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
cto: { authority: { withinRole: ["message.send"], crossRole: [] } },
coder: { authority: { withinRole: ["message.send"], crossRole: ["task.scope.change"] } },
},
},
};
// An in-process broker with a claimed coder and the human's capability.
// transport(business) mirrors humanTransport: errors carry the bus code.
export function broker(t) {
const root = tmp(t, "mosaic-cli-bus-");
const store = new Store(root);
t.after(() => store.close());
const b = new Broker({ store, businesses: BUSINESSES });
const coder = b.bindLaunch({ business: "demo", role: "coder", run: "coder-run", harness: "pi", address: "coder-run" });
b.request(coder, { verb: "role.claim" });
const human = b.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
const reader = b.bindReader({ business: "demo" });
const calls = [];
const transport = (business) => async (verb, args = {}) => {
calls.push({ business, verb, args });
try {
return structuredClone(b.request(human, { verb, args }));
} catch (e) {
if (!(e instanceof BusError)) throw e;
const error = new Error(e.code);
error.code = e.code;
throw error;
}
};
const raise = (action, extra = {}) =>
b.request(coder, { verb: "decision.raise", args: { action, question: "Push the release?", options: OPTIONS, recommendation: "no", blocking: false, ...extra } });
return { b, store, coder, human, reader, transport, calls, raise, read: (verb, args = {}) => structuredClone(b.request(reader, { verb, args })) };
}
// A script standing in for packages/bus/src/human-cli.mjs: it reads one
// request on stdin and answers from the table in its first argument.
export function fakeCli(root, body) {
const file = join(root, "fake-human-cli.mjs");
writeFileSync(file, body);
chmodSync(file, 0o600);
return file;
}
+222
View File
@@ -0,0 +1,222 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { createServer } from "node:http";
import { once } from "node:events";
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "../../bus/src/client.mjs";
import { bootConfig, loadSystem, REPO } from "../src/config.mjs";
import { hostDir, hostFile, hostStatus, startHost, startTimeOf, stopHost, watchChildren } from "../src/host.mjs";
import { journalPath } from "../src/notifier.mjs";
import { IDS, makeDeployment } from "../../discord/tests/helpers.mjs";
import { fixture, notifyConfig, OPTIONS, tmp } from "./helpers.mjs";
const CLI = fileURLToPath(new URL("../src/cli.mjs", import.meta.url));
const CHANNEL = "100000000000000900";
const TOKEN = "MTAw.abcdefghijklmnopqrstuvwxyz0123456789";
// A fake Discord REST on 127.0.0.1: opens one DM channel, accepts messages.
async function fakeDiscord(t) {
const requests = [];
let n = 0;
const server = createServer((req, res) => {
let body = "";
req.on("data", (b) => (body += b));
req.on("end", () => {
requests.push({ method: req.method, url: req.url, body: body ? JSON.parse(body) : null, authorized: req.headers.authorization === `Bot ${TOKEN}` });
res.setHeader("content-type", "application/json");
if (req.url === "/users/@me/channels") return res.end(JSON.stringify({ id: CHANNEL, type: 1 }));
if (req.url === `/channels/${CHANNEL}/messages`) return res.end(JSON.stringify({ id: `30000000000000${String(++n).padStart(4, "0")}` }));
res.statusCode = 404;
res.end("{}");
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
t.after(() => server.close());
return { base: `http://127.0.0.1:${server.address().port}`, requests, dms: () => requests.filter((r) => r.url.endsWith("/messages") && r.body.nonce.startsWith("dm")) };
}
async function until(fn, ms = 8000) {
const end = Date.now() + ms;
while (Date.now() < end) {
if (fn()) return;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
const procText = (pid, what) => {
try {
return readFileSync(`/proc/${pid}/${what}`, "utf8");
} catch {
return "";
}
};
test("the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once", async (t) => {
const root = tmp(t);
const f = fixture(root);
makeDeployment(root, { dmRecipient: IDS.owner });
const discord = await fakeDiscord(t);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
assert.equal("trackers" in boot, false);
const logs = [];
const host = await startHost({ boot, business: "acme", notifier: { binding: "test-seat", base: discord.base, pollMs: 100 }, log: (l) => logs.push(l) });
t.after(() => host.close(0));
const state = JSON.parse(readFileSync(hostFile(f.dataRoot), "utf8"));
assert.equal(statSync(hostFile(f.dataRoot)).mode & 0o777, 0o600);
assert.deepEqual(Object.keys(state).sort(), ["business", "hostVersion", "notifier", "pid", "startTime", "startedAt"]);
assert.equal(hostStatus(f.dataRoot).host.live, true);
const launch = await host.bindLaunch({ business: "acme", role: "coder", run: "coder-run", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
assert.equal(launch.run, "coder-run");
const coder = new Client({ path: host.path, cap: launch.cap });
await coder.call("role.claim");
const d = await coder.call("decision.raise", { action: "git.push.protected", target: "refactor", question: "Push?", options: OPTIONS, recommendation: "no", blocking: true, task_ref: "vikunja:1/7" });
await until(() => discord.dms().length === 1);
await new Promise((r) => setTimeout(r, 500));
assert.equal(discord.dms().length, 1, "five more polls send nothing new");
assert.ok(discord.requests.every((r) => r.authorized));
assert.match(discord.dms()[0].body.content, new RegExp(`mosaic decide ${d.id.slice(0, 8)}`));
// No capability in a child's argv or environment, or in the state file.
for (const pid of Object.values(host.pids)) {
assert.ok(!procText(pid, "cmdline").includes(launch.cap));
assert.ok(!procText(pid, "environ").includes(launch.cap));
}
assert.ok(!readFileSync(hostFile(f.dataRoot), "utf8").includes(launch.cap));
assert.equal(await host.close(0), 0);
const journal = readFileSync(journalPath(f.dataRoot, "acme"), "utf8");
for (const id of [IDS.owner, CHANNEL, TOKEN]) assert.ok(!journal.includes(id));
assert.equal(journal.trim().split("\n").filter((l) => JSON.parse(l).kind === "dm").length, 1);
assert.equal(existsSync(hostFile(f.dataRoot)), false);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
});
test("a notifier that dies takes the host down with exit 1, so the unit restarts the pair", async (t) => {
const root = tmp(t);
const f = fixture(root);
makeDeployment(root, { dmRecipient: IDS.owner });
const discord = await fakeDiscord(t);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const logs = [];
const host = await startHost({ boot, business: "acme", notifier: { binding: "test-seat", base: discord.base, pollMs: 100 }, log: (l) => logs.push(l) });
process.kill(host.pids.notifier, "SIGKILL");
assert.equal(await host.done, 1);
assert.match(logs.join("\n"), /notifier exited \(SIGKILL\); stopping the host/);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
assert.equal(existsSync(hostFile(f.dataRoot)), false);
});
test("a notifier that refuses stops the broker and the host refuses with exit 3", async (t) => {
const root = tmp(t);
const f = fixture(root);
makeDeployment(root);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const started = startHost({ boot, business: "acme", notifier: { binding: "test-seat" }, log: () => {} });
// If the refusal regresses, the host starts; close it so the file still ends.
t.after(async () => (await started.catch(() => null))?.close(0));
await assert.rejects(started, (e) => e.exitCode === 3 && /no dmRecipient/.test(e.message));
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
assert.equal(existsSync(hostFile(f.dataRoot)), false);
});
test("watchChildren reports a child that died before it was called, and one that dies later", async (t) => {
const early = spawn(process.execPath, ["-e", "process.exit(7)"], { stdio: "ignore" });
await once(early, "exit");
const late = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: "ignore" });
t.after(() => late.kill("SIGKILL"));
await once(late, "spawn");
const deaths = [];
watchChildren({ broker: early, notifier: late, none: null }, (...d) => deaths.push(d));
assert.deepEqual(deaths, [["broker", 7, null]], "the exit before the watch is not lost");
late.kill("SIGTERM");
await once(late, "exit");
assert.deepEqual(deaths, [["broker", 7, null], ["notifier", null, "SIGTERM"]]);
});
test("bus stop refuses to signal a live pid that is not a bus host", async (t) => {
const dataRoot = tmp(t);
const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: "ignore" });
t.after(() => child.kill("SIGKILL"));
await once(child, "spawn");
mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: child.pid, startTime: startTimeOf(child.pid), business: "acme" }), { mode: 0o600 });
await assert.rejects(stopHost(dataRoot, { timeoutMs: 1000 }), (e) => e.exitCode === 3 && /is not a bus host; refusing to signal it/.test(e.message));
await new Promise((r) => setTimeout(r, 200));
assert.equal(child.exitCode, null);
assert.equal(child.signalCode, null, "the child was not signalled");
});
test("bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock", (t) => {
const f = fixture(tmp(t));
notifyConfig(f.dataRoot, "acme", null);
mkdirSync(join(f.dataRoot, "bus"), { recursive: true, mode: 0o700 });
writeFileSync(join(f.dataRoot, "bus", "writer.lock"), JSON.stringify({ pid: 999999999, at: "2026-10-08T00:00:00Z" }), { mode: 0o600 });
const r = spawnSync(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, encoding: "utf8", timeout: 40000 });
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /broker refused to start: startup-refused/);
const s = spawnSync(process.execPath, [CLI, "bus", "status"], { env: f.env, encoding: "utf8" });
assert.equal(s.status, 0, s.stderr);
assert.match(s.stdout, /host: none/);
assert.match(s.stdout, /writer\.lock: pid 999999999 \(not running/);
});
test("bus start refuses with exit 3 without a notifier config", (t) => {
const f = fixture(tmp(t));
const r = spawnSync(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, encoding: "utf8", timeout: 40000 });
assert.equal(r.status, 3);
assert.match(r.stderr, /no notifier config/);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
});
test("bus start runs until bus stop; status reports it while it runs", async (t) => {
const f = fixture(tmp(t));
notifyConfig(f.dataRoot, "acme", null);
const child = spawn(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => child.exitCode === null && child.kill("SIGKILL"));
let out = "";
child.stdout.on("data", (b) => (out += b));
child.stderr.on("data", (b) => (out += b));
await until(() => /bus host up: business acme/.test(out), 30000);
const status = spawnSync(process.execPath, [CLI, "bus", "status", "--json"], { env: f.env, encoding: "utf8" });
const s = JSON.parse(status.stdout);
assert.equal(s.host.live, true);
assert.equal(s.host.pid, child.pid);
assert.equal(s.host.notifier, null);
assert.equal(s.socket, true);
assert.equal(s.writerLock.live, true);
const exit = once(child, "exit");
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
assert.equal(stop.status, 0, stop.stderr);
assert.match(stop.stdout, new RegExp(`stopped bus host for acme \\(pid ${child.pid}\\)`));
assert.equal((await exit)[0], 0, out);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
const again = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8" });
assert.match(again.stdout, /no host runs/);
});
test("bus-service.sh renders the unit and installs it into a given directory", (t) => {
const dir = tmp(t);
const script = join(REPO, "scripts", "bus-service.sh");
const render = spawnSync(script, ["render"], { encoding: "utf8" });
assert.equal(render.status, 0, render.stderr);
assert.match(render.stdout, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`));
assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/);
assert.doesNotMatch(render.stdout, /@REPO@|@PATH@/);
// A user unit cannot order on a system target (Darkwing F3).
assert.doesNotMatch(render.stdout, /network-online/);
const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8" });
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /written: /);
assert.equal(readFileSync(join(dir, "[email protected]"), "utf8"), render.stdout);
assert.match(spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8" }).stdout, /unchanged: /);
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8" }).stdout, /removed: /);
assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8" }).status, 4);
});
+244
View File
@@ -0,0 +1,244 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { appendFileSync, chmodSync, mkdirSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createNotifier, digestContent, digestNonce, dmNonce, journalPath, openJournal, runLoop, zoned } from "../src/notifier.mjs";
import { RestOutcome } from "../../discord/src/rest.mjs";
import { broker, tmp } from "./helpers.mjs";
// Discord-side fake: records sends, answers from a script (default: ok).
function fakeDirect(script = []) {
const sends = [];
let n = 0;
return {
sends,
async send(m) {
sends.push(m);
const next = script.shift() ?? "ok";
if (next === "ok") return { messageId: `30000000000000${String(++n).padStart(4, "0")}` };
throw new RestOutcome(next, `dm: ${next}`, { status: next === "refused" ? 403 : null });
},
};
}
// 2026-10-08 is CDT (UTC-5): 13:00Z is 08:00 Chicago.
const at = (iso) => {
const clock = { t: new Date(iso) };
return { clock, now: () => clock.t, advance: (ms) => (clock.t = new Date(clock.t.getTime() + ms)) };
};
function setup(t, iso, script) {
const bus = broker(t);
const dataRoot = tmp(t);
const direct = fakeDirect(script);
const time = at(iso);
const logs = [];
const make = () => createNotifier({ business: "demo", dataRoot, inbox: async () => bus.read("inbox"), direct, now: time.now, log: (l) => logs.push(l) });
return { ...bus, dataRoot, direct, time, logs, make, notifier: make(), journal: () => readFileSync(journalPath(dataRoot, "demo"), "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) };
}
test("zoned uses the IANA zone across DST", () => {
assert.deepEqual(zoned(new Date("2026-10-08T13:00:00Z")), { day: "2026-10-08", hour: 8 });
assert.deepEqual(zoned(new Date("2026-12-08T13:00:00Z")), { day: "2026-12-08", hour: 7 });
assert.deepEqual(zoned(new Date("2026-10-09T04:59:00Z")), { day: "2026-10-08", hour: 23 });
assert.throws(() => zoned(new Date(), "Not/AZone"), RangeError);
});
test("each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not", async (t) => {
const s = setup(t, "2026-10-08T12:00:00Z");
const blocking = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
s.raise("deploy", { target: "staging" });
assert.deepEqual(await s.notifier.tick(), { dms: 1, digest: false, failed: 0 });
assert.deepEqual(await s.notifier.tick(), { dms: 0, digest: false, failed: 0 });
assert.deepEqual(await s.make().tick(), { dms: 0, digest: false, failed: 0 });
assert.equal(s.direct.sends.length, 1);
const [dm] = s.direct.sends;
assert.equal(dm.nonce, dmNonce(blocking.id));
assert.ok(dm.nonce.length <= 25);
assert.match(dm.content, /a blocking decision needs you/);
assert.match(dm.content, /choosing "yes" authorizes it/);
assert.match(dm.content, /task vikunja:1\/7/);
assert.match(dm.content, new RegExp(`mosaic decide ${blocking.id.slice(0, 8)} <option>`));
const [rec] = s.journal();
assert.deepEqual(Object.keys(rec).sort(), ["at", "decision", "kind", "messageId", "outcome"]);
assert.equal(rec.outcome, "confirmed");
assert.equal(rec.decision, blocking.id);
assert.equal(statSync(journalPath(s.dataRoot, "demo")).mode & 0o777, 0o600);
});
test("two blocking decisions get two DMs with different nonces", async (t) => {
const s = setup(t, "2026-10-08T12:00:00Z");
const a = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
const b = s.raise("git.push.protected", { target: "main", blocking: true, task_ref: "vikunja:1/8" });
assert.equal((await s.notifier.tick()).dms, 2);
const nonces = s.direct.sends.map((m) => m.nonce);
assert.deepEqual(nonces, [dmNonce(a.id), dmNonce(b.id)]);
assert.notEqual(nonces[0], nonces[1]);
});
test("the digest nonce differs per business and per day and fits Discord's 25 characters", () => {
const n = digestNonce("demo", "2026-10-08");
assert.ok(n.startsWith("dg") && n.length <= 25);
assert.notEqual(n, digestNonce("acme", "2026-10-08"));
assert.notEqual(n, digestNonce("demo", "2026-10-09"));
});
test("a failed DM is journaled, backs off, and is retried until it lands", async (t) => {
const s = setup(t, "2026-10-08T12:00:00Z", ["unknown", "refused"]);
s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
assert.equal((await s.notifier.tick()).failed, 1);
s.time.advance(10_000);
assert.equal((await s.notifier.tick()).failed, 0, "inside the first 30 s backoff");
s.time.advance(25_000);
assert.equal((await s.notifier.tick()).failed, 1, "second attempt refused");
s.time.advance(45_000);
assert.equal((await s.notifier.tick()).dms, 0, "inside the 60 s backoff");
s.time.advance(20_000);
assert.equal((await s.notifier.tick()).dms, 1);
assert.deepEqual(s.journal().map((r) => r.outcome), ["unknown", "refused", "confirmed"]);
assert.equal(s.journal()[1].status, 403);
assert.equal(new Set(s.direct.sends.map((m) => m.nonce)).size, 1, "every retry reuses the nonce");
assert.ok(s.logs.some((l) => /retry in 30 s/.test(l)));
});
test("the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd", async (t) => {
const s = setup(t, "2026-10-08T12:59:00Z");
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
s.raise("deploy", { target: "staging" });
assert.equal((await s.notifier.tick()).digest, false, "07:59 is before the digest");
s.time.advance(60_000);
assert.equal((await s.notifier.tick()).digest, true);
assert.equal((await s.notifier.tick()).digest, false, "one a day");
const digest = s.direct.sends.at(-1);
assert.equal(digest.nonce, digestNonce("demo", "2026-10-08"));
assert.match(digest.content, /^Mosaic digest \(demo, 2026-10-08\): 2 open decision\(s\)\./);
assert.match(digest.content, new RegExp(`\\[blocking, DM sent\\] ${d.id.slice(0, 8)} git\\.push\\.protected`));
assert.match(digest.content, /- [0-9a-f]{8} deploy: /);
assert.match(digest.content, /Run mosaic inbox for the full list\.$/);
assert.deepEqual(s.journal().at(-1), { ...s.journal().at(-1), kind: "digest", decision: null, day: "2026-10-08", outcome: "confirmed" });
s.time.advance(24 * 3600_000);
assert.equal((await s.notifier.tick()).digest, true, "the next day has its own");
});
test("a late start with no digest for the day sends one at once; an empty inbox gets one line", async (t) => {
const s = setup(t, "2026-10-08T21:30:00Z");
assert.deepEqual(await s.notifier.tick(), { dms: 0, digest: true, failed: 0 });
assert.equal(s.direct.sends[0].content, "Mosaic digest (demo, 2026-10-08): your inbox is empty.");
assert.equal((await s.make().tick()).digest, false, "a restart reads the day from the journal");
});
test("an inbox read failure is logged and the next poll retries", async (t) => {
const dataRoot = tmp(t);
let fail = true;
const n = createNotifier({ business: "demo", dataRoot, inbox: async () => { if (fail) { const e = new Error("x"); e.code = "outcome-unknown"; throw e; } return []; }, direct: fakeDirect(), now: () => new Date("2026-10-08T12:00:00Z"), log: () => {} });
assert.equal((await n.tick()).inboxError, true);
fail = false;
assert.equal((await n.tick()).inboxError, undefined);
});
test("no Discord id reaches the journal or the log", async (t) => {
const s = setup(t, "2026-10-08T13:00:00Z", ["refused"]);
s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
await s.notifier.tick();
const text = readFileSync(journalPath(s.dataRoot, "demo"), "utf8") + s.logs.join("\n");
assert.doesNotMatch(text, /channel|recipient|user/i);
for (const r of s.journal()) assert.ok(r.messageId === null || /^[0-9]+$/.test(r.messageId));
});
const tornFiles = (file) => readdirSync(dirname(file)).filter((n) => /^torn-\d{8}T\d{9}Z(-\d+)?\.bin$/.test(n)).sort();
const FRAGMENT = '{"at":"x","kind":"dm","dec';
test("the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly", (t) => {
const file = journalPath(tmp(t), "demo");
openJournal(file).append({ at: "x", kind: "dm", decision: "a", outcome: "confirmed", messageId: "1" });
const good = readFileSync(file);
appendFileSync(file, FRAGMENT);
const logs = [];
const j = openJournal(file, { log: (l) => logs.push(l), now: () => new Date("2026-10-08T23:52:12.345Z") });
assert.deepEqual([...j.sent], ["a"]);
assert.deepEqual(tornFiles(file), ["torn-20261008T235212345Z.bin"]);
const copy = join(dirname(file), "torn-20261008T235212345Z.bin");
assert.equal(readFileSync(copy, "utf8"), FRAGMENT);
assert.equal(statSync(copy).mode & 0o777, 0o600);
assert.deepEqual(readFileSync(file), good, "truncated to its last newline");
assert.equal(logs.length, 2);
assert.match(logs[0], /copied a torn final line \(26 bytes\) to torn-20261008T235212345Z\.bin/);
assert.match(logs[1], /truncated .* to its last newline/);
j.append({ at: "y", kind: "dm", decision: "b", outcome: "confirmed", messageId: "2" });
const again = [];
assert.deepEqual([...openJournal(file, { log: (l) => again.push(l) }).sent], ["a", "b"]);
assert.deepEqual(again, [], "nothing torn the second time");
});
test("the journal: a crash between the copy and the truncate leaves a tail the next open repairs", (t) => {
const file = journalPath(tmp(t), "demo");
openJournal(file).append({ at: "x", kind: "dm", decision: "a", outcome: "confirmed", messageId: "1" });
appendFileSync(file, FRAGMENT);
const now = () => new Date("2026-10-08T23:52:12.345Z");
// The log after step 1 throws: the process dies before step 2.
assert.throws(() => openJournal(file, { now, log: () => { throw new Error("crash"); } }), /crash/);
assert.ok(readFileSync(file, "utf8").endsWith(FRAGMENT), "still torn");
assert.deepEqual(tornFiles(file), ["torn-20261008T235212345Z.bin"]);
const j = openJournal(file, { now });
assert.deepEqual(tornFiles(file), ["torn-20261008T235212345Z-1.bin", "torn-20261008T235212345Z.bin"], "a second copy, the first kept");
for (const n of tornFiles(file)) assert.equal(readFileSync(join(dirname(file), n), "utf8"), FRAGMENT);
j.append({ at: "y", kind: "dm", decision: "b", outcome: "confirmed", messageId: "2" });
assert.deepEqual([...openJournal(file).sent], ["a", "b"]);
});
test("the journal: a whole file that is one torn line truncates to empty", (t) => {
const file = journalPath(tmp(t), "demo");
openJournal(file);
writeFileSync(file, FRAGMENT);
assert.equal(openJournal(file).sent.size, 0);
assert.equal(readFileSync(file, "utf8"), "");
assert.equal(tornFiles(file).length, 1);
});
test("the journal: a malformed complete line refuses and leaves the file and any torn tail alone", (t) => {
const file = journalPath(tmp(t), "demo");
openJournal(file);
writeFileSync(file, `garbage\n${FRAGMENT}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && /line 1 is malformed/.test(e.message));
assert.equal(readFileSync(file, "utf8"), `garbage\n${FRAGMENT}`);
assert.equal(tornFiles(file).length, 0);
});
test("the journal: a loose file mode, a loose directory or a symlinked journal refuses", (t) => {
const root = tmp(t);
const file = journalPath(root, "demo");
openJournal(file);
chmodSync(file, 0o644);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && /mode 0600/.test(e.message));
chmodSync(file, 0o600);
chmodSync(dirname(file), 0o755);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && /directory must be mode 0700/.test(e.message));
chmodSync(dirname(file), 0o700);
const other = join(root, "elsewhere.jsonl");
writeFileSync(other, "", { mode: 0o600 });
const linked = journalPath(root, "linked");
mkdirSync(dirname(linked), { mode: 0o700 });
symlinkSync(other, linked);
assert.throws(() => openJournal(linked), (e) => e.exitCode === 3 && /must not be a symlink/.test(e.message));
});
test("digest content stays within Discord's 2000 characters", () => {
const inbox = Array.from({ length: 60 }, (_, i) => ({ id: `${String(i).padStart(8, "0")}-x`, action: "deploy", question: "q".repeat(300), blocking: i % 2 === 0 }));
const text = digestContent("demo", "2026-10-08", inbox, () => true);
assert.ok(text.length <= 2000, String(text.length));
assert.match(text, /… and \d+ more\.\nRun mosaic inbox for the full list\.$/);
});
test("runLoop never overlaps ticks and stops after the one in flight", async () => {
let active = 0;
let max = 0;
let count = 0;
const loop = runLoop({ tick: async () => { active++; max = Math.max(max, active); count++; await new Promise((r) => setTimeout(r, 15)); active--; } }, { pollMs: 1 });
await new Promise((r) => setTimeout(r, 80));
await loop.stop();
const after = count;
await new Promise((r) => setTimeout(r, 30));
assert.equal(max, 1);
assert.ok(after >= 2);
assert.equal(count, after);
});
+50
View File
@@ -0,0 +1,50 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { busExit, humanTransport, refuseInsideAgent } from "../src/transport.mjs";
import { fakeCli, humanEnv, tmp } from "./helpers.mjs";
// The fake records argv and stdin, then answers per verb. The real
// human-cli.mjs is never run here: its proof needs a human shell.
const BODY = `
import { readFileSync, writeFileSync } from "node:fs";
const input = readFileSync(0, "utf8");
writeFileSync(process.env.FAKE_LOG, JSON.stringify({ argv: process.argv.slice(2), input }));
const { verb } = JSON.parse(input);
if (verb === "inbox") process.stdout.write(JSON.stringify([{ id: "d1" }]) + "\\n");
else if (verb === "garbage") process.stdout.write("not json");
else if (verb === "hang") setTimeout(() => {}, 60000);
else { process.stderr.write("some noise\\n" + verb + "\\n"); process.exit(2); }
`;
function setup(t) {
const root = tmp(t);
const log = join(root, "log.json");
const cli = fakeCli(root, BODY);
const call = humanTransport({ socket: "/run/fake.sock", business: "acme", env: humanEnv({ FAKE_LOG: log }), cli, timeoutMs: 2000 });
return { call, seen: () => JSON.parse(readFileSync(log, "utf8")) };
}
test("the transport writes {business, verb, args} to the child and reads its JSON", async (t) => {
const s = setup(t);
assert.deepEqual(await s.call("inbox"), [{ id: "d1" }]);
assert.deepEqual(s.seen().argv, ["/run/fake.sock"]);
assert.deepEqual(JSON.parse(s.seen().input), { business: "acme", verb: "inbox", args: {} });
});
test("a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems", async (t) => {
const s = setup(t);
for (const [code, exit] of [["human-required", 3], ["read-only", 3], ["decision-closed", 2], ["outcome-unknown", 1]]) {
await assert.rejects(s.call(code), (e) => e.code === code && e.exitCode === exit, code);
}
await assert.rejects(s.call("garbage"), (e) => e.code === "invalid-response" && e.exitCode === 1);
await assert.rejects(s.call("hang"), (e) => e.exitCode === 1 && /outcome-unknown/.test(e.message));
});
test("busExit and refuseInsideAgent", () => {
assert.equal(busExit("unauthenticated"), 3);
assert.equal(busExit("invalid-request"), 2);
assert.doesNotThrow(() => refuseInsideAgent(humanEnv()));
assert.throws(() => refuseInsideAgent({ PI_AGENT_DIR: "/x" }), (e) => e.exitCode === 3 && /PI_AGENT_DIR/.test(e.message));
});
+9 -1
View File
@@ -74,7 +74,7 @@ scripts/discord-service.sh render | install | uninstall | status <binding>
owner exits 1; the file applies at the next start). The process re-reads
the file and swaps `guildName`, `channels`, `users` and `limits` in place;
a channel that is new to the binding is read over REST and must be in the
bound guild. `name`, `seat`, `guildId`, `botUserId`, `tokenFile`, `engine`
bound guild. `name`, `seat`, `guildId`, `botUserId`, `tokenFile`, `dmRecipient`, `engine`
and `context` are fixed for the life of the process, because the engine
and its prompt are launched once and the token is read once; a change
there, an invalid file or a failed channel lookup refuses the reload and
@@ -136,6 +136,7 @@ is `src/binding.mjs`.
| `tokenFile` | absolute path to the bot token, 0600, read into memory at start, never printed or journaled |
| `channels[]` | `{id, name, mode}`; `open` answers every message, `mention` only when the bot is mentioned; threads inherit the parent's mode |
| `users[]` | `{id, name, channels?}`; the only authors that get a turn. `channels` is an optional allowlist of listed channel ids; absent means every listed channel, present means those and their threads only, everything else is dropped as `channel-not-for-user` |
| `dmRecipient` | optional. The one listed user the bus notifier (`packages/cli`, `src/notify.mjs`) may DM: a snowflake that must appear in `users`. Read once at notifier start; a change refuses `reload` like the other fixed keys. Absent means the notifier refuses to start against this binding |
| `engine` | `provider`, `model`, `thinking` for pi |
| `limits` | `turnsPerDay` (200), `turnTimeoutSeconds` (180), `replyChunkChars` (1900), `inboundMaxChars` (4000) |
| `context.files[]` | files appended to pi's system prompt in order, repository-relative and inside the repository (no absolute paths, `..` or symlinks); the Discord block is added after them |
@@ -401,3 +402,10 @@ flags expose none, and a missing `MOSAIC_DISCORD_TOOLS` makes pi exit.
Repository writes, per-user tool gating, announcements, attachments, slash
commands, DMs, per-thread sessions, more than one server or seat. Section 8
of the pilot brief keeps the list; the control-board row is darkwing's.
One exception to "DMs": `src/notify.mjs` sends outbound DMs to the binding's
`dmRecipient` for the bus notifier (Slice 1 S4, lead decision 70). It opens
one DM channel through `POST /users/@me/channels`, posts with a nonce, and
returns only the message id; it never reads the gateway, takes no inbound
DM, and keeps the recipient id, channel id and token out of every return
value, error and log line. The connector process itself still ignores DMs.
+13 -4
View File
@@ -40,7 +40,7 @@ export const LIMIT_DEFAULTS = Object.freeze({
inboundMaxChars: 4000,
});
const TOP_KEYS = ["bindingVersion", "name", "seat", "guildId", "guildName", "botUserId", "tokenFile", "channels", "users", "engine", "limits", "context", "tools"];
const TOP_KEYS = ["bindingVersion", "name", "seat", "guildId", "guildName", "botUserId", "tokenFile", "channels", "users", "dmRecipient", "engine", "limits", "context", "tools"];
const CHANNEL_KEYS = ["id", "name", "mode"];
const USER_KEYS = ["id", "name", "channels"];
const ENGINE_KEYS = ["provider", "model", "thinking"];
@@ -156,6 +156,13 @@ export function validateBinding(raw, where = "binding") {
});
if (new Set(users.map((u) => u.id)).size !== users.length) throw new DiscordError(`${where}: duplicate user id`);
if (users.some((u) => u.id === botUserId)) throw new DiscordError(`${where}: the bot cannot be an authorized user`);
// The one user the bus notifier may DM (packages/cli, lead decision 70).
// Optional; it must be a listed user, so a binding can't DM a stranger.
let dmRecipient = null;
if (raw.dmRecipient !== undefined) {
dmRecipient = requireSnowflake(raw, "dmRecipient", where);
if (!users.some((u) => u.id === dmRecipient)) throw new DiscordError(`${where}: dmRecipient must be one of the listed users`);
}
if (!isObject(raw.engine)) throw new DiscordError(`${where}: engine must be an object`);
onlyKeys(raw.engine, ENGINE_KEYS, `${where}.engine`);
@@ -226,6 +233,7 @@ export function validateBinding(raw, where = "binding") {
name, seat, guildId, guildName, botUserId, tokenFile,
channels: Object.freeze(channels),
users: Object.freeze(users),
dmRecipient,
engine: Object.freeze({ provider, model, thinking }),
limits,
context: Object.freeze({ files: Object.freeze(files) }),
@@ -235,11 +243,12 @@ export function validateBinding(raw, where = "binding") {
// What a running connector may take from a re-read binding, and what it may
// not: the engine and its prompt are launched once, the token is read once,
// and the journal directory is named after the binding. A change to a fixed
// key needs a stop and a start. Returns a summary of the reloadable
// the journal directory is named after the binding, and the bus notifier
// reads the DM recipient once at start. A change to a fixed key needs a stop
// and a start. Returns a summary of the reloadable
// differences or throws with exit 2.
export const RELOADABLE_KEYS = Object.freeze(["guildName", "channels", "users", "limits"]);
export const FIXED_KEYS = Object.freeze(["bindingVersion", "name", "seat", "guildId", "botUserId", "tokenFile", "engine", "context", "tools"]);
export const FIXED_KEYS = Object.freeze(["bindingVersion", "name", "seat", "guildId", "botUserId", "tokenFile", "dmRecipient", "engine", "context", "tools"]);
export function reloadDiff(current, next) {
for (const k of FIXED_KEYS) {
+46
View File
@@ -0,0 +1,46 @@
// The outbound DM path for the bus notifier (packages/cli, lead decision 70).
// It uses the connector's bot token and REST client but not its gateway
// process: one binding, one recipient (`dmRecipient`, which must be a listed
// user), one DM channel opened on first use and kept in memory.
//
// The caller sees outcomes only: `{messageId}` on a confirmed send, or a
// RestOutcome (refused | unknown) it journals and retries. The recipient id,
// the channel id and the token never leave this module: not in a return
// value, an error message or a log line.
import { DiscordError } from "./errors.mjs";
import { bindingPath, loadBinding, readToken } from "./binding.mjs";
import { createRest, API_BASE, RestOutcome } from "./rest.mjs";
export function openDirect({ dataRoot, name, fetch = globalThis.fetch, base = API_BASE, sleep, log = () => {} }) {
const binding = loadBinding(bindingPath(dataRoot, name));
if (binding.dmRecipient === null) throw new DiscordError(`binding ${name}: no dmRecipient; the notifier has nobody to DM`, 2);
const rest = createRest({ token: readToken(binding), fetch, base, sleep, log });
const recipient = binding.dmRecipient;
let channel = null;
return Object.freeze({
binding: binding.name,
// Resolves {messageId}. Throws RestOutcome (refused | unknown). The
// nonce makes Discord return the earlier message when an unknown send
// is retried within its dedupe window.
async send({ content, nonce }) {
if (channel === null) {
try {
channel = await rest.createDm(recipient);
} catch (err) {
if (err instanceof RestOutcome) throw new RestOutcome(err.kind, `dm channel: ${err.kind}`, { status: err.details.status });
throw err;
}
}
try {
const { messageId } = await rest.createMessage(channel, { content, nonce });
return { messageId };
} catch (err) {
if (!(err instanceof RestOutcome)) throw err;
// A refusal can mean the channel went away; open it again next time.
if (err.kind === "refused") channel = null;
throw new RestOutcome(err.kind, `dm: ${err.kind}`, { status: err.details.status });
}
},
});
}
+14
View File
@@ -71,6 +71,20 @@ export function createRest({ token, fetch = globalThis.fetch, base = API_BASE, s
getGuild: (guildId) => get(`/guilds/${guildId}`),
getChannel: (channelId) => get(`/channels/${channelId}`),
// Open (or fetch) the DM channel with one user; Discord returns the
// same channel each time. Resolves the channel id. Throws RestOutcome:
// refused on a 4xx, unknown on a 5xx, a socket error or a 2xx without
// an id. Only the bus notifier calls it (packages/discord/src/notify.mjs).
async createDm(recipientId) {
if (typeof recipientId !== "string" || !/^[0-9]{17,20}$/.test(recipientId)) throw new DiscordError("createDm: recipient must be a snowflake id", 1);
const r = await call("POST", "/users/@me/channels", { recipient_id: recipientId });
if (r.status >= 200 && r.status < 300) {
if (!r.json || typeof r.json.id !== "string") throw new RestOutcome("unknown", "createDm: 2xx without a channel id", { status: r.status });
return r.json.id;
}
throw new RestOutcome(r.status >= 500 ? "unknown" : "refused", `createDm: HTTP ${r.status} ${redact(r.text)}`, { status: r.status });
},
// Read receipt: one reaction on the inbound message. Best effort like
// typing: resolves true on 2xx, false otherwise, never throws. A
// reaction that fails must not fail the turn.
+13 -1
View File
@@ -79,7 +79,7 @@ test("reloadDiff: reloadable keys are summarised by id; every fixed key refuses
const fixed = {
name: "other-seat", seat: "other", guildId: "100000000000000009", botUserId: "100000000000000003",
tokenFile: "/nonexistent/other", engine: { provider: "zai", model: "glm-5.3", thinking: "low" },
context: { files: ["contracts/STANDARDS.md"] },
context: { files: ["contracts/STANDARDS.md"] }, dmRecipient: "100000000000000100",
};
for (const [k, v] of Object.entries(fixed)) {
assert.throws(() => reloadDiff(cur, validateBinding(rawBinding({ [k]: v }))),
@@ -87,6 +87,18 @@ test("reloadDiff: reloadable keys are summarised by id; every fixed key refuses
}
});
test("binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key", () => {
assert.equal(validateBinding(rawBinding()).dmRecipient, null);
assert.equal(validateBinding(rawBinding({ dmRecipient: "100000000000000100" })).dmRecipient, "100000000000000100");
refuses(rawBinding({ dmRecipient: "100000000000000101" }), /dmRecipient must be one of the listed users/);
refuses(rawBinding({ dmRecipient: "owner" }), /dmRecipient is not a Discord snowflake/);
refuses(rawBinding({ dmRecipient: 100000000000000100 }), /dmRecipient must be a non-empty string/);
refuses(rawBinding({ dmRecipient: "100000000000000002" }), /dmRecipient must be one of the listed users/);
assert.ok(FIXED_KEYS.includes("dmRecipient"));
// A reload that drops the recipient from users fails validation before reloadDiff runs.
refuses(rawBinding({ dmRecipient: "100000000000000100", users: [{ id: "100000000000000101", name: "guest" }] }), /dmRecipient must be one of the listed users/);
});
test("binding: file must be 0600, regular, not a symlink", () => {
const root = makeRoot();
const dep = makeDeployment(root);
+80
View File
@@ -0,0 +1,80 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { openDirect } from "../src/notify.mjs";
import { RestOutcome } from "../src/rest.mjs";
import { DiscordError } from "../src/errors.mjs";
import { makeRoot, makeDeployment, IDS } from "./helpers.mjs";
const CHANNEL = "100000000000000900";
const TOKEN = "MTAw.abcdefghijklmnopqrstuvwxyz0123456789";
function fakeFetch(script) {
const calls = [];
const fetch = async (url, init) => {
calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : null, auth: init.headers.Authorization });
const next = script.shift();
if (!next) throw new Error("fake fetch: no scripted response");
return { status: next.status, text: async () => (next.body === undefined ? "" : JSON.stringify(next.body)) };
};
return { fetch, calls };
}
const secretFree = (err) => ![IDS.owner, CHANNEL, TOKEN].some((s) => err.message.includes(s) || JSON.stringify(err.details ?? {}).includes(s));
test("notify: the DM channel opens once, every send carries the nonce, and only the message id comes back", async () => {
const { dataRoot } = makeDeployment(makeRoot(), { dmRecipient: IDS.owner });
const f = fakeFetch([
{ status: 200, body: { id: CHANNEL, type: 1 } },
{ status: 200, body: { id: "300000000000000001" } },
{ status: 200, body: { id: "300000000000000002" } },
]);
const dm = openDirect({ dataRoot, name: "test-seat", fetch: f.fetch, base: "http://fake" });
assert.deepEqual(await dm.send({ content: "one", nonce: "dm1" }), { messageId: "300000000000000001" });
assert.deepEqual(await dm.send({ content: "two", nonce: "dm2" }), { messageId: "300000000000000002" });
assert.deepEqual(f.calls.map((c) => `${c.method} ${c.url}`), [
"POST http://fake/users/@me/channels",
`POST http://fake/channels/${CHANNEL}/messages`,
`POST http://fake/channels/${CHANNEL}/messages`,
]);
assert.deepEqual(f.calls[0].body, { recipient_id: IDS.owner });
assert.equal(f.calls[1].body.nonce, "dm1");
assert.equal(f.calls[1].body.enforce_nonce, true);
assert.deepEqual(f.calls[1].body.allowed_mentions, { parse: [], replied_user: false });
assert.ok(f.calls.every((c) => c.auth === `Bot ${TOKEN}`));
assert.equal(JSON.stringify(dm).includes(TOKEN) || JSON.stringify(dm).includes(IDS.owner), false);
});
test("notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time", async () => {
const { dataRoot } = makeDeployment(makeRoot(), { dmRecipient: IDS.owner });
const f = fakeFetch([
{ status: 403, body: { code: 50007, message: `Cannot send messages to ${IDS.owner}` } },
{ status: 200, body: { id: CHANNEL } },
{ status: 502, body: { message: `channel ${CHANNEL} upstream` } },
{ status: 200, body: { id: "300000000000000003" } },
{ status: 403, body: { code: 50007, message: "Cannot send messages to this user" } },
{ status: 200, body: { id: CHANNEL } },
{ status: 200, body: { id: "300000000000000004" } },
]);
const dm = openDirect({ dataRoot, name: "test-seat", fetch: f.fetch, base: "http://fake" });
await assert.rejects(dm.send({ content: "x", nonce: "n1" }), (e) => e instanceof RestOutcome && e.kind === "refused" && e.details.status === 403 && secretFree(e));
await assert.rejects(dm.send({ content: "x", nonce: "n1" }), (e) => e instanceof RestOutcome && e.kind === "unknown" && secretFree(e));
// unknown keeps the channel; the retry reuses it with the same nonce.
assert.deepEqual(await dm.send({ content: "x", nonce: "n1" }), { messageId: "300000000000000003" });
await assert.rejects(dm.send({ content: "y", nonce: "n2" }), (e) => e.kind === "refused" && secretFree(e));
assert.deepEqual(await dm.send({ content: "y", nonce: "n2" }), { messageId: "300000000000000004" });
assert.equal(f.calls.filter((c) => c.url.endsWith("/users/@me/channels")).length, 3);
});
test("notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use", async () => {
let calls = 0;
const fetch = async () => {
calls++;
throw new Error("network");
};
const none = makeDeployment(makeRoot());
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "test-seat", fetch }), (e) => e instanceof DiscordError && e.exitCode === 2 && /no dmRecipient/.test(e.message));
const loose = makeDeployment(makeRoot(), { dmRecipient: IDS.owner }, { tokenMode: 0o644 });
assert.throws(() => openDirect({ dataRoot: loose.dataRoot, name: "test-seat", fetch }), (e) => e instanceof DiscordError && /mode 0600/.test(e.message) && !e.message.includes(TOKEN));
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "absent", fetch }), (e) => e instanceof DiscordError && /binding not found/.test(e.message));
assert.equal(calls, 0);
});
+26
View File
@@ -88,3 +88,29 @@ test("rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is
assert.ok(logs.every((l) => !l.includes(TOKEN)));
await assert.rejects(rest.react("c1", "m4", ""), /emoji required/);
});
test("rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent", async () => {
let f = fakeFetch([{ status: 200, body: { id: "100000000000000900", type: 1 } }]);
let rest = createRest({ token: TOKEN, fetch: f.fetch });
assert.equal(await rest.createDm("100000000000000002"), "100000000000000900");
assert.equal(f.calls[0].url, "https://discord.com/api/v10/users/@me/channels");
assert.equal(f.calls[0].init.method, "POST");
assert.deepEqual(JSON.parse(f.calls[0].init.body), { recipient_id: "100000000000000002" });
f = fakeFetch([{ status: 403, body: { code: 50007, message: "Cannot send messages to this user" } }]);
rest = createRest({ token: TOKEN, fetch: f.fetch });
await assert.rejects(rest.createDm("100000000000000002"), (err) => err instanceof RestOutcome && err.kind === "refused" && err.details.status === 403);
f = fakeFetch([{ status: 503 }]);
rest = createRest({ token: TOKEN, fetch: f.fetch });
await assert.rejects(rest.createDm("100000000000000002"), (err) => err instanceof RestOutcome && err.kind === "unknown");
f = fakeFetch([{ status: 200, body: {} }]);
rest = createRest({ token: TOKEN, fetch: f.fetch });
await assert.rejects(rest.createDm("100000000000000002"), (err) => err instanceof RestOutcome && err.kind === "unknown");
f = fakeFetch([]);
rest = createRest({ token: TOKEN, fetch: f.fetch });
await assert.rejects(rest.createDm("not-an-id"), /recipient must be a snowflake/);
assert.equal(f.calls.length, 0);
});
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# `scripts/bus-service.sh render|install|uninstall|status [<business>]`:
# the systemd user unit that supervises `scripts/mosaic bus start <business>`
# (lead decision 70). The template is
# packages/cli/systemd/mosaic-bus.service.in; the rendered unit is
# [email protected] in the systemd user directory, one instance per
# business (mosaic-bus@<business>). Nothing here reads a capability, the
# binding or the token. See packages/cli/README.md, "Bus host".
#
# render print the rendered unit on stdout
# install [--dir DIR] [--no-reload]
# write the unit (write to a temp file, then
# rename) and run `systemctl --user daemon-reload`
# uninstall [--dir DIR] [--no-reload]
# remove the unit; refuses while an instance is active
# status <business> unit state, then `mosaic bus status`
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in"
UNIT_NAME="[email protected]"
USAGE="usage: scripts/bus-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <business>"
die() { echo "bus-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
render() {
[ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE"
local node_dir
node_dir="$(dirname "$(command -v node || true)")"
[ -n "$node_dir" ] && [ "$node_dir" != "." ] || die "node not found on PATH"
local path="/usr/local/bin:/usr/bin:/bin"
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "$TEMPLATE"
}
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
RELOAD=1
parse_flags() {
while [ $# -gt 0 ]; do
case "$1" in
--dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; shift 2 ;;
--no-reload) RELOAD=0; shift ;;
*) usage ;;
esac
done
}
install_unit() {
parse_flags "$@"
mkdir -p "$UNIT_DIR"
local target="$UNIT_DIR/$UNIT_NAME" tmp
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
render > "$tmp"
chmod 0644 "$tmp"
if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
rm -f "$tmp"
echo "unchanged: $target"
else
mv -f "$tmp" "$target"
echo "written: $target"
fi
if [ "$RELOAD" = 1 ]; then
systemctl --user daemon-reload || die "daemon-reload failed"
echo "daemon-reload done"
fi
cat <<MSG
next, for one business (one per data root):
write <dataRoot>/notify/<business>/notify.json, mode 0600:
{"notifyVersion": 1, "binding": "<discord binding>"} or "binding": null for no DMs
systemctl --user enable --now mosaic-bus@<business> start now and at login
systemctl --user status mosaic-bus@<business>
journalctl --user -u mosaic-bus@<business> -f the host's log
systemctl --user stop mosaic-bus@<business> SIGTERM; restartable
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
}
uninstall_unit() {
parse_flags "$@"
local target="$UNIT_DIR/$UNIT_NAME"
if [ "$RELOAD" = 1 ]; then
local active
active="$(systemctl --user list-units --no-legend --plain 'mosaic-bus@*' 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
[ -z "$active" ] || die "refusing: instance(s) still running: $(echo "$active" | tr '\n' ' ')stop them first"
fi
if [ -f "$target" ]; then
rm -f "$target"
echo "removed: $target"
else
echo "absent: $target"
fi
if [ "$RELOAD" = 1 ]; then
systemctl --user daemon-reload || die "daemon-reload failed"
echo "daemon-reload done"
fi
}
status_unit() {
[ $# -eq 1 ] || usage
local business="$1"
case "$business" in ''|*[!a-z0-9-]*) die "business id must be [a-z0-9-]+" ;; esac
local unit="mosaic-bus@$business.service"
echo "unit: $unit"
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
systemctl --user show "$unit" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result 2>/dev/null | sed 's/^/ /'
"$REPO/scripts/mosaic" bus status | sed 's/^/ /' || true
}
[ $# -ge 1 ] || usage
cmd="$1"; shift
case "$cmd" in
render) [ $# -eq 0 ] || usage; render ;;
install) install_unit "$@" ;;
uninstall) uninstall_unit "$@" ;;
status) status_unit "$@" ;;
-h|--help) echo "$USAGE" ;;
*) usage ;;
esac
+5
View File
@@ -4,6 +4,8 @@
# `mosaic queue <verb>`: the work queue. See packages/queue/README.md.
# `mosaic business <verb>`: business files and role instances. See
# packages/business/README.md.
# `mosaic inbox|decide|tasks|agents|trail` and `mosaic bus start|stop|status`:
# the human commands and the bus host. See packages/cli/README.md.
# Not the npm-global `mosaic` CLI from the estate tooling; this one is
# repository-local and only reachable as scripts/mosaic.
set -euo pipefail
@@ -16,4 +18,7 @@ if [ "${1:-}" = business ]; then
shift
exec node "$REPO/packages/business/src/cli.mjs" "$@"
fi
case "${1:-}" in
inbox|decide|tasks|agents|trail|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"