@remit/doctor 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/loop.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { attempt } from "./attempt.js";
2
+ import type { DoctorConfig } from "./config.js";
3
+ import { advance } from "./dwell.js";
4
+ import type { Log } from "./log.js";
5
+ import type { CounterState, DoctorState } from "./state.js";
6
+ import type { CheckResult } from "./verdict.js";
7
+ import type { Delivery } from "./webhook.js";
8
+
9
+ export interface LoopDependencies {
10
+ readonly runCheck: (
11
+ counters: Readonly<Record<string, CounterState>>,
12
+ ) => Promise<CheckResult>;
13
+ readonly saveState: (state: DoctorState) => Promise<void>;
14
+ readonly postWebhook: (result: CheckResult) => Promise<Delivery>;
15
+ readonly pingDeadMan: () => Promise<void>;
16
+ readonly now: () => Date;
17
+ readonly log: Log;
18
+ }
19
+
20
+ /**
21
+ * One turn of the loop: check, decide, announce a settled change, record, ping.
22
+ *
23
+ * A transition is spent when the endpoint has answered about it, not when it is
24
+ * decided. A payload an endpoint rejects (4xx) is a decision that repeating
25
+ * cannot change, so `firedVerdict` advances and the operator gets one error
26
+ * line. A payload that never arrived — a timeout, a refused connection, a 5xx —
27
+ * is not a decision, so `firedVerdict` is left where it was and the next check
28
+ * announces the same transition again. `candidateRuns` is already pinned at the
29
+ * dwell count, so the retry is immediate rather than another three checks away.
30
+ *
31
+ * The dead-man's switch cannot cover a lost delivery. It is a different URL at
32
+ * a different provider and it answers 200 all week while a webhook is down, so
33
+ * without the retry one transient 5xx on the firing check is an outage the
34
+ * operator is never told about, and the next thing they hear is the recovery.
35
+ *
36
+ * The state is written after the attempt, which means a crash in the gap
37
+ * between a delivered alert and the write re-announces once on restart. That is
38
+ * the cheaper of the two failures: a duplicate is noise, a dropped outage alert
39
+ * is the thing this exists to prevent.
40
+ *
41
+ * The dead-man ping is last and unconditional on the verdict. It reports that
42
+ * the checker is running, not what the checker found — a scrape failure
43
+ * degrades the verdict and still pings. A check that throws before producing a
44
+ * verdict does not reach it, which is the whole signal.
45
+ */
46
+ export const runOnce = async (
47
+ deps: LoopDependencies,
48
+ config: DoctorConfig,
49
+ state: DoctorState,
50
+ ): Promise<DoctorState> => {
51
+ const result = await deps.runCheck(state.counters);
52
+ const transition = advance(
53
+ { ...state, counters: result.counters },
54
+ result.verdict,
55
+ config.dwellChecks,
56
+ deps.now(),
57
+ );
58
+
59
+ deps.log.debug(
60
+ {
61
+ verdict: result.verdict,
62
+ reasons: result.reasons.map((reason) => reason.code),
63
+ settledRuns: transition.state.candidateRuns,
64
+ fires: transition.fires ?? null,
65
+ },
66
+ "doctor: check complete",
67
+ );
68
+
69
+ let next = transition.state;
70
+ const fired = transition.fires;
71
+ if (fired !== undefined && config.webhookUrl !== undefined) {
72
+ const delivery = await attempt(deps.postWebhook(result));
73
+ const outcome: Delivery = delivery.ok
74
+ ? delivery.value
75
+ : { kind: "unreachable", detail: delivery.error };
76
+ if (outcome.kind === "sent") {
77
+ deps.log.info({ verdict: fired }, "doctor: alert sent");
78
+ } else if (outcome.kind === "rejected") {
79
+ deps.log.error(
80
+ { verdict: fired, error: outcome.detail },
81
+ "doctor: the webhook refused this payload; the transition is spent",
82
+ );
83
+ } else {
84
+ // Roll the announcement back. Everything else the check learned —
85
+ // the counter baselines, the settled run — is kept.
86
+ next = { ...next, firedVerdict: state.firedVerdict };
87
+ deps.log.error(
88
+ { verdict: fired, error: outcome.detail },
89
+ "doctor: the webhook could not be reached; retrying on the next check",
90
+ );
91
+ }
92
+ }
93
+
94
+ await deps.saveState(next);
95
+
96
+ if (config.deadManUrl !== undefined) {
97
+ const ping = await attempt(deps.pingDeadMan());
98
+ if (!ping.ok) {
99
+ deps.log.error({ error: ping.error }, "doctor: dead-man ping failed");
100
+ }
101
+ }
102
+
103
+ return next;
104
+ };
105
+
106
+ export interface RunLoopOptions {
107
+ readonly deps: LoopDependencies;
108
+ readonly config: DoctorConfig;
109
+ readonly initial: DoctorState;
110
+ readonly signal: AbortSignal;
111
+ readonly sleep: (ms: number, signal: AbortSignal) => Promise<void>;
112
+ }
113
+
114
+ /**
115
+ * Runs until aborted. A check that throws is logged and the loop continues: the
116
+ * container that reports the stack's failures must not be brought down by one
117
+ * of them, and a crash-looping checker is a dead-man's switch that fires for
118
+ * the wrong reason.
119
+ */
120
+ export const runLoop = async (
121
+ options: RunLoopOptions,
122
+ ): Promise<DoctorState> => {
123
+ let state = options.initial;
124
+ while (!options.signal.aborted) {
125
+ const turn = await attempt(runOnce(options.deps, options.config, state));
126
+ if (turn.ok) {
127
+ state = turn.value;
128
+ } else {
129
+ options.deps.log.error(
130
+ { error: turn.error },
131
+ "doctor: check failed to produce a verdict",
132
+ );
133
+ }
134
+ await options.sleep(options.config.intervalMs, options.signal);
135
+ }
136
+ return state;
137
+ };
138
+
139
+ export const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
140
+ new Promise((resolve) => {
141
+ if (signal.aborted) {
142
+ resolve();
143
+ return;
144
+ }
145
+ const timer = setTimeout(() => {
146
+ signal.removeEventListener("abort", onAbort);
147
+ resolve();
148
+ }, ms);
149
+ const onAbort = () => {
150
+ clearTimeout(timer);
151
+ resolve();
152
+ };
153
+ signal.addEventListener("abort", onAbort, { once: true });
154
+ });
package/src/main.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { runCheck } from "./check.js";
2
+ import { loadConfig } from "./config.js";
3
+ import { pingDeadMan } from "./deadman.js";
4
+ import { describeError, log, setLogLevel } from "./log.js";
5
+ import { runLoop, sleep } from "./loop.js";
6
+ import { readState, writeState } from "./state.js";
7
+ import { postWebhook } from "./webhook.js";
8
+
9
+ /**
10
+ * The `doctor` service (D9). A socket-free node process on the compose network:
11
+ * it scrapes the `/metrics` endpoints over that network and mounts the
12
+ * heartbeat volume read-only, and nothing else.
13
+ *
14
+ * It is its own container rather than a job inside the backend because an
15
+ * alerter inside the backend dies with the thing it is meant to report. It runs
16
+ * whether or not alerting is configured, because `remit doctor` execs into it.
17
+ */
18
+ const config = await Promise.resolve()
19
+ .then(() => loadConfig())
20
+ .catch((error: unknown) => {
21
+ log.error({ error: describeError(error) }, "doctor: refusing to start");
22
+ return process.exit(1);
23
+ });
24
+
25
+ setLogLevel(config.logLevel);
26
+
27
+ const controller = new AbortController();
28
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
29
+ process.on(signal, () => {
30
+ log.info({ signal }, "doctor: shutting down");
31
+ controller.abort();
32
+ });
33
+ }
34
+
35
+ log.info(
36
+ {
37
+ intervalSeconds: config.intervalMs / 1000,
38
+ dwellChecks: config.dwellChecks,
39
+ targets: config.targets.map((target) => target.service),
40
+ alerting: config.webhookUrl !== undefined,
41
+ },
42
+ "doctor: started",
43
+ );
44
+
45
+ await runLoop({
46
+ config,
47
+ initial: await readState(config.stateDir),
48
+ signal: controller.signal,
49
+ sleep,
50
+ deps: {
51
+ runCheck: (counters) => runCheck(config, counters),
52
+ saveState: (state) => writeState(config.stateDir, state),
53
+ postWebhook: (result) =>
54
+ postWebhook(
55
+ {
56
+ // Reached only when a transition fired, which requires the URL.
57
+ url: config.webhookUrl ?? "",
58
+ template: config.webhookTemplate,
59
+ contentType: config.webhookContentType,
60
+ timeoutMs: config.requestTimeoutMs,
61
+ },
62
+ result,
63
+ ),
64
+ pingDeadMan: () =>
65
+ pingDeadMan(config.deadManUrl ?? "", config.requestTimeoutMs),
66
+ now: () => new Date(),
67
+ log,
68
+ },
69
+ });
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { parseMetrics, seriesNamed, sumOf } from "./prometheus.js";
4
+
5
+ describe("parseMetrics", () => {
6
+ it("reads a labelled sample", () => {
7
+ const samples = parseMetrics(
8
+ '# HELP remit_queue_messages Messages.\n# TYPE remit_queue_messages gauge\nremit_queue_messages{queue="imap-sync",role="work"} 3\n',
9
+ );
10
+ assert.deepEqual(samples, [
11
+ {
12
+ name: "remit_queue_messages",
13
+ labels: { queue: "imap-sync", role: "work" },
14
+ value: 3,
15
+ },
16
+ ]);
17
+ });
18
+
19
+ it("reads a sample with no labels", () => {
20
+ assert.deepEqual(parseMetrics("remit_search_index_backlog_rows 0\n"), [
21
+ { name: "remit_search_index_backlog_rows", labels: {}, value: 0 },
22
+ ]);
23
+ });
24
+
25
+ it("drops comments and blank lines without dropping the response", () => {
26
+ const samples = parseMetrics("# HELP x y\n\n \nx 1\n");
27
+ assert.equal(samples.length, 1);
28
+ });
29
+
30
+ it("skips a line it does not recognise rather than refusing the response", () => {
31
+ const samples = parseMetrics("not a sample at all\nx 1\n");
32
+ assert.deepEqual(
33
+ samples.map((sample) => sample.name),
34
+ ["x"],
35
+ );
36
+ });
37
+
38
+ it("unescapes a quote, a backslash and a newline in a label value", () => {
39
+ const [sample] = parseMetrics(
40
+ 'x{a="he said \\"hi\\"",b="c:\\\\path",c="one\\ntwo"} 1\n',
41
+ );
42
+ assert.equal(sample.labels.a, 'he said "hi"');
43
+ assert.equal(sample.labels.b, "c:\\path");
44
+ assert.equal(sample.labels.c, "one\ntwo");
45
+ });
46
+
47
+ it("keeps an unknown escape verbatim, both characters", () => {
48
+ const [sample] = parseMetrics('x{a="tab\\there"} 1\n');
49
+ assert.equal(sample.labels.a, "tab\\there");
50
+ });
51
+
52
+ it("splits on the comma between pairs, not one inside a value", () => {
53
+ const [sample] = parseMetrics('x{a="one, two",b="three"} 1\n');
54
+ assert.deepEqual(sample.labels, { a: "one, two", b: "three" });
55
+ });
56
+
57
+ it("reads a quoted comma at the end of a value", () => {
58
+ const [sample] = parseMetrics('x{a="ends\\",",b="two"} 1\n');
59
+ assert.deepEqual(sample.labels, { a: 'ends",', b: "two" });
60
+ });
61
+
62
+ it("reads infinities and NaN", () => {
63
+ const samples = parseMetrics("a +Inf\nb -Inf\nc NaN\n");
64
+ assert.equal(samples[0].value, Number.POSITIVE_INFINITY);
65
+ assert.equal(samples[1].value, Number.NEGATIVE_INFINITY);
66
+ assert.ok(Number.isNaN(samples[2].value));
67
+ });
68
+
69
+ it("ignores a trailing timestamp", () => {
70
+ const [sample] = parseMetrics("x 5 1700000000000\n");
71
+ assert.equal(sample.value, 5);
72
+ });
73
+
74
+ it("reads an exponent-notation value", () => {
75
+ const [sample] = parseMetrics("x 1.5e+03\n");
76
+ assert.equal(sample.value, 1500);
77
+ });
78
+
79
+ it("tolerates an empty label block", () => {
80
+ const [sample] = parseMetrics("x{} 2\n");
81
+ assert.deepEqual(sample.labels, {});
82
+ });
83
+
84
+ it("stops on a malformed label block instead of throwing", () => {
85
+ assert.deepEqual(parseMetrics("x{a} 2\n")[0].labels, {});
86
+ assert.deepEqual(parseMetrics('x{a=b"} 2\n')[0].labels, {});
87
+ });
88
+ });
89
+
90
+ describe("seriesNamed and sumOf", () => {
91
+ const samples = parseMetrics('a{k="1"} 2\na{k="2"} 3\nb 9\nc +Inf\n');
92
+
93
+ it("selects one series", () => {
94
+ assert.equal(seriesNamed(samples, "a").length, 2);
95
+ });
96
+
97
+ it("sums a series and reads an absent one as zero", () => {
98
+ assert.equal(sumOf(samples, "a"), 5);
99
+ assert.equal(sumOf(samples, "missing"), 0);
100
+ });
101
+
102
+ it("does not let a non-finite sample poison a sum", () => {
103
+ assert.equal(sumOf(samples, "c"), 0);
104
+ });
105
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The subset of the Prometheus text exposition format this checker reads back.
3
+ *
4
+ * Hand-written rather than a dependency: the checker is the container that has
5
+ * to keep working when the rest of the stack does not, and the whole grammar it
6
+ * needs is a name, an optional label set and a float. prom-client renders the
7
+ * format and does not parse it, so a parser is either sixty lines here or a
8
+ * third-party package in the one image whose job is to outlive failures.
9
+ */
10
+ export interface Sample {
11
+ readonly name: string;
12
+ readonly labels: Readonly<Record<string, string>>;
13
+ readonly value: number;
14
+ }
15
+
16
+ const UNESCAPE: Readonly<Record<string, string>> = {
17
+ n: "\n",
18
+ '"': '"',
19
+ "\\": "\\",
20
+ };
21
+
22
+ /**
23
+ * Label values escape `\`, `"` and a newline, and nothing else — a `\t` in the
24
+ * wire format is a literal backslash followed by `t`, so an unknown escape
25
+ * keeps both characters rather than swallowing the backslash.
26
+ */
27
+ const unescapeLabelValue = (raw: string): string => {
28
+ let out = "";
29
+ for (let index = 0; index < raw.length; index += 1) {
30
+ const char = raw[index];
31
+ if (char !== "\\" || index === raw.length - 1) {
32
+ out += char;
33
+ continue;
34
+ }
35
+ const next = raw[index + 1];
36
+ const decoded = UNESCAPE[next];
37
+ out += decoded ?? `\\${next}`;
38
+ index += 1;
39
+ }
40
+ return out;
41
+ };
42
+
43
+ /**
44
+ * Split a label block on the commas that separate pairs, ignoring any comma
45
+ * inside a quoted value. Written as a scan rather than a regular expression
46
+ * because a value may contain `",` verbatim once unescaped.
47
+ */
48
+ const parseLabels = (block: string): Record<string, string> => {
49
+ const labels: Record<string, string> = {};
50
+ let index = 0;
51
+ while (index < block.length) {
52
+ const equals = block.indexOf("=", index);
53
+ if (equals === -1) break;
54
+ const name = block.slice(index, equals).trim();
55
+ const openQuote = block.indexOf('"', equals);
56
+ if (openQuote === -1) break;
57
+ let cursor = openQuote + 1;
58
+ let raw = "";
59
+ while (cursor < block.length) {
60
+ const char = block[cursor];
61
+ if (char === "\\") {
62
+ raw += block.slice(cursor, cursor + 2);
63
+ cursor += 2;
64
+ continue;
65
+ }
66
+ if (char === '"') break;
67
+ raw += char;
68
+ cursor += 1;
69
+ }
70
+ // Only when the value was actually closed. A truncated block is garbage,
71
+ // and recording a label whose value is "whatever was left" invents a
72
+ // series the exporter never rendered.
73
+ if (name !== "" && block[cursor] === '"') {
74
+ labels[name] = unescapeLabelValue(raw);
75
+ }
76
+ const comma = block.indexOf(",", cursor);
77
+ if (comma === -1) break;
78
+ index = comma + 1;
79
+ }
80
+ return labels;
81
+ };
82
+
83
+ /**
84
+ * The value, or `undefined` when the token is not one. `NaN` is a legitimate
85
+ * sample value and parses; a word that merely happens to sit where a value goes
86
+ * does not, so a line of prose cannot become a series.
87
+ */
88
+ const parseValue = (raw: string): number | undefined => {
89
+ const token = raw.trim().split(/\s+/)[0] ?? "";
90
+ if (token === "+Inf") return Number.POSITIVE_INFINITY;
91
+ if (token === "-Inf") return Number.NEGATIVE_INFINITY;
92
+ if (token === "NaN") return Number.NaN;
93
+ const parsed = Number(token);
94
+ return token === "" || Number.isNaN(parsed) ? undefined : parsed;
95
+ };
96
+
97
+ const SERIES = /^([A-Za-z_:][A-Za-z0-9_:]*)(\{(.*)\})?[ \t]+(.+)$/;
98
+
99
+ /**
100
+ * Every sample in an exposition response, in the order it was rendered.
101
+ * `# HELP`/`# TYPE` lines and blanks are dropped; a line that is not a sample
102
+ * is skipped rather than throwing, because a scraper that refuses a whole
103
+ * response over one unfamiliar line reports a healthy service as unreachable.
104
+ */
105
+ export const parseMetrics = (body: string): Sample[] => {
106
+ const samples: Sample[] = [];
107
+ for (const line of body.split("\n")) {
108
+ const trimmed = line.trim();
109
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
110
+ const match = SERIES.exec(trimmed);
111
+ if (!match) continue;
112
+ const [, name, , labelBlock, raw] = match;
113
+ const value = parseValue(raw);
114
+ if (value === undefined) continue;
115
+ samples.push({
116
+ name,
117
+ labels: labelBlock === undefined ? {} : parseLabels(labelBlock),
118
+ value,
119
+ });
120
+ }
121
+ return samples;
122
+ };
123
+
124
+ /** Every sample of one series, across every scraped target. */
125
+ export const seriesNamed = (
126
+ samples: readonly Sample[],
127
+ name: string,
128
+ ): Sample[] => samples.filter((sample) => sample.name === name);
129
+
130
+ /** The sum of one series, `0` when nothing exported it. */
131
+ export const sumOf = (samples: readonly Sample[], name: string): number =>
132
+ seriesNamed(samples, name).reduce(
133
+ (total, sample) =>
134
+ total + (Number.isFinite(sample.value) ? sample.value : 0),
135
+ 0,
136
+ );
@@ -0,0 +1,165 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { exitCodeFor, renderJson, renderLines } from "./report.js";
4
+ import type { CheckResult } from "./verdict.js";
5
+
6
+ const degraded: CheckResult = {
7
+ verdict: "degraded",
8
+ checkedAt: "2026-07-27T10:00:00.000Z",
9
+ summary: "remit is degraded",
10
+ reasons: [
11
+ {
12
+ code: "account_sync_stalled",
13
+ summary: "1 of 3 accounts have not completed a sync in over 3h",
14
+ detail: "0f8a: 40000s",
15
+ },
16
+ {
17
+ code: "dead_letter_queue_not_empty",
18
+ summary:
19
+ "2 messages are quarantined on 1 dead-letter queue (imap-sync-dlq)",
20
+ detail: undefined,
21
+ },
22
+ ],
23
+ counters: {},
24
+ };
25
+
26
+ const healthy: CheckResult = {
27
+ verdict: "healthy",
28
+ checkedAt: "2026-07-27T10:00:00.000Z",
29
+ summary: "remit is healthy",
30
+ reasons: [],
31
+ counters: {},
32
+ };
33
+
34
+ /** The parse the wrapper does: first token is the key, the rest is the value. */
35
+ const parseLines = (out: string): [string, string][] =>
36
+ out
37
+ .split("\n")
38
+ .filter((line) => line !== "")
39
+ .map((line) => {
40
+ const space = line.indexOf(" ");
41
+ return [line.slice(0, space), line.slice(space + 1)] as [string, string];
42
+ });
43
+
44
+ describe("the line format", () => {
45
+ it("opens with the verdict, the timestamp and the headline", () => {
46
+ const records = parseLines(renderLines(degraded));
47
+ assert.deepEqual(records.slice(0, 3), [
48
+ ["verdict", "degraded"],
49
+ ["checked-at", "2026-07-27T10:00:00.000Z"],
50
+ ["summary", "remit is degraded"],
51
+ ]);
52
+ });
53
+
54
+ it("carries one record per reason, then the details", () => {
55
+ const records = parseLines(renderLines(degraded));
56
+ assert.deepEqual(
57
+ records.filter(([key]) => key === "reason").map(([, value]) => value),
58
+ [
59
+ "account_sync_stalled 1 of 3 accounts have not completed a sync in over 3h",
60
+ "dead_letter_queue_not_empty 2 messages are quarantined on 1 dead-letter queue (imap-sync-dlq)",
61
+ ],
62
+ );
63
+ assert.deepEqual(
64
+ records.filter(([key]) => key === "detail").map(([, value]) => value),
65
+ ["account_sync_stalled 0f8a: 40000s"],
66
+ );
67
+ });
68
+
69
+ it("uses a closed key vocabulary, so an unknown key is a version skew and not a value", () => {
70
+ const keys = new Set(parseLines(renderLines(degraded)).map(([key]) => key));
71
+ assert.deepEqual([...keys].sort(), [
72
+ "checked-at",
73
+ "detail",
74
+ "reason",
75
+ "summary",
76
+ "verdict",
77
+ ]);
78
+ });
79
+
80
+ it("puts no reason records in a healthy report", () => {
81
+ const records = parseLines(renderLines(healthy));
82
+ assert.equal(records.length, 3);
83
+ });
84
+
85
+ it("never wraps a record, so one line is always one record", () => {
86
+ for (const line of renderLines(degraded).trimEnd().split("\n")) {
87
+ assert.ok(line.length > 0);
88
+ assert.ok(!line.includes("\n"));
89
+ }
90
+ });
91
+
92
+ // A queue name can hold a newline: the exposition format escapes it and the
93
+ // parser decodes it back. Split across two lines, a caller reading by
94
+ // position takes the remainder as a record with a garbage key and silently
95
+ // drops half the reason. The JSON body escapes its way out of this; a line
96
+ // format cannot.
97
+ it("keeps a newline in a value from splitting the record", () => {
98
+ const nasty: CheckResult = {
99
+ ...degraded,
100
+ reasons: [
101
+ {
102
+ code: "dead_letter_queue_not_empty",
103
+ summary:
104
+ '2 messages are quarantined on 1 dead-letter queue (bad\nname"x)',
105
+ detail: "first\r\nsecond",
106
+ },
107
+ ],
108
+ };
109
+ const out = renderLines(nasty);
110
+ assert.equal(out.trimEnd().split("\n").length, 5);
111
+ const records = parseLines(out);
112
+ assert.deepEqual(
113
+ records.filter(([key]) => key === "reason").map(([, value]) => value),
114
+ [
115
+ 'dead_letter_queue_not_empty 2 messages are quarantined on 1 dead-letter queue (bad name"x)',
116
+ ],
117
+ );
118
+ assert.deepEqual(
119
+ records.filter(([key]) => key === "detail").map(([, value]) => value),
120
+ ["dead_letter_queue_not_empty first second"],
121
+ );
122
+ });
123
+
124
+ it("collapses every C0 control character, not only the newline", () => {
125
+ const out = renderLines({
126
+ ...degraded,
127
+ reasons: [
128
+ {
129
+ code: "scrape_failed",
130
+ summary: "a\u0000b\u001bc\u007fd",
131
+ detail: undefined,
132
+ },
133
+ ],
134
+ });
135
+ assert.equal(out.trimEnd().split("\n").length, 4);
136
+ assert.match(out, /reason scrape_failed a b c d/);
137
+ });
138
+ });
139
+
140
+ describe("the json format", () => {
141
+ it("parses, and carries the same verdict and reasons", () => {
142
+ const parsed = JSON.parse(renderJson(degraded)) as {
143
+ verdict: string;
144
+ reasons: { code: string; summary: string; detail: string | null }[];
145
+ };
146
+ assert.equal(parsed.verdict, "degraded");
147
+ assert.deepEqual(
148
+ parsed.reasons.map((reason) => reason.code),
149
+ ["account_sync_stalled", "dead_letter_queue_not_empty"],
150
+ );
151
+ assert.equal(parsed.reasons[1].detail, null);
152
+ });
153
+
154
+ it("renders a healthy verdict as an empty reason list, not an absent key", () => {
155
+ const parsed = JSON.parse(renderJson(healthy)) as { reasons: unknown[] };
156
+ assert.deepEqual(parsed.reasons, []);
157
+ });
158
+ });
159
+
160
+ describe("exit codes", () => {
161
+ it("is zero on healthy and non-zero on degraded, so cron can use it directly", () => {
162
+ assert.equal(exitCodeFor(healthy), 0);
163
+ assert.equal(exitCodeFor(degraded), 1);
164
+ });
165
+ });