diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index fcd7c0132..5d92a4d25 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -176,11 +176,18 @@ async function human(verb, rest, io, deps) { if (json) return print(decisions, formatInbox); const messages = []; let after; - do { - const page = await call("messages", { limit: 200, ...(after ? { after } : {}) }); - messages.push(...page.messages.filter(m => !m.read)); - after = page.next; - } while (after); + try { + do { + const page = await call("messages", { limit: 200, ...(after ? { after } : {}) }); + messages.push(...page.messages.filter(m => !m.read)); + after = page.next; + } while (after); + } catch (e) { + // A bus from before row 94 has no messages verb. Keep the decisions and say + // so. A partial list from earlier pages is dropped, not printed as the count. + if (e.code !== "unknown-verb") throw e; + return io.stdout.write(`${formatInbox(decisions)}\nmessages: not available until the bus restarts\n`); + } return print({ decisions, messages }, value => formatInbox(value.decisions) + `\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join("")); } diff --git a/packages/cli/tests/cli.test.mjs b/packages/cli/tests/cli.test.mjs index 57f8ea22f..cd9dd40fb 100644 --- a/packages/cli/tests/cli.test.mjs +++ b/packages/cli/tests/cli.test.mjs @@ -3,6 +3,7 @@ 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 { formatInbox } from "../src/format.mjs"; import { hostDir, hostFile, startTimeOf } from "../src/host.mjs"; import { broker, io, notifyConfig, tmp } from "./helpers.mjs"; import { writeJson } from "../../business/tests/helpers.mjs"; @@ -43,6 +44,81 @@ test("inbox lists only decisions routed to the human, with what approving author assert.deepEqual(s.calls.map((c) => c.verb), ["inbox", "messages", "inbox"]); }); +// Text-mode `inbox` over a stub transport. `inbox` answers from the broker; +// `messages` answers from messages(args), which returns a page or throws. +function inboxStub(s, messages) { + const calls = []; + const transport = () => async (verb, args = {}) => { + calls.push({ verb, args }); + if (verb === "inbox") return s.read("inbox"); + return messages(args); + }; + return { calls, transport }; +} + +const coded = (code) => Object.assign(new Error(code), { code }); +const unread = (id, body) => ({ id, from_role: "coder", class: "INFORM", in_reply_to: null, body, read: false }); +const NO_MESSAGES_VERB = "messages: not available until the bus restarts\n"; + +test("text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0", async (t) => { + const s = setup(t); + s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" }); + const { calls, transport } = inboxStub(s, () => { + throw coded("unknown-verb"); + }); + const x = io(); + assert.equal(await exitOf(main(["inbox", "--business", "demo"], x, { system: { dataRoot: s.dataRoot }, transport })), 0); + assert.equal(x.out.text, `${formatInbox(s.read("inbox"))}\n${NO_MESSAGES_VERB}`); + assert.deepEqual(calls.map((c) => c.verb), ["inbox", "messages"]); +}); + +test("text-mode inbox still fails on any other messages error, and prints nothing", async (t) => { + const s = setup(t); + s.raise("git.push.protected", { target: "refactor" }); + for (const code of ["unauthenticated", "invalid-response", "outcome-unknown"]) { + const { transport } = inboxStub(s, () => { + throw coded(code); + }); + const x = io(); + const err = await main(["inbox", "--business", "demo"], x, { system: { dataRoot: s.dataRoot }, transport }).catch((e) => e); + assert.equal(err?.code, code); + assert.equal(x.out.text, ""); + } +}); + +test("text-mode inbox prints the unread count and every unread message across pages", async (t) => { + const s = setup(t); + s.raise("git.push.protected", { target: "refactor" }); + const { calls, transport } = inboxStub(s, (args) => + args.after + ? { messages: [unread("msg-0003-cccccccc", "third\n")], next: null } + : { messages: [unread("msg-0001-aaaaaaaa", "first\n"), { ...unread("msg-0002-bbbbbbbb", "read\n"), read: true }], next: "msg-0002-bbbbbbbb" }, + ); + const x = io(); + assert.equal(await exitOf(main(["inbox", "--business", "demo"], x, { system: { dataRoot: s.dataRoot }, transport })), 0); + assert.equal( + x.out.text, + `${formatInbox(s.read("inbox"))}\nmessages: 2 unread\ncoder (INFORM) msg-0001:\nfirst\ncoder (INFORM) msg-0003:\nthird\n`, + ); + assert.deepEqual( + calls.filter((c) => c.verb === "messages").map((c) => c.args), + [{ limit: 200 }, { limit: 200, after: "msg-0002-bbbbbbbb" }], + ); +}); + +test("a later page answered unknown-verb drops the partial list and prints only the unavailable line", async (t) => { + const s = setup(t); + s.raise("git.push.protected", { target: "refactor" }); + const { calls, transport } = inboxStub(s, (args) => { + if (args.after) throw coded("unknown-verb"); + return { messages: [unread("msg-0001-aaaaaaaa", "first\n")], next: "msg-0001-aaaaaaaa" }; + }); + const x = io(); + assert.equal(await exitOf(main(["inbox", "--business", "demo"], x, { system: { dataRoot: s.dataRoot }, transport })), 0); + assert.equal(x.out.text, `${formatInbox(s.read("inbox"))}\n${NO_MESSAGES_VERB}`); + assert.deepEqual(calls.map((c) => c.verb), ["inbox", "messages", "messages"]); +}); + 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" });