@remit/doctor 0.0.6 → 0.0.7
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 +1 -1
- package/src/cli.ts +3 -1
- package/src/config.test.ts +40 -0
- package/src/config.ts +39 -1
- package/src/report.test.ts +50 -27
- package/src/report.ts +11 -2
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -34,7 +34,9 @@ const check = async (): Promise<number> => {
|
|
|
34
34
|
const result = await runCheck(config, state.counters);
|
|
35
35
|
await writeVerdict(
|
|
36
36
|
process.stdout,
|
|
37
|
-
json
|
|
37
|
+
json
|
|
38
|
+
? renderJson(result, config.searchEmbeddingProvider)
|
|
39
|
+
: renderLines(result, config.searchEmbeddingProvider),
|
|
38
40
|
);
|
|
39
41
|
return exitCodeFor(result);
|
|
40
42
|
};
|
package/src/config.test.ts
CHANGED
|
@@ -21,6 +21,46 @@ describe("loadConfig", () => {
|
|
|
21
21
|
]);
|
|
22
22
|
assert.equal(config.tlsMode, "off");
|
|
23
23
|
assert.equal(config.tunnelReadyUrl, "http://tunnel:2000/ready");
|
|
24
|
+
assert.equal(config.searchEmbeddingProvider, "unknown");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// `search-index-worker` writes a heartbeat only while it is running, and on
|
|
28
|
+
// `off` it sits behind the `semantic` compose profile and is not. Watching
|
|
29
|
+
// for a file a deliberate opt-out never writes reports that opt-out as a
|
|
30
|
+
// fault, on every check, forever.
|
|
31
|
+
it("stops watching search-index-worker when semantic search is off", () => {
|
|
32
|
+
const config = loadConfig({ DOCTOR_SEARCH_EMBEDDING_PROVIDER: "off" });
|
|
33
|
+
assert.equal(config.searchEmbeddingProvider, "off");
|
|
34
|
+
assert.deepEqual(config.heartbeatServices, [
|
|
35
|
+
"imap-worker",
|
|
36
|
+
"smtp-worker",
|
|
37
|
+
"account-worker",
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps watching it on every provider that runs the worker", () => {
|
|
42
|
+
for (const provider of ["local", "bedrock"]) {
|
|
43
|
+
const config = loadConfig({
|
|
44
|
+
DOCTOR_SEARCH_EMBEDDING_PROVIDER: provider,
|
|
45
|
+
});
|
|
46
|
+
assert.equal(config.searchEmbeddingProvider, provider);
|
|
47
|
+
assert.ok(config.heartbeatServices.includes("search-index-worker"));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps watching it when nothing named a provider, rather than assuming off", () => {
|
|
52
|
+
assert.ok(loadConfig({}).heartbeatServices.includes("search-index-worker"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("leaves an explicit heartbeat list alone, off or not", () => {
|
|
56
|
+
const config = loadConfig({
|
|
57
|
+
DOCTOR_SEARCH_EMBEDDING_PROVIDER: "off",
|
|
58
|
+
DOCTOR_HEARTBEAT_SERVICES: "imap-worker, search-index-worker",
|
|
59
|
+
});
|
|
60
|
+
assert.deepEqual(config.heartbeatServices, [
|
|
61
|
+
"imap-worker",
|
|
62
|
+
"search-index-worker",
|
|
63
|
+
]);
|
|
24
64
|
});
|
|
25
65
|
|
|
26
66
|
it("takes the deployment's serving mode and the edge's readiness endpoint", () => {
|
package/src/config.ts
CHANGED
|
@@ -41,6 +41,15 @@ export interface DoctorConfig {
|
|
|
41
41
|
*/
|
|
42
42
|
readonly tlsMode: string;
|
|
43
43
|
readonly tunnelReadyUrl: string;
|
|
44
|
+
/**
|
|
45
|
+
* The deployment's `SEARCH_EMBEDDING_PROVIDER`, verbatim, handed through as
|
|
46
|
+
* `DOCTOR_SEARCH_EMBEDDING_PROVIDER` for the same reason `tlsMode` is: which
|
|
47
|
+
* services are in the stack is a property of how the deployment is
|
|
48
|
+
* configured. `off` holds `search-index-worker` down behind the `semantic`
|
|
49
|
+
* compose profile, and a heartbeat file for a service that is deliberately
|
|
50
|
+
* not running would report an opt-out as a fault forever.
|
|
51
|
+
*/
|
|
52
|
+
readonly searchEmbeddingProvider: string;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
/**
|
|
@@ -64,6 +73,25 @@ const DEFAULT_HEARTBEAT_SERVICES = [
|
|
|
64
73
|
"search-index-worker",
|
|
65
74
|
];
|
|
66
75
|
|
|
76
|
+
/**
|
|
77
|
+
* What the checker reports when nothing told it which provider is configured.
|
|
78
|
+
* The compose service always passes the value through, so this is a container
|
|
79
|
+
* started some other way — and the answer is to say it was not told and keep
|
|
80
|
+
* every signal on, never to drop a worker from the watch on an assumption. A
|
|
81
|
+
* check that quietly stops checking is the failure this design exists to remove.
|
|
82
|
+
*/
|
|
83
|
+
const DEFAULT_SEARCH_EMBEDDING_PROVIDER = "unknown";
|
|
84
|
+
|
|
85
|
+
/** The provider under which `search-index-worker` is in the stack at all. */
|
|
86
|
+
const SEARCH_EMBEDDING_OFF = "off";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The worker whose liveness only exists when semantic search is on. Dropped
|
|
90
|
+
* from the default set rather than from the reading, so an operator who names
|
|
91
|
+
* DOCTOR_HEARTBEAT_SERVICES themselves still gets exactly what they asked for.
|
|
92
|
+
*/
|
|
93
|
+
const SEARCH_INDEX_WORKER = "search-index-worker";
|
|
94
|
+
|
|
67
95
|
/** The same 420 s the workers' own compose healthcheck uses; one threshold. */
|
|
68
96
|
const DEFAULT_HEARTBEAT_MAX_AGE_SECONDS = 420;
|
|
69
97
|
|
|
@@ -210,6 +238,15 @@ export const loadConfig = (env: Env = process.env): DoctorConfig => {
|
|
|
210
238
|
|
|
211
239
|
const targetsRaw = text(env, "DOCTOR_TARGETS");
|
|
212
240
|
const servicesRaw = text(env, "DOCTOR_HEARTBEAT_SERVICES");
|
|
241
|
+
const searchEmbeddingProvider =
|
|
242
|
+
text(env, "DOCTOR_SEARCH_EMBEDDING_PROVIDER") ??
|
|
243
|
+
DEFAULT_SEARCH_EMBEDDING_PROVIDER;
|
|
244
|
+
const defaultHeartbeatServices =
|
|
245
|
+
searchEmbeddingProvider === SEARCH_EMBEDDING_OFF
|
|
246
|
+
? DEFAULT_HEARTBEAT_SERVICES.filter(
|
|
247
|
+
(name) => name !== SEARCH_INDEX_WORKER,
|
|
248
|
+
)
|
|
249
|
+
: DEFAULT_HEARTBEAT_SERVICES;
|
|
213
250
|
|
|
214
251
|
return {
|
|
215
252
|
intervalMs:
|
|
@@ -226,7 +263,7 @@ export const loadConfig = (env: Env = process.env): DoctorConfig => {
|
|
|
226
263
|
heartbeatDir: text(env, "DOCTOR_HEARTBEAT_DIR") ?? "/data/heartbeat",
|
|
227
264
|
heartbeatServices:
|
|
228
265
|
servicesRaw === undefined
|
|
229
|
-
?
|
|
266
|
+
? defaultHeartbeatServices
|
|
230
267
|
: servicesRaw
|
|
231
268
|
.split(",")
|
|
232
269
|
.map((name) => name.trim())
|
|
@@ -271,5 +308,6 @@ export const loadConfig = (env: Env = process.env): DoctorConfig => {
|
|
|
271
308
|
tlsMode: text(env, "DOCTOR_TLS_MODE") ?? DEFAULT_TLS_MODE,
|
|
272
309
|
tunnelReadyUrl:
|
|
273
310
|
text(env, "DOCTOR_TUNNEL_READY_URL") ?? DEFAULT_TUNNEL_READY_URL,
|
|
311
|
+
searchEmbeddingProvider,
|
|
274
312
|
};
|
|
275
313
|
};
|
package/src/report.test.ts
CHANGED
|
@@ -48,7 +48,7 @@ const parseLines = (out: string): [string, string][] =>
|
|
|
48
48
|
|
|
49
49
|
describe("the line format", () => {
|
|
50
50
|
it("opens with the verdict, the timestamp and the headline", () => {
|
|
51
|
-
const records = parseLines(renderLines(degraded));
|
|
51
|
+
const records = parseLines(renderLines(degraded, "off"));
|
|
52
52
|
assert.deepEqual(records.slice(0, 3), [
|
|
53
53
|
["verdict", "degraded"],
|
|
54
54
|
["checked-at", "2026-07-27T10:00:00.000Z"],
|
|
@@ -57,7 +57,7 @@ describe("the line format", () => {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
it("carries one record per reason, then the details", () => {
|
|
60
|
-
const records = parseLines(renderLines(degraded));
|
|
60
|
+
const records = parseLines(renderLines(degraded, "off"));
|
|
61
61
|
assert.deepEqual(
|
|
62
62
|
records.filter(([key]) => key === "reason").map(([, value]) => value),
|
|
63
63
|
[
|
|
@@ -72,23 +72,26 @@ describe("the line format", () => {
|
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
it("uses a closed key vocabulary, so an unknown key is a version skew and not a value", () => {
|
|
75
|
-
const keys = new Set(
|
|
75
|
+
const keys = new Set(
|
|
76
|
+
parseLines(renderLines(degraded, "off")).map(([key]) => key),
|
|
77
|
+
);
|
|
76
78
|
assert.deepEqual([...keys].sort(), [
|
|
77
79
|
"checked-at",
|
|
78
80
|
"detail",
|
|
79
81
|
"reason",
|
|
82
|
+
"semantic",
|
|
80
83
|
"summary",
|
|
81
84
|
"verdict",
|
|
82
85
|
]);
|
|
83
86
|
});
|
|
84
87
|
|
|
85
88
|
it("puts no reason records in a healthy report", () => {
|
|
86
|
-
const records = parseLines(renderLines(healthy));
|
|
87
|
-
assert.equal(records.length,
|
|
89
|
+
const records = parseLines(renderLines(healthy, "off"));
|
|
90
|
+
assert.equal(records.length, 4);
|
|
88
91
|
});
|
|
89
92
|
|
|
90
93
|
it("never wraps a record, so one line is always one record", () => {
|
|
91
|
-
for (const line of renderLines(degraded).trimEnd().split("\n")) {
|
|
94
|
+
for (const line of renderLines(degraded, "off").trimEnd().split("\n")) {
|
|
92
95
|
assert.ok(line.length > 0);
|
|
93
96
|
assert.ok(!line.includes("\n"));
|
|
94
97
|
}
|
|
@@ -111,8 +114,8 @@ describe("the line format", () => {
|
|
|
111
114
|
},
|
|
112
115
|
],
|
|
113
116
|
};
|
|
114
|
-
const out = renderLines(nasty);
|
|
115
|
-
assert.equal(out.trimEnd().split("\n").length,
|
|
117
|
+
const out = renderLines(nasty, "off");
|
|
118
|
+
assert.equal(out.trimEnd().split("\n").length, 6);
|
|
116
119
|
const records = parseLines(out);
|
|
117
120
|
assert.deepEqual(
|
|
118
121
|
records.filter(([key]) => key === "reason").map(([, value]) => value),
|
|
@@ -127,24 +130,40 @@ describe("the line format", () => {
|
|
|
127
130
|
});
|
|
128
131
|
|
|
129
132
|
it("collapses every C0 control character, not only the newline", () => {
|
|
130
|
-
const out = renderLines(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
133
|
+
const out = renderLines(
|
|
134
|
+
{
|
|
135
|
+
...degraded,
|
|
136
|
+
reasons: [
|
|
137
|
+
{
|
|
138
|
+
code: "scrape_failed",
|
|
139
|
+
summary: "a\u0000b\u001bc\u007fd",
|
|
140
|
+
detail: undefined,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
"off",
|
|
145
|
+
);
|
|
146
|
+
assert.equal(out.trimEnd().split("\n").length, 5);
|
|
141
147
|
assert.match(out, /reason scrape_failed a b c d/);
|
|
142
148
|
});
|
|
149
|
+
|
|
150
|
+
// The one record an operator reads to know whether "no semantic hits" means
|
|
151
|
+
// nothing matched or nothing is indexed. Verbatim, so `bedrock` and a value
|
|
152
|
+
// the wrapper does not know both arrive as themselves.
|
|
153
|
+
it("carries the deployment's embedding provider", () => {
|
|
154
|
+
for (const provider of ["off", "local", "bedrock"]) {
|
|
155
|
+
const records = parseLines(renderLines(healthy, provider));
|
|
156
|
+
assert.deepEqual(
|
|
157
|
+
records.filter(([key]) => key === "semantic"),
|
|
158
|
+
[["semantic", provider]],
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
143
162
|
});
|
|
144
163
|
|
|
145
164
|
describe("the json format", () => {
|
|
146
165
|
it("parses, and carries the same verdict and reasons", () => {
|
|
147
|
-
const parsed = JSON.parse(renderJson(degraded)) as {
|
|
166
|
+
const parsed = JSON.parse(renderJson(degraded, "off")) as {
|
|
148
167
|
verdict: string;
|
|
149
168
|
reasons: { code: string; summary: string; detail: string | null }[];
|
|
150
169
|
};
|
|
@@ -157,7 +176,9 @@ describe("the json format", () => {
|
|
|
157
176
|
});
|
|
158
177
|
|
|
159
178
|
it("renders a healthy verdict as an empty reason list, not an absent key", () => {
|
|
160
|
-
const parsed = JSON.parse(renderJson(healthy)) as {
|
|
179
|
+
const parsed = JSON.parse(renderJson(healthy, "off")) as {
|
|
180
|
+
reasons: unknown[];
|
|
181
|
+
};
|
|
161
182
|
assert.deepEqual(parsed.reasons, []);
|
|
162
183
|
});
|
|
163
184
|
});
|
|
@@ -196,14 +217,16 @@ describe("writeVerdict", () => {
|
|
|
196
217
|
it("does not resolve until the stream drains", async () => {
|
|
197
218
|
const sink = backPressured();
|
|
198
219
|
let settled = false;
|
|
199
|
-
const done = writeVerdict(sink.stream, renderLines(degraded)).then(
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
const done = writeVerdict(sink.stream, renderLines(degraded, "off")).then(
|
|
221
|
+
() => {
|
|
222
|
+
settled = true;
|
|
223
|
+
},
|
|
224
|
+
);
|
|
202
225
|
await new Promise((resolve) => setImmediate(resolve));
|
|
203
226
|
assert.equal(settled, false, "resolved before the stream drained");
|
|
204
227
|
sink.release();
|
|
205
228
|
await done;
|
|
206
|
-
assert.equal(sink.chunks.join(""), renderLines(degraded));
|
|
229
|
+
assert.equal(sink.chunks.join(""), renderLines(degraded, "off"));
|
|
207
230
|
});
|
|
208
231
|
|
|
209
232
|
it("resolves straight away when the write was taken", async () => {
|
|
@@ -217,7 +240,7 @@ describe("writeVerdict", () => {
|
|
|
217
240
|
assert.fail("waited on drain after a write that was taken");
|
|
218
241
|
},
|
|
219
242
|
} as unknown as NodeJS.WritableStream;
|
|
220
|
-
await writeVerdict(stream, renderJson(healthy));
|
|
221
|
-
assert.equal(chunks.join(""), renderJson(healthy));
|
|
243
|
+
await writeVerdict(stream, renderJson(healthy, "off"));
|
|
244
|
+
assert.equal(chunks.join(""), renderJson(healthy, "off"));
|
|
222
245
|
});
|
|
223
246
|
});
|
package/src/report.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { CheckResult } from "./verdict.js";
|
|
|
18
18
|
* summary one-line headline, no reason detail
|
|
19
19
|
* reason <code> <summary> — zero or more, stable order
|
|
20
20
|
* detail <code> <detail> — zero or more, only for reasons that have one
|
|
21
|
+
* semantic the deployment's SEARCH_EMBEDDING_PROVIDER, verbatim
|
|
21
22
|
*
|
|
22
23
|
* `reason` summaries carry counts, service names and queue names (D10).
|
|
23
24
|
* `detail` carries the account ids behind them and is printed here because this
|
|
@@ -61,11 +62,15 @@ const oneLine = (value: string): string => {
|
|
|
61
62
|
return out;
|
|
62
63
|
};
|
|
63
64
|
|
|
64
|
-
export const renderLines = (
|
|
65
|
+
export const renderLines = (
|
|
66
|
+
result: CheckResult,
|
|
67
|
+
searchEmbeddingProvider: string,
|
|
68
|
+
): string => {
|
|
65
69
|
const lines = [
|
|
66
70
|
`verdict ${result.verdict}`,
|
|
67
71
|
`checked-at ${result.checkedAt}`,
|
|
68
72
|
`summary ${oneLine(result.summary)}`,
|
|
73
|
+
`semantic ${oneLine(searchEmbeddingProvider)}`,
|
|
69
74
|
];
|
|
70
75
|
for (const reason of result.reasons) {
|
|
71
76
|
lines.push(`reason ${reason.code} ${oneLine(reason.summary)}`);
|
|
@@ -78,12 +83,16 @@ export const renderLines = (result: CheckResult): string => {
|
|
|
78
83
|
return `${lines.join("\n")}\n`;
|
|
79
84
|
};
|
|
80
85
|
|
|
81
|
-
export const renderJson = (
|
|
86
|
+
export const renderJson = (
|
|
87
|
+
result: CheckResult,
|
|
88
|
+
searchEmbeddingProvider: string,
|
|
89
|
+
): string =>
|
|
82
90
|
`${JSON.stringify(
|
|
83
91
|
{
|
|
84
92
|
verdict: result.verdict,
|
|
85
93
|
checkedAt: result.checkedAt,
|
|
86
94
|
summary: result.summary,
|
|
95
|
+
semantic: searchEmbeddingProvider,
|
|
87
96
|
reasons: result.reasons.map((reason) => ({
|
|
88
97
|
code: reason.code,
|
|
89
98
|
summary: reason.summary,
|