Files
stack/packages/cli/tests/notifier.test.mjs
T
jason.woltjeandClaude Opus 5.5 2f5303c1c7 feat(cli): the mosaic CLI, broker host and decision notifier (row 39, S4, rocko)
packages/cli adds mosaic inbox, decide, tasks, agents and trail over the
human-cli transport, and mosaic bus start, stop and status as the trusted
host (unit mosaic-bus@<business>, scripts/bus-service.sh). The host boots
packages/bus/src/process.mjs, passes config.trackers from the tracker.*
variables (lead decision 70), and runs a notifier child. The notifier DMs
each open blocking decision once and sends an 08:00 America/Chicago
digest, journaled in notify/<business>/sent.jsonl at 0600 with no Discord
ids. A torn journal tail is copied aside and truncated; a malformed line,
a directory looser than 0700 or a symlinked journal refuses (lead
decision 71). packages/discord gains dmRecipient, createDm and notify.mjs.

Candidate agents/rocko/work/slice1-s4, base b9b6cf00, build.patch
b52f7d68, manifest e858504e (29 files). Darkwing approved round 2 on
#1521 (comment 26855), Filbert approved round 2 (comment 26856). The
packet's mutant table lists M28 as killed; it survived, and BUILD-LOG
records the correction.

Integration gate in a worktree on 2557e29d with the patch applied:
bus 67, business 60, cli 49, control-board 124, discord 178, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3, the same K1, K3 and K10 cases that fail
on the base; S4 doesn't touch the package. Every scripts/test-*.sh is
green, with test-release 14/14 and test-task 98/98 on the existing gate2
compose network. A scratch test, not in this commit, booted the real
host with trackers against S3's fake Vikunja: the adapter went ready and
a task.close on a missing task answered task-not-found after a Vikunja
read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 07:49:38 -05:00

245 lines
13 KiB
JavaScript

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);
});