@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/package.json +29 -0
- package/src/attempt.ts +34 -0
- package/src/check.test.ts +81 -0
- package/src/check.ts +36 -0
- package/src/cli.ts +43 -0
- package/src/config.test.ts +115 -0
- package/src/config.ts +241 -0
- package/src/deadman.test.ts +31 -0
- package/src/deadman.ts +34 -0
- package/src/dwell.test.ts +120 -0
- package/src/dwell.ts +53 -0
- package/src/heartbeats.test.ts +68 -0
- package/src/heartbeats.ts +68 -0
- package/src/index.ts +18 -0
- package/src/log.ts +71 -0
- package/src/loop.test.ts +301 -0
- package/src/loop.ts +154 -0
- package/src/main.ts +69 -0
- package/src/prometheus.test.ts +105 -0
- package/src/prometheus.ts +136 -0
- package/src/report.test.ts +165 -0
- package/src/report.ts +106 -0
- package/src/scrape.test.ts +71 -0
- package/src/scrape.ts +63 -0
- package/src/state.test.ts +111 -0
- package/src/state.ts +127 -0
- package/src/verdict.test.ts +554 -0
- package/src/verdict.ts +386 -0
- package/src/webhook.test.ts +241 -0
- package/src/webhook.ts +155 -0
- package/tsconfig.json +8 -0
package/src/report.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { CheckResult } from "./verdict.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The exec seam (D4). `remit doctor` is a POSIX shell script whose only tool is
|
|
5
|
+
* `docker`, so it does not scrape anything itself — it runs
|
|
6
|
+
* `docker compose exec -T doctor node check.mjs` and formats what comes back.
|
|
7
|
+
*
|
|
8
|
+
* Two renderings, because the wrapper needs both and can build neither from the
|
|
9
|
+
* other: a line format `while read -r key rest` parses in shell without a JSON
|
|
10
|
+
* parser, and a JSON object for `remit doctor --json` to pass straight through.
|
|
11
|
+
*
|
|
12
|
+
* The line format is a contract. Keys are a closed vocabulary, one record per
|
|
13
|
+
* line, the key is the first space-delimited token and the value is the rest of
|
|
14
|
+
* the line verbatim. New keys may be added; existing keys do not change meaning.
|
|
15
|
+
*
|
|
16
|
+
* verdict healthy | degraded
|
|
17
|
+
* checked-at ISO 8601 UTC
|
|
18
|
+
* summary one-line headline, no reason detail
|
|
19
|
+
* reason <code> <summary> — zero or more, stable order
|
|
20
|
+
* detail <code> <detail> — zero or more, only for reasons that have one
|
|
21
|
+
*
|
|
22
|
+
* `reason` summaries carry counts, service names and queue names (D10).
|
|
23
|
+
* `detail` carries the account ids behind them and is printed here because this
|
|
24
|
+
* output never leaves the box; nothing in the alert path reads it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One record is one line, whatever the value contains.
|
|
29
|
+
*
|
|
30
|
+
* A queue name may hold a newline — the exposition format escapes it, and the
|
|
31
|
+
* parser decodes it back into a real one — and a caught error's message may
|
|
32
|
+
* hold anything. Either would split a record in two, and a caller parsing by
|
|
33
|
+
* position reads the remainder as a record with a garbage key: half a reason
|
|
34
|
+
* silently dropped, with nothing to say it happened. The JSON rendering and the
|
|
35
|
+
* webhook body escape their way out of this; a line format cannot, so the
|
|
36
|
+
* control characters are collapsed before they get in.
|
|
37
|
+
*/
|
|
38
|
+
const DELETE = 0x7f;
|
|
39
|
+
const LAST_CONTROL = 0x1f;
|
|
40
|
+
|
|
41
|
+
const isControl = (code: number): boolean =>
|
|
42
|
+
code <= LAST_CONTROL || code === DELETE;
|
|
43
|
+
|
|
44
|
+
// Written as a code-point scan rather than a character class: a regular
|
|
45
|
+
// expression carrying literal control characters is exactly the pattern the
|
|
46
|
+
// lint rule exists to catch, and spelling the range out reads better anyway.
|
|
47
|
+
const oneLine = (value: string): string => {
|
|
48
|
+
let out = "";
|
|
49
|
+
let pending = false;
|
|
50
|
+
for (const character of value) {
|
|
51
|
+
if (isControl(character.charCodeAt(0))) {
|
|
52
|
+
pending = out !== "";
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (pending) {
|
|
56
|
+
out += " ";
|
|
57
|
+
pending = false;
|
|
58
|
+
}
|
|
59
|
+
out += character;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const renderLines = (result: CheckResult): string => {
|
|
65
|
+
const lines = [
|
|
66
|
+
`verdict ${result.verdict}`,
|
|
67
|
+
`checked-at ${result.checkedAt}`,
|
|
68
|
+
`summary ${oneLine(result.summary)}`,
|
|
69
|
+
];
|
|
70
|
+
for (const reason of result.reasons) {
|
|
71
|
+
lines.push(`reason ${reason.code} ${oneLine(reason.summary)}`);
|
|
72
|
+
}
|
|
73
|
+
for (const reason of result.reasons) {
|
|
74
|
+
if (reason.detail !== undefined) {
|
|
75
|
+
lines.push(`detail ${reason.code} ${oneLine(reason.detail)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return `${lines.join("\n")}\n`;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const renderJson = (result: CheckResult): string =>
|
|
82
|
+
`${JSON.stringify(
|
|
83
|
+
{
|
|
84
|
+
verdict: result.verdict,
|
|
85
|
+
checkedAt: result.checkedAt,
|
|
86
|
+
summary: result.summary,
|
|
87
|
+
reasons: result.reasons.map((reason) => ({
|
|
88
|
+
code: reason.code,
|
|
89
|
+
summary: reason.summary,
|
|
90
|
+
detail: reason.detail ?? null,
|
|
91
|
+
})),
|
|
92
|
+
},
|
|
93
|
+
null,
|
|
94
|
+
2,
|
|
95
|
+
)}\n`;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 0 healthy, 1 degraded. A crash before a verdict exists exits 2 — both are
|
|
99
|
+
* non-zero, so a cron job or an external monitor can use the command directly,
|
|
100
|
+
* and a caller that wants to distinguish "something is wrong with the stack"
|
|
101
|
+
* from "the checker could not answer" can.
|
|
102
|
+
*/
|
|
103
|
+
export const exitCodeFor = (result: CheckResult): number =>
|
|
104
|
+
result.verdict === "healthy" ? 0 : 1;
|
|
105
|
+
|
|
106
|
+
export const NO_VERDICT_EXIT_CODE = 2;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { scrapeAll, scrapeTarget } from "./scrape.js";
|
|
4
|
+
|
|
5
|
+
const ok = (body: string) =>
|
|
6
|
+
(async () => new Response(body, { status: 200 })) as unknown as typeof fetch;
|
|
7
|
+
|
|
8
|
+
describe("scrapeTarget", () => {
|
|
9
|
+
it("parses a 200 response", async () => {
|
|
10
|
+
const result = await scrapeTarget(
|
|
11
|
+
{ service: "queue", url: "http://queue:9324/metrics" },
|
|
12
|
+
1000,
|
|
13
|
+
ok('remit_queue_messages{queue="a",role="work"} 1\n'),
|
|
14
|
+
);
|
|
15
|
+
assert.equal(result.error, undefined);
|
|
16
|
+
assert.equal(result.samples.length, 1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reads a non-2xx as a failure, not as an empty set of signals", async () => {
|
|
20
|
+
const result = await scrapeTarget(
|
|
21
|
+
{ service: "backend", url: "http://backend:8080/metrics" },
|
|
22
|
+
1000,
|
|
23
|
+
(async () =>
|
|
24
|
+
new Response("collection failed", {
|
|
25
|
+
status: 500,
|
|
26
|
+
})) as unknown as typeof fetch,
|
|
27
|
+
);
|
|
28
|
+
assert.equal(result.error, "HTTP 500");
|
|
29
|
+
assert.deepEqual(result.samples, []);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("reads a refused connection as a failure rather than throwing", async () => {
|
|
33
|
+
const result = await scrapeTarget(
|
|
34
|
+
{ service: "backend", url: "http://backend:8080/metrics" },
|
|
35
|
+
1000,
|
|
36
|
+
(async () => {
|
|
37
|
+
throw new Error("connect ECONNREFUSED");
|
|
38
|
+
}) as unknown as typeof fetch,
|
|
39
|
+
);
|
|
40
|
+
assert.match(result.error ?? "", /ECONNREFUSED/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("describes a thrown non-Error", async () => {
|
|
44
|
+
const result = await scrapeTarget(
|
|
45
|
+
{ service: "backend", url: "http://backend:8080/metrics" },
|
|
46
|
+
1000,
|
|
47
|
+
(async () => {
|
|
48
|
+
throw "nope";
|
|
49
|
+
}) as unknown as typeof fetch,
|
|
50
|
+
);
|
|
51
|
+
assert.equal(result.error, "nope");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("scrapeAll", () => {
|
|
56
|
+
it("one refused endpoint does not cost the readings of the others", async () => {
|
|
57
|
+
const results = await scrapeAll(
|
|
58
|
+
[
|
|
59
|
+
{ service: "backend", url: "http://backend:8080/metrics" },
|
|
60
|
+
{ service: "queue", url: "http://queue:9324/metrics" },
|
|
61
|
+
],
|
|
62
|
+
1000,
|
|
63
|
+
(async (url: string) => {
|
|
64
|
+
if (url.includes("backend")) throw new Error("down");
|
|
65
|
+
return new Response("x 1\n", { status: 200 });
|
|
66
|
+
}) as unknown as typeof fetch,
|
|
67
|
+
);
|
|
68
|
+
assert.equal(results[0].error, "down");
|
|
69
|
+
assert.equal(results[1].samples.length, 1);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/scrape.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { attempt } from "./attempt.js";
|
|
2
|
+
import type { ScrapeTarget } from "./config.js";
|
|
3
|
+
import { parseMetrics, type Sample } from "./prometheus.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One target's scrape. A failure is a value, not a throw: an unreachable
|
|
7
|
+
* endpoint is a signal the verdict has to carry, and a checker that aborts on
|
|
8
|
+
* the first refused connection reports nothing about the services that did
|
|
9
|
+
* answer.
|
|
10
|
+
*/
|
|
11
|
+
export interface ScrapeResult {
|
|
12
|
+
readonly service: string;
|
|
13
|
+
readonly samples: readonly Sample[];
|
|
14
|
+
readonly error: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Fetcher = typeof fetch;
|
|
18
|
+
|
|
19
|
+
export const scrapeTarget = async (
|
|
20
|
+
target: ScrapeTarget,
|
|
21
|
+
timeoutMs: number,
|
|
22
|
+
fetcher: Fetcher = fetch,
|
|
23
|
+
): Promise<ScrapeResult> => {
|
|
24
|
+
const response = await attempt(
|
|
25
|
+
fetcher(target.url, {
|
|
26
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
27
|
+
headers: { accept: "text/plain" },
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
return { service: target.service, samples: [], error: response.error };
|
|
32
|
+
}
|
|
33
|
+
if (!response.value.ok) {
|
|
34
|
+
return {
|
|
35
|
+
service: target.service,
|
|
36
|
+
samples: [],
|
|
37
|
+
error: `HTTP ${response.value.status}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const body = await attempt(response.value.text());
|
|
41
|
+
if (!body.ok) {
|
|
42
|
+
return { service: target.service, samples: [], error: body.error };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
service: target.service,
|
|
46
|
+
samples: parseMetrics(body.value),
|
|
47
|
+
error: undefined,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every target at once. They are independent endpoints on the same network and
|
|
53
|
+
* the slowest is bounded by the scrape timeout, so a sequential pass would only
|
|
54
|
+
* make the whole check as slow as the sum of its stalls.
|
|
55
|
+
*/
|
|
56
|
+
export const scrapeAll = async (
|
|
57
|
+
targets: readonly ScrapeTarget[],
|
|
58
|
+
timeoutMs: number,
|
|
59
|
+
fetcher: Fetcher = fetch,
|
|
60
|
+
): Promise<ScrapeResult[]> =>
|
|
61
|
+
Promise.all(
|
|
62
|
+
targets.map((target) => scrapeTarget(target, timeoutMs, fetcher)),
|
|
63
|
+
);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
type DoctorState,
|
|
8
|
+
initialState,
|
|
9
|
+
parseState,
|
|
10
|
+
readState,
|
|
11
|
+
STATE_VERSION,
|
|
12
|
+
stateFile,
|
|
13
|
+
writeState,
|
|
14
|
+
} from "./state.js";
|
|
15
|
+
|
|
16
|
+
const temporaryDir = () => mkdtemp(join(tmpdir(), "remit-doctor-state-"));
|
|
17
|
+
|
|
18
|
+
const state: DoctorState = {
|
|
19
|
+
version: STATE_VERSION,
|
|
20
|
+
firedVerdict: "degraded",
|
|
21
|
+
candidateVerdict: "degraded",
|
|
22
|
+
candidateRuns: 3,
|
|
23
|
+
counters: {
|
|
24
|
+
"imap-worker:imap_auth_failures": { total: 4, lastRoseAt: 1785142027000 },
|
|
25
|
+
},
|
|
26
|
+
updatedAt: "2026-07-27T10:00:00.000Z",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe("parseState", () => {
|
|
30
|
+
it("round-trips what writeState writes", async () => {
|
|
31
|
+
assert.deepEqual(await parseState(JSON.stringify(state)), state);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("falls back to the baseline rather than refusing to start", async () => {
|
|
35
|
+
assert.deepEqual(await parseState("not json at all"), initialState);
|
|
36
|
+
assert.deepEqual(await parseState("null"), initialState);
|
|
37
|
+
assert.deepEqual(await parseState('"a string"'), initialState);
|
|
38
|
+
assert.deepEqual(await parseState('{"version":3}'), initialState);
|
|
39
|
+
assert.deepEqual(
|
|
40
|
+
await parseState('{"version":2,"firedVerdict":"broken"}'),
|
|
41
|
+
initialState,
|
|
42
|
+
);
|
|
43
|
+
assert.deepEqual(
|
|
44
|
+
await parseState(
|
|
45
|
+
'{"version":2,"firedVerdict":"healthy","candidateVerdict":"healthy","candidateRuns":"three","counters":{}}',
|
|
46
|
+
),
|
|
47
|
+
initialState,
|
|
48
|
+
);
|
|
49
|
+
assert.deepEqual(
|
|
50
|
+
await parseState(
|
|
51
|
+
'{"version":2,"firedVerdict":"healthy","candidateVerdict":"healthy","candidateRuns":1,"counters":{"a":"b"}}',
|
|
52
|
+
),
|
|
53
|
+
initialState,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("assumes a stack nobody has checked is healthy, so no install announces itself", () => {
|
|
58
|
+
assert.equal(initialState.firedVerdict, "healthy");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("resets a state file written by the version before the counter timestamps", async () => {
|
|
62
|
+
// v1 stored `counters` as bare totals, which cannot say when a counter last
|
|
63
|
+
// rose. Reading one as a baseline would leave the auth signal unable to
|
|
64
|
+
// hold, so the file is replaced; the cost is at most one repeated alert.
|
|
65
|
+
assert.deepEqual(
|
|
66
|
+
await parseState(
|
|
67
|
+
'{"version":1,"firedVerdict":"degraded","candidateVerdict":"degraded","candidateRuns":3,"counters":{"imap-worker:imap_auth_failures":4}}',
|
|
68
|
+
),
|
|
69
|
+
initialState,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("tolerates a missing updatedAt", async () => {
|
|
74
|
+
const parsed = await parseState(
|
|
75
|
+
'{"version":2,"firedVerdict":"healthy","candidateVerdict":"healthy","candidateRuns":1,"counters":{}}',
|
|
76
|
+
);
|
|
77
|
+
assert.equal(parsed.updatedAt, undefined);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("readState and writeState", () => {
|
|
82
|
+
it("persists across a read", async () => {
|
|
83
|
+
const directory = await temporaryDir();
|
|
84
|
+
await writeState(directory, state);
|
|
85
|
+
assert.deepEqual(await readState(directory), state);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("creates its directory", async () => {
|
|
89
|
+
const directory = join(await temporaryDir(), "nested", "deeper");
|
|
90
|
+
await writeState(directory, state);
|
|
91
|
+
assert.deepEqual(await readState(directory), state);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("reads a directory with no state file as a fresh install", async () => {
|
|
95
|
+
assert.deepEqual(await readState(await temporaryDir()), initialState);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reads a truncated file as a fresh install rather than throwing", async () => {
|
|
99
|
+
const directory = await temporaryDir();
|
|
100
|
+
await writeFile(stateFile(directory), '{"version":1,"fired');
|
|
101
|
+
assert.deepEqual(await readState(directory), initialState);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("renames into place, so a kill mid-write cannot truncate the live file", async () => {
|
|
105
|
+
const directory = await temporaryDir();
|
|
106
|
+
await writeState(directory, state);
|
|
107
|
+
await writeState(directory, { ...state, candidateRuns: 1 });
|
|
108
|
+
const raw = await readFile(stateFile(directory), "utf8");
|
|
109
|
+
assert.equal(JSON.parse(raw).candidateRuns, 1);
|
|
110
|
+
});
|
|
111
|
+
});
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { attempt, safeJsonParse } from "./attempt.js";
|
|
4
|
+
import type { Verdict } from "./verdict.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the checker has to remember across a restart: which verdict it last
|
|
8
|
+
* announced, how long the current one has held, and the counter totals a delta
|
|
9
|
+
* is measured against.
|
|
10
|
+
*
|
|
11
|
+
* Without the first field a container restart re-announces a condition already
|
|
12
|
+
* reported, which is precisely the noise D8 exists to remove — and a checker
|
|
13
|
+
* with `restart: unless-stopped` restarts on every `remit update`.
|
|
14
|
+
*/
|
|
15
|
+
export const STATE_VERSION = 2;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A counter's last observed total, and when it last went up.
|
|
19
|
+
*
|
|
20
|
+
* The total is the baseline a delta is measured against. `lastRoseAt` is what
|
|
21
|
+
* makes the signal a condition rather than an instant: authentication failures
|
|
22
|
+
* arrive in one burst per sync tick, so the quiet hour between two bursts is
|
|
23
|
+
* not a recovery, and a reason that is true for one check in sixty can never
|
|
24
|
+
* satisfy a three-check dwell.
|
|
25
|
+
*/
|
|
26
|
+
export interface CounterState {
|
|
27
|
+
readonly total: number;
|
|
28
|
+
/** Epoch milliseconds, or `null` when it has not risen since first seen. */
|
|
29
|
+
readonly lastRoseAt: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DoctorState {
|
|
33
|
+
readonly version: typeof STATE_VERSION;
|
|
34
|
+
/** The last verdict actually sent. Never null: a fresh install is healthy. */
|
|
35
|
+
readonly firedVerdict: Verdict;
|
|
36
|
+
/** The verdict the run below is counting. */
|
|
37
|
+
readonly candidateVerdict: Verdict;
|
|
38
|
+
/** Consecutive checks that have agreed on `candidateVerdict`. */
|
|
39
|
+
readonly candidateRuns: number;
|
|
40
|
+
readonly counters: Readonly<Record<string, CounterState>>;
|
|
41
|
+
readonly updatedAt: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A stack that has never been checked is assumed healthy rather than unknown.
|
|
46
|
+
* Treating it as unknown makes the first settled `healthy` a transition, so
|
|
47
|
+
* every install would announce itself to the operator's channel on boot, and
|
|
48
|
+
* the one message people learn to ignore is the one that arrives when nothing
|
|
49
|
+
* is wrong.
|
|
50
|
+
*/
|
|
51
|
+
export const initialState: DoctorState = {
|
|
52
|
+
version: STATE_VERSION,
|
|
53
|
+
firedVerdict: "healthy",
|
|
54
|
+
candidateVerdict: "healthy",
|
|
55
|
+
candidateRuns: 0,
|
|
56
|
+
counters: {},
|
|
57
|
+
updatedAt: undefined,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const isVerdict = (value: unknown): value is Verdict =>
|
|
61
|
+
value === "healthy" || value === "degraded";
|
|
62
|
+
|
|
63
|
+
const isCounterState = (value: unknown): value is CounterState =>
|
|
64
|
+
typeof value === "object" &&
|
|
65
|
+
value !== null &&
|
|
66
|
+
typeof (value as CounterState).total === "number" &&
|
|
67
|
+
(typeof (value as CounterState).lastRoseAt === "number" ||
|
|
68
|
+
(value as CounterState).lastRoseAt === null);
|
|
69
|
+
|
|
70
|
+
const isCounters = (value: unknown): value is Record<string, CounterState> =>
|
|
71
|
+
typeof value === "object" &&
|
|
72
|
+
value !== null &&
|
|
73
|
+
!Array.isArray(value) &&
|
|
74
|
+
Object.values(value).every(isCounterState);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A state file this version does not recognise is replaced by the baseline, not
|
|
78
|
+
* refused. The alternative is a container that will not start because of its
|
|
79
|
+
* own bookkeeping, and the cost of the reset is at most one repeated alert.
|
|
80
|
+
*/
|
|
81
|
+
export const parseState = async (raw: string): Promise<DoctorState> => {
|
|
82
|
+
const parsed = await safeJsonParse<unknown>(raw).catch(() => undefined);
|
|
83
|
+
if (typeof parsed !== "object" || parsed === null) return initialState;
|
|
84
|
+
const candidate = parsed as Record<string, unknown>;
|
|
85
|
+
if (
|
|
86
|
+
candidate.version !== STATE_VERSION ||
|
|
87
|
+
!isVerdict(candidate.firedVerdict) ||
|
|
88
|
+
!isVerdict(candidate.candidateVerdict) ||
|
|
89
|
+
typeof candidate.candidateRuns !== "number" ||
|
|
90
|
+
!isCounters(candidate.counters)
|
|
91
|
+
) {
|
|
92
|
+
return initialState;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
version: STATE_VERSION,
|
|
96
|
+
firedVerdict: candidate.firedVerdict,
|
|
97
|
+
candidateVerdict: candidate.candidateVerdict,
|
|
98
|
+
candidateRuns: candidate.candidateRuns,
|
|
99
|
+
counters: candidate.counters,
|
|
100
|
+
updatedAt:
|
|
101
|
+
typeof candidate.updatedAt === "string" ? candidate.updatedAt : undefined,
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const stateFile = (directory: string): string =>
|
|
106
|
+
join(directory, "state.json");
|
|
107
|
+
|
|
108
|
+
export const readState = async (directory: string): Promise<DoctorState> => {
|
|
109
|
+
const raw = await attempt(readFile(stateFile(directory), "utf8"));
|
|
110
|
+
return raw.ok ? parseState(raw.value) : initialState;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write then rename. A checker killed mid-write must come back to the previous
|
|
115
|
+
* state rather than to a truncated file it would read as a fresh install and
|
|
116
|
+
* re-announce from.
|
|
117
|
+
*/
|
|
118
|
+
export const writeState = async (
|
|
119
|
+
directory: string,
|
|
120
|
+
state: DoctorState,
|
|
121
|
+
): Promise<void> => {
|
|
122
|
+
await mkdir(directory, { recursive: true });
|
|
123
|
+
const target = stateFile(directory);
|
|
124
|
+
const temporary = `${target}.tmp`;
|
|
125
|
+
await writeFile(temporary, `${JSON.stringify(state, null, "\t")}\n`);
|
|
126
|
+
await rename(temporary, target);
|
|
127
|
+
};
|