@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/doctor",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
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 ? renderJson(result) : renderLines(result),
37
+ json
38
+ ? renderJson(result, config.searchEmbeddingProvider)
39
+ : renderLines(result, config.searchEmbeddingProvider),
38
40
  );
39
41
  return exitCodeFor(result);
40
42
  };
@@ -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
- ? DEFAULT_HEARTBEAT_SERVICES
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
  };
@@ -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(parseLines(renderLines(degraded)).map(([key]) => key));
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, 3);
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, 5);
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
- ...degraded,
132
- reasons: [
133
- {
134
- code: "scrape_failed",
135
- summary: "a\u0000b\u001bc\u007fd",
136
- detail: undefined,
137
- },
138
- ],
139
- });
140
- assert.equal(out.trimEnd().split("\n").length, 4);
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 { reasons: unknown[] };
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
- settled = true;
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 = (result: CheckResult): string => {
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 = (result: CheckResult): string =>
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,