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, baseb9b6cf00, build.patch b52f7d68, manifest e858504e (29 files). Darkwing approved round 2 on #1521 (comment 26855), Filbert approved round 2 (comment 26856). The packet's mutant table lists M28 as killed; it survived, and BUILD-LOG records the correction. Integration gate in a worktree on2557e29dwith the patch applied: bus 67, business 60, cli 49, control-board 124, discord 178, ledger 78, mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no failures. Conversation is 149/3, the same K1, K3 and K10 cases that fail on the base; S4 doesn't touch the package. Every scripts/test-*.sh is green, with test-release 14/14 and test-task 98/98 on the existing gate2 compose network. A scratch test, not in this commit, booted the real host with trackers against S3's fake Vikunja: the adapter went ready and a task.close on a missing task answered task-not-found after a Vikunja read. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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", () => {});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Executable
+122
@@ -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
|
||||
@@ -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" "$@"
|
||||
|
||||
Reference in New Issue
Block a user